Renaming some things, add recipe cards to recipes page.

This commit is contained in:
Jesse Brault 2025-12-15 19:00:16 -06:00
parent cce55b8ebb
commit 1a51a8f751
28 changed files with 234 additions and 54 deletions

48
package-lock.json generated
View File

@ -14,6 +14,8 @@
"@angular/forms": "^21.0.0", "@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0", "@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@fortawesome/angular-fontawesome": "^4.0.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@tanstack/angular-query-experimental": "^5.90.16", "@tanstack/angular-query-experimental": "^5.90.16",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@ -1572,6 +1574,52 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fortawesome/angular-fontawesome": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-4.0.0.tgz",
"integrity": "sha512-TCqHqT5ovFY1A4RgMpoBUgS+RX3OVs39+CzHFgzDhbCPAopOa26J748TZJcuZwJAvGAk9tbWeVEmWuLByINAeg==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"tslib": "^2.8.1"
},
"peerDependencies": {
"@angular/core": "^21.0.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
"integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@inquirer/ansi": { "node_modules/@inquirer/ansi": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",

View File

@ -29,6 +29,8 @@
"@angular/forms": "^21.0.0", "@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0", "@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@fortawesome/angular-fontawesome": "^4.0.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@tanstack/angular-query-experimental": "^5.90.16", "@tanstack/angular-query-experimental": "^5.90.16",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"

View File

@ -1,4 +1,5 @@
<main>
<app-header /> <app-header />
<app-nav />
<main>
<router-outlet /> <router-outlet />
</main> </main>

View File

@ -1,5 +1,5 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { RecipeView } from './recipe-view/recipe-view'; import { RecipePage } from './recipe-page/recipe-page';
import { RecipesPage } from './recipes-page/recipes-page'; import { RecipesPage } from './recipes-page/recipes-page';
export const routes: Routes = [ export const routes: Routes = [
@ -9,6 +9,6 @@ export const routes: Routes = [
}, },
{ {
path: 'recipes/:username/:slug', path: 'recipes/:username/:slug',
component: RecipeView, component: RecipePage,
}, },
]; ];

View File

@ -1,10 +1,12 @@
import { Component, signal } from '@angular/core'; import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { Header } from './header/header'; import { Header } from './header/header';
import { Nav } from './nav/nav';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, Header], imports: [RouterOutlet, Header, Nav, FontAwesomeModule],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css', styleUrl: './app.css',
}) })

View File

@ -1,8 +1,9 @@
<header>
<h1>Meals Made Easy</h1> <h1>Meals Made Easy</h1>
@if (isLoggedIn()) { @if (isLoggedIn()) {
<h3>Welcome {{ username() }}!</h3> <p>Welcome {{ username() }}!</p>
<button (click)="logoutClick()">Logout</button> <button (click)="logoutClick()">Logout</button>
} @else { } @else {
<button (click)="loginClick()">Login</button> <button (click)="loginClick()">Login</button>
} }
<a [routerLink]="'/'">Browse Recipes</a> </header>

View File

@ -1,10 +1,9 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { AuthService } from '../service/auth.service'; import { AuthService } from '../service/auth.service';
import { RouterLink } from '@angular/router';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
imports: [RouterLink], imports: [],
templateUrl: './header.html', templateUrl: './header.html',
styleUrl: './header.css', styleUrl: './header.css',
}) })

View File

@ -17,9 +17,11 @@ export interface RecipeView {
export interface Recipe { export interface Recipe {
id: number; id: number;
isPublic: boolean;
mainImage: ImageView; mainImage: ImageView;
owner: ResourceOwner; owner: ResourceOwner;
slug: string; slug: string;
starCount: number;
text: string; text: string;
title: string; title: string;
} }

6
src/app/nav/nav.html Normal file
View File

@ -0,0 +1,6 @@
<nav>
<h2>Nav</h2>
<ul>
<li><a [routerLink]="'/'">Browse Recipes</a></li>
</ul>
</nav>

22
src/app/nav/nav.spec.ts Normal file
View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Nav } from './nav';
describe('Nav', () => {
let component: Nav;
let fixture: ComponentFixture<Nav>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Nav],
}).compileComponents();
fixture = TestBed.createComponent(Nav);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

