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