feat(nav): Hamburger-Menü mit Register statt Settings-Icon
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s

Ersetzt das Settings-Zahnrad im Header durch ein Dreistriche-Menü. Das
Menü enthält zwei Punkte: „Register" führt zu einer neuen /recipes-Route
mit allen Rezepten alphabetisch gruppiert (A-Z-Buchstabenchips zum
Scrollen, Live-Filter oben, Umlaut-normalisiert). „Einstellungen" zeigt
wie bisher /admin.

Auf Mobile <520px wird das App-Icon komplett ausgeblendet, damit die
Suchleiste mehr Platz bekommt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-17 21:54:04 +02:00
parent f72fe64d8e
commit b4a7355b24
5 changed files with 360 additions and 21 deletions

View File

@@ -74,6 +74,22 @@ export function listRecentRecipes(
.all(limit) as SearchHit[];
}
export function listAllRecipes(db: Database.Database): SearchHit[] {
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 r.title COLLATE NOCASE`
)
.all() as SearchHit[];
}
export function listFavoritesForProfile(
db: Database.Database,
profileId: number

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto, afterNavigate } from '$app/navigation';
import { Settings, CookingPot, Globe, Utensils } from 'lucide-svelte';
import { Settings, CookingPot, Globe, Utensils, Menu, BookOpen } from 'lucide-svelte';
import { profileStore } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte';
import { pwaStore } from '$lib/client/pwa.svelte';
@@ -24,6 +24,8 @@
let navOpen = $state(false);
let navContainer: HTMLElement | undefined = $state();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let menuOpen = $state(false);
let menuContainer: HTMLElement | undefined = $state();
const showHeaderSearch = $derived(
$page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview'
@@ -86,10 +88,16 @@
if (navContainer && !navContainer.contains(e.target as Node)) {
navOpen = false;
}
if (menuContainer && !menuContainer.contains(e.target as Node)) {
menuOpen = false;
}
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape' && navOpen) navOpen = false;
if (e.key === 'Escape') {
if (navOpen) navOpen = false;
if (menuOpen) menuOpen = false;
}
}
function pickHit() {
@@ -104,6 +112,7 @@
navHits = [];
navWebHits = [];
navOpen = false;
menuOpen = false;
// Badge nach jeder Client-Navigation frisch halten — sonst kann er
// hinter den tatsächlichen Wunschliste-Einträgen herlaufen, wenn
// auf einem anderen Gerät oder in einem anderen Tab etwas geändert
@@ -234,9 +243,29 @@
<span class="badge">{wishlistStore.count}</span>
{/if}
</a>
<a href="/admin" class="nav-link" aria-label="Einstellungen">
<Settings size={20} strokeWidth={2} />
</a>
<div class="menu-wrap" bind:this={menuContainer}>
<button
class="nav-link"
aria-label="Menü"
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
<Menu size={22} strokeWidth={2} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<a href="/recipes" class="menu-item" role="menuitem" onclick={() => (menuOpen = false)}>
<BookOpen size={18} strokeWidth={2} />
<span>Register</span>
</a>
<a href="/admin" class="menu-item" role="menuitem" onclick={() => (menuOpen = false)}>
<Settings size={18} strokeWidth={2} />
<span>Einstellungen</span>
</a>
</div>
{/if}
</div>
<ProfileSwitcher />
</div>
</div>
@@ -410,6 +439,43 @@
flex-shrink: 0;
margin-left: auto;
}
.menu-wrap {
position: relative;
}
.menu-wrap > .nav-link {
background: transparent;
border: 0;
cursor: pointer;
color: inherit;
}
.menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
min-width: 180px;
padding: 0.3rem;
z-index: 55;
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
border-radius: 8px;
text-decoration: none;
color: #1a1a1a;
font-size: 0.95rem;
min-height: 44px;
}
.menu-item:hover {
background: #f4f8f5;
}
.nav-link {
display: inline-flex;
align-items: center;
@@ -447,21 +513,9 @@
margin: 0 auto;
}
@media (max-width: 520px) {
/* App-Icon auf engen Screens komplett aus — die Suche bekommt den Platz. */
.brand {
font-size: 0;
width: 1.6rem;
height: 1.6rem;
background: #2b6a3d;
border-radius: 8px;
position: relative;
}
.brand::after {
content: '🍳';
font-size: 1rem;
position: absolute;
inset: 0;
display: grid;
place-items: center;
display: none;
}
.nav-link {
width: 36px;
@@ -474,7 +528,7 @@
position: absolute;
top: 0.6rem;
bottom: 0.6rem;
left: calc(1rem + 1.6rem + 0.6rem);
left: 1rem;
right: 1rem;
z-index: 60;
}

View File

@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
import { getDb } from '$lib/server/db';
import { listAllRecipes } from '$lib/server/recipes/search-local';
export const load: PageServerLoad = async () => {
const db = getDb();
return { recipes: listAllRecipes(db) };
};

View File

@@ -0,0 +1,234 @@
<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>

View File

@@ -1,7 +1,11 @@
import { describe, it, expect } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db';
import { insertRecipe } from '../../src/lib/server/recipes/repository';
import { searchLocal, listRecentRecipes } from '../../src/lib/server/recipes/search-local';
import {
searchLocal,
listRecentRecipes,
listAllRecipes
} from '../../src/lib/server/recipes/search-local';
import type { Recipe } from '../../src/lib/types';
function recipe(overrides: Partial<Recipe> = {}): Recipe {
@@ -89,3 +93,26 @@ describe('listRecentRecipes', () => {
expect(recent[0].title === 'New' || recent[0].title === 'Old').toBe(true);
});
});
describe('listAllRecipes', () => {
it('returns all recipes sorted alphabetically, case-insensitive', () => {
const db = openInMemoryForTest();
insertRecipe(db, recipe({ title: 'zuccini' }));
insertRecipe(db, recipe({ title: 'Apfelkuchen' }));
insertRecipe(db, recipe({ title: 'birnenkompott' }));
const all = listAllRecipes(db);
expect(all.map((r) => r.title)).toEqual([
'Apfelkuchen',
'birnenkompott',
'zuccini'
]);
});
it('includes hidden-from-recent recipes too', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({ title: 'Versteckt' }));
db.prepare('UPDATE recipe SET hidden_from_recent = 1 WHERE id = ?').run(id);
const all = listAllRecipes(db);
expect(all.length).toBe(1);
});
});