webapp: .NET 9 Razor Pages, neue rezepte, systemd service

This commit is contained in:
Meleki committed 2026-02-17 10:46:28 +01:00
1 parent 07428b0456
commit 466cb09d1c
20 files changed
+969 -4

No files matched your search

Executable
+2
View File
@@ -0,0 +1,2 @@
/webapp/Kochstube/bin/
/webapp/Kochstube/obj/
+13 -4
View File
@@ -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. 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 ## Pfad
`/_stacks/openclaw/meleki/workspace/projects/kochstube` `/_stacks/openclaw/meleki/home/kochstube` (auf stein2, via SSH erreichbar)
## Rezeptquellen (primär) ## Rezeptquellen (primär)
@@ -15,6 +25,5 @@ Rezepte & Kochpläne sammeln. Fokus auf gesundes, leckeres Essen. Tausi gibt Zut
## Notizen ## Notizen
- Kein SSH/stein2-Zugriff nötig - alles direkt im eigenen Workspace schreiben - Rezepte als JSON in `rezepte/` ablegen
- Rezepte als JSON in `rezepte/` ablegen (für spätere Website) - 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"
}
+45
View File
@@ -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"
}
+92
View File
@@ -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"
}
+7
View File
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+69
View File
@@ -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; }
}
+62
View File
@@ -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>
+34
View File
@@ -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";
}
}
+86
View File
@@ -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>
+23
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
@{
Layout = "/Pages/Shared/_Layout.cshtml";
}
+12
View File
@@ -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"
}
}
}
+9
View File
@@ -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"
}
}
}