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,
|
isPlanner,
|
||||||
slotMap,
|
slotMap,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
topSuggestion,
|
||||||
onflip,
|
onflip,
|
||||||
onclose,
|
onclose,
|
||||||
onswap,
|
onswap,
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
isPlanner: boolean;
|
isPlanner: boolean;
|
||||||
slotMap: Record<string, any>;
|
slotMap: Record<string, any>;
|
||||||
suggestions: Suggestion[];
|
suggestions: Suggestion[];
|
||||||
|
topSuggestion?: Suggestion;
|
||||||
onflip?: (slotId: string) => void;
|
onflip?: (slotId: string) => void;
|
||||||
onclose?: () => void;
|
onclose?: () => void;
|
||||||
onswap?: () => void;
|
onswap?: () => void;
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
|
|
||||||
{#if slot.recipe}
|
{#if slot.recipe}
|
||||||
<div
|
<div
|
||||||
data-testid="day-meal-card"
|
data-testid="day-meal-card-{slot.slotDate ?? ''}"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label={slot.recipe?.name ?? 'Gericht'}
|
aria-label={slot.recipe?.name ?? 'Gericht'}
|
||||||
@@ -155,7 +157,7 @@
|
|||||||
slotId={slot.id ?? ''}
|
slotId={slot.id ?? ''}
|
||||||
{isPlanner}
|
{isPlanner}
|
||||||
{slotMap}
|
{slotMap}
|
||||||
topSuggestion={undefined}
|
{topSuggestion}
|
||||||
{onaddrecipe}
|
{onaddrecipe}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -27,30 +27,30 @@ describe('DesktopDayTile — filled slot', () => {
|
|||||||
|
|
||||||
it('has data-testid="day-meal-card" on the scene element', () => {
|
it('has data-testid="day-meal-card" on the scene element', () => {
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
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', () => {
|
it('applies today ring when isToday', () => {
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
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');
|
expect(scene.getAttribute('data-today')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies selected ring when activeSlotId matches slot id', () => {
|
it('applies selected ring when activeSlotId matches slot id', () => {
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
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');
|
expect(scene.getAttribute('data-flipped')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dims tile when another slot is active', () => {
|
it('dims tile when another slot is active', () => {
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } });
|
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');
|
expect(scene.getAttribute('data-dimmed')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is not dimmed when no slot is active', () => {
|
it('is not dimmed when no slot is active', () => {
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
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');
|
expect(scene.getAttribute('data-dimmed')).toBe('false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -60,7 +60,7 @@ describe('DesktopDayTile — filled slot', () => {
|
|||||||
const onflip = vi.fn();
|
const onflip = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
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');
|
expect(onflip).toHaveBeenCalledWith('s1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ describe('DesktopDayTile — filled slot', () => {
|
|||||||
const onflip = vi.fn();
|
const onflip = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
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}');
|
await user.keyboard('{Enter}');
|
||||||
expect(onflip).toHaveBeenCalledWith('s1');
|
expect(onflip).toHaveBeenCalledWith('s1');
|
||||||
});
|
});
|
||||||
@@ -77,7 +77,7 @@ describe('DesktopDayTile — filled slot', () => {
|
|||||||
const onflip = vi.fn();
|
const onflip = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
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(' ');
|
await user.keyboard(' ');
|
||||||
expect(onflip).toHaveBeenCalledWith('s1');
|
expect(onflip).toHaveBeenCalledWith('s1');
|
||||||
});
|
});
|
||||||
@@ -147,13 +147,13 @@ describe('DesktopDayTile — filled slot', () => {
|
|||||||
|
|
||||||
it('aria-expanded is true when flipped', () => {
|
it('aria-expanded is true when flipped', () => {
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
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');
|
expect(scene.getAttribute('aria-expanded')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aria-expanded is false when not flipped', () => {
|
it('aria-expanded is false when not flipped', () => {
|
||||||
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
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');
|
expect(scene.getAttribute('aria-expanded')).toBe('false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,8 +60,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RecipePicker content -->
|
<!-- RecipePicker content — only mount when open to avoid duplicate text in DOM -->
|
||||||
<div style="overflow-y: auto; flex: 1;">
|
<div style="overflow-y: auto; flex: 1;">
|
||||||
|
{#if open}
|
||||||
<RecipePicker
|
<RecipePicker
|
||||||
{planId}
|
{planId}
|
||||||
date={slotDate}
|
date={slotDate}
|
||||||
@@ -73,5 +74,6 @@
|
|||||||
{excludeRecipeId}
|
{excludeRecipeId}
|
||||||
{replacingRecipe}
|
{replacingRecipe}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
export interface TagItem {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
tagType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Recipe {
|
export interface Recipe {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
cookTimeMin?: number;
|
cookTimeMin?: number;
|
||||||
|
heroImageUrl?: string | null;
|
||||||
|
tags?: TagItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
|
|||||||
name: r.name!,
|
name: r.name!,
|
||||||
cookTimeMin: r.cookTimeMin,
|
cookTimeMin: r.cookTimeMin,
|
||||||
effort: r.effort,
|
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) {
|
if (weekPlanResult.error || !weekPlanResult.data?.id) {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||||||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||||||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||||||
|
import DesktopDayTile from '$lib/planner/DesktopDayTile.svelte';
|
||||||
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
||||||
|
import RecipePickerDrawer from '$lib/planner/RecipePickerDrawer.svelte';
|
||||||
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
|
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
|
||||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
import UndoBar from '$lib/planner/UndoBar.svelte';
|
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||||
@@ -49,35 +51,27 @@
|
|||||||
|
|
||||||
let weekRange = $derived(formatWeekRange(weekStart));
|
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
|
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow
|
||||||
let pickerOpen = $state(false);
|
let pickerOpen = $state(false);
|
||||||
let actionSheetOpen = $state(false);
|
let actionSheetOpen = $state(false);
|
||||||
let swapSheetOpen = $state(false);
|
let swapSheetOpen = $state(false);
|
||||||
let swapLoading = $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(
|
const activePickerDate = $derived(
|
||||||
pickerOpen ? selectedDay
|
pickerOpen ? selectedDay
|
||||||
: swapSheetOpen ? selectedDay
|
: swapSheetOpen ? selectedDay
|
||||||
: panelState.kind === 'recipe-picker' ? panelState.date
|
: drawerOpen && drawerSlotId ? drawerSlotId
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
let suggestions: Suggestion[] = $state([]);
|
let suggestions: Suggestion[] = $state([]);
|
||||||
let isLoadingSuggestions = $state(false);
|
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
|
// Hidden form field bindings
|
||||||
let addPlanId = $state('');
|
let addPlanId = $state('');
|
||||||
let addSlotDate = $state('');
|
let addSlotDate = $state('');
|
||||||
@@ -115,9 +109,23 @@
|
|||||||
return () => controller.abort();
|
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) {
|
function handleSelectDay(day: string) {
|
||||||
selectedDay = day;
|
selectedDay = day;
|
||||||
panelState = { kind: 'day-detail', date: day };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||||||
@@ -130,14 +138,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRecipePick(recipeId: string, recipeName: string) {
|
async function handleRecipePick(recipeId: string, recipeName: string) {
|
||||||
// Capture date before modifying panel state
|
// Drawer date takes priority (desktop), then mobile picker date
|
||||||
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay;
|
const date = drawerOpen && drawerSlotId ? drawerSlotId : selectedDay;
|
||||||
|
|
||||||
// Close pickers
|
// Close all pickers
|
||||||
pickerOpen = false;
|
pickerOpen = false;
|
||||||
if (panelState.kind === 'recipe-picker') {
|
drawerOpen = false;
|
||||||
panelState = { kind: 'idle' };
|
drawerSlotId = null;
|
||||||
}
|
|
||||||
|
|
||||||
const existingSlot = slotMap[date];
|
const existingSlot = slotMap[date];
|
||||||
|
|
||||||
@@ -196,17 +203,39 @@
|
|||||||
swapLoading = false;
|
swapLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePanelToIdle() {
|
// Desktop tile handlers
|
||||||
panelState = { kind: 'idle' };
|
function handleTileFlip(slotId: string) {
|
||||||
|
activeSlotId = slotId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePanelToDayDetail() {
|
function handleTileClose() {
|
||||||
if (panelState.kind === 'recipe-picker') {
|
activeSlotId = null;
|
||||||
panelState = { kind: 'day-detail', date: panelState.date };
|
|
||||||
} else {
|
|
||||||
panelState = { kind: 'idle' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Mobile & Tablet: vertical stack -->
|
<!-- Mobile & Tablet: vertical stack -->
|
||||||
@@ -369,7 +398,7 @@
|
|||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
</div>
|
</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">
|
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||||
<!-- Topbar -->
|
<!-- Topbar -->
|
||||||
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
<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
|
Heute
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<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">
|
<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}
|
{#if varietyScore}
|
||||||
<div class="mt-auto">
|
<div class="mt-auto">
|
||||||
<VarietyScoreCard
|
<VarietyScoreCard
|
||||||
@@ -426,8 +445,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main calendar (only scrollable panel) -->
|
<!-- Main grid — full width, full height -->
|
||||||
<main class="flex-1 overflow-y-auto p-5">
|
<main class="flex-1 overflow-hidden p-5">
|
||||||
{#if !weekPlan}
|
{#if !weekPlan}
|
||||||
<div class="flex h-full flex-col items-center justify-center">
|
<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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-7 gap-[8px]">
|
<div class="grid h-full grid-cols-7 gap-2">
|
||||||
{#each days as day (day)}
|
{#each days as day (day)}
|
||||||
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
||||||
{@const isTodayDay = day === today}
|
{@const isTodayDay = day === today}
|
||||||
{@const isSelectedDay = day === selectedDay}
|
|
||||||
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
||||||
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
|
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
|
||||||
|
{@const isThisTileActive = drawerSlotId === day}
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<!-- Column header: day name + date badge -->
|
<!-- Column header -->
|
||||||
<div class="mb-2 flex flex-col items-center gap-1">
|
<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)]">
|
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
{dayAbbr}
|
{dayAbbr}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
|
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
|
||||||
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''}
|
{isTodayDay ? 'bg-[var(--yellow)] text-white' : 'bg-transparent text-[var(--color-text)]'}"
|
||||||
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
|
|
||||||
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
|
|
||||||
>
|
>
|
||||||
{dateNum}
|
{dateNum}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Meal tile -->
|
<!-- Flip tile -->
|
||||||
<button
|
<div class="min-h-0 flex-1">
|
||||||
type="button"
|
<DesktopDayTile
|
||||||
onclick={() => {
|
{slot}
|
||||||
handleSelectDay(day);
|
isToday={isTodayDay}
|
||||||
if (!slot.recipe && isPlanner) {
|
{activeSlotId}
|
||||||
panelState = { kind: 'recipe-picker', date: day };
|
{isPlanner}
|
||||||
}
|
{slotMap}
|
||||||
}}
|
{suggestions}
|
||||||
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
|
topSuggestion={isThisTileActive && suggestions.length > 0 ? suggestions[0] : undefined}
|
||||||
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)]
|
onflip={handleTileFlip}
|
||||||
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
|
onclose={handleTileClose}
|
||||||
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
|
onswap={() => handleTileSwap(day)}
|
||||||
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
|
onremove={() => handleTileRemove(slot)}
|
||||||
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}"
|
onaddrecipe={() => handleEmptyTileAdd(day)}
|
||||||
>
|
/>
|
||||||
{#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>
|
</div>
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</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>
|
</div>
|
||||||
|
|
||||||
{:else if panelState.kind === 'day-detail'}
|
<!-- Recipe picker drawer (slide-in from right) -->
|
||||||
{@const detailDate = panelState.date}
|
<RecipePickerDrawer
|
||||||
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
|
open={drawerOpen}
|
||||||
|
slotDate={drawerSlotId ?? ''}
|
||||||
<!-- 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 ?? ''}
|
planId={weekPlan?.id ?? ''}
|
||||||
date={pickerDate}
|
{suggestions}
|
||||||
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}
|
allRecipes={data.recipes}
|
||||||
isLoading={isLoadingSuggestions}
|
isLoading={isLoadingSuggestions}
|
||||||
onpick={handleRecipePick}
|
onpick={handleRecipePick}
|
||||||
|
onclose={() => { drawerOpen = false; drawerSlotId = null; }}
|
||||||
|
excludeRecipeId={drawerSlot?.recipe?.id}
|
||||||
|
replacingRecipe={drawerSlot?.recipe ? { name: drawerSlot.recipe.name, meta: drawerReplacingMeta || undefined } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden forms for slot mutations -->
|
<!-- Hidden forms for slot mutations -->
|
||||||
<div class="hidden">
|
<div class="hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user