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[];
|
||||
}
|
||||
|
||||
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(
|
||||
db: Database.Database,
|
||||
profileId: number
|
||||
|
||||
@@ -30,6 +30,21 @@
|
||||
let skipNextSearch = false;
|
||||
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 = {
|
||||
query: string;
|
||||
hits: SearchHit[];
|
||||
@@ -71,6 +86,31 @@
|
||||
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) {
|
||||
const res = await fetch(`/api/recipes/favorites?profile_id=${profileId}`);
|
||||
if (!res.ok) {
|
||||
@@ -89,6 +129,44 @@
|
||||
if (urlQ) query = urlQ;
|
||||
void loadRecent();
|
||||
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,
|
||||
@@ -422,6 +500,52 @@
|
||||
</ul>
|
||||
</section>
|
||||
{/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}
|
||||
|
||||
<style>
|
||||
@@ -482,6 +606,55 @@
|
||||
color: #444;
|
||||
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 {
|
||||
color: #888;
|
||||
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 {
|
||||
searchLocal,
|
||||
listRecentRecipes,
|
||||
listAllRecipes
|
||||
listAllRecipes,
|
||||
listAllRecipesPaginated
|
||||
} from '../../src/lib/server/recipes/search-local';
|
||||
import type { Recipe } from '../../src/lib/types';
|
||||
|
||||
@@ -147,3 +148,49 @@ describe('listAllRecipes', () => {
|
||||
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