Added tanstack query, basic auth logic.

This commit is contained in:
Jesse Brault 2025-12-15 12:32:26 -06:00
parent 86ef321065
commit 6ae8da85f9
28 changed files with 363 additions and 49 deletions

View File

@ -1,4 +1,5 @@
{ {
"arrowParens": "avoid",
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"tabWidth": 4, "tabWidth": 4,

45
package-lock.json generated
View File

@ -14,7 +14,7 @@
"@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",
"prettier": "^3.7.4", "@tanstack/angular-query-experimental": "^5.90.16",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@ -23,6 +23,7 @@
"@angular/cli": "^21.0.2", "@angular/cli": "^21.0.2",
"@angular/compiler-cli": "^21.0.0", "@angular/compiler-cli": "^21.0.0",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"prettier": "^3.7.4",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"vitest": "^4.0.8" "vitest": "^4.0.8"
} }
@ -3891,6 +3892,47 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/@tufjs/canonical-json": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@ -7272,6 +7314,7 @@
"version": "3.7.4", "version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"

View File

@ -29,6 +29,7 @@
"@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",
"@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

@ -2,7 +2,16 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; 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 = { export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)] providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
provideTanStackQuery(new QueryClient(), withDevtools()),
],
}; };

View File

@ -1,4 +1,4 @@
<main> <main>
<h1>Meals Made Easy</h1> <app-header />
<router-outlet /> <router-outlet />
</main> </main>

View File

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

View File

@ -1,9 +1,10 @@
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';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet, Header],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css', styleUrl: './app.css',
}) })

View File

View File

@ -0,0 +1,7 @@
<h1>Meals Made Easy</h1>
@if (username(); as username) {
<h3>Welcome {{ username }}</h3>
}
<button (click)="loginClick()">Login</button>
<button (click)="logoutClick()">Logout</button>
<a [routerLink]="'/'">Browse Recipes</a>

View File

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

25
src/app/header/header.ts Normal file
View File

@ -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<string | null>(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);
}
}

View File

@ -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);
}
};

View File

@ -0,0 +1,5 @@
export interface LoginView {
username: string;
accessToken: string;
expires: string;
}

View File

@ -14,12 +14,23 @@ export interface RecipeView {
export interface Recipe { export interface Recipe {
id: number; id: number;
title: string;
mainImage: ImageView; mainImage: ImageView;
owner: ResourceOwner;
slug: string;
text: string; text: string;
title: string;
}
export interface ResourceOwner {
id: number;
username: string;
} }
export interface ImageView { export interface ImageView {
alt: string; alt: string;
filename: string;
height: number | null;
owner: ResourceOwner;
url: string; url: string;
width: number | null;
} }

View File

@ -1,3 +1,10 @@
<h1>{{ recipe.title }}</h1> <h1>{{ recipe.title }}</h1>
<img [ngSrc]="recipe.mainImage.url" [alt]="recipe.mainImage.alt" [width]="600" [height]="400"> @if (mainImageUrl.isSuccess()) {
<img
[src]="mainImageUrl.data()"
[alt]="recipe.mainImage.alt"
[height]="recipe.mainImage.height"
[width]="recipe.mainImage.width"
/>
}
<div [innerHTML]="recipe.text"></div> <div [innerHTML]="recipe.text"></div>

View File

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

View File

@ -1,18 +1,22 @@
import { Component, Input } from '@angular/core'; import { Component, inject, Input } from '@angular/core';
import { Recipe } from '../../model/Recipe.model'; 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({ @Component({
selector: 'app-recipe-view-card', selector: 'app-recipe-view-card',
imports: [ imports: [],
NgOptimizedImage templateUrl: './recipe-view-card.component.html',
], styleUrl: './recipe-view-card.component.css',
templateUrl: './recipe-view-card.component.html',
styleUrl: './recipe-view-card.component.css',
}) })
export class RecipeViewCard { export class RecipeViewCard {
@Input({ required: true }) @Input({ required: true })
public recipe!: Recipe; 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),
}));
} }

View File

