Compare commits
2 Commits
434e401918
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5290d73502 | |||
| 418be69369 |
@@ -0,0 +1,91 @@
|
|||||||
|
# What To Cook (WTC) - Kochbuch & Wochenplaner
|
||||||
|
|
||||||
|
Digitales Kochbuch, Wochenmen?planer & Buchdruck-Engine (FastAPI & MariaDB).
|
||||||
|
|
||||||
|
## Systemanforderungen
|
||||||
|
- Debian 12 / 13 (oder kompatibles Linux)
|
||||||
|
- Python 3.13+ mit `python3-venv`
|
||||||
|
- Nginx (Reverse Proxy mit SSL)
|
||||||
|
- MariaDB Server auf \`db.waiswuaschd.de:3306\` (Datenbank: \`v2_db_wtc_cookbook\`)
|
||||||
|
|
||||||
|
## 1. Installation
|
||||||
|
```bash
|
||||||
|
# Repository klonen
|
||||||
|
cd /home/maxi/Projekt
|
||||||
|
git clone https://gitea.sp13l.de/kobold-ai/wtc.waiswuaschd.de.git wtc.waiswuaschd.de
|
||||||
|
cd wtc.waiswuaschd.de
|
||||||
|
|
||||||
|
# Python Virtualenv erstellen und Abh?ngigkeiten installieren
|
||||||
|
python3 -m venv venv
|
||||||
|
./venv/bin/pip install --upgrade pip
|
||||||
|
./venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Umgebungsvariablen (.env)
|
||||||
|
Erstelle im Projektverzeichnis eine `.env` Datei:
|
||||||
|
```ini
|
||||||
|
DB_ENGINE=mariadb
|
||||||
|
MARIADB_HOST=db.waiswuaschd.de
|
||||||
|
MARIADB_PORT=3306
|
||||||
|
MARIADB_USER=v2_usr_wtc_cookbook
|
||||||
|
MARIADB_PASS=XKikHf&j6Wj!rs5%A8#ZzyvE#*A1hT
|
||||||
|
MARIADB_NAME=v2_db_wtc_cookbook
|
||||||
|
|
||||||
|
APP_USERNAME=admin
|
||||||
|
APP_PASSWORD=wtc2026!
|
||||||
|
SECRET_KEY=wtc_secure_hmac_secret_key_2026_kochbuch_session
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Systemd Service (/etc/systemd/system/wtc.service)
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=What To Cook (WTC) Zufallsgenerator f?r Essen (FastAPI)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=maxi
|
||||||
|
Group=maxi
|
||||||
|
WorkingDirectory=/home/maxi/Projekt/wtc.waiswuaschd.de
|
||||||
|
Environment="PATH=/home/maxi/Projekt/wtc.waiswuaschd.de/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
|
ExecStart=/home/maxi/Projekt/wtc.waiswuaschd.de/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8003 --workers 1
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
Aktivieren und starten:
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now wtc
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Nginx Reverse Proxy
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name wtc.dev.waiswuaschd.de;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name wtc.dev.waiswuaschd.de;
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# SSL Zertifikate hier konfigurieren (z. B. via certbot)
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /home/maxi/Projekt/wtc.waiswuaschd.de/app/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8003;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 753 KiB |
|
After Width: | Height: | Size: 825 KiB |
|
After Width: | Height: | Size: 395 KiB |
@@ -24,6 +24,9 @@
|
|||||||
<span id="cookmode-nav-badge" class="cb-badge-pill">Aus</span>
|
<span id="cookmode-nav-badge" class="cb-badge-pill">Aus</span>
|
||||||
</button>
|
</button>
|
||||||
{% if not is_shared %}
|
{% if not is_shared %}
|
||||||
|
<a href="/shopping-list?import_recipe_id={{ recipe.id }}" class="cb-btn cb-btn-outline" title="Zutaten auf die Einkaufsliste übernehmen">
|
||||||
|
<span>🛒</span> <span>Auf Einkaufsliste</span>
|
||||||
|
</a>
|
||||||
<a href="/cookbook" class="cb-btn cb-btn-outline">
|
<a href="/cookbook" class="cb-btn cb-btn-outline">
|
||||||
<span>📖</span> <span>Alle Rezepte</span>
|
<span>📖</span> <span>Alle Rezepte</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -65,6 +68,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<hr class="cb-mobile-divider">
|
<hr class="cb-mobile-divider">
|
||||||
{% if not is_shared %}
|
{% if not is_shared %}
|
||||||
|
<a href="/shopping-list?import_recipe_id={{ recipe.id }}" class="cb-mobile-item">
|
||||||
|
<span>🛒</span> <span>Auf Einkaufsliste</span>
|
||||||
|
</a>
|
||||||
<button type="button" onclick="openShareModal()" class="cb-mobile-item">
|
<button type="button" onclick="openShareModal()" class="cb-mobile-item">
|
||||||
<span>🔗</span> <span>Rezept teilen</span>
|
<span>🔗</span> <span>Rezept teilen</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -541,6 +541,9 @@
|
|||||||
<button type="button" onclick="openMealPlanImportModal()" class="cb-nav-btn" title="Zutaten aus Wochenplan holen">
|
<button type="button" onclick="openMealPlanImportModal()" class="cb-nav-btn" title="Zutaten aus Wochenplan holen">
|
||||||
<span>📅</span> <span>Aus Wochenplan holen</span>
|
<span>📅</span> <span>Aus Wochenplan holen</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onclick="openCookbookImportModal()" class="cb-nav-btn" title="Zutaten aus einem Kochbuch-Rezept holen">
|
||||||
|
<span>📖</span> <span>Aus Kochbuch holen</span>
|
||||||
|
</button>
|
||||||
<button type="button" onclick="deleteCheckedItems()" class="cb-nav-btn" title="Erledigte Artikel löschen">
|
<button type="button" onclick="deleteCheckedItems()" class="cb-nav-btn" title="Erledigte Artikel löschen">
|
||||||
<span>🧹</span> <span>Erledigte löschen</span>
|
<span>🧹</span> <span>Erledigte löschen</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -573,6 +576,9 @@
|
|||||||
<button type="button" onclick="handleMobileMenuAction(openMealPlanImportModal)" class="cb-mobile-item">
|
<button type="button" onclick="handleMobileMenuAction(openMealPlanImportModal)" class="cb-mobile-item">
|
||||||
<span>📅</span> <span>Aus Wochenplan holen</span>
|
<span>📅</span> <span>Aus Wochenplan holen</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onclick="handleMobileMenuAction(openCookbookImportModal)" class="cb-mobile-item">
|
||||||
|
<span>📖</span> <span>Aus Kochbuch holen</span>
|
||||||
|
</button>
|
||||||
<button type="button" onclick="handleMobileMenuAction(deleteCheckedItems)" class="cb-mobile-item">
|
<button type="button" onclick="handleMobileMenuAction(deleteCheckedItems)" class="cb-mobile-item">
|
||||||
<span>🧹</span> <span>Erledigte löschen</span>
|
<span>🧹</span> <span>Erledigte löschen</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -616,10 +622,15 @@
|
|||||||
<div class="shop-empty-state" id="empty-state" style="display: none;">
|
<div class="shop-empty-state" id="empty-state" style="display: none;">
|
||||||
<div class="shop-empty-icon">🎉</div>
|
<div class="shop-empty-icon">🎉</div>
|
||||||
<h2 class="shop-empty-title">Alles eingekauft!</h2>
|
<h2 class="shop-empty-title">Alles eingekauft!</h2>
|
||||||
<p class="shop-empty-desc">Deine Einkaufsliste ist leer. Tippe unten rechts auf das <strong>+</strong>, um einen neuen Eintrag zu notieren, oder übernimm Zutaten direkt aus dem Wochenplan.</p>
|
<p class="shop-empty-desc">Deine Einkaufsliste ist leer. Tippe unten rechts auf das <strong>+</strong>, um einen neuen Eintrag zu notieren, oder übernimm Zutaten direkt aus dem Wochenplan oder Kochbuch.</p>
|
||||||
<button type="button" class="cb-btn cb-btn-green" onclick="openMealPlanImportModal()">
|
<div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
|
||||||
<span>📅</span> <span>Aus Wochenplan importieren</span>
|
<button type="button" class="cb-btn cb-btn-green" onclick="openMealPlanImportModal()">
|
||||||
</button>
|
<span>📅</span> <span>Aus Wochenplan holen</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="cb-btn cb-btn-creme" onclick="openCookbookImportModal()" style="border: 1.5px solid var(--shop-green); color: var(--shop-green); font-weight: 700;">
|
||||||
|
<span>📖</span> <span>Aus Kochbuch holen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -816,6 +827,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<!-- MODAL: ZUTATEN AUS KOCHBUCH HOLEN -->
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<div id="modal-import-cookbook" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-import-cookbook')">
|
||||||
|
<div class="cb-modal-card" style="max-width: 600px;">
|
||||||
|
<div class="cb-modal-header">
|
||||||
|
<h3 class="cb-modal-title"><span>📖</span> Zutaten aus Kochbuch holen</h3>
|
||||||
|
<button type="button" class="cb-modal-close" onclick="closeModal('modal-import-cookbook')" title="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="cb-modal-body">
|
||||||
|
<div id="cookbook-import-body" style="min-height: 120px;">
|
||||||
|
<!-- Dynamischer Inhalt -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-btn-row">
|
||||||
|
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-import-cookbook')">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
<button type="button" class="cb-btn cb-btn-green" id="btn-confirm-import-cookbook" onclick="confirmImportCookbookIngredients()" disabled>
|
||||||
|
Ausgewählte übernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ========================================== -->
|
<!-- ========================================== -->
|
||||||
<!-- MODAL: SCHNELL-REGALWAHL (1-TAP) -->
|
<!-- MODAL: SCHNELL-REGALWAHL (1-TAP) -->
|
||||||
<!-- ========================================== -->
|
<!-- ========================================== -->
|
||||||
@@ -1813,6 +1849,398 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MODAL: ZUTATEN AUS KOCHBUCH HOLEN
|
||||||
|
// ==========================================
|
||||||
|
let allCookbookRecipes = null;
|
||||||
|
let selectedCookbookRecipes = [];
|
||||||
|
|
||||||
|
const CHAPTER_NAMES = {
|
||||||
|
1: "Suppen & Eintöpfe",
|
||||||
|
2: "Traditionell & Basics",
|
||||||
|
3: "Pasta & Nudelgerichte",
|
||||||
|
4: "Kartoffeln",
|
||||||
|
5: "Reis & Getreide",
|
||||||
|
6: "Teig & Gebackenes",
|
||||||
|
7: "Asiatische Küche",
|
||||||
|
8: "Süßspeisen",
|
||||||
|
9: "Desserts",
|
||||||
|
10: "Kuchen & Gebäck"
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanIngredientName(name) {
|
||||||
|
if (!name) return '';
|
||||||
|
let str = name.trim().replace(/^[ \-\*\s]+/, '');
|
||||||
|
str = str.replace(/\s*\([^)]*\)/g, '');
|
||||||
|
if (str.includes(',')) {
|
||||||
|
const parts = str.split(',');
|
||||||
|
if (parts[1].trim().split(/\s+/).length <= 4) {
|
||||||
|
str = parts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
str = str.replace(/[\s.,;:]+$/, '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (str && str[0] === str[0].toLowerCase()) {
|
||||||
|
str = str[0].toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNormalizedIngredientKey(name) {
|
||||||
|
if (!name) return "";
|
||||||
|
let str = name.toLowerCase().replace(/^[ \-\*\s]+/, "").replace(/\s*\([^)]*\)/g, "");
|
||||||
|
if (str.includes(",")) str = str.split(",")[0];
|
||||||
|
str = str.replace(/[\s.,;:]+$/, "").replace(/\s+/g, " ").trim();
|
||||||
|
if (str.endsWith("en") && str.length > 5) str = str.slice(0, -2);
|
||||||
|
else if (str.endsWith("n") && str.length > 4 && !str.endsWith("in")) str = str.slice(0, -1);
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchingOpenShoppingItem(name) {
|
||||||
|
const k = getNormalizedIngredientKey(name);
|
||||||
|
if (!k) return null;
|
||||||
|
return allItems.find(it => !it.checked && getNormalizedIngredientKey(it.item_name) === k) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCookbookImportModal(preselectedRecipeId = null) {
|
||||||
|
openModal('modal-import-cookbook');
|
||||||
|
const body = document.getElementById('cookbook-import-body');
|
||||||
|
const confirmBtn = document.getElementById('btn-confirm-import-cookbook');
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.textContent = 'Ausgewählte übernehmen';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allCookbookRecipes) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 35px 20px; color: #555;">
|
||||||
|
<div class="shop-spinner shop-spinner-green" style="margin-bottom: 12px;"></div>
|
||||||
|
<div style="font-weight: 600; font-size: 0.95rem; color: var(--shop-green);">Rezepte werden geladen...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/recipes');
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Laden der Rezepte');
|
||||||
|
allCookbookRecipes = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Rezepte:', err);
|
||||||
|
body.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 25px; color: #b02a37;">
|
||||||
|
Fehler beim Laden der Rezepte. Bitte stelle sicher, dass du online bist.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preselectedRecipeId) {
|
||||||
|
selectedCookbookRecipes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCookbookImportUI();
|
||||||
|
|
||||||
|
if (preselectedRecipeId) {
|
||||||
|
await addCookbookRecipeToSelection(preselectedRecipeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCookbookImportUI() {
|
||||||
|
const body = document.getElementById('cookbook-import-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
const recipesByChapter = {};
|
||||||
|
allCookbookRecipes.forEach(r => {
|
||||||
|
const ch = r.chapter_number || 1;
|
||||||
|
if (!recipesByChapter[ch]) recipesByChapter[ch] = [];
|
||||||
|
recipesByChapter[ch].push(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedChapters = Object.keys(recipesByChapter).map(Number).sort((a,b) => a - b);
|
||||||
|
|
||||||
|
let optionsHtml = '<option value="">-- Rezept auswählen... --</option>';
|
||||||
|
sortedChapters.forEach(ch => {
|
||||||
|
const chName = CHAPTER_NAMES[ch] || `Kapitel ${ch}`;
|
||||||
|
optionsHtml += `<optgroup label="Kapitel ${ch}: ${escapeAttr(chName)}">`;
|
||||||
|
recipesByChapter[ch].forEach(r => {
|
||||||
|
const sub = r.subtitle ? ` (${r.subtitle})` : '';
|
||||||
|
optionsHtml += `<option value="${r.id}">${escapeHtml(r.title)}${escapeHtml(sub)}</option>`;
|
||||||
|
});
|
||||||
|
optionsHtml += `</optgroup>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<div style="margin-bottom: 14px; background: rgba(30, 77, 59, 0.04); border: 1.5px solid rgba(30, 77, 59, 0.15); border-radius: 8px; padding: 12px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; flex-wrap: wrap; gap: 4px;">
|
||||||
|
<label style="font-family: var(--font-hand); font-size: 1.15rem; font-weight: 700; color: var(--shop-green); margin: 0;">
|
||||||
|
📖 Rezept aus dem Kochbuch wählen:
|
||||||
|
</label>
|
||||||
|
<span style="font-size: 0.8rem; color: #666;">${allCookbookRecipes.length} Rezepte</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px; position: relative;">
|
||||||
|
<input type="text" id="cookbook-search-input" class="cb-input"
|
||||||
|
placeholder="🔍 Tippen zum Suchen (z. B. Chili, Strudel, Suppe...)"
|
||||||
|
oninput="onFilterCookbookSearch(this.value)"
|
||||||
|
autocomplete="off"
|
||||||
|
style="width: 100%; box-sizing: border-box; padding: 8px 12px; font-size: 0.92rem; border-radius: 6px;">
|
||||||
|
<div id="cookbook-search-dropdown-results" style="display: none; max-height: 180px; overflow-y: auto; background: #fff; border: 1.5px solid var(--shop-green); border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 50;"></div>
|
||||||
|
<select id="cookbook-recipe-select" class="cb-select" onchange="onSelectCookbookDropdown(this.value)" style="width: 100%; box-sizing: border-box; padding: 8px 12px; font-size: 0.92rem; border-radius: 6px;">
|
||||||
|
${optionsHtml}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cookbook-selected-recipes-container" style="max-height: 48vh; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding-right: 4px;">
|
||||||
|
${renderSelectedCookbookRecipeCards()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
updateCookbookConfirmBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedCookbookRecipeCards() {
|
||||||
|
if (selectedCookbookRecipes.length === 0) {
|
||||||
|
return `
|
||||||
|
<div style="text-align: center; padding: 25px 15px; color: #666; background: rgba(30, 77, 59, 0.02); border: 1.5px dashed rgba(30, 77, 59, 0.2); border-radius: 8px;">
|
||||||
|
<span style="font-size: 2.2rem; display: block; margin-bottom: 6px;">📖</span>
|
||||||
|
<p style="font-size: 1.05rem; margin: 0 0 6px 0; font-weight: 700; color: var(--shop-green);">Noch kein Rezept ausgewählt</p>
|
||||||
|
<p style="font-size: 0.88rem; margin: 0; color: #777; max-width: 440px; margin-inline: auto;">
|
||||||
|
Wähle oben ein Rezept aus, um dessen Zutaten auf deine Einkaufsliste zu setzen. Mengen von bereits vorhandenen Zutaten werden automatisch addiert – ohne Dopplungen!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedCookbookRecipes.map(r => {
|
||||||
|
const ingredients = r.parsed_ingredients || [];
|
||||||
|
const chName = CHAPTER_NAMES[r.chapter_number] || `Kapitel ${r.chapter_number || 1}`;
|
||||||
|
|
||||||
|
let itemsHtml = '';
|
||||||
|
if (ingredients.length === 0) {
|
||||||
|
itemsHtml = `<div style="color: #888; font-size: 0.88rem; font-style: italic; padding: 6px 0;">Keine Zutaten für dieses Rezept hinterlegt.</div>`;
|
||||||
|
} else {
|
||||||
|
itemsHtml = ingredients.map(ing => {
|
||||||
|
if (ing.is_header) {
|
||||||
|
return `<div style="font-weight: 700; color: var(--shop-green); font-size: 0.85rem; margin-top: 6px; font-family: var(--font-hand);">${escapeHtml(ing.raw)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = cleanIngredientName(ing.name || ing.raw || '');
|
||||||
|
if (!cleanName) return '';
|
||||||
|
|
||||||
|
const amtStr = formatAmount(ing.amount_formatted || ing.amount_str || ing.amount, ing.unit);
|
||||||
|
const match = findMatchingOpenShoppingItem(cleanName);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<label style="display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 0.92rem; cursor: pointer; padding: 4px 6px; border-radius: 4px; background: ${match ? 'rgba(46, 125, 50, 0.06)' : 'transparent'};">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<input type="checkbox" class="cookbook-ing-checkbox" checked
|
||||||
|
data-recipe-id="${r.id}"
|
||||||
|
data-name="${escapeAttr(cleanName)}"
|
||||||
|
data-amount="${escapeAttr(ing.amount_formatted || ing.amount_str || (ing.amount !== null && ing.amount !== undefined ? String(ing.amount) : ''))}"
|
||||||
|
data-unit="${escapeAttr(ing.unit || '')}"
|
||||||
|
data-source="${escapeAttr(r.title)}"
|
||||||
|
onchange="updateCookbookConfirmBtn()">
|
||||||
|
<span>${amtStr ? `<strong>${escapeHtml(amtStr)}</strong> ` : ''}${escapeHtml(cleanName)}</span>
|
||||||
|
</div>
|
||||||
|
${match ? `
|
||||||
|
<span style="font-size: 0.72rem; background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; border-radius: 10px; padding: 1px 7px; font-weight: 600; white-space: nowrap;" title="Bereits auf der Einkaufsliste – Menge wird addiert!">
|
||||||
|
➕ wird addiert (${escapeHtml(match.amount ? match.amount + ' ' + (match.unit || '') : 'auf Liste')})
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="cookbook-recipe-card" data-recipe-id="${r.id}" style="background: rgba(30, 77, 59, 0.05); border: 1.5px solid rgba(30, 77, 59, 0.18); border-radius: 8px; padding: 12px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; gap: 8px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-family: var(--font-hand); font-size: 1.25rem; font-weight: 700; color: var(--shop-green);">
|
||||||
|
🍳 ${escapeHtml(r.title)}
|
||||||
|
</div>
|
||||||
|
${r.subtitle ? `<div style="font-size: 0.8rem; color: #666; font-style: italic;">${escapeHtml(r.subtitle)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 6px; align-items: center;">
|
||||||
|
<span style="font-size: 0.72rem; background: rgba(30, 77, 59, 0.12); color: var(--shop-green); padding: 2px 7px; border-radius: 6px; font-weight: 600;">${escapeHtml(chName)}</span>
|
||||||
|
<button type="button" onclick="removeCookbookRecipeFromSelection(${r.id})" title="Dieses Rezept entfernen" style="background: none; border: none; color: #b02a37; font-size: 1.15rem; cursor: pointer; padding: 0 4px; line-height: 1;">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-bottom: 8px; font-size: 0.82rem;">
|
||||||
|
<button type="button" onclick="toggleCookbookRecipeChecks(${r.id}, true)" style="background: none; border: none; color: var(--shop-green); font-weight: 700; cursor: pointer; text-decoration: underline;">Alle</button>
|
||||||
|
<span style="color: #ccc;">|</span>
|
||||||
|
<button type="button" onclick="toggleCookbookRecipeChecks(${r.id}, false)" style="background: none; border: none; color: #888; font-weight: 600; cursor: pointer; text-decoration: underline;">Keine</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
${itemsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCookbookRecipeToSelection(recipeId) {
|
||||||
|
recipeId = parseInt(recipeId, 10);
|
||||||
|
if (!recipeId) return;
|
||||||
|
|
||||||
|
if (selectedCookbookRecipes.some(r => r.id === recipeId)) {
|
||||||
|
showToast('Rezept bereits in der Auswahl');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/recipes/${recipeId}`);
|
||||||
|
if (!res.ok) throw new Error('Rezept nicht gefunden');
|
||||||
|
const recipeData = await res.json();
|
||||||
|
selectedCookbookRecipes.push(recipeData);
|
||||||
|
|
||||||
|
const container = document.getElementById('cookbook-selected-recipes-container');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = renderSelectedCookbookRecipeCards();
|
||||||
|
}
|
||||||
|
updateCookbookConfirmBtn();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden des Rezepts:', err);
|
||||||
|
showToast('Fehler beim Laden des Rezepts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCookbookRecipeFromSelection(recipeId) {
|
||||||
|
selectedCookbookRecipes = selectedCookbookRecipes.filter(r => r.id !== recipeId);
|
||||||
|
const container = document.getElementById('cookbook-selected-recipes-container');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = renderSelectedCookbookRecipeCards();
|
||||||
|
}
|
||||||
|
updateCookbookConfirmBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCookbookRecipeChecks(recipeId, val) {
|
||||||
|
const cbs = document.querySelectorAll(`.cookbook-ing-checkbox[data-recipe-id="${recipeId}"]`);
|
||||||
|
cbs.forEach(cb => cb.checked = val);
|
||||||
|
updateCookbookConfirmBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectCookbookDropdown(recipeId) {
|
||||||
|
if (!recipeId) return;
|
||||||
|
addCookbookRecipeToSelection(recipeId);
|
||||||
|
const sel = document.getElementById('cookbook-recipe-select');
|
||||||
|
if (sel) sel.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterCookbookSearch(query) {
|
||||||
|
const resultsDiv = document.getElementById('cookbook-search-dropdown-results');
|
||||||
|
if (!resultsDiv || !allCookbookRecipes) return;
|
||||||
|
|
||||||
|
const q = (query || '').toLowerCase().trim();
|
||||||
|
if (!q) {
|
||||||
|
resultsDiv.style.display = 'none';
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = allCookbookRecipes.filter(r => {
|
||||||
|
const titleMatch = (r.title || '').toLowerCase().includes(q);
|
||||||
|
const subMatch = (r.subtitle || '').toLowerCase().includes(q);
|
||||||
|
const ingMatch = (r.ingredients || '').toLowerCase().includes(q);
|
||||||
|
return titleMatch || subMatch || ingMatch;
|
||||||
|
}).slice(0, 10);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
resultsDiv.innerHTML = `<div style="padding: 10px; font-size: 0.88rem; color: #888; text-align: center;">Kein passendes Rezept gefunden</div>`;
|
||||||
|
resultsDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = matches.map(r => {
|
||||||
|
const chName = CHAPTER_NAMES[r.chapter_number] || `Kapitel ${r.chapter_number || 1}`;
|
||||||
|
return `
|
||||||
|
<div onclick="selectSearchRecipeMatch(${r.id})" style="padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; transition: background 0.15s ease;" onmouseover="this.style.background='rgba(30,77,59,0.08)'" onmouseout="this.style.background='transparent'">
|
||||||
|
<div>
|
||||||
|
<strong style="color: var(--shop-green);">${escapeHtml(r.title)}</strong>
|
||||||
|
${r.subtitle ? `<span style="font-size: 0.8rem; color: #666; margin-left: 6px;">${escapeHtml(r.subtitle)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 0.72rem; color: #888;">${escapeHtml(chName)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
resultsDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSearchRecipeMatch(recipeId) {
|
||||||
|
addCookbookRecipeToSelection(recipeId);
|
||||||
|
const searchInput = document.getElementById('cookbook-search-input');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
const resultsDiv = document.getElementById('cookbook-search-dropdown-results');
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.style.display = 'none';
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCookbookConfirmBtn() {
|
||||||
|
const checkedBoxes = Array.from(document.querySelectorAll('.cookbook-ing-checkbox:checked'));
|
||||||
|
const btn = document.getElementById('btn-confirm-import-cookbook');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = checkedBoxes.length === 0;
|
||||||
|
btn.textContent = `Ausgewählte übernehmen (${checkedBoxes.length})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmImportCookbookIngredients() {
|
||||||
|
const cbs = Array.from(document.querySelectorAll('.cookbook-ing-checkbox:checked'));
|
||||||
|
if (cbs.length === 0) return;
|
||||||
|
|
||||||
|
const confirmBtn = document.getElementById('btn-confirm-import-cookbook');
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.innerHTML = '<span class="shop-spinner" style="margin-right: 6px;"></span> <span>Wird übernommen...</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToAdd = cbs.map(cb => ({
|
||||||
|
item_name: cb.dataset.name,
|
||||||
|
amount: cb.dataset.amount || '',
|
||||||
|
unit: cb.dataset.unit || '',
|
||||||
|
source: cb.dataset.source || '',
|
||||||
|
category: null,
|
||||||
|
note: ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/shopping-list/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ items: itemsToAdd })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.items && Array.isArray(d.items)) {
|
||||||
|
allItems = d.items;
|
||||||
|
} else {
|
||||||
|
await refreshDataFromServer();
|
||||||
|
}
|
||||||
|
saveLocalCache(allItems);
|
||||||
|
closeModal('modal-import-cookbook');
|
||||||
|
renderCategoryFilterChips();
|
||||||
|
renderShoppingList();
|
||||||
|
showToast(`✓ ${itemsToAdd.length} Zutaten übernommen (Mengen zusammengeführt)`);
|
||||||
|
} else {
|
||||||
|
showToast('Fehler beim Übernehmen');
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
confirmBtn.textContent = 'Ausgewählte übernehmen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Importieren aus dem Kochbuch:', err);
|
||||||
|
showToast('Fehler beim Übernehmen');
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
confirmBtn.textContent = 'Ausgewählte übernehmen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// MODAL: SUPERMARKT-BEREICHE VERWALTUNG
|
// MODAL: SUPERMARKT-BEREICHE VERWALTUNG
|
||||||
@@ -2108,6 +2536,16 @@
|
|||||||
renderCategoryFilterChips();
|
renderCategoryFilterChips();
|
||||||
renderShoppingList();
|
renderShoppingList();
|
||||||
initWebSocket();
|
initWebSocket();
|
||||||
|
|
||||||
|
// Prüfen, ob ein Rezept per URL-Parameter übergeben wurde
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const impRecipeId = urlParams.get('import_recipe_id');
|
||||||
|
if (impRecipeId) {
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
setTimeout(() => {
|
||||||
|
openCookbookImportModal(parseInt(impRecipeId, 10));
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
fastapi>=0.110.0
|
annotated-doc==0.0.5
|
||||||
uvicorn>=0.29.0
|
annotated-types==0.8.0
|
||||||
jinja2>=3.1.3
|
anyio==4.15.1
|
||||||
python-dotenv>=1.0.1
|
certifi==2026.7.22
|
||||||
pydantic>=2.6.0
|
cffi==2.1.1
|
||||||
python-multipart>=0.0.9
|
click==8.5.0
|
||||||
pymysql>=1.1.0
|
cryptography==50.0.2
|
||||||
cryptography>=42.0.0
|
fastapi==0.141.1
|
||||||
pillow>=10.0.0
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.20
|
||||||
|
Jinja2==3.1.6
|
||||||
|
lxml==6.1.3
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
pillow==12.3.0
|
||||||
|
pycparser==3.11
|
||||||
|
pydantic==2.13.5
|
||||||
|
pydantic_core==2.46.5
|
||||||
|
PyMySQL==1.2.3
|
||||||
|
pypdf==6.20.1
|
||||||
|
python-docx==1.2.0
|
||||||
|
python-dotenv==1.2.3
|
||||||
|
python-multipart==0.0.32
|
||||||
|
python-pptx==1.0.2
|
||||||
|
starlette==1.7.0
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
typing_extensions==4.16.0
|
||||||
|
uvicorn==0.53.0
|
||||||
|
websockets==17.2
|
||||||
|
xlsxwriter==3.2.9
|
||||||
|
|||||||