Add ai-search page.

This commit is contained in:
Jesse Brault 2025-12-29 12:43:26 -06:00
parent 0699c704de
commit 625b38701f
23 changed files with 157 additions and 34 deletions

View File

@ -1,12 +1,17 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { RecipePage } from './recipe-page/recipe-page'; import { RecipePage } from './recipe-page/recipe-page';
import { RecipesPage } from './recipes-page/recipes-page'; import { RecipesPage } from './recipes-page/recipes-page';
import { RecipesSearchPage } from './recipes-search-page/recipes-search-page';
export const routes: Routes = [ export const routes: Routes = [
{ {
path: '', path: '',
component: RecipesPage, component: RecipesPage,
}, },
{
path: 'recipes-search',
component: RecipesSearchPage,
},
{ {
path: 'recipes/:username/:slug', path: 'recipes/:username/:slug',
component: RecipePage, component: RecipePage,

View File

@ -2,5 +2,6 @@
<h2>Nav</h2> <h2>Nav</h2>
<ul> <ul>
<li><a [routerLink]="'/'">Browse Recipes</a></li> <li><a [routerLink]="'/'">Browse Recipes</a></li>
<li><a [routerLink]="'/recipes-search'">Search Recipes</a></li>
</ul> </ul>
</nav> </nav>

View File

@ -2,14 +2,12 @@ import { inject, Pipe, PipeTransform } from '@angular/core';
import { DateService } from '../service/date.service'; import { DateService } from '../service/date.service';
@Pipe({ @Pipe({
name: 'dateTimeFormat' name: 'dateTimeFormat',
}) })
export class DateTimeFormatPipe implements PipeTransform { export class DateTimeFormatPipe implements PipeTransform {
private readonly dateService = inject(DateService); private readonly dateService = inject(DateService);
public transform(raw: string): string { public transform(raw: string): string {
return this.dateService.format(raw); return this.dateService.format(raw);
} }
} }

View File

@ -0,0 +1,17 @@
.recipe-card-grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 1300px) {
.recipe-card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media screen and (min-width: 1700px) {
.recipe-card-grid {
grid-template-columns: repeat(4, 1fr);
}
}

View File

@ -0,0 +1,5 @@
<section class="recipe-card-grid">
@for (recipe of recipes(); track recipe.id) {
<app-recipe-card [recipe]="recipe" />
}
</section>

View File

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

View File

@ -0,0 +1,13 @@
import { Component, input } from '@angular/core';
import { RecipeCard } from './recipe-card/recipe-card';
import { Recipe } from '../model/Recipe.model';
@Component({
selector: 'app-recipe-card-grid',
imports: [RecipeCard],
templateUrl: './recipe-card-grid.html',
styleUrl: './recipe-card-grid.css',
})
export class RecipeCardGrid {
public readonly recipes = input.required<Recipe[]>();
}

View File

@ -31,7 +31,9 @@
<li class="comment"> <li class="comment">
<div class="comment-username-time"> <div class="comment-username-time">
<span class="comment-username">{{ recipeComment.owner.username }}</span> <span class="comment-username">{{ recipeComment.owner.username }}</span>
<span class="comment-time">{{ recipeComment.created | dateTimeFormat }}</span> <span class="comment-time">{{
recipeComment.created | dateTimeFormat
}}</span>
</div> </div>
<div class="comment-text" [innerHTML]="recipeComment.text"></div> <div class="comment-text" [innerHTML]="recipeComment.text"></div>
</li> </li>

View File

@ -47,7 +47,7 @@ export class RecipeCommentsList {
protected readonly addCommentMutation = injectMutation(() => ({ protected readonly addCommentMutation = injectMutation(() => ({
mutationFn: (commentText: string) => mutationFn: (commentText: string) =>
this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText) this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText),
})); }));
protected onCommentSubmit() { protected onCommentSubmit() {

View File

@ -1,17 +0,0 @@
.recipe-card-grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 1300px) {
.recipe-card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media screen and (min-width: 1700px) {
.recipe-card-grid {
grid-template-columns: repeat(4, 1fr);
}
}

View File

@ -1,10 +1,6 @@
<h1>Recipes</h1> <h1>Recipes</h1>
@if (recipes.isSuccess()) { @if (recipes.isSuccess()) {
<section class="recipe-card-grid"> <app-recipe-card-grid [recipes]="recipes.data()" />
@for (recipe of recipes.data(); track recipe.id) {
<app-recipe-card [recipe]="recipe" />
}
</section>
} @else if (recipes.isLoading()) { } @else if (recipes.isLoading()) {
<p>Loading...</p> <p>Loading...</p>
} @else if (recipes.isError()) { } @else if (recipes.isError()) {

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 { RecipeCard } from './recipe-card/recipe-card'; import { RecipeCardGrid } from '../recipe-card-grid/recipe-card-grid';
@Component({ @Component({
selector: 'app-recipes-page', selector: 'app-recipes-page',
imports: [RecipeCard], imports: [RecipeCardGrid],
templateUrl: './recipes-page.html', templateUrl: './recipes-page.html',
styleUrl: './recipes-page.css', styleUrl: './recipes-page.css',
}) })

View File

@ -0,0 +1,16 @@
<h1>Search Recipes</h1>
<form id="search-recipes-form" [formGroup]="searchRecipesForm" (ngSubmit)="onPromptSubmit()">
<label for="prompt">Search Prompt:</label>
<input id="prompt" formControlName="prompt" type="text" />
<button type="submit" [disabled]="!searchRecipesForm.valid">Search</button>
</form>
@if (givenPrompt() !== null) {
@if (resultsQuery.isLoading()) {
<p>Loading search results...</p>
} @else if (resultsQuery.isSuccess()) {
<p>Showing results for {{ givenPrompt() }}</p>
<app-recipe-card-grid [recipes]="resultsQuery.data()" />
} @else if (resultsQuery.isError()) {
<p>There was an error during search.</p>
}
}

View File

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

View File

@ -0,0 +1,33 @@
import { Component, inject, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { RecipeService } from '../service/recipe.service';
import { RecipeCardGrid } from '../recipe-card-grid/recipe-card-grid';
@Component({
selector: 'app-recipes-search-page',
imports: [ReactiveFormsModule, RecipeCardGrid],
templateUrl: './recipes-search-page.html',
styleUrl: './recipes-search-page.css',
})
export class RecipesSearchPage {
private readonly recipeService = inject(RecipeService);
protected readonly searchRecipesForm = new FormGroup({
prompt: new FormControl('', [Validators.required]),
});
protected readonly givenPrompt = signal<null | string>(null);
protected readonly resultsQuery = injectQuery(() => ({
queryFn: () => this.recipeService.aiSearch(this.givenPrompt()!),
queryKey: ['recipes-search', this.givenPrompt()],
enabled: () => !!this.givenPrompt(),
}));
protected onPromptSubmit() {
if (this.searchRecipesForm.value.prompt) {
this.givenPrompt.set(this.searchRecipesForm.value.prompt);
}
}
}

View File

@ -1,17 +1,15 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class DateService { export class DateService {
private readonly dateTimeFormat = Intl.DateTimeFormat('en-US', { private readonly dateTimeFormat = Intl.DateTimeFormat('en-US', {
dateStyle: 'long', dateStyle: 'long',
timeStyle: 'short' timeStyle: 'short',
}); });
public format(raw: string): string { public format(raw: string): string {
return this.dateTimeFormat.format(new Date(raw)); return this.dateTimeFormat.format(new Date(raw));
} }
} }

View File

@ -33,7 +33,7 @@ export class EndpointService {
} }
}); });
let pathString = pathParams?.join('/'); let pathString = pathParams?.join('/') || '';
if (pathString?.length) { if (pathString?.length) {
pathString = '/' + pathString; pathString = '/' + pathString;
} }

View File

@ -80,4 +80,16 @@ export class RecipeService {
}); });
return comment; return comment;
} }
public async aiSearch(prompt: string): Promise<Recipe[]> {
const recipeInfoViews = await firstValueFrom(
this.http.post<{ results: Recipe[] }>(this.endpointService.getUrl('recipes'), {
type: 'AI_PROMPT',
data: {
prompt,
},
}),
);
return recipeInfoViews.results;
}
} }