@ -1,5 +1,9 @@
@if (recipe.isLoading()) { @if (recipe.isLoading()) {
<p>Loading...</p> <p>Loading...</p>
} @else if (recipe.hasValue()) { } @else if (recipe.isSuccess()) {
<app-recipe-view-card [recipe]="recipe.value()"></app-recipe-view-card> <app-recipe-view-card [recipe]="recipe.data()"></app-recipe-view-card>
} @else if (recipe.error(); as error) {
<p>{{ error }}</p>
} @else {
<p>There was an error loading the recipe.</p>
} }

View File

@ -2,12 +2,11 @@ import { Component, inject, resource } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { RecipeService } from '../recipe.service'; import { RecipeService } from '../recipe.service';
import { RecipeViewCard } from './recipe-view-card/recipe-view-card.component'; import { RecipeViewCard } from './recipe-view-card/recipe-view-card.component';
import { injectQuery } from '@tanstack/angular-query-experimental';
@Component({ @Component({
selector: 'app-recipe-view', selector: 'app-recipe-view',
imports: [ imports: [RecipeViewCard],
RecipeViewCard
],
templateUrl: './recipe-view.component.html', templateUrl: './recipe-view.component.html',
styleUrl: './recipe-view.component.css', styleUrl: './recipe-view.component.css',
}) })
@ -17,9 +16,8 @@ export class RecipeView {
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 = resource({ protected recipe = injectQuery(() => ({
loader: () => { queryKey: ['recipe', this.username, this.slug],
return this.recipeService.getRecipe(this.username, this.slug); queryFn: () => this.recipeService.getRecipe(this.username, this.slug),
}, }));
});
} }

View File

@ -1,6 +1,6 @@
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 { map, Observable } from 'rxjs'; import { firstValueFrom, map } from 'rxjs';
import { Recipe, RecipeInfoViews, RecipeView } from './model/Recipe.model'; import { Recipe, RecipeInfoViews, RecipeView } from './model/Recipe.model';
@Injectable({ @Injectable({
@ -9,15 +9,19 @@ import { Recipe, RecipeInfoViews, RecipeView } from './model/Recipe.model';
export class RecipeService { export class RecipeService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
public getRecipes(): Observable<Recipe[]> { public getRecipes(): Promise<Recipe[]> {
return this.http return firstValueFrom(
.get<RecipeInfoViews>('http://localhost:8080/recipes') this.http
.pipe(map((res) => res.content)); .get<RecipeInfoViews>('http://localhost:8080/recipes')
.pipe(map((res) => res.content)),
);
} }
public async getRecipe(username: string, slug: string): Promise<Recipe> { public async getRecipe(username: string, slug: string): Promise<Recipe> {
const res = await fetch(`http://localhost:8080/recipes/${username}/${slug}`) return firstValueFrom(
const recipeView = await res.json() as RecipeView; this.http
return recipeView.recipe; .get<RecipeView>(`http://localhost:8080/recipes/${username}/${slug}`)
.pipe(map((recipeView) => recipeView.recipe)),
);
} }
} }

View File

View File

@ -0,0 +1,11 @@
@if (recipes.isSuccess()) {
<ul>
@for (recipe of recipes.data(); track recipe.id) {
<li>
<a [routerLink]="['recipes', recipe.owner.username, recipe.slug]">{{
recipe.title
}}</a>
</li>
}
</ul>
}

View File

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

View File

@ -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(),
}));
}

View File

@ -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();
});
});

View File

@ -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<LoginView> {
const loginView = await firstValueFrom(
this.http
.post<LoginView>('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<void> {
await firstValueFrom(this.http.post('http://localhost:8080/auth/logout', null));
await this.queryClient.invalidateQueries();
await this.router.navigate(['/']);
}
}

View File

@ -0,0 +1,25 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class AuthStore {
private readonly accessToken = signal<string | null>(null);
private readonly username = signal<string | null>(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();
}
}

View File

@ -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<string> {
return firstValueFrom(
this.httpClient
.get(backendUrl, {
responseType: 'blob',
})
.pipe(map((blob) => URL.createObjectURL(blob))),
);
}
}