diff --git a/docs/CHATGPT-PROMPT.md b/docs/CHATGPT-PROMPT.md index f8b8e29..750b6ed 100644 --- a/docs/CHATGPT-PROMPT.md +++ b/docs/CHATGPT-PROMPT.md @@ -25,15 +25,17 @@ Antworte ausschließlich mit einem vollständigen JSON-Objekt, ohne Markdown-Cod - `optional`: JSON-Boolean `true` oder `false`, kein Text. - „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. -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. - `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. - `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. 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`. -11. Setze `"image": ""`, `"images": []` und `"published": false`. Keine erfundenen Bild-URLs. +11. Bilder sind über lokale HamHam-Pfade (`/media/.webp`, mitgelieferte `/images/.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. ## Inhalt und Grenzen @@ -102,13 +104,15 @@ Dieses kleine Beispiel zeigt die verbindliche Struktur. Ersetze den Inhalt volls "title": "Gurke vorbereiten", "text": "{{z_gurke}} waschen, die Enden entfernen und die Gurke fein hobeln.", "ingredients": ["z_gurke"], + "images": [], "timers": [] }, { "id": "s_marinieren", "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"], + "images": [], "timers": [ { "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. - `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. +- 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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b26272c..796e7cb 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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. +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 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). diff --git a/docs/IMPORT.md b/docs/IMPORT.md index 38bc8a6..58d120b 100644 --- a/docs/IMPORT.md +++ b/docs/IMPORT.md @@ -47,17 +47,26 @@ Schritt: { "id": "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"], + "images": [], "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. +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 -Nur lokale URLs: `/media/.webp` oder mitgelieferte `/images/.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/.webp` oder mitgelieferte `/images/.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. @@ -65,16 +74,17 @@ 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 ``, darunter `…`. Rezeptfelder entsprechen JSON. Array-Container: -| JSON | XML | -| ---------------- | ----------------------------------------------------------- | -| recipes | `…` | -| ingredients | `…` | -| steps | `…` | -| timers | `…` | -| categories | `Vegetarisch` | -| tags | `Schnell` | -| images | `/media/…webp` | -| step.ingredients | `huhn` | +| JSON | XML | +| ---------------- | ----------------------------------------------------------------- | +| recipes | `…` | +| ingredients | `…` | +| steps | `…` | +| timers | `…` | +| categories | `Vegetarisch` | +| tags | `Schnell` | +| images | `/media/…webp` | +| step.ingredients | `huhn` | +| step.images | `https://example.org/schritt.jpg` | Leere Menge: ``. Keine Nährwerte: ``. Boolean: `true`/`false`. Texte standardkonform escapen (`&`, `<` 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. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 7e6f0b8..1d83cc8 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -2,7 +2,7 @@ Stand: **24. Oktober 2026**. Die folgenden Prüfungen wurden tatsächlich ausgeführt, nicht lediglich vorbereitet. -## Ergebnisse +## Ergebnisse der Erstprüfung | 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. +### 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 - 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. - 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. -- 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. Diese Grenzen sind keine als fertig ausgegebenen ungeprüften Plattformversprechen. Die oben genannten implementierten Kernabläufe wurden in der dokumentierten Umgebung erfolgreich geprüft. diff --git a/docs/screenshots/cooking-desktop.png b/docs/screenshots/cooking-desktop.png index 96c6cc0..a07a9c3 100644 Binary files a/docs/screenshots/cooking-desktop.png and b/docs/screenshots/cooking-desktop.png differ diff --git a/docs/screenshots/cooking-mobile.png b/docs/screenshots/cooking-mobile.png index 1f377fd..cc24209 100644 Binary files a/docs/screenshots/cooking-mobile.png and b/docs/screenshots/cooking-mobile.png differ diff --git a/docs/screenshots/cooking-tablet.png b/docs/screenshots/cooking-tablet.png index 0a01a41..772d8a1 100644 Binary files a/docs/screenshots/cooking-tablet.png and b/docs/screenshots/cooking-tablet.png differ diff --git a/docs/screenshots/editor-desktop.png b/docs/screenshots/editor-desktop.png index b84fa0b..da48f00 100644 Binary files a/docs/screenshots/editor-desktop.png and b/docs/screenshots/editor-desktop.png differ diff --git a/docs/screenshots/recipe-desktop.png b/docs/screenshots/recipe-desktop.png index 285f49c..8d5089e 100644 Binary files a/docs/screenshots/recipe-desktop.png and b/docs/screenshots/recipe-desktop.png differ diff --git a/docs/screenshots/recipe-mobile.png b/docs/screenshots/recipe-mobile.png index fae5a04..57c2b8b 100644 Binary files a/docs/screenshots/recipe-mobile.png and b/docs/screenshots/recipe-mobile.png differ diff --git a/docs/screenshots/recipe-tablet.png b/docs/screenshots/recipe-tablet.png index c3cf99f..f561c48 100644 Binary files a/docs/screenshots/recipe-tablet.png and b/docs/screenshots/recipe-tablet.png differ diff --git a/next.config.mjs b/next.config.mjs index e0499f1..7785a60 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,7 +16,7 @@ const config = { value: "default-src 'self'; script-src 'self' 'unsafe-inline'" + (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'", }, ], }, diff --git a/public/examples/hamham.schema.json b/public/examples/hamham.schema.json index b75c612..66b15b0 100644 --- a/public/examples/hamham.schema.json +++ b/public/examples/hamham.schema.json @@ -37,8 +37,17 @@ "default": "", "anyOf": [ { - "type": "string", - "pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$" + "anyOf": [ + { + "type": "string", + "pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$" + }, + { + "type": "string", + "maxLength": 2048, + "pattern": "^https:\\/\\/[^@\\s/\\\\]+(?:[/?#][^\\s\\\\]*)?$" + } + ] }, { "type": "string", @@ -51,8 +60,17 @@ "maxItems": 20, "type": "array", "items": { - "type": "string", - "pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$" + "anyOf": [ + { + "type": "string", + "pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$" + }, + { + "type": "string", + "maxLength": 2048, + "pattern": "^https:\\/\\/[^@\\s/\\\\]+(?:[/?#][^\\s\\\\]*)?$" + } + ] } }, "categories": { @@ -214,6 +232,24 @@ "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": { "default": [], "maxItems": 10, @@ -250,6 +286,7 @@ "title", "text", "ingredients", + "images", "timers" ], "additionalProperties": false diff --git a/public/examples/kochbuch.json b/public/examples/kochbuch.json index e8d4f43..5f9861a 100644 --- a/public/examples/kochbuch.json +++ b/public/examples/kochbuch.json @@ -97,6 +97,7 @@ "kaese", "salz" ], + "images": [], "timers": [] }, { @@ -109,6 +110,7 @@ "oel", "kaese" ], + "images": [], "timers": [ { "id": "roesten", @@ -124,6 +126,7 @@ "ingredients": [ "pasta" ], + "images": [], "timers": [ { "id": "nudeln", @@ -140,6 +143,7 @@ "zitrone", "salz" ], + "images": [], "timers": [] } ], @@ -256,6 +260,7 @@ "tomaten", "gurke" ], + "images": [], "timers": [] }, { @@ -265,6 +270,7 @@ "ingredients": [ "kichererbsen" ], + "images": [], "timers": [ { "id": "roesten", @@ -283,6 +289,7 @@ "wasser", "salz" ], + "images": [], "timers": [] } ], @@ -386,6 +393,7 @@ "kartoffeln", "zwiebel" ], + "images": [], "timers": [] }, { @@ -396,6 +404,7 @@ "oel", "bruehe" ], + "images": [], "timers": [ { "id": "suppe", @@ -412,6 +421,7 @@ "bohnen", "kraeuter" ], + "images": [], "timers": [ { "id": "bohnen", diff --git a/public/examples/pljeskavica.json b/public/examples/pljeskavica.json index 31d90f8..3d47878 100644 --- a/public/examples/pljeskavica.json +++ b/public/examples/pljeskavica.json @@ -107,6 +107,7 @@ "wasser", "pfeffer" ], + "images": [], "timers": [] }, { @@ -114,6 +115,7 @@ "title": "Geschmack entwickeln lassen", "text": "Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.", "ingredients": [], + "images": [], "timers": [ { "id": "ruhezeit", @@ -127,6 +129,7 @@ "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.", "ingredients": [], + "images": [], "timers": [] }, { @@ -134,6 +137,7 @@ "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.", "ingredients": [], + "images": [], "timers": [ { "id": "erste-seite", @@ -155,6 +159,7 @@ "brot", "ajvar" ], + "images": [], "timers": [] } ], diff --git a/public/examples/pljeskavica.xml b/public/examples/pljeskavica.xml index c8bb5f3..cfcf04e 100644 --- a/public/examples/pljeskavica.xml +++ b/public/examples/pljeskavica.xml @@ -107,6 +107,7 @@ wasser pfeffer + @@ -114,6 +115,7 @@ Geschmack entwickeln lassen Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen. + ruhezeit @@ -127,6 +129,7 @@ Flache Laibchen formen Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken. + @@ -134,6 +137,7 @@ Kräftig grillen 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. + erste-seite @@ -155,6 +159,7 @@ brot ajvar + diff --git a/src/app/api/[...path]/route.ts b/src/app/api/[...path]/route.ts index 3ebbc1f..61836a4 100644 --- a/src/app/api/[...path]/route.ts +++ b/src/app/api/[...path]/route.ts @@ -249,7 +249,10 @@ async function handle(req: Request, ctx: Context) { .parse(await jsonBody(req)); if ( (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.'); diff --git a/src/components/admin.tsx b/src/components/admin.tsx index 03cab49..06c0451 100644 --- a/src/components/admin.tsx +++ b/src/components/admin.tsx @@ -18,7 +18,9 @@ import { } from 'lucide-react'; import { api, toast } from '@/lib/client'; 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'; export function Login() { const router = useRouter(), @@ -155,7 +157,7 @@ export function AdminDashboard({ {recipes.map((r) => (
- {r.image ? : } + {r.image ? : }
@@ -387,7 +389,11 @@ function ImportPanel({ onImported }: { onImported: () => void }) {
    {r.steps.map((s) => ( -
  1. {renderStep(s.text, r, '1')}
  2. +
  3. + {s.title} + + +
  4. ))}
diff --git a/src/components/cooking.tsx b/src/components/cooking.tsx index ba29dc2..34d7a8d 100644 --- a/src/components/cooking.tsx +++ b/src/components/cooking.tsx @@ -18,7 +18,8 @@ import { } from 'lucide-react'; import Decimal from 'decimal.js'; 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 { type CookingProgress, type TimerState, @@ -218,7 +219,10 @@ export function Cooking({ > JETZT GEHT’S ANS KOCHEN

{current.title || `Schritt ${state.step + 1}`}

-

{renderStep(current.text, r, state.factor)}

+
+ +
+
{current.ingredients.map((id) => { const i = r.ingredients.find((i) => i.id === id)!; diff --git a/src/components/editor.tsx b/src/components/editor.tsx index e0eeb0a..7b5384d 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -18,8 +18,11 @@ import { 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 { ingredientLabel } from '@/lib/calculator'; 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 }) { const router = useRouter(), key = `hamham:draft:${initial?.id ?? 'new'}`; @@ -39,7 +42,10 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) { useEffect(() => { const draft = readLocal<{ recipe: Recipe; time: string } | null>(key, null); if (draft) { - setRecipe(draft.recipe); + setRecipe({ + ...draft.recipe, + steps: draft.recipe.steps.map((s) => ({ ...s, images: s.images ?? [] })), + }); dirty.current = true; setSaved( `Lokaler Entwurf vom ${new Date(draft.time).toLocaleString('de-AT')} wiederhergestellt`, @@ -76,6 +82,19 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) { 'steps', 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) { if (to < 0 || to >= recipe[kind].length) return; if (kind === 'ingredients') { @@ -115,12 +134,11 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) { setBusy(false); } } - async function upload(file: File, additional = false) { + async function upload(file: File) { setBusy(true); try { const url = await uploadImage(file); - if (additional) change('images', [...recipe.images, url]); - else change('image', url); + change('image', url); } catch (e) { setError((e as Error).message); } finally { @@ -241,7 +259,7 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) { > {recipe.image ? ( <> - Titelbild +
@@ -397,7 +415,8 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {

Schritt für Schritt zum Genuss.

- 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.

{recipe.steps.map((s, n) => (
updateStep(n, { text: e.target.value })} /> @@ -541,6 +563,22 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) { Timer hinzufügen
+
+ + Bilder zu Schritt {n + 1} ({s.images?.length ?? 0}) + + addImage(url, s.id)} + onRemove={(index) => + updateStep(n, { images: s.images.filter((_, i) => i !== index) }) + } + /> +
))} - - ))} - - + addImage(url)} + onRemove={(index) => + change( + 'images', + recipe.images.filter((_, i) => i !== index), + ) + } + /> )} {preview && (