Adds initial state of project to repo

This commit is contained in:
David Staffenberger 2026-07-12 17:17:31 +02:00
parent c9438c3991
commit 0457d36fd9
99 changed files with 4089 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
Pictures/MozartKekse.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
Pictures/ico/44086.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

BIN
Pictures/ico/KH.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
Pictures/ico/chef.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
Pictures/ico/cloche.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
Pictures/ico/cooking.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
Pictures/ico/dish.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
Pictures/ico/fett.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

BIN
Pictures/ico/fridge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
Pictures/ico/kcal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

BIN
Pictures/ico/marinade.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
Pictures/ico/oberunter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
Pictures/ico/oven.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
Pictures/ico/plate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
Pictures/ico/pot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
Pictures/ico/protein.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
Pictures/ico/robot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
Pictures/ico/whisk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
Pictures/lebkuchen.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,016 KiB

40
Recipes/ButterChicken.tex Normal file
View file

@ -0,0 +1,40 @@
\recipename{Butter Chicken}
\preptime{60 min} \cooktime{60 min} \marinade{1-24 h} \servings{3}
\\\hrule
\recipe{
\unit[2]{Stk.} & Nelken\\
\unit[1]{Stk.} & Zimtstangen\\
\unit[3]{Stk.} & Kardamomkapseln\\
\unit[1]{TL} & Korianderpulver\\
\unit[1]{TL} & Chilipulver\\
\unit[1]{TL} & Salz\\
\\
\unit[40]{g} & Butter\\
\unit[2]{EL} & Joghurt\\
\unit[300]{g} & Hühnerfilet, gewürfelt\\
\unit[2]{Stk.} & Tomaten, gewürfelt\\
\unit[40]{g} & Tomatenmark\\
\unit[1,5]{TL} & Knoblauch, fein gerieben\\
\unit[1,5]{TL} & Ingwer, fein gerieben\\
\unit[150]{g} & Cremefine
}{
}{
\item
Chili, Salz, 2/3 vom Knoblauch, 2/3 vom Ingwer, Korianderpulver und Joghurt in einer Schüssel verrühren und mit dem Hühnerfleisch vermischen.\\
Mind. 60 min (besser über Nacht) im Kühlschrank marinieren lassen.
\item
1/4 der Butter in einer Pfanne bei mittlerer Stufe schmelzen.\\
Zimtstange, Kardamomkapseln, Nelken, den restlichen Knoblauch und Ingwer mit den Tomaten für 4-5 min unter Rühren anbraten.\\
Tomatenmark hinzufügen und weitere 2 min anbraten.\\
Für 5 min auskühlen lassen und fein pürieren.
\item
Die restliche Butter in der gleichen Pfanne schmelzen, das marinierte Hühnerfleisch dazu geben, unter rühren ca. 10 min anbraten, bis das Fleisch nicht mehr rosa ist.
\item
Die pürierte Tomaten-Mischung dazu geben und mit dem Cremefine für 2-5 min weiterköcheln lassen.
}
\info{\kcal{302} \KH{10,1 g} \protein{26,7 g} \fett{16,6 g}}

View file

@ -0,0 +1,54 @@
\recipename{Eierlikörtorte}
\SetPageImage{eierlikoertorte.jpg}
\preptime{x min} \cooktime{x min} \baketime{x\textdegree C, x min} \cooltime{ x min} \servings{x} \robot{}
\\\hrule
\recipe{
\textbf{Tortenboden}\\
\unit[4]{Stk} & Eigelb\\
\unit[4]{Stk} & Eiweiß\\
\unit[4]{EL} & Wasser, warm\\
\unit[125]{g} & Zucker\\
\unit[75]{g} & Mehl\\
\unit[50]{g} & Speisestärke\\
\unit[1]{Msp} & Backpulver\\
\unit[50]{g} & Butter, zerlassen\\
\unit[1]{Pr} & Salz\\
\unit[1]{Pr} & Zitronenabrieb\\
\textbf{Eierlikör-Sahne}\\
\unit[175]{ml} & Eierlikör\\
\unit[600]{ml} & Schlagobers\\
\unit[6]{Stk} & Eigelb\\
\unit[75]{g} & Zucker\\
\unit[8]{Bl} & Gelatine\\
\unit[100]{g} & Zartbitterkuvertüre, geraspelt\\
\textbf{Eierlikörspiegel}\\
\unit[250]{ml} & Eierlikör\\
\unit[50]{g} & Weiße Kuvertüre\\
\unit[50]{g} & Glukose (1:2 Wasser:Dextrose)\\
\unit[3]{Bl} & Gelatine\\
\unit[8]{g} & dunkle Kuvertüre\\
\textbf{Deko}\\
\unit[nB]{} & Schlagobers, Schokosplitter
}{
test
}{
\item Das Eigelb mit dem warmen Wasser, dem Zitronenabrieb und ca. 1/3 des Zuckers schaumig rühren.\\
Das Eiweiß mit dem restlichen Zucker und der Prise Salz zu einem sehr steifen Eischnee schlagen.\\
Die Eigelbmasse vorsichtig unter den steifen Eischnee mischen und das gesiebte Mehl-Stärke-Backpulvergemisch unterheben.\\
Jetzt die flüssige warme Butter unterheben und die fertige Masse in eine gefettete Springform (26 cm) einfüllen und bei 180°C (Umluft) 20 - 25 Min backen.
\item Eigelb mit Zucker unter ständigem Rühren im heißen Wasserbad aufschlagen.\\
Die eingeweichte Gelatine ausdrücken und in die warme Eiermasse einrühren. Den Eierlikör zugeben. Unter die fast erkaltete Masse die geschlagene Sahne und die geraspelte Kuvertüre unterheben.
\item Den Tortenboden in ca. 1,5 cm dicke Scheiben schneiden und die erste Scheibe mit einem Tortenring umstellen.\\
Die Hälfte der Eierlikörsahne auf den Tortenboden streichen und den nächsten Boden auflegen. Restliche Eierlikörsahne einfüllen und glatt streichen.\\
Torte für mindestens 2 Std. kühlen.
\item Eierlikör, Glukose und weiße Kuvertüre auf ca. 50° C erwärmen. Die eingeweichte Gelatine darin auflösen. Zwei Esslöffel von der Masse mit der aufgelösten dunklen Kuvertüre vermischen.\\
Die Tortenoberfläche mit der vorbereiteten hellen Eierlikör-SpiegelGlasur gleichmäßig mit einem langen Messer bestreichen und mit einer feinen Spritztüte aus dem „dunklen Eierlikör“ eine gleichmäßige Spirale aufspritzen. Mit einem langen Messer von der Mitte aus bei jedem Tortenstück die Spirale nach außen ziehen.\\
4 Stunden kaltstellen.\\
Den Rand der Eierlikörtorte dünn mit Sahne bestreichen und mit Schokoladenspänen einstreuen.
}
\info{\kcal{x} \KH{x g} \protein{x g} \fett{x g}}

View file

@ -0,0 +1,33 @@
\recipename{Erdäpfeltasche mit Frischkäse-Spinatfüllung}
\preptime{30 min} \cooktime{25 min} \cooltime{30 min} \servings{4}
\\
\hrule
\recipe{%
\unit[500]{g} & Erdäpfel, mehlig\\
\unit[200]{g} & Spinat\\
\unit[2]{} & Zwoebeln, rot\\
\unit[3]{} & Knoblauchzehen\\
\unit[4]{EL} & Olivenöl\\
\unit[200]{g} & Frischkäse\\
\unit[1]{TL} & Zitronensaft\\
\unit[200]{g} & Mehl\\
\unit[nB]{l} & Salz\\
\unit[nB]{l} & Pfeffer\\
\unit[1]{Prise} & Muskatnuss
}{%
abc
}{%
\item Schäle die Kartoffeln und schneide sie in grobe Stücke. Koche sie anschließend in ausreichend gesalzenem Wasser, bis sie weich sind.
\item Wasche währenddessen den Spinat und hacke ihn grob.
\item Schäle die Zwiebeln und Knoblauchzehen und schneide beides in feine Würfel.
\item Erhitze 2 EL Öl in einer Pfanne <20><> und schwitze Zwiebeln und Knoblauch darin glasig an. Gib danach den Spinat hinzu, bis er zusammenfällt.
\item Rühre den Frischkäse dazu und schmecke die Füllung mit Salz, Pfeffer, Zitronensaft und Muskatnuss ab.
\item Gieße die gekochten Kartoffeln ab und lass sie gut abtropfen.
\item Zerstampfe sie und verarbeite sie mit Mehl, 1 TL Salz und 2 EL Öl zu einem glatten Teig. Wickel ihn in Frischhaltefolie ein und lass ihn für 30 Minuten im Kühlschrank ruhen.
\item Heize den Ofen auf 180 °C Ober-/Unterhitze vor.
\item Rolle den Kartoffelteig auf einer bemehlten Arbeitsfläche aus.
\item Steche Kreise aus dem Teig aus. Verteile auf jeden Teigkreis etwas Spinat-Frischkäsefüllung und bestreiche die Ränder mit Wasser. Falte die Kreise und presse die Ränder gut zusammen.
\item Platziere die Kartoffeltaschen auf ein mit Backpapier ausgelegtes Backblech, bestreiche sie mit etwas Olivenöl und backe sie für 20-25 Minuten goldbraun.
}

