From 6ae8da85f9c8151a061d54f32b8d17883f1f669d Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Mon, 15 Dec 2025 12:32:26 -0600 Subject: [PATCH] Added tanstack query, basic auth logic. --- .prettierrc | 1 + package-lock.json | 45 ++++++++++++++++++- package.json | 1 + src/app/app.config.ts | 11 ++++- src/app/app.html | 2 +- src/app/app.routes.ts | 9 +++- src/app/app.ts | 3 +- src/app/header/header.css | 0 src/app/header/header.html | 7 +++ src/app/header/header.spec.ts | 22 +++++++++ src/app/header/header.ts | 25 +++++++++++ src/app/interceptor/auth.interceptor.ts | 17 +++++++ src/app/model/LoginView.model.ts | 5 +++ src/app/model/Recipe.model.ts | 13 +++++- .../recipe-view-card.component.html | 9 +++- .../recipe-view-card.component.spec.ts | 27 ++++++----- .../recipe-view-card.component.ts | 22 +++++---- .../recipe-view/recipe-view.component.html | 8 +++- src/app/recipe-view/recipe-view.component.ts | 14 +++--- src/app/recipe.service.ts | 20 +++++---- src/app/recipes-page/recipes-page.css | 0 src/app/recipes-page/recipes-page.html | 11 +++++ src/app/recipes-page/recipes-page.spec.ts | 22 +++++++++ src/app/recipes-page/recipes-page.ts | 19 ++++++++ src/app/service/auth.service.spec.ts | 16 +++++++ src/app/service/auth.service.ts | 38 ++++++++++++++++ src/app/service/auth.store.ts | 25 +++++++++++ src/app/service/image.service.ts | 20 +++++++++ 28 files changed, 363 insertions(+), 49 deletions(-) create mode 100644 src/app/header/header.css create mode 100644 src/app/header/header.html create mode 100644 src/app/header/header.spec.ts create mode 100644 src/app/header/header.ts create mode 100644 src/app/interceptor/auth.interceptor.ts create mode 100644 src/app/model/LoginView.model.ts create mode 100644 src/app/recipes-page/recipes-page.css create mode 100644 src/app/recipes-page/recipes-page.html create mode 100644 src/app/recipes-page/recipes-page.spec.ts create mode 100644 src/app/recipes-page/recipes-page.ts create mode 100644 src/app/service/auth.service.spec.ts create mode 100644 src/app/service/auth.service.ts create mode 100644 src/app/service/auth.store.ts create mode 100644 src/app/service/image.service.ts diff --git a/.prettierrc b/.prettierrc index d65b315..057e899 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { + "arrowParens": "avoid", "semi": true, "singleQuote": true, "tabWidth": 4, diff --git a/package-lock.json b/package-lock.json index f99dd14..4505a27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@angular/forms": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/router": "^21.0.0", - "prettier": "^3.7.4", + "@tanstack/angular-query-experimental": "^5.90.16", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -23,6 +23,7 @@ "@angular/cli": "^21.0.2", "@angular/compiler-cli": "^21.0.0", "jsdom": "^27.1.0", + "prettier": "^3.7.4", "typescript": "~5.9.2", "vitest": "^4.0.8" } @@ -3891,6 +3892,47 @@ "license": "MIT", "peer": true }, + "node_modules/@tanstack/angular-query-experimental": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/angular-query-experimental/-/angular-query-experimental-5.90.16.tgz", + "integrity": "sha512-ezXyxuaSA6kpwUxwrxo5Tf3B7KL7mb7cxhLnun/RMlw6h3ZQJzl1cJgrvN97+C0AeHoucmK7RwhEoIY12gY7WA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "optionalDependencies": { + "@tanstack/query-devtools": "5.91.1" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.91.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz", + "integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -7272,6 +7314,7 @@ "version": "3.7.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" diff --git a/package.json b/package.json index 8479ba6..dbcdcde 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@angular/forms": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/router": "^21.0.0", + "@tanstack/angular-query-experimental": "^5.90.16", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 8c1e39c..9d6177c 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -2,7 +2,16 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/ import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { authInterceptor } from './interceptor/auth.interceptor'; +import { provideTanStackQuery, QueryClient } from '@tanstack/angular-query-experimental'; +import { withDevtools } from '@tanstack/angular-query-experimental/devtools'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)] + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), + provideTanStackQuery(new QueryClient(), withDevtools()), + ], }; diff --git a/src/app/app.html b/src/app/app.html index 3c9fcab..3c5683f 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,4 +1,4 @@
-

Meals Made Easy

