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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user