feat: support external and step images with structured cooking instructions

This commit is contained in:
Sascha Nesterovic committed 2026-10-24 17:31:47 +02:00
1 parent 361862ad0b
commit 0ec1d45bdb
34 files changed
+873 -67

No files matched your search

+8 -3
View File
@@ -25,15 +25,17 @@ Antworte ausschließlich mit einem vollständigen JSON-Objekt, ohne Markdown-Cod
- `optional`: JSON-Boolean `true` oder `false`, kein Text. - `optional`: JSON-Boolean `true` oder `false`, kein Text.
- „Nach Geschmack“ als `"amount": null, "unit": "", "note": "nach Geschmack"`. - „Nach Geschmack“ als `"amount": null, "unit": "", "note": "nach Geschmack"`.
- **Niemals `quantity` statt `amount` oder `group` statt `section` verwenden.** Diese Felder werden nicht als Mengen bzw. Abschnitte übernommen. - **Niemals `quantity` statt `amount` oder `group` statt `section` verwenden.** Diese Felder werden nicht als Mengen bzw. Abschnitte übernommen.
7. Jeder Schritt enthält exakt `id`, `title`, `text`, `ingredients`, `timers`: 7. Jeder Schritt enthält exakt `id`, `title`, `text`, `ingredients`, `images`, `timers`:
- `text` enthält die vollständige Anleitung, niemals `description` auf Schrittebene. - `text` enthält die vollständige Anleitung, niemals `description` auf Schrittebene.
- `ingredients` ist eine Liste vorhandener Zutaten-IDs, etwa `["fleisch", "salz"]`, sonst `[]`. Nicht `ingredientIds` verwenden und hier keine Zutatenobjekte einsetzen. - `ingredients` ist eine Liste vorhandener Zutaten-IDs, etwa `["fleisch", "salz"]`, sonst `[]`. Nicht `ingredientIds` verwenden und hier keine Zutatenobjekte einsetzen.
- Die Reihenfolge ergibt sich aus der Position im `steps`-Array. Kein `order`-Feld. - Die Reihenfolge ergibt sich aus der Position im `steps`-Array. Kein `order`-Feld.
- `timers` ist eine Liste von Timerobjekten oder `[]`. - `timers` ist eine Liste von Timerobjekten oder `[]`.
- `images` ist eine Liste von bis zu 10 Bildadressen für genau diesen Schritt, sonst `[]`.
- Gliedere `text` in kurze Absätze mit `\n\n`. Bei mehreren Teilaufgaben verwende eine eigene Zeile pro Aufgabe mit `- ` oder `1. `, `2. ` usw. Schreibe keinen langen Textblock. Kein HTML oder Markdown für Überschriften, Fettdruck oder eingebettete Bilder; Bilder gehören in `images`.
8. Jeder Timer enthält exakt `id`, `label`, `seconds`. `seconds` ist eine ganze JSON-Zahl von 1 bis 172800 in **Sekunden**, etwa `{"id":"timer_ruhen","label":"Ruhen lassen","seconds":600}`. Die `id` darf auch bei nur einem Timer nicht fehlen. 8. Jeder Timer enthält exakt `id`, `label`, `seconds`. `seconds` ist eine ganze JSON-Zahl von 1 bis 172800 in **Sekunden**, etwa `{"id":"timer_ruhen","label":"Ruhen lassen","seconds":600}`. Die `id` darf auch bei nur einem Timer nicht fehlen.
9. IDs für Zutaten, Schritte und Timer bestehen aus 1–80 Zeichen: ausschließlich `a–z`, `A–Z`, `0–9`, `_` und `-`. Keine Leerzeichen oder Umlaute. Verwende eindeutige lesbare IDs, beispielsweise `z_fleisch`, `s_mischen`, `t_ruhen`; wiederhole keine ID innerhalb eines Rezepts. 9. IDs für Zutaten, Schritte und Timer bestehen aus 1–80 Zeichen: ausschließlich `a–z`, `A–Z`, `0–9`, `_` und `-`. Keine Leerzeichen oder Umlaute. Verwende eindeutige lesbare IDs, beispielsweise `z_fleisch`, `s_mischen`, `t_ruhen`; wiederhole keine ID innerhalb eines Rezepts.
10. `tips` und `storage` sind jeweils **ein einzelner Text**, niemals Listen oder Objekte. Mehrere Hinweise durch `\n\n` innerhalb des JSON-Strings trennen. Verwende `storage`, niemals `storageNotes`. 10. `tips` und `storage` sind jeweils **ein einzelner Text**, niemals Listen oder Objekte. Mehrere Hinweise durch `\n\n` innerhalb des JSON-Strings trennen. Verwende `storage`, niemals `storageNotes`.
11. Setze `"image": ""`, `"images": []` und `"published": false`. Keine erfundenen Bild-URLs. 11. Bilder sind über lokale HamHam-Pfade (`/media/<uuid>.webp`, mitgelieferte `/images/<name>.jpg|webp|svg`) oder vollständige **HTTPS-Bildadressen** möglich (höchstens 2048 Zeichen, ohne Zugangsdaten in der URL). `image` ist das Titelbild, `images` auf Rezeptebene die Galerie (bis zu 20 Bilder), `images` innerhalb eines Schritts dessen Bilder (bis zu 10). Verwende nur von mir bereitgestellte oder tatsächlich überprüfte direkte Bildadressen, keine Webseiten-Links oder erfundenen URLs. Keine `http:`, `data:`- oder Base64-Bilder. Ohne geeignete Bilder setze `"image": ""` und alle `"images": []`. Setze `"published": false`.
12. Setze `"nutrition": null`, sofern keine verlässlichen Nährwerte **pro Portion** verfügbar sind. Andernfalls exakt `{"kcal": 0, "protein": 0, "carbs": 0, "fat": 0}` mit den tatsächlichen nicht negativen Zahlen ausfüllen; Makronährstoffe in Gramm. Keine scheinpräzisen Werte erfinden. 12. Setze `"nutrition": null`, sofern keine verlässlichen Nährwerte **pro Portion** verfügbar sind. Andernfalls exakt `{"kcal": 0, "protein": 0, "carbs": 0, "fat": 0}` mit den tatsächlichen nicht negativen Zahlen ausfüllen; Makronährstoffe in Gramm. Keine scheinpräzisen Werte erfinden.
## Inhalt und Grenzen ## Inhalt und Grenzen
@@ -102,13 +104,15 @@ Dieses kleine Beispiel zeigt die verbindliche Struktur. Ersetze den Inhalt volls
"title": "Gurke vorbereiten", "title": "Gurke vorbereiten",
"text": "{{z_gurke}} waschen, die Enden entfernen und die Gurke fein hobeln.", "text": "{{z_gurke}} waschen, die Enden entfernen und die Gurke fein hobeln.",
"ingredients": ["z_gurke"], "ingredients": ["z_gurke"],
"images": [],
"timers": [] "timers": []
}, },
{ {
"id": "s_marinieren", "id": "s_marinieren",
"title": "Marinieren und ziehen lassen", "title": "Marinieren und ziehen lassen",
"text": "Die Gurkenscheiben mit {{z_essig}} und {{z_salz}} vermengen. Im Kühlschrank 10 Minuten ziehen lassen, danach nochmals abschmecken und servieren.", "text": "- Die Gurkenscheiben mit {{z_essig}} und {{z_salz}} vermengen.\n- Im Kühlschrank 10 Minuten ziehen lassen.\n- Danach nochmals abschmecken und servieren.",
"ingredients": ["z_essig", "z_salz"], "ingredients": ["z_essig", "z_salz"],
"images": [],
"timers": [ "timers": [
{ {
"id": "t_ziehen", "id": "t_ziehen",
@@ -138,4 +142,5 @@ Prüfe und korrigiere die Ausgabe vor dem Antworten. Gib diese Prüfliste nicht
- Jede Zutat, jeder Schritt und jeder Timer hat eine gültige, eindeutige `id`. Alle Zutatenreferenzen existieren. - Jede Zutat, jeder Schritt und jeder Timer hat eine gültige, eindeutige `id`. Alle Zutatenreferenzen existieren.
- `tips` und `storage` sind Texte; `quantity`, `group`, `ingredientIds`, `order`, `storageNotes`, `country`, `format` und `schemaVersion` kommen als Feldnamen nicht vor. - `tips` und `storage` sind Texte; `quantity`, `group`, `ingredientIds`, `order`, `storageNotes`, `country`, `format` und `schemaVersion` kommen als Feldnamen nicht vor.
- Mengenverhältnisse, Portionszahl, Zeiten und Timer sind plausibel; alle Grenzen sind eingehalten. - Mengenverhältnisse, Portionszahl, Zeiten und Timer sind plausibel; alle Grenzen sind eingehalten.
- Schrittanleitungen sind in kurze Absätze oder Listen gegliedert. Bildfelder enthalten nur geeignete lokale Pfade oder HTTPS-Bildadressen, sonst bleiben sie leer.
- Die gesamte Antwort ist syntaktisch gültiges JSON und enthält keinen Begleittext. - Die gesamte Antwort ist syntaktisch gültiges JSON und enthält keinen Begleittext.
+6
View File
@@ -50,6 +50,12 @@ Zutaten werden als kompakte Liste mit Name, Menge und Einheit erfasst. 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. 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.
Titelbild, Rezeptgalerie und Schrittbilder unterstützen auch direkte HTTPS-Bildadressen. Diese werden ausschließlich im Browser geladen, nicht serverseitig abgerufen. `imageSchema` in `src/lib/recipe.ts` validiert lokale Pfade und HTTPS-URLs; die CSP erlaubt HTTPS-Bilder. `RecipeImage` setzt `no-referrer` und zeigt bei Ladefehlern einen Platzhalter. Die Medienlöschung berücksichtigt auch Bildreferenzen innerhalb von Schritten.
`steps[].images` ist optional beim Import (Standard `[]`, maximal 10 Bilder). Bestehende Datenbankeinträge und lokale Entwürfe erhalten beim Lesen leere Schrittbilder; eine Datenbankmigration ist dafür nicht erforderlich. JSON- und XML-Export erhalten die Referenzen. Externe Bilder werden nicht durch den Service Worker offline gespeichert.
`StepInstructions` wird in Rezeptansicht, Kochmodus und Vorschauen gemeinsam verwendet: kurze Absätze, einfache Listen und hervorgehobene skalierbare Zutatenreferenzen. `instructionBlocks` zerlegt ausschließlich Klartext; lange ältere Ein-Absatz-Texte werden mit `Intl.Segmenter` für Deutsch nach jeweils zwei Sätzen optisch gegliedert. Es wird kein HTML interpretiert und der gespeicherte Anleitungstext wird nicht umgeschrieben.
## Tests ## Tests
Unit: `npm test`. E2E: `npm run build && npm run test:e2e`. Tests verwenden echte PostgreSQL-Persistenz, echte HTTP-Authentifizierung und Chromium ohne gemockte Produktfunktionen. Die E2E-Datenbank muss auf `_test` enden und wird zurückgesetzt. Playwright erzeugt Screenshots für Desktop (1440 px), Smartphone (390 px) und Tablet (768 px). Unit: `npm test`. E2E: `npm run build && npm run test:e2e`. Tests verwenden echte PostgreSQL-Persistenz, echte HTTP-Authentifizierung und Chromium ohne gemockte Produktfunktionen. Die E2E-Datenbank muss auf `_test` enden und wird zurückgesetzt. Playwright erzeugt Screenshots für Desktop (1440 px), Smartphone (390 px) und Tablet (768 px).
+13 -3
View File
@@ -47,17 +47,26 @@ Schritt:
{ {
"id": "anbraten", "id": "anbraten",
"title": "Goldbraun anbraten", "title": "Goldbraun anbraten",
"text": "{{huhn}} in Würfel schneiden und anbraten.", "text": "- {{huhn}} in Würfel schneiden.\n- In der heißen Pfanne anbraten.",
"ingredients": ["huhn"], "ingredients": ["huhn"],
"images": [],
"timers": [{ "id": "bratzeit", "label": "Hühnerbrust braten", "seconds": 480 }] "timers": [{ "id": "bratzeit", "label": "Hühnerbrust braten", "seconds": 480 }]
} }
``` ```
`{{huhn}}` wird zu **Menge + Einheit + Zutatenname + Hinweis** aufgelöst. Diese Referenz bleibt beim Skalieren konsistent. Keine konkreten skalierbaren Mengen als Freitext wiederholen. Temperaturen und Zeiten bleiben normaler Text. `ingredients` enthält die in diesem Schritt benötigten Zutaten-IDs. Timer-IDs müssen im gesamten Rezept eindeutig sein; ein Timer dauert 1–172800 Sekunden. Zeiten werden nicht mit Portionen skaliert. `{{huhn}}` wird zu **Menge + Einheit + Zutatenname + Hinweis** aufgelöst. Diese Referenz bleibt beim Skalieren konsistent. Keine konkreten skalierbaren Mengen als Freitext wiederholen. Temperaturen und Zeiten bleiben normaler Text. `ingredients` enthält die in diesem Schritt benötigten Zutaten-IDs. Timer-IDs müssen im gesamten Rezept eindeutig sein; ein Timer dauert 1–172800 Sekunden. Zeiten werden nicht mit Portionen skaliert.
Schritttexte unterstützen Absätze bzw. Zeilenumbrüche (`\n`, `\n\n`), Aufzählungen mit `- `, `* ` oder `• ` und nummerierte Listen mit `1. ` bzw. `1) `. Die Darstellung funktioniert in Rezeptansicht, Kochmodus und Vorschau. Lange ältere Texte ohne Zeilenumbrüche werden für die Anzeige nach jeweils zwei Sätzen gegliedert; der gespeicherte Text bleibt erhalten. Zutatenreferenzen werden hervorgehoben. HTML und weitere Markdown-Formatierung sind nicht vorgesehen.
## Bilder ## Bilder
Nur lokale URLs: `/media/<uuid>.webp` oder mitgelieferte `/images/<name>.jpg|webp|svg`. Bilder zuerst im Adminbereich hochladen, dann den lokalen Pfad verwenden. ChatGPT soll `image: ""` und `images: []` setzen und keine erfundenen URLs ausgeben. Bildfelder akzeptieren lokale Pfade (`/media/<uuid>.webp` oder mitgelieferte `/images/<name>.jpg|webp|svg`) und vollständige externe **HTTPS-URLs** bis 2048 Zeichen ohne Zugangsdaten. Queryparameter sind erlaubt, z. B. für Bild-CDNs. HTTP, eingebettete Base64-/Data-URLs und Webseiten statt direkter Bildadressen sind nicht geeignet.
- `recipe.image`: ein Titelbild oder `""`.
- `recipe.images`: Galerie mit bis zu 20 Bildadressen, Standard `[]`.
- `recipe.steps[].images`: bis zu 10 Bildadressen pro Schritt, Standard `[]`. Bestehende Rezepte ohne dieses Feld bleiben kompatibel.
Lokale Bilder können im Editor hochgeladen werden. HTTPS-Adressen können für Titelbild, Galerie und Schrittbilder im Editor eingetragen oder über JSON/XML importiert werden. Externe Bilder lädt der Browser direkt mit `referrerPolicy="no-referrer"`; HamHam lädt sie nicht auf den Server herunter. Bei nicht erreichbaren oder vom Anbieter blockierten Bildern erscheint ein Platzhalter. Externe Bilder sind daher nicht Bestandteil des lokalen Bild-Backups. ChatGPT soll nur bereitgestellte oder überprüfte Bildadressen verwenden und die Bildfelder andernfalls leer lassen.
JSON/XML-Exporte enthalten Bildreferenzen, keine Binärdaten. Für einen Umzug das Bildvolume mitnehmen; für ein vollständiges Backup zusätzlich die Datenbank sichern. Metadaten, Zutaten, Schritte, Timer, Zeitstempel und IDs bleiben beim Export erhalten. JSON/XML-Exporte enthalten Bildreferenzen, keine Binärdaten. Für einen Umzug das Bildvolume mitnehmen; für ein vollständiges Backup zusätzlich die Datenbank sichern. Metadaten, Zutaten, Schritte, Timer, Zeitstempel und IDs bleiben beim Export erhalten.
@@ -66,7 +75,7 @@ JSON/XML-Exporte enthalten Bildreferenzen, keine Binärdaten. Für einen Umzug d
Das vollständige Beispiel ist `public/examples/pljeskavica.xml`. UTF-8, Wurzel `<hamham version="1">`, darunter `<recipes><recipe>…</recipe></recipes>`. Rezeptfelder entsprechen JSON. Array-Container: Das vollständige Beispiel ist `public/examples/pljeskavica.xml`. UTF-8, Wurzel `<hamham version="1">`, darunter `<recipes><recipe>…</recipe></recipes>`. Rezeptfelder entsprechen JSON. Array-Container:
| JSON | XML | | JSON | XML |
| ---------------- | ----------------------------------------------------------- | | ---------------- | ----------------------------------------------------------------- |
| recipes | `<recipes><recipe>…</recipe></recipes>` | | recipes | `<recipes><recipe>…</recipe></recipes>` |
| ingredients | `<ingredients><ingredient>…</ingredient></ingredients>` | | ingredients | `<ingredients><ingredient>…</ingredient></ingredients>` |
| steps | `<steps><step>…</step></steps>` | | steps | `<steps><step>…</step></steps>` |
@@ -75,6 +84,7 @@ Das vollständige Beispiel ist `public/examples/pljeskavica.xml`. UTF-8, Wurzel
| tags | `<tags><tag>Schnell</tag></tags>` | | tags | `<tags><tag>Schnell</tag></tags>` |
| images | `<images><image>/media/…webp</image></images>` | | images | `<images><image>/media/…webp</image></images>` |
| step.ingredients | `<ingredients><ref>huhn</ref></ingredients>` | | step.ingredients | `<ingredients><ref>huhn</ref></ingredients>` |
| step.images | `<images><image>https://example.org/schritt.jpg</image></images>` |
Leere Menge: `<amount/>`. Keine Nährwerte: `<nutrition/>`. Boolean: `true`/`false`. Texte standardkonform escapen (`&amp;`, `&lt;` etc.). DOCTYPE und Entity-Deklarationen sind verboten, es werden keine externen Ressourcen aufgelöst. Nach dem XML-Parsing gilt dieselbe Zod-Validierung wie bei JSON. Leere Menge: `<amount/>`. Keine Nährwerte: `<nutrition/>`. Boolean: `true`/`false`. Texte standardkonform escapen (`&amp;`, `&lt;` etc.). DOCTYPE und Entity-Deklarationen sind verboten, es werden keine externen Ressourcen aufgelöst. Nach dem XML-Parsing gilt dieselbe Zod-Validierung wie bei JSON.
+11 -2
View File
@@ -2,7 +2,7 @@
Stand: **24. Oktober 2026**. Die folgenden Prüfungen wurden tatsächlich ausgeführt, nicht lediglich vorbereitet. Stand: **24. Oktober 2026**. Die folgenden Prüfungen wurden tatsächlich ausgeführt, nicht lediglich vorbereitet.
## Ergebnisse ## Ergebnisse der Erstprüfung
| Prüfung | Ergebnis | | Prüfung | Ergebnis |
| ---------------------- | ------------------------------------------------------------------ | | ---------------------- | ------------------------------------------------------------------ |
@@ -68,6 +68,15 @@ Tatsächlich angesehen: Desktop- und Mobile-Bibliothek, Tablet-Bibliothek, mobil
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. 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.
### Nachprüfung: externe Bilder und strukturierte Schritte
- TypeScript-Prüfung und Produktionsbuild erfolgreich; **49/49 Unit-Tests** in vier Dateien bestanden.
- **13 betroffene Browser-/API-Abläufe** erfolgreich geprüft: Editor mit Schrittbild-Upload und Löschsperre für verwendete Medien, JSON/XML-Import, API-Validierung, Offline-Kochmodus sowie auf Desktop/Smartphone/Tablet jeweils externer Bildimport mit Editoränderung/Export, Portionsrechner/Druckansicht und Kochfortschritt/Timer.
- HTTPS-Titelbilder, Rezeptgalerien und Schrittbilder werden in JSON und XML verlustfrei erhalten. Alte Rezepte ohne Schrittbilder bleiben lesbar. Ungültige URL-Protokolle, Zugangsdaten in Bildadressen und Bildlimits sind getestet.
- Die neuen Browserabläufe verwenden deterministische HTTPS-Bildantworten per Playwright-Routing, einschließlich einer 404-Antwort. Sie prüfen die reale CSP, tatsächlich geladene Bilder, Fehlerplatzhalter, Listen/Absätze, skalierte Referenzen und fehlenden horizontalen Überlauf. Ein dabei entdeckter Bildfehler vor React-Hydration wurde durch Prüfung des bereits abgeschlossenen Bildladezustands behoben.
- Visuell angesehen: neue Desktop-Rezeptkarten für Schritte, Smartphone-Rezeptseite mit Schrittbildern, dunkle Desktop-Schrittkarte und mobiler Kochmodus. Die Rezept-/Kochmodus-Screenshots unter `docs/screenshots/` wurden aktualisiert; zusätzliche Bild-/Listenansichten liegen in den Playwright-Testartefakten.
- Die JSON-Vorlage in `docs/CHATGPT-PROMPT.md` wurde mit dem tatsächlichen Importparser geprüft; das öffentliche JSON-Schema und die Beispiele wurden neu erzeugt.
## Präzise Grenzen ## Präzise Grenzen
- Kein Test auf physischem Smartphone, Safari oder Firefox; keine tatsächliche iOS-/Android-Installation über das Betriebssystem-Installationsmenü. - Kein Test auf physischem Smartphone, Safari oder Firefox; keine tatsächliche iOS-/Android-Installation über das Betriebssystem-Installationsmenü.
@@ -76,7 +85,7 @@ Nach der gestalterischen Überarbeitung wurden TypeScript-Prüfung und Produktio
- Linux ARM64 ist tatsächlich getestet; ein separater AMD64-Deploy wurde nicht durchgeführt. - Linux ARM64 ist tatsächlich getestet; ein separater AMD64-Deploy wurde nicht durchgeführt.
- Kein externer Penetrationstest, keine Garantie, dass ein Abhängigkeitsaudit jede Schwachstelle erkennt. Es sind aus den durchgeführten Prüfungen keine kritischen Sicherheitsfehler bekannt. - Kein externer Penetrationstest, keine Garantie, dass ein Abhängigkeitsaudit jede Schwachstelle erkennt. Es sind aus den durchgeführten Prüfungen keine kritischen Sicherheitsfehler bekannt.
- Editorentwürfe, Favoriten, Einkaufslisten und Kochfortschritt sind bewusst geräte-/browserlokal; kein geräteübergreifender Sync. - Editorentwürfe, Favoriten, Einkaufslisten und Kochfortschritt sind bewusst geräte-/browserlokal; kein geräteübergreifender Sync.
- JSON/XML transportiert Rezeptdaten und lokale Bildreferenzen. Bilddateien werden separat über das persistente Volume gesichert und übertragen. - JSON/XML transportiert Rezeptdaten sowie lokale und externe Bildreferenzen. Lokale Bilddateien werden separat über das persistente Volume gesichert und übertragen; externe Bilder verbleiben beim jeweiligen Anbieter und werden nicht für den Offlinebetrieb gespeichert.
- Offline umfasst tatsächlich gespeicherte öffentliche Ansichten; neue oder aus dem Browsercache entfernte Inhalte benötigen Netz. Administration bleibt onlinepflichtig. - Offline umfasst tatsächlich gespeicherte öffentliche Ansichten; neue oder aus dem Browsercache entfernte Inhalte benötigen Netz. Administration bleibt onlinepflichtig.
Diese Grenzen sind keine als fertig ausgegebenen ungeprüften Plattformversprechen. Die oben genannten implementierten Kernabläufe wurden in der dokumentierten Umgebung erfolgreich geprüft. Diese Grenzen sind keine als fertig ausgegebenen ungeprüften Plattformversprechen. Die oben genannten implementierten Kernabläufe wurden in der dokumentierten Umgebung erfolgreich geprüft.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 KiB

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

+1 -1
View File
@@ -16,7 +16,7 @@ const config = {
value: value:
"default-src 'self'; script-src 'self' 'unsafe-inline'" + "default-src 'self'; script-src 'self' 'unsafe-inline'" +
(process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : '') + (process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : '') +
"; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; worker-src 'self'", "; style-src 'self' 'unsafe-inline'; img-src 'self' https: blob: data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; worker-src 'self'",
}, },
], ],
}, },
+37
View File
@@ -35,11 +35,20 @@
}, },
"image": { "image": {
"default": "", "default": "",
"anyOf": [
{
"anyOf": [ "anyOf": [
{ {
"type": "string", "type": "string",
"pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$" "pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$"
}, },
{
"type": "string",
"maxLength": 2048,
"pattern": "^https:\\/\\/[^@\\s/\\\\]+(?:[/?#][^\\s\\\\]*)?$"
}
]
},
{ {
"type": "string", "type": "string",
"const": "" "const": ""
@@ -51,8 +60,17 @@
"maxItems": 20, "maxItems": 20,
"type": "array", "type": "array",
"items": { "items": {
"anyOf": [
{
"type": "string", "type": "string",
"pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$" "pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$"
},
{
"type": "string",
"maxLength": 2048,
"pattern": "^https:\\/\\/[^@\\s/\\\\]+(?:[/?#][^\\s\\\\]*)?$"
}
]
} }
}, },
"categories": { "categories": {
@@ -214,6 +232,24 @@
"pattern": "^[a-zA-Z0-9_-]{1,80}$" "pattern": "^[a-zA-Z0-9_-]{1,80}$"
} }
}, },
"images": {
"default": [],
"maxItems": 10,
"type": "array",
"items": {
"anyOf": [
{
"type": "string",
"pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$"
},
{
"type": "string",
"maxLength": 2048,
"pattern": "^https:\\/\\/[^@\\s/\\\\]+(?:[/?#][^\\s\\\\]*)?$"
}
]
}
},
"timers": { "timers": {
"default": [], "default": [],
"maxItems": 10, "maxItems": 10,
@@ -250,6 +286,7 @@
"title", "title",
"text", "text",
"ingredients", "ingredients",
"images",
"timers" "timers"
], ],
"additionalProperties": false "additionalProperties": false
+10
View File
@@ -97,6 +97,7 @@
"kaese", "kaese",
"salz" "salz"
], ],
"images": [],
"timers": [] "timers": []
}, },
{ {
@@ -109,6 +110,7 @@
"oel", "oel",
"kaese" "kaese"
], ],
"images": [],
"timers": [ "timers": [
{ {
"id": "roesten", "id": "roesten",
@@ -124,6 +126,7 @@
"ingredients": [ "ingredients": [
"pasta" "pasta"
], ],
"images": [],
"timers": [ "timers": [
{ {
"id": "nudeln", "id": "nudeln",
@@ -140,6 +143,7 @@
"zitrone", "zitrone",
"salz" "salz"
], ],
"images": [],
"timers": [] "timers": []
} }
], ],
@@ -256,6 +260,7 @@
"tomaten", "tomaten",
"gurke" "gurke"
], ],
"images": [],
"timers": [] "timers": []
}, },
{ {
@@ -265,6 +270,7 @@
"ingredients": [ "ingredients": [
"kichererbsen" "kichererbsen"
], ],
"images": [],
"timers": [ "timers": [
{ {
"id": "roesten", "id": "roesten",
@@ -283,6 +289,7 @@
"wasser", "wasser",
"salz" "salz"
], ],
"images": [],
"timers": [] "timers": []
} }
], ],
@@ -386,6 +393,7 @@
"kartoffeln", "kartoffeln",
"zwiebel" "zwiebel"
], ],
"images": [],
"timers": [] "timers": []
}, },
{ {
@@ -396,6 +404,7 @@
"oel", "oel",
"bruehe" "bruehe"
], ],
"images": [],
"timers": [ "timers": [
{ {
"id": "suppe", "id": "suppe",
@@ -412,6 +421,7 @@
"bohnen", "bohnen",
"kraeuter" "kraeuter"
], ],
"images": [],
"timers": [ "timers": [
{ {
"id": "bohnen", "id": "bohnen",
+5
View File
@@ -107,6 +107,7 @@
"wasser", "wasser",
"pfeffer" "pfeffer"
], ],
"images": [],
"timers": [] "timers": []
}, },
{ {
@@ -114,6 +115,7 @@
"title": "Geschmack entwickeln lassen", "title": "Geschmack entwickeln lassen",
"text": "Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.", "text": "Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.",
"ingredients": [], "ingredients": [],
"images": [],
"timers": [ "timers": [
{ {
"id": "ruhezeit", "id": "ruhezeit",
@@ -127,6 +129,7 @@
"title": "Flache Laibchen formen", "title": "Flache Laibchen formen",
"text": "Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken.", "text": "Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken.",
"ingredients": [], "ingredients": [],
"images": [],
"timers": [] "timers": []
}, },
{ {
@@ -134,6 +137,7 @@
"title": "Kräftig grillen", "title": "Kräftig grillen",
"text": "Grill oder Grillpfanne vorheizen. Laibchen von beiden Seiten je etwa fünf bis sechs Minuten braten. Faschiertes vollständig durchgaren: im Kern mindestens 70 °C für zwei Minuten erreichen.", "text": "Grill oder Grillpfanne vorheizen. Laibchen von beiden Seiten je etwa fünf bis sechs Minuten braten. Faschiertes vollständig durchgaren: im Kern mindestens 70 °C für zwei Minuten erreichen.",
"ingredients": [], "ingredients": [],
"images": [],
"timers": [ "timers": [
{ {
"id": "erste-seite", "id": "erste-seite",
@@ -155,6 +159,7 @@
"brot", "brot",
"ajvar" "ajvar"
], ],
"images": [],
"timers": [] "timers": []
} }
], ],
+5
View File
@@ -107,6 +107,7 @@
<ref>wasser</ref> <ref>wasser</ref>
<ref>pfeffer</ref> <ref>pfeffer</ref>
</ingredients> </ingredients>
<images></images>
<timers></timers> <timers></timers>
</step> </step>
<step> <step>
@@ -114,6 +115,7 @@
<title>Geschmack entwickeln lassen</title> <title>Geschmack entwickeln lassen</title>
<text>Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.</text> <text>Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.</text>
<ingredients></ingredients> <ingredients></ingredients>
<images></images>
<timers> <timers>
<timer> <timer>
<id>ruhezeit</id> <id>ruhezeit</id>
@@ -127,6 +129,7 @@
<title>Flache Laibchen formen</title> <title>Flache Laibchen formen</title>
<text>Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken.</text> <text>Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken.</text>
<ingredients></ingredients> <ingredients></ingredients>
<images></images>
<timers></timers> <timers></timers>
</step> </step>
<step> <step>
@@ -134,6 +137,7 @@
<title>Kräftig grillen</title> <title>Kräftig grillen</title>
<text>Grill oder Grillpfanne vorheizen. Laibchen von beiden Seiten je etwa fünf bis sechs Minuten braten. Faschiertes vollständig durchgaren: im Kern mindestens 70 °C für zwei Minuten erreichen.</text> <text>Grill oder Grillpfanne vorheizen. Laibchen von beiden Seiten je etwa fünf bis sechs Minuten braten. Faschiertes vollständig durchgaren: im Kern mindestens 70 °C für zwei Minuten erreichen.</text>
<ingredients></ingredients> <ingredients></ingredients>
<images></images>
<timers> <timers>
<timer> <timer>
<id>erste-seite</id> <id>erste-seite</id>
@@ -155,6 +159,7 @@
<ref>brot</ref> <ref>brot</ref>
<ref>ajvar</ref> <ref>ajvar</ref>
</ingredients> </ingredients>
<images></images>
<timers></timers> <timers></timers>
</step> </step>
</steps> </steps>
+4 -1
View File
@@ -249,7 +249,10 @@ async function handle(req: Request, ctx: Context) {
.parse(await jsonBody(req)); .parse(await jsonBody(req));
if ( if (
(await listRecipes(true)).some( (await listRecipes(true)).some(
(r) => r.image === `/media/${name}` || r.images.includes(`/media/${name}`), (r) =>
r.image === `/media/${name}` ||
r.images.includes(`/media/${name}`) ||
r.steps.some((s) => s.images.includes(`/media/${name}`)),
) )
) )
throw new ApiError(409, 'Das Bild wird noch von einem Rezept verwendet.'); throw new ApiError(409, 'Das Bild wird noch von einem Rezept verwendet.');
+9 -3
View File
@@ -18,7 +18,9 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { api, toast } from '@/lib/client'; import { api, toast } from '@/lib/client';
import type { SavedRecipe, Recipe } from '@/lib/recipe'; import type { SavedRecipe, Recipe } from '@/lib/recipe';
import { renderStep, ingredientLabel } from '@/lib/calculator'; import { ingredientLabel } from '@/lib/calculator';
import { RecipeImage } from './recipe-image';
import { StepInstructions, StepImages } from './step-content';
import { Dialog } from './dialog'; import { Dialog } from './dialog';
export function Login() { export function Login() {
const router = useRouter(), const router = useRouter(),
@@ -155,7 +157,7 @@ export function AdminDashboard({
{recipes.map((r) => ( {recipes.map((r) => (
<article key={r.id}> <article key={r.id}>
<div className="admin-thumb"> <div className="admin-thumb">
{r.image ? <img src={r.image} alt="" /> : <BookOpen size={25} />} {r.image ? <RecipeImage src={r.image} alt="" /> : <BookOpen size={25} />}
</div> </div>
<div className="admin-recipe-name"> <div className="admin-recipe-name">
<Link href={`/admin/rezepte/${r.id}`}> <Link href={`/admin/rezepte/${r.id}`}>
@@ -387,7 +389,11 @@ function ImportPanel({ onImported }: { onImported: () => void }) {
</ul> </ul>
<ol> <ol>
{r.steps.map((s) => ( {r.steps.map((s) => (
<li key={s.id}>{renderStep(s.text, r, '1')}</li> <li key={s.id}>
<strong>{s.title}</strong>
<StepInstructions text={s.text} recipe={r} factor="1" />
<StepImages step={s} />
</li>
))} ))}
</ol> </ol>
</div> </div>
+6 -2
View File
@@ -18,7 +18,8 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Decimal from 'decimal.js'; import Decimal from 'decimal.js';
import type { SavedRecipe } from '@/lib/recipe'; import type { SavedRecipe } from '@/lib/recipe';
import { renderStep, ingredientLabel, formatAmount, positive } from '@/lib/calculator'; import { ingredientLabel, formatAmount, positive } from '@/lib/calculator';
import { StepInstructions, StepImages } from './step-content';
import { import {
type CookingProgress, type CookingProgress,
type TimerState, type TimerState,
@@ -218,7 +219,10 @@ export function Cooking({
> >
<span className="eyebrow">JETZT GEHT’S ANS KOCHEN</span> <span className="eyebrow">JETZT GEHT’S ANS KOCHEN</span>
<h1>{current.title || `Schritt ${state.step + 1}`}</h1> <h1>{current.title || `Schritt ${state.step + 1}`}</h1>
<p className="cooking-instruction">{renderStep(current.text, r, state.factor)}</p> <div className="cooking-instruction">
<StepInstructions text={current.text} recipe={r} factor={state.factor} />
</div>
<StepImages step={current} />
<div className="step-ingredients"> <div className="step-ingredients">
{current.ingredients.map((id) => { {current.ingredients.map((id) => {
const i = r.ingredients.find((i) => i.id === id)!; const i = r.ingredients.find((i) => i.id === id)!;
+67 -36
View File
@@ -18,8 +18,11 @@ import {
import { type Recipe, type SavedRecipe, type Step, emptyRecipe, 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 { api, readLocal, writeLocal, toast } from '@/lib/client';
import { uploadImage } from './admin'; import { uploadImage } from './admin';
import { renderStep, ingredientLabel } from '@/lib/calculator'; import { ingredientLabel } from '@/lib/calculator';
import { IngredientEditor } from './ingredient-editor'; import { IngredientEditor } from './ingredient-editor';
import { ImageCollectionEditor } from './image-collection-editor';
import { StepInstructions, StepImages } from './step-content';
import { RecipeImage } from './recipe-image';
export function RecipeEditor({ initial }: { initial?: SavedRecipe }) { export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
const router = useRouter(), const router = useRouter(),
key = `hamham:draft:${initial?.id ?? 'new'}`; key = `hamham:draft:${initial?.id ?? 'new'}`;
@@ -39,7 +42,10 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
useEffect(() => { useEffect(() => {
const draft = readLocal<{ recipe: Recipe; time: string } | null>(key, null); const draft = readLocal<{ recipe: Recipe; time: string } | null>(key, null);
if (draft) { if (draft) {
setRecipe(draft.recipe); setRecipe({
...draft.recipe,
steps: draft.recipe.steps.map((s) => ({ ...s, images: s.images ?? [] })),
});
dirty.current = true; dirty.current = true;
setSaved( setSaved(
`Lokaler Entwurf vom ${new Date(draft.time).toLocaleString('de-AT')} wiederhergestellt`, `Lokaler Entwurf vom ${new Date(draft.time).toLocaleString('de-AT')} wiederhergestellt`,
@@ -76,6 +82,19 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
'steps', 'steps',
recipe.steps.map((s, index) => (index === n ? { ...s, ...patch } : s)), recipe.steps.map((s, index) => (index === n ? { ...s, ...patch } : s)),
); );
function addImage(url: string, stepId?: string) {
dirty.current = true;
setRecipe((r) =>
stepId
? {
...r,
steps: r.steps.map((s) =>
s.id === stepId ? { ...s, images: [...(s.images ?? []), url] } : s,
),
}
: { ...r, images: [...r.images, url] },
);
}
function move(kind: 'ingredients' | 'steps', from: number, to: number) { function move(kind: 'ingredients' | 'steps', from: number, to: number) {
if (to < 0 || to >= recipe[kind].length) return; if (to < 0 || to >= recipe[kind].length) return;
if (kind === 'ingredients') { if (kind === 'ingredients') {
@@ -115,12 +134,11 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
setBusy(false); setBusy(false);
} }
} }
async function upload(file: File, additional = false) { async function upload(file: File) {
setBusy(true); setBusy(true);
try { try {
const url = await uploadImage(file); const url = await uploadImage(file);
if (additional) change('images', [...recipe.images, url]); change('image', url);
else change('image', url);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} finally { } finally {
@@ -241,7 +259,7 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
> >
{recipe.image ? ( {recipe.image ? (
<> <>
<img src={recipe.image} alt="Titelbild" /> <RecipeImage src={recipe.image} alt="Titelbild" />
<button <button
className="icon-button" className="icon-button"
aria-label="Titelbild entfernen" aria-label="Titelbild entfernen"
@@ -270,11 +288,11 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
</label> </label>
</div> </div>
<label> <label>
Oder vorhandenen lokalen Bildpfad verwenden Titelbild-Adresse (HTTPS oder lokaler Bildpfad)
<input <input
value={recipe.image} value={recipe.image}
onChange={(e) => change('image', e.target.value)} onChange={(e) => change('image', e.target.value)}
placeholder="/media/…webp" placeholder="https://… oder /media/…webp"
/> />
</label> </label>
<div className="form-grid"> <div className="form-grid">
@@ -397,7 +415,8 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
<section className="panel editor-panel"> <section className="panel editor-panel">
<h2>Schritt für Schritt zum Genuss.</h2> <h2>Schritt für Schritt zum Genuss.</h2>
<p className="muted"> <p className="muted">
Füge Zutaten als Referenz ein, damit die Mengen beim Skalieren mitwachsen. Pro Schritt eine klare Aufgabe. Absätze mit Enter, Aufzählungen mit „- “ oder „1. “.
Zutatenreferenzen passen ihre Mengen beim Skalieren automatisch an.
</p> </p>
{recipe.steps.map((s, n) => ( {recipe.steps.map((s, n) => (
<article <article
@@ -440,6 +459,9 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
<textarea <textarea
aria-label={`Anleitung ${n + 1}`} aria-label={`Anleitung ${n + 1}`}
rows={4} rows={4}
placeholder={
'Vorbereitung kurz erklären.\n\n- Erste Teilaufgabe\n- Zweite Teilaufgabe'
}
value={s.text} value={s.text}
onChange={(e) => updateStep(n, { text: e.target.value })} onChange={(e) => updateStep(n, { text: e.target.value })}
/> />
@@ -541,6 +563,22 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
<Plus size={16} /> Timer hinzufügen <Plus size={16} /> Timer hinzufügen
</button> </button>
</div> </div>
<details className="step-image-editor">
<summary>
Bilder zu Schritt {n + 1} ({s.images?.length ?? 0})
</summary>
<ImageCollectionEditor
images={s.images ?? []}
label={`Schritt ${n + 1}: Bild`}
limit={10}
disabled={busy}
onBusy={setBusy}
onAdd={(url) => addImage(url, s.id)}
onRemove={(index) =>
updateStep(n, { images: s.images.filter((_, i) => i !== index) })
}
/>
</details>
</article> </article>
))} ))}
<button <button
@@ -548,7 +586,14 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
onClick={() => onClick={() =>
change('steps', [ change('steps', [
...recipe.steps, ...recipe.steps,
{ id: crypto.randomUUID(), title: '', text: '', ingredients: [], timers: [] }, {
id: crypto.randomUUID(),
title: '',
text: '',
ingredients: [],
images: [],
timers: [],
},
]) ])
} }
> >
@@ -614,42 +659,27 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
</div> </div>
)} )}
<h3>Weitere Bilder</h3> <h3>Weitere Bilder</h3>
<div className="image-gallery"> <ImageCollectionEditor
{recipe.images.map((src) => ( images={recipe.images}
<div key={src}> label="Weiteres Bild"
<img src={src} alt="Zusätzliches Rezeptbild" /> limit={20}
<button disabled={busy}
className="text-button danger" onBusy={setBusy}
onClick={() => onAdd={(url) => addImage(url)}
onRemove={(index) =>
change( change(
'images', 'images',
recipe.images.filter((i) => i !== src), recipe.images.filter((_, i) => i !== index),
) )
} }
>
Entfernen
</button>
</div>
))}
</div>
<label className="button secondary">
<Upload size={18} /> Weiteres Bild hochladen
<input
className="visually-hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => {
if (e.target.files?.[0]) void upload(e.target.files[0], true);
}}
/> />
</label>
</section> </section>
)} )}
</div> </div>
{preview && ( {preview && (
<aside className="editor-preview panel"> <aside className="editor-preview panel">
<span className="eyebrow">LIVE-VORSCHAU</span> <span className="eyebrow">LIVE-VORSCHAU</span>
{recipe.image && <img src={recipe.image} alt="Rezeptvorschau" />} {recipe.image && <RecipeImage src={recipe.image} alt="Rezeptvorschau" />}
<h2>{recipe.title || 'Dein Rezepttitel'}</h2> <h2>{recipe.title || 'Dein Rezepttitel'}</h2>
<p>{recipe.description}</p> <p>{recipe.description}</p>
<h3>Zutaten für {recipe.servings} Portionen</h3> <h3>Zutaten für {recipe.servings} Portionen</h3>
@@ -663,7 +693,8 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
{recipe.steps.map((s) => ( {recipe.steps.map((s) => (
<li key={s.id}> <li key={s.id}>
<strong>{s.title}</strong> <strong>{s.title}</strong>
<p>{renderStep(s.text, recipe, '1')}</p> <StepInstructions text={s.text} recipe={recipe} factor="1" />
<StepImages step={s} />
</li> </li>
))} ))}
</ol> </ol>
+122
View File
@@ -0,0 +1,122 @@
'use client';
import { useState } from 'react';
import { Upload, Plus } from 'lucide-react';
import { imageSchema } from '@/lib/recipe';
import { uploadImage } from './admin';
import { RecipeImage } from './recipe-image';
export function ImageCollectionEditor({
images,
label,
limit,
disabled,
onAdd,
onRemove,
onBusy,
}: {
images: string[];
label: string;
limit: number;
disabled: boolean;
onAdd: (url: string) => void;
onRemove: (index: number) => void;
onBusy: (busy: boolean) => void;
}) {
const [url, setUrl] = useState('');
const [error, setError] = useState('');
const full = images.length >= limit;
function add() {
const result = imageSchema.safeParse(url.trim());
if (!result.success) {
setError('Bitte einen lokalen Bildpfad oder eine vollständige HTTPS-Bildadresse eingeben.');
return;
}
if (images.includes(result.data)) {
setError('Dieses Bild ist bereits hinzugefügt.');
return;
}
onAdd(result.data);
setUrl('');
setError('');
}
return (
<div className="image-collection-editor">
<div className="image-gallery">
{images.map((src, n) => (
<div key={`${src}-${n}`}>
<RecipeImage src={src} alt={`${label} ${n + 1}`} loading="lazy" />
<button
type="button"
className="text-button danger"
disabled={disabled}
aria-label={`${label} ${n + 1} entfernen`}
onClick={() => onRemove(n)}
>
Entfernen
</button>
</div>
))}
</div>
<label>
{label}: Bildadresse
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={disabled || full}
placeholder="https://… oder /media/…webp"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (url.trim() && !disabled && !full) add();
}
}}
/>
</label>
<div className="button-row">
<button
type="button"
className="button secondary"
disabled={disabled || full || !url.trim()}
onClick={add}
>
<Plus size={16} />
{label} verlinken
</button>
<label className="button secondary">
<Upload size={16} />
{label} hochladen
<input
className="visually-hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
disabled={disabled || full}
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
onBusy(true);
setError('');
try {
onAdd(await uploadImage(file));
} catch (e) {
setError((e as Error).message);
} finally {
onBusy(false);
}
}}
/>
</label>
</div>
<p className="small muted">
{images.length} / {limit} Bilder · Externe Bilder werden direkt von der HTTPS-Adresse
geladen.
</p>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
</div>
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { RecipeImage } from './recipe-image';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
@@ -28,7 +29,7 @@ export function RecipeCard({ recipe: r }: { recipe: SavedRecipe }) {
> >
<Link href={`/rezepte/${r.id}`} className="card-image"> <Link href={`/rezepte/${r.id}`} className="card-image">
{r.image ? ( {r.image ? (
<img src={r.image} alt={r.title} loading="lazy" /> <RecipeImage src={r.image} alt={r.title} loading="lazy" />
) : ( ) : (
<div className="recipe-art"> <div className="recipe-art">
<Utensils size={56} strokeWidth={1} /> <Utensils size={56} strokeWidth={1} />
+26 -6
View File
@@ -25,12 +25,14 @@ import {
ingredientFactor, ingredientFactor,
scaledAmount, scaledAmount,
formatAmount, formatAmount,
renderStep, ingredientLabel,
mergeShopping, mergeShopping,
type ShoppingItem, type ShoppingItem,
} from '@/lib/calculator'; } from '@/lib/calculator';
import { readLocal, writeLocal, useLocal, toast } from '@/lib/client'; import { readLocal, writeLocal, useLocal, toast } from '@/lib/client';
import { RecipeCard } from './library'; import { RecipeCard } from './library';
import { StepInstructions, StepImages } from './step-content';
import { RecipeImage } from './recipe-image';
function AmountInput({ function AmountInput({
ingredient: i, ingredient: i,
factor, factor,
@@ -189,7 +191,7 @@ export function RecipeDetail({
</div> </div>
<div className="recipe-cover"> <div className="recipe-cover">
{r.image ? ( {r.image ? (
<img src={r.image} alt={r.title} /> <RecipeImage src={r.image} alt={r.title} />
) : ( ) : (
<div className="recipe-art"> <div className="recipe-art">
<ChefHat size={90} strokeWidth={1} /> <ChefHat size={90} strokeWidth={1} />
@@ -335,11 +337,28 @@ export function RecipeDetail({
</div> </div>
<ol className="steps-list"> <ol className="steps-list">
{r.steps.map((s, n) => ( {r.steps.map((s, n) => (
<li key={s.id}> <li key={s.id} className="preparation-step">
<div className="preparation-step-heading">
<span className="step-number">{String(n + 1).padStart(2, '0')}</span> <span className="step-number">{String(n + 1).padStart(2, '0')}</span>
<div>
<h3>{s.title || `Schritt ${n + 1}`}</h3> <h3>{s.title || `Schritt ${n + 1}`}</h3>
<p>{renderStep(s.text, r, factor)}</p> </div>
{s.ingredients.length > 0 && (
<div className="step-supplies">
<span>Für diesen Schritt</span>
<ul>
{s.ingredients.map((id) => {
const ingredient = r.ingredients.find((i) => i.id === id);
return ingredient ? (
<li key={id}>{ingredientLabel(ingredient, factor)}</li>
) : null;
})}
</ul>
</div>
)}
<StepInstructions text={s.text} recipe={r} factor={factor} />
<StepImages step={s} />
{s.timers.length > 0 && (
<div className="step-timers">
{s.timers.map((t) => ( {s.timers.map((t) => (
<span className="timer-tag" key={t.id}> <span className="timer-tag" key={t.id}>
<Clock3 size={14} /> <Clock3 size={14} />
@@ -347,6 +366,7 @@ export function RecipeDetail({
</span> </span>
))} ))}
</div> </div>
)}
</li> </li>
))} ))}
</ol> </ol>
@@ -385,7 +405,7 @@ export function RecipeDetail({
<div className="image-gallery"> <div className="image-gallery">
{r.images.map((image, n) => ( {r.images.map((image, n) => (
<a href={image} key={image} target="_blank" rel="noreferrer"> <a href={image} key={image} target="_blank" rel="noreferrer">
<img src={image} alt={`${r.title} – Ansicht ${n + 1}`} loading="lazy" /> <RecipeImage src={image} alt={`${r.title} – Ansicht ${n + 1}`} loading="lazy" />
</a> </a>
))} ))}
</div> </div>
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { useEffect, useRef, useState, type ImgHTMLAttributes } from 'react';
import { ImageOff } from 'lucide-react';
import './step-content.css';
type ImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & { src: string };
function ImageContent(props: ImageProps) {
const [failed, setFailed] = useState(false);
const image = useRef<HTMLImageElement>(null);
useEffect(() => {
// A cached failure can occur before React attaches the error handler during hydration.
if (image.current?.complete && image.current.naturalWidth === 0) setFailed(true);
}, []);
return failed ? (
<div
className="recipe-image-fallback"
role="img"
aria-label={`${props.alt} – Bild nicht verfügbar`}
>
<ImageOff size={24} />
<span>Bild nicht verfügbar</span>
</div>
) : (
<img {...props} ref={image} referrerPolicy="no-referrer" onError={() => setFailed(true)} />
);
}
export function RecipeImage(props: ImageProps) {
return <ImageContent key={props.src} {...props} />;
}
+177
View File
@@ -0,0 +1,177 @@
.steps-list > .preparation-step {
display: block;
padding: 24px;
margin-bottom: 18px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
break-inside: avoid;
}
.preparation-step-heading {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.preparation-step-heading .step-number {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex-shrink: 0;
font-size: 22px;
background: var(--soft-green);
color: var(--green);
border-radius: 12px;
}
.steps-list .preparation-step-heading h3 {
margin: 0;
font-size: 22px;
}
.step-instructions {
overflow-wrap: anywhere;
}
.step-instructions > p,
.steps-list .step-instructions > p {
color: var(--ink);
font-size: 14px;
line-height: 1.85;
margin: 0 0 16px;
}
.step-instructions > ul,
.step-instructions > ol {
padding-left: 22px;
margin: 0 0 16px;
}
.step-instructions li {
padding-left: 5px;
margin-bottom: 10px;
font-size: 14px;
line-height: 1.85;
}
.step-instructions li::marker {
color: var(--accent);
}
.instruction-ingredient {
font-weight: 600;
color: var(--green);
}
.step-supplies {
margin: 0 0 20px;
padding: 12px 14px;
background: var(--bg);
border-radius: 10px;
}
.step-supplies > span {
font-size: 9px;
letter-spacing: 1px;
color: var(--muted);
text-transform: uppercase;
}
.step-supplies ul {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
list-style: none;
margin: 7px 0 0;
padding: 0;
}
.step-supplies li {
font-size: 11px;
color: var(--green);
}
.step-timers {
border-top: 1px solid var(--line);
padding-top: 14px;
margin-top: 18px;
}
.step-images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
gap: 12px;
margin: 18px 0;
}
.step-images a {
min-width: 0;
}
.step-images img {
display: block;
width: 100%;
height: 210px;
object-fit: cover;
border-radius: 12px;
}
.recipe-image-fallback {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
min-height: 180px;
height: 100%;
width: 100%;
background: var(--surface-2);
color: var(--muted);
border-radius: 12px;
font-size: 12px;
}
.admin-thumb .recipe-image-fallback {
min-height: 0;
}
.admin-thumb .recipe-image-fallback span {
display: none;
}
.image-collection-editor {
margin: 20px 0;
}
.image-collection-editor .button-row {
flex-wrap: wrap;
}
.image-collection-editor .image-gallery:empty {
display: none;
}
.image-collection-editor .image-gallery {
margin: 0 0 16px;
}
.cooking-step .step-instructions {
max-width: 780px;
margin: 24px auto;
text-align: left;
}
.cooking-step .step-instructions p,
.cooking-step .step-instructions li {
font-size: clamp(16px, 2vw, 21px);
line-height: 1.85;
}
.cooking-step .step-images {
max-width: 780px;
margin: 24px auto;
}
.editor-preview .step-instructions p,
.editor-preview .step-instructions li {
font-size: 12px;
}
@media (max-width: 600px) {
.steps-list > .preparation-step {
padding: 18px;
}
.steps-list .preparation-step-heading h3 {
font-size: 20px;
}
.preparation-step-heading {
gap: 12px;
}
.step-images img {
height: 180px;
}
}
@media print {
.steps-list > .preparation-step {
padding: 12px 0;
border: 0;
border-bottom: 1px solid #ddd;
}
.step-images {
display: none;
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { Recipe, Step } from '@/lib/recipe';
import { ingredientLabel } from '@/lib/calculator';
import { instructionBlocks } from '@/lib/instructions';
import { RecipeImage } from './recipe-image';
import './step-content.css';
export function StepInstructions({
text,
recipe,
factor,
}: {
text: string;
recipe: Recipe;
factor: string;
}) {
const inline = (value: string) =>
value.split(/(\{\{[a-zA-Z0-9_-]+\}\})/g).map((part, n) => {
const id = part.match(/^\{\{(.+)\}\}$/)?.[1];
const ingredient = id ? recipe.ingredients.find((i) => i.id === id) : undefined;
return ingredient ? (
<strong className="instruction-ingredient" key={n}>
{ingredientLabel(ingredient, factor)}
</strong>
) : (
part
);
});
return (
<div className="step-instructions">
{instructionBlocks(text).map((block, n) =>
block.type === 'paragraph' ? (
<p key={n}>{inline(block.lines[0])}</p>
) : block.type === 'ordered' ? (
<ol key={n}>
{block.lines.map((line, i) => (
<li key={i}>{inline(line)}</li>
))}
</ol>
) : (
<ul key={n}>
{block.lines.map((line, i) => (
<li key={i}>{inline(line)}</li>
))}
</ul>
),
)}
</div>
);
}
export function StepImages({ step }: { step: Step }) {
if (!step.images?.length) return null;
return (
<div className="step-images" aria-label="Bilder zu diesem Schritt">
{step.images.map((src, n) => (
<a key={`${src}-${n}`} href={src} target="_blank" rel="noreferrer">
<RecipeImage
src={src}
alt={`${step.title || 'Zubereitung'} – Bild ${n + 1}`}
loading="lazy"
/>
</a>
))}
</div>
);
}
+2
View File
@@ -53,6 +53,7 @@ export function parseImport(raw: string, format: 'json' | 'xml') {
steps: (r.steps?.step ?? []).map((s: Record<string, any>) => ({ steps: (r.steps?.step ?? []).map((s: Record<string, any>) => ({
...s, ...s,
ingredients: s.ingredients?.ref ?? [], ingredients: s.ingredients?.ref ?? [],
images: s.images?.image ?? [],
timers: (s.timers?.timer ?? []).map((t: Record<string, any>) => ({ timers: (s.timers?.timer ?? []).map((t: Record<string, any>) => ({
...t, ...t,
seconds: num(t.seconds), seconds: num(t.seconds),
@@ -78,6 +79,7 @@ export function exportRecipes(recipes: Recipe[], format: 'json' | 'xml') {
steps: { steps: {
step: r.steps.map((s) => ({ step: r.steps.map((s) => ({
...s, ...s,
images: { image: s.images ?? [] },
ingredients: { ref: s.ingredients }, ingredients: { ref: s.ingredients },
timers: { timer: s.timers }, timers: { timer: s.timers },
})), })),
+41
View File
@@ -0,0 +1,41 @@
export type InstructionBlock = { type: 'paragraph' | 'unordered' | 'ordered'; lines: string[] };
/** Plain text only: preserve explicit paragraphs and group consecutive list items. */
export function instructionBlocks(text: string): InstructionBlock[] {
const normalized = text.replace(/\r\n?/g, '\n').trim();
if (!normalized) return [];
const blocks: InstructionBlock[] = [];
for (const line of normalized.split('\n')) {
const value = line.trim();
if (!value) continue;
const bullet = value.match(/^(?:[-*•]\s+|\d+[.)]\s+)(.+)$/);
const type = bullet ? (/^\d/.test(value) ? 'ordered' : 'unordered') : 'paragraph';
const content = bullet ? bullet[1] : value;
const previous = blocks.at(-1);
if (type !== 'paragraph' && previous?.type === type) previous.lines.push(content);
else blocks.push({ type, lines: [content] });
}
// Make older, long single-paragraph instructions readable without changing their wording.
if (
blocks.length === 1 &&
blocks[0].type === 'paragraph' &&
normalized.length > 280 &&
!normalized.includes('\n')
) {
const sentences = [
...new Intl.Segmenter('de', { granularity: 'sentence' }).segment(normalized),
].map((s) => s.segment);
if (sentences.length > 2) {
return Array.from({ length: Math.ceil(sentences.length / 2) }, (_, n) => ({
type: 'paragraph',
lines: [
sentences
.slice(n * 2, n * 2 + 2)
.join('')
.trim(),
],
}));
}
}
return blocks;
}
+14 -9
View File
@@ -8,6 +8,16 @@ const text = (max: number) =>
.refine((v) => !/<\/?[a-z][^>]*>/i.test(v), 'HTML ist nicht erlaubt.'); .refine((v) => !/<\/?[a-z][^>]*>/i.test(v), 'HTML ist nicht erlaubt.');
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/); const id = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
export const units = ['g', 'kg', 'ml', 'l', 'Stück', 'TL', 'EL', 'Prise', 'Bund', ''] as const; export const units = ['g', 'kg', 'ml', 'l', 'Stück', 'TL', 'EL', 'Prise', 'Bund', ''] as const;
export const imageSchema = z.union(
[
z.string().regex(/^\/(?:media\/[a-f0-9-]+\.webp|images\/[a-z0-9-]+\.(?:webp|jpg|svg))$/),
z
.url({ protocol: /^https$/ })
.max(2048)
.regex(/^https:\/\/[^@\s/\\]+(?:[/?#][^\s\\]*)?$/i),
],
{ error: 'Bitte einen lokalen Bildpfad oder eine vollständige HTTPS-Bildadresse verwenden.' },
);
export const ingredientSchema = z.object({ export const ingredientSchema = z.object({
id, id,
name: text(160).min(1, 'Zutat benötigt einen Namen.'), name: text(160).min(1, 'Zutat benötigt einen Namen.'),
@@ -22,25 +32,20 @@ export const stepSchema = z.object({
title: text(160).default(''), title: text(160).default(''),
text: text(6000).min(1, 'Schritt benötigt eine Anleitung.'), text: text(6000).min(1, 'Schritt benötigt eine Anleitung.'),
ingredients: z.array(id).max(100).default([]), ingredients: z.array(id).max(100).default([]),
images: z.array(imageSchema).max(10).default([]),
timers: z timers: z
.array(z.object({ id, label: text(100).min(1), seconds: z.number().int().min(1).max(172800) })) .array(z.object({ id, label: text(100).min(1), seconds: z.number().int().min(1).max(172800) }))
.max(10) .max(10)
.default([]), .default([]),
}); });
const image = z
.string()
.regex(
/^\/(?:media\/[a-f0-9-]+\.webp|images\/[a-z0-9-]+\.(?:webp|jpg|svg))$/,
'Nur lokale Bilder sind erlaubt.',
);
export const recipeSchema = z export const recipeSchema = z
.object({ .object({
id: z.string().uuid().optional(), id: z.string().uuid().optional(),
title: text(180).min(2, 'Titel: mindestens 2 Zeichen.'), title: text(180).min(2, 'Titel: mindestens 2 Zeichen.'),
subtitle: text(200).default(''), subtitle: text(200).default(''),
description: text(4000).default(''), description: text(4000).default(''),
image: image.or(z.literal('')).default(''), image: imageSchema.or(z.literal('')).default(''),
images: z.array(image).max(20).default([]), images: z.array(imageSchema).max(20).default([]),
categories: z.array(text(60).min(1)).max(20).default([]), categories: z.array(text(60).min(1)).max(20).default([]),
tags: z.array(text(60).min(1)).max(30).default([]), tags: z.array(text(60).min(1)).max(30).default([]),
cuisine: text(80).default(''), cuisine: text(80).default(''),
@@ -128,7 +133,7 @@ export function emptyRecipe(): Recipe {
ingredients: [ ingredients: [
{ id: 'i1', name: '', amount: null, unit: 'g', section: '', note: '', optional: false }, { id: 'i1', name: '', amount: null, unit: 'g', section: '', note: '', optional: false },
], ],
steps: [{ id: 's1', title: '', text: '', ingredients: [], timers: [] }], steps: [{ id: 's1', title: '', text: '', ingredients: [], images: [], timers: [] }],
tips: '', tips: '',
storage: '', storage: '',
nutrition: null, nutrition: null,
+1
View File
@@ -6,6 +6,7 @@ import { ApiError } from './server';
export function serialize(row: typeof recipes.$inferSelect): SavedRecipe { export function serialize(row: typeof recipes.$inferSelect): SavedRecipe {
return { return {
...row.data, ...row.data,
steps: row.data.steps.map((step) => ({ ...step, images: step.images ?? [] })),
id: row.id, id: row.id,
title: row.title, title: row.title,
published: row.published, published: row.published,
+21
View File
@@ -36,6 +36,9 @@ test('admin creates and edits recipes with recoverable drafts and image upload',
await page.getByRole('textbox', { name: 'Anleitung 1' }).fill('In Salzwasser weich kochen.'); await page.getByRole('textbox', { name: 'Anleitung 1' }).fill('In Salzwasser weich kochen.');
await page.getByRole('button', { name: 'Kartoffeln', exact: true }).click(); await page.getByRole('button', { name: 'Kartoffeln', exact: true }).click();
await page.getByRole('button', { name: 'Timer hinzufügen' }).click(); await page.getByRole('button', { name: 'Timer hinzufügen' }).click();
await page.getByText('Bilder zu Schritt 1 (0)', { exact: true }).click();
await page.getByLabel('Schritt 1: Bild hochladen').setInputFiles('public/images/garden.jpg');
await expect(page.getByRole('img', { name: 'Schritt 1: Bild 1', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Live-Vorschau', exact: true }).click(); await page.getByRole('button', { name: 'Live-Vorschau', exact: true }).click();
await expect(page.locator('.editor-preview')).toContainText('400 g Kartoffeln'); await expect(page.locator('.editor-preview')).toContainText('400 g Kartoffeln');
await page.screenshot({ path: 'docs/screenshots/editor-desktop.png', fullPage: true }); await page.screenshot({ path: 'docs/screenshots/editor-desktop.png', fullPage: true });
@@ -58,11 +61,29 @@ test('admin creates and edits recipes with recoverable drafts and image upload',
const download = await downloadPromise; const download = await downloadPromise;
const data = JSON.parse(await readFile((await download.path())!, 'utf8')); const data = JSON.parse(await readFile((await download.path())!, 'utf8'));
expect(data.recipes[0].title).toBe('Browser-Küchenrezept verbessert'); expect(data.recipes[0].title).toBe('Browser-Küchenrezept verbessert');
const stepImage = data.recipes[0].steps[0].images[0];
expect(stepImage).toMatch(/^\/media\/.+\.webp$/);
expect(
(
await page.request.delete('/api/media', {
headers: { origin: 'http://localhost:3100' },
data: { name: stepImage.split('/').at(-1) },
})
).status(),
).toBe(409);
await page.getByRole('button', { name: 'Browser-Küchenrezept verbessert löschen' }).click(); await page.getByRole('button', { name: 'Browser-Küchenrezept verbessert löschen' }).click();
await page.getByRole('button', { name: 'Endgültig löschen' }).click(); await page.getByRole('button', { name: 'Endgültig löschen' }).click();
await expect(page.getByRole('heading', { name: 'Browser-Küchenrezept verbessert' })).toHaveCount( await expect(page.getByRole('heading', { name: 'Browser-Küchenrezept verbessert' })).toHaveCount(
0, 0,
); );
expect(
(
await page.request.delete('/api/media', {
headers: { origin: 'http://localhost:3100' },
data: { name: stepImage.split('/').at(-1) },
})
).status(),
).toBe(200);
await page.getByRole('button', { name: 'Abmelden' }).click(); await page.getByRole('button', { name: 'Abmelden' }).click();
await expect(page.getByRole('heading', { name: 'Willkommen zurück.' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Willkommen zurück.' })).toBeVisible();
}); });
+107
View File
@@ -1,6 +1,112 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { mkdir } from 'node:fs/promises'; import { mkdir } from 'node:fs/promises';
import { pasta } from '../fixtures';
import { exportRecipes } from '../../src/lib/import';
const id = '10000000-0000-4000-8000-000000000001'; const id = '10000000-0000-4000-8000-000000000001';
test('external images and structured steps survive import, editing, scaling and cooking', async ({
page,
}, info) => {
const headers = { origin: 'http://localhost:3100' };
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.route('https://images.example.org/**', async (route) => {
if (route.request().url().includes('missing')) await route.fulfill({ status: 404, body: '' });
else await route.fulfill({ contentType: 'image/jpeg', path: 'public/images/garden.jpg' });
});
const cover = 'https://images.example.org/cover.jpg?width=1200&quality=80';
const stepImage = 'https://images.example.org/step.jpg';
const recipe = {
...pasta,
id: undefined,
title: `Bilderküche ${info.project.name}`,
image: cover,
images: ['https://images.example.org/gallery.jpg'],
steps: pasta.steps.map((s, n) =>
n === 0
? {
...s,
text: 'Alles für das Pesto vorbereiten.\n\n- {{basilikum}} waschen und trocken schütteln.\n- {{kaese}} fein reiben.\n\n1. Einen großen Topf Wasser aufsetzen.\n2. Die Zutaten bereitstellen.',
images: [stepImage, 'https://images.example.org/missing.jpg'],
}
: s,
),
};
expect(
(
await page.request.post('/api/auth/login', {
headers,
data: { username: 'testadmin', password: process.env.TEST_PASSWORD },
})
).ok(),
).toBe(true);
const imported = await page.request.post('/api/import', {
headers,
data: { format: 'json', preview: false, raw: exportRecipes([recipe], 'json') },
});
expect(imported.status()).toBe(201);
const [saved] = await imported.json();
try {
await page.goto(`/admin/rezepte/${saved.id}`);
await expect(page.getByLabel('Titelbild-Adresse (HTTPS oder lokaler Bildpfad)')).toHaveValue(
cover,
);
await page.getByRole('button', { name: 'Zubereitung', exact: true }).click();
await page.getByText('Bilder zu Schritt 1 (2)', { exact: true }).click();
await page
.getByLabel('Schritt 1: Bild: Bildadresse', { exact: true })
.fill('https://images.example.org/another.jpg');
await page.getByRole('button', { name: 'Schritt 1: Bild verlinken', exact: true }).click();
await page.getByRole('button', { name: 'Rezept speichern', exact: true }).click();
await expect(page.getByRole('status')).toContainText('erfolgreich gespeichert');
const exported = await (await page.request.get(`/api/export?id=${saved.id}`)).json();
expect(exported.recipes[0].steps[0].images).toEqual([
...recipe.steps[0].images,
'https://images.example.org/another.jpg',
]);
const response = await page.goto(`/rezepte/${saved.id}`);
expect(response?.headers()['content-security-policy']).toContain("img-src 'self' https:");
const first = page.locator('.preparation-step').first();
await expect(first.locator('.step-instructions > p')).toHaveCount(1);
await expect(first.locator('.step-instructions > ul > li')).toHaveCount(2);
await expect(first.locator('.step-instructions > ol > li')).toHaveCount(2);
await first.locator('.step-images').scrollIntoViewIfNeeded();
await expect(first.getByRole('img', { name: /Bild 2 – Bild nicht verfügbar/ })).toBeVisible();
await expect
.poll(() =>
page.locator('.recipe-cover img').evaluate((img: HTMLImageElement) => img.naturalWidth),
)
.toBeGreaterThan(0);
await expect(first.locator('.step-images img').first()).toHaveJSProperty('complete', true);
expect(
await first
.locator('.step-images img')
.first()
.evaluate((img: HTMLImageElement) => img.naturalWidth),
).toBeGreaterThan(0);
await page.getByRole('spinbutton', { name: 'Portionen', exact: true }).fill('4');
await expect(first.locator('.instruction-ingredient').first()).toHaveText('80 g Basilikum');
await expect(first.locator('.step-supplies')).toContainText('80 g Basilikum');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true,
);
await page.screenshot({ path: info.outputPath('structured-steps.png'), fullPage: true });
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
await first.screenshot({ path: info.outputPath('structured-step-dark.png') });
await page.goto(`/rezepte/${saved.id}/kochen?faktor=2`);
await expect(page.locator('.cooking-instruction .instruction-ingredient').first()).toHaveText(
'80 g Basilikum',
);
await expect(page.locator('.cooking-step .step-images img')).toHaveCount(2);
await expect(page.locator('.cooking-step')).toHaveCSS('opacity', '1');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true,
);
await page.screenshot({ path: info.outputPath('structured-cooking.png'), fullPage: true });
expect(errors).toEqual([]);
} finally {
await page.request.delete(`/api/recipes/${saved.id}`, { headers });
}
});
test('editor remains usable on the current viewport in dark mode', async ({ page }, info) => { test('editor remains usable on the current viewport in dark mode', async ({ page }, info) => {
await page.goto('/admin'); await page.goto('/admin');
await page.getByLabel('Benutzername').fill('testadmin'); await page.getByLabel('Benutzername').fill('testadmin');
@@ -146,6 +252,7 @@ test('guided cooking restores scaled progress and timestamp timers through navig
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
await page.getByRole('button', { name: 'Pinienkerne rösten starten' }).click(); await page.getByRole('button', { name: 'Pinienkerne rösten starten' }).click();
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
await expect(page.locator('.cooking-step')).toHaveCSS('opacity', '1');
await page.screenshot({ await page.screenshot({
path: `docs/screenshots/cooking-${info.project.name}.png`, path: `docs/screenshots/cooking-${info.project.name}.png`,
fullPage: true, fullPage: true,
+47
View File
@@ -17,6 +17,53 @@ describe('Versioned, safe, lossless recipe interchange', () => {
}; };
expect(parseImport(exportRecipes([r], 'xml'), 'xml').recipes[0]).toEqual(r); expect(parseImport(exportRecipes([r], 'xml'), 'xml').recipes[0]).toEqual(r);
}); });
it.each(['json', 'xml'] as const)(
'roundtrips external cover, gallery and step images in %s',
(format) => {
const r = recipeSchema.parse({
...pasta,
image: 'https://images.example.org/cover?width=1200&format=webp',
images: ['https://images.example.org/gallery.jpg'],
steps: pasta.steps.map((s, n) => ({
...s,
images:
n === 0 ? ['/images/kitchen.jpg', 'https://images.example.org/step.png?x=1&y=2'] : [],
})),
});
expect(parseImport(exportRecipes([r], format), format).recipes[0]).toEqual(r);
},
);
it('keeps older recipes without step images compatible', () => {
const r = { ...pasta, steps: pasta.steps.map(({ images, ...step }) => step) };
expect(
parseImport(JSON.stringify({ version: 1, recipes: [r] }), 'json').recipes[0].steps.every(
(s) => s.images.length === 0,
),
).toBe(true);
});
it.each([
'http://example.org/a.jpg',
'//example.org/a.jpg',
'javascript:alert(1)',
'data:image/png;base64,AAAA',
'https://user:password@example.org/a.jpg',
'https://',
'https://example.org/\\evil',
'https://example.org/' + 'a'.repeat(2048),
])('rejects unsupported image URL %s', (image) => {
expect(recipeSchema.safeParse({ ...pasta, image }).success).toBe(false);
expect(
recipeSchema.safeParse({ ...pasta, steps: [{ ...pasta.steps[0], images: [image] }] }).success,
).toBe(false);
});
it('limits step images', () => {
expect(
recipeSchema.safeParse({
...pasta,
steps: [{ ...pasta.steps[0], images: Array(11).fill('/images/kitchen.jpg') }],
}).success,
).toBe(false);
});
it('rejects unknown versions and missing mandatory fields', () => { it('rejects unknown versions and missing mandatory fields', () => {
expect(() => parseImport('{"version":2,"recipes":[]}', 'json')).toThrow(); expect(() => parseImport('{"version":2,"recipes":[]}', 'json')).toThrow();
expect(() => parseImport('{"version":1,"recipes":[{"title":"Test"}]}', 'json')).toThrow(); expect(() => parseImport('{"version":1,"recipes":[{"title":"Test"}]}', 'json')).toThrow();
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { instructionBlocks } from '../../src/lib/instructions';
describe('Readable plain-text instructions', () => {
it('preserves ingredient references, paragraphs and both list forms', () => {
expect(
instructionBlocks(
'Vorbereiten.\r\n\r\n- {{fleisch}} schneiden.\r\n- Salzen.\n\nAnschließend:\n1. Anbraten.\n2) Servieren.',
),
).toEqual([
{ type: 'paragraph', lines: ['Vorbereiten.'] },
{ type: 'unordered', lines: ['{{fleisch}} schneiden.', 'Salzen.'] },
{ type: 'paragraph', lines: ['Anschließend:'] },
{ type: 'ordered', lines: ['Anbraten.', 'Servieren.'] },
]);
});
it('keeps decimal quantities and temperatures intact when splitting legacy text', () => {
const text =
'Das Fleisch in 1.5 cm große Würfel schneiden und anschließend gründlich trocken tupfen. Die Pfanne auf mittlerer Hitze vorheizen und das Öl darin gleichmäßig verteilen. Das Fleisch vorsichtig hineinlegen und unter gelegentlichem Wenden goldbraun braten. Bei 180 °C im vorgeheizten Ofen fertig garen und anschließend servieren.';
const blocks = instructionBlocks(text);
expect(blocks.length).toBeGreaterThan(1);
expect(blocks.flatMap((b) => b.lines).join(' ')).toBe(text);
});
it('leaves short prose, explicit line breaks and literal HTML as text', () => {
expect(instructionBlocks('Bei 180 °C garen.')).toEqual([
{ type: 'paragraph', lines: ['Bei 180 °C garen.'] },
]);
expect(instructionBlocks('<script>test</script>\nFertig.')).toEqual([
{ type: 'paragraph', lines: ['<script>test</script>'] },
{ type: 'paragraph', lines: ['Fertig.'] },
]);
});
});