+
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 03e7597..fad28ba 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,9 +1,14 @@ import { Routes } from '@angular/router'; import { RecipeView } from './recipe-view/recipe-view.component'; +import { RecipesPage } from './recipes-page/recipes-page'; export const routes: Routes = [ + { + path: '', + component: RecipesPage, + }, { path: 'recipes/:username/:slug', - component: RecipeView - } + component: RecipeView, + }, ]; diff --git a/src/app/app.ts b/src/app/app.ts index b7d8125..fd2ab55 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,9 +1,10 @@ import { Component, signal } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { Header } from './header/header'; @Component({ selector: 'app-root', - imports: [RouterOutlet], + imports: [RouterOutlet, Header], templateUrl: './app.html', styleUrl: './app.css', }) diff --git a/src/app/header/header.css b/src/app/header/header.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/header/header.html b/src/app/header/header.html new file mode 100644 index 0000000..1f4a514 --- /dev/null +++ b/src/app/header/header.html @@ -0,0 +1,7 @@ +

Meals Made Easy

+@if (username(); as username) { +

Welcome {{ username }}

+} + + +Browse Recipes diff --git a/src/app/header/header.spec.ts b/src/app/header/header.spec.ts new file mode 100644 index 0000000..074621e --- /dev/null +++ b/src/app/header/header.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Header } from './header'; + +describe('Header', () => { + let component: Header; + let fixture: ComponentFixture
; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Header], + }).compileComponents(); + + fixture = TestBed.createComponent(Header); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/header/header.ts b/src/app/header/header.ts new file mode 100644 index 0000000..7678e94 --- /dev/null +++ b/src/app/header/header.ts @@ -0,0 +1,25 @@ +import { Component, inject, signal } from '@angular/core'; +import { AuthService } from '../service/auth.service'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-header', + imports: [RouterLink], + templateUrl: './header.html', + styleUrl: './header.css', +}) +export class Header { + private readonly authService = inject(AuthService); + + protected readonly username = signal(null); + + protected async loginClick() { + const loginView = await this.authService.login('test-user', 'test'); + this.username.set(loginView.username); + } + + protected async logoutClick() { + await this.authService.logout(); + this.username.set(null); + } +} diff --git a/src/app/interceptor/auth.interceptor.ts b/src/app/interceptor/auth.interceptor.ts new file mode 100644 index 0000000..7061451 --- /dev/null +++ b/src/app/interceptor/auth.interceptor.ts @@ -0,0 +1,17 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { AuthStore } from '../service/auth.store'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const authStore = inject(AuthStore); + const token = authStore.getAccessToken(); + if (token) { + return next( + req.clone({ + headers: req.headers.set('Authorization', `Bearer ${token}`), + }), + ); + } else { + return next(req); + } +}; diff --git a/src/app/model/LoginView.model.ts b/src/app/model/LoginView.model.ts new file mode 100644 index 0000000..4fb6445 --- /dev/null +++ b/src/app/model/LoginView.model.ts @@ -0,0 +1,5 @@ +export interface LoginView { + username: string; + accessToken: string; + expires: string; +} diff --git a/src/app/model/Recipe.model.ts b/src/app/model/Recipe.model.ts index 5dba7c6..dcf23cb 100644 --- a/src/app/model/Recipe.model.ts +++ b/src/app/model/Recipe.model.ts @@ -14,12 +14,23 @@ export interface RecipeView { export interface Recipe { id: number; - title: string; mainImage: ImageView; + owner: ResourceOwner; + slug: string; text: string; + title: string; +} + +export interface ResourceOwner { + id: number; + username: string; } export interface ImageView { alt: string; + filename: string; + height: number | null; + owner: ResourceOwner; url: string; + width: number | null; } diff --git a/src/app/recipe-view/recipe-view-card/recipe-view-card.component.html b/src/app/recipe-view/recipe-view-card/recipe-view-card.component.html index a86d6b6..ce493fd 100644 --- a/src/app/recipe-view/recipe-view-card/recipe-view-card.component.html +++ b/src/app/recipe-view/recipe-view-card/recipe-view-card.component.html @@ -1,3 +1,10 @@

{{ recipe.title }}

