Add starring ability.

This commit is contained in:
Jesse Brault 2025-12-15 20:34:31 -06:00
parent 1a51a8f751
commit 28222e0655
7 changed files with 111 additions and 37 deletions

View File

@ -1,6 +1,21 @@
@let recipe = recipeView().recipe;
<article>
<div id="recipe-header">
<h1>{{ recipe.title }}</h1> <h1>{{ recipe.title }}</h1>
@if (isLoggedIn()) {
<button id="star" (click)="starMutation.mutate()">
<fa-icon [icon]="faStar"/>
<span>Star</span>
<span id="star-count">{{ recipe.starCount }}</span>
</button>
} @else {
<div>
<fa-icon [icon]="faStar"/>
<span id="star-count">{{ recipe.starCount }}</span>
</div>
}
</div>
@if (mainImageUrl.isSuccess()) { @if (mainImageUrl.isSuccess()) {
<!--suppress AngularNgOptimizedImage -->
<img <img
[src]="mainImageUrl.data()" [src]="mainImageUrl.data()"
[alt]="recipe.mainImage.alt" [alt]="recipe.mainImage.alt"
@ -9,3 +24,4 @@
/> />
} }
<div [innerHTML]="recipe.text"></div> <div [innerHTML]="recipe.text"></div>
</article>

View File

@ -1,22 +1,42 @@
import { Component, inject, Input } from '@angular/core'; import { Component, computed, inject, input, Input } from '@angular/core';
import { Recipe } from '../../model/Recipe.model'; import { RecipeView } from '../../model/Recipe.model';
import { injectQuery } from '@tanstack/angular-query-experimental'; import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../service/image.service'; import { ImageService } from '../../service/image.service';
import { faStar } from "@fortawesome/free-solid-svg-icons";
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecipeService } from '../../service/recipe.service';
import { AuthService } from '../../service/auth.service';
@Component({ @Component({
selector: 'app-recipe-page-content', selector: 'app-recipe-page-content',
imports: [], imports: [
FaIconComponent
],
templateUrl: './recipe-page-content.html', templateUrl: './recipe-page-content.html',
styleUrl: './recipe-page-content.css', styleUrl: './recipe-page-content.css',
}) })
export class RecipePageContent { export class RecipePageContent {
@Input({ required: true })
public recipe!: Recipe; public recipeView = input.required<RecipeView>();
private readonly imageService = inject(ImageService); private readonly imageService = inject(ImageService);
private readonly recipeService = inject(RecipeService);
private readonly authService = inject(AuthService);
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
protected readonly mainImageUrl = injectQuery(() => {
const recipe = this.recipeView().recipe;
return {
queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename],
queryFn: () => this.imageService.getImage(recipe.mainImage.url),
}
});
protected readonly starMutation = injectMutation(() => ({
mutationFn: () => this.recipeService.toggleStar(this.recipeView()),
}));
protected readonly faStar = faStar;
protected mainImageUrl = injectQuery(() => ({
queryKey: ['images', this.recipe.mainImage.owner.username, this.recipe.mainImage.filename],
queryFn: () => this.imageService.getImage(this.recipe.mainImage.url),
}));
} }

View File

@ -1,8 +1,8 @@
@if (recipe.isLoading()) { @if (recipeView.isLoading()) {
<p>Loading...</p> <p>Loading...</p>
} @else if (recipe.isSuccess()) { } @else if (recipeView.isSuccess()) {
<app-recipe-page-content [recipe]="recipe.data()"></app-recipe-page-content> <app-recipe-page-content [recipeView]="recipeView.data()"></app-recipe-page-content>
} @else if (recipe.error(); as error) { } @else if (recipeView.error(); as error) {
<p>{{ error.message }}</p> <p>{{ error.message }}</p>
} @else { } @else {
<p>There was an error loading the recipe.</p> <p>There was an error loading the recipe.</p>

View File

@ -16,8 +16,8 @@ export class RecipePage {
private username = this.route.snapshot.paramMap.get('username') as string; private username = this.route.snapshot.paramMap.get('username') as string;
private slug = this.route.snapshot.paramMap.get('slug') as string; private slug = this.route.snapshot.paramMap.get('slug') as string;
protected recipe = injectQuery(() => ({ protected recipeView = injectQuery(() => ({
queryKey: ['recipe', this.username, this.slug], queryKey: ['recipe', this.username, this.slug],
queryFn: () => this.recipeService.getRecipe(this.username, this.slug), queryFn: () => this.recipeService.getRecipeView(this.username, this.slug),
})); }));
} }

View File

@ -1,5 +1,22 @@
.recipe-card-image { #recipe-card-image {
max-height: 200px; max-height: 200px;
width: 100%; width: 100%;
object-fit: cover; object-fit: cover;
} }
article {
display: flex;
flex-direction: column;
row-gap: 5px;
}
#recipe-title {
margin: 0;
font-size: 18px;
}
#title-and-visibility,
#user-and-stars {
display: flex;
justify-content: space-between;
}