View file

@ -0,0 +1,29 @@
\recipename{Lachs \& Süßkartoffel Bowl\\ mit Honig-Senf-Soße}
%\SetPageImage{eierlikoertorte.jpg}
\preptime{x min} \cooktime{10 min} \baketime{200\textdegree C, 45 min} \cooltime{ 20 min} \servings{6}\\
\\\hrule
\recipe{
\unit[2]{Stk} & Lachsfillet (à 100g)\\
\unit[420]{g} & Süßkartoffeln\\
\unit[150]{g} & Brokkoli\\
\unit{nB} & Salz\&Pfeffer\\
\textbf{Soße}\\
\unit[1,5]{EL} & Senf\\
\unit[1]{EL} & Honig\\
\unit[0,5]{EL} & Zitronensaft\\
\unit[0,5]{EL} & Sojasoße \\
\unit[1]{Zehe} & Knoblauch, gerieben\\
\unit[1]{EL} & Dill\\
\unit[0,5]{EL} & Margerine
}{
}{
\item Lachs trocken tupfen und mit Salz und Pfeffer würzen.
\item Das Gemüse würzen und bei ca. 200 ° C für 20-30 min in Ofen dünsten / braten.
\item Für die Soße, alle Zutaten, bis auf die Margerine, vermischen.
\item Den Lachs mit etwas Öl anbraten, ca. 5 min pro Seite.\\
Die Soße dazu geben, weitere 2 min braten.\\
Vom Herd nehmen und die Margerine unter rühren.
}
\info{\kcal{1.072} \KH{128 g} \protein{50 g} \fett{40 g}}

View file

@ -0,0 +1,21 @@
\recipename{Limetten-Kokos-Makronen}
\SetPageImage{LimettenKokosMakronen.png}
\preptime{120 min} \baketime{130\textdegree C 16-18 min} \cooltime{30 min} \servings{75 Stk.}
\\\hrule
\recipe{%
\unit[3]{} & Limetten\\
\unit[150]{g} & Staubzucker\\
\unit[3]{} & Eiweiß, Gr. L\\
\unit[1]{Pr.} & Salz\\
\unit[180]{g} & Kokosflocken\\
\unit[140]{g} & Kuvertüre, weiß\\
\unit[15]{g} & Kokosfett
}{%
}{%
\item Alle Limetten heiß waschen und die Schale fein abreiben. 1 Limette halbieren, den Saft auspressen. Puderzucker sieben. 2/3 Limettenschale auf einen Teller geben und trocknen lassen (evtl. auch in der Nachwärme des Backofens!)
\item Eiweiß steif schlagen. Salz und Puderzucker dabei einrieseln lassen. So lange schlagen, bis die Masse dickcremig ist. Zum Schluss das übrige Drittel der abgeriebenen Limettenschale und 3 TL Limettensaft unterschlagen. Kokosraspel unterheben.
\item Masse in einen Spritzbeutel mit großer Lochtülle (ca. 1 cm Ø) füllen und ca. 75 Tupfen (à ca. 3 cm Ø) auf 3 mit Backpapier ausgelegte Backbleche spritzen. Blechweise im vorgeheizten Backofen auf der 2 Schiene von unten (E-Herd: 150 °C / Umluft: 130 °C) 1618 Minuten backen. Fertige Makronen auskühlen lassen.
\item Kuvertüre und Kokosfett hacken. Über einem warmen Wasserbad unter Rühren schmelzen. 1/3 der getrockneten Limettenschale unter die geschmolzene Schokolade rühren. Makronen zur Hälfte in die Kuvertüre tauchen. Abtropfen lassen, auf Backpapier legen und mit übriger Limettenschale bestreuen. Trocknen lassen.
}

27
Recipes/MozartKekse.tex Normal file
View file

@ -0,0 +1,27 @@
\recipename{Mozart-Kekse}
\SetPageImage{MozartKekse.png}
\preptime{75 min} \baketime{180\textdegree C, 12-15 min} \cooltime{90 min} \servings{64 Stk.}
\\
\hrule
\recipe{%
\unit[225]{g} & Mehl\\
\unit[75]{g} & Zucker\\
\unit[1]{} & Eigelb, Gr.M\\
\unit[150]{g} & Butter\\
\unit[1]{EL} & Marillengeist\\
\unit[150]{g} & Marzipan-Rohmasse\\
\unit[50+50]{g} & Pistazien\\
\unit[2]{EL} & Staubzucker\\
\unit[150]{g} & Zartbitterschokolade\\
\unit[10]{g} & Kokosfett
}{%
}{%
\item Für den Teig Mehl, Zucker, Eigelb, Butter in Stückchen und Marillengeist zum glatten Teig verkneten. In Folie wickeln und ca. 1 Stunde kalt stellen.
\item Für die Füllung Marzipan raspeln. 50 g Pistazien hacken. Beides mit Puderzucker verkneten. Masse zu vier Rollen (ca. 25 cm lang) formen.
\item Teig halbieren, je auf wenig Mehl rechteckig (ca. 12x25 cm) ausrollen. Rechtecke längs halbieren (ca. 6x25 cm). Jede Marzipanrolle auf ein Teigrechteck legen, darin einrollen. Rollen ca. 30 Minuten kalt stellen.
\item Rollen auf ein mit Backpapier ausgelegtes Backblech legen. Im vorgeheizten Ofen (E-Herd: 200°C/Umluft: 180°C) 1215 Minuten backen. Herausnehmen, kurz abkühlen lassen, dann noch warm in ca. 16 Scheiben schneiden. Auskühlen lassen.
\item Schokolade in Stücke teilen und mit Kokosfett schmelzen. Plätzchen zur Hälfte hineintauchen, auf ein Gitter legen und mit je einem Pistazienkern belegen. Trocknen lassen.
}

19
Recipes/Naan.tex Normal file
View file

@ -0,0 +1,19 @@
\recipename{Naan}
\preptime{x min} \cooktime{x min} \robot{} \servings{6}
\\\hrule
\recipe{
\unit[250]{g} & Weizenmehl\\
\unit[1/4]{Würfel} & Germ\\
\unit[1/2]{TL} & Backpulver\\
\unit[1/4]{TL} & Zucker\\
\unit[1/2]{TL} & Salz\\
\unit[100]{g} & Naturjoghurt\\
\unit[100]{g} & Milch
}{
}{
\item step 1
\item step 2
}
\info{\kcal{166} \KH{29,8 g} \protein{6,5 g} \fett{2 g}}

View file

@ -0,0 +1,23 @@
\recipename{Pistazien-Traumstücke}
\SetPageImage{PistazienTraumstuecke.png}
\preptime{40 min} \baketime{180\textdegree C 8-12 min} \cooltime{60 min} \servings{45 Stk.}
\\\hrule
\recipe{%
\unit[80]{g} & Pistazien, gemahlen\\
\unit[1/2]{} & Zitronenschale\\
\unit[2]{} & Eier, Gr. M\\
\unit[125]{g} & Butter\\
\unit[230]{g} & Mehl\\
\unit[80]{g} & Zucker\\
\unit[1]{Pck.} & Vanillezucker\\
\unit[80]{g} & Puderzucker\\
}{%
}{%
\item Eier trennen. Eiweiß anderweitig verwenden. In einer Schüssel das Eigelb mit gewürfelter Butter, Mehl, 50 g gemahlene Pistazien, 1 TL Zitronenabrieb, Zucker und Vanillezucker zunächst mit den Knethaken des Handmixers verrühren, danach mit den Händen zu einem geschmeidigen Teig verkneten.
\item Teig in 3 gleichgroße Stücke teilen und zu Kugeln formen, in Frischhaltefolie wickeln und für 1 Stunde kalt stellen.
\item Teigkugeln jeweils zu 1 cm dicken Würsten formen, ganz leicht platt drücken und mit dem Messer in 2-3 cm breite Stücke teilen. Teigstücke auf ein mit Backpapier ausgelegtes Backblech legen.
\item Im vorgeheizten Backofen bei 180 °C (Ober-/Unterhitze) für 8-12 Minuten hell backen.
\item Puderzucker mit restlichen gemahlenen Pistazien vermischen. Traumstücke damit großzügig bestäuben.
}

16
Recipes/Sample.tex Normal file
View file

@ -0,0 +1,16 @@
\recipename{Sample Recipe}
\preptime{x min} \cooktime{x min} \baketime{x\textdegree C, x min} \cooltime{ x min} \servings{x} \robot{}
\\
\hrule
\recipe{%
\unit[1]{kg} & ingredient\\
\unit[1]{l} & ingredient
}{%
abc
}{%
\item step
\item step
}
\info{\kcal{x} \KH{x g} \protein{x g} \fett{x g}}

View file

