Initial commit: What To Cook (WTC) v4.4 - Digitales Kochbuch, Wochenmenueplan & Buchdruck

This commit is contained in:
2026-10-14 20:24:37 +02:00
commit c01d2d7639
171 changed files with 14073 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if recipe %}Rezept bearbeiten{% else %}Neues Rezept eintragen{% endif %} – Mein Kochbuch</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.4">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📝</text></svg>">
</head>
<body class="cookbook-body">
<!-- Header -->
<header class="cb-nav">
<a href="/cookbook" class="cb-nav-brand">
<span class="cb-brand-icon">📖</span>
<h1 class="cb-nav-title">Kochbuch</h1>
</a>
<!-- Desktop Actions -->
<div class="cb-nav-actions desktop-nav-actions">
<a href="/cookbook" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme);">
<span>📖</span> <span>Zum Kochbuch</span>
</a>
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
<!-- Mobile 3-Dots Kebab Menu -->
<div class="mobile-nav-wrapper">
<button type="button" class="cb-kebab-btn" id="mobileMenuBtn" aria-label="Menü öffnen" onclick="toggleMobileMenu()">
⋮
</button>
<div class="cb-mobile-dropdown" id="mobileDropdown">
<a href="/cookbook" class="cb-mobile-item">
<span>📖</span> Zum Kochbuch
</a>
<div class="cb-mobile-divider"></div>
<a href="/logout" class="cb-mobile-item" style="color: #c53030;">
<span>🚪</span> Abmelden
</a>
</div>
</div>
</header>
<main class="book-wrapper" style="max-width: 780px;">
<div class="form-card">
<h2 class="form-title">
{% if recipe %}✏️ Rezept bearbeiten{% else %}➕ Neues Rezept eintragen{% endif %}
</h2>
<p class="form-subtitle">
Trage dein Lieblingsrezept ein – mit Foto, Zutaten und Zubereitungsschritten.
</p>
<form id="recipe-form" enctype="multipart/form-data">
{% if recipe %}
<input type="hidden" id="recipe-id" value="{{ recipe.id }}">
{% endif %}
<!-- Titel -->
<div class="cb-form-group">
<label for="title" class="cb-label">Name des Rezepts *</label>
<input type="text" id="title" name="title" class="cb-input"
value="{{ recipe.title if recipe else '' }}"
placeholder="z. B. Klassische Spaghetti Carbonara" required autocomplete="off">
</div>
<!-- Kategorie / Kapitel -->
<div class="cb-form-group">
<label for="chapter_number" class="cb-label">Kategorie / Kapitel *</label>
<select id="chapter_number" name="chapter_number" class="cb-select" required>
<option value="" disabled {% if not recipe and not selected_chapter %}selected{% endif %}>Kapitel wählen...</option>
{% for chap in chapters %}
<option value="{{ chap.number }}"
{% if (recipe and recipe.chapter_number == chap.number) or (not recipe and selected_chapter == chap.number) %}selected{% endif %}>
Kapitel {{ chap.number }}: {{ chap.title }}
</option>
{% endfor %}
</select>
</div>
<!-- Metadaten: Aufwand, Zeiten, Portionen -->
<div class="cb-row">
<div class="cb-col">
<div class="cb-form-group">
<label for="effort" class="cb-label">Arbeitsaufwand *</label>
<select id="effort" name="effort" class="cb-select" required>
<option value="gering" {% if recipe and recipe.effort == 'gering' %}selected{% endif %}>Gering 🟢 (≤ 20 Min)</option>
<option value="mittel" {% if not recipe or recipe.effort == 'mittel' %}selected{% endif %}>Mittel 🟡 (~ 45 Min)</option>
<option value="hoch" {% if recipe and recipe.effort == 'hoch' %}selected{% endif %}>Hoch 🔴 (> 60 Min)</option>
</select>
</div>
</div>
<div class="cb-col">
<div class="cb-form-group">
<label for="portions" class="cb-label">Portionen</label>
<input type="number" id="portions" name="portions" class="cb-input"
value="{{ recipe.portions if recipe else 4 }}" min="1" max="50">
</div>
</div>
</div>
<div class="cb-row">
<div class="cb-col">
<div class="cb-form-group">
<label for="prep_time" class="cb-label">Zubereitungszeit</label>
<input type="text" id="prep_time" name="prep_time" class="cb-input"
value="{{ recipe.prep_time if recipe else '' }}" placeholder="z. B. 15 Min">
</div>
</div>
<div class="cb-col">
<div class="cb-form-group">
<label for="cook_time" class="cb-label">Koch- / Backzeit</label>
<input type="text" id="cook_time" name="cook_time" class="cb-input"
value="{{ recipe.cook_time if recipe else '' }}" placeholder="z. B. 30 Min">
</div>
</div>
</div>
<!-- Checkbox: Multi-Portionen-Tabelle -->
<div class="cb-form-group" style="margin-top: 4px; margin-bottom: 20px; background: rgba(44, 74, 52, 0.05); padding: 10px 14px; border-radius: 8px; border: 1px dashed var(--tannengruen);">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 0.95rem; font-weight: 600; color: var(--dunkelgrau); margin-bottom: 0;">
<input type="checkbox" id="has_portion_table" name="has_portion_table" value="1"
{% if recipe and recipe.has_portion_table %}checked{% endif %}
style="width: 18px; height: 18px; accent-color: var(--tannengruen);">
<span>🔢 Zutatentabelle für 2 / 4 / 6 / 8 Portionen berechnen (z. B. für Waffeln, Pfannkuchen, Teige)</span>
</label>
</div>
<!-- Foto-Upload -->
<div class="cb-form-group">
<label class="cb-label">Foto des Gerichts 📸</label>
<div class="upload-dropzone" id="dropzone" onclick="document.getElementById('image-upload').click();">
<span style="font-size: 2.2rem;">📷</span>
<p style="font-family: var(--font-hand); font-size: 1.15rem; margin: 8px 0 4px 0; color: var(--tannengruen);">
Hier klicken, um ein Foto auszuwählen oder mit dem Smartphone aufzunehmen
</p>
<span style="font-size: 0.85rem; color: #777;">(JPG, PNG, WebP)</span>
<input type="file" id="image-upload" name="image_file" accept="image/*" style="display: none;">
</div>
<div class="preview-container" id="preview-box" {% if recipe and recipe.image_url %}style="display: block;"{% endif %}>
<img id="image-preview" src="{{ recipe.image_url if recipe and recipe.image_url else '' }}" alt="Vorschau">
</div>
</div>
<!-- Zutaten -->
<div class="cb-form-group">
<label for="ingredients" class="cb-label">Zutaten * (eine Zutat pro Zeile)</label>
<textarea id="ingredients" name="ingredients" class="cb-textarea" rows="8"
placeholder="500g Pasta&#10;150g Schinken oder Speck&#10;4 Eigelb&#10;50g Parmesan gerieben&#10;Salz und frisch gemahlener Pfeffer" required>{{ recipe.ingredients if recipe else '' }}</textarea>
</div>
<!-- Zubereitungsschritte -->
<div class="cb-form-group">
<label for="instructions" class="cb-label">Zubereitung * (Absätze werden nummeriert)</label>
<textarea id="instructions" name="instructions" class="cb-textarea" rows="10"
placeholder="Reichlich Salzwasser in einem großen Topf zum Kochen bringen.&#10;Den Schinken in Streifen schneiden und knusprig anbraten.&#10;Die Eigelbe mit dem geriebenen Käse und Pfeffer cremig rühren.&#10;Pasta al dente kochen und mit etwas Nudelwasser vermengen..." required>{{ recipe.instructions if recipe else '' }}</textarea>
</div>
<!-- Notizen & Tipps -->
<div class="cb-form-group">
<label for="notes" class="cb-label">💡 Tipps, Notizen & Kniffe (optional)</label>
<textarea id="notes" name="notes" class="cb-textarea" rows="3"
placeholder="Wichtig: Den Herd vor dem Zugeben der Eier-Käse-Mischung ausschalten, damit das Ei nicht stockt!">{{ recipe.notes if recipe else '' }}</textarea>
</div>
<!-- Credits / Copyright / Quelle -->
<div class="cb-form-group">
<label for="source" class="cb-label">© Quelle / Credits / Copyright (optional)</label>
<input type="text" id="source" name="source" class="cb-input"
value="{{ recipe.source if recipe and recipe.source else '' }}"
placeholder="z. B. Von Oma Anna, Chefkoch.de, Jamie Oliver, Familienrezept">
</div>
<!-- Checkbox: Auch als Zufallsgenerator-Gericht anlegen -->
{% if not recipe %}
<div class="cb-form-group" style="margin-top: 10px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-family: var(--font-hand); font-size: 1.1rem;">
<input type="checkbox" id="add_to_dishes" name="add_to_dishes" value="true" checked style="width: 20px; height: 20px; accent-color: var(--tannengruen);">
<span>Auch als Gericht für den Zufallsgenerator hinzufügen</span>
</label>
</div>
{% endif %}
<!-- Error Feedback Banner -->
<div id="form-error-banner" style="display: none; background: #fff0f0; border: 2px solid #e53e3e; color: #9b1c1c; padding: 12px 16px; border-radius: 8px; margin-top: 15px; font-weight: 600;"></div>
<!-- Submit Button -->
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
<a href="/cookbook" class="cb-btn cb-btn-outline" style="color: var(--schwarz); border-color: rgba(34,34,34,0.3);">
Abbrechen
</a>
<button type="submit" id="btn-submit" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme); font-size: 1.15rem; padding: 12px 24px;">
<span>💾</span> <span>Rezept speichern</span>
</button>
</div>
</form>
</div>
</main>
<script>
// Live Image Preview & Compression Helper
const fileInput = document.getElementById('image-upload');
const previewBox = document.getElementById('preview-box');
const previewImg = document.getElementById('image-preview');
const errorBanner = document.getElementById('form-error-banner');
let preparedImageFile = null;
fileInput.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
// Vorschau anzeigen
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
previewBox.style.display = 'block';
};
reader.readAsDataURL(file);
// Foto für schnellen Upload komprimieren (max 1600px Breite/Höhe, JPEG 85%)
try {
preparedImageFile = await compressImageFile(file, 1600, 1600, 0.85);
} catch (err) {
console.warn('Komprimierung fehlgeschlagen, Originaldatei wird verwendet:', err);
preparedImageFile = file;
}
});
function compressImageFile(file, maxWidth, maxHeight, quality) {
return new Promise((resolve) => {
// Bei SVG oder WebP/GIF direkt original belassen
if (file.type === 'image/svg+xml' || file.type === 'image/gif') {
return resolve(file);
}
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.width;
let h = img.height;
if (w > maxWidth || h > maxHeight) {
if (w > h) {
h = Math.round((h * maxWidth) / w);
w = maxWidth;
} else {
w = Math.round((w * maxHeight) / h);
h = maxHeight;
}
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob((blob) => {
if (!blob) return resolve(file);
const safeName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
resolve(new File([blob], safeName, { type: "image/jpeg" }));
}, 'image/jpeg', quality);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(file);
};
img.src = url;
});
}
// Form Submit (AJAX multipart)
const form = document.getElementById('recipe-form');
const btnSubmit = document.getElementById('btn-submit');
const recipeIdEl = document.getElementById('recipe-id');
form.addEventListener('submit', async function(e) {
e.preventDefault();
errorBanner.style.display = 'none';
errorBanner.textContent = '';
btnSubmit.disabled = true;
btnSubmit.innerHTML = '<span>⏳</span> Speichern...';
const formData = new FormData(form);
// Wenn ein Bild komprimiert vorliegt, dieses im FormData setzen
if (preparedImageFile) {
formData.set('image_file', preparedImageFile);
} else if (fileInput.files.length === 0) {
formData.delete('image_file');
}
// Checkbox Wert explizit absichern
const addDishCb = document.getElementById('add_to_dishes');
if (addDishCb) {
formData.set('add_to_dishes', addDishCb.checked ? 'true' : 'false');
}
const hasPortionTableCb = document.getElementById('has_portion_table');
if (hasPortionTableCb) {
formData.set('has_portion_table', hasPortionTableCb.checked ? '1' : '0');
}
const isEdit = !!recipeIdEl;
const url = isEdit ? `/api/recipes/${recipeIdEl.value}` : '/api/recipes';
const method = isEdit ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method: method,
body: formData
});
let data;
const textResponse = await res.text();
try {
data = JSON.parse(textResponse);
} catch (e) {
throw new Error(`Serverfehler (${res.status}): ${textResponse.slice(0, 150)}`);
}
if (!res.ok) {
const detailMsg = (typeof data.detail === 'string')
? data.detail
: (Array.isArray(data.detail) ? data.detail.map(d => d.msg || d).join(', ') : 'Fehler beim Speichern');
throw new Error(detailMsg);
}
const recipeId = data.recipe ? data.recipe.id : (recipeIdEl ? recipeIdEl.value : '');
window.location.href = `/cookbook#rezept-${recipeId}`;
} catch (err) {
console.error('Fehler beim Speichern:', err);
errorBanner.textContent = `❌ ${err.message}`;
errorBanner.style.display = 'block';
btnSubmit.disabled = false;
btnSubmit.innerHTML = '<span>💾</span> Rezept speichern';
}
});
// Mobile Kebab Menu Functions
function toggleMobileMenu() {
const dd = document.getElementById('mobileDropdown');
if (dd) dd.classList.toggle('show');
}
document.addEventListener('click', function(e) {
const wrap = document.querySelector('.mobile-nav-wrapper');
if (wrap && !wrap.contains(e.target)) {
const dd = document.getElementById('mobileDropdown');
if (dd) dd.classList.remove('show');
}
});
</script>
</body>
</html>