View File

@ -1,13 +1,12 @@
<article> <article>
<a [routerLink]="recipePageLink()"> <a [routerLink]="recipePageLink()">
@if (mainImage.isSuccess()) { @if (mainImage.isSuccess()) {
<!--suppress AngularNgOptimizedImage --> <img [src]="mainImage.data()" id="recipe-card-image" [alt]="recipe.mainImage.alt" />
<img [src]="mainImage.data()" class="recipe-card-image" [alt]="recipe.mainImage.alt" />
} }
</a> </a>
<div> <div id="title-and-visibility">
<a [routerLink]="recipePageLink()"> <a [routerLink]="recipePageLink()">
<h1>{{ recipe.title }}</h1> <h1 id="recipe-title">{{ recipe.title }}</h1>
</a> </a>
@if (recipe.isPublic) { @if (recipe.isPublic) {
<fa-icon [icon]="faGlobe" /> <fa-icon [icon]="faGlobe" />
@ -15,7 +14,7 @@
<fa-icon [icon]="faLock" /> <fa-icon [icon]="faLock" />
} }
</div> </div>
<div> <div id="user-and-stars">
<span><fa-icon [icon]="faUser" /> {{ recipe.owner.username }}</span> <span><fa-icon [icon]="faUser" /> {{ recipe.owner.username }}</span>
<span><fa-icon [icon]="faStar" /> {{ recipe.starCount }}</span> <span><fa-icon [icon]="faStar" /> {{ recipe.starCount }}</span>
</div> </div>

View File

@ -1,13 +1,17 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { firstValueFrom, map } from 'rxjs'; import { firstValueFrom, lastValueFrom, map } from 'rxjs';
import { Recipe, RecipeInfoViews, RecipeView } from '../model/Recipe.model'; import { Recipe, RecipeInfoViews, RecipeView } from '../model/Recipe.model';
import { AuthService } from './auth.service';
import { QueryClient } from '@tanstack/angular-query-experimental';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class RecipeService { export class RecipeService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly authService = inject(AuthService);
private readonly queryClient = inject(QueryClient);
public getRecipes(): Promise<Recipe[]> { public getRecipes(): Promise<Recipe[]> {
return firstValueFrom( return firstValueFrom(
@ -17,11 +21,29 @@ export class RecipeService {
); );
} }
public async getRecipe(username: string, slug: string): Promise<Recipe> { public async getRecipeView(username: string, slug: string): Promise<RecipeView> {
return firstValueFrom( return firstValueFrom(
this.http this.http.get<RecipeView>(`http://localhost:8080/recipes/${username}/${slug}`)
.get<RecipeView>(`http://localhost:8080/recipes/${username}/${slug}`)
.pipe(map((recipeView) => recipeView.recipe)),
); );
} }
private getRecipeUrl(recipeView: RecipeView): string {
return `http://localhost:8080/recipes/${recipeView.recipe.owner.username}/${recipeView.recipe.slug}`;
}
public async toggleStar(recipeView: RecipeView) {
if (this.authService.accessToken()) {
if (recipeView.isStarred) {
await lastValueFrom(this.http.delete(this.getRecipeUrl(recipeView) + '/star'));
} else {
await lastValueFrom(this.http.post(this.getRecipeUrl(recipeView) + '/star', null));
}
await this.queryClient.invalidateQueries({
queryKey: ['recipe', recipeView.recipe.owner.username, recipeView.recipe.slug],
})
} else {
throw new Error('Cannot star a recipe when not logged in.')
}
}
} }