diff --git a/Pictures/LimettenKokosMakronen.png b/Pictures/LimettenKokosMakronen.png
new file mode 100644
index 0000000..36fcbf0
Binary files /dev/null and b/Pictures/LimettenKokosMakronen.png differ
diff --git a/Pictures/MozartKekse.png b/Pictures/MozartKekse.png
new file mode 100644
index 0000000..f923b65
Binary files /dev/null and b/Pictures/MozartKekse.png differ
diff --git a/Pictures/PistazienTraumstuecke.png b/Pictures/PistazienTraumstuecke.png
new file mode 100644
index 0000000..851c7a4
Binary files /dev/null and b/Pictures/PistazienTraumstuecke.png differ
diff --git a/Pictures/eierlikoertorte.jpg b/Pictures/eierlikoertorte.jpg
new file mode 100644
index 0000000..d676a41
Binary files /dev/null and b/Pictures/eierlikoertorte.jpg differ
diff --git a/Pictures/ico/44086.jpg b/Pictures/ico/44086.jpg
new file mode 100644
index 0000000..52e23b5
Binary files /dev/null and b/Pictures/ico/44086.jpg differ
diff --git a/Pictures/ico/KH.png b/Pictures/ico/KH.png
new file mode 100644
index 0000000..461b414
Binary files /dev/null and b/Pictures/ico/KH.png differ
diff --git a/Pictures/ico/chef.png b/Pictures/ico/chef.png
new file mode 100644
index 0000000..bb330ee
Binary files /dev/null and b/Pictures/ico/chef.png differ
diff --git a/Pictures/ico/cloche.png b/Pictures/ico/cloche.png
new file mode 100644
index 0000000..bac9280
Binary files /dev/null and b/Pictures/ico/cloche.png differ
diff --git a/Pictures/ico/cooking.png b/Pictures/ico/cooking.png
new file mode 100644
index 0000000..5ca8056
Binary files /dev/null and b/Pictures/ico/cooking.png differ
diff --git a/Pictures/ico/dish.png b/Pictures/ico/dish.png
new file mode 100644
index 0000000..ce59861
Binary files /dev/null and b/Pictures/ico/dish.png differ
diff --git a/Pictures/ico/fett.png b/Pictures/ico/fett.png
new file mode 100644
index 0000000..4b5ba4c
Binary files /dev/null and b/Pictures/ico/fett.png differ
diff --git a/Pictures/ico/fridge.png b/Pictures/ico/fridge.png
new file mode 100644
index 0000000..f6143e8
Binary files /dev/null and b/Pictures/ico/fridge.png differ
diff --git a/Pictures/ico/kcal.png b/Pictures/ico/kcal.png
new file mode 100644
index 0000000..7768728
Binary files /dev/null and b/Pictures/ico/kcal.png differ
diff --git a/Pictures/ico/marinade.png b/Pictures/ico/marinade.png
new file mode 100644
index 0000000..edf0df0
Binary files /dev/null and b/Pictures/ico/marinade.png differ
diff --git a/Pictures/ico/oberunter.png b/Pictures/ico/oberunter.png
new file mode 100644
index 0000000..b5d660e
Binary files /dev/null and b/Pictures/ico/oberunter.png differ
diff --git a/Pictures/ico/oven.png b/Pictures/ico/oven.png
new file mode 100644
index 0000000..ad4e651
Binary files /dev/null and b/Pictures/ico/oven.png differ
diff --git a/Pictures/ico/plate.png b/Pictures/ico/plate.png
new file mode 100644
index 0000000..4aff4eb
Binary files /dev/null and b/Pictures/ico/plate.png differ
diff --git a/Pictures/ico/pot.png b/Pictures/ico/pot.png
new file mode 100644
index 0000000..282cfdd
Binary files /dev/null and b/Pictures/ico/pot.png differ
diff --git a/Pictures/ico/protein.png b/Pictures/ico/protein.png
new file mode 100644
index 0000000..d2c6677
Binary files /dev/null and b/Pictures/ico/protein.png differ
diff --git a/Pictures/ico/robot.png b/Pictures/ico/robot.png
new file mode 100644
index 0000000..eeea8a2
Binary files /dev/null and b/Pictures/ico/robot.png differ
diff --git a/Pictures/ico/whisk.png b/Pictures/ico/whisk.png
new file mode 100644
index 0000000..ecf92bc
Binary files /dev/null and b/Pictures/ico/whisk.png differ
diff --git a/Pictures/lebkuchen.jpg b/Pictures/lebkuchen.jpg
new file mode 100644
index 0000000..bc742c6
Binary files /dev/null and b/Pictures/lebkuchen.jpg differ
diff --git a/Pictures/sauerteigToastbrot.png b/Pictures/sauerteigToastbrot.png
new file mode 100644
index 0000000..576d138
Binary files /dev/null and b/Pictures/sauerteigToastbrot.png differ
diff --git a/Recipes/ButterChicken.tex b/Recipes/ButterChicken.tex
new file mode 100644
index 0000000..4753016
--- /dev/null
+++ b/Recipes/ButterChicken.tex
@@ -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}}
\ No newline at end of file
diff --git a/Recipes/Eierlikörtorte.tex b/Recipes/Eierlikörtorte.tex
new file mode 100644
index 0000000..725033b
--- /dev/null
+++ b/Recipes/Eierlikörtorte.tex
@@ -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}}
\ No newline at end of file
diff --git a/Recipes/Erdapfeltasche.tex b/Recipes/Erdapfeltasche.tex
new file mode 100644
index 0000000..79aef7b
--- /dev/null
+++ b/Recipes/Erdapfeltasche.tex
@@ -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 �� 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.
+}
\ No newline at end of file
diff --git a/Recipes/Lachs&Süßkartoffel.tex b/Recipes/Lachs&Süßkartoffel.tex
new file mode 100644
index 0000000..dffe250
--- /dev/null
+++ b/Recipes/Lachs&Süßkartoffel.tex
@@ -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}}
\ No newline at end of file
diff --git a/Recipes/LimettenKokosMakronen.tex b/Recipes/LimettenKokosMakronen.tex
new file mode 100644
index 0000000..d3e5da7
--- /dev/null
+++ b/Recipes/LimettenKokosMakronen.tex
@@ -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.
+}
\ No newline at end of file
diff --git a/Recipes/MozartKekse.tex b/Recipes/MozartKekse.tex
new file mode 100644
index 0000000..7cdaf86
--- /dev/null
+++ b/Recipes/MozartKekse.tex
@@ -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.
+}
\ No newline at end of file
diff --git a/Recipes/Naan.tex b/Recipes/Naan.tex
new file mode 100644
index 0000000..d6382dc
--- /dev/null
+++ b/Recipes/Naan.tex
@@ -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}}
\ No newline at end of file
diff --git a/Recipes/PistazienTraumstuecke.tex b/Recipes/PistazienTraumstuecke.tex
new file mode 100644
index 0000000..efcc2c7
--- /dev/null
+++ b/Recipes/PistazienTraumstuecke.tex
@@ -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.
+}
\ No newline at end of file
diff --git a/Recipes/Sample.tex b/Recipes/Sample.tex
new file mode 100644
index 0000000..c058031
--- /dev/null
+++ b/Recipes/Sample.tex
@@ -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}}
\ No newline at end of file
diff --git a/Recipes/Sauerteig_Toastbrot.tex b/Recipes/Sauerteig_Toastbrot.tex
new file mode 100644
index 0000000..ad44eee
--- /dev/null
+++ b/Recipes/Sauerteig_Toastbrot.tex
@@ -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}}
\ No newline at end of file
diff --git a/Recipes/lebkuchen.tex b/Recipes/lebkuchen.tex
new file mode 100644
index 0000000..17f05d1
--- /dev/null
+++ b/Recipes/lebkuchen.tex
@@ -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.
+}
\ No newline at end of file
diff --git a/Rezeptbuch.tex b/Rezeptbuch.tex
new file mode 100644
index 0000000..f8190a7
--- /dev/null
+++ b/Rezeptbuch.tex
@@ -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}
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..4fc95ea
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,3 @@
+.venv
+__pycache__
+*.pyc
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..ec72455
--- /dev/null
+++ b/backend/Dockerfile
@@ -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"]
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..00de244
--- /dev/null
+++ b/backend/app/database.py
@@ -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()
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..25f18be
--- /dev/null
+++ b/backend/app/main.py
@@ -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"}
diff --git a/backend/app/models.py b/backend/app/models.py
new file mode 100644
index 0000000..cdb8812
--- /dev/null
+++ b/backend/app/models.py
@@ -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")
diff --git a/backend/app/pdf/__init__.py b/backend/app/pdf/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/pdf/assets/cuisine.sty b/backend/app/pdf/assets/cuisine.sty
new file mode 100644
index 0000000..79b9c99
--- /dev/null
+++ b/backend/app/pdf/assets/cuisine.sty
@@ -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
diff --git a/backend/app/pdf/assets/icons/KH.png b/backend/app/pdf/assets/icons/KH.png
new file mode 100644
index 0000000..461b414
Binary files /dev/null and b/backend/app/pdf/assets/icons/KH.png differ
diff --git a/backend/app/pdf/assets/icons/chef.png b/backend/app/pdf/assets/icons/chef.png
new file mode 100644
index 0000000..bb330ee
Binary files /dev/null and b/backend/app/pdf/assets/icons/chef.png differ
diff --git a/backend/app/pdf/assets/icons/cloche.png b/backend/app/pdf/assets/icons/cloche.png
new file mode 100644
index 0000000..bac9280
Binary files /dev/null and b/backend/app/pdf/assets/icons/cloche.png differ
diff --git a/backend/app/pdf/assets/icons/cooking.png b/backend/app/pdf/assets/icons/cooking.png
new file mode 100644
index 0000000..5ca8056
Binary files /dev/null and b/backend/app/pdf/assets/icons/cooking.png differ
diff --git a/backend/app/pdf/assets/icons/dish.png b/backend/app/pdf/assets/icons/dish.png
new file mode 100644
index 0000000..ce59861
Binary files /dev/null and b/backend/app/pdf/assets/icons/dish.png differ
diff --git a/backend/app/pdf/assets/icons/fett.png b/backend/app/pdf/assets/icons/fett.png
new file mode 100644
index 0000000..4b5ba4c
Binary files /dev/null and b/backend/app/pdf/assets/icons/fett.png differ
diff --git a/backend/app/pdf/assets/icons/fridge.png b/backend/app/pdf/assets/icons/fridge.png
new file mode 100644
index 0000000..f6143e8
Binary files /dev/null and b/backend/app/pdf/assets/icons/fridge.png differ
diff --git a/backend/app/pdf/assets/icons/kcal.png b/backend/app/pdf/assets/icons/kcal.png
new file mode 100644
index 0000000..7768728
Binary files /dev/null and b/backend/app/pdf/assets/icons/kcal.png differ
diff --git a/backend/app/pdf/assets/icons/marinade.png b/backend/app/pdf/assets/icons/marinade.png
new file mode 100644
index 0000000..edf0df0
Binary files /dev/null and b/backend/app/pdf/assets/icons/marinade.png differ
diff --git a/backend/app/pdf/assets/icons/oberunter.png b/backend/app/pdf/assets/icons/oberunter.png
new file mode 100644
index 0000000..b5d660e
Binary files /dev/null and b/backend/app/pdf/assets/icons/oberunter.png differ
diff --git a/backend/app/pdf/assets/icons/oven.png b/backend/app/pdf/assets/icons/oven.png
new file mode 100644
index 0000000..ad4e651
Binary files /dev/null and b/backend/app/pdf/assets/icons/oven.png differ
diff --git a/backend/app/pdf/assets/icons/plate.png b/backend/app/pdf/assets/icons/plate.png
new file mode 100644
index 0000000..4aff4eb
Binary files /dev/null and b/backend/app/pdf/assets/icons/plate.png differ
diff --git a/backend/app/pdf/assets/icons/pot.png b/backend/app/pdf/assets/icons/pot.png
new file mode 100644
index 0000000..282cfdd
Binary files /dev/null and b/backend/app/pdf/assets/icons/pot.png differ
diff --git a/backend/app/pdf/assets/icons/protein.png b/backend/app/pdf/assets/icons/protein.png
new file mode 100644
index 0000000..d2c6677
Binary files /dev/null and b/backend/app/pdf/assets/icons/protein.png differ
diff --git a/backend/app/pdf/assets/icons/robot.png b/backend/app/pdf/assets/icons/robot.png
new file mode 100644
index 0000000..eeea8a2
Binary files /dev/null and b/backend/app/pdf/assets/icons/robot.png differ
diff --git a/backend/app/pdf/assets/icons/whisk.png b/backend/app/pdf/assets/icons/whisk.png
new file mode 100644
index 0000000..ecf92bc
Binary files /dev/null and b/backend/app/pdf/assets/icons/whisk.png differ
diff --git a/backend/app/pdf/escape.py b/backend/app/pdf/escape.py
new file mode 100644
index 0000000..63c126c
--- /dev/null
+++ b/backend/app/pdf/escape.py
@@ -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)
diff --git a/backend/app/pdf/render.py b/backend/app/pdf/render.py
new file mode 100644
index 0000000..18e03e4
--- /dev/null
+++ b/backend/app/pdf/render.py
@@ -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()
diff --git a/backend/app/pdf/templates/recipe.tex.jinja b/backend/app/pdf/templates/recipe.tex.jinja
new file mode 100644
index 0000000..347ece3
--- /dev/null
+++ b/backend/app/pdf/templates/recipe.tex.jinja
@@ -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}
diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/routers/pdf.py b/backend/app/routers/pdf.py
new file mode 100644
index 0000000..0c8a43c
--- /dev/null
+++ b/backend/app/routers/pdf.py
@@ -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}"'},
+ )
diff --git a/backend/app/routers/recipes.py b/backend/app/routers/recipes.py
new file mode 100644
index 0000000..4036733
--- /dev/null
+++ b/backend/app/routers/recipes.py
@@ -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
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
new file mode 100644
index 0000000..b043677
--- /dev/null
+++ b/backend/app/schemas.py
@@ -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]
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..81f77c6
--- /dev/null
+++ b/backend/requirements.txt
@@ -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
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..8056803
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ebf9bb1
--- /dev/null
+++ b/docker-compose.yml
@@ -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
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -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?
diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json
new file mode 100644
index 0000000..6fa991d
--- /dev/null
+++ b/frontend/.oxlintrc.json
@@ -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 }]
+ }
+}
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..ad4248b
--- /dev/null
+++ b/frontend/Dockerfile
@@ -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
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..d6af7e3
--- /dev/null
+++ b/frontend/README.md
@@ -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.
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..878104a
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ RecipeInator
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..a866341
--- /dev/null
+++ b/frontend/nginx.conf
@@ -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;
+ }
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..c39e4f1
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1477 @@
+{
+ "name": "frontend",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "frontend",
+ "version": "0.0.0",
+ "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"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.138.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
+ "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz",
+ "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz",
+ "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz",
+ "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz",
+ "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz",
+ "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz",
+ "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz",
+ "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz",
+ "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz",
+ "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz",
+ "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz",
+ "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz",
+ "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz",
+ "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz",
+ "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz",
+ "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz",
+ "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz",
+ "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz",
+ "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz",
+ "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
+ "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
+ "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
+ "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
+ "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
+ "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
+ "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
+ "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
+ "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
+ "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
+ "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
+ "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
+ "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
+ "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
+ "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
+ "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
+ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz",
+ "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.72.0",
+ "@oxlint/binding-android-arm64": "1.72.0",
+ "@oxlint/binding-darwin-arm64": "1.72.0",
+ "@oxlint/binding-darwin-x64": "1.72.0",
+ "@oxlint/binding-freebsd-x64": "1.72.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.72.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.72.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.72.0",
+ "@oxlint/binding-linux-arm64-musl": "1.72.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.72.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.72.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.72.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.72.0",
+ "@oxlint/binding-linux-x64-gnu": "1.72.0",
+ "@oxlint/binding-linux-x64-musl": "1.72.0",
+ "@oxlint/binding-openharmony-arm64": "1.72.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.72.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.72.0",
+ "@oxlint/binding-win32-x64-msvc": "1.72.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=0.22.1",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
+ "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
+ "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
+ "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.138.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.4",
+ "@rolldown/binding-darwin-arm64": "1.1.4",
+ "@rolldown/binding-darwin-x64": "1.1.4",
+ "@rolldown/binding-freebsd-x64": "1.1.4",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.4",
+ "@rolldown/binding-linux-arm64-musl": "1.1.4",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.4",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.4",
+ "@rolldown/binding-linux-x64-gnu": "1.1.4",
+ "@rolldown/binding-linux-x64-musl": "1.1.4",
+ "@rolldown/binding-openharmony-arm64": "1.1.4",
+ "@rolldown/binding-wasm32-wasi": "1.1.4",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.4",
+ "@rolldown/binding-win32-x64-msvc": "1.1.4"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.1.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+ "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.16",
+ "rolldown": "~1.1.3",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..b9275f3
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/frontend/public/icons.svg
@@ -0,0 +1,24 @@
+
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..e39a3b7
--- /dev/null
+++ b/frontend/src/App.css
@@ -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;
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..8d18dce
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -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 (
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+}
+
+export default App;
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..e49a4a4
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -0,0 +1,65 @@
+import type { Recipe, RecipeInput, RecipeSummary } from "../types/recipe";
+
+const BASE = "/api";
+
+async function request(path: string, options?: RequestInit): Promise {
+ 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;
+}
+
+export function listRecipes(search?: string): Promise {
+ const query = search ? `?search=${encodeURIComponent(search)}` : "";
+ return request(`/recipes${query}`);
+}
+
+export function getRecipe(id: number): Promise {
+ return request(`/recipes/${id}`);
+}
+
+export function createRecipe(payload: RecipeInput): Promise {
+ return request(`/recipes`, { method: "POST", body: JSON.stringify(payload) });
+}
+
+export function updateRecipe(id: number, payload: RecipeInput): Promise {
+ return request(`/recipes/${id}`, { method: "PUT", body: JSON.stringify(payload) });
+}
+
+export function deleteRecipe(id: number): Promise {
+ return request(`/recipes/${id}`, { method: "DELETE" });
+}
+
+export function uploadRecipeImage(id: number, file: File): Promise {
+ const form = new FormData();
+ form.append("file", file);
+ return request(`/recipes/${id}/image`, { method: "POST", body: form });
+}
+
+export function deleteRecipeImage(id: number): Promise {
+ 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}`;
+}
diff --git a/frontend/src/components/IngredientRow.tsx b/frontend/src/components/IngredientRow.tsx
new file mode 100644
index 0000000..76df142
--- /dev/null
+++ b/frontend/src/components/IngredientRow.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/components/NutritionFields.tsx b/frontend/src/components/NutritionFields.tsx
new file mode 100644
index 0000000..49faa6a
--- /dev/null
+++ b/frontend/src/components/NutritionFields.tsx
@@ -0,0 +1,30 @@
+import type { RecipeInput } from "../types/recipe";
+
+interface Props {
+ value: RecipeInput;
+ onChange: (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 (
+
+ {FIELDS.map(({ key, label, placeholder }) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/StepRow.tsx b/frontend/src/components/StepRow.tsx
new file mode 100644
index 0000000..4033d1a
--- /dev/null
+++ b/frontend/src/components/StepRow.tsx
@@ -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 (
+
+
+ {index + 1}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TimingFields.tsx b/frontend/src/components/TimingFields.tsx
new file mode 100644
index 0000000..11d87d0
--- /dev/null
+++ b/frontend/src/components/TimingFields.tsx
@@ -0,0 +1,33 @@
+import type { RecipeInput } from "../types/recipe";
+
+interface Props {
+ value: RecipeInput;
+ onChange: (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 (
+
+ {FIELDS.map(({ key, label, placeholder }) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..83e1781
--- /dev/null
+++ b/frontend/src/index.css
@@ -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;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..ade9d64
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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(
+
+
+
+
+ ,
+)
diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx
new file mode 100644
index 0000000..1fa69d0
--- /dev/null
+++ b/frontend/src/pages/RecipeDetailPage.tsx
@@ -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(null);
+ const [error, setError] = useState(null);
+ const [downloading, setDownloading] = useState(false);
+
+ useEffect(() => {
+ if (!id) return;
+ getRecipe(Number(id)).then(setRecipe).catch((e) => setError(e.message));
+ }, [id]);
+
+ if (error) return {error}
;
+ if (!recipe) return Lädt…
;
+
+ 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 (
+
+
+
{recipe.name}
+
+
+ Bearbeiten
+
+
+
+
+ {error &&
{error}
}
+
+ {recipe.image_filename && (
+
})
+ )}
+
+ {recipe.category &&
{recipe.category}
}
+
+ {timingParts.length > 0 && (
+
+ {timingParts.map(({ key, label }) => (
+ {label}: {recipe[key] as string}
+ ))}
+
+ )}
+
+
+
+
Zutaten
+
+ {recipe.ingredients.map((ing) =>
+ ing.kind === "section_header" ? (
+ - {ing.text}
+ ) : (
+ -
+ {[ing.quantity, ing.unit].filter(Boolean).join(" ")}
+ {ing.text}
+
+ ),
+ )}
+
+ {recipe.notes && (
+ <>
+
Notizen
+
{recipe.notes}
+ >
+ )}
+
+
+
+
Zubereitung
+
+ {recipe.steps.map((step) => (
+ - {step.text}
+ ))}
+
+
+
+
+ {(recipe.kcal || recipe.kh || recipe.protein || recipe.fett) && (
+
+ {recipe.kcal && Kalorien: {recipe.kcal}}
+ {recipe.kh && Kohlenhydrate: {recipe.kh}}
+ {recipe.protein && Protein: {recipe.protein}}
+ {recipe.fett && Fett: {recipe.fett}}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/RecipeFormPage.tsx b/frontend/src/pages/RecipeFormPage.tsx
new file mode 100644
index 0000000..9e0ad40
--- /dev/null
+++ b/frontend/src/pages/RecipeFormPage.tsx
@@ -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(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(EMPTY_RECIPE);
+ const [existingImage, setExistingImage] = useState(null);
+ const [imageFile, setImageFile] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(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(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 (
+
+ );
+}
diff --git a/frontend/src/pages/RecipeListPage.tsx b/frontend/src/pages/RecipeListPage.tsx
new file mode 100644
index 0000000..12aab9b
--- /dev/null
+++ b/frontend/src/pages/RecipeListPage.tsx
@@ -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([]);
+ const [search, setSearch] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+
+
Rezepte
+ + Neues Rezept
+
+
+
setSearch(e.target.value)}
+ />
+
+ {error &&
{error}
}
+ {loading &&
Lädt…
}
+
+
+ {recipes.map((recipe) => (
+
+ {recipe.image_filename ? (
+
})
+ ) : (
+
+ )}
+
+ {recipe.name}
+ {recipe.category && {recipe.category}}
+
+
+ ))}
+ {!loading && recipes.length === 0 &&
Keine Rezepte gefunden.
}
+
+
+ );
+}
diff --git a/frontend/src/types/recipe.ts b/frontend/src/types/recipe.ts
new file mode 100644
index 0000000..21462ec
--- /dev/null
+++ b/frontend/src/types/recipe.ts
@@ -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[];
+}
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..6830b6f
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -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"]
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..8455dcb
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -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"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..2cae8de
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -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',
+ },
+ },
+})
diff --git a/reverse-proxy/Caddyfile b/reverse-proxy/Caddyfile
new file mode 100644
index 0000000..5e219eb
--- /dev/null
+++ b/reverse-proxy/Caddyfile
@@ -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
+# }
diff --git a/reverse-proxy/docker-compose.yml b/reverse-proxy/docker-compose.yml
new file mode 100644
index 0000000..6286c5b
--- /dev/null
+++ b/reverse-proxy/docker-compose.yml
@@ -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