Files
kochwas/src/routes/recipes/+page.svelte

235 lines
5.5 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { CookingPot } from 'lucide-svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let filter = $state('');
// Umlaute und Diakritika auf Basis-Buchstaben normalisieren, damit
// "apfel" auch "Äpfel" findet und "A/Ä/O/Ö/U/Ü" im gleichen Section-Header landen.
function normalize(s: string): string {
return s
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function sectionKey(title: string): string {
const first = normalize(title.trim()).charAt(0).toUpperCase();
return /[A-Z]/.test(first) ? first : '#';
}
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
type Hit = PageData['recipes'][number];
type Section = { letter: string; recipes: Hit[] };
const sections = $derived.by<Section[]>(() => {
const f = normalize(filter.trim());
const filtered = f
? data.recipes.filter((r) => normalize(r.title).includes(f))
: data.recipes;
const sorted = [...filtered].sort((a, b) => collator.compare(a.title, b.title));
const groups = new Map<string, Hit[]>();
for (const r of sorted) {
const key = sectionKey(r.title);
const arr = groups.get(key);
if (arr) arr.push(r);
else groups.set(key, [r]);
}
// '#' am Ende, sonst alphabetisch
return [...groups.entries()]
.sort(([a], [b]) => {
if (a === '#') return 1;
if (b === '#') return -1;
return collator.compare(a, b);
})
.map(([letter, recipes]) => ({ letter, recipes }));
});
const letters = $derived(sections.map((s) => s.letter));
function scrollToLetter(letter: string) {
const el = document.getElementById(`sect-${letter}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
</script>
<header class="head">
<h1>Register</h1>
<p class="sub">{data.recipes.length} Rezepte insgesamt</p>
</header>
<div class="filter-wrap">
<input
type="search"
bind:value={filter}
placeholder="Rezepte filtern…"
autocomplete="off"
inputmode="search"
aria-label="Rezepte filtern"
/>
</div>
{#if letters.length > 1 && !filter.trim()}
<nav class="letters" aria-label="Buchstaben-Navigation">
{#each letters as l}
<button class="letter-chip" onclick={() => scrollToLetter(l)}>{l}</button>
{/each}
</nav>
{/if}
{#if sections.length === 0}
<p class="empty">Nichts passt zu „{filter}".</p>
{:else}
{#each sections as sect (sect.letter)}
<section class="sect" id={`sect-${sect.letter}`}>
<h2 class="letter">{sect.letter}</h2>
<ul class="list">
{#each sect.recipes as r (r.id)}
<li>
<a class="item" href={`/recipes/${r.id}`}>
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={22} /></div>
{/if}
<div class="body">
<div class="title">{r.title}</div>
<div class="meta">
{#if r.source_domain}<span>{r.source_domain}</span>{/if}
{#if r.avg_stars !== null}<span>· ★ {r.avg_stars.toFixed(1)}</span>{/if}
</div>
</div>
</a>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
<style>
.head {
padding: 1.25rem 0 0.5rem;
}
.head h1 {
margin: 0;
font-size: 1.6rem;
color: #2b6a3d;
}
.sub {
margin: 0.2rem 0 0;
color: #666;
font-size: 0.9rem;
}
.filter-wrap {
position: sticky;
top: 57px;
z-index: 5;
background: #f8faf8;
padding: 0.75rem 0 0.5rem;
}
.filter-wrap input {
width: 100%;
padding: 0.6rem 0.9rem;
font-size: 0.95rem;
border: 1px solid #cfd9d1;
border-radius: 999px;
background: white;
min-height: 44px;
}
.filter-wrap input:focus {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
}
.letters {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.25rem 0 0.75rem;
}
.letter-chip {
min-width: 32px;
min-height: 32px;
padding: 0 0.4rem;
border: 1px solid #e4eae7;
background: white;
border-radius: 8px;
color: #2b6a3d;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.letter-chip:hover {
background: #eaf4ed;
}
.empty {
color: #888;
text-align: center;
padding: 2rem 0;
}
.sect {
margin-top: 0.75rem;
scroll-margin-top: 115px;
}
.sect h2.letter {
margin: 0;
padding: 0.35rem 0.1rem;
font-size: 0.95rem;
color: #2b6a3d;
border-bottom: 1px solid #e4eae7;
font-weight: 700;
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.item {
display: flex;
gap: 0.7rem;
padding: 0.55rem 0.25rem;
text-decoration: none;
color: inherit;
border-bottom: 1px solid #f0f3f1;
min-height: 56px;
align-items: center;
}
.item:hover {
background: #f4f8f5;
}
.item img,
.placeholder {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 8px;
background: #eef3ef;
display: grid;
place-items: center;
color: #8fb097;
flex-shrink: 0;
}
.body {
min-width: 0;
flex: 1;
}
.title {
font-weight: 600;
font-size: 0.98rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: 0.8rem;
color: #888;
display: flex;
gap: 0.25rem;
margin-top: 0.1rem;
}
</style>