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:
201
frontend/src/lib/planner/DesktopDayTile.svelte
Normal file
201
frontend/src/lib/planner/DesktopDayTile.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user