feat(home): „Alle Rezepte"-Sektion mit Sortierung und Endless-Scroll
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Neue Sektion unter „Zuletzt hinzugefügt": sortierbar nach Name, Bewertung, zuletzt gekocht und Hinzugefügt. Auswahl persistiert in localStorage (kochwas.allSort). - Neuer Endpoint GET /api/recipes/all?sort=name&limit=10&offset=0. - listAllRecipesPaginated(db, sort, limit, offset) im repository: NULLS-last-Emulation per CASE für rating/cooked — funktioniert auch auf älteren SQLite-Versionen. - Endless Scroll per IntersectionObserver auf ein Sentinel-Element am Listen-Ende (rootMargin 200px, damit schon vor dem harten Rand nachgeladen wird). Pagesize 10. - 4 neue Tests: Name-Sort, Rating-Sort, Cooked-Sort, Pagination-Offset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,43 @@ export function listAllRecipes(db: Database.Database): SearchHit[] {
|
|||||||
.all() as SearchHit[];
|
.all() as SearchHit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created';
|
||||||
|
|
||||||
|
export function listAllRecipesPaginated(
|
||||||
|
db: Database.Database,
|
||||||
|
sort: AllRecipesSort,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): SearchHit[] {
|
||||||
|
// NULLS-last-Emulation per CASE-Expression — SQLite unterstützt NULLS LAST
|
||||||
|
// zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und
|
||||||
|
// CASE ist überall zuverlässig.
|
||||||
|
const orderBy: Record<AllRecipesSort, string> = {
|
||||||
|
name: 'r.title COLLATE NOCASE ASC',
|
||||||
|
rating:
|
||||||
|
'CASE WHEN (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
|
||||||
|
'(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
|
||||||
|
cooked:
|
||||||
|
'CASE WHEN (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
|
||||||
|
'(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
|
||||||
|
created: 'r.created_at DESC, r.id DESC'
|
||||||
|
};
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id,
|
||||||
|
r.title,
|
||||||
|
r.description,
|
||||||
|
r.image_path,
|
||||||
|
r.source_domain,
|
||||||
|
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
||||||
|
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
||||||
|
FROM recipe r
|
||||||
|
ORDER BY ${orderBy[sort]}
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(limit, offset) as SearchHit[];
|
||||||
|
}
|
||||||
|
|
||||||
export function listFavoritesForProfile(
|
export function listFavoritesForProfile(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
profileId: number
|
profileId: number
|
||||||
|
|||||||
@@ -30,6 +30,21 @@
|
|||||||
let skipNextSearch = false;
|
let skipNextSearch = false;
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const ALL_PAGE = 10;
|
||||||
|
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
|
||||||
|
const ALL_SORTS: { value: AllSort; label: string }[] = [
|
||||||
|
{ value: 'name', label: 'Name' },
|
||||||
|
{ value: 'rating', label: 'Bewertung' },
|
||||||
|
{ value: 'cooked', label: 'Zuletzt gekocht' },
|
||||||
|
{ value: 'created', label: 'Hinzugefügt' }
|
||||||
|
];
|
||||||
|
let allRecipes = $state<SearchHit[]>([]);
|
||||||
|
let allSort = $state<AllSort>('name');
|
||||||
|
let allExhausted = $state(false);
|
||||||
|
let allLoading = $state(false);
|
||||||
|
let allSentinel: HTMLElement | undefined = $state();
|
||||||
|
let allObserver: IntersectionObserver | null = null;
|
||||||
|
|
||||||
type SearchSnapshot = {
|
type SearchSnapshot = {
|
||||||
query: string;
|
query: string;
|
||||||
hits: SearchHit[];
|
hits: SearchHit[];
|
||||||
@@ -71,6 +86,31 @@
|
|||||||
recent = body.hits;
|
recent = body.hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAllMore() {
|
||||||
|
if (allLoading || allExhausted) return;
|
||||||
|
allLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}`
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = await res.json();
|
||||||
|
const more = body.hits as SearchHit[];
|
||||||
|
const seen = new Set(allRecipes.map((h) => h.id));
|
||||||
|
const deduped = more.filter((h) => !seen.has(h.id));
|
||||||
|
allRecipes = [...allRecipes, ...deduped];
|
||||||
|
if (more.length < ALL_PAGE) allExhausted = true;
|
||||||
|
} finally {
|
||||||
|
allLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAllRecipes() {
|
||||||
|
allRecipes = [];
|
||||||
|
allExhausted = false;
|
||||||
|
allLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFavorites(profileId: number) {
|
async function loadFavorites(profileId: number) {
|
||||||
const res = await fetch(`/api/recipes/favorites?profile_id=${profileId}`);
|
const res = await fetch(`/api/recipes/favorites?profile_id=${profileId}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -89,6 +129,44 @@
|
|||||||
if (urlQ) query = urlQ;
|
if (urlQ) query = urlQ;
|
||||||
void loadRecent();
|
void loadRecent();
|
||||||
void searchFilterStore.load();
|
void searchFilterStore.load();
|
||||||
|
const saved = localStorage.getItem('kochwas.allSort');
|
||||||
|
if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) {
|
||||||
|
allSort = saved as AllSort;
|
||||||
|
}
|
||||||
|
void loadAllMore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort-Change → liste zurücksetzen und neu laden.
|
||||||
|
$effect(() => {
|
||||||
|
const s = allSort;
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem('kochwas.allSort', s);
|
||||||
|
// Nur neu laden, wenn wir schon geladen hatten (sonst doppelter Initial-Call).
|
||||||
|
if (allRecipes.length > 0 || allExhausted) {
|
||||||
|
resetAllRecipes();
|
||||||
|
void loadAllMore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen.
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!allSentinel) return;
|
||||||
|
if (allExhausted) return;
|
||||||
|
if (allObserver) allObserver.disconnect();
|
||||||
|
allObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) void loadAllMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
allObserver.observe(allSentinel);
|
||||||
|
return () => {
|
||||||
|
allObserver?.disconnect();
|
||||||
|
allObserver = null;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bei Änderung der Domain-Auswahl: laufende Suche neu ausführen,
|
// Bei Änderung der Domain-Auswahl: laufende Suche neu ausführen,
|
||||||
@@ -422,6 +500,52 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
<section class="listing">
|
||||||
|
<div class="listing-head">
|
||||||
|
<h2>Alle Rezepte</h2>
|
||||||
|
<label class="sort-select">
|
||||||
|
Sortieren:
|
||||||
|
<select bind:value={allSort}>
|
||||||
|
{#each ALL_SORTS as s (s.value)}
|
||||||
|
<option value={s.value}>{s.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if allRecipes.length === 0 && allExhausted}
|
||||||
|
<p class="muted">Noch keine Rezepte gespeichert.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="cards">
|
||||||
|
{#each allRecipes as r (r.id)}
|
||||||
|
<li>
|
||||||
|
<a href={`/recipes/${r.id}`} class="card">
|
||||||
|
{#if r.image_path}
|
||||||
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
|
{/if}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="title">{r.title}</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
{#if r.avg_stars !== null}
|
||||||
|
<span class="stars">★ {r.avg_stars.toFixed(1)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if r.source_domain}
|
||||||
|
<span class="domain">{r.source_domain}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if !allExhausted}
|
||||||
|
<div bind:this={allSentinel} class="sentinel" aria-hidden="true">
|
||||||
|
{#if allLoading}<span class="loading">Lade …</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -482,6 +606,55 @@
|
|||||||
color: #444;
|
color: #444;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
.listing-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.listing-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sort-select {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.sort-select select {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
min-height: 36px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.meta-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stars {
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sentinel {
|
||||||
|
min-height: 40px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
.muted {
|
.muted {
|
||||||
color: #888;
|
color: #888;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
18
src/routes/api/recipes/all/+server.ts
Normal file
18
src/routes/api/recipes/all/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getDb } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
listAllRecipesPaginated,
|
||||||
|
type AllRecipesSort
|
||||||
|
} from '$lib/server/recipes/search-local';
|
||||||
|
|
||||||
|
const VALID_SORTS = new Set<AllRecipesSort>(['name', 'rating', 'cooked', 'created']);
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort;
|
||||||
|
if (!VALID_SORTS.has(sortRaw)) error(400, { message: 'Invalid sort' });
|
||||||
|
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
|
||||||
|
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
|
||||||
|
const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset);
|
||||||
|
return json({ sort: sortRaw, limit, offset, hits });
|
||||||
|
};
|
||||||
@@ -4,7 +4,8 @@ import { insertRecipe } from '../../src/lib/server/recipes/repository';
|
|||||||
import {
|
import {
|
||||||
searchLocal,
|
searchLocal,
|
||||||
listRecentRecipes,
|
listRecentRecipes,
|
||||||
listAllRecipes
|
listAllRecipes,
|
||||||
|
listAllRecipesPaginated
|
||||||
} from '../../src/lib/server/recipes/search-local';
|
} from '../../src/lib/server/recipes/search-local';
|
||||||
import type { Recipe } from '../../src/lib/types';
|
import type { Recipe } from '../../src/lib/types';
|
||||||
|
|
||||||
@@ -147,3 +148,49 @@ describe('listAllRecipes', () => {
|
|||||||
expect(all.length).toBe(1);
|
expect(all.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listAllRecipesPaginated', () => {
|
||||||
|
it('sorts by name asc case-insensitive', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'zucchini' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'Apfel' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'birnen' }));
|
||||||
|
const page = listAllRecipesPaginated(db, 'name', 10, 0);
|
||||||
|
expect(page.map((h) => h.title)).toEqual(['Apfel', 'birnen', 'zucchini']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates with limit + offset', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
for (let i = 0; i < 15; i++) insertRecipe(db, recipe({ title: `R${i.toString().padStart(2, '0')}` }));
|
||||||
|
const first = listAllRecipesPaginated(db, 'name', 5, 0);
|
||||||
|
const second = listAllRecipesPaginated(db, 'name', 5, 5);
|
||||||
|
expect(first.length).toBe(5);
|
||||||
|
expect(second.length).toBe(5);
|
||||||
|
const overlap = first.filter((h) => second.some((s) => s.id === h.id));
|
||||||
|
expect(overlap.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by rating desc, unrated last', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const a = insertRecipe(db, recipe({ title: 'A' }));
|
||||||
|
const b = insertRecipe(db, recipe({ title: 'B' }));
|
||||||
|
const c = insertRecipe(db, recipe({ title: 'C' }));
|
||||||
|
db.prepare('INSERT INTO profile(name) VALUES (?)').run('P');
|
||||||
|
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 3)').run(a);
|
||||||
|
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 5)').run(c);
|
||||||
|
const page = listAllRecipesPaginated(db, 'rating', 10, 0);
|
||||||
|
// C (5) > A (3) > B (null)
|
||||||
|
expect(page.map((h) => h.title)).toEqual(['C', 'A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by last_cooked_at desc, never-cooked last', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const a = insertRecipe(db, recipe({ title: 'A' }));
|
||||||
|
const b = insertRecipe(db, recipe({ title: 'B' }));
|
||||||
|
db.prepare('INSERT INTO profile(name) VALUES (?)').run('P');
|
||||||
|
db.prepare('INSERT INTO cooking_log(recipe_id, profile_id) VALUES (?, 1)').run(a);
|
||||||
|
const page = listAllRecipesPaginated(db, 'cooked', 10, 0);
|
||||||
|
expect(page[0].title).toBe('A');
|
||||||
|
expect(page[1].title).toBe('B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user