Initial commit: What To Cook (WTC) v4.4 - Digitales Kochbuch, Wochenmenueplan & Buchdruck
This commit is contained in:
Executable
+1877
File diff suppressed because it is too large
Load Diff
Executable
+227
@@ -0,0 +1,227 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#1e222d">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>What To Cook? – Essens-Zufallsgenerator</title>
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<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>
|
||||
<div class="app-container">
|
||||
|
||||
<!-- Top Header -->
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<span class="brand-icon">🍽️</span>
|
||||
<div>
|
||||
<h1 class="brand-title">What To Cook?</h1>
|
||||
<p class="brand-subtitle">Zufallsgenerator für Essen</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="nav-tabs" role="tablist">
|
||||
<button id="tab-btn-generator" class="nav-tab active" data-tab="generator" role="tab" aria-selected="true">
|
||||
<span class="icon">🎲</span> Generator
|
||||
</button>
|
||||
<a href="/cookbook" class="nav-tab" style="text-decoration: none;">
|
||||
<span class="icon">📖</span> Kochbuch
|
||||
</a>
|
||||
<a href="/cookbook/new" class="nav-tab" style="text-decoration: none;">
|
||||
<span class="icon">➕</span> Rezept
|
||||
</a>
|
||||
<button id="tab-btn-admin" class="nav-tab" data-tab="admin" role="tab" aria-selected="false">
|
||||
<span class="icon">⚙️</span> Gerichte
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="app-main">
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- TAB 1: GENERATOR -->
|
||||
<!-- ========================================== -->
|
||||
<section id="tab-generator" class="tab-panel active" role="tabpanel">
|
||||
|
||||
<!-- Banner / Hint -->
|
||||
<div class="info-card">
|
||||
<div class="info-content">
|
||||
<span class="info-icon">💡</span>
|
||||
<div>
|
||||
<strong>Bis zu 5 Gerichte konfigurieren:</strong>
|
||||
Wähle Kategorie & Aufwand pro Gericht. Nicht benötigte Gerichte einfach frei lassen.
|
||||
</div>
|
||||
</div>
|
||||
<div class="active-counter-badge" id="active-slots-counter">
|
||||
<span id="active-slots-count">2</span> von 5 aktiv
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5 Dish Configuration Slots -->
|
||||
<div class="slots-container" id="slots-container">
|
||||
<!-- Rendered dynamically by app.js (1 to 5) -->
|
||||
</div>
|
||||
|
||||
<!-- Floating / Sticky Action Bar -->
|
||||
<div class="action-bar-wrapper">
|
||||
<button id="btn-roll-all" class="btn btn-primary btn-hero">
|
||||
<span class="btn-icon">🎲</span>
|
||||
<span class="btn-text">Zufallsgenerator starten</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results Section (Anchor / Modal or Inline display) -->
|
||||
<section id="results-section" class="results-section hidden">
|
||||
<div class="results-header">
|
||||
<div class="results-title-group">
|
||||
<h2 class="results-title">Ausgeloste Gerichte</h2>
|
||||
<span class="results-badge" id="results-count-badge">0 Gerichte</span>
|
||||
</div>
|
||||
<button id="btn-reroll-all" class="btn btn-outline btn-sm">
|
||||
<span class="icon">🔄</span> Alle neu würfeln
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="results-list" class="results-grid">
|
||||
<!-- Dynamic Result Cards -->
|
||||
</div>
|
||||
|
||||
<div class="results-footer">
|
||||
<button id="btn-back-to-config" class="btn btn-secondary">
|
||||
✏️ Filter anpassen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- TAB 2: ADMIN / DATABASE -->
|
||||
<!-- ========================================== -->
|
||||
<section id="tab-admin" class="tab-panel" role="tabpanel" hidden>
|
||||
|
||||
<div class="admin-topbar">
|
||||
<div>
|
||||
<h2 class="section-title">Gerichte verwalten</h2>
|
||||
<p class="section-desc" id="admin-stats-summary">Lade Datenbank...</p>
|
||||
</div>
|
||||
<button id="btn-open-add-dish" class="btn btn-primary">
|
||||
<span>➕</span> Neues Gericht
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Admin Filters -->
|
||||
<div class="admin-filters-card">
|
||||
<div class="search-input-wrap">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" id="admin-search-input" placeholder="Gericht nach Namen suchen..." autocomplete="off">
|
||||
<button id="admin-search-clear" class="btn-clear hidden" title="Suche leeren">✕</button>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="form-group flex-1">
|
||||
<label for="admin-filter-category" class="filter-label">Kategorie:</label>
|
||||
<select id="admin-filter-category" class="custom-select">
|
||||
<option value="alle">Alle Kategorien</option>
|
||||
<!-- Filled via JS -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group flex-1">
|
||||
<label for="admin-filter-effort" class="filter-label">Aufwand:</label>
|
||||
<select id="admin-filter-effort" class="custom-select">
|
||||
<option value="alle">Jeder Aufwand</option>
|
||||
<option value="gering">Gering 🟢</option>
|
||||
<option value="mittel">Mittel 🟡</option>
|
||||
<option value="hoch">Hoch 🔴</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dishes Table / Cards for Mobile -->
|
||||
<div class="dishes-list-wrap">
|
||||
<div class="dishes-list-header">
|
||||
<span id="dishes-count-label">Gerichte</span>
|
||||
</div>
|
||||
<div id="admin-dishes-list" class="admin-dishes-list">
|
||||
<div class="loading-spinner">Lade Gerichte...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Dialog: Add / Edit Dish -->
|
||||
<dialog id="dish-modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title" class="modal-title">Neues Gericht anlegen</h3>
|
||||
<button id="btn-close-modal" class="btn-icon-close" aria-label="Schließen">✕</button>
|
||||
</div>
|
||||
<form id="dish-form" method="dialog">
|
||||
<input type="hidden" id="dish-form-id" value="">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dish-name-input" class="form-label">Name des Gerichts *</label>
|
||||
<input type="text" id="dish-name-input" class="text-input" placeholder="z. B. Spaghetti Carbonara" required autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kategorien * <span class="form-label-hint">(Mehrfachauswahl möglich)</span></label>
|
||||
<div class="category-checkbox-group" id="modal-category-group">
|
||||
<!-- Filled via JS as checkbox pills -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Arbeitsaufwand *</label>
|
||||
<div class="effort-radio-group">
|
||||
<label class="effort-radio-label">
|
||||
<input type="radio" name="modal-effort" value="gering" required>
|
||||
<span class="effort-pill effort-gering">Gering 🟢 (≤ 20 Min)</span>
|
||||
</label>
|
||||
<label class="effort-radio-label">
|
||||
<input type="radio" name="modal-effort" value="mittel" checked required>
|
||||
<span class="effort-pill effort-mittel">Mittel 🟡 (~ 45 Min)</span>
|
||||
</label>
|
||||
<label class="effort-radio-label">
|
||||
<input type="radio" name="modal-effort" value="hoch" required>
|
||||
<span class="effort-pill effort-hoch">Hoch 🔴 (> 60 Min)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="btn-cancel-modal" class="btn btn-secondary">Abbrechen</button>
|
||||
<button type="submit" id="btn-save-modal" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Dialog: Delete Confirm -->
|
||||
<dialog id="delete-modal" class="modal">
|
||||
<div class="modal-box modal-box-sm">
|
||||
<h3 class="modal-title">Gericht löschen?</h3>
|
||||
<p id="delete-modal-text" class="modal-text">Möchtest du dieses Gericht wirklich aus der Datenbank entfernen?</p>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-cancel-delete" class="btn btn-secondary">Abbrechen</button>
|
||||
<button id="btn-confirm-delete" class="btn btn-danger">Unwiderruflich löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Anmeldung — WTC Kochbuch</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Lora:ital,wght@0,400;0,600;1,400&family=Playfair+Display:ital,wght@0,600;0,700;0,800;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/cookbook.css?v=4.0">
|
||||
<style>
|
||||
body.login-body {
|
||||
background-color: var(--creme, #faf6ee);
|
||||
background-image:
|
||||
radial-gradient(circle at 100% 0%, rgba(212, 175, 55, 0.08) 0%, transparent 40%),
|
||||
radial-gradient(circle at 0% 100%, rgba(30, 61, 47, 0.08) 0%, transparent 40%),
|
||||
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.015) 2px, rgba(0, 0, 0, 0.015) 4px);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body, 'Lora', Georgia, serif);
|
||||
color: var(--schwarz, #1a1a1a);
|
||||
}
|
||||
.login-card {
|
||||
background: #ffffff;
|
||||
border: 3px solid var(--tannengruen, #1e3d2f);
|
||||
border-radius: 16px;
|
||||
box-shadow: 6px 8px 0px rgba(30, 61, 47, 0.25);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 36px 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.login-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--creme, #faf6ee);
|
||||
border: 2px solid var(--gold, #d4af37);
|
||||
border-radius: 50%;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.06);
|
||||
}
|
||||
.login-title {
|
||||
font-family: var(--font-hand, 'Caveat', cursive);
|
||||
font-size: 2.2rem;
|
||||
color: var(--tannengruen, #1e3d2f);
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.login-subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: #666;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--tannengruen, #1e3d2f);
|
||||
}
|
||||
.form-input {
|
||||
padding: 10px 14px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
background: #fafafa;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--tannengruen, #1e3d2f);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(30, 61, 47, 0.12);
|
||||
}
|
||||
.form-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.88rem;
|
||||
color: #555;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.form-checkbox-row input {
|
||||
accent-color: var(--tannengruen, #1e3d2f);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-btn {
|
||||
background: var(--tannengruen, #1e3d2f);
|
||||
color: var(--creme, #faf6ee);
|
||||
border: 2px solid var(--tannengruen, #1e3d2f);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-hand, 'Caveat', cursive);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 3px 4px 0px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.18s ease;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.login-btn:hover {
|
||||
background: #274f3d;
|
||||
border-color: #274f3d;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 3px 5px 0px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 1px 2px 0px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.login-error {
|
||||
background: #fdf2f2;
|
||||
border: 1px solid #f8b4b4;
|
||||
color: #9b1c1c;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
font-size: 0.82rem;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-card">
|
||||
<div class="login-badge">📖</div>
|
||||
<h1 class="login-title">WTC Kochbuch</h1>
|
||||
<p class="login-subtitle">Privater Bereich • Bitte anmelden</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="login-error">
|
||||
⚠️ {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/login" class="login-form">
|
||||
<input type="hidden" name="next" value="{{ next_url or '/cookbook' }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="username">Benutzername</label>
|
||||
<input class="form-input" type="text" id="username" name="username" value="{{ username or 'admin' }}" required autofocus autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Passwort</label>
|
||||
<input class="form-input" type="password" id="password" name="password" required autocomplete="current-password" placeholder="Passwort eingeben">
|
||||
</div>
|
||||
|
||||
<div class="form-checkbox-row">
|
||||
<input type="checkbox" id="remember" name="remember" value="true" checked>
|
||||
<label for="remember">Angemeldet bleiben (30 Tage)</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn">Anmelden ✨</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
What To Cook? • Privates Rezeptarchiv & Kochbuch
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ recipe.title }} – 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">
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<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 Navigation -->
|
||||
<div class="cb-nav-actions desktop-nav-actions">
|
||||
<a href="/cookbook" class="cb-btn cb-btn-outline">
|
||||
<span>📖</span> <span>Alle Rezepte</span>
|
||||
</a>
|
||||
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-btn cb-btn-green">
|
||||
<span>✏️</span> <span>Bearbeiten</span>
|
||||
</a>
|
||||
<button onclick="window.print()" class="cb-btn cb-btn-outline" title="Rezeptseite drucken">
|
||||
<span>🖨️</span> <span>Drucken</span>
|
||||
</button>
|
||||
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
|
||||
<span>🚪</span> <span>Abmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Kebab (3-Punkte) Menü -->
|
||||
<div class="mobile-nav-wrapper">
|
||||
<button type="button" class="cb-kebab-btn" onclick="toggleMobileMenu(event)" aria-label="Menü öffnen" title="Menü">
|
||||
<span>⋮</span>
|
||||
</button>
|
||||
<div id="cb-mobile-menu" class="cb-mobile-dropdown">
|
||||
<a href="/cookbook" class="cb-mobile-item">
|
||||
<span>📖</span> <span>Zum Kochbuch</span>
|
||||
</a>
|
||||
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-mobile-item">
|
||||
<span>✏️</span> <span>Rezept bearbeiten</span>
|
||||
</a>
|
||||
<button type="button" onclick="window.print()" class="cb-mobile-item">
|
||||
<span>🖨️</span> <span>Drucken & PDF</span>
|
||||
</button>
|
||||
<hr class="cb-mobile-divider">
|
||||
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
|
||||
<span>🚪</span> <span>Abmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="book-wrapper">
|
||||
|
||||
<!-- EINHEITLICHE REZEPTSEITE -->
|
||||
<article class="page recipe-page">
|
||||
|
||||
<!-- Header (Ohne Kapitelangabe & ohne Quelle im Titel) -->
|
||||
<header class="recipe-header">
|
||||
<h2 class="recipe-title">{{ recipe.title }}</h2>
|
||||
|
||||
<div class="recipe-meta-row">
|
||||
{% if recipe.effort %}
|
||||
<div class="recipe-meta-item">
|
||||
<span>⏱️</span>
|
||||
<span>Aufwand:
|
||||
{% if recipe.effort == 'gering' %}Gering 🟢{% elif recipe.effort == 'hoch' %}Hoch 🔴{% else %}Mittel 🟡{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if recipe.prep_time %}
|
||||
<div class="recipe-meta-item">
|
||||
<span>🔪</span>
|
||||
<span>Zubereitung: {{ recipe.prep_time }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if recipe.cook_time %}
|
||||
<div class="recipe-meta-item">
|
||||
<span>🍳</span>
|
||||
<span>Kochen/Backen: {{ recipe.cook_time }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if recipe.portions %}
|
||||
<div class="recipe-meta-item">
|
||||
<span>👥</span>
|
||||
<span>{{ recipe.portions }} Portionen</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if recipe.source and recipe.source.strip() %}
|
||||
<div class="recipe-meta-item">
|
||||
<span>📖</span>
|
||||
<span>Quelle: {{ recipe.source.strip() }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2-Spalten Layout -->
|
||||
<div class="recipe-grid">
|
||||
|
||||
<!-- Linke Spalte: Foto + Zutaten -->
|
||||
<aside class="recipe-side">
|
||||
<div class="recipe-photo-box">
|
||||
{% if recipe.image_url %}
|
||||
<img src="{{ recipe.image_url }}" alt="{{ recipe.title }}">
|
||||
{% else %}
|
||||
<div class="recipe-photo-empty">
|
||||
<span>📸</span>
|
||||
<span>Kein Foto hinterlegt</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Zutatenleiste ohne Portionsangabe (da schon in Kopfzeile) -->
|
||||
<div class="ingredients-box {% if recipe.has_portion_table %}has-portion-matrix{% endif %}">
|
||||
<h4 class="section-hand-title">Zutaten</h4>
|
||||
|
||||
{% if recipe.has_portion_table and recipe.portion_matrix %}
|
||||
<!-- Tabelle für 2/4/6/8 Portionen (z. B. Waffeln / Pancakes) -->
|
||||
<table class="portion-matrix-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="matrix-th-ing">Zutat</th>
|
||||
<th class="matrix-th-p">2 P.</th>
|
||||
<th class="matrix-th-p">4 P.</th>
|
||||
<th class="matrix-th-p">6 P.</th>
|
||||
<th class="matrix-th-p">8 P.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in recipe.portion_matrix %}
|
||||
{% if row.is_header %}
|
||||
<tr class="matrix-header-row">
|
||||
<th colspan="5">{{ row.text }}</th>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="matrix-row">
|
||||
<td class="matrix-td-name">{{ row.name }}</td>
|
||||
<td class="matrix-td-amt">{{ row.amounts[2] }}</td>
|
||||
<td class="matrix-td-amt highlight">{{ row.amounts[4] }}</td>
|
||||
<td class="matrix-td-amt">{{ row.amounts[6] }}</td>
|
||||
<td class="matrix-td-amt">{{ row.amounts[8] }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<!-- Normale kompakte Zutatenliste (kein Abschneiden im Druck) -->
|
||||
<ul class="ingredients-list">
|
||||
{% for line in recipe.ingredients.split('\n') %}
|
||||
{% if line.strip() %}
|
||||
{% set raw_line = line.strip() %}
|
||||
{% if raw_line.endswith(':') or raw_line.lower().startswith('für ') %}
|
||||
<li class="ing-subheading">{{ raw_line }}</li>
|
||||
{% else %}
|
||||
<li>{{ raw_line }}</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Rechte Spalte: Zubereitung + Notizen + Quelle -->
|
||||
<section class="recipe-main">
|
||||
<div>
|
||||
<h4 class="section-hand-title">Zubereitung</h4>
|
||||
<div class="instructions-steps">
|
||||
{% for step in recipe.instructions.split('\n') %}
|
||||
{% if step.strip() %}
|
||||
<div class="step-card">
|
||||
<span class="step-badge">{{ loop.index }}</span>
|
||||
<div class="step-text">{{ step.strip() }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if recipe.notes and recipe.notes.strip() %}
|
||||
<div class="notes-box">
|
||||
<div class="notes-box-title">💡 Tipp & Notiz</div>
|
||||
<div>{{ recipe.notes.strip() }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="notes-box notes-box-empty print-only">
|
||||
<div class="notes-box-title">💡 Eigene Notizen & Tipps</div>
|
||||
<div class="notes-blank-lines">
|
||||
<div class="notes-blank-line"></div>
|
||||
<div class="notes-blank-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function toggleMobileMenu(e) {
|
||||
if (e) e.stopPropagation();
|
||||
const menu = document.getElementById('cb-mobile-menu');
|
||||
if (menu) menu.classList.toggle('is-open');
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.mobile-nav-wrapper')) {
|
||||
const menu = document.getElementById('cb-mobile-menu');
|
||||
if (menu) menu.classList.remove('is-open');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+357
@@ -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 150g Schinken oder Speck 4 Eigelb 50g Parmesan gerieben 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. Den Schinken in Streifen schneiden und knusprig anbraten. Die Eigelbe mit dem geriebenen Käse und Pfeffer cremig rühren. 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>
|
||||
Reference in New Issue
Block a user