Rejects non-allowlisted types (only JPEG, PNG, GIF, WebP accepted) with an inline error message. Uses image/bmp as test vector since it passes accept="image/*" but is not in the allowed set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
369 lines
12 KiB
Svelte
369 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { enhance } from '$app/forms';
|
|
|
|
type Category = { id: string; name: string; tagType?: string };
|
|
|
|
type EditRecipe = {
|
|
id: string;
|
|
name: string;
|
|
serves?: number;
|
|
cookTimeMin?: number;
|
|
effort?: string;
|
|
heroImageUrl?: string;
|
|
ingredients: { name: string; quantity: number; unit: string }[];
|
|
steps: { instruction: string }[];
|
|
tagIds: string[];
|
|
} | null;
|
|
|
|
const { recipe, categories, action }: {
|
|
recipe: EditRecipe;
|
|
categories: Category[];
|
|
action: string;
|
|
} = $props();
|
|
|
|
const effortOptions = [
|
|
{ label: 'Leicht', value: 'easy' },
|
|
{ label: 'Mittel', value: 'medium' },
|
|
{ label: 'Schwer', value: 'hard' }
|
|
];
|
|
|
|
const initial = (() => $state.snapshot(recipe))();
|
|
|
|
const TAG_TYPE_LABELS: Record<string, string> = {
|
|
dietary: 'Ernährung',
|
|
cuisine: 'Küche',
|
|
protein: 'Protein',
|
|
other: 'Sonstiges'
|
|
};
|
|
|
|
const groupedCategories = $derived(
|
|
Object.entries(
|
|
categories.reduce<Record<string, typeof categories>>((acc, cat) => {
|
|
const type = cat.tagType ?? 'other';
|
|
(acc[type] ??= []).push(cat);
|
|
return acc;
|
|
}, {})
|
|
)
|
|
);
|
|
|
|
let name = $state(initial?.name ?? '');
|
|
let serves = $state<number | ''>(initial?.serves ?? '');
|
|
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
|
|
let effort = $state(initial?.effort ?? '');
|
|
let selectedTagIds = $state<string[]>(initial?.tagIds ? [...initial.tagIds] : []);
|
|
let ingredients = $state(
|
|
initial?.ingredients.map((ing) => ({
|
|
name: ing.name,
|
|
quantity: ing.quantity as number | '',
|
|
unit: ing.unit
|
|
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
|
|
);
|
|
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
|
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
|
|
let imageError = $state<string | null>(null);
|
|
|
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
|
|
|
function handleImageChange(e: Event) {
|
|
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
if (file.size > MAX_IMAGE_BYTES) {
|
|
imageError = 'Datei zu groß. Maximal 5 MB erlaubt.';
|
|
(e.currentTarget as HTMLInputElement).value = '';
|
|
return;
|
|
}
|
|
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
|
imageError = 'Dateityp nicht unterstützt. Erlaubt: JPEG, PNG, GIF, WebP.';
|
|
(e.currentTarget as HTMLInputElement).value = '';
|
|
return;
|
|
}
|
|
imageError = null;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
heroImageUrl = reader.result as string;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
</script>
|
|
|
|
<form method="POST" {action} use:enhance>
|
|
<!-- Error banner -->
|
|
{#if $page.form?.error}
|
|
<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}
|
|
|
|
<!-- Two-column layout -->
|
|
<div class="md:flex md:gap-[32px]">
|
|
<!-- 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>
|
|
|
|
<div class="mb-[16px]">
|
|
<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>
|
|
|
|
<div class="mb-[16px]">
|
|
<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 -->
|
|
<div class="mb-[24px]">
|
|
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">
|
|
Schwierigkeitsgrad
|
|
</p>
|
|
<div class="flex flex-wrap gap-[8px]">
|
|
{#each effortOptions as opt (opt.value)}
|
|
<label
|
|
class={[
|
|
'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>
|
|
|
|
<!-- Image -->
|
|
<div class="mb-[24px]">
|
|
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Bild</p>
|
|
{#if heroImageUrl}
|
|
<img
|
|
src={heroImageUrl}
|
|
alt=""
|
|
class="mb-[8px] max-h-[200px] w-full rounded-[var(--radius-md)] object-cover"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onclick={() => (heroImageUrl = null)}
|
|
class="mb-[8px] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
|
>
|
|
Bild entfernen
|
|
</button>
|
|
{/if}
|
|
<label
|
|
class="block w-full cursor-pointer rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] text-center text-[13px] text-[var(--color-text-muted)]"
|
|
>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onchange={handleImageChange}
|
|
class="sr-only"
|
|
/>
|
|
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
|
|
</label>
|
|
{#if imageError}
|
|
<p class="mt-[6px] text-[12px] text-[var(--color-error)]">{imageError}</p>
|
|
{:else}
|
|
<p class="mt-[6px] text-[11px] text-[var(--color-text-muted)]">Max. 5 MB</p>
|
|
{/if}
|
|
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
|
|
</div>
|
|
|
|
<!-- Ingredients -->
|
|
<div class="mb-[24px]">
|
|
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
|
|
<div class="flex flex-col gap-[8px]">
|
|
{#each ingredients as ing, i (i)}
|
|
<div class="flex items-center gap-[8px]">
|
|
<input
|
|
type="number"
|
|
bind:value={ing.quantity}
|
|
placeholder="Menge"
|
|
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"
|
|
/>
|
|
<input
|
|
type="text"
|
|
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"
|
|
/>
|
|
<input
|
|
type="text"
|
|
bind:value={ing.name}
|
|
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"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}
|
|
class="shrink-0 text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
|
>
|
|
Entfernen
|
|
</button>
|
|
</div>
|
|
{/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>
|
|
</div>
|
|
|
|
<!-- Steps -->
|
|
<div class="mb-[24px]">
|
|
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Schritte</p>
|
|
<div class="flex flex-col gap-[12px]">
|
|
{#each steps as _, i (i)}
|
|
<div class="flex items-start gap-[12px]">
|
|
<span
|
|
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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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-[16px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
|
|
{#each groupedCategories as [type, tags] (type)}
|
|
<div class="mb-[16px] last:mb-0">
|
|
<p class="mb-[8px] text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
|
{TAG_TYPE_LABELS[type] ?? type}
|
|
</p>
|
|
<div class="flex flex-wrap gap-[8px]">
|
|
{#each tags 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>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden inputs for form submission -->
|
|
<input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} />
|
|
<input type="hidden" name="stepsJson" value={JSON.stringify(steps)} />
|
|
|
|
<!-- Footer -->
|
|
<div class="mt-[32px] flex items-center justify-between">
|
|
<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>
|