feat(editor): streamline ingredient entry with compact rows and expandable details
No files matched your search
@@ -46,7 +46,9 @@ Faktoren bleiben als hochpräzise Dezimalstrings erhalten. Neue Zielmengen werde
|
||||
|
||||
Änderungen werden lokal im Browser gesichert, ohne das veröffentlichte Rezept zu verändern. „Rezept speichern“ validiert und überträgt ausdrücklich auf den Server. Der Browser warnt beim Verlassen mit noch nicht auf den Server übertragenen Änderungen. Konflikte mit einer zwischenzeitlich geänderten Fassung werden als 409 zurückgegeben. Lokale Entwürfe bleiben dann erhalten.
|
||||
|
||||
Zutaten und Schritte lassen sich per Drag-Handle sowie per Auf/Ab-Buttons sortieren. So ist die Funktion auch ohne Maus bedienbar. Bilder werden sofort in die lokale Medienablage hochgeladen und können dort später entfernt werden, solange kein Rezept sie verwendet.
|
||||
Zutaten werden als kompakte Liste mit Name, Menge und Einheit erfasst. Abschnitt, Hinweis und die optionale Kennzeichnung lassen sich pro Zeile aufklappen; ausgefüllte Details bleiben als kurze Zusammenfassung sichtbar. Enter im Zutatenname fügt die nächste Zeile hinzu und fokussiert sie direkt. Eine bereits leere Zeile wird wiederverwendet. Neue Zutaten übernehmen den letzten Abschnitt.
|
||||
|
||||
Zutaten und Schritte lassen sich per Drag-Handle sowie per Auf/Ab-Buttons sortieren; bei Zutaten befinden sich die Buttons in den aufgeklappten Details. So ist die Funktion auch ohne Maus bedienbar. Bilder werden sofort in die lokale Medienablage hochgeladen und können dort später entfernt werden, solange kein Rezept sie verwendet.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -60,10 +60,14 @@ Ein erster Docker-Prüflauf deckte einen wiederverwendeten, durch den Containern
|
||||
|
||||
## Visuelle Inspektion
|
||||
|
||||
16 reale Browser-Screenshots liegen in [`docs/screenshots/`](screenshots/): Bibliothek, Rezeptseite, Kochmodus und Favoriten in Desktop/Mobile/Tablet sowie Editor in hellem und dunklem Design.
|
||||
19 reale Browser-Screenshots liegen in [`docs/screenshots/`](screenshots/): Bibliothek, Rezeptseite, Kochmodus und Favoriten in Desktop/Mobile/Tablet sowie Editor und kompakte Zutatenliste in hellem und dunklem Design.
|
||||
|
||||
Tatsächlich angesehen: Desktop- und Mobile-Bibliothek, Tablet-Bibliothek, mobile Rezeptseite, mobiler Kochmodus, Desktop-Editor, mobiler Dark-Mode-Editor und dunkle Favoritenansicht. Dabei wurden eine falsche Suchfeld-Flexrichtung, ein zu schmaler mobiler Infobereich und eine Timer-Anzeigeabweichung nach Start korrigiert. Die aktualisierten Bilder wurden erneut kontrolliert. Browserprüfungen bestätigen fehlenden horizontalen Überlauf in den geprüften Bibliotheks-, Rezept- und Editoransichten.
|
||||
|
||||
### Nachprüfung: kompakter Zutateneditor
|
||||
|
||||
Nach der gestalterischen Überarbeitung wurden TypeScript-Prüfung und Produktionsbuild erneut erfolgreich ausgeführt. Die vier betroffenen Browserabläufe bestanden: vollständige Rezeptanlage/-bearbeitung auf Desktop sowie der Zutateneditor auf Desktop, Smartphone und Tablet. Geprüft wurden aufklappbare Details, Mengen, optionale Zutaten, Fokus nach Hinzufügen, Enter zum Weitererfassen, Reihenfolge per Buttons und Desktop-Drag-and-drop, Entwurfswiederherstellung sowie fehlender horizontaler Überlauf mit und ohne Live-Vorschau. Ein zunächst falsch auf `textbox` statt `combobox` gerichteter Testselektor für die Abschnittsauswahl wurde korrigiert; anschließend bestanden alle vier Tests. Die neuen Screenshots der hellen Desktop-/Smartphone-Ansicht und der dunklen Tablet-Ansicht wurden visuell kontrolliert.
|
||||
|
||||
## Präzise Grenzen
|
||||
|
||||
- Kein Test auf physischem Smartphone, Safari oder Firefox; keine tatsächliche iOS-/Android-Installation über das Betriebssystem-Installationsmenü.
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -1878,16 +1878,6 @@ input[type='checkbox'] {
|
||||
.image-upload label {
|
||||
margin: 0;
|
||||
}
|
||||
.editor-ingredient {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
padding: 17px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.reorder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1904,44 +1894,6 @@ input[type='checkbox'] {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
}
|
||||
.ingredient-fields {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ingredient-main-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 90px minmax(100px, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.ingredient-main-fields label {
|
||||
font-size: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ingredient-secondary-fields {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ingredient-secondary-fields > input {
|
||||
font-size: 10px;
|
||||
padding: 7px;
|
||||
flex: 1;
|
||||
min-width: 50px;
|
||||
}
|
||||
.ingredient-secondary-fields .checkbox-label {
|
||||
font-size: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.ingredient-fields > small {
|
||||
font-size: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.editor-ingredient > .icon-button {
|
||||
width: 25px;
|
||||
height: 30px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.editor-step {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
@@ -2779,38 +2731,9 @@ input[type='checkbox'] {
|
||||
.image-upload > img {
|
||||
height: 180px;
|
||||
}
|
||||
.editor-ingredient {
|
||||
padding: 12px;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
.ingredient-main-fields {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
.ingredient-main-fields > label:nth-child(3) {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.ingredient-secondary-fields {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ingredient-secondary-fields > input {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.editor-ingredient > .icon-button {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 3px;
|
||||
margin: 0;
|
||||
width: 24px;
|
||||
}
|
||||
.reorder {
|
||||
padding-top: 20px;
|
||||
}
|
||||
.ingredient-fields small {
|
||||
font-size: 7px;
|
||||
}
|
||||
.editor-step {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
@@ -15,18 +15,11 @@ import {
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type Recipe,
|
||||
type SavedRecipe,
|
||||
type Ingredient,
|
||||
type Step,
|
||||
emptyRecipe,
|
||||
units,
|
||||
recipeSchema,
|
||||
} from '@/lib/recipe';
|
||||
import { type Recipe, type SavedRecipe, type Step, emptyRecipe, recipeSchema } from '@/lib/recipe';
|
||||
import { api, readLocal, writeLocal, toast } from '@/lib/client';
|
||||
import { uploadImage } from './admin';
|
||||
import { renderStep, ingredientLabel } from '@/lib/calculator';
|
||||
import { IngredientEditor } from './ingredient-editor';
|
||||
export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
|
||||
const router = useRouter(),
|
||||
key = `hamham:draft:${initial?.id ?? 'new'}`;
|
||||
@@ -78,11 +71,6 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
|
||||
dirty.current = true;
|
||||
setRecipe((r) => ({ ...r, [key]: value }));
|
||||
};
|
||||
const updateIngredient = (n: number, patch: Partial<Ingredient>) =>
|
||||
change(
|
||||
'ingredients',
|
||||
recipe.ingredients.map((i, index) => (index === n ? { ...i, ...patch } : i)),
|
||||
);
|
||||
const updateStep = (n: number, patch: Partial<Step>) =>
|
||||
change(
|
||||
'steps',
|
||||
@@ -384,98 +372,15 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
|
||||
</section>
|
||||
)}
|
||||
{tab === 'ingredients' && (
|
||||
<section className="panel editor-panel">
|
||||
<h2>Alles, was du brauchst.</h2>
|
||||
<p className="muted">
|
||||
Menge leer lassen für „nach Geschmack“. Sortieren per Ziehen oder Pfeiltasten.
|
||||
</p>
|
||||
<div className="editor-ingredient-list">
|
||||
{recipe.ingredients.map((i, n) => (
|
||||
<div
|
||||
className="editor-ingredient"
|
||||
key={i.id}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragging.current !== null) move('ingredients', dragging.current, n);
|
||||
}}
|
||||
>
|
||||
{reorder('ingredients', n)}
|
||||
<div className="ingredient-fields">
|
||||
<div className="ingredient-main-fields">
|
||||
<label>
|
||||
Menge
|
||||
<input
|
||||
aria-label={`Menge ${n + 1}`}
|
||||
type="number"
|
||||
step="any"
|
||||
min="0.001"
|
||||
value={i.amount ?? ''}
|
||||
onChange={(e) =>
|
||||
updateIngredient(n, {
|
||||
amount: e.target.value === '' ? null : +e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Einheit
|
||||
<select
|
||||
aria-label={`Einheit ${n + 1}`}
|
||||
value={i.unit}
|
||||
onChange={(e) =>
|
||||
updateIngredient(n, { unit: e.target.value as Ingredient['unit'] })
|
||||
}
|
||||
>
|
||||
{units.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u || 'ohne'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Zutat
|
||||
<input
|
||||
aria-label={`Zutat ${n + 1}`}
|
||||
value={i.name}
|
||||
onChange={(e) => updateIngredient(n, { name: e.target.value })}
|
||||
placeholder="z. B. Kartoffeln"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="ingredient-secondary-fields">
|
||||
<input
|
||||
aria-label={`Abschnitt ${n + 1}`}
|
||||
value={i.section}
|
||||
onChange={(e) => updateIngredient(n, { section: e.target.value })}
|
||||
placeholder="Abschnitt, z. B. Sauce"
|
||||
/>
|
||||
<input
|
||||
aria-label={`Hinweis ${n + 1}`}
|
||||
value={i.note}
|
||||
onChange={(e) => updateIngredient(n, { note: e.target.value })}
|
||||
placeholder="Hinweis, z. B. fein gehackt"
|
||||
/>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={i.optional}
|
||||
onChange={(e) => updateIngredient(n, { optional: e.target.checked })}
|
||||
/>
|
||||
Optional
|
||||
</label>
|
||||
</div>
|
||||
<small className="muted">Referenz: {`{{${i.id}}}`}</small>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button danger"
|
||||
aria-label={`Zutat ${n + 1} entfernen`}
|
||||
disabled={recipe.ingredients.length === 1}
|
||||
onClick={() => {
|
||||
const used = recipe.steps.some(
|
||||
(s) => s.ingredients.includes(i.id) || s.text.includes(`{{${i.id}}}`),
|
||||
);
|
||||
if (used) {
|
||||
<IngredientEditor
|
||||
ingredients={recipe.ingredients}
|
||||
onChange={(ingredients) => change('ingredients', ingredients)}
|
||||
onRemove={(id) => {
|
||||
if (
|
||||
recipe.steps.some(
|
||||
(s) => s.ingredients.includes(id) || s.text.includes(`{{${id}}}`),
|
||||
)
|
||||
) {
|
||||
setError(
|
||||
'Diese Zutat wird in einem Schritt verwendet. Entferne zuerst die Referenz.',
|
||||
);
|
||||
@@ -483,35 +388,10 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
|
||||
}
|
||||
change(
|
||||
'ingredients',
|
||||
recipe.ingredients.filter((x) => x.id !== i.id),
|
||||
recipe.ingredients.filter((i) => i.id !== id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() =>
|
||||
change('ingredients', [
|
||||
...recipe.ingredients,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
amount: null,
|
||||
unit: 'g',
|
||||
section: '',
|
||||
note: '',
|
||||
optional: false,
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus size={18} /> Zutat hinzufügen
|
||||
</button>
|
||||
</section>
|
||||
/>
|
||||
)}
|
||||
{tab === 'steps' && (
|
||||
<section className="panel editor-panel">
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
.ingredient-composer {
|
||||
container: ingredient-editor / inline-size;
|
||||
}
|
||||
.ingredient-composer-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 4px 0 32px;
|
||||
}
|
||||
.ingredient-composer-heading .eyebrow {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ingredient-composer-heading h2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ingredient-composer-heading p {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
.ingredient-count {
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 13px;
|
||||
border-radius: 30px;
|
||||
background: var(--soft-green);
|
||||
color: var(--green);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ingredient-table-head,
|
||||
.ingredient-entry-line {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(120px, 1fr) 88px 88px 38px 38px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.ingredient-table-head {
|
||||
padding-bottom: 12px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ingredient-table-head span:nth-child(2) {
|
||||
padding-left: 10px;
|
||||
}
|
||||
.ingredient-table-head span:nth-child(3),
|
||||
.ingredient-table-head span:nth-child(4) {
|
||||
text-align: center;
|
||||
}
|
||||
.ingredient-entries {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.ingredient-entry {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition:
|
||||
background 0.18s,
|
||||
opacity 0.18s;
|
||||
}
|
||||
.ingredient-entry:focus-within,
|
||||
.ingredient-entry.expanded {
|
||||
background: var(--bg);
|
||||
}
|
||||
.ingredient-entry.dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.ingredient-entry.drop-target {
|
||||
box-shadow: inset 0 2px var(--green);
|
||||
}
|
||||
.ingredient-grip {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
color: var(--muted);
|
||||
cursor: grab;
|
||||
}
|
||||
.ingredient-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.ingredient-entry-name {
|
||||
min-width: 0;
|
||||
}
|
||||
.ingredient-entry-name > input {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding: 10px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.ingredient-entry-name > input:hover {
|
||||
border-color: var(--line);
|
||||
}
|
||||
.ingredient-entry-name > input:focus {
|
||||
background: var(--surface);
|
||||
}
|
||||
.ingredient-entry-line > input,
|
||||
.ingredient-entry-line > select {
|
||||
background: var(--surface-2);
|
||||
border-color: transparent;
|
||||
font-size: 13px;
|
||||
padding: 10px 8px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.ingredient-entry-amount {
|
||||
text-align: center;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.ingredient-entry-amount::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.ingredient-entry-unit {
|
||||
text-align: center;
|
||||
}
|
||||
.ingredient-detail-toggle,
|
||||
.ingredient-remove {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ingredient-detail-toggle:hover,
|
||||
.ingredient-detail-toggle[aria-expanded='true'] {
|
||||
background: var(--soft-green);
|
||||
color: var(--green);
|
||||
}
|
||||
.ingredient-detail-toggle svg {
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
.ingredient-detail-toggle[aria-expanded='true'] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ingredient-remove:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
background: var(--sand);
|
||||
}
|
||||
.ingredient-entry-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
padding: 0 10px 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ingredient-section-label,
|
||||
.ingredient-optional-label {
|
||||
font-size: 9px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
background: var(--soft-green);
|
||||
color: var(--green);
|
||||
}
|
||||
.ingredient-optional-label {
|
||||
background: var(--sand);
|
||||
color: var(--ink);
|
||||
}
|
||||
.ingredient-entry-details {
|
||||
padding: 18px 18px 4px 52px;
|
||||
}
|
||||
.ingredient-detail-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.ingredient-detail-fields label {
|
||||
margin-bottom: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ingredient-detail-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.ingredient-detail-footer .checkbox-label {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ingredient-order-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ingredient-order-actions > span {
|
||||
margin-right: 7px;
|
||||
}
|
||||
.ingredient-order-actions button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ingredient-order-actions button:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
color: var(--green);
|
||||
}
|
||||
.ingredient-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
margin: 18px 0 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--soft-green);
|
||||
color: var(--green);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.ingredient-add > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--surface);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ingredient-add:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ingredient-add kbd {
|
||||
margin-left: auto;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.editor-panel > .ingredient-composer-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
@container ingredient-editor (max-width: 560px) {
|
||||
.ingredient-composer-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.ingredient-composer-heading h2 {
|
||||
font-size: 27px;
|
||||
}
|
||||
.ingredient-composer-heading .eyebrow {
|
||||
font-size: 7px;
|
||||
}
|
||||
.ingredient-count {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.ingredient-table-head {
|
||||
display: none;
|
||||
}
|
||||
.ingredient-entry-line {
|
||||
grid-template-columns: 20px minmax(0, 1fr) 72px 36px 36px;
|
||||
gap: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.ingredient-grip {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.ingredient-entry-name {
|
||||
grid-column: 2 / -1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.ingredient-entry-name > input {
|
||||
font-size: 16px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.ingredient-entry-line > input,
|
||||
.ingredient-entry-line > select {
|
||||
font-size: 16px;
|
||||
}
|
||||
.ingredient-entry-amount {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
.ingredient-entry-unit {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
.ingredient-detail-toggle {
|
||||
grid-column: 4;
|
||||
grid-row: 2;
|
||||
width: 36px;
|
||||
}
|
||||
.ingredient-remove {
|
||||
grid-column: 5;
|
||||
grid-row: 2;
|
||||
width: 36px;
|
||||
}
|
||||
.ingredient-entry-summary {
|
||||
padding-left: 6px;
|
||||
}
|
||||
.ingredient-entry-details {
|
||||
padding: 18px 12px 4px 30px;
|
||||
}
|
||||
.ingredient-detail-fields {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
.ingredient-detail-fields input {
|
||||
font-size: 16px;
|
||||
}
|
||||
.ingredient-detail-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.ingredient-order-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronDown, GripVertical, Leaf, Plus, Trash2 } from 'lucide-react';
|
||||
import { type Ingredient, units } from '@/lib/recipe';
|
||||
import './ingredient-editor.css';
|
||||
|
||||
export function IngredientEditor({
|
||||
ingredients,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
ingredients: Ingredient[];
|
||||
onChange: (ingredients: Ingredient[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<string[]>([]);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const names = useRef(new Map<string, HTMLInputElement>());
|
||||
const pendingFocus = useRef<string | null>(null);
|
||||
const sections = Array.from(new Set(ingredients.map((i) => i.section).filter(Boolean)));
|
||||
const count = ingredients.filter((i) => i.name.trim()).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingFocus.current) {
|
||||
names.current.get(pendingFocus.current)?.focus();
|
||||
pendingFocus.current = null;
|
||||
}
|
||||
}, [ingredients]);
|
||||
|
||||
function update(id: string, patch: Partial<Ingredient>) {
|
||||
onChange(ingredients.map((i) => (i.id === id ? { ...i, ...patch } : i)));
|
||||
}
|
||||
|
||||
function add() {
|
||||
const empty = ingredients.find((i) => !i.name.trim());
|
||||
if (empty) {
|
||||
names.current.get(empty.id)?.focus();
|
||||
return;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
pendingFocus.current = id;
|
||||
onChange([
|
||||
...ingredients,
|
||||
{
|
||||
id,
|
||||
name: '',
|
||||
amount: null,
|
||||
unit: 'g',
|
||||
section: ingredients.at(-1)?.section ?? '',
|
||||
note: '',
|
||||
optional: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function move(id: string, destination: number) {
|
||||
const from = ingredients.findIndex((i) => i.id === id);
|
||||
if (from < 0 || destination < 0 || destination >= ingredients.length || from === destination)
|
||||
return;
|
||||
const next = [...ingredients];
|
||||
next.splice(destination, 0, next.splice(from, 1)[0]);
|
||||
onChange(next);
|
||||
setAnnouncement(
|
||||
`${ingredients[from].name || 'Zutat'} an Position ${destination + 1} verschoben.`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel editor-panel ingredient-composer">
|
||||
<div className="ingredient-composer-heading">
|
||||
<div>
|
||||
<span className="eyebrow">DIE GUTEN DINGE KOMMEN ZUSAMMEN</span>
|
||||
<h2>Alles, was du brauchst.</h2>
|
||||
<p>Zutat für Zutat zu deinem Lieblingsgericht.</p>
|
||||
</div>
|
||||
<span className="ingredient-count">
|
||||
<Leaf size={16} />
|
||||
{count} {count === 1 ? 'Zutat' : 'Zutaten'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ingredient-table-head" aria-hidden="true">
|
||||
<span /> <span>Zutat</span> <span>Menge</span> <span>Einheit</span>
|
||||
</div>
|
||||
<div className="ingredient-entries">
|
||||
{ingredients.map((i, n) => {
|
||||
const open = expanded.includes(i.id);
|
||||
const detailId = `ingredient-details-${i.id}`;
|
||||
return (
|
||||
<div
|
||||
key={i.id}
|
||||
className={`ingredient-entry${open ? ' expanded' : ''}${dragging === i.id ? ' dragging' : ''}${dropTarget === i.id && dragging !== i.id ? ' drop-target' : ''}`}
|
||||
onDragOver={(e) => {
|
||||
if (!dragging) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDropTarget(i.id);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragging) move(dragging, n);
|
||||
setDragging(null);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="ingredient-entry-line">
|
||||
<span
|
||||
className="ingredient-grip"
|
||||
draggable
|
||||
title="Zutat zum Sortieren ziehen"
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', i.id);
|
||||
setDragging(i.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragging(null);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</span>
|
||||
<div className="ingredient-entry-name">
|
||||
<input
|
||||
ref={(element) => {
|
||||
if (element) names.current.set(i.id, element);
|
||||
else names.current.delete(i.id);
|
||||
}}
|
||||
aria-label={`Zutat ${n + 1}`}
|
||||
value={i.name}
|
||||
onChange={(e) => update(i.id, { name: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
if (i.name.trim()) add();
|
||||
}
|
||||
}}
|
||||
placeholder={n === 0 ? 'Was kommt in dein Rezept?' : 'Nächste Zutat …'}
|
||||
/>
|
||||
{!open && (i.section || i.note || i.optional) && (
|
||||
<div className="ingredient-entry-summary">
|
||||
{i.section && <span className="ingredient-section-label">{i.section}</span>}
|
||||
{i.note && <span>{i.note}</span>}
|
||||
{i.optional && <span className="ingredient-optional-label">optional</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
className="ingredient-entry-amount"
|
||||
aria-label={`Menge ${n + 1}`}
|
||||
title="Ohne Menge: nach Geschmack"
|
||||
type="number"
|
||||
step="any"
|
||||
min="0.001"
|
||||
inputMode="decimal"
|
||||
placeholder="–"
|
||||
value={i.amount ?? ''}
|
||||
onChange={(e) =>
|
||||
update(i.id, { amount: e.target.value === '' ? null : +e.target.value })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="ingredient-entry-unit"
|
||||
aria-label={`Einheit ${n + 1}`}
|
||||
value={i.unit}
|
||||
onChange={(e) => update(i.id, { unit: e.target.value as Ingredient['unit'] })}
|
||||
>
|
||||
{units.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u || 'ohne'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="ingredient-detail-toggle"
|
||||
aria-label={`Details für Zutat ${n + 1}`}
|
||||
aria-expanded={open}
|
||||
aria-controls={detailId}
|
||||
title="Abschnitt, Hinweis und weitere Optionen"
|
||||
onClick={() =>
|
||||
setExpanded(open ? expanded.filter((id) => id !== i.id) : [...expanded, i.id])
|
||||
}
|
||||
>
|
||||
<ChevronDown size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ingredient-remove"
|
||||
aria-label={`Zutat ${n + 1} entfernen`}
|
||||
title="Zutat entfernen"
|
||||
disabled={ingredients.length === 1}
|
||||
onClick={() => onRemove(i.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ingredient-entry-details" id={detailId} hidden={!open}>
|
||||
<div className="ingredient-detail-fields">
|
||||
<label>
|
||||
Abschnitt
|
||||
<input
|
||||
aria-label={`Abschnitt ${n + 1}`}
|
||||
list="ingredient-sections"
|
||||
value={i.section}
|
||||
onChange={(e) => update(i.id, { section: e.target.value })}
|
||||
placeholder="z. B. Für die Sauce"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Hinweis
|
||||
<input
|
||||
aria-label={`Hinweis ${n + 1}`}
|
||||
value={i.note}
|
||||
onChange={(e) => update(i.id, { note: e.target.value })}
|
||||
placeholder="z. B. fein gehackt"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="ingredient-detail-footer">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={i.optional}
|
||||
onChange={(e) => update(i.id, { optional: e.target.checked })}
|
||||
/>
|
||||
Optionale Zutat
|
||||
</label>
|
||||
<div className="ingredient-order-actions">
|
||||
<span>Reihenfolge</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Zutat ${n + 1} nach oben`}
|
||||
disabled={n === 0}
|
||||
onClick={() => move(i.id, n - 1)}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Zutat ${n + 1} nach unten`}
|
||||
disabled={n === ingredients.length - 1}
|
||||
onClick={() => move(i.id, n + 1)}
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button type="button" className="ingredient-add" onClick={add}>
|
||||
<span>
|
||||
<Plus size={18} />
|
||||
</span>
|
||||
Zutat hinzufügen<kbd>↵</kbd>
|
||||
</button>
|
||||
<p className="ingredient-composer-hint">
|
||||
Keine feste Menge? Lass das Mengenfeld einfach leer. Details findest du am kleinen Pfeil.
|
||||
</p>
|
||||
<datalist id="ingredient-sections">
|
||||
{sections.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
<span className="visually-hidden" role="status">
|
||||
{announcement}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,16 +10,57 @@ test('editor remains usable on the current viewport in dark mode', async ({ page
|
||||
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Zutat 1', exact: true }).fill('Kartoffeln');
|
||||
await page.getByRole('spinbutton', { name: 'Menge 1', exact: true }).fill('400');
|
||||
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Abschnitt 1', exact: true }).fill('Für das Gemüse');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Hinweis 1', exact: true })
|
||||
.fill('geschält und gewürfelt');
|
||||
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Zutat hinzufügen' }).click();
|
||||
await expect(page.getByRole('textbox', { name: 'Zutat 2', exact: true })).toBeFocused();
|
||||
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).fill('Karotten');
|
||||
await page.getByRole('spinbutton', { name: 'Menge 2', exact: true }).fill('200');
|
||||
await page.getByRole('button', { name: 'Details für Zutat 2', exact: true }).click();
|
||||
await page.getByRole('checkbox', { name: 'Optionale Zutat' }).check();
|
||||
await page.getByRole('button', { name: 'Zutat 2 nach oben' }).click();
|
||||
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue('Karotten');
|
||||
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).press('Enter');
|
||||
await expect(page.getByRole('textbox', { name: 'Zutat 3', exact: true })).toBeFocused();
|
||||
await page.getByRole('textbox', { name: 'Zutat 3', exact: true }).fill('Zwiebel');
|
||||
await page.getByRole('spinbutton', { name: 'Menge 3', exact: true }).fill('1');
|
||||
await page.getByRole('combobox', { name: 'Einheit 3', exact: true }).selectOption('Stück');
|
||||
await page.getByRole('textbox', { name: 'Zutat 3', exact: true }).press('Enter');
|
||||
await page.getByRole('textbox', { name: 'Zutat 4', exact: true }).fill('Olivenöl');
|
||||
await page.getByRole('spinbutton', { name: 'Menge 4', exact: true }).fill('2');
|
||||
await page.getByRole('combobox', { name: 'Einheit 4', exact: true }).selectOption('EL');
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
|
||||
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue('Karotten');
|
||||
await expect(page.locator('.ingredient-entry').nth(0)).toContainText('optional');
|
||||
await expect(page.locator('.ingredient-entry').nth(1)).toContainText('geschält und gewürfelt');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({
|
||||
path: `docs/screenshots/editor-dark-${info.project.name}.png`,
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
|
||||
await page.screenshot({
|
||||
path: `docs/screenshots/ingredients-light-${info.project.name}.png`,
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
if (info.project.name === 'desktop') {
|
||||
await page.locator('.ingredient-grip').first().dragTo(page.locator('.ingredient-entry').nth(1));
|
||||
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue(
|
||||
'Kartoffeln',
|
||||
);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Live-Vorschau', exact: true }).click();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
});
|
||||
test('public library, shareable search, favorites, themes and responsive layouts', async ({
|
||||
page,
|
||||
|
||||