All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m22s
border-radius: 999px war 15x im CSS dupliziert. Ausgelagert als :root --pill-radius Variable im globalen :root-Block in +layout.svelte, Call-Sites auf var(--pill-radius) umgestellt. Bewusst NICHT angefasst (plan war "nur Werte die mehrfach vorkommen"): - z-index: 10 Distinct Values in 14 Sites, bilden ein implizites Layer-System. Konsolidieren = behavior-change-Risiko ohne konkreten Nutzen. Wenn kuenftig einheitliche Modal-/Popover-Layer noetig, separate Phase. - setTimeout(): 3 Sites, jeder mit eigener Semantik (Debounce/Print/ Spinner). Kein DRY-Nutzen durch Extraktion. Gate: svelte-check 0 Warnings, 184/184 Tests, Build clean, kein sichtbarer Unterschied (einzige Aenderung: selber Wert ueber Variable). Refs docs/superpowers/plans/2026-04-19-post-review-roadmap.md Item F. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
540 lines
13 KiB
Svelte
540 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onMount, tick } from 'svelte';
|
|
import { CookingPot, Link, Plus, ChevronDown, Pencil } from 'lucide-svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { alertAction } from '$lib/client/confirm.svelte';
|
|
import { requireOnline } from '$lib/client/require-online';
|
|
import type { PageData } from './$types';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
let filter = $state('');
|
|
let importUrl = $state('');
|
|
let menuOpen = $state(false);
|
|
let importOpen = $state(false);
|
|
let creatingBlank = $state(false);
|
|
let menuWrap: HTMLElement | undefined = $state();
|
|
let importInput: HTMLInputElement | undefined = $state();
|
|
|
|
function toggleMenu() {
|
|
menuOpen = !menuOpen;
|
|
}
|
|
|
|
async function openImport() {
|
|
menuOpen = false;
|
|
importOpen = true;
|
|
await tick();
|
|
importInput?.focus();
|
|
}
|
|
|
|
function closeImport() {
|
|
importOpen = false;
|
|
importUrl = '';
|
|
}
|
|
|
|
function submitImport(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
const url = importUrl.trim();
|
|
if (!url) return;
|
|
if (!requireOnline('Der URL-Import')) return;
|
|
importOpen = false;
|
|
goto(`/preview?url=${encodeURIComponent(url)}`);
|
|
}
|
|
|
|
async function createBlank() {
|
|
if (creatingBlank) return;
|
|
if (!requireOnline('Das Anlegen')) return;
|
|
menuOpen = false;
|
|
creatingBlank = true;
|
|
try {
|
|
const res = await fetch('/api/recipes/blank', { method: 'POST' });
|
|
if (!res.ok) {
|
|
await alertAction({
|
|
title: 'Anlegen fehlgeschlagen',
|
|
message: `HTTP ${res.status}`
|
|
});
|
|
return;
|
|
}
|
|
const body = await res.json();
|
|
goto(`/recipes/${body.id}?edit=1`);
|
|
} finally {
|
|
creatingBlank = false;
|
|
}
|
|
}
|
|
|
|
function onDocClick(e: MouseEvent) {
|
|
if (!menuOpen) return;
|
|
if (menuWrap && !menuWrap.contains(e.target as Node)) menuOpen = false;
|
|
}
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
if (importOpen) closeImport();
|
|
else if (menuOpen) menuOpen = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
document.addEventListener('click', onDocClick);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('click', onDocClick);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
});
|
|
|
|
// 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">
|
|
<div class="head-top">
|
|
<div class="head-titles">
|
|
<h1>Register</h1>
|
|
<p class="sub">{data.recipes.length} Rezepte insgesamt</p>
|
|
</div>
|
|
<div class="add-menu" bind:this={menuWrap}>
|
|
<button
|
|
type="button"
|
|
class="add-btn"
|
|
onclick={toggleMenu}
|
|
aria-haspopup="menu"
|
|
aria-expanded={menuOpen}
|
|
>
|
|
<Plus size={16} strokeWidth={2.2} />
|
|
<span>Rezept hinzufügen</span>
|
|
<ChevronDown size={14} strokeWidth={2.2} />
|
|
</button>
|
|
{#if menuOpen}
|
|
<div class="menu" role="menu">
|
|
<button type="button" role="menuitem" class="menu-item" onclick={openImport}>
|
|
<Link size={16} strokeWidth={2} />
|
|
<div class="menu-text">
|
|
<div class="menu-title">Von URL importieren</div>
|
|
<div class="menu-desc">Rezept aus einer Website ziehen</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
class="menu-item"
|
|
onclick={createBlank}
|
|
disabled={creatingBlank}
|
|
>
|
|
<Pencil size={16} strokeWidth={2} />
|
|
<div class="menu-text">
|
|
<div class="menu-title">Leeres Rezept</div>
|
|
<div class="menu-desc">Manuell ausfüllen</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{#if importOpen}
|
|
<div
|
|
class="modal-backdrop"
|
|
role="presentation"
|
|
onclick={(e) => {
|
|
if (e.target === e.currentTarget) closeImport();
|
|
}}
|
|
>
|
|
<div
|
|
class="modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="import-title"
|
|
tabindex="-1"
|
|
>
|
|
<h2 id="import-title">Rezept-URL importieren</h2>
|
|
<form onsubmit={submitImport}>
|
|
<input
|
|
bind:this={importInput}
|
|
type="url"
|
|
bind:value={importUrl}
|
|
placeholder="https://…"
|
|
aria-label="Rezept-URL"
|
|
required
|
|
/>
|
|
<div class="modal-actions">
|
|
<button type="button" class="btn" onclick={closeImport}>Abbrechen</button>
|
|
<button type="submit" class="btn primary" disabled={!importUrl.trim()}>
|
|
Weiter
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<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-top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.head-titles {
|
|
min-width: 0;
|
|
}
|
|
.head h1 {
|
|
margin: 0;
|
|
font-size: 1.6rem;
|
|
color: #2b6a3d;
|
|
}
|
|
.sub {
|
|
margin: 0.2rem 0 0;
|
|
color: #666;
|
|
font-size: 0.9rem;
|
|
}
|
|
.add-menu {
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
}
|
|
.add-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.55rem 0.9rem;
|
|
background: #2b6a3d;
|
|
color: white;
|
|
border: 0;
|
|
border-radius: 10px;
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
min-height: 40px;
|
|
}
|
|
.add-btn:hover {
|
|
background: #235532;
|
|
}
|
|
.menu {
|
|
position: absolute;
|
|
top: calc(100% + 0.35rem);
|
|
right: 0;
|
|
min-width: 260px;
|
|
background: white;
|
|
border: 1px solid #e4eae7;
|
|
border-radius: 12px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
padding: 0.3rem;
|
|
z-index: 20;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
}
|
|
.menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.7rem;
|
|
padding: 0.6rem 0.75rem;
|
|
background: transparent;
|
|
border: 0;
|
|
border-radius: 8px;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
color: #1a1a1a;
|
|
width: 100%;
|
|
}
|
|
.menu-item:hover:not(:disabled) {
|
|
background: #f4f8f5;
|
|
}
|
|
.menu-item:disabled {
|
|
opacity: 0.55;
|
|
cursor: progress;
|
|
}
|
|
.menu-item :global(svg) {
|
|
color: #2b6a3d;
|
|
flex-shrink: 0;
|
|
}
|
|
.menu-text {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.1rem;
|
|
}
|
|
.menu-title {
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
}
|
|
.menu-desc {
|
|
color: #888;
|
|
font-size: 0.8rem;
|
|
}
|
|
.modal-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(20, 30, 25, 0.45);
|
|
display: grid;
|
|
place-items: center;
|
|
z-index: 100;
|
|
padding: 1rem;
|
|
}
|
|
.modal {
|
|
background: white;
|
|
border-radius: 14px;
|
|
padding: 1.1rem 1.1rem 1rem;
|
|
width: min(440px, 100%);
|
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
|
}
|
|
.modal h2 {
|
|
margin: 0 0 0.75rem;
|
|
font-size: 1.05rem;
|
|
color: #2b6a3d;
|
|
}
|
|
.modal input {
|
|
width: 100%;
|
|
padding: 0.7rem 0.85rem;
|
|
border: 1px solid #cfd9d1;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
min-height: 44px;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
}
|
|
.modal input:focus {
|
|
outline: 2px solid #2b6a3d;
|
|
outline-offset: 1px;
|
|
}
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
margin-top: 0.85rem;
|
|
}
|
|
.modal .btn {
|
|
padding: 0.6rem 1rem;
|
|
min-height: 42px;
|
|
border: 1px solid #cfd9d1;
|
|
background: white;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
font-size: 0.95rem;
|
|
font-family: inherit;
|
|
}
|
|
.modal .btn:hover:not(:disabled) {
|
|
background: #f4f8f5;
|
|
}
|
|
.modal .btn.primary {
|
|
background: #2b6a3d;
|
|
color: white;
|
|
border: 0;
|
|
}
|
|
.modal .btn.primary:hover:not(:disabled) {
|
|
background: #235532;
|
|
}
|
|
.modal .btn.primary:disabled {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
}
|
|
.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: var(--pill-radius);
|
|
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>
|