From b4a7355b2409f1ab6b0be7c5f52350d03b8107af Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:54:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(nav):=20Hamburger-Men=C3=BC=20mit=20Regist?= =?UTF-8?q?er=20statt=20Settings-Icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/server/recipes/search-local.ts | 16 ++ src/routes/+layout.svelte | 94 +++++++--- src/routes/recipes/+page.server.ts | 8 + src/routes/recipes/+page.svelte | 234 +++++++++++++++++++++++++ tests/integration/search-local.test.ts | 29 ++- 5 files changed, 360 insertions(+), 21 deletions(-) create mode 100644 src/routes/recipes/+page.server.ts create mode 100644 src/routes/recipes/+page.svelte diff --git a/src/lib/server/recipes/search-local.ts b/src/lib/server/recipes/search-local.ts index b158554..6c7df08 100644 --- a/src/lib/server/recipes/search-local.ts +++ b/src/lib/server/recipes/search-local.ts @@ -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 diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2d247b4..d9943aa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 | 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 @@ {wishlistStore.count} {/if} - - - + @@ -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; } diff --git a/src/routes/recipes/+page.server.ts b/src/routes/recipes/+page.server.ts new file mode 100644 index 0000000..88c93c0 --- /dev/null +++ b/src/routes/recipes/+page.server.ts @@ -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) }; +}; diff --git a/src/routes/recipes/+page.svelte b/src/routes/recipes/+page.svelte new file mode 100644 index 0000000..45a5081 --- /dev/null +++ b/src/routes/recipes/+page.svelte @@ -0,0 +1,234 @@ + + +
+

Register

+

{data.recipes.length} Rezepte insgesamt

+
+ +
+ +
+ +{#if letters.length > 1 && !filter.trim()} + +{/if} + +{#if sections.length === 0} +

Nichts passt zu „{filter}".

+{:else} + {#each sections as sect (sect.letter)} +
+

{sect.letter}

+ +
+ {/each} +{/if} + + diff --git a/tests/integration/search-local.test.ts b/tests/integration/search-local.test.ts index 191c840..d0e07da 100644 --- a/tests/integration/search-local.test.ts +++ b/tests/integration/search-local.test.ts @@ -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 { @@ -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); + }); +});