@ -0,0 +1,29 @@
\recipename{Sauerteig Toastbrot}
\SetPageImage{sauerteigToastbrot.png}
\preptime{15 min} \baketime{250-210\textdegree C, 50-60 min} \cooltime{12-18 Std. / 1 Std. / 3-4 Std.} \robot{30 min}
\\\hrule
\recipe{
\textbf{Vorteig}\\
\unit[200]{g} & Wasser, kalt\\
\unit[400]{g} & Weizenmehl Type 550\\
\unit[20-40]{g} & Kristallzucker\\
\unit[1]{} & Ei\\
\unit[100]{g} & Sauerteig\\
\textbf{Hauptteig}\\
\unit[1]{} & Vorteig\\
\unit[40]{g} & Butter\\
\unit[12]{g} & Salz\\
\unit[250]{g} & Wizenmehl Type 550\\
\unit[120]{g} & Wasser
}{
Weizenmehl Type 550 = W700 griffig
}{
\item \textbf{Vorteig}: Alle Zutaten in der Küchenmaschine zu einem Teig verkneten. Danach den Vorteig zu einer Kugeln formen, in eine Schüssel legen und zugedeckt 12-18 Std. bei Raumtemperatur gären lassen. In dieser zeit sollte sich das Volumen verdoppelt besser noch verdreifacht haben.
\item \textbf{Hauptteig}: Empfehlung: 10-20\% vom Wasser zurückbehalten und nur nach und nach bei Bedarf dazugeben. Alle Zutaten in der Küchenmaschine auf kleiner Stufe 2-3 Min. ankneten. Danach die Knetgeschwindigkeit erhöhen und den Teig total 10-12 Min. kneten. Danach den Teig in ein Becken legen, zudecken und für 1 Std. bei Raumtemperatur ruhen lassen.\\
\item \textbf{Formen}: Nach der Gärzeit den Teig auf eine bemehlte Arbeitsfläche geben und in vier gleich grosse Stücke portionieren. Jedes Teigstück zu einer gleichmässigen Kugel formen.
Eine Toastbrotform gut einfetten und den Deckel nicht vergessen. Anschliessend die Teiglinge in die Toastbrotform legen und gut runter drücken, so dass der Teig auch in die Ecken gedrückt wird. Die Form ist etwa zu 1/3 gefüllt. Die Teiglinge 3-4 Std. bei Raumtemperatur stehen lassen. Den Backofen auf 250 Grad Ober- und Unterhitze vorheizen.
\item \textbf{Backen}: Nach der Stückgare die Form schliessen (je nach Form den Deckel fixieren) und in die Mitte des vorgeheizten Backofens schieben. Die Backtemperatur auf 210 Grad reduzieren und das Toastbrot für etwa 50-60 Min. backen. Nach dem Backen das Toastbrot ausformen und auf einem Gitter auskühlen lassen. Tipp: Sollten die Seiten noch etwas hell sein, kannst du das Toastbrot ohne form nochmals 5-10 Min. backen.
}
\info{\kcal{x} \KH{x g} \protein{x g} \fett{x g}}

27
Recipes/lebkuchen.tex Normal file
View file

@ -0,0 +1,27 @@
\recipename{Lebkuchen}
\SetPageImage{lebkuchen.jpg}
\preptime{60 min} \baketime{180\textdegree C 10-12 min} \cooltime{8-24 h}
\\\hrule
\recipe{%
\unit[500]{g} & Roggenmehl\\
\unit[100]{g} & Zucker\\
\unit[2]{EL} & Lebkuchengewürz\\
\unit[1]{TL} & Natron\\
\unit[50]{g} & Butter\\
\unit[200-250]{g} & Honig\\
\unit[2]{} & Eier\\
\unit[2]{EL} & Wasser\\
\unit[1]{} & Ei zum Bestreichen
}{%
ca. 5cm dick ausrollen
}{%
\item Für das Lebkuchen Rezept die trockenen Zutaten (Mehl, Zucker, Natron, Gewürze) vermischen und mit der Butter abbröseln.
\item Honig und Eier mit etwas Wasser glattrühren.\\
(Ist der Honig zu zäh, kann man ihn leicht erwärmen.)
\item Ein Grübchen im Mehl machen und das Honiggemisch langsam mit dem Mehl vermengen, dass ein mittelfester Teig entsteht. Nach Bedarf etwas Wasser oder Mehl hinzufügen.\\
(Ist der Lebkuchenteig zu fest, lässt er sich gar nicht Austreiben, ist er dagegen zu weich, entstehen Sprünge beim Austreiben.)
\item Den Lebkuchenteig über Nacht an einem kühlen Ort rasten lassen.
\item Am nächsten Tag den Teig mit einem Nudelholz auf der bemehlten Arbeitsfläche nicht zu dünn ausrollen und mit Formen ausstechen.
\item Den Lebkuchen mit verquirltem Ei bestreichen und im Backrohr bei ca. 180° Ober- und Unterhitze je nach Größe etwa 10 bis 12 Minuten hell backen.
}

45
Rezeptbuch.tex Normal file
View file

@ -0,0 +1,45 @@
\documentclass[twosides, 11pt]{book}
\usepackage[utf8]{inputenc} % Required for inputting international characters
\usepackage[T1]{fontenc} % Output font encoding for international characters
\usepackage[ngerman]{babel}
\usepackage{cuisine}
%\overfullrule=5pt % zur Überprüfung von Overfull \hbox
%\input{CoverPage.tex}
%\makeindex
\begin{document}
%\input{Recipes/Sample}
%\maketitle
%\tableofcontents
%\chapter{Suppen}
%input{Recipes/Sample}
%\AddToShipoutPictureBG{\includegraphics[width=\paperwidth,height=\paperheight]{carbo}}
%\chapter{Vorspeisen}
\chapter{Hauptspeisen}
\input{Recipes/Sample}
\input{Recipes/ButterChicken}
\input{Recipes/Lachs&Süßkartoffel}
\input{Recipes/Erdapfeltasche}
\chapter{Beilagen}
\input{Recipes/Naan}
\input{Recipes/Sauerteig_Toastbrot}
\chapter{Nachspeisen}
\input{Recipes/Eierlikörtorte}
\input{Recipes/lebkuchen}
\input{Recipes/PistazienTraumstuecke}
\input{Recipes/MozartKekse}
\input{Recipes/LimettenKokosMakronen}
%\chapter{Nachspeisen}
\clearpage
%\printindex
\end{document}

3
backend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
.venv
__pycache__
*.pyc

27
backend/Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-latex-recommended \
texlive-fonts-recommended \
texlive-xetex \
texlive-latex-extra \
texlive-fonts-extra \
texlive-plain-generic \
texlive-pictures \
fonts-comic-neue \
fontconfig \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
ENV DATA_DIR=/data
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View file

27
backend/app/database.py Normal file
View file

@ -0,0 +1,27 @@
import os
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR = DATA_DIR / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
DATABASE_URL = f"sqlite:///{DATA_DIR / 'recipes.db'}"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

19
backend/app/main.py Normal file
View file

@ -0,0 +1,19 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.database import UPLOAD_DIR, Base, engine
from app.routers import pdf, recipes
Base.metadata.create_all(bind=engine)
app = FastAPI(title="RecipeInator")
app.include_router(recipes.router)
app.include_router(pdf.router)
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
@app.get("/api/health")
def health():
return {"status": "ok"}

101
backend/app/models.py Normal file
View file

