235 lines
5.5 KiB
Svelte
235 lines
5.5 KiB
Svelte
|
|
<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>
|