Files
mealprep/frontend/src/lib/planner/DesktopDayTile.svelte
Marcel Raddatz 3f9bd2b226 refactor(planner): replace hardcoded values with design tokens
- border-radius: 10px → var(--radius-lg) in both tile components
- opacity: 0.42 → var(--opacity-dimmed) in DesktopDayTile
- var(--yellow) → var(--color-ring-today) for today ring and date circle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:17:50 +02:00

413 lines
10 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import EmptyDayTile from './EmptyDayTile.svelte';
import { formatDayAbbr } from '$lib/planner/week';
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils';
let {
slot,
isToday,
activeSlotId,
isPlanner,
slotMap,
suggestions,
topSuggestion,
onflip,
onclose,
onswap,
onremove,
onaddrecipe
}: {
slot: Slot;
isToday: boolean;
activeSlotId: string | null;
isPlanner: boolean;
slotMap: Record<string, any>;
suggestions: Suggestion[];
topSuggestion?: 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);
const dayAbbr = $derived(slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : '');
const dateNum = $derived(slot.slotDate ? slot.slotDate.slice(-2).replace(/^0/, '') : '');
const visibleTags = $derived(
(slot.recipe?.tags ?? []).filter((t) => t.tagType === 'protein' || t.tagType === 'cuisine')
);
const metaLine = $derived((() => {
const parts: string[] = [];
if (slot.recipe?.cookTimeMin) parts.push(`${slot.recipe.cookTimeMin} Min`);
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
return parts.join(' · ');
})());
function handleFlip() {
onflip?.(slotId);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onflip?.(slotId);
}
}
const umlautMap: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
function toCssKey(name: string): string {
return name.toLowerCase().replace(/[äöüß]/g, (c) => umlautMap[c] ?? c);
}
const gradientBackground = $derived((() => {
if (!slot.recipe) return 'var(--color-surface)';
if (slot.recipe.heroImageUrl) return `url("${sanitizeForCssUrl(slot.recipe.heroImageUrl)}")`;
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
if (proteinTag?.name) {
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
}
const cuisineTag = slot.recipe.tags?.find((t) => t.tagType === 'cuisine');
if (cuisineTag?.name) {
return `var(--gradient-cuisine-${toCssKey(cuisineTag.name)})`;
}
return 'var(--color-surface)';
})());
</script>
{#if slot.recipe}
<div
data-testid="day-meal-card-{slot.slotDate ?? ''}"
role="button"
tabindex="0"
aria-label={slot.recipe?.name ?? 'Gericht'}
aria-expanded={isFlipped}
data-today={isToday}
data-flipped={isFlipped}
data-dimmed={isDimmed}
class="scene"
class:scene-today={isToday && !isFlipped}
class:scene-selected={isFlipped}
class:scene-dimmed={isDimmed}
onclick={handleFlip}
onkeydown={handleKeydown}
>
<!-- FRONT -->
<div class="card-front" class:flipped={isFlipped}>
<div
class="card-front-inner"
style="background: {gradientBackground}; background-size: cover; background-position: center;"
>
<!-- Full-tile dual gradient overlay -->
<div class="tile-overlay"></div>
<!-- Day header -->
<div class="tile-head">
<span class="tile-day-abbr">{dayAbbr}</span>
<span class="tile-day-num" class:dn-today={isToday} class:dn-selected={isFlipped}>
{dateNum}
</span>
</div>
<!-- Recipe info at bottom -->
<div class="tile-info">
<p class="tile-name">{slot.recipe.name}</p>
{#if metaLine}
<p class="tile-meta">{metaLine}</p>
{/if}
{#if visibleTags.length > 0}
<div class="tile-tags">
{#each visibleTags as tag (tag.id)}
<span class="tile-tag" class:tag-today={isToday} class:tag-selected={isFlipped}>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
</div> <!-- /.card-front-inner -->
</div>
<!-- BACK -->
<div class="card-back" class:flipped={isFlipped} aria-hidden={!isFlipped}>
<div class="card-back-inner">
<button
type="button"
aria-label="Schließen"
class="btn-close"
onclick={(e) => { e.stopPropagation(); onclose?.(); }}
>
×
</button>
<p class="back-name">{slot.recipe.name}</p>
{#if metaLine}
<p class="back-meta">{metaLine}</p>
{/if}
<div class="back-actions">
<a class="btn-action btn-primary" href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
<a class="btn-action" href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
{#if isPlanner}
<button
type="button"
class="btn-action"
onclick={(e) => { e.stopPropagation(); onswap?.(); }}
>
Gericht tauschen
</button>
{#if slot.id}
<button
type="button"
class="btn-action btn-danger"
onclick={(e) => { e.stopPropagation(); onremove?.(); }}
>
Entfernen
</button>
{/if}
{/if}
</div>
</div> <!-- /.card-back-inner -->
</div>
</div>
{:else}
<EmptyDayTile
slotDate={slot.slotDate ?? ''}
slotId={slot.id ?? ''}
{isPlanner}
{slotMap}
{topSuggestion}
{onaddrecipe}
/>
{/if}
<style>
/* ── Scene (outermost positioned element) ── */
.scene {
perspective: 900px;
height: 100%;
width: 100%;
cursor: pointer;
border-radius: var(--radius-lg);
box-shadow: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
transition: box-shadow .15s, opacity .15s;
}
.scene:hover {
box-shadow: 0 6px 18px rgba(28,28,24,.14), 0 2px 6px rgba(28,28,24,.08);
}
.scene-today {
box-shadow: 0 0 0 2px var(--color-ring-today), 0 1px 3px rgba(28,28,24,.06);
}
.scene-today:hover {
box-shadow: 0 0 0 2px var(--color-ring-today), 0 6px 18px rgba(28,28,24,.14);
}
.scene-selected {
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
}
/* Keep ring visible on hover — :hover alone has higher specificity than .scene-selected */
.scene-selected:hover {
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
}
.scene-dimmed {
opacity: var(--opacity-dimmed);
pointer-events: none;
}
/* ── Card flip — independent face transforms, no preserve-3d ──
preserve-3d + box-shadow/transition on parent causes Chrome to
fail backface-visibility:hidden. Rotating each face independently
avoids the 3D context entirely. */
.card-front,
.card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
.card-front { transform: rotateY(0deg); }
.card-front.flipped { transform: rotateY(-180deg); }
.card-back { transform: rotateY(180deg); pointer-events: none; }
.card-back.flipped { transform: rotateY(0deg); pointer-events: auto; }
.card-front.flipped { pointer-events: none; }
.card-front-inner {
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
overflow: hidden;
}
/* ── Front face ── */
.tile-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0,0,0,.32) 0%,
rgba(0,0,0,0) 30%,
rgba(0,0,0,0) 45%,
rgba(0,0,0,.55) 100%
);
border-radius: inherit;
}
.tile-head {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 8px;
z-index: 2;
}
.tile-day-abbr {
font-size: 13px;
text-transform: uppercase;
letter-spacing: .06em;
color: rgba(255,255,255,.85);
font-weight: 500;
}
.tile-day-num {
width: 20px; height: 20px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
color: rgba(255,255,255,.9);
background: rgba(255,255,255,.22);
}
.dn-today {
background: var(--color-ring-today) !important;
color: #fff !important;
}
.dn-selected {
background: var(--green-dark) !important;
color: #fff !important;
}
.tile-info {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 8px 9px 9px;
z-index: 2;
}
.tile-name {
font-size: 19px;
font-weight: 300;
color: #fff;
line-height: 1.3;
margin: 0;
text-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.tile-meta {
font-size: 14px;
color: rgba(255,255,255,.75);
margin: 2px 0 0;
}
.tile-tags {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-top: 5px;
}
.tile-tag {
font-size: 12px;
font-weight: 500;
padding: 2px 5px;
border-radius: 2px;
background: rgba(255,255,255,.2);
color: rgba(255,255,255,.92);
backdrop-filter: blur(2px);
}
.tag-today { background: rgba(242,193,46,.35); }
.tag-selected { background: rgba(46,110,57,.45); }
/* ── Back face ── */
.card-back-inner {
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
overflow-y: auto;
background: var(--color-page);
border: 1px solid var(--color-border);
padding: 10px;
display: flex;
flex-direction: column;
}
.btn-close {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: var(--color-text-muted);
padding: 2px 6px;
align-self: flex-end;
margin: -4px -4px 2px 0;
}
.btn-close:hover { color: var(--color-text); }
.back-name {
font-family: var(--font-display);
font-size: 13px;
font-weight: 300;
margin: 0 0 2px;
line-height: 1.3;
color: var(--color-text);
}
.back-meta {
font-size: 10px;
color: var(--color-text-muted);
margin: 0 0 10px;
}
.back-actions {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: auto;
}
.btn-action {
display: block;
width: 100%;
padding: 7px;
border-radius: 6px;
border: 1px solid var(--color-border);
background: #fff;
color: var(--color-text);
font-size: 11px;
font-weight: 500;
letter-spacing: .04em;
text-align: center;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
}
.btn-action:hover { background: var(--color-surface); }
.btn-primary {
background: var(--green-dark);
color: #fff;
border: none;
}
.btn-primary:hover { background: var(--green-dark); filter: brightness(1.1); }
.btn-danger {
color: #dc4c3e;
border-color: #dc4c3e;
background: transparent;
}
.btn-danger:hover { background: rgba(220,76,62,.08); }
@media (prefers-reduced-motion: reduce) {
.card-front, .card-back { transition: none; }
.scene { transition: none; }
}
</style>