refactor(editor): IngredientRow + shared types

IngredientRow rendert eine einzelne editierbare Zutat-Zeile. DraftIng
und DraftStep sind jetzt in recipe-editor-types.ts, damit Parent und
Sub-Components auf dieselbe Form referenzieren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 13:40:10 +02:00
parent c43b1dca87
commit defbb5e24d
3 changed files with 147 additions and 105 deletions

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import { Trash2, ChevronUp, ChevronDown } from 'lucide-svelte';
import type { DraftIng } from './recipe-editor-types';
type Props = {
ing: DraftIng;
idx: number;
total: number;
onmove: (dir: -1 | 1) => void;
onremove: () => void;
};
let { ing, idx, total, onmove, onremove }: Props = $props();
</script>
<li class="ing-row">
<div class="move">
<button
class="move-btn"
type="button"
aria-label="Zutat nach oben"
disabled={idx === 0}
onclick={() => onmove(-1)}
>
<ChevronUp size={14} strokeWidth={2.5} />
</button>
<button
class="move-btn"
type="button"
aria-label="Zutat nach unten"
disabled={idx === total - 1}
onclick={() => onmove(1)}
>
<ChevronDown size={14} strokeWidth={2.5} />
</button>
</div>
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
<button class="del" type="button" aria-label="Zutat entfernen" onclick={onremove}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
<style>
.ing-row {
display: grid;
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
gap: 0.35rem;
align-items: center;
}
.move {
display: flex;
flex-direction: column;
gap: 2px;
}
.move-btn {
width: 28px;
height: 20px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 6px;
cursor: pointer;
color: #555;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.move-btn:hover:not(:disabled) {
background: #f4f8f5;
}
.move-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ing-row input {
padding: 0.5rem 0.55rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.9rem;
min-height: 38px;
font-family: inherit;
min-width: 0;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
@media (max-width: 560px) {
.ing-row {
grid-template-columns: 28px 70px 1fr 40px;
grid-template-areas:
'move qty name del'
'move unit unit del'
'note note note note';
}
.ing-row .move {
grid-area: move;
}
.ing-row .qty {
grid-area: qty;
}
.ing-row .unit {
grid-area: unit;
}
.ing-row .name {
grid-area: name;
}
.ing-row .note {
grid-area: note;
}
.ing-row .del {
grid-area: del;
}
}
</style>

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-svelte'; import { Plus, Trash2 } from 'lucide-svelte';
import type { Recipe, Ingredient, Step } from '$lib/types'; import type { Recipe, Ingredient, Step } from '$lib/types';
import ImageUploadBox from '$lib/components/ImageUploadBox.svelte'; import ImageUploadBox from '$lib/components/ImageUploadBox.svelte';
import IngredientRow from '$lib/components/IngredientRow.svelte';
import type { DraftIng, DraftStep } from '$lib/components/recipe-editor-types';
type Props = { type Props = {
recipe: Recipe; recipe: Recipe;
@@ -34,14 +36,6 @@
let cookMin = $state<number | ''>(untrack(() => recipe.cook_time_min ?? '')); let cookMin = $state<number | ''>(untrack(() => recipe.cook_time_min ?? ''));
let totalMin = $state<number | ''>(untrack(() => recipe.total_time_min ?? '')); let totalMin = $state<number | ''>(untrack(() => recipe.total_time_min ?? ''));
type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
type DraftStep = { text: string };
let ingredients = $state<DraftIng[]>( let ingredients = $state<DraftIng[]>(
untrack(() => untrack(() =>
recipe.ingredients.map((i) => ({ recipe.ingredients.map((i) => ({
@@ -173,35 +167,13 @@
<h2>Zutaten</h2> <h2>Zutaten</h2>
<ul class="ing-list"> <ul class="ing-list">
{#each ingredients as ing, idx (idx)} {#each ingredients as ing, idx (idx)}
<li class="ing-row"> <IngredientRow
<div class="move"> {ing}
<button {idx}
class="move-btn" total={ingredients.length}
type="button" onmove={(dir) => moveIngredient(idx, dir)}
aria-label="Zutat nach oben" onremove={() => removeIngredient(idx)}
disabled={idx === 0} />
onclick={() => moveIngredient(idx, -1)}
>
<ChevronUp size={14} strokeWidth={2.5} />
</button>
<button
class="move-btn"
type="button"
aria-label="Zutat nach unten"
disabled={idx === ingredients.length - 1}
onclick={() => moveIngredient(idx, 1)}
>
<ChevronDown size={14} strokeWidth={2.5} />
</button>
</div>
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
<button class="del" type="button" aria-label="Zutat entfernen" onclick={() => removeIngredient(idx)}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
{/each} {/each}
</ul> </ul>
<button class="add" type="button" onclick={addIngredient}> <button class="add" type="button" onclick={addIngredient}>
@@ -310,46 +282,6 @@
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
.ing-row {
display: grid;
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
gap: 0.35rem;
align-items: center;
}
.move {
display: flex;
flex-direction: column;
gap: 2px;
}
.move-btn {
width: 28px;
height: 20px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 6px;
cursor: pointer;
color: #555;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.move-btn:hover:not(:disabled) {
background: #f4f8f5;
}
.move-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ing-row input {
padding: 0.5rem 0.55rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.9rem;
min-height: 38px;
font-family: inherit;
min-width: 0;
}
.step-row { .step-row {
display: grid; display: grid;
grid-template-columns: 32px 1fr 40px; grid-template-columns: 32px 1fr 40px;
@@ -437,31 +369,4 @@
opacity: 0.6; opacity: 0.6;
cursor: progress; cursor: progress;
} }
@media (max-width: 560px) {
.ing-row {
grid-template-columns: 28px 70px 1fr 40px;
grid-template-areas:
'move qty name del'
'move unit unit del'
'note note note note';
}
.ing-row .move {
grid-area: move;
}
.ing-row .qty {
grid-area: qty;
}
.ing-row .unit {
grid-area: unit;
}
.ing-row .name {
grid-area: name;
}
.ing-row .note {
grid-area: note;
}
.ing-row .del {
grid-area: del;
}
}
</style> </style>

View File

@@ -0,0 +1,8 @@
export type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
export type DraftStep = { text: string };