feat(nav): Hamburger-Menü mit Register statt Settings-Icon
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
8
src/routes/recipes/+page.server.ts
Normal file
8
src/routes/recipes/+page.server.ts
Normal 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) };
|
||||
};
|
||||
234
src/routes/recipes/+page.svelte
Normal file
234
src/routes/recipes/+page.svelte
Normal 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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user