10
src/app/nav/nav.ts Normal file
View File

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-nav',
imports: [RouterLink],
templateUrl: './nav.html',
styleUrl: './nav.css',
})
export class Nav {}

View File

@ -1,5 +1,6 @@
<h1>{{ recipe.title }}</h1> <h1>{{ recipe.title }}</h1>
@if (mainImageUrl.isSuccess()) { @if (mainImageUrl.isSuccess()) {
<!--suppress AngularNgOptimizedImage -->
<img <img
[src]="mainImageUrl.data()" [src]="mainImageUrl.data()"
[alt]="recipe.mainImage.alt" [alt]="recipe.mainImage.alt"

View File

@ -1,17 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RecipeViewCard } from './recipe-view-card'; import { RecipePageContent } from './recipe-page-content';
describe('Card', () => { describe('Card', () => {
let component: RecipeViewCard; let component: RecipePageContent;
let fixture: ComponentFixture<RecipeViewCard>; let fixture: ComponentFixture<RecipePageContent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [RecipeViewCard], imports: [RecipePageContent],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(RecipeViewCard); fixture = TestBed.createComponent(RecipePageContent);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); });

View File

@ -4,12 +4,12 @@ import { injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../service/image.service'; import { ImageService } from '../../service/image.service';
@Component({ @Component({
selector: 'app-recipe-view-card', selector: 'app-recipe-page-content',
imports: [], imports: [],
templateUrl: './recipe-view-card.html', templateUrl: './recipe-page-content.html',
styleUrl: './recipe-view-card.css', styleUrl: './recipe-page-content.css',
}) })
export class RecipeViewCard { export class RecipePageContent {
@Input({ required: true }) @Input({ required: true })
public recipe!: Recipe; public recipe!: Recipe;

View File

View File

@ -1,7 +1,7 @@
@if (recipe.isLoading()) { @if (recipe.isLoading()) {
<p>Loading...</p> <p>Loading...</p>
} @else if (recipe.isSuccess()) { } @else if (recipe.isSuccess()) {
<app-recipe-view-card [recipe]="recipe.data()"></app-recipe-view-card> <app-recipe-page-content [recipe]="recipe.data()"></app-recipe-page-content>
} @else if (recipe.error(); as error) { } @else if (recipe.error(); as error) {
<p>{{ error.message }}</p> <p>{{ error.message }}</p>
} @else { } @else {

View File

@ -1,17 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RecipeView } from './recipe-view'; import { RecipePage } from './recipe-page';
describe('Recipe', () => { describe('Recipe', () => {
let component: RecipeView; let component: RecipePage;
let fixture: ComponentFixture<RecipeView>; let fixture: ComponentFixture<RecipePage>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [RecipeView], imports: [RecipePage],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(RecipeView); fixture = TestBed.createComponent(RecipePage);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); });

View File

@ -1,16 +1,16 @@
import { Component, inject, resource } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { RecipeService } from '../service/recipe.service'; import { RecipeService } from '../service/recipe.service';
import { RecipeViewCard } from './recipe-view-card/recipe-view-card'; import { RecipePageContent } from './recipe-page-content/recipe-page-content';
import { injectQuery } from '@tanstack/angular-query-experimental'; import { injectQuery } from '@tanstack/angular-query-experimental';
@Component({ @Component({
selector: 'app-recipe-view', selector: 'app-recipe-page',
imports: [RecipeViewCard], imports: [RecipePageContent],
templateUrl: './recipe-view.html', templateUrl: './recipe-page.html',
styleUrl: './recipe-view.css', styleUrl: './recipe-page.css',
}) })
export class RecipeView { export class RecipePage {
private recipeService = inject(RecipeService); private recipeService = inject(RecipeService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private username = this.route.snapshot.paramMap.get('username') as string; private username = this.route.snapshot.paramMap.get('username') as string;

View File

@ -0,0 +1,5 @@
.recipe-card-image {
max-height: 200px;
width: 100%;
object-fit: cover;
}

View File

@ -0,0 +1,22 @@
<article>
<a [routerLink]="recipePageLink()">
@if (mainImage.isSuccess()) {
<!--suppress AngularNgOptimizedImage -->
<img [src]="mainImage.data()" class="recipe-card-image" [alt]="recipe.mainImage.alt" />
}
</a>
<div>
<a [routerLink]="recipePageLink()">
<h1>{{ recipe.title }}</h1>
</a>
@if (recipe.isPublic) {
<fa-icon [icon]="faGlobe" />
} @else {
<fa-icon [icon]="faLock" />
}
</div>
<div>
<span><fa-icon [icon]="faUser" /> {{ recipe.owner.username }}</span>
<span><fa-icon [icon]="faStar" /> {{ recipe.starCount }}</span>
</div>
</article>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RecipeCard } from './recipe-card';
describe('RecipeCard', () => {
let component: RecipeCard;
let fixture: ComponentFixture<RecipeCard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RecipeCard],
}).compileComponents();
fixture = TestBed.createComponent(RecipeCard);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,36 @@
import { Component, computed, inject, Input } from '@angular/core';
import { Recipe } from '../../model/Recipe.model';
import { RouterLink } from '@angular/router';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../service/image.service';
import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@Component({
selector: 'app-recipe-card',
imports: [RouterLink, FaIconComponent],
templateUrl: './recipe-card.html',
styleUrl: './recipe-card.css',
})
export class RecipeCard {
@Input({ required: true })
public recipe!: Recipe;
protected readonly recipePageLink = computed(() => [
'/recipes',
this.recipe.owner.username,
this.recipe.slug,
]);
protected readonly faGlobe = faGlobe;
protected readonly faLock = faLock;
protected readonly faUser = faUser;
protected readonly faStar = faStar;
private readonly imageService = inject(ImageService);
protected mainImage = injectQuery(() => ({
queryKey: ['images', this.recipe.mainImage.owner.username, this.recipe.mainImage.filename],
queryFn: () => this.imageService.getImage(this.recipe.mainImage.url),
}));
}

View File

@ -0,0 +1,5 @@
.recipe-card-grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(3, 1fr);
}

View File

@ -1,11 +1,12 @@
<h1>Recipes</h1>
@if (recipes.isSuccess()) { @if (recipes.isSuccess()) {
<ul> <section class="recipe-card-grid">
@for (recipe of recipes.data(); track recipe.id) { @for (recipe of recipes.data(); track recipe.id) {
<li> <app-recipe-card [recipe]="recipe" />
<a [routerLink]="['recipes', recipe.owner.username, recipe.slug]">{{
recipe.title
}}</a>
</li>
} }
</ul> </section>
} @else if (recipes.isLoading()) {
<p>Loading...</p>
} @else if (recipes.isError()) {
<p>{{ recipes.error().message }}</p>
} }

View File

@ -1,11 +1,11 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RecipeService } from '../service/recipe.service'; import { RecipeService } from '../service/recipe.service';
import { injectQuery } from '@tanstack/angular-query-experimental'; import { injectQuery } from '@tanstack/angular-query-experimental';
import { RouterLink } from '@angular/router'; import { RecipeCard } from './recipe-card/recipe-card';
@Component({ @Component({
selector: 'app-recipes-page', selector: 'app-recipes-page',
imports: [RouterLink], imports: [RecipeCard],
templateUrl: './recipes-page.html', templateUrl: './recipes-page.html',
styleUrl: './recipes-page.css', styleUrl: './recipes-page.css',
}) })

View File

@ -1,7 +1,7 @@
import { inject, Injectable, Signal, signal } from '@angular/core'; import { inject, Injectable, Signal, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { LoginView } from '../model/LoginView.model'; import { LoginView } from '../model/LoginView.model';
import { firstValueFrom, tap } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { QueryClient } from '@tanstack/angular-query-experimental'; import { QueryClient } from '@tanstack/angular-query-experimental';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@ -21,15 +21,10 @@ export class AuthService {
public async login(username: string, password: string): Promise<LoginView> { public async login(username: string, password: string): Promise<LoginView> {
const loginView = await firstValueFrom( const loginView = await firstValueFrom(
this.http this.http.post<LoginView>('http://localhost:8080/auth/login', { username, password }),
.post<LoginView>('http://localhost:8080/auth/login', { username, password }) );
.pipe(
tap((loginView) => {
this._accessToken.set(loginView.accessToken); this._accessToken.set(loginView.accessToken);
this._username.set(loginView.username); this._username.set(loginView.username);
}),
),
);
await this.queryClient.invalidateQueries(); await this.queryClient.invalidateQueries();
return loginView; return loginView;
} }