webapp: .NET 9 Razor Pages, neue rezepte, systemd service
This commit is contained in:
1 parent
07428b0456
commit
466cb09d1c
20 files changed
+969
-4
No files matched your search
Executable
+2
@@ -0,0 +1,2 @@
|
||||
/webapp/Kochstube/bin/
|
||||
/webapp/Kochstube/obj/
|
||||
@@ -4,9 +4,19 @@
|
||||
|
||||
Rezepte & Kochpläne sammeln. Fokus auf gesundes, leckeres Essen. Tausi gibt Zutaten vor, ich schlage passende Rezepte vor - bei Bedarf mit Webrecherche.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** .NET 9 (ASP.NET Razor Pages)
|
||||
- **Port:** 18683
|
||||
- **Host:** stein2 (192.168.100.200)
|
||||
- **Pfad auf stein2:** `/var/lib/docker/workspace/stacks/openclaw/meleki/home/kochstube/`
|
||||
- **systemd Service:** `kochstube` (enabled, auto-restart)
|
||||
- **Rebuild nach Code-Änderungen:** `sudo dotnet build -c Release` im webapp/Kochstube Ordner, dann `sudo systemctl restart kochstube`
|
||||
- **Neue Rezepte:** JSON in `rezepte/` ablegen, wird automatisch geladen (kein Rebuild nötig)
|
||||
|
||||
## Pfad
|
||||
|
||||
`/_stacks/openclaw/meleki/workspace/projects/kochstube`
|
||||
`/_stacks/openclaw/meleki/home/kochstube` (auf stein2, via SSH erreichbar)
|
||||
|
||||
## Rezeptquellen (primär)
|
||||
|
||||
@@ -15,6 +25,5 @@ Rezepte & Kochpläne sammeln. Fokus auf gesundes, leckeres Essen. Tausi gibt Zut
|
||||
|
||||
## Notizen
|
||||
|
||||
- Kein SSH/stein2-Zugriff nötig - alles direkt im eigenen Workspace schreiben
|
||||
- Rezepte als JSON in `rezepte/` ablegen (für spätere Website)
|
||||
-
|
||||
- Rezepte als JSON in `rezepte/` ablegen
|
||||
- Web-App cached Rezepte 10 Sekunden
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "chinesisches-kartoffel-stir-fry",
|
||||
"titel": "Chinesisches Kartoffel-Stir-Fry (Szechuan-Art)",
|
||||
"beschreibung": "Knackige Kartoffelstreifen aus dem Wok, sauer-scharf mit Sojasauce, Essig und Paprika. Steht in 15 Minuten auf dem Tisch – chinesische Alltagsküche at its best.",
|
||||
"bild": "https://www.eat-this.org/wp-content/uploads/2022/09/eat_this_chinesisches_kartoffel-stir-fry-003.jpg",
|
||||
"kategorie": "Hauptspeise",
|
||||
"tags": ["asiatisch", "schnell", "pfanne", "vegan", "kartoffeln", "szechuan"],
|
||||
"schwierigkeit": "einfach",
|
||||
"zubereitungszeit_min": 15,
|
||||
"portionen": 2,
|
||||
"zutaten": [
|
||||
{"menge": "650 g", "zutat": "festkochende Kartoffeln"},
|
||||
{"menge": "2", "zutat": "Schalotten (oder kleine Zwiebeln)"},
|
||||
{"menge": "2 Zehen", "zutat": "Knoblauch"},
|
||||
{"menge": "½", "zutat": "grüne Paprika"},
|
||||
{"menge": "1 TL", "zutat": "Szechuanpfeffer (oder schwarzer Pfeffer)"},
|
||||
{"menge": "2-5", "zutat": "getrocknete Chilis (nach Geschmack)"},
|
||||
{"menge": "3 EL", "zutat": "neutrales Öl"},
|
||||
{"menge": "½ TL", "zutat": "Salz"},
|
||||
{"menge": "3 EL", "zutat": "Balsamico-Essig (ersetzt chin. schwarzen Essig)"},
|
||||
{"menge": "1 EL", "zutat": "Sojasauce"}
|
||||
],
|
||||
"zubereitung": [
|
||||
"Kartoffeln schälen und in dünne Streifen (3-4 mm) schneiden oder hobeln. Gut abspülen und in kaltem Wasser einlegen.",
|
||||
"Zwiebeln und Knoblauch in dünne Scheiben, Paprika in Streifen schneiden. Chilis grob zerkleinern.",
|
||||
"Kartoffeln abgießen und sehr gut abtropfen lassen.",
|
||||
"Öl in einem Wok oder einer Pfanne stark erhitzen. Chilis, Pfeffer und Knoblauch hineingeben und 20-30 Sekunden rösten.",
|
||||
"Hitze auf Maximum stellen, Zwiebeln, Kartoffeln und Paprika dazugeben, salzen und unter ständigem Rühren 3-4 Minuten braten, bis die Kartoffeln etwas weicher, aber noch knackig sind.",
|
||||
"Mit Essig und Sojasauce ablöschen, bei Bedarf nachsalzen und sofort heiß servieren."
|
||||
],
|
||||
"notizen": "Chinesischen schwarzen Essig (Chinkiang) kann man durch Balsamico ersetzen. Kartoffeln müssen festkochend sein! Dazu passt Reis. Chilis weglassen für milde Version.",
|
||||
"quelle": {
|
||||
"name": "eat-this.org",
|
||||
"url": "https://www.eat-this.org/chinesisches-kartoffel-stir-fry/",
|
||||
"autor": "Jörg"
|
||||
},
|
||||
"erstellt": "2026-02-17"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"id": "gebackener-feta-kartoffeln-rahmgurken",
|
||||
"titel": "Gebackener Feta mit Kartoffeln und Rahmgurken",
|
||||
"beschreibung": "Knusprig panierter Feta auf Salzkartoffeln mit cremigem Gurkenrahmsalat – ein einfaches, deftiges Gericht mit wenig Aufwand.",
|
||||
"bild": "https://ais.kochbar.de/kbrezept/384593_410941/1200x1200/gebackener-feta-mit-kartoffeln-und-rahmgurken-rezept.jpg",
|
||||
"kategorie": "Hauptspeise",
|
||||
"tags": ["deftig", "schnell", "vegetarisch", "kartoffeln", "feta"],
|
||||
"schwierigkeit": "einfach",
|
||||
"zubereitungszeit_min": 35,
|
||||
"portionen": 2,
|
||||
"zutaten": [
|
||||
{"menge": "750 g", "zutat": "Kartoffeln (festkochend)"},
|
||||
{"menge": "2 Stück", "zutat": "Feta"},
|
||||
{"menge": "etwas", "zutat": "Semmelbrösel"},
|
||||
{"menge": "etwas", "zutat": "Butter"},
|
||||
{"menge": "1", "zutat": "Ei"},
|
||||
{"menge": "1", "zutat": "Salatgurke"},
|
||||
{"menge": "200 ml", "zutat": "Schmand"},
|
||||
{"menge": "½", "zutat": "Zwiebel"},
|
||||
{"menge": "etwas", "zutat": "Salz"},
|
||||
{"menge": "etwas", "zutat": "Pfeffer"},
|
||||
{"menge": "etwas", "zutat": "Essig"}
|
||||
],
|
||||
"zubereitung": [
|
||||
"Kartoffeln waschen und in kochendem Salzwasser gar kochen.",
|
||||
"Gurke schälen, fein hobeln und in einer Schüssel salzen. Kurz ziehen lassen.",
|
||||
"Feta abtropfen lassen, in verquirltem Ei wenden, dann in Semmelbröseln wenden.",
|
||||
"Butter in einer Pfanne erhitzen und den Feta auf beiden Seiten goldgelb ausbacken.",
|
||||
"Gurken aus der Schüssel nehmen und die Feuchtigkeit ausdrücken.",
|
||||
"Zwiebel schälen und fein würfeln. Schmand und Zwiebeln zu den Gurken geben.",
|
||||
"Gurkensalat mit Essig, Salz und Pfeffer abschmecken.",
|
||||
"Kartoffeln abgießen, pellen und mit gebackenem Feta und Rahmgurken servieren."
|
||||
],
|
||||
"notizen": "Schmand muss evtl. besorgt werden. Alternativ Saure Sahne oder Crème fraîche verwenden. Semmelbrösel können aus altem Brot selbst gemacht werden.",
|
||||
"quelle": {
|
||||
"name": "kochbar.de",
|
||||
"url": "https://www.kochbar.de/rezept/384593/Gebackener-Feta-mit-Kartoffeln-und-Rahmgurken.html",
|
||||
"autor": "kochbar-User"
|
||||
},
|
||||
"erstellt": "2026-02-17"
|
||||
}
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "pad-thai-gruener-spargel-tofu",
|
||||
"titel": "Pad Thai mit grünem Spargel und Tofu",
|
||||
"beschreibung": "Authentisches Pad Thai mit knackigem grünem Spargel, kross gebratenem Tofu und würziger Sauce - in 30 Minuten fertig.",
|
||||
"bild": "https://www.eat-this.org/wp-content/uploads/2017/05/Pad-Thai-mit-gruenem-Spargel-1.jpg",
|
||||
"kategorie": "Hauptspeise",
|
||||
"tags": ["vegan", "schnell", "asiatisch", "nudeln", "thai"],
|
||||
"schwierigkeit": "einfach",
|
||||
"zubereitungszeit_min": 30,
|
||||
"portionen": 4,
|
||||
"zutaten": [
|
||||
{"menge": "400 g", "zutat": "dünne Reisnudeln"},
|
||||
{"menge": "400 g", "zutat": "grüner Spargel"},
|
||||
{"menge": "1 Bund", "zutat": "Frühlingszwiebeln"},
|
||||
{"menge": "4 Zehen", "zutat": "Knoblauch"},
|
||||
{"menge": "1 Stück", "zutat": "Ingwer (daumengroß)"},
|
||||
{"menge": "75 ml", "zutat": "Sojasauce"},
|
||||
{"menge": "4 EL", "zutat": "Tafelessig"},
|
||||
{"menge": "2 EL", "zutat": "Rohrohrzucker"},
|
||||
{"menge": "4 EL", "zutat": "Erdnüsse"},
|
||||
{"menge": "300 g", "zutat": "Tofu"},
|
||||
{"menge": "4 EL", "zutat": "Erdnussöl"},
|
||||
{"menge": "250 g", "zutat": "Mungbohnensprossen"},
|
||||
{"menge": "0.5 Bund", "zutat": "Koriander"},
|
||||
{"menge": "2", "zutat": "rote Chili"}
|
||||
],
|
||||
"zubereitung": [
|
||||
"Reisnudeln in einer Schüssel mit kochendem Wasser übergießen, abdecken und beiseite stellen.",
|
||||
"Spargel diagonal in 1 cm dicke Scheiben, den weißen Teil der Frühlingszwiebeln in 4 cm lange Stücke schneiden. Grünen Teil fein hacken und für die Deko beiseite stellen.",
|
||||
"Knoblauch pressen, Ingwer reiben. Mit Sojasauce, Essig, Zucker und 8 EL Wasser verrühren. Sauce beiseite stellen.",
|
||||
"Erdnüsse in einem Wok ohne Öl bei mittlerer bis hoher Hitze 2 Minuten unter dauerndem Rühren rösten. Aus der Pfanne nehmen.",
|
||||
"Tofu in 3 cm große Würfel schneiden. Öl in den Wok geben, Tofuwürfel bei mittlerer bis hoher Hitze 6 Minuten goldbraun braten.",
|
||||
"Frühlingszwiebeln dazu geben, weitere 2 Minuten braten.",
|
||||
"Spargel unterheben, 3 Minuten unter häufigem Rühren rösten.",
|
||||
"Nudeln abgießen, unter das Gemüse mischen und 1 Minute pfannenrühren. Mit Sauce ablöschen, Sprossen unterrühren und 2 Minuten schmoren.",
|
||||
"Mit Erdnüssen, frisch gehacktem Koriander und Chili toppen."
|
||||
],
|
||||
"notizen": null,
|
||||
"quelle": {
|
||||
"name": "eat-this.org",
|
||||
"url": "https://www.eat-this.org/pad-thai-mit-gruenem-spargel-und-tofu/",
|
||||
"autor": null
|
||||
},
|
||||
"erstellt": "2026-02-17"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"id": "shakshuka-mit-feta",
|
||||
"titel": "Shakshuka mit Feta",
|
||||
"beschreibung": "Würzige nordafrikanische Eierpfanne in aromatischer Tomaten-Paprika-Sauce, getoppt mit cremigem Feta. Perfekt zum Dippen mit frischem Brot.",
|
||||
"bild": "",
|
||||
"kategorie": "Hauptspeise",
|
||||
"tags": [
|
||||
"pfanne",
|
||||
"vegetarisch",
|
||||
"deftig",
|
||||
"orientalisch",
|
||||
"eier"
|
||||
],
|
||||
"schwierigkeit": "einfach",
|
||||
"zubereitungszeit_min": 30,
|
||||
"portionen": 2,
|
||||
"zutaten": [
|
||||
{
|
||||
"menge": "1",
|
||||
"zutat": "Zwiebel"
|
||||
},
|
||||
{
|
||||
"menge": "2 Zehen",
|
||||
"zutat": "Knoblauch"
|
||||
},
|
||||
{
|
||||
"menge": "1 große",
|
||||
"zutat": "Paprika (rot)"
|
||||
},
|
||||
{
|
||||
"menge": "1 kleine",
|
||||
"zutat": "Zucchini"
|
||||
},
|
||||
{
|
||||
"menge": "2 EL",
|
||||
"zutat": "Olivenöl"
|
||||
},
|
||||
{
|
||||
"menge": "1 EL",
|
||||
"zutat": "Tomatenmark"
|
||||
},
|
||||
{
|
||||
"menge": "400 ml",
|
||||
"zutat": "Passierte Tomaten"
|
||||
},
|
||||
{
|
||||
"menge": "1 TL",
|
||||
"zutat": "Kreuzkümmel (Cumin)"
|
||||
},
|
||||
{
|
||||
"menge": "1 TL",
|
||||
"zutat": "Paprikapulver (geräuchert)"
|
||||
},
|
||||
{
|
||||
"menge": "0.5 TL",
|
||||
"zutat": "Chiliflocken"
|
||||
},
|
||||
{
|
||||
"menge": "1 TL",
|
||||
"zutat": "Zucker"
|
||||
},
|
||||
{
|
||||
"menge": "",
|
||||
"zutat": "Salz und Pfeffer"
|
||||
},
|
||||
{
|
||||
"menge": "4",
|
||||
"zutat": "Eier"
|
||||
},
|
||||
{
|
||||
"menge": "80 g",
|
||||
"zutat": "Feta"
|
||||
}
|
||||
],
|
||||
"zubereitung": [
|
||||
"Zwiebel und Knoblauch fein hacken. Paprika in kleine Würfel schneiden, Zucchini in Halbmonde.",
|
||||
"Olivenöl in einer großen Pfanne mit Deckel bei mittlerer Hitze erhitzen. Zwiebel 3-4 Minuten glasig dünsten.",
|
||||
"Paprika, Zucchini und Knoblauch dazu geben und 3-4 Minuten mitbraten.",
|
||||
"Tomatenmark einrühren und 1 Minute anrösten. Kreuzkümmel, Paprikapulver und Chiliflocken dazu und kurz mitrösten.",
|
||||
"Passierte Tomaten angießen, Zucker, Salz und Pfeffer zugeben. Alles 8-10 Minuten bei mittlerer Hitze köcheln lassen, bis die Sauce eingedickt ist.",
|
||||
"Mit einem Löffel 4 Mulden in die Sauce drücken und je ein Ei hineinschlagen. Feta darüber bröseln.",
|
||||
"Deckel auflegen und bei niedriger Hitze 6-8 Minuten garen, bis das Eiweiß gestockt ist, das Eigelb aber noch weich.",
|
||||
"Mit frischem Brot servieren."
|
||||
],
|
||||
"notizen": "Brot muss ggf. besorgt werden (Fladenbrot ideal). Eigelb soll noch flüssig sein — lieber kürzer garen und nachschauen. Wer es schärfer mag, mehr Chiliflocken nehmen.",
|
||||
"quelle": {
|
||||
"name": "Klassisches Rezept",
|
||||
"url": "",
|
||||
"autor": "Meleki"
|
||||
},
|
||||
"erstellt": "2026-02-17"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kochstube.Models;
|
||||
|
||||
public class Rezept
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("titel")]
|
||||
public string Titel { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("beschreibung")]
|
||||
public string Beschreibung { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("bild")]
|
||||
public string? Bild { get; set; }
|
||||
|
||||
[JsonPropertyName("kategorie")]
|
||||
public string Kategorie { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("schwierigkeit")]
|
||||
public string Schwierigkeit { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("zubereitungszeit_min")]
|
||||
public int ZubereitungszeitMin { get; set; }
|
||||
|
||||
[JsonPropertyName("portionen")]
|
||||
public int Portionen { get; set; }
|
||||
|
||||
[JsonPropertyName("zutaten")]
|
||||
public List<Zutat> Zutaten { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("zubereitung")]
|
||||
public List<string> Zubereitung { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("notizen")]
|
||||
public string? Notizen { get; set; }
|
||||
|
||||
[JsonPropertyName("quelle")]
|
||||
public Quelle? Quelle { get; set; }
|
||||
|
||||
[JsonPropertyName("erstellt")]
|
||||
public string? Erstellt { get; set; }
|
||||
}
|
||||
|
||||
public class Zutat
|
||||
{
|
||||
[JsonPropertyName("menge")]
|
||||
public string Menge { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("zutat")]
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
public class Quelle
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("autor")]
|
||||
public string? Autor { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@page
|
||||
@model IndexModel
|
||||
|
||||
@section FilterBar {
|
||||
<div class="search-bar-wrap">
|
||||
<form method="get" class="search-bar">
|
||||
<input type="text" name="q" value="@Model.Q" placeholder="🔍 Rezept suchen..." />
|
||||
<select name="tag">
|
||||
<option value="">Alle Tags</option>
|
||||
@foreach (var t in Model.AlleTags)
|
||||
{
|
||||
<option value="@t" selected="@(Model.Tag == t)">@t</option>
|
||||
}
|
||||
</select>
|
||||
<select name="kategorie">
|
||||
<option value="">Alle Kategorien</option>
|
||||
@foreach (var k in Model.AlleKategorien)
|
||||
{
|
||||
<option value="@k" selected="@(Model.Kategorie == k)">@k</option>
|
||||
}
|
||||
</select>
|
||||
<button type="submit">Suchen</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="stats">@Model.Rezepte.Count Rezept(e)</div>
|
||||
|
||||
<div class="grid">
|
||||
@foreach (var r in Model.Rezepte)
|
||||
{
|
||||
<a class="card" href="/rezept/@r.Id">
|
||||
@if (r.ZubereitungszeitMin <= 15)
|
||||
{
|
||||
<div class="badge-quick">⚡ @r.ZubereitungszeitMin Min</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(r.Bild))
|
||||
{
|
||||
<img class="card-img" src="@r.Bild" alt="@r.Titel" loading="lazy" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card-img-placeholder">🍽️</div>
|
||||
}
|
||||
<div class="card-body">
|
||||
<h3>@r.Titel</h3>
|
||||
<p>@(r.Beschreibung.Length > 120 ? r.Beschreibung[..120] + "..." : r.Beschreibung)</p>
|
||||
<div class="card-meta">
|
||||
<span>⏱ @r.ZubereitungszeitMin Min</span>
|
||||
<span>👥 @r.Portionen Port.</span>
|
||||
<span>📊 @r.Schwierigkeit</span>
|
||||
</div>
|
||||
<div class="tags">
|
||||
@foreach (var tag in r.Tags)
|
||||
{
|
||||
<span class="tag">@tag</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Kochstube.Models;
|
||||
using Kochstube.Services;
|
||||
|
||||
namespace Kochstube.Pages;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly RezeptService _service;
|
||||
|
||||
public IndexModel(RezeptService service) => _service = service;
|
||||
|
||||
public List<Rezept> Rezepte { get; set; } = [];
|
||||
public List<string> AlleTags { get; set; } = [];
|
||||
public List<string> AlleKategorien { get; set; } = [];
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? Q { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? Tag { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? Kategorie { get; set; }
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
AlleTags = _service.GetAllTags();
|
||||
AlleKategorien = _service.GetAllKategorien();
|
||||
Rezepte = _service.Search(Q, Tag, Kategorie);
|
||||
ViewData["Title"] = "Rezepte";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@page "/rezept/{id}"
|
||||
@model RezeptModel
|
||||
|
||||
@if (Model.Rezept == null) { return; }
|
||||
|
||||
@{ var r = Model.Rezept; }
|
||||
|
||||
<div class="detail">
|
||||
<a class="back-link" href="/">← Zurück zur Übersicht</a>
|
||||
|
||||
<h1>@r.Titel</h1>
|
||||
<p class="desc">@r.Beschreibung</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(r.Bild))
|
||||
{
|
||||
<img class="detail-img" src="@r.Bild" alt="@r.Titel" />
|
||||
}
|
||||
|
||||
<div class="detail-meta">
|
||||
<div class="meta-item">
|
||||
<span class="label">Zubereitungszeit</span>
|
||||
<span class="value">@r.ZubereitungszeitMin min</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">Portionen</span>
|
||||
<span class="value">@r.Portionen</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">Schwierigkeit</span>
|
||||
<span class="value">@r.Schwierigkeit</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">Kategorie</span>
|
||||
<span class="value">@r.Kategorie</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tags" style="margin-bottom: 1.5rem;">
|
||||
@foreach (var tag in r.Tags)
|
||||
{
|
||||
<a class="tag" href="/?tag=@tag">@tag</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Zutaten</h2>
|
||||
<ul class="zutaten-list">
|
||||
@foreach (var z in r.Zutaten)
|
||||
{
|
||||
<li>
|
||||
<span>@z.Name</span>
|
||||
<span class="menge">@z.Menge</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Zubereitung</h2>
|
||||
<ol class="steps">
|
||||
@foreach (var step in r.Zubereitung)
|
||||
{
|
||||
<li>@step</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(r.Notizen))
|
||||
{
|
||||
<div class="section">
|
||||
<h2>Notizen</h2>
|
||||
<div class="notizen">@r.Notizen</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (r.Quelle != null)
|
||||
{
|
||||
<div class="section quelle">
|
||||
Quelle: <a href="@r.Quelle.Url" target="_blank">@r.Quelle.Name</a>
|
||||
@if (!string.IsNullOrEmpty(r.Quelle.Autor))
|
||||
{
|
||||
<span> von @r.Quelle.Autor</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Kochstube.Models;
|
||||
using Kochstube.Services;
|
||||
|
||||
namespace Kochstube.Pages;
|
||||
|
||||
public class RezeptModel : PageModel
|
||||
{
|
||||
private readonly RezeptService _service;
|
||||
|
||||
public RezeptModel(RezeptService service) => _service = service;
|
||||
|
||||
public Rezept? Rezept { get; set; }
|
||||
|
||||
public IActionResult OnGet(string id)
|
||||
{
|
||||
Rezept = _service.GetById(id);
|
||||
if (Rezept == null) return NotFound();
|
||||
ViewData["Title"] = Rezept.Titel;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>@ViewData["Title"] - Kochstube</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f0e8;
|
||||
--card: #ffffff;
|
||||
--accent: #d94f30;
|
||||
--accent2: #fef3e2;
|
||||
--text: #2d2a26;
|
||||
--muted: #7a7470;
|
||||
--tag-bg: #f0e6d6;
|
||||
--tag-text: #6b5e4f;
|
||||
--quick: #e8960c;
|
||||
--border: #e8e0d4;
|
||||
--shadow: rgba(120, 100, 70, .1);
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 2.5rem 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, #fff8ef, #fef3e2);
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
header h1 { font-size: 2.4rem; letter-spacing: 2px; color: var(--text); }
|
||||
header h1 span { color: var(--accent); }
|
||||
header p { color: var(--muted); margin-top: .4rem; font-size: .95rem; }
|
||||
header a { text-decoration: none; color: inherit; }
|
||||
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
/* Search / Filter bar */
|
||||
.search-bar-wrap {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
justify-content: center;
|
||||
padding: 1.2rem 1rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-bar input, .search-bar select {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: .85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-bar input { flex: 1; min-width: 200px; }
|
||||
.search-bar input::placeholder { color: #b5ada5; }
|
||||
.search-bar input:focus, .search-bar select:focus { border-color: var(--accent); }
|
||||
|
||||
.search-bar select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: .45rem 1.2rem;
|
||||
border-radius: 20px;
|
||||
font-size: .85rem;
|
||||
cursor: pointer;
|
||||
transition: opacity .2s;
|
||||
}
|
||||
|
||||
.search-bar button:hover { opacity: .9; }
|
||||
|
||||
.stats {
|
||||
color: var(--muted);
|
||||
margin-bottom: 1rem;
|
||||
font-size: .9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 2px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(120, 100, 70, .18);
|
||||
}
|
||||
|
||||
.card-img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-img-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: var(--tag-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.badge-quick {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: var(--quick);
|
||||
color: #fff;
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
padding: .25rem .6rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
.card-body { padding: 1rem 1.2rem 1.2rem; }
|
||||
.card-body h3 { font-size: 1.1rem; margin-bottom: .5rem; line-height: 1.3; }
|
||||
.card-body p {
|
||||
color: var(--muted);
|
||||
font-size: .85rem;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: .8rem;
|
||||
margin-top: .7rem;
|
||||
font-size: .8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card-meta span { display: flex; align-items: center; gap: .3rem; }
|
||||
|
||||
.tags { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .6rem; }
|
||||
|
||||
.tag {
|
||||
background: var(--tag-bg);
|
||||
color: var(--tag-text);
|
||||
padding: .15rem .55rem;
|
||||
border-radius: 8px;
|
||||
font-size: .72rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag:hover { opacity: .8; }
|
||||
|
||||
/* Detail page */
|
||||
.detail { max-width: 750px; margin: 0 auto; }
|
||||
.detail h1 { font-size: 1.8rem; margin-bottom: .3rem; }
|
||||
.detail .desc { color: var(--muted); font-size: 1.05rem; margin-bottom: 1.2rem; line-height: 1.5; }
|
||||
|
||||
.detail-img {
|
||||
width: 100%;
|
||||
max-height: 350px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 4px 20px var(--shadow);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
background: var(--accent2);
|
||||
padding: .5rem 1rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.meta-item .label { font-size: .7rem; color: var(--muted); display: block; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.meta-item .value { font-size: 1.1rem; font-weight: 700; }
|
||||
|
||||
.section { margin-bottom: 2rem; }
|
||||
.section h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: .6rem;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.zutaten-list { list-style: none; }
|
||||
.zutaten-list li {
|
||||
padding: .5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: .92rem;
|
||||
}
|
||||
.zutaten-list .menge { color: var(--accent); font-weight: 600; }
|
||||
|
||||
.steps { list-style: none; counter-reset: step; }
|
||||
.steps li {
|
||||
counter-increment: step;
|
||||
padding: .8rem 0;
|
||||
padding-left: 2.5rem;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--border);
|
||||
line-height: 1.5;
|
||||
font-size: .92rem;
|
||||
}
|
||||
.steps li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: .85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notizen {
|
||||
background: #fef6e9;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border-left: 3px solid var(--quick);
|
||||
color: var(--muted);
|
||||
font-size: .9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.quelle {
|
||||
font-size: .85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.quelle a { color: var(--accent); text-decoration: none; }
|
||||
.quelle a:hover { text-decoration: underline; }
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: .9rem;
|
||||
}
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@media (max-width: 600px) {
|
||||
header { padding: 1.5rem 1rem 1rem; }
|
||||
header h1 { font-size: 1.8rem; }
|
||||
main { padding: 1rem 0.5rem; }
|
||||
.grid { grid-template-columns: 1fr; gap: 1rem; }
|
||||
.detail-body { padding: 1.2rem; }
|
||||
.search-bar input { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/">
|
||||
<h1>🍳 Koch<span>stube</span></h1>
|
||||
<p>Rezepte von Tausi & Nocki</p>
|
||||
</a>
|
||||
</header>
|
||||
@RenderSection("FilterBar", required: false)
|
||||
<main>
|
||||
@RenderBody()
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
@using Kochstube.Models
|
||||
@namespace Kochstube.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "/Pages/Shared/_Layout.cshtml";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Kochstube.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddSingleton<RezeptService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.MapRazorPages();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5134",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.Json;
|
||||
using Kochstube.Models;
|
||||
|
||||
namespace Kochstube.Services;
|
||||
|
||||
public class RezeptService
|
||||
{
|
||||
private readonly string _rezeptPath;
|
||||
private List<Rezept>? _cache;
|
||||
private DateTime _lastLoad;
|
||||
|
||||
public RezeptService(IConfiguration config)
|
||||
{
|
||||
_rezeptPath = config["RezeptPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "rezepte");
|
||||
}
|
||||
|
||||
public List<Rezept> GetAll()
|
||||
{
|
||||
// Reload if older than 10 seconds (good enough for dev)
|
||||
if (_cache == null || DateTime.UtcNow - _lastLoad > TimeSpan.FromSeconds(10))
|
||||
{
|
||||
_cache = LoadFromDisk();
|
||||
_lastLoad = DateTime.UtcNow;
|
||||
}
|
||||
return _cache;
|
||||
}
|
||||
|
||||
public Rezept? GetById(string id) => GetAll().FirstOrDefault(r => r.Id == id);
|
||||
|
||||
public List<string> GetAllTags() => GetAll().SelectMany(r => r.Tags).Distinct().OrderBy(t => t).ToList();
|
||||
|
||||
public List<string> GetAllKategorien() => GetAll().Select(r => r.Kategorie).Distinct().OrderBy(k => k).ToList();
|
||||
|
||||
public List<Rezept> Search(string? query, string? tag, string? kategorie)
|
||||
{
|
||||
var results = GetAll().AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
var q = query.ToLowerInvariant();
|
||||
results = results.Where(r =>
|
||||
r.Titel.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Beschreibung.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Zutaten.Any(z => z.Name.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
results = results.Where(r => r.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(kategorie))
|
||||
results = results.Where(r => r.Kategorie.Equals(kategorie, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
private List<Rezept> LoadFromDisk()
|
||||
{
|
||||
var dir = Path.GetFullPath(_rezeptPath);
|
||||
if (!Directory.Exists(dir)) return [];
|
||||
|
||||
var rezepte = new List<Rezept>();
|
||||
foreach (var file in Directory.GetFiles(dir, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
var r = JsonSerializer.Deserialize<Rezept>(json);
|
||||
if (r != null) rezepte.Add(r);
|
||||
}
|
||||
catch { /* skip broken files */ }
|
||||
}
|
||||
return rezepte.OrderByDescending(r => r.Erstellt ?? "").ThenBy(r => r.Titel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"RezeptPath": "/var/lib/docker/workspace/stacks/openclaw/meleki/home/kochstube/rezepte",
|
||||
"Urls": "http://0.0.0.0:18683",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user