feat(planner): add DesktopDayTile flip-tile component

CSS 3D card flip with scene/card/front/back structure. Filled slots
show gradient/image front face and action back face (Koch-Modus,
tauschen, entfernen). Empty slots delegate to EmptyDayTile.
Sibling dimming and aria-expanded via activeSlotId prop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:51:21 +02:00
parent 2b7a7cceec
commit d20cd53be2
2 changed files with 373 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import EmptyDayTile from './EmptyDayTile.svelte';
interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
interface SlotRecipe {
id?: string;
name?: string;
effort?: string;
cookTimeMin?: number;
heroImageUrl?: string | null;
tags?: TagItem[];
}
interface Slot {
id?: string | null;
slotDate?: string;
recipe?: SlotRecipe | null;
}
interface Suggestion {
recipe: any;
scoreDelta: number;
hasConflict: boolean;
}
let {
slot,
isToday,
activeSlotId,
isPlanner,
slotMap,
suggestions,
onflip,
onclose,
onswap,
onremove,
onaddrecipe
}: {
slot: Slot;
isToday: boolean;
activeSlotId: string | null;
isPlanner: boolean;
slotMap: Record<string, any>;
suggestions: Suggestion[];
onflip?: (slotId: string) => void;
onclose?: () => void;
onswap?: () => void;
onremove?: () => void;
onaddrecipe?: () => void;
} = $props();
const slotId = $derived(slot.id ?? '');
const isFlipped = $derived(activeSlotId === slot.id && !!slot.recipe);
const isDimmed = $derived(activeSlotId !== null && activeSlotId !== slot.id && !!slot.recipe);
function handleFlip() {
onflip?.(slotId);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onflip?.(slotId);
}
}
const gradientBackground = $derived((() => {
if (!slot.recipe) return 'var(--color-surface)';
if (slot.recipe.heroImageUrl) return `url(${slot.recipe.heroImageUrl})`;
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
if (proteinTag?.name) {
return `var(--gradient-protein-${proteinTag.name.toLowerCase()})`;
}
return 'var(--color-surface)';
})());
</script>
{#if slot.recipe}
<div
data-testid="day-meal-card"
role="button"
tabindex="0"
aria-label={slot.recipe?.name ?? 'Gericht'}
aria-expanded={isFlipped}
data-today={isToday}
data-flipped={isFlipped}
data-dimmed={isDimmed}
class="scene"
onclick={handleFlip}
onkeydown={handleKeydown}
>
<div class="card" class:flipped={isFlipped}>
<div
class="card-front"
style="background: {gradientBackground}; background-size: cover; background-position: center;"
>
<p style="font-family: var(--font-display); font-size: 13px; padding: 8px; margin: 0; color: var(--color-text);">
{slot.recipe.name}
</p>
</div>
<div class="card-back" aria-hidden={!isFlipped}>
<button
type="button"
aria-label="Schließen"
onclick={(e) => { e.stopPropagation(); onclose?.(); }}
>
×
</button>
{#if slot.recipe.cookTimeMin}
<span style="font-size: 12px;">{slot.recipe.cookTimeMin} min</span>
{/if}
{#if slot.recipe.effort}
<span style="font-size: 12px; margin-left: 4px;">{slot.recipe.effort}</span>
{/if}
<div style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;">
<a href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
<a href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
</div>
{#if isPlanner}
<button
type="button"
onclick={(e) => { e.stopPropagation(); onswap?.(); }}
style="margin-top: 8px; display: block;"
>
Gericht tauschen
</button>
{/if}
{#if isPlanner && slot.id}
<button
type="button"
onclick={(e) => { e.stopPropagation(); onremove?.(); }}
style="margin-top: 4px; display: block;"
>
Entfernen
</button>
{/if}
</div>
</div>
</div>
{:else}
<EmptyDayTile
slotDate={slot.slotDate ?? ''}
slotId={slot.id ?? ''}
{isPlanner}
{slotMap}
topSuggestion={undefined}
{onaddrecipe}
/>
{/if}
<style>
.scene {
perspective: 900px;
height: 100%;
width: 100%;
cursor: pointer;
}
.card {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 10px;
}
.card.flipped {
transform: rotateY(180deg);
}
.card-front,
.card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
border-radius: 10px;
overflow: hidden;
}
.card-back {
transform: rotateY(180deg);
background: var(--color-page);
border: 1px solid var(--color-border);
padding: 10px;
overflow-y: auto;
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
</style>