feat: align recipe edit page with viewer design

Replace CardAdd with EditTitleImgParallax so /rezepte/edit/[name]
mirrors the hero-parallax layout of /rezepte/[name]. Add Lucide icons
and a width-constrained info-card grid to CreateStepList's additional
info section. Refactor the English translation view to use semantic
theme vars, side-by-side German/English field comparison, and a card
aesthetic matching the rest of the site.

Fix portions binding bug where the shared portions store was being
written by both language ingredient lists. CreateIngredientList now
accepts a bindable portions prop; the English list uses it with
useStore=false to stay isolated from the German value.
This commit is contained in:
2026-04-12 21:22:45 +02:00
parent 69c2e05462
commit f108e9ceaa
7 changed files with 1533 additions and 637 deletions
@@ -0,0 +1,557 @@
<script lang="ts">
import Cross from '$lib/assets/icons/Cross.svelte';
import { toast } from '$lib/js/toast.svelte';
import { onMount, type Snippet } from 'svelte';
type CardData = {
icon?: string;
category?: string;
name?: string;
description?: string;
tags?: string[];
};
type Props = {
card_data: CardData;
image_preview_url: string;
selected_image_file: File | null;
color?: string;
titleExtras?: Snippet;
children?: Snippet;
};
let {
card_data = $bindable(),
image_preview_url = $bindable(''),
selected_image_file = $bindable<File | null>(null),
color = '',
titleExtras,
children
}: Props = $props();
const ALLOWED_MIME = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
const MAX_SIZE = 5 * 1024 * 1024;
let fileInput: HTMLInputElement;
let new_tag = $state('');
if (!card_data.tags) card_data.tags = [];
function handleFileSelect(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!ALLOWED_MIME.includes(file.type)) {
toast.error('Ungültiger Dateityp. Bitte JPEG, PNG oder WebP hochladen.');
input.value = '';
return;
}
if (file.size > MAX_SIZE) {
toast.error(
`Datei zu gross. Maximum 5 MB, deine Datei ${(file.size / 1024 / 1024).toFixed(2)} MB.`
);
input.value = '';
return;
}
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
image_preview_url = URL.createObjectURL(file);
selected_image_file = file;
}
function clearSelectedImage() {
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
image_preview_url = '';
selected_image_file = null;
if (fileInput) fileInput.value = '';
}
function triggerFilePicker() {
fileInput?.click();
}
onMount(() => {
if (image_preview_url && !image_preview_url.startsWith('blob:')) {
const img = new Image();
img.onload = () => {
if (img.naturalWidth === 150 && img.naturalHeight === 150) image_preview_url = '';
};
img.onerror = () => {
image_preview_url = '';
};
img.src = image_preview_url;
}
});
function addTag() {
const t = new_tag.trim();
if (t && !card_data.tags!.includes(t)) {
card_data.tags = [...card_data.tags!, t];
}
new_tag = '';
}
function removeTag(tag: string) {
card_data.tags = card_data.tags!.filter((x) => x !== tag);
}
function onTagKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}
</script>
<section class="section">
<figure class="image-container">
<button
type="button"
class="image-wrap"
onclick={triggerFilePicker}
style:background-color={color || 'var(--color-bg-elevated)'}
aria-label={image_preview_url ? 'Bild ersetzen' : 'Bild hochladen'}
>
{#if image_preview_url}
<img class="image" src={image_preview_url} alt="" />
{/if}
<div class="upload-overlay" class:empty={!image_preview_url}>
<svg
class="camera"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
aria-hidden="true"
>
<path
d="M149.1 64.8L138.7 96H64C28.7 96 0 124.7 0 160V416c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V160c0-35.3-28.7-64-64-64H373.3L362.9 64.8C356.4 45.2 338.1 32 317.4 32H194.6c-20.7 0-39 13.2-45.5 32.8zM256 192a96 96 0 1 1 0 192 96 96 0 1 1 0-192z"
/>
</svg>
<span class="upload-label">
{image_preview_url ? 'Bild ersetzen' : 'Bild hochladen'}
</span>
</div>
</button>
{#if selected_image_file}
<button
type="button"
class="clear-img"
onclick={clearSelectedImage}
title="Auswahl verwerfen"
aria-label="Auswahl verwerfen"
>
<Cross fill="white" width="1.25rem" height="1.25rem" />
</button>
{/if}
<input
bind:this={fileInput}
type="file"
accept="image/webp,image/jpeg,image/jpg,image/png"
onchange={handleFileSelect}
class="file-input"
tabindex="-1"
aria-hidden="true"
/>
</figure>
<div class="content">
<div class="title" style="view-transition-name: recipe-title">
<input
class="category g-pill g-btn-dark"
placeholder="Kategorie…"
bind:value={card_data.category}
aria-label="Kategorie"
/>
<input
class="icon g-icon-badge"
placeholder="🥫"
bind:value={card_data.icon}
aria-label="Icon"
maxlength="4"
/>
<input
class="name"
placeholder="Rezeptname…"
bind:value={card_data.name}
aria-label="Rezeptname"
/>
<p
class="description"
contenteditable="plaintext-only"
bind:innerText={card_data.description}
data-placeholder="Kurzbeschreibung…"
aria-label="Kurzbeschreibung"
></p>
<h2 class="section-label">Stichwörter</h2>
<div class="tags center">
{#each card_data.tags ?? [] as tag (tag)}
<button
type="button"
class="g-tag tag-chip"
onclick={() => removeTag(tag)}
aria-label={`Stichwort ${tag} entfernen`}
>
<span>{tag}</span><span class="x" aria-hidden="true">×</span>
</button>
{/each}
<label class="g-tag tag-add">
<span aria-hidden="true">+</span>
<input
type="text"
bind:value={new_tag}
onkeydown={onTagKey}
onblur={addTag}
placeholder="neu…"
size="1"
aria-label="Neues Stichwort"
/>
</label>
</div>
{#if titleExtras}{@render titleExtras()}{/if}
</div>
{#if children}{@render children()}{/if}
</div>
</section>
<style>
.section {
--scale: 0.3;
margin-bottom: -20vh;
margin-top: calc(-3.5rem - 12px - env(safe-area-inset-top, 0px));
transform-origin: center top;
transform: scaleY(calc(1 - var(--scale)));
}
@media (prefers-reduced-motion) {
.section {
--scale: 0;
}
}
.section > :global(*) {
transform-origin: center top;
transform: scaleY(calc(1 / (1 - var(--scale))));
}
.content {
position: relative;
margin: 30vh auto 0;
}
.image-container {
position: sticky;
top: 0;
height: max(55dvh, 540px);
z-index: -10;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.image-wrap {
all: unset;
box-sizing: border-box;
display: block;
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: min(calc(1000px + 2rem), 100dvw);
height: max(65dvh, 640px);
overflow: hidden;
cursor: pointer;
}
.image {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 20%;
}
.upload-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: white;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.15) 0%,
rgba(0, 0, 0, 0.45) 100%
);
opacity: 0;
transition: opacity 200ms ease;
font-size: 1rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 600;
}
.image-wrap:hover .upload-overlay,
.image-wrap:focus-visible .upload-overlay {
opacity: 1;
}
.upload-overlay.empty {
opacity: 1;
background: color-mix(in srgb, var(--color-primary) 65%, transparent);
}
.camera {
width: 2.5rem;
height: 2.5rem;
fill: white;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4));
}
.upload-label {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.clear-img {
position: absolute;
top: calc(1rem + env(safe-area-inset-top, 0px));
right: 1rem;
background: rgba(0, 0, 0, 0.55);
border: none;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
z-index: 5;
transition:
transform 150ms ease,
background 150ms ease;
backdrop-filter: blur(6px);
}
.clear-img:hover,
.clear-img:focus-visible {
background: var(--red);
transform: scale(1.08);
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.title {
position: relative;
width: min(800px, 80vw);
margin-inline: auto;
background-color: var(--color-bg-tertiary);
padding: 1rem 2rem 1.5rem;
translate: 0 1px;
z-index: 1;
}
.category {
--size: 1.75rem;
position: absolute;
top: calc(-1 * var(--size));
left: calc(-1.5 * var(--size));
font-size: var(--size);
padding: calc(var(--size) * 2 / 3);
border: none;
outline: none;
max-width: min(16rem, 70%);
text-align: center;
transition: var(--transition-fast);
}
.category::placeholder {
color: var(--color-text-tertiary);
font-style: italic;
opacity: 0.8;
}
.category:hover,
.category:focus-visible {
scale: 1.04;
}
.icon {
position: absolute;
top: -1em;
right: -0.75em;
padding: 0.5em;
font-size: 1.5rem;
background-color: var(--color-bg-tertiary);
text-align: center;
border: none;
outline: none;
width: 2.6rem;
height: 2.6rem;
box-sizing: border-box;
transition: var(--transition-fast);
}
.icon::placeholder {
opacity: 0.5;
}
.icon:hover,
.icon:focus-visible {
scale: 1.15;
}
.name {
all: unset;
display: block;
width: 100%;
box-sizing: border-box;
text-align: center;
padding-block: 0.5em;
margin: 0;
font-size: 3rem;
font-weight: 600;
line-height: 1.1;
color: var(--color-text-primary);
overflow-wrap: break-word;
text-wrap: balance;
border-bottom: 1px dashed transparent;
transition: border-color 200ms ease;
}
.name::placeholder {
color: var(--color-text-tertiary);
font-style: italic;
font-weight: 400;
}
.name:hover,
.name:focus-visible {
border-bottom-color: var(--color-border);
}
.description {
text-align: center;
margin: -0.25em 0 1.75em;
padding: 0.25em 0.5em;
color: var(--color-text-secondary);
font-size: 1.05rem;
min-height: 1.2em;
outline: none;
border-bottom: 1px dashed transparent;
transition: border-color 200ms ease;
}
.description:hover,
.description:focus {
border-bottom-color: var(--color-border);
}
.description:empty::before {
content: attr(data-placeholder);
color: var(--color-text-tertiary);
font-style: italic;
}
.section-label {
font-size: 1.2rem;
font-weight: 700;
text-align: center;
margin-block: 1.25rem 0.5rem;
color: var(--color-text-primary);
}
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
margin-block: 0.5rem 1rem;
font-size: 1.05rem;
}
.tags.center {
justify-content: center;
}
.tag-chip {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.25em 0.9em;
border-radius: var(--radius-pill);
background: var(--color-bg-elevated);
color: var(--color-text-primary);
transition: var(--transition-fast);
}
.tag-chip .x {
opacity: 0.55;
font-size: 1.1em;
line-height: 1;
}
.tag-chip:hover,
.tag-chip:focus-visible {
background: var(--red);
color: white;
transform: scale(1.05);
}
.tag-chip:hover .x,
.tag-chip:focus-visible .x {
opacity: 1;
}
.tag-add {
display: inline-flex;
align-items: center;
gap: 0.35em;
background: transparent;
border: 1px dashed var(--color-border);
color: var(--color-text-secondary);
padding: 0.15em 0.7em;
border-radius: var(--radius-pill);
}
.tag-add input {
all: unset;
min-width: 6ch;
color: var(--color-text-primary);
font-size: inherit;
}
.tag-add input::placeholder {
color: var(--color-text-tertiary);
font-style: italic;
}
.tag-add:focus-within {
border-style: solid;
border-color: var(--color-primary);
}
@media screen and (max-width: 800px) {
.title {
width: 100%;
}
.icon {
right: 1rem;
top: -1.75rem;
}
.category {
left: 1rem;
top: calc(var(--size) * -1.5);
}
.name {
font-size: 2.2rem;
}
}
:global(::view-transition-new(recipe-title)) {
animation: slide-up 0.35s ease both;
}
:global(::view-transition-old(recipe-title)) {
animation: slide-down 0.25s ease both;
}
@keyframes slide-up {
from {
transform: translateY(var(--title-slide, 100vh));
}
}
@keyframes slide-down {
to {
transform: translateY(var(--title-slide, 100vh));
}
}
</style>