feat(planner): overhaul desktop layout — flip tiles, no right panel
Replaces 3-panel layout with 2-panel (sidebar + full-width grid): - Remove persistent right panel and toolbar + Gericht hinzufügen button - grid-cols-7 tiles use DesktopDayTile (CSS 3D card flip) - RecipePickerDrawer slides in on tile CTA / Gericht tauschen - Page-owned activeSlotId + drawerOpen/drawerSlotId state - Single Escape handler: drawer > flip priority - Extend server load to forward recipe tags from /v1/recipes API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,8 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
name: r.name!,
|
||||
cookTimeMin: r.cookTimeMin,
|
||||
effort: r.effort,
|
||||
heroImageUrl: r.heroImageUrl
|
||||
heroImageUrl: r.heroImageUrl,
|
||||
tags: (r.tags ?? []).map((t: any) => ({ id: t.id, name: t.name, tagType: t.tagType }))
|
||||
}));
|
||||
|
||||
if (weekPlanResult.error || !weekPlanResult.data?.id) {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||||
import DesktopDayTile from '$lib/planner/DesktopDayTile.svelte';
|
||||
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
||||
import RecipePickerDrawer from '$lib/planner/RecipePickerDrawer.svelte';
|
||||
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
|
||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||
@@ -49,35 +51,27 @@
|
||||
|
||||
let weekRange = $derived(formatWeekRange(weekStart));
|
||||
|
||||
// Desktop right panel state machine
|
||||
type PanelState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'day-detail'; date: string }
|
||||
| { kind: 'recipe-picker'; date: string };
|
||||
|
||||
let panelState = $state<PanelState>({ kind: 'idle' });
|
||||
|
||||
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow
|
||||
let pickerOpen = $state(false);
|
||||
let actionSheetOpen = $state(false);
|
||||
let swapSheetOpen = $state(false);
|
||||
let swapLoading = $state(false);
|
||||
|
||||
// Desktop flip tile + drawer state (page-owned per Kai's architecture decision)
|
||||
let activeSlotId = $state<string | null>(null);
|
||||
let drawerOpen = $state(false);
|
||||
let drawerSlotId = $state<string | null>(null);
|
||||
|
||||
const activePickerDate = $derived(
|
||||
pickerOpen ? selectedDay
|
||||
: swapSheetOpen ? selectedDay
|
||||
: panelState.kind === 'recipe-picker' ? panelState.date
|
||||
: drawerOpen && drawerSlotId ? drawerSlotId
|
||||
: null
|
||||
);
|
||||
|
||||
let suggestions: Suggestion[] = $state([]);
|
||||
let isLoadingSuggestions = $state(false);
|
||||
|
||||
// Recipes already in any slot this week — used for ⚠ overlap warnings
|
||||
let currentWeekRecipeIds = $derived(
|
||||
new Set<string>(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id))
|
||||
);
|
||||
|
||||
// Hidden form field bindings
|
||||
let addPlanId = $state('');
|
||||
let addSlotDate = $state('');
|
||||
@@ -115,9 +109,23 @@
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
// Single Escape key handler — priority: drawer > flip (Kai architecture decision)
|
||||
$effect(() => {
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (drawerOpen) {
|
||||
drawerOpen = false;
|
||||
drawerSlotId = null;
|
||||
} else if (activeSlotId) {
|
||||
activeSlotId = null;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
function handleSelectDay(day: string) {
|
||||
selectedDay = day;
|
||||
panelState = { kind: 'day-detail', date: day };
|
||||
}
|
||||
|
||||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||||
@@ -130,14 +138,13 @@
|
||||
}
|
||||
|
||||
async function handleRecipePick(recipeId: string, recipeName: string) {
|
||||
// Capture date before modifying panel state
|
||||
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay;
|
||||
// Drawer date takes priority (desktop), then mobile picker date
|
||||
const date = drawerOpen && drawerSlotId ? drawerSlotId : selectedDay;
|
||||
|
||||
// Close pickers
|
||||
// Close all pickers
|
||||
pickerOpen = false;
|
||||
if (panelState.kind === 'recipe-picker') {
|
||||
panelState = { kind: 'idle' };
|
||||
}
|
||||
drawerOpen = false;
|
||||
drawerSlotId = null;
|
||||
|
||||
const existingSlot = slotMap[date];
|
||||
|
||||
@@ -196,17 +203,39 @@
|
||||
swapLoading = false;
|
||||
}
|
||||
|
||||
function closePanelToIdle() {
|
||||
panelState = { kind: 'idle' };
|
||||
// Desktop tile handlers
|
||||
function handleTileFlip(slotId: string) {
|
||||
activeSlotId = slotId;
|
||||
}
|
||||
|
||||
function closePanelToDayDetail() {
|
||||
if (panelState.kind === 'recipe-picker') {
|
||||
panelState = { kind: 'day-detail', date: panelState.date };
|
||||
} else {
|
||||
panelState = { kind: 'idle' };
|
||||
}
|
||||
function handleTileClose() {
|
||||
activeSlotId = null;
|
||||
}
|
||||
|
||||
function handleTileSwap(slotDate: string) {
|
||||
activeSlotId = null;
|
||||
drawerSlotId = slotDate;
|
||||
drawerOpen = true;
|
||||
}
|
||||
|
||||
async function handleTileRemove(slot: any) {
|
||||
activeSlotId = null;
|
||||
await handleRemoveMeal(slot);
|
||||
}
|
||||
|
||||
function handleEmptyTileAdd(slotDate: string) {
|
||||
drawerSlotId = slotDate;
|
||||
drawerOpen = true;
|
||||
}
|
||||
|
||||
const drawerSlot = $derived(drawerSlotId ? (slotMap[drawerSlotId] ?? null) : null);
|
||||
const drawerReplacingMeta = $derived(
|
||||
drawerSlot?.recipe
|
||||
? [drawerSlot.recipe.cookTimeMin ? `${drawerSlot.recipe.cookTimeMin} Min` : null, drawerSlot.recipe.effort ?? null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Mobile & Tablet: vertical stack -->
|
||||
@@ -369,7 +398,7 @@
|
||||
</BottomSheet>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 3-panel layout -->
|
||||
<!-- Desktop: 2-panel layout (sidebar + full-width flip-tile grid) -->
|
||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||
<!-- Topbar -->
|
||||
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
||||
@@ -400,21 +429,11 @@
|
||||
Heute
|
||||
</button>
|
||||
</div>
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (panelState = { kind: 'recipe-picker', date: selectedDay })}
|
||||
class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left sidebar -->
|
||||
<!-- Left sidebar (unchanged) -->
|
||||
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<!-- Variety widget at bottom -->
|
||||
{#if varietyScore}
|
||||
<div class="mt-auto">
|
||||
<VarietyScoreCard
|
||||
@@ -426,8 +445,8 @@
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- Main calendar (only scrollable panel) -->
|
||||
<main class="flex-1 overflow-y-auto p-5">
|
||||
<!-- Main grid — full width, full height -->
|
||||
<main class="flex-1 overflow-hidden p-5">
|
||||
{#if !weekPlan}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
||||
@@ -441,198 +460,65 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-7 gap-[8px]">
|
||||
<div class="grid h-full grid-cols-7 gap-2">
|
||||
{#each days as day (day)}
|
||||
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
||||
{@const isTodayDay = day === today}
|
||||
{@const isSelectedDay = day === selectedDay}
|
||||
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
||||
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
|
||||
{@const isThisTileActive = drawerSlotId === day}
|
||||
|
||||
<div class="flex flex-col">
|
||||
<!-- Column header: day name + date badge -->
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Column header -->
|
||||
<div class="mb-2 flex flex-col items-center gap-1">
|
||||
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{dayAbbr}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
|
||||
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''}
|
||||
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
|
||||
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
|
||||
{isTodayDay ? 'bg-[var(--yellow)] text-white' : 'bg-transparent text-[var(--color-text)]'}"
|
||||
>
|
||||
{dateNum}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meal tile -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
handleSelectDay(day);
|
||||
if (!slot.recipe && isPlanner) {
|
||||
panelState = { kind: 'recipe-picker', date: day };
|
||||
}
|
||||
}}
|
||||
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
|
||||
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)]
|
||||
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
|
||||
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
|
||||
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
|
||||
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}"
|
||||
>
|
||||
{#if slot.recipe}
|
||||
<p class="font-[var(--font-display)] text-[13px] font-[300] leading-tight text-[var(--color-text)]">
|
||||
{slot.recipe.name}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-center justify-center py-4 text-[var(--color-text-muted)]">
|
||||
<span class="text-[18px]" aria-hidden="true">+</span>
|
||||
<span class="font-[var(--font-sans)] text-[11px]">Gericht wählen</span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Flip tile -->
|
||||
<div class="min-h-0 flex-1">
|
||||
<DesktopDayTile
|
||||
{slot}
|
||||
isToday={isTodayDay}
|
||||
{activeSlotId}
|
||||
{isPlanner}
|
||||
{slotMap}
|
||||
{suggestions}
|
||||
topSuggestion={isThisTileActive && suggestions.length > 0 ? suggestions[0] : undefined}
|
||||
onflip={handleTileFlip}
|
||||
onclose={handleTileClose}
|
||||
onswap={() => handleTileSwap(day)}
|
||||
onremove={() => handleTileRemove(slot)}
|
||||
onaddrecipe={() => handleEmptyTileAdd(day)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Right detail panel -->
|
||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-page)] p-4">
|
||||
{#if panelState.kind === 'idle'}
|
||||
<div class="flex flex-1 flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
|
||||
</div>
|
||||
|
||||
{:else if panelState.kind === 'day-detail'}
|
||||
{@const detailDate = panelState.date}
|
||||
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
|
||||
|
||||
<!-- Panel header with close button -->
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(detailDate)} · Abendessen
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closePanelToIdle}
|
||||
aria-label="Panel schließen"
|
||||
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if detailSlot.recipe}
|
||||
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
||||
{detailSlot.recipe.name}
|
||||
</h2>
|
||||
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<a
|
||||
href="/recipes/{detailSlot.recipe.id}"
|
||||
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Rezept ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/recipes/{detailSlot.recipe.id}/cook"
|
||||
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Koch-Modus
|
||||
</a>
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
||||
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Gericht tauschen
|
||||
</button>
|
||||
{#if detailSlot.id}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { handleRemoveMeal(detailSlot as any); panelState = { kind: 'idle' }; }}
|
||||
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-error,#d9534f)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-error,#d9534f)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
||||
class="mt-3 block w-full rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht wählen
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{:else if panelState.kind === 'recipe-picker'}
|
||||
{@const pickerDate = panelState.date}
|
||||
{@const pickerSlot = slotMap[pickerDate] ?? null}
|
||||
{@const isSwapContext = !!pickerSlot?.recipe}
|
||||
|
||||
<!-- Panel header with back/close button -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closePanelToDayDetail}
|
||||
aria-label="Zurück"
|
||||
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isSwapContext}
|
||||
{@const replacingMeta = [
|
||||
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
|
||||
pickerSlot.recipe.effort ?? null
|
||||
].filter(Boolean).join(' · ')}
|
||||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
||||
<RecipePicker
|
||||
planId={weekPlan?.id ?? ''}
|
||||
date={pickerDate}
|
||||
dateLabel={formatDayLabel(pickerDate)}
|
||||
suggestions={suggestions}
|
||||
allRecipes={data.recipes}
|
||||
isLoading={isLoadingSuggestions}
|
||||
excludeRecipeId={pickerSlot.recipe.id}
|
||||
replacingRecipe={{ name: pickerSlot.recipe.name, meta: replacingMeta || undefined }}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
||||
<RecipePicker
|
||||
planId={weekPlan?.id ?? ''}
|
||||
date={pickerDate}
|
||||
dateLabel={formatDayLabel(pickerDate)}
|
||||
suggestions={suggestions}
|
||||
allRecipes={data.recipes}
|
||||
isLoading={isLoadingSuggestions}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Recipe picker drawer (slide-in from right) -->
|
||||
<RecipePickerDrawer
|
||||
open={drawerOpen}
|
||||
slotDate={drawerSlotId ?? ''}
|
||||
planId={weekPlan?.id ?? ''}
|
||||
{suggestions}
|
||||
allRecipes={data.recipes}
|
||||
isLoading={isLoadingSuggestions}
|
||||
onpick={handleRecipePick}
|
||||
onclose={() => { drawerOpen = false; drawerSlotId = null; }}
|
||||
excludeRecipeId={drawerSlot?.recipe?.id}
|
||||
replacingRecipe={drawerSlot?.recipe ? { name: drawerSlot.recipe.name, meta: drawerReplacingMeta || undefined } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Hidden forms for slot mutations -->
|
||||
|
||||
Reference in New Issue
Block a user