Adds initial state of project to repo
BIN
Pictures/LimettenKokosMakronen.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
Pictures/MozartKekse.png
Normal file
|
After Width: | Height: | Size: 657 KiB |
BIN
Pictures/PistazienTraumstuecke.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
Pictures/eierlikoertorte.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
Pictures/ico/44086.jpg
Normal file
|
After Width: | Height: | Size: 5.1 MiB |
BIN
Pictures/ico/KH.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Pictures/ico/chef.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Pictures/ico/cloche.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
Pictures/ico/cooking.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
Pictures/ico/dish.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
Pictures/ico/fett.png
Normal file
|
After Width: | Height: | Size: 789 B |
BIN
Pictures/ico/fridge.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
Pictures/ico/kcal.png
Normal file
|
After Width: | Height: | Size: 969 B |
BIN
Pictures/ico/marinade.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
Pictures/ico/oberunter.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
Pictures/ico/oven.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
Pictures/ico/plate.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
Pictures/ico/pot.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Pictures/ico/protein.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Pictures/ico/robot.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
Pictures/ico/whisk.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Pictures/lebkuchen.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
Pictures/sauerteigToastbrot.png
Normal file
|
After Width: | Height: | Size: 1,016 KiB |
40
Recipes/ButterChicken.tex
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
\recipename{Butter Chicken}
|
||||||
|
|
||||||
|
\preptime{60 min} \cooktime{60 min} \marinade{1-24 h} \servings{3}
|
||||||
|
\\\hrule
|
||||||
|
|
||||||
|
\recipe{
|
||||||
|
\unit[2]{Stk.} & Nelken\\
|
||||||
|
\unit[1]{Stk.} & Zimtstangen\\
|
||||||
|
\unit[3]{Stk.} & Kardamomkapseln\\
|
||||||
|
\unit[1]{TL} & Korianderpulver\\
|
||||||
|
\unit[1]{TL} & Chilipulver\\
|
||||||
|
\unit[1]{TL} & Salz\\
|
||||||
|
\\
|
||||||
|
\unit[40]{g} & Butter\\
|
||||||
|
\unit[2]{EL} & Joghurt\\
|
||||||
|
\unit[300]{g} & Hühnerfilet, gewürfelt\\
|
||||||
|
\unit[2]{Stk.} & Tomaten, gewürfelt\\
|
||||||
|
\unit[40]{g} & Tomatenmark\\
|
||||||
|
\unit[1,5]{TL} & Knoblauch, fein gerieben\\
|
||||||
|
\unit[1,5]{TL} & Ingwer, fein gerieben\\
|
||||||
|
\unit[150]{g} & Cremefine
|
||||||
|
}{
|
||||||
|
}{
|
||||||
|
\item
|
||||||
|
Chili, Salz, 2/3 vom Knoblauch, 2/3 vom Ingwer, Korianderpulver und Joghurt in einer Schüssel verrühren und mit dem Hühnerfleisch vermischen.\\
|
||||||
|
Mind. 60 min (besser über Nacht) im Kühlschrank marinieren lassen.
|
||||||
|
|
||||||
|
\item
|
||||||
|
1/4 der Butter in einer Pfanne bei mittlerer Stufe schmelzen.\\
|
||||||
|
Zimtstange, Kardamomkapseln, Nelken, den restlichen Knoblauch und Ingwer mit den Tomaten für 4-5 min unter Rühren anbraten.\\
|
||||||
|
Tomatenmark hinzufügen und weitere 2 min anbraten.\\
|
||||||
|
Für 5 min auskühlen lassen und fein pürieren.
|
||||||
|
|
||||||
|
\item
|
||||||
|
Die restliche Butter in der gleichen Pfanne schmelzen, das marinierte Hühnerfleisch dazu geben, unter rühren ca. 10 min anbraten, bis das Fleisch nicht mehr rosa ist.
|
||||||
|
|
||||||
|
\item
|
||||||
|
Die pürierte Tomaten-Mischung dazu geben und mit dem Cremefine für 2-5 min weiterköcheln lassen.
|
||||||
|
}
|
||||||
|
\info{\kcal{302} \KH{10,1 g} \protein{26,7 g} \fett{16,6 g}}
|
||||||
54
Recipes/Eierlikörtorte.tex
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
\recipename{Eierlikörtorte}
|
||||||
|
\SetPageImage{eierlikoertorte.jpg}
|
||||||
|
|
||||||
|
\preptime{x min} \cooktime{x min} \baketime{x\textdegree C, x min} \cooltime{ x min} \servings{x} \robot{}
|
||||||
|
\\\hrule
|
||||||
|
|
||||||
|
\recipe{
|
||||||
|
\textbf{Tortenboden}\\
|
||||||
|
\unit[4]{Stk} & Eigelb\\
|
||||||
|
\unit[4]{Stk} & Eiweiß\\
|
||||||
|
\unit[4]{EL} & Wasser, warm\\
|
||||||
|
\unit[125]{g} & Zucker\\
|
||||||
|
\unit[75]{g} & Mehl\\
|
||||||
|
\unit[50]{g} & Speisestärke\\
|
||||||
|
\unit[1]{Msp} & Backpulver\\
|
||||||
|
\unit[50]{g} & Butter, zerlassen\\
|
||||||
|
\unit[1]{Pr} & Salz\\
|
||||||
|
\unit[1]{Pr} & Zitronenabrieb\\
|
||||||
|
|
||||||
|
\textbf{Eierlikör-Sahne}\\
|
||||||
|
\unit[175]{ml} & Eierlikör\\
|
||||||
|
\unit[600]{ml} & Schlagobers\\
|
||||||
|
\unit[6]{Stk} & Eigelb\\
|
||||||
|
\unit[75]{g} & Zucker\\
|
||||||
|
\unit[8]{Bl} & Gelatine\\
|
||||||
|
\unit[100]{g} & Zartbitterkuvertüre, geraspelt\\
|
||||||
|
|
||||||
|
\textbf{Eierlikörspiegel}\\
|
||||||
|
\unit[250]{ml} & Eierlikör\\
|
||||||
|
\unit[50]{g} & Weiße Kuvertüre\\
|
||||||
|
\unit[50]{g} & Glukose (1:2 Wasser:Dextrose)\\
|
||||||
|
\unit[3]{Bl} & Gelatine\\
|
||||||
|
\unit[8]{g} & dunkle Kuvertüre\\
|
||||||
|
|
||||||
|
\textbf{Deko}\\
|
||||||
|
\unit[nB]{} & Schlagobers, Schokosplitter
|
||||||
|
}{
|
||||||
|
test
|
||||||
|
}{
|
||||||
|
\item Das Eigelb mit dem warmen Wasser, dem Zitronenabrieb und ca. 1/3 des Zuckers schaumig rühren.\\
|
||||||
|
Das Eiweiß mit dem restlichen Zucker und der Prise Salz zu einem sehr steifen Eischnee schlagen.\\
|
||||||
|
Die Eigelbmasse vorsichtig unter den steifen Eischnee mischen und das gesiebte Mehl-Stärke-Backpulvergemisch unterheben.\\
|
||||||
|
Jetzt die flüssige warme Butter unterheben und die fertige Masse in eine gefettete Springform (26 cm) einfüllen und bei 180°C (Umluft) 20 - 25 Min backen.
|
||||||
|
\item Eigelb mit Zucker unter ständigem Rühren im heißen Wasserbad aufschlagen.\\
|
||||||
|
Die eingeweichte Gelatine ausdrücken und in die warme Eiermasse einrühren. Den Eierlikör zugeben. Unter die fast erkaltete Masse die geschlagene Sahne und die geraspelte Kuvertüre unterheben.
|
||||||
|
\item Den Tortenboden in ca. 1,5 cm dicke Scheiben schneiden und die erste Scheibe mit einem Tortenring umstellen.\\
|
||||||
|
Die Hälfte der Eierlikörsahne auf den Tortenboden streichen und den nächsten Boden auflegen. Restliche Eierlikörsahne einfüllen und glatt streichen.\\
|
||||||
|
Torte für mindestens 2 Std. kühlen.
|
||||||
|
\item Eierlikör, Glukose und weiße Kuvertüre auf ca. 50° C erwärmen. Die eingeweichte Gelatine darin auflösen. Zwei Esslöffel von der Masse mit der aufgelösten dunklen Kuvertüre vermischen.\\
|
||||||
|
Die Tortenoberfläche mit der vorbereiteten hellen Eierlikör-SpiegelGlasur gleichmäßig mit einem langen Messer bestreichen und mit einer feinen Spritztüte aus dem „dunklen Eierlikör“ eine gleichmäßige Spirale aufspritzen. Mit einem langen Messer von der Mitte aus bei jedem Tortenstück die Spirale nach außen ziehen.\\
|
||||||
|
4 Stunden kaltstellen.\\
|
||||||
|
Den Rand der Eierlikörtorte dünn mit Sahne bestreichen und mit Schokoladenspänen einstreuen.
|
||||||
|
}
|
||||||
|
\info{\kcal{x} \KH{x g} \protein{x g} \fett{x g}}
|
||||||
33
Recipes/Erdapfeltasche.tex
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
\recipename{Erdäpfeltasche mit Frischkäse-Spinatfüllung}
|
||||||
|
|
||||||
|
\preptime{30 min} \cooktime{25 min} \cooltime{30 min} \servings{4}
|
||||||
|
\\
|
||||||
|
\hrule
|
||||||
|
|
||||||
|
\recipe{%
|
||||||
|
\unit[500]{g} & Erdäpfel, mehlig\\
|
||||||
|
\unit[200]{g} & Spinat\\
|
||||||
|
\unit[2]{} & Zwoebeln, rot\\
|
||||||
|
\unit[3]{} & Knoblauchzehen\\
|
||||||
|
\unit[4]{EL} & Olivenöl\\
|
||||||
|
\unit[200]{g} & Frischkäse\\
|
||||||
|
\unit[1]{TL} & Zitronensaft\\
|
||||||
|
\unit[200]{g} & Mehl\\
|
||||||
|
\unit[nB]{l} & Salz\\
|
||||||
|
\unit[nB]{l} & Pfeffer\\
|
||||||
|
\unit[1]{Prise} & Muskatnuss
|
||||||
|
}{%
|
||||||
|
abc
|
||||||
|
}{%
|
||||||
|
\item Schäle die Kartoffeln und schneide sie in grobe Stücke. Koche sie anschließend in ausreichend gesalzenem Wasser, bis sie weich sind.
|
||||||
|
\item Wasche währenddessen den Spinat und hacke ihn grob.
|
||||||
|
\item Schäle die Zwiebeln und Knoblauchzehen und schneide beides in feine Würfel.
|
||||||
|
\item Erhitze 2 EL Öl in einer Pfanne <20><> und schwitze Zwiebeln und Knoblauch darin glasig an. Gib danach den Spinat hinzu, bis er zusammenfällt.
|
||||||
|
\item Rühre den Frischkäse dazu und schmecke die Füllung mit Salz, Pfeffer, Zitronensaft und Muskatnuss ab.
|
||||||
|
\item Gieße die gekochten Kartoffeln ab und lass sie gut abtropfen.
|
||||||
|
\item Zerstampfe sie und verarbeite sie mit Mehl, 1 TL Salz und 2 EL Öl zu einem glatten Teig. Wickel ihn in Frischhaltefolie ein und lass ihn für 30 Minuten im Kühlschrank ruhen.
|
||||||
|
\item Heize den Ofen auf 180 °C Ober-/Unterhitze vor.
|
||||||
|
\item Rolle den Kartoffelteig auf einer bemehlten Arbeitsfläche aus.
|
||||||
|
\item Steche Kreise aus dem Teig aus. Verteile auf jeden Teigkreis etwas Spinat-Frischkäsefüllung und bestreiche die Ränder mit Wasser. Falte die Kreise und presse die Ränder gut zusammen.
|
||||||
|
\item Platziere die Kartoffeltaschen auf ein mit Backpapier ausgelegtes Backblech, bestreiche sie mit etwas Olivenöl und backe sie für 20-25 Minuten goldbraun.
|
||||||
|
}
|
||||||
29
Recipes/Lachs&Süßkartoffel.tex
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
\recipename{Lachs \& Süßkartoffel Bowl\\ mit Honig-Senf-Soße}
|
||||||
|
%\SetPageImage{eierlikoertorte.jpg}
|
||||||
|
|
||||||
|
\preptime{x min} \cooktime{10 min} \baketime{200\textdegree C, 45 min} \cooltime{ 20 min} \servings{6}\\
|
||||||
|
\\\hrule
|
||||||
|
|
||||||
|
\recipe{
|
||||||
|
\unit[2]{Stk} & Lachsfillet (à 100g)\\
|
||||||
|
\unit[420]{g} & Süßkartoffeln\\
|
||||||
|
\unit[150]{g} & Brokkoli\\
|
||||||
|
\unit{nB} & Salz\&Pfeffer\\
|
||||||
|
\textbf{Soße}\\
|
||||||
|
\unit[1,5]{EL} & Senf\\
|
||||||
|
\unit[1]{EL} & Honig\\
|
||||||
|
\unit[0,5]{EL} & Zitronensaft\\
|
||||||
|
\unit[0,5]{EL} & Sojasoße \\
|
||||||
|
\unit[1]{Zehe} & Knoblauch, gerieben\\
|
||||||
|
\unit[1]{EL} & Dill\\
|
||||||
|
\unit[0,5]{EL} & Margerine
|
||||||
|
}{
|
||||||
|
}{
|
||||||
|
\item Lachs trocken tupfen und mit Salz und Pfeffer würzen.
|
||||||
|
\item Das Gemüse würzen und bei ca. 200 ° C für 20-30 min in Ofen dünsten / braten.
|
||||||
|
\item Für die Soße, alle Zutaten, bis auf die Margerine, vermischen.
|
||||||
|
\item Den Lachs mit etwas Öl anbraten, ca. 5 min pro Seite.\\
|
||||||
|
Die Soße dazu geben, weitere 2 min braten.\\
|
||||||
|
Vom Herd nehmen und die Margerine unter rühren.
|
||||||
|
}
|
||||||
|
\info{\kcal{1.072} \KH{128 g} \protein{50 g} \fett{40 g}}
|
||||||
21
Recipes/LimettenKokosMakronen.tex
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
\recipename{Limetten-Kokos-Makronen}
|
||||||
|
\SetPageImage{LimettenKokosMakronen.png}
|
||||||
|
\preptime{120 min} \baketime{130\textdegree C 16-18 min} \cooltime{30 min} \servings{75 Stk.}
|
||||||
|
\\\hrule
|
||||||
|
|
||||||
|
\recipe{%
|
||||||
|
\unit[3]{} & Limetten\\
|
||||||
|
\unit[150]{g} & Staubzucker\\
|
||||||
|
\unit[3]{} & Eiweiß, Gr. L\\
|
||||||
|
\unit[1]{Pr.} & Salz\\
|
||||||
|
\unit[180]{g} & Kokosflocken\\
|
||||||
|
\unit[140]{g} & Kuvertüre, weiß\\
|
||||||
|
\unit[15]{g} & Kokosfett
|
||||||
|
}{%
|
||||||
|
|
||||||
|
}{%
|
||||||
|
\item Alle Limetten heiß waschen und die Schale fein abreiben. 1 Limette halbieren, den Saft auspressen. Puderzucker sieben. 2/3 Limettenschale auf einen Teller geben und trocknen lassen (evtl. auch in der Nachwärme des Backofens!)
|
||||||
|
\item Eiweiß steif schlagen. Salz und Puderzucker dabei einrieseln lassen. So lange schlagen, bis die Masse dickcremig ist. Zum Schluss das übrige Drittel der abgeriebenen Limettenschale und 3 TL Limettensaft unterschlagen. Kokosraspel unterheben.
|
||||||
|
\item Masse in einen Spritzbeutel mit großer Lochtülle (ca. 1 cm Ø) füllen und ca. 75 Tupfen (à ca. 3 cm Ø) auf 3 mit Backpapier ausgelegte Backbleche spritzen. Blechweise im vorgeheizten Backofen auf der 2 Schiene von unten (E-Herd: 150 °C / Umluft: 130 °C) 16–18 Minuten backen. Fertige Makronen auskühlen lassen.
|
||||||
|
\item Kuvertüre und Kokosfett hacken. Über einem warmen Wasserbad unter Rühren schmelzen. 1/3 der getrockneten Limettenschale unter die geschmolzene Schokolade rühren. Makronen zur Hälfte in die Kuvertüre tauchen. Abtropfen lassen, auf Backpapier legen und mit übriger Limettenschale bestreuen. Trocknen lassen.
|
||||||
|
}
|
||||||
27
Recipes/MozartKekse.tex
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
\recipename{Mozart-Kekse}
|
||||||
|
\SetPageImage{MozartKekse.png}
|
||||||
|
|
||||||
|
\preptime{75 min} \baketime{180\textdegree C, 12-15 min} \cooltime{90 min} \servings{64 Stk.}
|
||||||
|
\\
|
||||||
|
\hrule
|
||||||
|
|
||||||
|
\recipe{%
|
||||||
|
\unit[225]{g} & Mehl\\
|
||||||
|
\unit[75]{g} & Zucker\\
|
||||||
|
\unit[1]{} & Eigelb, Gr.M\\
|
||||||
|
\unit[150]{g} & Butter\\
|
||||||
|
\unit[1]{EL} & Marillengeist\\
|
||||||
|
\unit[150]{g} & Marzipan-Rohmasse\\
|
||||||
|
\unit[50+50]{g} & Pistazien\\
|
||||||
|
\unit[2]{EL} & Staubzucker\\
|
||||||
|
\unit[150]{g} & Zartbitterschokolade\\
|
||||||
|
\unit[10]{g} & Kokosfett
|
||||||
|
}{%
|
||||||
|
|
||||||
|
}{%
|
||||||
|
\item Für den Teig Mehl, Zucker, Eigelb, Butter in Stückchen und Marillengeist zum glatten Teig verkneten. In Folie wickeln und ca. 1 Stunde kalt stellen.
|
||||||
|
\item Für die Füllung Marzipan raspeln. 50 g Pistazien hacken. Beides mit Puderzucker verkneten. Masse zu vier Rollen (ca. 25 cm lang) formen.
|
||||||
|
\item Teig halbieren, je auf wenig Mehl rechteckig (ca. 12x25 cm) ausrollen. Rechtecke längs halbieren (ca. 6x25 cm). Jede Marzipanrolle auf ein Teigrechteck legen, darin einrollen. Rollen ca. 30 Minuten kalt stellen.
|
||||||
|
\item Rollen auf ein mit Backpapier ausgelegtes Backblech legen. Im vorgeheizten Ofen (E-Herd: 200°C/Umluft: 180°C) 12–15 Minuten backen. Herausnehmen, kurz abkühlen lassen, dann noch warm in ca. 16 Scheiben schneiden. Auskühlen lassen.
|
||||||
|
\item Schokolade in Stücke teilen und mit Kokosfett schmelzen. Plätzchen zur Hälfte hineintauchen, auf ein Gitter legen und mit je einem Pistazienkern belegen. Trocknen lassen.
|
||||||
|
}
|
||||||
19
Recipes/Naan.tex
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
\recipename{Naan}
|
||||||
|
|
||||||
|
\preptime{x min} \cooktime{x min} \robot{} \servings{6}
|
||||||
|
\\\hrule
|
||||||
|
|
||||||
|
\recipe{
|
||||||
|
\unit[250]{g} & Weizenmehl\\
|
||||||
|
\unit[1/4]{Würfel} & Germ\\
|
||||||
|
\unit[1/2]{TL} & Backpulver\\
|
||||||
|
\unit[1/4]{TL} & Zucker\\
|
||||||
|
\unit[1/2]{TL} & Salz\\
|
||||||
|
\unit[100]{g} & Naturjoghurt\\
|
||||||
|
\unit[100]{g} & Milch
|
||||||
|
}{
|
||||||
|
}{
|
||||||
|
\item step 1
|
||||||
|
\item step 2
|
||||||
|
}
|
||||||
|
\info{\kcal{166} \KH{29,8 g} \protein{6,5 g} \fett{2 g}}
|
||||||
23
Recipes/PistazienTraumstuecke.tex
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
\recipename{Pistazien-Traumstücke}
|
||||||
|
\SetPageImage{PistazienTraumstuecke.png}
|
||||||
|
\preptime{40 min} \baketime{180\textdegree C 8-12 min} \cooltime{60 min} \servings{45 Stk.}
|
||||||
|
\\\hrule
|
||||||
|
|
||||||
|
\recipe{%
|
||||||
|
\unit[80]{g} & Pistazien, gemahlen\\
|
||||||
|
\unit[1/2]{} & Zitronenschale\\
|
||||||
|
\unit[2]{} & Eier, Gr. M\\
|
||||||
|
\unit[125]{g} & Butter\\
|
||||||
|
\unit[230]{g} & Mehl\\
|
||||||
|
\unit[80]{g} & Zucker\\
|
||||||
|
\unit[1]{Pck.} & Vanillezucker\\
|
||||||
|
\unit[80]{g} & Puderzucker\\
|
||||||
|
}{%
|
||||||
|
|
||||||
|
}{%
|
||||||
|
\item Eier trennen. Eiweiß anderweitig verwenden. In einer Schüssel das Eigelb mit gewürfelter Butter, Mehl, 50 g gemahlene Pistazien, 1 TL Zitronenabrieb, Zucker und Vanillezucker zunächst mit den Knethaken des Handmixers verrühren, danach mit den Händen zu einem geschmeidigen Teig verkneten.
|
||||||
|
\item Teig in 3 gleichgroße Stücke teilen und zu Kugeln formen, in Frischhaltefolie wickeln und für 1 Stunde kalt stellen.
|
||||||
|
\item Teigkugeln jeweils zu 1 cm dicken Würsten formen, ganz leicht platt drücken und mit dem Messer in 2-3 cm breite Stücke teilen. Teigstücke auf ein mit Backpapier ausgelegtes Backblech legen.
|
||||||
|
\item Im vorgeheizten Backofen bei 180 °C (Ober-/Unterhitze) für 8-12 Minuten hell backen.
|
||||||
|
\item Puderzucker mit restlichen gemahlenen Pistazien vermischen. Traumstücke damit großzügig bestäuben.
|
||||||
|
}
|
||||||
16
Recipes/Sample.tex
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
\recipename{Sample Recipe}
|
||||||
|
|
||||||
|
\preptime{x min} \cooktime{x min} \baketime{x\textdegree C, x min} \cooltime{ x min} \servings{x} \robot{}
|
||||||
|
\\
|
||||||
|
\hrule
|
||||||
|
|
||||||
|
\recipe{%
|
||||||
|
\unit[1]{kg} & ingredient\\
|
||||||
|
\unit[1]{l} & ingredient
|
||||||
|
}{%
|
||||||
|
abc
|
||||||
|
}{%
|
||||||
|
\item step
|
||||||
|
\item step
|
||||||
|
}
|
||||||
|
\info{\kcal{x} \KH{x g} \protein{x g} \fett{x g}}
|
||||||
29
Recipes/Sauerteig_Toastbrot.tex
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
\recipename{Sauerteig Toastbrot}
|
||||||
|
\SetPageImage{sauerteigToastbrot.png}
|
||||||
|
|
||||||
|
\preptime{15 min} \baketime{250-210\textdegree C, 50-60 min} \cooltime{12-18 Std. / 1 Std. / 3-4 Std.} \robot{30 min}
|
||||||
|
\\\hrule
|
||||||
|
|
||||||
|
\recipe{
|
||||||
|
\textbf{Vorteig}\\
|
||||||
|
\unit[200]{g} & Wasser, kalt\\
|
||||||
|
\unit[400]{g} & Weizenmehl Type 550\\
|
||||||
|
\unit[20-40]{g} & Kristallzucker\\
|
||||||
|
\unit[1]{} & Ei\\
|
||||||
|
\unit[100]{g} & Sauerteig\\
|
||||||
|
\textbf{Hauptteig}\\
|
||||||
|
\unit[1]{} & Vorteig\\
|
||||||
|
\unit[40]{g} & Butter\\
|
||||||
|
\unit[12]{g} & Salz\\
|
||||||
|
\unit[250]{g} & Wizenmehl Type 550\\
|
||||||
|
\unit[120]{g} & Wasser
|
||||||
|
}{
|
||||||
|
Weizenmehl Type 550 = W700 griffig
|
||||||
|
}{
|
||||||
|
\item \textbf{Vorteig}: Alle Zutaten in der Küchenmaschine zu einem Teig verkneten. Danach den Vorteig zu einer Kugeln formen, in eine Schüssel legen und zugedeckt 12-18 Std. bei Raumtemperatur gären lassen. In dieser zeit sollte sich das Volumen verdoppelt – besser noch verdreifacht haben.
|
||||||
|
\item \textbf{Hauptteig}: Empfehlung: 10-20\% vom Wasser zurückbehalten und nur nach und nach bei Bedarf dazugeben. Alle Zutaten in der Küchenmaschine auf kleiner Stufe 2-3 Min. ankneten. Danach die Knetgeschwindigkeit erhöhen und den Teig total 10-12 Min. kneten. Danach den Teig in ein Becken legen, zudecken und für 1 Std. bei Raumtemperatur ruhen lassen.\\
|
||||||
|
\item \textbf{Formen}: Nach der Gärzeit den Teig auf eine bemehlte Arbeitsfläche geben und in vier gleich grosse Stücke portionieren. Jedes Teigstück zu einer gleichmässigen Kugel formen.
|
||||||
|
Eine Toastbrotform gut einfetten und den Deckel nicht vergessen. Anschliessend die Teiglinge in die Toastbrotform legen und gut runter drücken, so dass der Teig auch in die Ecken gedrückt wird. Die Form ist etwa zu 1/3 gefüllt. Die Teiglinge 3-4 Std. bei Raumtemperatur stehen lassen. Den Backofen auf 250 Grad Ober- und Unterhitze vorheizen.
|
||||||
|
\item \textbf{Backen}: Nach der Stückgare die Form schliessen (je nach Form den Deckel fixieren) und in die Mitte des vorgeheizten Backofens schieben. Die Backtemperatur auf 210 Grad reduzieren und das Toastbrot für etwa 50-60 Min. backen. Nach dem Backen das Toastbrot ausformen und auf einem Gitter auskühlen lassen. Tipp: Sollten die Seiten noch etwas hell sein, kannst du das Toastbrot ohne form nochmals 5-10 Min. backen.
|
||||||
|
}
|
||||||
|
\info{\kcal{x} \KH{x g} \protein{x g} \fett{x g}}
|
||||||
27
Recipes/lebkuchen.tex
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
\recipename{Lebkuchen}
|
||||||
|
\SetPageImage{lebkuchen.jpg}
|
||||||
|
\preptime{60 min} \baketime{180\textdegree C 10-12 min} \cooltime{8-24 h}
|
||||||
|
\\\hrule
|
||||||
|
|
||||||
|
\recipe{%
|
||||||
|
\unit[500]{g} & Roggenmehl\\
|
||||||
|
\unit[100]{g} & Zucker\\
|
||||||
|
\unit[2]{EL} & Lebkuchengewürz\\
|
||||||
|
\unit[1]{TL} & Natron\\
|
||||||
|
\unit[50]{g} & Butter\\
|
||||||
|
\unit[200-250]{g} & Honig\\
|
||||||
|
\unit[2]{} & Eier\\
|
||||||
|
\unit[2]{EL} & Wasser\\
|
||||||
|
\unit[1]{} & Ei zum Bestreichen
|
||||||
|
}{%
|
||||||
|
ca. 5cm dick ausrollen
|
||||||
|
}{%
|
||||||
|
\item Für das Lebkuchen Rezept die trockenen Zutaten (Mehl, Zucker, Natron, Gewürze) vermischen und mit der Butter abbröseln.
|
||||||
|
\item Honig und Eier mit etwas Wasser glattrühren.\\
|
||||||
|
(Ist der Honig zu zäh, kann man ihn leicht erwärmen.)
|
||||||
|
\item Ein Grübchen im Mehl machen und das Honiggemisch langsam mit dem Mehl vermengen, dass ein mittelfester Teig entsteht. Nach Bedarf etwas Wasser oder Mehl hinzufügen.\\
|
||||||
|
(Ist der Lebkuchenteig zu fest, lässt er sich gar nicht Austreiben, ist er dagegen zu weich, entstehen Sprünge beim Austreiben.)
|
||||||
|
\item Den Lebkuchenteig über Nacht an einem kühlen Ort rasten lassen.
|
||||||
|
\item Am nächsten Tag den Teig mit einem Nudelholz auf der bemehlten Arbeitsfläche nicht zu dünn ausrollen und mit Formen ausstechen.
|
||||||
|
\item Den Lebkuchen mit verquirltem Ei bestreichen und im Backrohr bei ca. 180° Ober- und Unterhitze je nach Größe etwa 10 bis 12 Minuten hell backen.
|
||||||
|
}
|
||||||
45
Rezeptbuch.tex
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
\documentclass[twosides, 11pt]{book}
|
||||||
|
\usepackage[utf8]{inputenc} % Required for inputting international characters
|
||||||
|
\usepackage[T1]{fontenc} % Output font encoding for international characters
|
||||||
|
\usepackage[ngerman]{babel}
|
||||||
|
\usepackage{cuisine}
|
||||||
|
|
||||||
|
%\overfullrule=5pt % zur Überprüfung von Overfull \hbox
|
||||||
|
|
||||||
|
%\input{CoverPage.tex}
|
||||||
|
%\makeindex
|
||||||
|
|
||||||
|
\begin{document}
|
||||||
|
%\input{Recipes/Sample}
|
||||||
|
|
||||||
|
%\maketitle
|
||||||
|
%\tableofcontents
|
||||||
|
|
||||||
|
%\chapter{Suppen}
|
||||||
|
%input{Recipes/Sample}
|
||||||
|
%\AddToShipoutPictureBG{\includegraphics[width=\paperwidth,height=\paperheight]{carbo}}
|
||||||
|
|
||||||
|
%\chapter{Vorspeisen}
|
||||||
|
|
||||||
|
\chapter{Hauptspeisen}
|
||||||
|
\input{Recipes/Sample}
|
||||||
|
\input{Recipes/ButterChicken}
|
||||||
|
\input{Recipes/Lachs&Süßkartoffel}
|
||||||
|
\input{Recipes/Erdapfeltasche}
|
||||||
|
|
||||||
|
\chapter{Beilagen}
|
||||||
|
\input{Recipes/Naan}
|
||||||
|
\input{Recipes/Sauerteig_Toastbrot}
|
||||||
|
|
||||||
|
\chapter{Nachspeisen}
|
||||||
|
\input{Recipes/Eierlikörtorte}
|
||||||
|
\input{Recipes/lebkuchen}
|
||||||
|
\input{Recipes/PistazienTraumstuecke}
|
||||||
|
\input{Recipes/MozartKekse}
|
||||||
|
\input{Recipes/LimettenKokosMakronen}
|
||||||
|
|
||||||
|
%\chapter{Nachspeisen}
|
||||||
|
|
||||||
|
\clearpage
|
||||||
|
%\printindex
|
||||||
|
\end{document}
|
||||||
3
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
27
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
texlive-latex-recommended \
|
||||||
|
texlive-fonts-recommended \
|
||||||
|
texlive-xetex \
|
||||||
|
texlive-latex-extra \
|
||||||
|
texlive-fonts-extra \
|
||||||
|
texlive-plain-generic \
|
||||||
|
texlive-pictures \
|
||||||
|
fonts-comic-neue \
|
||||||
|
fontconfig \
|
||||||
|
&& fc-cache -f \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
backend/app/__init__.py
Normal file
27
backend/app/database.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
DATABASE_URL = f"sqlite:///{DATA_DIR / 'recipes.db'}"
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
19
backend/app/main.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from app.database import UPLOAD_DIR, Base, engine
|
||||||
|
from app.routers import pdf, recipes
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="RecipeInator")
|
||||||
|
|
||||||
|
app.include_router(recipes.router)
|
||||||
|
app.include_router(pdf.router)
|
||||||
|
|
||||||
|
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
101
backend/app/models.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe(Base):
|
||||||
|
__tablename__ = "recipes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
image_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
preptime: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
cooktime: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
baketime: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
cooltime: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
marinade_time: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
robot_time: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
servings: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
kcal: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
kh: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
protein: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
fett: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||||
|
)
|
||||||
|
|
||||||
|
ingredients: Mapped[list["Ingredient"]] = relationship(
|
||||||
|
back_populates="recipe", cascade="all, delete-orphan", order_by="Ingredient.position"
|
||||||
|
)
|
||||||
|
steps: Mapped[list["Step"]] = relationship(
|
||||||
|
back_populates="recipe", cascade="all, delete-orphan", order_by="Step.position"
|
||||||
|
)
|
||||||
|
book_links: Mapped[list["BookRecipe"]] = relationship(
|
||||||
|
back_populates="recipe", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Ingredient(Base):
|
||||||
|
__tablename__ = "ingredients"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id"), nullable=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), nullable=False, default="item") # item | section_header
|
||||||
|
quantity: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
text: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
recipe: Mapped["Recipe"] = relationship(back_populates="ingredients")
|
||||||
|
|
||||||
|
|
||||||
|
class Step(Base):
|
||||||
|
__tablename__ = "steps"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id"), nullable=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
|
recipe: Mapped["Recipe"] = relationship(back_populates="steps")
|
||||||
|
|
||||||
|
|
||||||
|
class Book(Base):
|
||||||
|
__tablename__ = "books"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
recipe_links: Mapped[list["BookRecipe"]] = relationship(
|
||||||
|
back_populates="book", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BookRecipe(Base):
|
||||||
|
__tablename__ = "book_recipes"
|
||||||
|
__table_args__ = (UniqueConstraint("book_id", "recipe_id", name="uq_book_recipe"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
book_id: Mapped[int] = mapped_column(ForeignKey("books.id"), nullable=False)
|
||||||
|
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id"), nullable=False)
|
||||||
|
chapter: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
book: Mapped["Book"] = relationship(back_populates="recipe_links")
|
||||||
|
recipe: Mapped["Recipe"] = relationship(back_populates="book_links")
|
||||||
0
backend/app/pdf/__init__.py
Normal file
204
backend/app/pdf/assets/cuisine.sty
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
%% -------------------------------------------------------------- %%
|
||||||
|
%% -------------------- cuisine.sty package --------------------- %%
|
||||||
|
%% a package written by Baptiste Le Roi %%
|
||||||
|
%% %%
|
||||||
|
%% Package / style modified from livrerecettes.sty of Alexis Flesh %%
|
||||||
|
%% %%
|
||||||
|
%% modified from cookybooky.dtx package from J. Gilg %%
|
||||||
|
%% %%
|
||||||
|
%% ------- Copyright (C) 2009,2021 by Gilg, Flesj, Le Roi ------- %%
|
||||||
|
%% gilg@acrotex.net, alexis.flesch@gmail.com, leroi.bwh@gmail.com %%
|
||||||
|
%% %%
|
||||||
|
%% This work may be distributed and/or modified under the %%
|
||||||
|
%% conditions of the LaTeX Project Public License (LPPL), %%
|
||||||
|
%% either version 1.3 of this license or (at your option) %%
|
||||||
|
%% any later version. %%
|
||||||
|
%% The latest version of this license is in %%
|
||||||
|
%% %%
|
||||||
|
%% http://www.latex-project.org/lppl.txt %%
|
||||||
|
%% %%
|
||||||
|
%% and version 1.3 or later is part of all distributions of %%
|
||||||
|
%% LaTeX version 2005/12/01 or later. %%
|
||||||
|
%% %%
|
||||||
|
%% -------------------------------------------------------------- %%
|
||||||
|
|
||||||
|
\NeedsTeXFormat{LaTeX2e}
|
||||||
|
\ProvidesPackage{cuisine}
|
||||||
|
\RequirePackage{xkeyval}
|
||||||
|
\RequirePackage{marvosym}
|
||||||
|
%\RequirePackage{showframe}
|
||||||
|
|
||||||
|
% Geometry of the book
|
||||||
|
\RequirePackage[paperheight=21cm,paperwidth=14.8cm,margin=1cm,heightrounded]{geometry}%showframe
|
||||||
|
|
||||||
|
%glossary of the recipes
|
||||||
|
\RequirePackage{imakeidx}
|
||||||
|
|
||||||
|
% Links in the book
|
||||||
|
\RequirePackage[hidelinks,colorlinks=false]{hyperref}
|
||||||
|
|
||||||
|
% Colors
|
||||||
|
\RequirePackage{xcolor} % Required for specifying colors by name
|
||||||
|
\definecolor{ocre}{RGB}{164,16,52} %{243,102,25} % Define the red color used for highlighting throughout the book
|
||||||
|
|
||||||
|
% Headers
|
||||||
|
\RequirePackage{fancyhdr}
|
||||||
|
\pagestyle{fancy}
|
||||||
|
\renewcommand{\chaptermark}[1]{\markboth{\normalsize\bfseries\ #1}{}} % Chapter text font settings
|
||||||
|
\fancyhf{} \renewcommand{\headrulewidth}{0pt}
|
||||||
|
%\fancyfoot[LE]{\bfseries\thepage} % Font setting for the page number in the foot
|
||||||
|
%\fancyfoot[RO]{\huge\bfseries\textcolor{white}{ \rightmark}} %% Font setting for the page number in the foot
|
||||||
|
% remove the page number on chapter pages
|
||||||
|
\makeatletter
|
||||||
|
\let\ps@plain\ps@empty
|
||||||
|
\makeatother
|
||||||
|
|
||||||
|
|
||||||
|
% Pictures and graphic content
|
||||||
|
\RequirePackage{graphicx} % Required for including pictures
|
||||||
|
\graphicspath{{Pictures/}} % Specifies the directory where pictures are stored
|
||||||
|
\RequirePackage{wrapfig} % To wrap the tabular with the ingredients
|
||||||
|
\RequirePackage{floatrow}
|
||||||
|
\RequirePackage{tikz} % Required for drawing custom shapes
|
||||||
|
\RequirePackage{eso-pic} % for background pictures
|
||||||
|
\RequirePackage{transparent} % for transparency
|
||||||
|
|
||||||
|
% Fonts
|
||||||
|
\RequirePackage{lettrine}
|
||||||
|
\RequirePackage{polyglossia}
|
||||||
|
\setdefaultlanguage{english}
|
||||||
|
\usepackage{fontspec}
|
||||||
|
\setmainfont{Comic Neue}
|
||||||
|
%\renewcommand{\familydefault}{\sfdefault} %Schriftart Sans-Serif
|
||||||
|
%\RequirePackage[defaultfam,tabular,lining,alternates]{montserrat} %% Option 'defaultfam'
|
||||||
|
%% only if the base font of the document is to be sans serif
|
||||||
|
\renewcommand*\oldstylenums[1]{\oldstylenums{#1}} %wenn Montserrat nicht verwendet
|
||||||
|
%\renewcommand*\oldstylenums[1]{{\fontfamily{Montserrat-TOsF}\selectfont #1}}
|
||||||
|
|
||||||
|
% the name of the TOC
|
||||||
|
\addto\captionsfrench{% Replace "english" with the language you use
|
||||||
|
\renewcommand{\contentsname}%
|
||||||
|
{Inhalte}%
|
||||||
|
}
|
||||||
|
|
||||||
|
% lists
|
||||||
|
\RequirePackage{enumitem}
|
||||||
|
|
||||||
|
\usepackage{paracol}%f. Seitenumbrüche in minipage
|
||||||
|
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
% Elements of the recipe %
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
|
||||||
|
|
||||||
|
%Counter of recipe
|
||||||
|
%\newcounter{numrecette}\setcounter{numrecette}{0}
|
||||||
|
|
||||||
|
\RequirePackage[compact]{titlesec}
|
||||||
|
\titleformat{\section}
|
||||||
|
{\color{ocre}\normalfont\Huge\bfseries}
|
||||||
|
{\color{ocre}\thesection}{1em}{}
|
||||||
|
\renewcommand{\thesection}{}
|
||||||
|
\titleformat{\chapter}[display]
|
||||||
|
{\normalfont\bfseries}{}{0pt}{\Huge}
|
||||||
|
|
||||||
|
% Name of the recipe
|
||||||
|
\newcommand*{\recipename}[1]
|
||||||
|
{
|
||||||
|
\newpage
|
||||||
|
\section{#1}
|
||||||
|
\index{#1}}
|
||||||
|
%\addcontentsline{toc}{section}{#1}}
|
||||||
|
%% \stepcounter{numrecette}
|
||||||
|
% \textcolor{ocre}{{\Huge \textbf{#1\hfill}}}
|
||||||
|
% \vspace{1em}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
% Icons and text
|
||||||
|
\newcommand{\preptime}[1]{\includegraphics[height=15pt]{Pictures/ico/chef.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\cooktime}[1]{\includegraphics[height=15pt]{Pictures/ico/pot.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\baketime}[1]{\includegraphics[height=15pt]{Pictures/ico/oven.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\servings}[1]{\includegraphics[height=15pt]{Pictures/ico/plate.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\cooltime}[1]{\includegraphics[height=15pt]{Pictures/ico/fridge.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\robot}[1]{\includegraphics[height=15pt]{Pictures/ico/robot.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\marinade}[1]{\includegraphics[height=15pt]{Pictures/ico/marinade.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\kcal}[1]{\includegraphics[height=15pt]{Pictures/ico/kcal.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\KH}[1]{\includegraphics[height=15pt]{Pictures/ico/KH.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\protein}[1]{\includegraphics[height=15pt]{Pictures/ico/protein.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\fett}[1]{\includegraphics[height=15pt]{Pictures/ico/fett.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
\newcommand{\oberunter}[1]{\includegraphics[height=15pt]{Pictures/ico/oberunter.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
|
||||||
|
|
||||||
|
% icon from https://www.flaticon.com/fr/auteurs/those-icons
|
||||||
|
|
||||||
|
|
||||||
|
% Ingredients
|
||||||
|
\RequirePackage{wrapfig} % To wrap the tabular with the ingredients
|
||||||
|
\RequirePackage{units}
|
||||||
|
\RequirePackage{tabulary} % line breaks in tabular
|
||||||
|
|
||||||
|
\usepackage{tabularx}
|
||||||
|
% Neue Spaltenart: T = minimal flexible Breite
|
||||||
|
\newcolumntype{T}{>{\raggedleft\arraybackslash}p{1.8cm}}
|
||||||
|
|
||||||
|
\setlength{\columnsep}{2em} % Abstand zwischen Spalten
|
||||||
|
\columnratio{0.65} % linke Spalte 60%, rechte 40%
|
||||||
|
|
||||||
|
\newcommand{\recipe}[3]{
|
||||||
|
\vspace{2em}
|
||||||
|
\begin{paracol}{2}
|
||||||
|
\flushcolumns % <-- sorgt für gleiche Startpositionen
|
||||||
|
%Zutaten
|
||||||
|
\begin{leftcolumn}
|
||||||
|
\textbf{Zutaten}\\[1em]
|
||||||
|
\begin{tabularx}{\linewidth}{@{}T X@{}}
|
||||||
|
#1
|
||||||
|
\end{tabularx}
|
||||||
|
\end{leftcolumn}
|
||||||
|
%Notizen
|
||||||
|
\begin{rightcolumn}
|
||||||
|
\textbf{Notizen}\\[1em]
|
||||||
|
#2
|
||||||
|
\end{rightcolumn}
|
||||||
|
\end{paracol}
|
||||||
|
\vspace{3em}
|
||||||
|
%Zubereitung
|
||||||
|
\textbf{Zubereitung}
|
||||||
|
\begin{enumerate}[label=\color{ocre}\theenumi, font=\bfseries]
|
||||||
|
#3
|
||||||
|
\end{enumerate}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
% The photo of the recipe
|
||||||
|
\newcommand{\photo}[1]{
|
||||||
|
\newpage
|
||||||
|
\vfill{ \transparent{0} :)}
|
||||||
|
\AddToShipoutPictureBG*{\includegraphics[width=\paperwidth,height=\paperheight]{#1}}
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand*{\info}[1]
|
||||||
|
{\vfill
|
||||||
|
\begin{tikzpicture}[overlay]
|
||||||
|
\draw[line width=1.5pt,color=ocre] (-.2,0) -- (3,0);
|
||||||
|
\draw[line width=1.5pt,color=ocre] (0,.2) -- (0,-1);
|
||||||
|
%\draw[color=red] (0.2,-.5) node[right] {\Large Astuce :};
|
||||||
|
\end{tikzpicture}
|
||||||
|
|
||||||
|
\vspace{1em}
|
||||||
|
\hspace{1em}\begin{minipage}{.915\textwidth}%
|
||||||
|
\itshape#1
|
||||||
|
\end{minipage}
|
||||||
|
}
|
||||||
|
|
||||||
|
% Hilfsbefehl zum Setzen eines Bildes auf einer Seite
|
||||||
|
\newcommand{\SetPageImage}[1]{%
|
||||||
|
\begin{tikzpicture}[remember picture,overlay]
|
||||||
|
\node[anchor=north east, xshift=-0.2cm, yshift=-0.2cm]
|
||||||
|
at (current page.north east) {\includegraphics[height=1.75cm]{#1}};
|
||||||
|
\end{tikzpicture}%
|
||||||
|
}
|
||||||
|
|
||||||
|
\endinput
|
||||||
BIN
backend/app/pdf/assets/icons/KH.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
backend/app/pdf/assets/icons/chef.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
backend/app/pdf/assets/icons/cloche.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
backend/app/pdf/assets/icons/cooking.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
backend/app/pdf/assets/icons/dish.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
backend/app/pdf/assets/icons/fett.png
Normal file
|
After Width: | Height: | Size: 789 B |
BIN
backend/app/pdf/assets/icons/fridge.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
backend/app/pdf/assets/icons/kcal.png
Normal file
|
After Width: | Height: | Size: 969 B |
BIN
backend/app/pdf/assets/icons/marinade.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
backend/app/pdf/assets/icons/oberunter.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
backend/app/pdf/assets/icons/oven.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
backend/app/pdf/assets/icons/plate.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
backend/app/pdf/assets/icons/pot.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/app/pdf/assets/icons/protein.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
backend/app/pdf/assets/icons/robot.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/app/pdf/assets/icons/whisk.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
41
backend/app/pdf/escape.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
_SPECIAL_CHARS = {
|
||||||
|
"&": r"\&",
|
||||||
|
"%": r"\%",
|
||||||
|
"$": r"\$",
|
||||||
|
"#": r"\#",
|
||||||
|
"_": r"\_",
|
||||||
|
"{": r"\{",
|
||||||
|
"}": r"\}",
|
||||||
|
"~": r"\textasciitilde{}",
|
||||||
|
"^": r"\textasciicircum{}",
|
||||||
|
"\\": r"\textbackslash{}",
|
||||||
|
}
|
||||||
|
_SPECIAL_CHARS_RE = re.compile("|".join(re.escape(c) for c in _SPECIAL_CHARS))
|
||||||
|
|
||||||
|
|
||||||
|
def escape_latex(value: str) -> str:
|
||||||
|
"""Escape LaTeX special characters in plain user-entered text."""
|
||||||
|
return _SPECIAL_CHARS_RE.sub(lambda m: _SPECIAL_CHARS[m.group(0)], value)
|
||||||
|
|
||||||
|
|
||||||
|
_STEP_BOLD_RE = re.compile(r"^\*\*(.+?)\*\*(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def format_multiline(value: str) -> str:
|
||||||
|
"""Escape text and convert user newlines into LaTeX line breaks.
|
||||||
|
|
||||||
|
Supports a leading "**Label**: rest" line convention (seen in the existing
|
||||||
|
hand-written recipes) which becomes \\textbf{Label}: rest.
|
||||||
|
"""
|
||||||
|
lines = value.strip().splitlines()
|
||||||
|
rendered_lines = []
|
||||||
|
for line in lines:
|
||||||
|
match = _STEP_BOLD_RE.match(line)
|
||||||
|
if match:
|
||||||
|
label, rest = match.groups()
|
||||||
|
rendered_lines.append(f"\\textbf{{{escape_latex(label)}}}{escape_latex(rest)}")
|
||||||
|
else:
|
||||||
|
rendered_lines.append(escape_latex(line))
|
||||||
|
return " \\\\\n ".join(rendered_lines)
|
||||||
126
backend/app/pdf/render.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from app.models import Recipe
|
||||||
|
from app.pdf.escape import escape_latex, format_multiline
|
||||||
|
|
||||||
|
ASSETS_DIR = Path(__file__).parent / "assets"
|
||||||
|
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||||
|
|
||||||
|
_env = Environment(
|
||||||
|
loader=FileSystemLoader(TEMPLATES_DIR),
|
||||||
|
block_start_string="((*",
|
||||||
|
block_end_string="*))",
|
||||||
|
variable_start_string="(((",
|
||||||
|
variable_end_string=")))",
|
||||||
|
comment_start_string="((=",
|
||||||
|
comment_end_string="=))",
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TIMING_MACROS = [
|
||||||
|
("preptime", "preptime"),
|
||||||
|
("cooktime", "cooktime"),
|
||||||
|
("baketime", "baketime"),
|
||||||
|
("marinade_time", "marinade"),
|
||||||
|
("cooltime", "cooltime"),
|
||||||
|
("robot_time", "robot"),
|
||||||
|
("servings", "servings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PdfRenderError(RuntimeError):
|
||||||
|
def __init__(self, message: str, log_tail: str):
|
||||||
|
super().__init__(message)
|
||||||
|
self.log_tail = log_tail
|
||||||
|
|
||||||
|
|
||||||
|
def _build_timing_line(recipe: Recipe) -> str:
|
||||||
|
parts = []
|
||||||
|
for field, macro in _TIMING_MACROS:
|
||||||
|
value = getattr(recipe, field)
|
||||||
|
if value:
|
||||||
|
parts.append(f"\\{macro}{{{escape_latex(value)}}}")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ingredient_lines(recipe: Recipe) -> list[str]:
|
||||||
|
lines = []
|
||||||
|
for ing in recipe.ingredients:
|
||||||
|
if ing.kind == "section_header":
|
||||||
|
lines.append(f"\\textbf{{{escape_latex(ing.text)}}}\\\\")
|
||||||
|
continue
|
||||||
|
quantity = escape_latex(ing.quantity) if ing.quantity else ""
|
||||||
|
unit = escape_latex(ing.unit) if ing.unit else ""
|
||||||
|
text = escape_latex(ing.text)
|
||||||
|
lines.append(f"\\unit[{quantity}]{{{unit}}} & {text}\\\\")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _build_info_line(recipe: Recipe) -> str | None:
|
||||||
|
parts = []
|
||||||
|
if recipe.kcal:
|
||||||
|
parts.append(f"\\kcal{{{escape_latex(recipe.kcal)}}}")
|
||||||
|
if recipe.kh:
|
||||||
|
parts.append(f"\\KH{{{escape_latex(recipe.kh)}}}")
|
||||||
|
if recipe.protein:
|
||||||
|
parts.append(f"\\protein{{{escape_latex(recipe.protein)}}}")
|
||||||
|
if recipe.fett:
|
||||||
|
parts.append(f"\\fett{{{escape_latex(recipe.fett)}}}")
|
||||||
|
return " ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def render_recipe_tex(recipe: Recipe) -> str:
|
||||||
|
template = _env.get_template("recipe.tex.jinja")
|
||||||
|
context = {
|
||||||
|
"name": escape_latex(recipe.name),
|
||||||
|
"image_filename": recipe.image_filename,
|
||||||
|
"timing_line": _build_timing_line(recipe),
|
||||||
|
"ingredient_lines": _build_ingredient_lines(recipe),
|
||||||
|
"notes": format_multiline(recipe.notes) if recipe.notes else "",
|
||||||
|
"step_items": [format_multiline(step.text) for step in recipe.steps],
|
||||||
|
"info_line": _build_info_line(recipe),
|
||||||
|
}
|
||||||
|
return template.render(**context)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_recipe_pdf(recipe: Recipe, upload_dir: Path) -> bytes:
|
||||||
|
tex_source = render_recipe_tex(recipe)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="recipe-pdf-") as tmp:
|
||||||
|
workdir = Path(tmp)
|
||||||
|
(workdir / "Pictures" / "ico").mkdir(parents=True)
|
||||||
|
shutil.copy(ASSETS_DIR / "cuisine.sty", workdir / "cuisine.sty")
|
||||||
|
for icon in (ASSETS_DIR / "icons").glob("*.png"):
|
||||||
|
shutil.copy(icon, workdir / "Pictures" / "ico" / icon.name)
|
||||||
|
if recipe.image_filename:
|
||||||
|
src = upload_dir / recipe.image_filename
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy(src, workdir / "Pictures" / recipe.image_filename)
|
||||||
|
|
||||||
|
tex_path = workdir / "recipe.tex"
|
||||||
|
tex_path.write_text(tex_source, encoding="utf-8")
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
subprocess.run(
|
||||||
|
["xelatex", "-interaction=nonstopmode", "-halt-on-error", tex_path.name],
|
||||||
|
cwd=workdir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_path = workdir / "recipe.pdf"
|
||||||
|
if not pdf_path.exists():
|
||||||
|
log_path = workdir / "recipe.log"
|
||||||
|
log_tail = ""
|
||||||
|
if log_path.exists():
|
||||||
|
log_tail = "\n".join(log_path.read_text(errors="ignore").splitlines()[-40:])
|
||||||
|
raise PdfRenderError("PDF compilation failed", log_tail)
|
||||||
|
|
||||||
|
return pdf_path.read_bytes()
|
||||||
29
backend/app/pdf/templates/recipe.tex.jinja
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
\documentclass[11pt]{book}
|
||||||
|
\usepackage{cuisine}
|
||||||
|
|
||||||
|
\begin{document}
|
||||||
|
\pagenumbering{gobble}
|
||||||
|
\recipename{(((name)))}
|
||||||
|
((* if image_filename *))
|
||||||
|
\SetPageImage{(((image_filename)))}
|
||||||
|
((* endif *))
|
||||||
|
|
||||||
|
(((timing_line)))
|
||||||
|
\\
|
||||||
|
\hrule
|
||||||
|
|
||||||
|
\recipe{
|
||||||
|
((* for line in ingredient_lines *))
|
||||||
|
(((line)))
|
||||||
|
((* endfor *))
|
||||||
|
}{
|
||||||
|
(((notes)))
|
||||||
|
}{
|
||||||
|
((* for item in step_items *))
|
||||||
|
\item (((item)))
|
||||||
|
((* endfor *))
|
||||||
|
}
|
||||||
|
((* if info_line *))
|
||||||
|
\info{(((info_line)))}
|
||||||
|
((* endif *))
|
||||||
|
\end{document}
|
||||||
0
backend/app/routers/__init__.py
Normal file
32
backend/app/routers/pdf.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from slugify import slugify
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import UPLOAD_DIR, get_db
|
||||||
|
from app.models import Recipe
|
||||||
|
from app.pdf.render import PdfRenderError, compile_recipe_pdf
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/recipes", tags=["pdf"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{recipe_id}/pdf")
|
||||||
|
def download_recipe_pdf(recipe_id: int, db: Session = Depends(get_db)):
|
||||||
|
recipe = db.get(Recipe, recipe_id)
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_bytes = compile_recipe_pdf(recipe, UPLOAD_DIR)
|
||||||
|
except PdfRenderError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"{exc}: {exc.log_tail}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
filename = f"{slugify(recipe.name) or 'recipe'}.pdf"
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
165
backend/app/routers/recipes.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
||||||
|
from slugify import slugify
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import UPLOAD_DIR, get_db
|
||||||
|
from app.models import Ingredient, Recipe, Step
|
||||||
|
from app.schemas import RecipeIn, RecipeOut, RecipeSummary
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_slug(db: Session, name: str, *, exclude_id: int | None = None) -> str:
|
||||||
|
base = slugify(name) or "recipe"
|
||||||
|
slug = base
|
||||||
|
suffix = 2
|
||||||
|
while True:
|
||||||
|
stmt = select(Recipe.id).where(Recipe.slug == slug)
|
||||||
|
if exclude_id is not None:
|
||||||
|
stmt = stmt.where(Recipe.id != exclude_id)
|
||||||
|
if db.scalar(stmt) is None:
|
||||||
|
return slug
|
||||||
|
slug = f"{base}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_children(db: Session, recipe: Recipe, payload: RecipeIn) -> None:
|
||||||
|
recipe.ingredients.clear()
|
||||||
|
for position, ing in enumerate(payload.ingredients):
|
||||||
|
recipe.ingredients.append(
|
||||||
|
Ingredient(
|
||||||
|
position=position,
|
||||||
|
kind=ing.kind,
|
||||||
|
quantity=ing.quantity,
|
||||||
|
unit=ing.unit,
|
||||||
|
text=ing.text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recipe.steps.clear()
|
||||||
|
for position, step in enumerate(payload.steps):
|
||||||
|
recipe.steps.append(Step(position=position, text=step.text))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RecipeSummary])
|
||||||
|
def list_recipes(search: str | None = None, db: Session = Depends(get_db)):
|
||||||
|
stmt = select(Recipe).order_by(Recipe.name)
|
||||||
|
if search:
|
||||||
|
like = f"%{search.lower()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
Recipe.name.ilike(like) | Recipe.category.ilike(like)
|
||||||
|
)
|
||||||
|
return db.scalars(stmt).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{recipe_id}", response_model=RecipeOut)
|
||||||
|
def get_recipe(recipe_id: int, db: Session = Depends(get_db)):
|
||||||
|
recipe = db.get(Recipe, recipe_id)
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RecipeOut, status_code=201)
|
||||||
|
def create_recipe(payload: RecipeIn, db: Session = Depends(get_db)):
|
||||||
|
recipe = Recipe(
|
||||||
|
name=payload.name,
|
||||||
|
slug=_unique_slug(db, payload.name),
|
||||||
|
category=payload.category,
|
||||||
|
preptime=payload.preptime,
|
||||||
|
cooktime=payload.cooktime,
|
||||||
|
baketime=payload.baketime,
|
||||||
|
cooltime=payload.cooltime,
|
||||||
|
marinade_time=payload.marinade_time,
|
||||||
|
robot_time=payload.robot_time,
|
||||||
|
servings=payload.servings,
|
||||||
|
notes=payload.notes,
|
||||||
|
kcal=payload.kcal,
|
||||||
|
kh=payload.kh,
|
||||||
|
protein=payload.protein,
|
||||||
|
fett=payload.fett,
|
||||||
|
)
|
||||||
|
_apply_children(db, recipe, payload)
|
||||||
|
db.add(recipe)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(recipe)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{recipe_id}", response_model=RecipeOut)
|
||||||
|
def update_recipe(recipe_id: int, payload: RecipeIn, db: Session = Depends(get_db)):
|
||||||
|
recipe = db.get(Recipe, recipe_id)
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||||
|
|
||||||
|
if payload.name != recipe.name:
|
||||||
|
recipe.slug = _unique_slug(db, payload.name, exclude_id=recipe_id)
|
||||||
|
recipe.name = payload.name
|
||||||
|
recipe.category = payload.category
|
||||||
|
recipe.preptime = payload.preptime
|
||||||
|
recipe.cooktime = payload.cooktime
|
||||||
|
recipe.baketime = payload.baketime
|
||||||
|
recipe.cooltime = payload.cooltime
|
||||||
|
recipe.marinade_time = payload.marinade_time
|
||||||
|
recipe.robot_time = payload.robot_time
|
||||||
|
recipe.servings = payload.servings
|
||||||
|
recipe.notes = payload.notes
|
||||||
|
recipe.kcal = payload.kcal
|
||||||
|
recipe.kh = payload.kh
|
||||||
|
recipe.protein = payload.protein
|
||||||
|
recipe.fett = payload.fett
|
||||||
|
_apply_children(db, recipe, payload)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(recipe)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{recipe_id}", status_code=204)
|
||||||
|
def delete_recipe(recipe_id: int, db: Session = Depends(get_db)):
|
||||||
|
recipe = db.get(Recipe, recipe_id)
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||||
|
if recipe.image_filename:
|
||||||
|
(UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True)
|
||||||
|
db.delete(recipe)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recipe_id}/image", response_model=RecipeOut)
|
||||||
|
async def upload_recipe_image(recipe_id: int, file: UploadFile, db: Session = Depends(get_db)):
|
||||||
|
recipe = db.get(Recipe, recipe_id)
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||||
|
ext = ALLOWED_IMAGE_TYPES.get(file.content_type or "")
|
||||||
|
if ext is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Only JPEG, PNG or WEBP images are allowed")
|
||||||
|
|
||||||
|
if recipe.image_filename:
|
||||||
|
(UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
filename = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
dest = UPLOAD_DIR / filename
|
||||||
|
dest.write_bytes(await file.read())
|
||||||
|
|
||||||
|
recipe.image_filename = filename
|
||||||
|
db.commit()
|
||||||
|
db.refresh(recipe)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{recipe_id}/image", response_model=RecipeOut)
|
||||||
|
def delete_recipe_image(recipe_id: int, db: Session = Depends(get_db)):
|
||||||
|
recipe = db.get(Recipe, recipe_id)
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||||
|
if recipe.image_filename:
|
||||||
|
(UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True)
|
||||||
|
recipe.image_filename = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(recipe)
|
||||||
|
return recipe
|
||||||
80
backend/app/schemas.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
IngredientKind = Literal["item", "section_header"]
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientIn(BaseModel):
|
||||||
|
kind: IngredientKind = "item"
|
||||||
|
quantity: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientOut(IngredientIn):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class StepIn(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class StepOut(StepIn):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
category: str | None = None
|
||||||
|
preptime: str | None = None
|
||||||
|
cooktime: str | None = None
|
||||||
|
baketime: str | None = None
|
||||||
|
cooltime: str | None = None
|
||||||
|
marinade_time: str | None = None
|
||||||
|
robot_time: str | None = None
|
||||||
|
servings: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
kcal: str | None = None
|
||||||
|
kh: str | None = None
|
||||||
|
protein: str | None = None
|
||||||
|
fett: str | None = None
|
||||||
|
ingredients: list[IngredientIn] = Field(default_factory=list)
|
||||||
|
steps: list[StepIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
category: str | None
|
||||||
|
image_filename: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
category: str | None
|
||||||
|
image_filename: str | None
|
||||||
|
preptime: str | None
|
||||||
|
cooktime: str | None
|
||||||
|
baketime: str | None
|
||||||
|
cooltime: str | None
|
||||||
|
marinade_time: str | None
|
||||||
|
robot_time: str | None
|
||||||
|
servings: str | None
|
||||||
|
notes: str | None
|
||||||
|
kcal: str | None
|
||||||
|
kh: str | None
|
||||||
|
protein: str | None
|
||||||
|
fett: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
ingredients: list[IngredientOut]
|
||||||
|
steps: list[StepOut]
|
||||||
7
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fastapi==0.121.2
|
||||||
|
uvicorn[standard]==0.38.0
|
||||||
|
sqlalchemy==2.0.44
|
||||||
|
pydantic==2.13.4
|
||||||
|
python-multipart==0.0.20
|
||||||
|
jinja2==3.1.5
|
||||||
|
python-slugify==8.0.4
|
||||||
19
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: recipeinator-backend:arm64
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: recipeinator-frontend:arm64
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- proxy-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-net:
|
||||||
|
external: true
|
||||||
19
docker-compose.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- proxy-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-net:
|
||||||
|
external: true
|
||||||
2
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
24
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
frontend/.oxlintrc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
32
frontend/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RecipeInator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
1477
frontend/package-lock.json
generated
Normal file
26
frontend/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
285
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
.app {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
background: #fdecea;
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card img,
|
||||||
|
.recipe-card-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card-body {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-row .qty {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-row .unit {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-row .text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-row textarea {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-controls button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-height: 120px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.4fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-list,
|
||||||
|
.step-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-list li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-list .qty {
|
||||||
|
min-width: 4.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-list .section-header {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-list {
|
||||||
|
list-style: decimal;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-list li {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
white-space: pre-line;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nutrition-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.detail-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Link, Route, Routes } from "react-router-dom";
|
||||||
|
import "./App.css";
|
||||||
|
import RecipeDetailPage from "./pages/RecipeDetailPage";
|
||||||
|
import RecipeFormPage from "./pages/RecipeFormPage";
|
||||||
|
import RecipeListPage from "./pages/RecipeListPage";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<Link to="/" className="brand">🍳 RecipeInator</Link>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<RecipeListPage />} />
|
||||||
|
<Route path="/recipes/new" element={<RecipeFormPage />} />
|
||||||
|
<Route path="/recipes/:id" element={<RecipeDetailPage />} />
|
||||||
|
<Route path="/recipes/:id/edit" element={<RecipeFormPage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
65
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import type { Recipe, RecipeInput, RecipeSummary } from "../types/recipe";
|
||||||
|
|
||||||
|
const BASE = "/api";
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const isFormData = options?.body instanceof FormData;
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
detail = data.detail ?? JSON.stringify(data);
|
||||||
|
} catch {
|
||||||
|
detail = await res.text();
|
||||||
|
}
|
||||||
|
throw new Error(detail || `Request failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRecipes(search?: string): Promise<RecipeSummary[]> {
|
||||||
|
const query = search ? `?search=${encodeURIComponent(search)}` : "";
|
||||||
|
return request(`/recipes${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipe(id: number): Promise<Recipe> {
|
||||||
|
return request(`/recipes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecipe(payload: RecipeInput): Promise<Recipe> {
|
||||||
|
return request(`/recipes`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRecipe(id: number, payload: RecipeInput): Promise<Recipe> {
|
||||||
|
return request(`/recipes/${id}`, { method: "PUT", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRecipe(id: number): Promise<void> {
|
||||||
|
return request(`/recipes/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadRecipeImage(id: number, file: File): Promise<Recipe> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
return request(`/recipes/${id}/image`, { method: "POST", body: form });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRecipeImage(id: number): Promise<Recipe> {
|
||||||
|
return request(`/recipes/${id}/image`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfDownloadUrl(id: number): string {
|
||||||
|
return `${BASE}/recipes/${id}/pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadedImageUrl(filename: string): string {
|
||||||
|
return `/uploads/${filename}`;
|
||||||
|
}
|
||||||
59
frontend/src/components/IngredientRow.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type { Ingredient } from "../types/recipe";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ingredient: Ingredient;
|
||||||
|
onChange: (ingredient: Ingredient) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IngredientRow({ ingredient, onChange, onRemove, onMoveUp, onMoveDown }: Props) {
|
||||||
|
const isHeader = ingredient.kind === "section_header";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row ingredient-row">
|
||||||
|
<div className="row-controls">
|
||||||
|
<button type="button" onClick={onMoveUp} title="Move up">↑</button>
|
||||||
|
<button type="button" onClick={onMoveDown} title="Move down">↓</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-inline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isHeader}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...ingredient, kind: e.target.checked ? "section_header" : "item" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Section header
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!isHeader && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="qty"
|
||||||
|
placeholder="Menge"
|
||||||
|
value={ingredient.quantity ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...ingredient, quantity: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="unit"
|
||||||
|
placeholder="Einheit"
|
||||||
|
value={ingredient.unit ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...ingredient, unit: e.target.value })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="text"
|
||||||
|
placeholder={isHeader ? "Abschnittsname (z.B. Vorteig)" : "Zutat"}
|
||||||
|
value={ingredient.text}
|
||||||
|
onChange={(e) => onChange({ ...ingredient, text: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="button" className="remove" onClick={onRemove} title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/NutritionFields.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { RecipeInput } from "../types/recipe";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: RecipeInput;
|
||||||
|
onChange: <K extends keyof RecipeInput>(field: K, value: RecipeInput[K]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: { key: keyof RecipeInput; label: string; placeholder: string }[] = [
|
||||||
|
{ key: "kcal", label: "Kalorien", placeholder: "z.B. 350" },
|
||||||
|
{ key: "kh", label: "Kohlenhydrate", placeholder: "z.B. 20 g" },
|
||||||
|
{ key: "protein", label: "Protein", placeholder: "z.B. 15 g" },
|
||||||
|
{ key: "fett", label: "Fett", placeholder: "z.B. 10 g" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NutritionFields({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="field-grid">
|
||||||
|
{FIELDS.map(({ key, label, placeholder }) => (
|
||||||
|
<label key={key}>
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
value={(value[key] as string | null | undefined) ?? ""}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/components/StepRow.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Step } from "../types/recipe";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
step: Step;
|
||||||
|
index: number;
|
||||||
|
onChange: (step: Step) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StepRow({ step, index, onChange, onRemove, onMoveUp, onMoveDown }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="row step-row">
|
||||||
|
<div className="row-controls">
|
||||||
|
<span className="step-number">{index + 1}</span>
|
||||||
|
<button type="button" onClick={onMoveUp} title="Move up">↑</button>
|
||||||
|
<button type="button" onClick={onMoveDown} title="Move down">↓</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
placeholder="Zubereitungsschritt (Tipp: **Label**: Text für eine fette Teil-Überschrift)"
|
||||||
|
value={step.text}
|
||||||
|
onChange={(e) => onChange({ ...step, text: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<button type="button" className="remove" onClick={onRemove} title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/TimingFields.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { RecipeInput } from "../types/recipe";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: RecipeInput;
|
||||||
|
onChange: <K extends keyof RecipeInput>(field: K, value: RecipeInput[K]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: { key: keyof RecipeInput; label: string; placeholder: string }[] = [
|
||||||
|
{ key: "preptime", label: "Vorbereitung", placeholder: "z.B. 20 min" },
|
||||||
|
{ key: "cooktime", label: "Kochzeit", placeholder: "z.B. 10 min" },
|
||||||
|
{ key: "baketime", label: "Backzeit", placeholder: "z.B. 180°C, 20 min" },
|
||||||
|
{ key: "cooltime", label: "Kühlzeit", placeholder: "z.B. 30 min" },
|
||||||
|
{ key: "marinade_time", label: "Marinierzeit", placeholder: "z.B. 1-24 h" },
|
||||||
|
{ key: "robot_time", label: "Küchenmaschine", placeholder: "z.B. 30 min" },
|
||||||
|
{ key: "servings", label: "Portionen", placeholder: "z.B. 4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TimingFields({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="field-grid">
|
||||||
|
{FIELDS.map(({ key, label, placeholder }) => (
|
||||||
|
<label key={key}>
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
value={(value[key] as string | null | undefined) ?? ""}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
:root {
|
||||||
|
--text: #2b2b2b;
|
||||||
|
--text-muted: #6b6b6b;
|
||||||
|
--bg: #faf8f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e2ded6;
|
||||||
|
--accent: #a41034;
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
--danger: #b3261e;
|
||||||
|
|
||||||
|
font: 16px/1.5 system-ui, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
13
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
135
frontend/src/pages/RecipeDetailPage.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { deleteRecipe, getRecipe, pdfDownloadUrl, uploadedImageUrl } from "../api/client";
|
||||||
|
import type { Recipe } from "../types/recipe";
|
||||||
|
|
||||||
|
const TIMING_LABELS: { key: keyof Recipe; label: string }[] = [
|
||||||
|
{ key: "preptime", label: "Vorbereitung" },
|
||||||
|
{ key: "cooktime", label: "Kochzeit" },
|
||||||
|
{ key: "baketime", label: "Backzeit" },
|
||||||
|
{ key: "cooltime", label: "Kühlzeit" },
|
||||||
|
{ key: "marinade_time", label: "Marinierzeit" },
|
||||||
|
{ key: "robot_time", label: "Küchenmaschine" },
|
||||||
|
{ key: "servings", label: "Portionen" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RecipeDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
getRecipe(Number(id)).then(setRecipe).catch((e) => setError(e.message));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (error) return <p className="error">{error}</p>;
|
||||||
|
if (!recipe) return <p>Lädt…</p>;
|
||||||
|
|
||||||
|
const timingParts = TIMING_LABELS.filter(({ key }) => recipe[key]);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!recipe || !confirm(`"${recipe.name}" wirklich löschen?`)) return;
|
||||||
|
await deleteRecipe(recipe.id);
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
if (!recipe) return;
|
||||||
|
setDownloading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(pdfDownloadUrl(recipe.id));
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
throw new Error(data?.detail ?? `PDF-Erstellung fehlgeschlagen (${res.status})`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${recipe.slug}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>{recipe.name}</h1>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="button primary" onClick={handleDownload} disabled={downloading}>
|
||||||
|
{downloading ? "Erstelle PDF…" : "PDF herunterladen"}
|
||||||
|
</button>
|
||||||
|
<Link className="button" to={`/recipes/${recipe.id}/edit`}>Bearbeiten</Link>
|
||||||
|
<button className="button danger" onClick={handleDelete}>Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{recipe.image_filename && (
|
||||||
|
<img className="detail-image" src={uploadedImageUrl(recipe.image_filename)} alt="" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipe.category && <p className="tag">{recipe.category}</p>}
|
||||||
|
|
||||||
|
{timingParts.length > 0 && (
|
||||||
|
<div className="timing-summary">
|
||||||
|
{timingParts.map(({ key, label }) => (
|
||||||
|
<span key={key}><strong>{label}:</strong> {recipe[key] as string}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="detail-columns">
|
||||||
|
<div>
|
||||||
|
<h2>Zutaten</h2>
|
||||||
|
<ul className="ingredient-list">
|
||||||
|
{recipe.ingredients.map((ing) =>
|
||||||
|
ing.kind === "section_header" ? (
|
||||||
|
<li key={ing.id} className="section-header">{ing.text}</li>
|
||||||
|
) : (
|
||||||
|
<li key={ing.id}>
|
||||||
|
<span className="qty">{[ing.quantity, ing.unit].filter(Boolean).join(" ")}</span>
|
||||||
|
<span>{ing.text}</span>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
{recipe.notes && (
|
||||||
|
<>
|
||||||
|
<h2>Notizen</h2>
|
||||||
|
<p className="notes">{recipe.notes}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Zubereitung</h2>
|
||||||
|
<ol className="step-list">
|
||||||
|
{recipe.steps.map((step) => (
|
||||||
|
<li key={step.id}>{step.text}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(recipe.kcal || recipe.kh || recipe.protein || recipe.fett) && (
|
||||||
|
<div className="nutrition-summary">
|
||||||
|
{recipe.kcal && <span>Kalorien: {recipe.kcal}</span>}
|
||||||
|
{recipe.kh && <span>Kohlenhydrate: {recipe.kh}</span>}
|
||||||
|
{recipe.protein && <span>Protein: {recipe.protein}</span>}
|
||||||
|
{recipe.fett && <span>Fett: {recipe.fett}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
frontend/src/pages/RecipeFormPage.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
createRecipe,
|
||||||
|
deleteRecipeImage,
|
||||||
|
getRecipe,
|
||||||
|
updateRecipe,
|
||||||
|
uploadRecipeImage,
|
||||||
|
uploadedImageUrl,
|
||||||
|
} from "../api/client";
|
||||||
|
import IngredientRow from "../components/IngredientRow";
|
||||||
|
import NutritionFields from "../components/NutritionFields";
|
||||||
|
import StepRow from "../components/StepRow";
|
||||||
|
import TimingFields from "../components/TimingFields";
|
||||||
|
import type { Ingredient, RecipeInput, Step } from "../types/recipe";
|
||||||
|
|
||||||
|
const EMPTY_RECIPE: RecipeInput = {
|
||||||
|
name: "",
|
||||||
|
category: "",
|
||||||
|
preptime: "",
|
||||||
|
cooktime: "",
|
||||||
|
baketime: "",
|
||||||
|
cooltime: "",
|
||||||
|
marinade_time: "",
|
||||||
|
robot_time: "",
|
||||||
|
servings: "",
|
||||||
|
notes: "",
|
||||||
|
kcal: "",
|
||||||
|
kh: "",
|
||||||
|
protein: "",
|
||||||
|
fett: "",
|
||||||
|
ingredients: [],
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function reorder<T>(items: T[], index: number, direction: -1 | 1): T[] {
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= items.length) return items;
|
||||||
|
const copy = [...items];
|
||||||
|
[copy[index], copy[target]] = [copy[target], copy[index]];
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<RecipeInput>(EMPTY_RECIPE);
|
||||||
|
const [existingImage, setExistingImage] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
getRecipe(Number(id)).then((recipe) => {
|
||||||
|
setForm({
|
||||||
|
name: recipe.name,
|
||||||
|
category: recipe.category ?? "",
|
||||||
|
preptime: recipe.preptime ?? "",
|
||||||
|
cooktime: recipe.cooktime ?? "",
|
||||||
|
baketime: recipe.baketime ?? "",
|
||||||
|
cooltime: recipe.cooltime ?? "",
|
||||||
|
marinade_time: recipe.marinade_time ?? "",
|
||||||
|
robot_time: recipe.robot_time ?? "",
|
||||||
|
servings: recipe.servings ?? "",
|
||||||
|
notes: recipe.notes ?? "",
|
||||||
|
kcal: recipe.kcal ?? "",
|
||||||
|
kh: recipe.kh ?? "",
|
||||||
|
protein: recipe.protein ?? "",
|
||||||
|
fett: recipe.fett ?? "",
|
||||||
|
ingredients: recipe.ingredients,
|
||||||
|
steps: recipe.steps,
|
||||||
|
});
|
||||||
|
setExistingImage(recipe.image_filename ?? null);
|
||||||
|
});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
function updateField<K extends keyof RecipeInput>(field: K, value: RecipeInput[K]) {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIngredient(index: number, ingredient: Ingredient) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ingredients: prev.ingredients.map((it, i) => (i === index ? ingredient : it)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIngredient() {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ingredients: [...prev.ingredients, { kind: "item", quantity: "", unit: "", text: "" }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredient(index: number) {
|
||||||
|
setForm((prev) => ({ ...prev, ingredients: prev.ingredients.filter((_, i) => i !== index) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveIngredient(index: number, direction: -1 | 1) {
|
||||||
|
setForm((prev) => ({ ...prev, ingredients: reorder(prev.ingredients, index, direction) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStep(index: number, step: Step) {
|
||||||
|
setForm((prev) => ({ ...prev, steps: prev.steps.map((it, i) => (i === index ? step : it)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStep() {
|
||||||
|
setForm((prev) => ({ ...prev, steps: [...prev.steps, { text: "" }] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStep(index: number) {
|
||||||
|
setForm((prev) => ({ ...prev, steps: prev.steps.filter((_, i) => i !== index) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveStep(index: number, direction: -1 | 1) {
|
||||||
|
setForm((prev) => ({ ...prev, steps: reorder(prev.steps, index, direction) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveExistingImage() {
|
||||||
|
if (!id) return;
|
||||||
|
await deleteRecipeImage(Number(id));
|
||||||
|
setExistingImage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setError("Bitte einen Namen angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const recipe = isEdit ? await updateRecipe(Number(id), form) : await createRecipe(form);
|
||||||
|
if (imageFile) {
|
||||||
|
await uploadRecipeImage(recipe.id, imageFile);
|
||||||
|
}
|
||||||
|
navigate(`/recipes/${recipe.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="page" onSubmit={handleSubmit}>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>{isEdit ? "Rezept bearbeiten" : "Neues Rezept"}</h1>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="submit" className="button primary" disabled={saving}>
|
||||||
|
{saving ? "Speichern…" : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<div className="field-grid">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Kategorie
|
||||||
|
<input
|
||||||
|
value={form.category ?? ""}
|
||||||
|
placeholder="z.B. Hauptspeisen, Beilagen, Nachspeisen"
|
||||||
|
onChange={(e) => updateField("category", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Bild</h2>
|
||||||
|
{existingImage && !imageFile && (
|
||||||
|
<div className="image-preview">
|
||||||
|
<img src={uploadedImageUrl(existingImage)} alt="" />
|
||||||
|
<button type="button" onClick={handleRemoveExistingImage}>Bild entfernen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{imageFile && (
|
||||||
|
<div className="image-preview">
|
||||||
|
<img src={URL.createObjectURL(imageFile)} alt="" />
|
||||||
|
<button type="button" onClick={() => setImageFile(null)}>Auswahl verwerfen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => setImageFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2>Zeiten & Portionen</h2>
|
||||||
|
<TimingFields value={form} onChange={updateField} />
|
||||||
|
|
||||||
|
<h2>Zutaten</h2>
|
||||||
|
<div className="row-list">
|
||||||
|
{form.ingredients.map((ingredient, index) => (
|
||||||
|
<IngredientRow
|
||||||
|
key={index}
|
||||||
|
ingredient={ingredient}
|
||||||
|
onChange={(value) => updateIngredient(index, value)}
|
||||||
|
onRemove={() => removeIngredient(index)}
|
||||||
|
onMoveUp={() => moveIngredient(index, -1)}
|
||||||
|
onMoveDown={() => moveIngredient(index, 1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="button" onClick={addIngredient}>+ Zutat</button>
|
||||||
|
|
||||||
|
<h2>Notizen</h2>
|
||||||
|
<textarea
|
||||||
|
value={form.notes ?? ""}
|
||||||
|
onChange={(e) => updateField("notes", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2>Zubereitung</h2>
|
||||||
|
<div className="row-list">
|
||||||
|
{form.steps.map((step, index) => (
|
||||||
|
<StepRow
|
||||||
|
key={index}
|
||||||
|
step={step}
|
||||||
|
index={index}
|
||||||
|
onChange={(value) => updateStep(index, value)}
|
||||||
|
onRemove={() => removeStep(index)}
|
||||||
|
onMoveUp={() => moveStep(index, -1)}
|
||||||
|
onMoveDown={() => moveStep(index, 1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="button" onClick={addStep}>+ Schritt</button>
|
||||||
|
|
||||||
|
<h2>Nährwerte (pro Portion)</h2>
|
||||||
|
<NutritionFields value={form} onChange={updateField} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/pages/RecipeListPage.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { listRecipes, uploadedImageUrl } from "../api/client";
|
||||||
|
import type { RecipeSummary } from "../types/recipe";
|
||||||
|
|
||||||
|
export default function RecipeListPage() {
|
||||||
|
const [recipes, setRecipes] = useState<RecipeSummary[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
setLoading(true);
|
||||||
|
listRecipes(search)
|
||||||
|
.then(setRecipes)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Rezepte</h1>
|
||||||
|
<Link className="button primary" to="/recipes/new">+ Neues Rezept</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="search"
|
||||||
|
placeholder="Suche nach Name oder Kategorie…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{loading && <p>Lädt…</p>}
|
||||||
|
|
||||||
|
<div className="recipe-grid">
|
||||||
|
{recipes.map((recipe) => (
|
||||||
|
<Link key={recipe.id} to={`/recipes/${recipe.id}`} className="recipe-card">
|
||||||
|
{recipe.image_filename ? (
|
||||||
|
<img src={uploadedImageUrl(recipe.image_filename)} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="recipe-card-placeholder" />
|
||||||
|
)}
|
||||||
|
<div className="recipe-card-body">
|
||||||
|
<strong>{recipe.name}</strong>
|
||||||
|
{recipe.category && <span className="tag">{recipe.category}</span>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!loading && recipes.length === 0 && <p>Keine Rezepte gefunden.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/types/recipe.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
export type IngredientKind = "item" | "section_header";
|
||||||
|
|
||||||
|
export interface Ingredient {
|
||||||
|
id?: number;
|
||||||
|
kind: IngredientKind;
|
||||||
|
quantity?: string | null;
|
||||||
|
unit?: string | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
id?: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
category?: string | null;
|
||||||
|
image_filename?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recipe extends RecipeSummary {
|
||||||
|
preptime?: string | null;
|
||||||
|
cooktime?: string | null;
|
||||||
|
baketime?: string | null;
|
||||||
|
cooltime?: string | null;
|
||||||
|
marinade_time?: string | null;
|
||||||
|
robot_time?: string | null;
|
||||||
|
servings?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
kcal?: string | null;
|
||||||
|
kh?: string | null;
|
||||||
|
protein?: string | null;
|
||||||
|
fett?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
ingredients: Ingredient[];
|
||||||
|
steps: Step[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeInput {
|
||||||
|
name: string;
|
||||||
|
category?: string | null;
|
||||||
|
preptime?: string | null;
|
||||||
|
cooktime?: string | null;
|
||||||
|
baketime?: string | null;
|
||||||
|
cooltime?: string | null;
|
||||||
|
marinade_time?: string | null;
|
||||||
|
robot_time?: string | null;
|
||||||
|
servings?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
kcal?: string | null;
|
||||||
|
kh?: string | null;
|
||||||
|
protein?: string | null;
|
||||||
|
fett?: string | null;
|
||||||
|
ingredients: Ingredient[];
|
||||||
|
steps: Step[];
|
||||||
|
}
|
||||||
26
frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
13
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
'/uploads': 'http://localhost:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
15
reverse-proxy/Caddyfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Catch-all: plain IP or "homepie1.local" access, useful for local testing
|
||||||
|
# and as a fallback before you've set up a per-service .local alias.
|
||||||
|
:80 {
|
||||||
|
reverse_proxy frontend:80
|
||||||
|
}
|
||||||
|
|
||||||
|
# RecipeInator
|
||||||
|
http://rezepte.local {
|
||||||
|
reverse_proxy frontend:80
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add one block like this per future service, e.g.:
|
||||||
|
# http://git.local {
|
||||||
|
# reverse_proxy git-service:PORT
|
||||||
|
# }
|
||||||
14
reverse-proxy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
networks:
|
||||||
|
- proxy-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-net:
|
||||||
|
name: proxy-net
|
||||||