Mobile: DayMealCard tap opens MealActionSheet; Swap → SwapSuggestionsSheet (BottomSheet + SwapSuggestionList, easiest-first). Empty slots still open RecipePicker directly. Desktop: recipe-picker panel detects swap context (slot has recipe) and renders SwapSuggestionList; empty slots continue to show RecipePicker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
649 lines
23 KiB
Svelte
649 lines
23 KiB
Svelte
<script lang="ts">
|
||
import { goto, invalidateAll } from '$app/navigation';
|
||
import { enhance } from '$app/forms';
|
||
import { tick } from 'svelte';
|
||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
||
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
|
||
import SwapSuggestionList from '$lib/planner/SwapSuggestionList.svelte';
|
||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange, sortEasiestFirst } from '$lib/planner/week';
|
||
|
||
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
|
||
|
||
// Use UTC date string (YYYY-MM-DD) consistently
|
||
const today: string = new Date().toISOString().slice(0, 10);
|
||
|
||
let weekStart = $derived(data.weekStart);
|
||
let weekPlan = $derived(data.weekPlan);
|
||
let varietyScore = $derived(data.varietyScore);
|
||
|
||
let days = $derived(weekDays(weekStart));
|
||
let slots = $derived(weekPlan?.slots ?? []);
|
||
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
|
||
|
||
// Default selected day: today if in this week, else first day
|
||
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
|
||
let selectedDay = $state((() => {
|
||
const init = data.weekStart;
|
||
const d = weekDays(init);
|
||
return d.includes(today) ? today : d[0];
|
||
})());
|
||
|
||
// When week changes via navigation, reset selected day
|
||
$effect(() => {
|
||
const newDays = weekDays(weekStart);
|
||
if (!newDays.includes(selectedDay)) {
|
||
selectedDay = newDays.includes(today) ? today : newDays[0];
|
||
}
|
||
});
|
||
|
||
let selectedSlot = $derived(slotMap[selectedDay] ?? { id: null, slotDate: selectedDay, recipe: null });
|
||
let remainingSlots = $derived(days.filter((d: string) => d > selectedDay).map((d: string) => slotMap[d] ?? { id: null, slotDate: d, recipe: null }));
|
||
let remainingSlotsWithMeal = $derived(remainingSlots.filter((s: any) => s.recipe));
|
||
|
||
let isPlanner = $derived((data as any).benutzer?.rolle === 'planer');
|
||
|
||
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);
|
||
|
||
// 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))
|
||
);
|
||
|
||
// Recipes sorted easiest-first for the swap suggestion list
|
||
let sortedRecipes = $derived(sortEasiestFirst(data.recipes));
|
||
|
||
// Hidden form field bindings
|
||
let addPlanId = $state('');
|
||
let addSlotDate = $state('');
|
||
let addRecipeId = $state('');
|
||
let addRecipeName = $state('');
|
||
let updPlanId = $state('');
|
||
let updSlotId = $state('');
|
||
let updRecipeId = $state('');
|
||
let updRecipeName = $state('');
|
||
let delPlanId = $state('');
|
||
let delSlotId = $state('');
|
||
|
||
let addSlotFormEl: HTMLFormElement;
|
||
let updateSlotFormEl: HTMLFormElement;
|
||
let deleteSlotFormEl: HTMLFormElement;
|
||
|
||
// UndoBar
|
||
let undoVisible = $state(false);
|
||
let undoMessage = $state('');
|
||
|
||
function handleSelectDay(day: string) {
|
||
selectedDay = day;
|
||
panelState = { kind: 'day-detail', date: day };
|
||
}
|
||
|
||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||
let newWeekStart: string;
|
||
if (direction === 'prev') newWeekStart = prevWeek(weekStart);
|
||
else if (direction === 'next') newWeekStart = nextWeek(weekStart);
|
||
else newWeekStart = getWeekStart(new Date());
|
||
|
||
await goto(`/planner?week=${newWeekStart}`, { invalidateAll: true });
|
||
}
|
||
|
||
async function handleRecipePick(recipeId: string, recipeName: string) {
|
||
// Capture date before modifying panel state
|
||
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay;
|
||
|
||
// Close pickers
|
||
pickerOpen = false;
|
||
if (panelState.kind === 'recipe-picker') {
|
||
panelState = { kind: 'idle' };
|
||
}
|
||
|
||
const existingSlot = slotMap[date];
|
||
|
||
if (existingSlot?.id) {
|
||
updPlanId = weekPlan!.id;
|
||
updSlotId = existingSlot.id;
|
||
updRecipeId = recipeId;
|
||
updRecipeName = recipeName;
|
||
await tick();
|
||
updateSlotFormEl.requestSubmit();
|
||
} else {
|
||
addPlanId = weekPlan!.id;
|
||
addSlotDate = date;
|
||
addRecipeId = recipeId;
|
||
addRecipeName = recipeName;
|
||
await tick();
|
||
addSlotFormEl.requestSubmit();
|
||
}
|
||
}
|
||
|
||
function handleUndo() {
|
||
undoVisible = false;
|
||
deleteSlotFormEl.requestSubmit();
|
||
}
|
||
|
||
async function handleSwapPick(recipeId: string, recipeName: string) {
|
||
swapSheetOpen = false;
|
||
await handleRecipePick(recipeId, recipeName);
|
||
}
|
||
|
||
function closePanelToIdle() {
|
||
panelState = { kind: 'idle' };
|
||
}
|
||
|
||
function closePanelToDayDetail() {
|
||
if (panelState.kind === 'recipe-picker') {
|
||
panelState = { kind: 'day-detail', date: panelState.date };
|
||
} else {
|
||
panelState = { kind: 'idle' };
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<!-- Mobile & Tablet: vertical stack -->
|
||
<div class="flex h-full flex-col lg:hidden">
|
||
<!-- Top nav (sticky) -->
|
||
<header class="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Diese Woche</h1>
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onclick={() => navigateWeek('prev')}
|
||
aria-label="Vorherige Woche"
|
||
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={() => navigateWeek('next')}
|
||
aria-label="Nächste Woche"
|
||
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||
>
|
||
›
|
||
</button>
|
||
{#if isPlanner}
|
||
<button
|
||
type="button"
|
||
onclick={() => (pickerOpen = true)}
|
||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||
>
|
||
+ Gericht
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Variety banner: sticky below the top nav so it's always visible (spec requirement) -->
|
||
{#if varietyScore}
|
||
<div class="sticky z-10 px-4 pt-3" style="top: 56px;">
|
||
<VarietyScoreCard
|
||
score={varietyScore.score ?? 0}
|
||
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
|
||
showReviewLink={false}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Day strip -->
|
||
<div class="px-4 pt-3">
|
||
<WeekStrip
|
||
{weekStart}
|
||
{slots}
|
||
{selectedDay}
|
||
{today}
|
||
onselectDay={handleSelectDay}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Selected day card -->
|
||
<div class="px-4 pt-4">
|
||
<p class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||
{formatDayLabel(selectedDay)}
|
||
</p>
|
||
<DayMealCard
|
||
slot={selectedSlot}
|
||
isToday={selectedDay === today}
|
||
isSelected={true}
|
||
readonly={!isPlanner}
|
||
onactionsheet={isPlanner && selectedSlot.recipe ? () => (actionSheetOpen = true) : undefined}
|
||
onaddrecipe={isPlanner && !selectedSlot.recipe ? () => (pickerOpen = true) : undefined}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Remaining days list -->
|
||
{#if remainingSlotsWithMeal.length > 0}
|
||
<div class="px-4 pt-6 pb-4">
|
||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||
Restliche Woche
|
||
</h2>
|
||
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0">
|
||
{#each remainingSlotsWithMeal as slot (slot.slotDate)}
|
||
<button
|
||
type="button"
|
||
onclick={() => handleSelectDay(slot.slotDate)}
|
||
class="flex w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left hover:border-[var(--green-light)]"
|
||
>
|
||
<span class="min-w-[36px] font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||
{formatDayLabel(slot.slotDate).split(',')[0]}
|
||
</span>
|
||
<span class="flex-1 truncate font-[var(--font-sans)] text-[14px] font-medium text-[var(--color-text)]">
|
||
{slot.recipe?.name}
|
||
</span>
|
||
{#if isPlanner}
|
||
<span class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">→</span>
|
||
{/if}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Empty week state -->
|
||
{#if !weekPlan}
|
||
<div class="flex flex-1 flex-col items-center justify-center px-4 py-8 text-center">
|
||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
||
{#if isPlanner}
|
||
<form method="POST" action="?/createPlan" class="mt-4">
|
||
<input type="hidden" name="weekStart" value={weekStart} />
|
||
<button
|
||
type="submit"
|
||
class="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"
|
||
>
|
||
Wochenplan erstellen
|
||
</button>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Mobile: empty slot → RecipePicker -->
|
||
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
|
||
<RecipePicker
|
||
planId={weekPlan?.id ?? ''}
|
||
date={selectedDay}
|
||
dateLabel={formatDayLabel(selectedDay)}
|
||
currentVarietyScore={varietyScore?.score ?? 0}
|
||
suggestions={[]}
|
||
allRecipes={data.recipes}
|
||
onpick={handleRecipePick}
|
||
/>
|
||
</BottomSheet>
|
||
|
||
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Cancel) -->
|
||
<MealActionSheet
|
||
open={actionSheetOpen}
|
||
slot={selectedSlot}
|
||
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
|
||
oncancel={() => (actionSheetOpen = false)}
|
||
/>
|
||
|
||
<!-- Mobile: swap suggestions sheet -->
|
||
<BottomSheet open={swapSheetOpen} onclose={() => (swapSheetOpen = false)} height="70vh">
|
||
{@const replacingMeta = [
|
||
selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
|
||
selectedSlot.recipe?.effort ?? null
|
||
].filter(Boolean).join(' · ')}
|
||
<div style="padding: 16px;">
|
||
<SwapSuggestionList
|
||
replacingName={selectedSlot.recipe?.name ?? ''}
|
||
replacingMeta={replacingMeta || undefined}
|
||
recipes={sortedRecipes}
|
||
{currentWeekRecipeIds}
|
||
onpick={handleSwapPick}
|
||
oncancel={() => (swapSheetOpen = false)}
|
||
/>
|
||
</div>
|
||
</BottomSheet>
|
||
</div>
|
||
|
||
<!-- Desktop: 3-panel layout -->
|
||
<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">
|
||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Wochenplaner</h1>
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onclick={() => navigateWeek('prev')}
|
||
aria-label="Vorherige Woche"
|
||
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||
>
|
||
‹
|
||
</button>
|
||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{weekRange}</span>
|
||
<button
|
||
type="button"
|
||
onclick={() => navigateWeek('next')}
|
||
aria-label="Nächste Woche"
|
||
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||
>
|
||
›
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={() => navigateWeek('today')}
|
||
class="flex min-h-[40px] items-center rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||
>
|
||
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 -->
|
||
<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
|
||
score={varietyScore.score ?? 0}
|
||
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
|
||
showReviewLink={true}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
</aside>
|
||
|
||
<!-- Main calendar (only scrollable panel) -->
|
||
<main class="flex-1 overflow-y-auto 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>
|
||
{#if isPlanner}
|
||
<form method="POST" action="?/createPlan" class="mt-4">
|
||
<input type="hidden" name="weekStart" value={weekStart} />
|
||
<button type="submit" class="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">
|
||
Wochenplan erstellen
|
||
</button>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<div class="grid grid-cols-7 gap-[8px]">
|
||
{#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')}
|
||
|
||
<div class="flex flex-col">
|
||
<!-- Column header: day name + date badge -->
|
||
<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)]' : ''}"
|
||
>
|
||
{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>
|
||
</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}
|
||
</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 px-4">
|
||
<SwapSuggestionList
|
||
replacingName={pickerSlot.recipe.name}
|
||
replacingMeta={replacingMeta || undefined}
|
||
recipes={sortedRecipes}
|
||
{currentWeekRecipeIds}
|
||
onpick={handleRecipePick}
|
||
/>
|
||
</div>
|
||
{:else}
|
||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
||
<RecipePicker
|
||
planId={weekPlan?.id ?? ''}
|
||
date={pickerDate}
|
||
dateLabel={formatDayLabel(pickerDate)}
|
||
currentVarietyScore={varietyScore?.score ?? 0}
|
||
suggestions={[]}
|
||
allRecipes={data.recipes}
|
||
onpick={handleRecipePick}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hidden forms for slot mutations -->
|
||
<div class="hidden">
|
||
<!-- Add slot -->
|
||
<form
|
||
method="POST"
|
||
action="?/addSlot"
|
||
bind:this={addSlotFormEl}
|
||
use:enhance={({ formData }) => {
|
||
formData.set('planId', addPlanId);
|
||
formData.set('slotDate', addSlotDate);
|
||
formData.set('recipeId', addRecipeId);
|
||
return async ({ result, update }) => {
|
||
if (result.type === 'success' && result.data?.success) {
|
||
delPlanId = addPlanId;
|
||
delSlotId = (result.data as any)?.slot?.id ?? '';
|
||
undoMessage = `${addRecipeName} hinzugefügt`;
|
||
undoVisible = true;
|
||
}
|
||
await update({ reset: false });
|
||
await invalidateAll();
|
||
};
|
||
}}
|
||
>
|
||
<input type="hidden" name="planId" value={addPlanId} />
|
||
<input type="hidden" name="slotDate" value={addSlotDate} />
|
||
<input type="hidden" name="recipeId" value={addRecipeId} />
|
||
</form>
|
||
|
||
<!-- Update slot -->
|
||
<form
|
||
method="POST"
|
||
action="?/updateSlot"
|
||
bind:this={updateSlotFormEl}
|
||
use:enhance={({ formData }) => {
|
||
formData.set('planId', updPlanId);
|
||
formData.set('slotId', updSlotId);
|
||
formData.set('recipeId', updRecipeId);
|
||
return async ({ result, update }) => {
|
||
if (result.type === 'success' && result.data?.success) {
|
||
delPlanId = updPlanId;
|
||
delSlotId = (result.data as any)?.slot?.id ?? '';
|
||
undoMessage = `${updRecipeName} eingetragen`;
|
||
undoVisible = true;
|
||
}
|
||
await update({ reset: false });
|
||
await invalidateAll();
|
||
};
|
||
}}
|
||
>
|
||
<input type="hidden" name="planId" value={updPlanId} />
|
||
<input type="hidden" name="slotId" value={updSlotId} />
|
||
<input type="hidden" name="recipeId" value={updRecipeId} />
|
||
</form>
|
||
|
||
<!-- Delete slot (for undo) -->
|
||
<form
|
||
method="POST"
|
||
action="?/deleteSlot"
|
||
bind:this={deleteSlotFormEl}
|
||
use:enhance={({ formData }) => {
|
||
formData.set('planId', delPlanId);
|
||
formData.set('slotId', delSlotId);
|
||
return async ({ update }) => {
|
||
await update({ reset: false });
|
||
await invalidateAll();
|
||
};
|
||
}}
|
||
>
|
||
<input type="hidden" name="planId" value={delPlanId} />
|
||
<input type="hidden" name="slotId" value={delSlotId} />
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Undo toast -->
|
||
<UndoBar
|
||
visible={undoVisible}
|
||
message={undoMessage}
|
||
onundo={handleUndo}
|
||
ondismiss={() => (undoVisible = false)}
|
||
/>
|