Extracts sanitizeForCssUrl helper that strips '"()\ before the URL
is embedded in url("..."). Prevents CSS injection via the hero image
field in inline style bindings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
413 lines
10 KiB
Svelte
413 lines
10 KiB
Svelte
<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: 10px;
|
||
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(--yellow), 0 1px 3px rgba(28,28,24,.06);
|
||
}
|
||
.scene-today:hover {
|
||
box-shadow: 0 0 0 2px var(--yellow), 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: 0.42;
|
||
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: 10px;
|
||
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(--yellow) !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: 10px;
|
||
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>
|