feat(recipes): style RecipeForm with design system + split-panel layout

- Full design system tokens: inputs, labels, chips, buttons
- Effort and category chips as pill-style radio/checkbox
- Desktop two-column split-panel: form left, categories right (280px)
- Ingredient rows: quantity/unit/name flex layout with remove ghost button
- Steps with numbered circle indicator
- Add use:enhance for SPA experience without full page reload
- Footer: cancel link left, primary save button right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 10:35:35 +02:00
parent e4d3008139
commit 33f3b30cb4

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { enhance } from '$app/forms';
type Category = { id: string; name: string; tagType?: string }; type Category = { id: string; name: string; tagType?: string };
@@ -27,8 +28,6 @@
{ label: 'Schwer', value: 'Hard' } { label: 'Schwer', value: 'Hard' }
]; ];
// Take a plain snapshot of the initial prop value so local state is
// initialised once and not tied to ongoing prop reactivity.
const initial = (() => $state.snapshot(recipe))(); const initial = (() => $state.snapshot(recipe))();
let name = $state(initial?.name ?? ''); let name = $state(initial?.name ?? '');
@@ -46,96 +45,238 @@
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']); let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
</script> </script>
<form method="POST" {action}> <form method="POST" {action} use:enhance>
<!-- Error banner --> <!-- Error banner -->
{#if $page.form?.error} {#if $page.form?.error}
<div role="alert">{$page.form.error}</div> <div
role="alert"
class="mb-[20px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]"
>
{$page.form.error}
</div>
{/if} {/if}
<!-- Basic info --> <!-- Two-column layout -->
<label for="name">Name</label> <div class="md:flex md:gap-[32px]">
<input id="name" name="name" type="text" bind:value={name} required /> <!-- Left column: main form fields -->
<div class="md:flex-1">
<!-- Basic info -->
<div class="mb-[24px]">
<div class="mb-[16px]">
<label
for="name"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Name
</label>
<input
id="name"
name="name"
type="text"
bind:value={name}
required
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
<label for="serves">Portionen</label> <div class="mb-[16px]">
<input id="serves" name="serves" type="number" bind:value={serves} /> <label
for="serves"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Portionen
</label>
<input
id="serves"
name="serves"
type="number"
bind:value={serves}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
<label for="cookTimeMin">Kochzeit</label> <div class="mb-[16px]">
<input id="cookTimeMin" name="cookTimeMin" type="number" bind:value={cookTimeMin} /> <label
for="cookTimeMin"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Kochzeit
</label>
<input
id="cookTimeMin"
name="cookTimeMin"
type="number"
bind:value={cookTimeMin}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
</div>
<!-- Effort chips --> <!-- Effort chips -->
<fieldset> <div class="mb-[24px]">
<legend>Schwierigkeitsgrad</legend> <p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">
{#each effortOptions as opt (opt.value)} Schwierigkeitsgrad
<label> </p>
<input type="radio" name="effort" value={opt.value} bind:group={effort} /> <div class="flex flex-wrap gap-[8px]">
{opt.label} {#each effortOptions as opt (opt.value)}
</label> <label
{/each} class={[
</fieldset> 'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
effort === opt.value
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
].join(' ')}
>
<input
type="radio"
name="effort"
value={opt.value}
bind:group={effort}
class="sr-only"
/>
{opt.label}
</label>
{/each}
</div>
</div>
<!-- Category chips --> <!-- Ingredients -->
<fieldset> <div class="mb-[24px]">
<legend>Kategorien</legend> <p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
{#each categories as cat (cat.id)} <div class="flex flex-col gap-[8px]">
<label> {#each ingredients as ing, i (i)}
<input <div class="flex items-center gap-[8px]">
type="checkbox" <input
name="tagIds" type="number"
value={cat.id} bind:value={ing.quantity}
checked={selectedTagIds.includes(cat.id)} placeholder="Menge"
onchange={(e) => { class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
if (e.currentTarget.checked) { />
selectedTagIds = [...selectedTagIds, cat.id]; <input
} else { type="text"
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id); bind:value={ing.unit}
} placeholder="Einheit"
}} class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/> />
{cat.name} <input
</label> type="text"
{/each} bind:value={ing.name}
</fieldset> placeholder="Zutat"
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
<!-- Ingredients --> />
<fieldset> <button
<legend>Zutaten</legend> type="button"
{#each ingredients as ing, i (i)} onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}
<div> class="shrink-0 text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
<input type="number" bind:value={ing.quantity} placeholder="Menge" /> >
<input type="text" bind:value={ing.unit} placeholder="Einheit" /> Entfernen
<input type="text" bind:value={ing.name} placeholder="Zutat" /> </button>
<button type="button" onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}> </div>
Entfernen {/each}
</div>
<button
type="button"
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '' as number | '', unit: '' }])}
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
>
Zutat hinzufügen
</button> </button>
</div> </div>
{/each}
<button
type="button"
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '', unit: '' }])}
>
Zutat hinzufügen
</button>
</fieldset>
<!-- Steps --> <!-- Steps -->
<fieldset> <div class="mb-[24px]">
<legend>Schritte</legend> <p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Schritte</p>
{#each steps as _, i (i)} <div class="flex flex-col gap-[12px]">
<div> {#each steps as _, i (i)}
<textarea bind:value={steps[i]} placeholder="Schritt beschreiben…"></textarea> <div class="flex items-start gap-[12px]">
<button type="button" onclick={() => (steps = steps.filter((_, j) => j !== i))}> <span
Entfernen class="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[var(--green-tint)] text-[12px] font-medium text-[var(--green-dark)]"
>
{i + 1}
</span>
<div class="flex flex-1 flex-col gap-[6px]">
<textarea
bind:value={steps[i]}
placeholder="Schritt beschreiben…"
rows="3"
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none resize-none"
></textarea>
<button
type="button"
onclick={() => (steps = steps.filter((_, j) => j !== i))}
class="self-start text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
>
Entfernen
</button>
</div>
</div>
{/each}
</div>
<button
type="button"
onclick={() => (steps = [...steps, ''])}
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
>
Schritt hinzufügen
</button> </button>
</div> </div>
{/each} </div>
<button type="button" onclick={() => (steps = [...steps, ''])}>Schritt hinzufügen</button>
</fieldset> <!-- Right panel: categories -->
<div class="md:w-[280px] md:flex-shrink-0 mt-[24px] md:mt-0">
<div
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]"
>
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
<div class="flex flex-wrap gap-[8px]">
{#each categories as cat (cat.id)}
<label
class={[
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
selectedTagIds.includes(cat.id)
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
].join(' ')}
>
<input
type="checkbox"
name="tagIds"
value={cat.id}
checked={selectedTagIds.includes(cat.id)}
onchange={(e) => {
if (e.currentTarget.checked) {
selectedTagIds = [...selectedTagIds, cat.id];
} else {
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
}
}}
class="sr-only"
/>
{cat.name}
</label>
{/each}
</div>
</div>
</div>
</div>
<!-- Hidden inputs for form submission --> <!-- Hidden inputs for form submission -->
<input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} /> <input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} />
<input type="hidden" name="stepsJson" value={JSON.stringify(steps)} /> <input type="hidden" name="stepsJson" value={JSON.stringify(steps)} />
<!-- Footer --> <!-- Footer -->
<a href="/recipes">Abbrechen</a> <div class="mt-[32px] flex items-center justify-between">
<button type="submit">Speichern</button> <a
href="/recipes"
class="text-[13px] font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
Abbrechen
</a>
<button
type="submit"
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white cursor-pointer"
>
Speichern
</button>
</div>
</form> </form>