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>
|
||||
</button>
|
||||
{% 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">
|
||||
<span>📖</span> <span>Alle Rezepte</span>
|
||||
</a>
|
||||
@@ -65,6 +68,9 @@
|
||||
</button>
|
||||
<hr class="cb-mobile-divider">
|
||||
{% 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">
|
||||
<span>🔗</span> <span>Rezept teilen</span>
|
||||
</button>
|
||||
|
||||
@@ -541,6 +541,9 @@
|
||||
<button type="button" onclick="openMealPlanImportModal()" class="cb-nav-btn" title="Zutaten aus Wochenplan holen">
|
||||
<span>📅</span> <span>Aus Wochenplan holen</span>
|
||||
</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">
|
||||
<span>🧹</span> <span>Erledigte löschen</span>
|
||||
</button>
|
||||
@@ -573,6 +576,9 @@
|
||||
<button type="button" onclick="handleMobileMenuAction(openMealPlanImportModal)" class="cb-mobile-item">
|
||||
<span>📅</span> <span>Aus Wochenplan holen</span>
|
||||
</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">
|
||||
<span>🧹</span> <span>Erledigte löschen</span>
|
||||
</button>
|
||||
@@ -616,10 +622,15 @@
|
||||
<div class="shop-empty-state" id="empty-state" style="display: none;">
|
||||
<div class="shop-empty-icon">🎉</div>
|
||||
<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>
|
||||
<button type="button" class="cb-btn cb-btn-green" onclick="openMealPlanImportModal()">
|
||||
<span>📅</span> <span>Aus Wochenplan importieren</span>
|
||||
</button>
|
||||
<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>
|
||||
<div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
|
||||
<button type="button" class="cb-btn cb-btn-green" onclick="openMealPlanImportModal()">
|
||||
<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>
|
||||
</main>
|
||||
|
||||
@@ -816,6 +827,31 @@
|
||||
</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) -->
|
||||
<!-- ========================================== -->
|
||||
@@ -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
|
||||
@@ -2108,6 +2536,16 @@
|
||||
renderCategoryFilterChips();
|
||||
renderShoppingList();
|
||||
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>
|
||||
</body>
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn>=0.29.0
|
||||
jinja2>=3.1.3
|
||||
python-dotenv>=1.0.1
|
||||
pydantic>=2.6.0
|
||||
python-multipart>=0.0.9
|
||||
pymysql>=1.1.0
|
||||
cryptography>=42.0.0
|
||||
pillow>=10.0.0
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
certifi==2026.7.22
|
||||
cffi==2.1.1
|
||||
click==8.5.0
|
||||
cryptography==50.0.2
|
||||
fastapi==0.141.1
|
||||
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
|
||||
|
||||