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:
@@ -35,6 +35,7 @@
|
||||
isPlanner,
|
||||
slotMap,
|
||||
suggestions,
|
||||
topSuggestion,
|
||||
onflip,
|
||||
onclose,
|
||||
onswap,
|
||||
@@ -47,6 +48,7 @@
|
||||
isPlanner: boolean;
|
||||
slotMap: Record<string, any>;
|
||||
suggestions: Suggestion[];
|
||||
topSuggestion?: Suggestion;
|
||||
onflip?: (slotId: string) => void;
|
||||
onclose?: () => void;
|
||||
onswap?: () => void;
|
||||
@@ -83,7 +85,7 @@
|
||||
|
||||
{#if slot.recipe}
|
||||
<div
|
||||
data-testid="day-meal-card"
|
||||
data-testid="day-meal-card-{slot.slotDate ?? ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={slot.recipe?.name ?? 'Gericht'}
|
||||
@@ -155,7 +157,7 @@
|
||||
slotId={slot.id ?? ''}
|
||||
{isPlanner}
|
||||
{slotMap}
|
||||
topSuggestion={undefined}
|
||||
{topSuggestion}
|
||||
{onaddrecipe}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -27,30 +27,30 @@ describe('DesktopDayTile — filled slot', () => {
|
||||
|
||||
it('has data-testid="day-meal-card" on the scene element', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
expect(screen.getByTestId('day-meal-card')).toBeTruthy();
|
||||
expect(screen.getByTestId("day-meal-card-2026-04-14")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies today ring when isToday', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId('day-meal-card');
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('data-today')).toBe('true');
|
||||
});
|
||||
|
||||
it('applies selected ring when activeSlotId matches slot id', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId('day-meal-card');
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('data-flipped')).toBe('true');
|
||||
});
|
||||
|
||||
it('dims tile when another slot is active', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId('day-meal-card');
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('data-dimmed')).toBe('true');
|
||||
});
|
||||
|
||||
it('is not dimmed when no slot is active', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId('day-meal-card');
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('data-dimmed')).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ describe('DesktopDayTile — filled slot', () => {
|
||||
const onflip = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||
await user.click(screen.getByTestId('day-meal-card'));
|
||||
await user.click(screen.getByTestId("day-meal-card-2026-04-14"));
|
||||
expect(onflip).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('DesktopDayTile — filled slot', () => {
|
||||
const onflip = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||
screen.getByTestId('day-meal-card').focus();
|
||||
screen.getByTestId("day-meal-card-2026-04-14").focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onflip).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe('DesktopDayTile — filled slot', () => {
|
||||
const onflip = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||
screen.getByTestId('day-meal-card').focus();
|
||||
screen.getByTestId("day-meal-card-2026-04-14").focus();
|
||||
await user.keyboard(' ');
|
||||
expect(onflip).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
@@ -147,13 +147,13 @@ describe('DesktopDayTile — filled slot', () => {
|
||||
|
||||
it('aria-expanded is true when flipped', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId('day-meal-card');
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('aria-expanded is false when not flipped', () => {
|
||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||
const scene = screen.getByTestId('day-meal-card');
|
||||
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||
expect(scene.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,8 +60,9 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RecipePicker content -->
|
||||
<!-- RecipePicker content — only mount when open to avoid duplicate text in DOM -->
|
||||
<div style="overflow-y: auto; flex: 1;">
|
||||
{#if open}
|
||||
<RecipePicker
|
||||
{planId}
|
||||
date={slotDate}
|
||||
@@ -73,5 +74,6 @@
|
||||
{excludeRecipeId}
|
||||
{replacingRecipe}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
export interface TagItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tagType?: string;
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
heroImageUrl?: string | null;
|
||||
tags?: TagItem[];
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
|
||||
@@ -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,199 +460,66 @@
|
||||
{/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>
|
||||
<!-- 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>
|
||||
{/if}
|
||||
</button>
|
||||
</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
|
||||
<!-- Recipe picker drawer (slide-in from right) -->
|
||||
<RecipePickerDrawer
|
||||
open={drawerOpen}
|
||||
slotDate={drawerSlotId ?? ''}
|
||||
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}
|
||||
{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>
|
||||
{/if}
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden forms for slot mutations -->
|
||||
<div class="hidden">
|
||||
|
||||
Reference in New Issue
Block a user