@ -0,0 +1,101 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Recipe(Base):
__tablename__ = "recipes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
image_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
preptime: Mapped[str | None] = mapped_column(String(100), nullable=True)
cooktime: Mapped[str | None] = mapped_column(String(100), nullable=True)
baketime: Mapped[str | None] = mapped_column(String(100), nullable=True)
cooltime: Mapped[str | None] = mapped_column(String(100), nullable=True)
marinade_time: Mapped[str | None] = mapped_column(String(100), nullable=True)
robot_time: Mapped[str | None] = mapped_column(String(100), nullable=True)
servings: Mapped[str | None] = mapped_column(String(50), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
kcal: Mapped[str | None] = mapped_column(String(50), nullable=True)
kh: Mapped[str | None] = mapped_column(String(50), nullable=True)
protein: Mapped[str | None] = mapped_column(String(50), nullable=True)
fett: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
ingredients: Mapped[list["Ingredient"]] = relationship(
back_populates="recipe", cascade="all, delete-orphan", order_by="Ingredient.position"
)
steps: Mapped[list["Step"]] = relationship(
back_populates="recipe", cascade="all, delete-orphan", order_by="Step.position"
)
book_links: Mapped[list["BookRecipe"]] = relationship(
back_populates="recipe", cascade="all, delete-orphan"
)
class Ingredient(Base):
__tablename__ = "ingredients"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
kind: Mapped[str] = mapped_column(String(20), nullable=False, default="item") # item | section_header
quantity: Mapped[str | None] = mapped_column(String(50), nullable=True)
unit: Mapped[str | None] = mapped_column(String(50), nullable=True)
text: Mapped[str] = mapped_column(String(255), nullable=False)
recipe: Mapped["Recipe"] = relationship(back_populates="ingredients")
class Step(Base):
__tablename__ = "steps"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
recipe: Mapped["Recipe"] = relationship(back_populates="steps")
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
recipe_links: Mapped[list["BookRecipe"]] = relationship(
back_populates="book", cascade="all, delete-orphan"
)
class BookRecipe(Base):
__tablename__ = "book_recipes"
__table_args__ = (UniqueConstraint("book_id", "recipe_id", name="uq_book_recipe"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
book_id: Mapped[int] = mapped_column(ForeignKey("books.id"), nullable=False)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id"), nullable=False)
chapter: Mapped[str | None] = mapped_column(String(100), nullable=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
book: Mapped["Book"] = relationship(back_populates="recipe_links")
recipe: Mapped["Recipe"] = relationship(back_populates="book_links")

View file

View file

@ -0,0 +1,204 @@
%% -------------------------------------------------------------- %%
%% -------------------- cuisine.sty package --------------------- %%
%% a package written by Baptiste Le Roi %%
%% %%
%% Package / style modified from livrerecettes.sty of Alexis Flesh %%
%% %%
%% modified from cookybooky.dtx package from J. Gilg %%
%% %%
%% ------- Copyright (C) 2009,2021 by Gilg, Flesj, Le Roi ------- %%
%% gilg@acrotex.net, alexis.flesch@gmail.com, leroi.bwh@gmail.com %%
%% %%
%% This work may be distributed and/or modified under the %%
%% conditions of the LaTeX Project Public License (LPPL), %%
%% either version 1.3 of this license or (at your option) %%
%% any later version. %%
%% The latest version of this license is in %%
%% %%
%% http://www.latex-project.org/lppl.txt %%
%% %%
%% and version 1.3 or later is part of all distributions of %%
%% LaTeX version 2005/12/01 or later. %%
%% %%
%% -------------------------------------------------------------- %%
\NeedsTeXFormat{LaTeX2e}
\ProvidesPackage{cuisine}
\RequirePackage{xkeyval}
\RequirePackage{marvosym}
%\RequirePackage{showframe}
% Geometry of the book
\RequirePackage[paperheight=21cm,paperwidth=14.8cm,margin=1cm,heightrounded]{geometry}%showframe
%glossary of the recipes
\RequirePackage{imakeidx}
% Links in the book
\RequirePackage[hidelinks,colorlinks=false]{hyperref}
% Colors
\RequirePackage{xcolor} % Required for specifying colors by name
\definecolor{ocre}{RGB}{164,16,52} %{243,102,25} % Define the red color used for highlighting throughout the book
% Headers
\RequirePackage{fancyhdr}
\pagestyle{fancy}
\renewcommand{\chaptermark}[1]{\markboth{\normalsize\bfseries\ #1}{}} % Chapter text font settings
\fancyhf{} \renewcommand{\headrulewidth}{0pt}
%\fancyfoot[LE]{\bfseries\thepage} % Font setting for the page number in the foot
%\fancyfoot[RO]{\huge\bfseries\textcolor{white}{ \rightmark}} %% Font setting for the page number in the foot
% remove the page number on chapter pages
\makeatletter
\let\ps@plain\ps@empty
\makeatother
% Pictures and graphic content
\RequirePackage{graphicx} % Required for including pictures
\graphicspath{{Pictures/}} % Specifies the directory where pictures are stored
\RequirePackage{wrapfig} % To wrap the tabular with the ingredients
\RequirePackage{floatrow}
\RequirePackage{tikz} % Required for drawing custom shapes
\RequirePackage{eso-pic} % for background pictures
\RequirePackage{transparent} % for transparency
% Fonts
\RequirePackage{lettrine}
\RequirePackage{polyglossia}
\setdefaultlanguage{english}
\usepackage{fontspec}
\setmainfont{Comic Neue}
%\renewcommand{\familydefault}{\sfdefault} %Schriftart Sans-Serif
%\RequirePackage[defaultfam,tabular,lining,alternates]{montserrat} %% Option 'defaultfam'
%% only if the base font of the document is to be sans serif
\renewcommand*\oldstylenums[1]{\oldstylenums{#1}} %wenn Montserrat nicht verwendet
%\renewcommand*\oldstylenums[1]{{\fontfamily{Montserrat-TOsF}\selectfont #1}}
% the name of the TOC
\addto\captionsfrench{% Replace "english" with the language you use
\renewcommand{\contentsname}%
{Inhalte}%
}
% lists
\RequirePackage{enumitem}
\usepackage{paracol}%f. Seitenumbrüche in minipage
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% Elements of the recipe %
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%Counter of recipe
%\newcounter{numrecette}\setcounter{numrecette}{0}
\RequirePackage[compact]{titlesec}
\titleformat{\section}
{\color{ocre}\normalfont\Huge\bfseries}
{\color{ocre}\thesection}{1em}{}
\renewcommand{\thesection}{}
\titleformat{\chapter}[display]
{\normalfont\bfseries}{}{0pt}{\Huge}
% Name of the recipe
\newcommand*{\recipename}[1]
{
\newpage
\section{#1}
\index{#1}}
%\addcontentsline{toc}{section}{#1}}
%% \stepcounter{numrecette}
% \textcolor{ocre}{{\Huge \textbf{#1\hfill}}}
% \vspace{1em}
% Icons and text
\newcommand{\preptime}[1]{\includegraphics[height=15pt]{Pictures/ico/chef.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\cooktime}[1]{\includegraphics[height=15pt]{Pictures/ico/pot.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\baketime}[1]{\includegraphics[height=15pt]{Pictures/ico/oven.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\servings}[1]{\includegraphics[height=15pt]{Pictures/ico/plate.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\cooltime}[1]{\includegraphics[height=15pt]{Pictures/ico/fridge.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\robot}[1]{\includegraphics[height=15pt]{Pictures/ico/robot.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\marinade}[1]{\includegraphics[height=15pt]{Pictures/ico/marinade.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\kcal}[1]{\includegraphics[height=15pt]{Pictures/ico/kcal.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\KH}[1]{\includegraphics[height=15pt]{Pictures/ico/KH.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\protein}[1]{\includegraphics[height=15pt]{Pictures/ico/protein.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\fett}[1]{\includegraphics[height=15pt]{Pictures/ico/fett.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
\newcommand{\oberunter}[1]{\includegraphics[height=15pt]{Pictures/ico/oberunter.png}{\raisebox{3pt}{ #1\hspace{1em}}}}
% icon from https://www.flaticon.com/fr/auteurs/those-icons
% Ingredients
\RequirePackage{wrapfig} % To wrap the tabular with the ingredients
\RequirePackage{units}
\RequirePackage{tabulary} % line breaks in tabular
\usepackage{tabularx}
% Neue Spaltenart: T = minimal flexible Breite
\newcolumntype{T}{>{\raggedleft\arraybackslash}p{1.8cm}}
\setlength{\columnsep}{2em} % Abstand zwischen Spalten
\columnratio{0.65} % linke Spalte 60%, rechte 40%
\newcommand{\recipe}[3]{
\vspace{2em}
\begin{paracol}{2}
\flushcolumns % <-- sorgt für gleiche Startpositionen
%Zutaten
\begin{leftcolumn}
\textbf{Zutaten}\\[1em]
\begin{tabularx}{\linewidth}{@{}T X@{}}
#1
\end{tabularx}
\end{leftcolumn}
%Notizen
\begin{rightcolumn}
\textbf{Notizen}\\[1em]
#2
\end{rightcolumn}
\end{paracol}
\vspace{3em}
%Zubereitung
\textbf{Zubereitung}
\begin{enumerate}[label=\color{ocre}\theenumi, font=\bfseries]
#3
\end{enumerate}
}
% The photo of the recipe
\newcommand{\photo}[1]{
\newpage
\vfill{ \transparent{0} :)}
\AddToShipoutPictureBG*{\includegraphics[width=\paperwidth,height=\paperheight]{#1}}
}
\newcommand*{\info}[1]
{\vfill
\begin{tikzpicture}[overlay]
\draw[line width=1.5pt,color=ocre] (-.2,0) -- (3,0);
\draw[line width=1.5pt,color=ocre] (0,.2) -- (0,-1);
%\draw[color=red] (0.2,-.5) node[right] {\Large Astuce :};
\end{tikzpicture}
\vspace{1em}
\hspace{1em}\begin{minipage}{.915\textwidth}%
\itshape#1
\end{minipage}
}
% Hilfsbefehl zum Setzen eines Bildes auf einer Seite
\newcommand{\SetPageImage}[1]{%
\begin{tikzpicture}[remember picture,overlay]
\node[anchor=north east, xshift=-0.2cm, yshift=-0.2cm]
at (current page.north east) {\includegraphics[height=1.75cm]{#1}};
\end{tikzpicture}%
}
\endinput

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

41
backend/app/pdf/escape.py Normal file
View file

@ -0,0 +1,41 @@
import re
_SPECIAL_CHARS = {
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\textasciicircum{}",
"\\": r"\textbackslash{}",
}
_SPECIAL_CHARS_RE = re.compile("|".join(re.escape(c) for c in _SPECIAL_CHARS))
def escape_latex(value: str) -> str:
"""Escape LaTeX special characters in plain user-entered text."""
return _SPECIAL_CHARS_RE.sub(lambda m: _SPECIAL_CHARS[m.group(0)], value)
_STEP_BOLD_RE = re.compile(r"^\*\*(.+?)\*\*(.*)$")
def format_multiline(value: str) -> str:
"""Escape text and convert user newlines into LaTeX line breaks.
Supports a leading "**Label**: rest" line convention (seen in the existing
hand-written recipes) which becomes \\textbf{Label}: rest.
"""
lines = value.strip().splitlines()
rendered_lines = []
for line in lines:
match = _STEP_BOLD_RE.match(line)
if match:
label, rest = match.groups()
rendered_lines.append(f"\\textbf{{{escape_latex(label)}}}{escape_latex(rest)}")
else:
rendered_lines.append(escape_latex(line))
return " \\\\\n ".join(rendered_lines)

126
backend/app/pdf/render.py Normal file
View file

@ -0,0 +1,126 @@
import shutil
import subprocess
import tempfile
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from app.models import Recipe
from app.pdf.escape import escape_latex, format_multiline
ASSETS_DIR = Path(__file__).parent / "assets"
TEMPLATES_DIR = Path(__file__).parent / "templates"
_env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
block_start_string="((*",
block_end_string="*))",
variable_start_string="(((",
variable_end_string=")))",
comment_start_string="((=",
comment_end_string="=))",
trim_blocks=True,
lstrip_blocks=True,
)
_TIMING_MACROS = [
("preptime", "preptime"),
("cooktime", "cooktime"),
("baketime", "baketime"),
("marinade_time", "marinade"),
("cooltime", "cooltime"),
("robot_time", "robot"),
("servings", "servings"),
]
class PdfRenderError(RuntimeError):
def __init__(self, message: str, log_tail: str):
super().__init__(message)
self.log_tail = log_tail
def _build_timing_line(recipe: Recipe) -> str:
parts = []
for field, macro in _TIMING_MACROS:
value = getattr(recipe, field)
if value:
parts.append(f"\\{macro}{{{escape_latex(value)}}}")
return " ".join(parts)
def _build_ingredient_lines(recipe: Recipe) -> list[str]:
lines = []
for ing in recipe.ingredients:
if ing.kind == "section_header":
lines.append(f"\\textbf{{{escape_latex(ing.text)}}}\\\\")
continue
quantity = escape_latex(ing.quantity) if ing.quantity else ""
unit = escape_latex(ing.unit) if ing.unit else ""
text = escape_latex(ing.text)
lines.append(f"\\unit[{quantity}]{{{unit}}} & {text}\\\\")
return lines
def _build_info_line(recipe: Recipe) -> str | None:
parts = []
if recipe.kcal:
parts.append(f"\\kcal{{{escape_latex(recipe.kcal)}}}")
if recipe.kh:
parts.append(f"\\KH{{{escape_latex(recipe.kh)}}}")
if recipe.protein:
parts.append(f"\\protein{{{escape_latex(recipe.protein)}}}")
if recipe.fett:
parts.append(f"\\fett{{{escape_latex(recipe.fett)}}}")
return " ".join(parts) if parts else None
def render_recipe_tex(recipe: Recipe) -> str:
template = _env.get_template("recipe.tex.jinja")
context = {
"name": escape_latex(recipe.name),
"image_filename": recipe.image_filename,
"timing_line": _build_timing_line(recipe),
"ingredient_lines": _build_ingredient_lines(recipe),
"notes": format_multiline(recipe.notes) if recipe.notes else "",
"step_items": [format_multiline(step.text) for step in recipe.steps],
"info_line": _build_info_line(recipe),
}
return template.render(**context)
def compile_recipe_pdf(recipe: Recipe, upload_dir: Path) -> bytes:
tex_source = render_recipe_tex(recipe)
with tempfile.TemporaryDirectory(prefix="recipe-pdf-") as tmp:
workdir = Path(tmp)
(workdir / "Pictures" / "ico").mkdir(parents=True)
shutil.copy(ASSETS_DIR / "cuisine.sty", workdir / "cuisine.sty")
for icon in (ASSETS_DIR / "icons").glob("*.png"):
shutil.copy(icon, workdir / "Pictures" / "ico" / icon.name)
if recipe.image_filename:
src = upload_dir / recipe.image_filename
if src.exists():
shutil.copy(src, workdir / "Pictures" / recipe.image_filename)
tex_path = workdir / "recipe.tex"
tex_path.write_text(tex_source, encoding="utf-8")
for _ in range(2):
subprocess.run(
["xelatex", "-interaction=nonstopmode", "-halt-on-error", tex_path.name],
cwd=workdir,
capture_output=True,
text=True,
timeout=60,
)
pdf_path = workdir / "recipe.pdf"
if not pdf_path.exists():
log_path = workdir / "recipe.log"
log_tail = ""
if log_path.exists():
log_tail = "\n".join(log_path.read_text(errors="ignore").splitlines()[-40:])
raise PdfRenderError("PDF compilation failed", log_tail)
return pdf_path.read_bytes()

View file

@ -0,0 +1,29 @@
\documentclass[11pt]{book}
\usepackage{cuisine}
\begin{document}
\pagenumbering{gobble}
\recipename{(((name)))}
((* if image_filename *))
\SetPageImage{(((image_filename)))}
((* endif *))
(((timing_line)))
\\
\hrule
\recipe{
((* for line in ingredient_lines *))
(((line)))
((* endfor *))
}{
(((notes)))
}{
((* for item in step_items *))
\item (((item)))
((* endfor *))
}
((* if info_line *))
\info{(((info_line)))}
((* endif *))
\end{document}

View file

View file

@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from slugify import slugify
from sqlalchemy.orm import Session
from app.database import UPLOAD_DIR, get_db
from app.models import Recipe
from app.pdf.render import PdfRenderError, compile_recipe_pdf
router = APIRouter(prefix="/api/recipes", tags=["pdf"])
@router.get("/{recipe_id}/pdf")
def download_recipe_pdf(recipe_id: int, db: Session = Depends(get_db)):
recipe = db.get(Recipe, recipe_id)
if recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found")
try:
pdf_bytes = compile_recipe_pdf(recipe, UPLOAD_DIR)
except PdfRenderError as exc:
raise HTTPException(
status_code=500,
detail=f"{exc}: {exc.log_tail}",
) from exc
filename = f"{slugify(recipe.name) or 'recipe'}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View file

@ -0,0 +1,165 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from slugify import slugify
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.database import UPLOAD_DIR, get_db
from app.models import Ingredient, Recipe, Step
from app.schemas import RecipeIn, RecipeOut, RecipeSummary
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
def _unique_slug(db: Session, name: str, *, exclude_id: int | None = None) -> str:
base = slugify(name) or "recipe"
slug = base
suffix = 2
while True:
stmt = select(Recipe.id).where(Recipe.slug == slug)
if exclude_id is not None:
stmt = stmt.where(Recipe.id != exclude_id)
if db.scalar(stmt) is None:
return slug
slug = f"{base}-{suffix}"
suffix += 1
def _apply_children(db: Session, recipe: Recipe, payload: RecipeIn) -> None:
recipe.ingredients.clear()
for position, ing in enumerate(payload.ingredients):
recipe.ingredients.append(
Ingredient(
position=position,
kind=ing.kind,
quantity=ing.quantity,
unit=ing.unit,
text=ing.text,
)
)
recipe.steps.clear()
for position, step in enumerate(payload.steps):
recipe.steps.append(Step(position=position, text=step.text))
@router.get("", response_model=list[RecipeSummary])
def list_recipes(search: str | None = None, db: Session = Depends(get_db)):
stmt = select(Recipe).order_by(Recipe.name)
if search:
like = f"%{search.lower()}%"
stmt = stmt.where(
Recipe.name.ilike(like) | Recipe.category.ilike(like)
)
return db.scalars(stmt).all()
@router.get("/{recipe_id}", response_model=RecipeOut)
def get_recipe(recipe_id: int, db: Session = Depends(get_db)):
recipe = db.get(Recipe, recipe_id)
if recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found")
return recipe
@router.post("", response_model=RecipeOut, status_code=201)
def create_recipe(payload: RecipeIn, db: Session = Depends(get_db)):
recipe = Recipe(
name=payload.name,
slug=_unique_slug(db, payload.name),
category=payload.category,
preptime=payload.preptime,
cooktime=payload.cooktime,
baketime=payload.baketime,
cooltime=payload.cooltime,
marinade_time=payload.marinade_time,
robot_time=payload.robot_time,
servings=payload.servings,
notes=payload.notes,
kcal=payload.kcal,
kh=payload.kh,
protein=payload.protein,
fett=payload.fett,
)
_apply_children(db, recipe, payload)
db.add(recipe)
db.commit()
db.refresh(recipe)
return recipe
@router.put("/{recipe_id}", response_model=RecipeOut)
def update_recipe(recipe_id: int, payload: RecipeIn, db: Session = Depends(get_db)):
recipe = db.get(Recipe, recipe_id)
if recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found")
if payload.name != recipe.name:
recipe.slug = _unique_slug(db, payload.name, exclude_id=recipe_id)
recipe.name = payload.name
recipe.category = payload.category
recipe.preptime = payload.preptime
recipe.cooktime = payload.cooktime
recipe.baketime = payload.baketime
recipe.cooltime = payload.cooltime
recipe.marinade_time = payload.marinade_time
recipe.robot_time = payload.robot_time
recipe.servings = payload.servings
recipe.notes = payload.notes
recipe.kcal = payload.kcal
recipe.kh = payload.kh
recipe.protein = payload.protein
recipe.fett = payload.fett
_apply_children(db, recipe, payload)
db.commit()
db.refresh(recipe)
return recipe
@router.delete("/{recipe_id}", status_code=204)
def delete_recipe(recipe_id: int, db: Session = Depends(get_db)):
recipe = db.get(Recipe, recipe_id)
if recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found")
if recipe.image_filename:
(UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True)
db.delete(recipe)
db.commit()
@router.post("/{recipe_id}/image", response_model=RecipeOut)
async def upload_recipe_image(recipe_id: int, file: UploadFile, db: Session = Depends(get_db)):
recipe = db.get(Recipe, recipe_id)
if recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found")
ext = ALLOWED_IMAGE_TYPES.get(file.content_type or "")
if ext is None:
raise HTTPException(status_code=400, detail="Only JPEG, PNG or WEBP images are allowed")
if recipe.image_filename:
(UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
dest = UPLOAD_DIR / filename
dest.write_bytes(await file.read())
recipe.image_filename = filename
db.commit()
db.refresh(recipe)
return recipe
@router.delete("/{recipe_id}/image", response_model=RecipeOut)
def delete_recipe_image(recipe_id: int, db: Session = Depends(get_db)):
recipe = db.get(Recipe, recipe_id)
if recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found")
if recipe.image_filename:
(UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True)
recipe.image_filename = None
db.commit()
db.refresh(recipe)
return recipe

80
backend/app/schemas.py Normal file
View file

@ -0,0 +1,80 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
IngredientKind = Literal["item", "section_header"]
class IngredientIn(BaseModel):
kind: IngredientKind = "item"
quantity: str | None = None
unit: str | None = None
text: str
class IngredientOut(IngredientIn):
model_config = ConfigDict(from_attributes=True)
id: int
class StepIn(BaseModel):
text: str
class StepOut(StepIn):
model_config = ConfigDict(from_attributes=True)
id: int
class RecipeIn(BaseModel):
name: str = Field(min_length=1)
category: str | None = None
preptime: str | None = None
cooktime: str | None = None
baketime: str | None = None
cooltime: str | None = None
marinade_time: str | None = None
robot_time: str | None = None
servings: str | None = None
notes: str | None = None
kcal: str | None = None
kh: str | None = None
protein: str | None = None
fett: str | None = None
ingredients: list[IngredientIn] = Field(default_factory=list)
steps: list[StepIn] = Field(default_factory=list)
class RecipeSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
category: str | None
image_filename: str | None
class RecipeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
category: str | None
image_filename: str | None
preptime: str | None
cooktime: str | None
baketime: str | None
cooltime: str | None
marinade_time: str | None
robot_time: str | None
servings: str | None
notes: str | None
kcal: str | None
kh: str | None
protein: str | None
fett: str | None
created_at: datetime
updated_at: datetime
ingredients: list[IngredientOut]
steps: list[StepOut]

7
backend/requirements.txt Normal file
View file

@ -0,0 +1,7 @@
fastapi==0.121.2
uvicorn[standard]==0.38.0
sqlalchemy==2.0.44
pydantic==2.13.4
python-multipart==0.0.20
jinja2==3.1.5
python-slugify==8.0.4

19
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,19 @@
services:
backend:
image: recipeinator-backend:arm64
volumes:
- ./data:/data
restart: unless-stopped
frontend:
image: recipeinator-frontend:arm64
depends_on:
- backend
networks:
- default
- proxy-net
restart: unless-stopped
networks:
proxy-net:
external: true

19
docker-compose.yml Normal file
View file

@ -0,0 +1,19 @@
services:
backend:
build: ./backend
volumes:
- ./data:/data
restart: unless-stopped
frontend:
build: ./frontend
depends_on:
- backend
networks:
- default
- proxy-net
restart: unless-stopped
networks:
proxy-net:
external: true

2
frontend/.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
dist

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

11
frontend/Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

32
frontend/README.md Normal file
View file

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RecipeInator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

19
frontend/nginx.conf Normal file
View file

@ -0,0 +1,19 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
location /uploads/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
}

1477
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
frontend/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

285
frontend/src/App.css Normal file
View file

@ -0,0 +1,285 @@
.app {
max-width: 960px;
margin: 0 auto;
min-height: 100vh;
}
.app-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--accent);
text-decoration: none;
}
.page {
padding: 1.5rem;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.button {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
text-decoration: none;
}
.button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
}
.button.danger {
color: var(--danger);
border-color: var(--danger);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: var(--danger);
background: #fdecea;
border: 1px solid var(--danger);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.search {
width: 100%;
margin-bottom: 1rem;
}
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.recipe-card {
display: block;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
text-decoration: none;
color: var(--text);
}
.recipe-card img,
.recipe-card-placeholder {
width: 100%;
height: 120px;
object-fit: cover;
background: var(--border);
}
.recipe-card-body {
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tag {
display: inline-block;
font-size: 0.8rem;
color: var(--text-muted);
background: var(--border);
border-radius: 4px;
padding: 0.1rem 0.5rem;
width: fit-content;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
}
.field-grid label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.row-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem;
background: var(--surface);
}
.ingredient-row .qty {
width: 5rem;
}
.ingredient-row .unit {
width: 5rem;
}
.ingredient-row .text {
flex: 1;
}
.checkbox-inline {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
.step-row textarea {
flex: 1;
}
.row-controls {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.row-controls button {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 4px;
line-height: 1;
padding: 0.1rem 0.3rem;
}
.step-number {
text-align: center;
font-weight: 600;
color: var(--accent);
}
.remove {
border: none;
background: transparent;
color: var(--danger);
font-size: 1.1rem;
}
.image-preview {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.image-preview img {
max-height: 120px;
border-radius: 6px;
}
.detail-image {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
margin-bottom: 1rem;
}
.timing-summary {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
margin: 0.75rem 0;
color: var(--text-muted);
}
.detail-columns {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 2rem;
margin-top: 1rem;
}
.ingredient-list,
.step-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ingredient-list li {
display: flex;
gap: 0.75rem;
}
.ingredient-list .qty {
min-width: 4.5rem;
color: var(--text-muted);
}
.ingredient-list .section-header {
font-weight: 700;
margin-top: 0.5rem;
}
.step-list {
list-style: decimal;
padding-left: 1.25rem;
}
.step-list li {
white-space: pre-line;
}
.notes {
white-space: pre-line;
color: var(--text-muted);
}
.nutrition-summary {
display: flex;
gap: 1.5rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
color: var(--text-muted);
}
@media (max-width: 640px) {
.detail-columns {
grid-template-columns: 1fr;
}
}

25
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,25 @@
import { Link, Route, Routes } from "react-router-dom";
import "./App.css";
import RecipeDetailPage from "./pages/RecipeDetailPage";
import RecipeFormPage from "./pages/RecipeFormPage";
import RecipeListPage from "./pages/RecipeListPage";
function App() {
return (
<div className="app">
<header className="app-header">
<Link to="/" className="brand">🍳 RecipeInator</Link>
</header>
<main>
<Routes>
<Route path="/" element={<RecipeListPage />} />
<Route path="/recipes/new" element={<RecipeFormPage />} />
<Route path="/recipes/:id" element={<RecipeDetailPage />} />
<Route path="/recipes/:id/edit" element={<RecipeFormPage />} />
</Routes>
</main>
</div>
);
}
export default App;

View file

@ -0,0 +1,65 @@
import type { Recipe, RecipeInput, RecipeSummary } from "../types/recipe";
const BASE = "/api";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const isFormData = options?.body instanceof FormData;
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
...(isFormData ? {} : { "Content-Type": "application/json" }),
...options?.headers,
},
});
if (!res.ok) {
let detail = "";
try {
const data = await res.json();
detail = data.detail ?? JSON.stringify(data);
} catch {
detail = await res.text();
}
throw new Error(detail || `Request failed: ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export function listRecipes(search?: string): Promise<RecipeSummary[]> {
const query = search ? `?search=${encodeURIComponent(search)}` : "";
return request(`/recipes${query}`);
}
export function getRecipe(id: number): Promise<Recipe> {
return request(`/recipes/${id}`);
}
export function createRecipe(payload: RecipeInput): Promise<Recipe> {
return request(`/recipes`, { method: "POST", body: JSON.stringify(payload) });
}
export function updateRecipe(id: number, payload: RecipeInput): Promise<Recipe> {
return request(`/recipes/${id}`, { method: "PUT", body: JSON.stringify(payload) });
}
export function deleteRecipe(id: number): Promise<void> {
return request(`/recipes/${id}`, { method: "DELETE" });
}
export function uploadRecipeImage(id: number, file: File): Promise<Recipe> {
const form = new FormData();
form.append("file", file);
return request(`/recipes/${id}/image`, { method: "POST", body: form });
}
export function deleteRecipeImage(id: number): Promise<Recipe> {
return request(`/recipes/${id}/image`, { method: "DELETE" });
}
export function pdfDownloadUrl(id: number): string {
return `${BASE}/recipes/${id}/pdf`;
}
export function uploadedImageUrl(filename: string): string {
return `/uploads/${filename}`;
}

View file

@ -0,0 +1,59 @@
import type { Ingredient } from "../types/recipe";
interface Props {
ingredient: Ingredient;
onChange: (ingredient: Ingredient) => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
export default function IngredientRow({ ingredient, onChange, onRemove, onMoveUp, onMoveDown }: Props) {
const isHeader = ingredient.kind === "section_header";
return (
<div className="row ingredient-row">
<div className="row-controls">
<button type="button" onClick={onMoveUp} title="Move up"></button>
<button type="button" onClick={onMoveDown} title="Move down"></button>
</div>
<label className="checkbox-inline">
<input
type="checkbox"
checked={isHeader}
onChange={(e) =>
onChange({ ...ingredient, kind: e.target.checked ? "section_header" : "item" })
}
/>
Section header
</label>
{!isHeader && (
<>
<input
className="qty"
placeholder="Menge"
value={ingredient.quantity ?? ""}
onChange={(e) => onChange({ ...ingredient, quantity: e.target.value })}
/>
<input
className="unit"
placeholder="Einheit"
value={ingredient.unit ?? ""}
onChange={(e) => onChange({ ...ingredient, unit: e.target.value })}
/>
</>
)}
<input
className="text"
placeholder={isHeader ? "Abschnittsname (z.B. Vorteig)" : "Zutat"}
value={ingredient.text}
onChange={(e) => onChange({ ...ingredient, text: e.target.value })}
/>
<button type="button" className="remove" onClick={onRemove} title="Remove"></button>
</div>
);
}

View file

@ -0,0 +1,30 @@
import type { RecipeInput } from "../types/recipe";
interface Props {
value: RecipeInput;
onChange: <K extends keyof RecipeInput>(field: K, value: RecipeInput[K]) => void;
}
const FIELDS: { key: keyof RecipeInput; label: string; placeholder: string }[] = [
{ key: "kcal", label: "Kalorien", placeholder: "z.B. 350" },
{ key: "kh", label: "Kohlenhydrate", placeholder: "z.B. 20 g" },
{ key: "protein", label: "Protein", placeholder: "z.B. 15 g" },
{ key: "fett", label: "Fett", placeholder: "z.B. 10 g" },
];
export default function NutritionFields({ value, onChange }: Props) {
return (
<div className="field-grid">
{FIELDS.map(({ key, label, placeholder }) => (
<label key={key}>
{label}
<input
value={(value[key] as string | null | undefined) ?? ""}
placeholder={placeholder}
onChange={(e) => onChange(key, e.target.value)}
/>
</label>
))}
</div>
);
}

View file

@ -0,0 +1,29 @@
import type { Step } from "../types/recipe";
interface Props {
step: Step;
index: number;
onChange: (step: Step) => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
export default function StepRow({ step, index, onChange, onRemove, onMoveUp, onMoveDown }: Props) {
return (
<div className="row step-row">
<div className="row-controls">
<span className="step-number">{index + 1}</span>
<button type="button" onClick={onMoveUp} title="Move up"></button>
<button type="button" onClick={onMoveDown} title="Move down"></button>
</div>
<textarea
placeholder="Zubereitungsschritt (Tipp: **Label**: Text für eine fette Teil-Überschrift)"
value={step.text}
onChange={(e) => onChange({ ...step, text: e.target.value })}
rows={3}
/>
<button type="button" className="remove" onClick={onRemove} title="Remove"></button>
</div>
);
}

View file

@ -0,0 +1,33 @@
import type { RecipeInput } from "../types/recipe";
interface Props {
value: RecipeInput;
onChange: <K extends keyof RecipeInput>(field: K, value: RecipeInput[K]) => void;
}
const FIELDS: { key: keyof RecipeInput; label: string; placeholder: string }[] = [
{ key: "preptime", label: "Vorbereitung", placeholder: "z.B. 20 min" },
{ key: "cooktime", label: "Kochzeit", placeholder: "z.B. 10 min" },
{ key: "baketime", label: "Backzeit", placeholder: "z.B. 180°C, 20 min" },
{ key: "cooltime", label: "Kühlzeit", placeholder: "z.B. 30 min" },
{ key: "marinade_time", label: "Marinierzeit", placeholder: "z.B. 1-24 h" },
{ key: "robot_time", label: "Küchenmaschine", placeholder: "z.B. 30 min" },
{ key: "servings", label: "Portionen", placeholder: "z.B. 4" },
];
export default function TimingFields({ value, onChange }: Props) {
return (
<div className="field-grid">
{FIELDS.map(({ key, label, placeholder }) => (
<label key={key}>
{label}
<input
value={(value[key] as string | null | undefined) ?? ""}
placeholder={placeholder}
onChange={(e) => onChange(key, e.target.value)}
/>
</label>
))}
</div>
);
}

59
frontend/src/index.css Normal file
View file

@ -0,0 +1,59 @@
:root {
--text: #2b2b2b;
--text-muted: #6b6b6b;
--bg: #faf8f5;
--surface: #ffffff;
--border: #e2ded6;
--accent: #a41034;
--accent-contrast: #ffffff;
--danger: #b3261e;
font: 16px/1.5 system-ui, "Segoe UI", Roboto, sans-serif;
color: var(--text);
background: var(--bg);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
color: var(--text);
font-weight: 600;
}
h1 {
font-size: 1.75rem;
margin: 0;
}
h2 {
font-size: 1.1rem;
margin: 1.5rem 0 0.5rem;
}
input,
textarea,
select {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
}
textarea {
width: 100%;
resize: vertical;
}
button {
font: inherit;
cursor: pointer;
}

13
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View file

@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { deleteRecipe, getRecipe, pdfDownloadUrl, uploadedImageUrl } from "../api/client";
import type { Recipe } from "../types/recipe";
const TIMING_LABELS: { key: keyof Recipe; label: string }[] = [
{ key: "preptime", label: "Vorbereitung" },
{ key: "cooktime", label: "Kochzeit" },
{ key: "baketime", label: "Backzeit" },
{ key: "cooltime", label: "Kühlzeit" },
{ key: "marinade_time", label: "Marinierzeit" },
{ key: "robot_time", label: "Küchenmaschine" },
{ key: "servings", label: "Portionen" },
];
export default function RecipeDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [error, setError] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
useEffect(() => {
if (!id) return;
getRecipe(Number(id)).then(setRecipe).catch((e) => setError(e.message));
}, [id]);
if (error) return <p className="error">{error}</p>;
if (!recipe) return <p>Lädt</p>;
const timingParts = TIMING_LABELS.filter(({ key }) => recipe[key]);
async function handleDelete() {
if (!recipe || !confirm(`"${recipe.name}" wirklich löschen?`)) return;
await deleteRecipe(recipe.id);
navigate("/");
}
async function handleDownload() {
if (!recipe) return;
setDownloading(true);
setError(null);
try {
const res = await fetch(pdfDownloadUrl(recipe.id));
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.detail ?? `PDF-Erstellung fehlgeschlagen (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${recipe.slug}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setError((e as Error).message);
} finally {
setDownloading(false);
}
}
return (
<div className="page">
<div className="page-header">
<h1>{recipe.name}</h1>
<div className="actions">
<button className="button primary" onClick={handleDownload} disabled={downloading}>
{downloading ? "Erstelle PDF…" : "PDF herunterladen"}
</button>
<Link className="button" to={`/recipes/${recipe.id}/edit`}>Bearbeiten</Link>
<button className="button danger" onClick={handleDelete}>Löschen</button>
</div>
</div>
{error && <p className="error">{error}</p>}
{recipe.image_filename && (
<img className="detail-image" src={uploadedImageUrl(recipe.image_filename)} alt="" />
)}
{recipe.category && <p className="tag">{recipe.category}</p>}
{timingParts.length > 0 && (
<div className="timing-summary">
{timingParts.map(({ key, label }) => (
<span key={key}><strong>{label}:</strong> {recipe[key] as string}</span>
))}
</div>
)}
<div className="detail-columns">
<div>
<h2>Zutaten</h2>
<ul className="ingredient-list">
{recipe.ingredients.map((ing) =>
ing.kind === "section_header" ? (
<li key={ing.id} className="section-header">{ing.text}</li>
) : (
<li key={ing.id}>
<span className="qty">{[ing.quantity, ing.unit].filter(Boolean).join(" ")}</span>
<span>{ing.text}</span>
</li>
),
)}
</ul>
{recipe.notes && (
<>
<h2>Notizen</h2>
<p className="notes">{recipe.notes}</p>
</>
)}
</div>
<div>
<h2>Zubereitung</h2>
<ol className="step-list">
{recipe.steps.map((step) => (
<li key={step.id}>{step.text}</li>
))}
</ol>
</div>
</div>
{(recipe.kcal || recipe.kh || recipe.protein || recipe.fett) && (
<div className="nutrition-summary">
{recipe.kcal && <span>Kalorien: {recipe.kcal}</span>}
{recipe.kh && <span>Kohlenhydrate: {recipe.kh}</span>}
{recipe.protein && <span>Protein: {recipe.protein}</span>}
{recipe.fett && <span>Fett: {recipe.fett}</span>}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,241 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
createRecipe,
deleteRecipeImage,
getRecipe,
updateRecipe,
uploadRecipeImage,
uploadedImageUrl,
} from "../api/client";
import IngredientRow from "../components/IngredientRow";
import NutritionFields from "../components/NutritionFields";
import StepRow from "../components/StepRow";
import TimingFields from "../components/TimingFields";
import type { Ingredient, RecipeInput, Step } from "../types/recipe";
const EMPTY_RECIPE: RecipeInput = {
name: "",
category: "",
preptime: "",
cooktime: "",
baketime: "",
cooltime: "",
marinade_time: "",
robot_time: "",
servings: "",
notes: "",
kcal: "",
kh: "",
protein: "",
fett: "",
ingredients: [],
steps: [],
};
function reorder<T>(items: T[], index: number, direction: -1 | 1): T[] {
const target = index + direction;
if (target < 0 || target >= items.length) return items;
const copy = [...items];
[copy[index], copy[target]] = [copy[target], copy[index]];
return copy;
}
export default function RecipeFormPage() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const [form, setForm] = useState<RecipeInput>(EMPTY_RECIPE);
const [existingImage, setExistingImage] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
getRecipe(Number(id)).then((recipe) => {
setForm({
name: recipe.name,
category: recipe.category ?? "",
preptime: recipe.preptime ?? "",
cooktime: recipe.cooktime ?? "",
baketime: recipe.baketime ?? "",
cooltime: recipe.cooltime ?? "",
marinade_time: recipe.marinade_time ?? "",
robot_time: recipe.robot_time ?? "",
servings: recipe.servings ?? "",
notes: recipe.notes ?? "",
kcal: recipe.kcal ?? "",
kh: recipe.kh ?? "",
protein: recipe.protein ?? "",
fett: recipe.fett ?? "",
ingredients: recipe.ingredients,
steps: recipe.steps,
});
setExistingImage(recipe.image_filename ?? null);
});
}, [id]);
function updateField<K extends keyof RecipeInput>(field: K, value: RecipeInput[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
function updateIngredient(index: number, ingredient: Ingredient) {
setForm((prev) => ({
...prev,
ingredients: prev.ingredients.map((it, i) => (i === index ? ingredient : it)),
}));
}
function addIngredient() {
setForm((prev) => ({
...prev,
ingredients: [...prev.ingredients, { kind: "item", quantity: "", unit: "", text: "" }],
}));
}
function removeIngredient(index: number) {
setForm((prev) => ({ ...prev, ingredients: prev.ingredients.filter((_, i) => i !== index) }));
}
function moveIngredient(index: number, direction: -1 | 1) {
setForm((prev) => ({ ...prev, ingredients: reorder(prev.ingredients, index, direction) }));
}
function updateStep(index: number, step: Step) {
setForm((prev) => ({ ...prev, steps: prev.steps.map((it, i) => (i === index ? step : it)) }));
}
function addStep() {
setForm((prev) => ({ ...prev, steps: [...prev.steps, { text: "" }] }));
}
function removeStep(index: number) {
setForm((prev) => ({ ...prev, steps: prev.steps.filter((_, i) => i !== index) }));
}
function moveStep(index: number, direction: -1 | 1) {
setForm((prev) => ({ ...prev, steps: reorder(prev.steps, index, direction) }));
}
async function handleRemoveExistingImage() {
if (!id) return;
await deleteRecipeImage(Number(id));
setExistingImage(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) {
setError("Bitte einen Namen angeben.");
return;
}
setSaving(true);
setError(null);
try {
const recipe = isEdit ? await updateRecipe(Number(id), form) : await createRecipe(form);
if (imageFile) {
await uploadRecipeImage(recipe.id, imageFile);
}
navigate(`/recipes/${recipe.id}`);
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<form className="page" onSubmit={handleSubmit}>
<div className="page-header">
<h1>{isEdit ? "Rezept bearbeiten" : "Neues Rezept"}</h1>
<div className="actions">
<button type="submit" className="button primary" disabled={saving}>
{saving ? "Speichern…" : "Speichern"}
</button>
</div>
</div>
{error && <p className="error">{error}</p>}
<div className="field-grid">
<label>
Name
<input value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
</label>
<label>
Kategorie
<input
value={form.category ?? ""}
placeholder="z.B. Hauptspeisen, Beilagen, Nachspeisen"
onChange={(e) => updateField("category", e.target.value)}
/>
</label>
</div>
<h2>Bild</h2>
{existingImage && !imageFile && (
<div className="image-preview">
<img src={uploadedImageUrl(existingImage)} alt="" />
<button type="button" onClick={handleRemoveExistingImage}>Bild entfernen</button>
</div>
)}
{imageFile && (
<div className="image-preview">
<img src={URL.createObjectURL(imageFile)} alt="" />
<button type="button" onClick={() => setImageFile(null)}>Auswahl verwerfen</button>
</div>
)}
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => setImageFile(e.target.files?.[0] ?? null)}
/>
<h2>Zeiten & Portionen</h2>
<TimingFields value={form} onChange={updateField} />
<h2>Zutaten</h2>
<div className="row-list">
{form.ingredients.map((ingredient, index) => (
<IngredientRow
key={index}
ingredient={ingredient}
onChange={(value) => updateIngredient(index, value)}
onRemove={() => removeIngredient(index)}
onMoveUp={() => moveIngredient(index, -1)}
onMoveDown={() => moveIngredient(index, 1)}
/>
))}
</div>
<button type="button" className="button" onClick={addIngredient}>+ Zutat</button>
<h2>Notizen</h2>
<textarea
value={form.notes ?? ""}
onChange={(e) => updateField("notes", e.target.value)}
rows={3}
/>
<h2>Zubereitung</h2>
<div className="row-list">
{form.steps.map((step, index) => (
<StepRow
key={index}
step={step}
index={index}
onChange={(value) => updateStep(index, value)}
onRemove={() => removeStep(index)}
onMoveUp={() => moveStep(index, -1)}
onMoveDown={() => moveStep(index, 1)}
/>
))}
</div>
<button type="button" className="button" onClick={addStep}>+ Schritt</button>
<h2>Nährwerte (pro Portion)</h2>
<NutritionFields value={form} onChange={updateField} />
</form>
);
}

View file

@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { listRecipes, uploadedImageUrl } from "../api/client";
import type { RecipeSummary } from "../types/recipe";
export default function RecipeListPage() {
const [recipes, setRecipes] = useState<RecipeSummary[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handle = setTimeout(() => {
setLoading(true);
listRecipes(search)
.then(setRecipes)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, 200);
return () => clearTimeout(handle);
}, [search]);
return (
<div className="page">
<div className="page-header">
<h1>Rezepte</h1>
<Link className="button primary" to="/recipes/new">+ Neues Rezept</Link>
</div>
<input
className="search"
placeholder="Suche nach Name oder Kategorie…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{error && <p className="error">{error}</p>}
{loading && <p>Lädt</p>}
<div className="recipe-grid">
{recipes.map((recipe) => (
<Link key={recipe.id} to={`/recipes/${recipe.id}`} className="recipe-card">
{recipe.image_filename ? (
<img src={uploadedImageUrl(recipe.image_filename)} alt="" />
) : (
<div className="recipe-card-placeholder" />
)}
<div className="recipe-card-body">
<strong>{recipe.name}</strong>
{recipe.category && <span className="tag">{recipe.category}</span>}
</div>
</Link>
))}
{!loading && recipes.length === 0 && <p>Keine Rezepte gefunden.</p>}
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
export type IngredientKind = "item" | "section_header";
export interface Ingredient {
id?: number;
kind: IngredientKind;
quantity?: string | null;
unit?: string | null;
text: string;
}
export interface Step {
id?: number;
text: string;
}
export interface RecipeSummary {
id: number;
name: string;
slug: string;
category?: string | null;
image_filename?: string | null;
}
export interface Recipe extends RecipeSummary {
preptime?: string | null;
cooktime?: string | null;
baketime?: string | null;
cooltime?: string | null;
marinade_time?: string | null;
robot_time?: string | null;
servings?: string | null;
notes?: string | null;
kcal?: string | null;
kh?: string | null;
protein?: string | null;
fett?: string | null;
created_at: string;
updated_at: string;
ingredients: Ingredient[];
steps: Step[];
}
export interface RecipeInput {
name: string;
category?: string | null;
preptime?: string | null;
cooktime?: string | null;
baketime?: string | null;
cooltime?: string | null;
marinade_time?: string | null;
robot_time?: string | null;
servings?: string | null;
notes?: string | null;
kcal?: string | null;
kh?: string | null;
protein?: string | null;
fett?: string | null;
ingredients: Ingredient[];
steps: Step[];
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

13
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
'/uploads': 'http://localhost:8000',
},
},
})

15
reverse-proxy/Caddyfile Normal file
View file

@ -0,0 +1,15 @@
# Catch-all: plain IP or "homepie1.local" access, useful for local testing
# and as a fallback before you've set up a per-service .local alias.
:80 {
reverse_proxy frontend:80
}
# RecipeInator
http://rezepte.local {
reverse_proxy frontend:80
}
# Add one block like this per future service, e.g.:
# http://git.local {
# reverse_proxy git-service:PORT
# }

View file

@ -0,0 +1,14 @@
services:
caddy:
image: caddy:2-alpine
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
ports:
- "80:80"
networks:
- proxy-net
restart: unless-stopped
networks:
proxy-net:
name: proxy-net