feat(home): Collapsible Sections fuer Favoriten + Zuletzt hinzugefuegt

Header als <button> mit Chevron + Count-Pill, slide-Transition (180ms).
State in localStorage unter kochwas.collapsed.sections — JSON-Map
{favorites, recent}, default beide offen, corrupt-JSON faellt auf
Default zurueck.

Alle Rezepte bleibt absichtlich nicht-collapsibel — Hauptliste, immer
sichtbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 14:38:43 +02:00
parent 01d29bff0e
commit 2216c89a04

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { CookingPot, X } from 'lucide-svelte'; import { CookingPot, X, ChevronDown } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { Snapshot } from './$types'; import type { Snapshot } from './$types';
import type { SearchHit } from '$lib/server/recipes/search-local'; import type { SearchHit } from '$lib/server/recipes/search-local';
import { randomQuote } from '$lib/quotes'; import { randomQuote } from '$lib/quotes';
@@ -49,6 +50,20 @@
let allChips: HTMLElement | undefined = $state(); let allChips: HTMLElement | undefined = $state();
let allObserver: IntersectionObserver | null = null; let allObserver: IntersectionObserver | null = null;
type CollapseKey = 'favorites' | 'recent';
const COLLAPSE_STORAGE_KEY = 'kochwas.collapsed.sections';
let collapsed = $state<Record<CollapseKey, boolean>>({
favorites: false,
recent: false
});
function toggleCollapsed(key: CollapseKey) {
collapsed[key] = !collapsed[key];
if (typeof localStorage !== 'undefined') {
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapsed));
}
}
// Snapshot persists across history navigation. We capture not only the // Snapshot persists across history navigation. We capture not only the
// search store, but also the pagination depth ("user had loaded 60 // search store, but also the pagination depth ("user had loaded 60
// recipes via infinite scroll") so on back-nav we can re-hydrate the // recipes via infinite scroll") so on back-nav we can re-hydrate the
@@ -175,6 +190,16 @@
// this; if loadAllMore lands first, rehydrateAll's larger result // this; if loadAllMore lands first, rehydrateAll's larger result
// simply overwrites allRecipes once it resolves. // simply overwrites allRecipes once it resolves.
void loadAllMore(); void loadAllMore();
const rawCollapsed = localStorage.getItem(COLLAPSE_STORAGE_KEY);
if (rawCollapsed) {
try {
const parsed = JSON.parse(rawCollapsed) as Partial<Record<CollapseKey, boolean>>;
if (typeof parsed.favorites === 'boolean') collapsed.favorites = parsed.favorites;
if (typeof parsed.recent === 'boolean') collapsed.recent = parsed.recent;
} catch {
// Corrupt JSON — keep defaults (both open).
}
}
}); });
// IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen. // IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen.
@@ -376,57 +401,91 @@
{:else} {:else}
{#if profileStore.active && favorites.length > 0} {#if profileStore.active && favorites.length > 0}
<section class="listing"> <section class="listing">
<h2>Deine Favoriten</h2> <button
<ul class="cards"> type="button"
{#each favorites as r (r.id)} class="section-head"
<li class="card-wrap"> onclick={() => toggleCollapsed('favorites')}
<a href={`/recipes/${r.id}`} class="card"> aria-expanded={!collapsed.favorites}
{#if r.image_path} >
<img src={`/images/${r.image_path}`} alt="" loading="lazy" /> <ChevronDown
{:else} size={18}
<div class="placeholder"><CookingPot size={36} /></div> strokeWidth={2.2}
{/if} class={collapsed.favorites ? 'chev rotated' : 'chev'}
<div class="card-body"> />
<div class="title">{r.title}</div> <h2>Deine Favoriten</h2>
{#if r.source_domain} <span class="count">{favorites.length}</span>
<div class="domain">{r.source_domain}</div> </button>
{/if} {#if !collapsed.favorites}
</div> <div transition:slide={{ duration: 180 }}>
</a> <ul class="cards">
</li> {#each favorites as r (r.id)}
{/each} <li class="card-wrap">
</ul> <a href={`/recipes/${r.id}`} class="card">
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={36} /></div>
{/if}
<div class="card-body">
<div class="title">{r.title}</div>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
</div>
{/if}
</section> </section>
{/if} {/if}
{#if recent.length > 0} {#if recent.length > 0}
<section class="listing"> <section class="listing">
<h2>Zuletzt hinzugefügt</h2> <button
<ul class="cards"> type="button"
{#each recent as r (r.id)} class="section-head"
<li class="card-wrap"> onclick={() => toggleCollapsed('recent')}
<a href={`/recipes/${r.id}`} class="card"> aria-expanded={!collapsed.recent}
{#if r.image_path} >
<img src={`/images/${r.image_path}`} alt="" loading="lazy" /> <ChevronDown
{:else} size={18}
<div class="placeholder"><CookingPot size={36} /></div> strokeWidth={2.2}
{/if} class={collapsed.recent ? 'chev rotated' : 'chev'}
<div class="card-body"> />
<div class="title">{r.title}</div> <h2>Zuletzt hinzugefügt</h2>
{#if r.source_domain} <span class="count">{recent.length}</span>
<div class="domain">{r.source_domain}</div> </button>
{/if} {#if !collapsed.recent}
</div> <div transition:slide={{ duration: 180 }}>
</a> <ul class="cards">
<button {#each recent as r (r.id)}
class="dismiss" <li class="card-wrap">
aria-label="Aus Zuletzt-hinzugefügt entfernen" <a href={`/recipes/${r.id}`} class="card">
onclick={(e) => dismissFromRecent(r.id, e)} {#if r.image_path}
> <img src={`/images/${r.image_path}`} alt="" loading="lazy" />
<X size={16} strokeWidth={2.5} /> {:else}
</button> <div class="placeholder"><CookingPot size={36} /></div>
</li> {/if}
{/each} <div class="card-body">
</ul> <div class="title">{r.title}</div>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
<button
class="dismiss"
aria-label="Aus Zuletzt-hinzugefügt entfernen"
onclick={(e) => dismissFromRecent(r.id, e)}
>
<X size={16} strokeWidth={2.5} />
</button>
</li>
{/each}
</ul>
</div>
{/if}
</section> </section>
{/if} {/if}
<section class="listing"> <section class="listing">
@@ -546,6 +605,45 @@
color: #444; color: #444;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
.section-head {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4rem 0.25rem;
background: transparent;
border: 0;
border-radius: 8px;
text-align: left;
cursor: pointer;
font-family: inherit;
color: inherit;
min-height: 44px;
margin-bottom: 0.4rem;
}
.section-head:hover {
background: #f4f8f5;
}
.section-head h2 {
margin: 0;
font-size: 1.05rem;
color: #444;
font-weight: 600;
}
.section-head .count {
margin-left: auto;
color: #888;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
:global(.chev) {
color: #2b6a3d;
flex-shrink: 0;
transition: transform 180ms;
}
:global(.chev.rotated) {
transform: rotate(-90deg);
}
.listing-head { .listing-head {
display: flex; display: flex;
align-items: center; align-items: center;