- +@if (mainImageUrl.isSuccess()) { + +}
diff --git a/src/app/recipe-view/recipe-view-card/recipe-view-card.component.spec.ts b/src/app/recipe-view/recipe-view-card/recipe-view-card.component.spec.ts index 13d3e27..d3083d8 100644 --- a/src/app/recipe-view/recipe-view-card/recipe-view-card.component.spec.ts +++ b/src/app/recipe-view/recipe-view-card/recipe-view-card.component.spec.ts @@ -3,21 +3,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RecipeViewCard } from './recipe-view-card.component'; describe('Card', () => { - let component: RecipeViewCard; - let fixture: ComponentFixture; + let component: RecipeViewCard; + let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RecipeViewCard] - }) - .compileComponents(); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecipeViewCard], + }).compileComponents(); - fixture = TestBed.createComponent(RecipeViewCard); - component = fixture.componentInstance; - await fixture.whenStable(); - }); + fixture = TestBed.createComponent(RecipeViewCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create', () => { + expect(component).toBeTruthy(); + }); }); diff --git a/src/app/recipe-view/recipe-view-card/recipe-view-card.component.ts b/src/app/recipe-view/recipe-view-card/recipe-view-card.component.ts index da788e3..338d62f 100644 --- a/src/app/recipe-view/recipe-view-card/recipe-view-card.component.ts +++ b/src/app/recipe-view/recipe-view-card/recipe-view-card.component.ts @@ -1,18 +1,22 @@ -import { Component, Input } from '@angular/core'; +import { Component, inject, Input } from '@angular/core'; import { Recipe } from '../../model/Recipe.model'; -import { NgOptimizedImage } from '@angular/common'; +import { injectQuery } from '@tanstack/angular-query-experimental'; +import { ImageService } from '../../service/image.service'; @Component({ - selector: 'app-recipe-view-card', - imports: [ - NgOptimizedImage - ], - templateUrl: './recipe-view-card.component.html', - styleUrl: './recipe-view-card.component.css', + selector: 'app-recipe-view-card', + imports: [], + templateUrl: './recipe-view-card.component.html', + styleUrl: './recipe-view-card.component.css', }) export class RecipeViewCard { - @Input({ required: true }) public recipe!: Recipe; + private readonly imageService = inject(ImageService); + + protected mainImageUrl = injectQuery(() => ({ + queryKey: ['images', this.recipe.mainImage.owner.username, this.recipe.mainImage.filename], + queryFn: () => this.imageService.getImage(this.recipe.mainImage.url), + })); } diff --git a/src/app/recipe-view/recipe-view.component.html b/src/app/recipe-view/recipe-view.component.html index a423143..e2a3fae 100644 --- a/src/app/recipe-view/recipe-view.component.html +++ b/src/app/recipe-view/recipe-view.component.html @@ -1,5 +1,9 @@ @if (recipe.isLoading()) {

Loading...

-} @else if (recipe.hasValue()) { - +} @else if (recipe.isSuccess()) { + +} @else if (recipe.error(); as error) { +

{{ error }}

+} @else { +

There was an error loading the recipe.

} diff --git a/src/app/recipe-view/recipe-view.component.ts b/src/app/recipe-view/recipe-view.component.ts index 2eaddb7..25c3f24 100644 --- a/src/app/recipe-view/recipe-view.component.ts +++ b/src/app/recipe-view/recipe-view.component.ts @@ -2,12 +2,11 @@ import { Component, inject, resource } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { RecipeService } from '../recipe.service'; import { RecipeViewCard } from './recipe-view-card/recipe-view-card.component'; +import { injectQuery } from '@tanstack/angular-query-experimental'; @Component({ selector: 'app-recipe-view', - imports: [ - RecipeViewCard - ], + imports: [RecipeViewCard], templateUrl: './recipe-view.component.html', styleUrl: './recipe-view.component.css', }) @@ -17,9 +16,8 @@ export class RecipeView { private username = this.route.snapshot.paramMap.get('username') as string; private slug = this.route.snapshot.paramMap.get('slug') as string; - protected recipe = resource({ - loader: () => { - return this.recipeService.getRecipe(this.username, this.slug); - }, - }); + protected recipe = injectQuery(() => ({ + queryKey: ['recipe', this.username, this.slug], + queryFn: () => this.recipeService.getRecipe(this.username, this.slug), + })); } diff --git a/src/app/recipe.service.ts b/src/app/recipe.service.ts index 7835d89..dc82330 100644 --- a/src/app/recipe.service.ts +++ b/src/app/recipe.service.ts @@ -1,6 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { map, Observable } from 'rxjs'; +import { firstValueFrom, map } from 'rxjs'; import { Recipe, RecipeInfoViews, RecipeView } from './model/Recipe.model'; @Injectable({ @@ -9,15 +9,19 @@ import { Recipe, RecipeInfoViews, RecipeView } from './model/Recipe.model'; export class RecipeService { private readonly http = inject(HttpClient); - public getRecipes(): Observable { - return this.http - .get('http://localhost:8080/recipes') - .pipe(map((res) => res.content)); + public getRecipes(): Promise { + return firstValueFrom( + this.http + .get('http://localhost:8080/recipes') + .pipe(map((res) => res.content)), + ); } public async getRecipe(username: string, slug: string): Promise { - const res = await fetch(`http://localhost:8080/recipes/${username}/${slug}`) - const recipeView = await res.json() as RecipeView; - return recipeView.recipe; + return firstValueFrom( + this.http + .get(`http://localhost:8080/recipes/${username}/${slug}`) + .pipe(map((recipeView) => recipeView.recipe)), + ); } } diff --git a/src/app/recipes-page/recipes-page.css b/src/app/recipes-page/recipes-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/recipes-page/recipes-page.html b/src/app/recipes-page/recipes-page.html new file mode 100644 index 0000000..12779b7 --- /dev/null +++ b/src/app/recipes-page/recipes-page.html @@ -0,0 +1,11 @@ +@if (recipes.isSuccess()) { + +} diff --git a/src/app/recipes-page/recipes-page.spec.ts b/src/app/recipes-page/recipes-page.spec.ts new file mode 100644 index 0000000..e390e92 --- /dev/null +++ b/src/app/recipes-page/recipes-page.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RecipesPage } from './recipes-page'; + +describe('RecipesPage', () => { + let component: RecipesPage; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecipesPage], + }).compileComponents(); + + fixture = TestBed.createComponent(RecipesPage); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/recipes-page/recipes-page.ts b/src/app/recipes-page/recipes-page.ts new file mode 100644 index 0000000..f9e87a7 --- /dev/null +++ b/src/app/recipes-page/recipes-page.ts @@ -0,0 +1,19 @@ +import { Component, inject } from '@angular/core'; +import { RecipeService } from '../recipe.service'; +import { injectQuery } from '@tanstack/angular-query-experimental'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-recipes-page', + imports: [RouterLink], + templateUrl: './recipes-page.html', + styleUrl: './recipes-page.css', +}) +export class RecipesPage { + private readonly recipeService = inject(RecipeService); + + protected readonly recipes = injectQuery(() => ({ + queryKey: ['recipes'], + queryFn: () => this.recipeService.getRecipes(), + })); +} diff --git a/src/app/service/auth.service.spec.ts b/src/app/service/auth.service.spec.ts new file mode 100644 index 0000000..c8f820f --- /dev/null +++ b/src/app/service/auth.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AuthService } from './auth.service'; + +describe('LoginService', () => { + let service: AuthService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AuthService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/service/auth.service.ts b/src/app/service/auth.service.ts new file mode 100644 index 0000000..21d4740 --- /dev/null +++ b/src/app/service/auth.service.ts @@ -0,0 +1,38 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { LoginView } from '../model/LoginView.model'; +import { firstValueFrom, tap } from 'rxjs'; +import { AuthStore } from './auth.store'; +import { QueryClient } from '@tanstack/angular-query-experimental'; +import { Router } from '@angular/router'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + private readonly http = inject(HttpClient); + private readonly authStore = inject(AuthStore); + private readonly queryClient = inject(QueryClient); + private readonly router = inject(Router); + + public async login(username: string, password: string): Promise { + const loginView = await firstValueFrom( + this.http + .post('http://localhost:8080/auth/login', { username, password }) + .pipe( + tap((loginView) => { + this.authStore.setAccessToken(loginView.accessToken); + this.authStore.setUsername(loginView.username); + }), + ), + ); + await this.queryClient.invalidateQueries(); + return loginView; + } + + public async logout(): Promise { + await firstValueFrom(this.http.post('http://localhost:8080/auth/logout', null)); + await this.queryClient.invalidateQueries(); + await this.router.navigate(['/']); + } +} diff --git a/src/app/service/auth.store.ts b/src/app/service/auth.store.ts new file mode 100644 index 0000000..425223e --- /dev/null +++ b/src/app/service/auth.store.ts @@ -0,0 +1,25 @@ +import { Injectable, signal } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthStore { + private readonly accessToken = signal(null); + private readonly username = signal(null); + + public setAccessToken(accessToken: string | null): void { + this.accessToken.set(accessToken); + } + + public getAccessToken(): string | null { + return this.accessToken(); + } + + public setUsername(username: string | null): void { + this.username.set(username); + } + + public getUsername(): string | null { + return this.username(); + } +} diff --git a/src/app/service/image.service.ts b/src/app/service/image.service.ts new file mode 100644 index 0000000..ed4a7a3 --- /dev/null +++ b/src/app/service/image.service.ts @@ -0,0 +1,20 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom, map } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class ImageService { + private readonly httpClient = inject(HttpClient); + + public getImage(backendUrl: string): Promise { + return firstValueFrom( + this.httpClient + .get(backendUrl, { + responseType: 'blob', + }) + .pipe(map((blob) => URL.createObjectURL(blob))), + ); + } +}