Files
cookbook/app/templates/shopping_list.html
T

2553 lines
99 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">
<title>Einkaufsliste – What To Cook?</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1E4D3B">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="What To Cook?">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/cookbook.css?v=4.6.7">
<style>
:root {
--shop-green: var(--tannengruen, #1E4D3B);
--shop-creme: var(--creme, #FAF6F0);
--shop-schwarz: var(--schwarz, #222222);
--shop-yellow: #FFF9D2;
--shop-red: #D9534F;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body.shopping-app-body {
margin: 0;
padding: 0;
min-height: 100vh;
min-height: 100dvh;
background-color: var(--shop-creme);
background-image: radial-gradient(rgba(30, 77, 59, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
color: var(--shop-schwarz);
width: 100%;
}
/* Fester Container für Header + Filterleiste (kleben gemeinsam oben) */
.cb-sticky-header-container {
position: sticky;
top: 0;
z-index: 1000;
width: 100%;
background: var(--shop-green);
}
.cb-nav-btn.is-active {
background: rgba(255, 255, 255, 0.28) !important;
border-color: rgba(255, 255, 255, 0.6) !important;
color: #FFFFFF !important;
font-weight: 700;
}
/* Live Online Badge in Navbar */
.cb-online-pill {
font-size: 0.72rem;
font-weight: 700;
padding: 2px 7px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
color: var(--shop-creme);
border: 1px solid rgba(255, 255, 255, 0.35);
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
user-select: none;
}
.cb-online-pill.is-offline {
background: rgba(217, 83, 79, 0.4);
border-color: #D9534F;
color: #FFF;
}
/* Lade-Spinner Animationen */
@keyframes shopSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.shop-spinner {
display: inline-block;
width: 15px;
height: 15px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
border-top-color: #ffffff;
animation: shopSpin 0.7s linear infinite;
vertical-align: middle;
}
.shop-spinner-green {
display: inline-block;
width: 28px;
height: 28px;
border: 3px solid rgba(30, 77, 59, 0.2);
border-radius: 50%;
border-top-color: var(--shop-green);
animation: shopSpin 0.7s linear infinite;
vertical-align: middle;
}
/* Hauptinhalt Container */
.shop-main-container {
max-width: 820px;
width: 100%;
margin: 0 auto;
padding: 14px 12px 100px 12px;
}
/* Kategorie Sektionen (kompakter & dezenter) */
.shop-cat-section {
margin-bottom: 14px;
}
.shop-cat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
margin-bottom: 5px;
background: rgba(30, 77, 59, 0.08);
border-left: 3.5px solid var(--shop-green);
border-radius: 4px 6px 6px 4px;
}
.shop-cat-title {
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 1.15rem;
font-weight: 700;
color: var(--shop-green);
margin: 0;
display: flex;
align-items: center;
gap: 6px;
line-height: 1.1;
}
.shop-cat-count {
font-size: 0.74rem;
font-weight: 700;
color: rgba(34, 34, 34, 0.65);
background: rgba(255, 255, 255, 0.85);
padding: 1px 6px;
border-radius: 8px;
border: 1px solid rgba(34, 34, 34, 0.15);
}
/* Listen-Einträge: Kompakter, lesbar, volle Breite */
.shop-items-list {
display: flex;
flex-direction: column;
gap: 5px;
list-style: none;
padding: 0;
margin: 0;
}
.shop-item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 11px;
background: #FFFFFF;
border: 1.5px solid rgba(34, 34, 34, 0.15);
border-radius: 8px;
box-shadow: 1px 2px 0px rgba(30, 77, 59, 0.06);
transition: background-color 0.15s, opacity 0.15s, transform 0.12s;
position: relative;
}
.shop-item-row:active {
transform: scale(0.995);
}
.shop-item-row.is-checked {
background: rgba(250, 246, 240, 0.65);
border-color: rgba(34, 34, 34, 0.1);
opacity: 0.68;
}
/* Checkbox */
.shop-checkbox-wrap {
flex-shrink: 0;
display: flex;
align-items: center;
cursor: pointer;
}
.shop-checkbox-input {
position: absolute;
opacity: 0;
cursor: pointer;
width: 0;
height: 0;
}
.shop-custom-check {
width: 20px;
height: 20px;
border-radius: 5px;
border: 2px solid var(--shop-green);
background: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
color: transparent;
font-size: 0.85rem;
font-weight: 900;
}
.shop-item-row.is-checked .shop-custom-check {
background: var(--shop-green);
color: #FFFFFF;
border-color: var(--shop-green);
}
/* Textbereich (Klick öffnet Bearbeiten-Modal) */
.shop-item-content {
flex: 1 1 auto;
min-width: 0;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.shop-item-title-row {
display: flex;
align-items: baseline;
gap: 7px;
flex-wrap: wrap;
word-break: break-word;
}
.shop-item-name {
font-size: 0.94rem;
font-weight: 600;
color: var(--shop-schwarz);
line-height: 1.25;
}
.shop-item-row.is-checked .shop-item-name {
text-decoration: line-through;
color: rgba(34, 34, 34, 0.5);
}
.shop-item-amount {
font-size: 0.84rem;
font-weight: 700;
color: var(--shop-green);
background: rgba(30, 77, 59, 0.1);
padding: 1px 6px;
border-radius: 5px;
white-space: nowrap;
}
.shop-item-row.is-checked .shop-item-amount {
color: rgba(30, 77, 59, 0.5);
background: rgba(30, 77, 59, 0.05);
}
/* Laufweg Schrittnummer & Badges */
.shop-cat-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--shop-green);
color: #FFFFFF;
font-family: var(--font-sans);
font-size: 0.72rem;
font-weight: 800;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: 10px;
margin-right: 4px;
flex-shrink: 0;
}
.shop-badge-shelf {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.72rem;
font-weight: 600;
color: var(--shop-green);
background: rgba(30, 77, 59, 0.08);
border: 1px solid rgba(30, 77, 59, 0.2);
border-radius: 4px;
padding: 1px 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
font-family: inherit;
}
.shop-badge-shelf:hover, .shop-badge-shelf:active {
background: rgba(30, 77, 59, 0.18);
border-color: var(--shop-green);
}
.shop-item-row.is-checked .shop-badge-shelf {
opacity: 0.6;
}
.quick-shelf-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
background: #FFFFFF;
border: 1.5px solid rgba(34, 34, 34, 0.15);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
color: var(--shop-schwarz);
transition: background-color 0.15s, border-color 0.15s;
width: 100%;
}
.quick-shelf-btn:hover, .quick-shelf-btn:active {
background: rgba(30, 77, 59, 0.08);
border-color: var(--shop-green);
}
.quick-shelf-btn.is-current {
background: rgba(30, 77, 59, 0.12);
border-color: var(--shop-green);
font-weight: 700;
}
.shop-preset-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.shop-preset-btn {
flex: 1 1 180px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
font-size: 0.85rem;
font-weight: 700;
border-radius: 6px;
border: 1.5px solid var(--shop-green);
background: #FFFFFF;
color: var(--shop-green);
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.shop-preset-btn:hover, .shop-preset-btn:active {
background: var(--shop-green);
color: #FFFFFF;
}
/* Badge nur für persönliche Notiz */
.shop-item-badges {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
margin-top: 2px;
}
.shop-badge-note {
font-size: 0.74rem;
color: #7a5a00;
background: var(--shop-yellow);
border: 1px solid #d4c165;
padding: 1px 5px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
}
/* Löschen-Icon am rechten Rand */
.shop-item-delete {
flex-shrink: 0;
background: none;
border: none;
font-size: 1rem;
color: rgba(34, 34, 34, 0.35);
cursor: pointer;
padding: 5px 6px;
border-radius: 6px;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.shop-item-delete:hover,
.shop-item-delete:active {
color: var(--shop-red);
background: rgba(217, 83, 79, 0.1);
}
/* Modal Form: Labels immer direkt über Input-Feld */
.modal-form-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-form-grid > div {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.modal-form-grid .cb-label {
display: block;
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 1.15rem;
font-weight: 700;
color: var(--shop-schwarz);
margin: 0 0 3px 0;
text-align: left;
}
.modal-form-grid .cb-input,
.modal-form-grid .cb-select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
font-size: 0.95rem;
}
/* Floating Action Button (FAB '+') */
.cb-fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 58px;
height: 58px;
border-radius: 50%;
background: var(--shop-green);
color: var(--shop-creme);
border: 2.5px solid var(--shop-schwarz);
box-shadow: 3px 5px 12px rgba(0, 0, 0, 0.35);
font-size: 2.2rem;
font-family: var(--font-hand, 'Caveat', cursive);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 950;
transition: all 0.18s ease;
line-height: 1;
padding-bottom: 4px;
}
.cb-fab:hover {
transform: scale(1.08) translateY(-2px);
background: #143729;
}
.cb-fab:active {
transform: scale(0.95);
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.3);
}
/* Leerer Zustand */
.shop-empty-state {
text-align: center;
padding: 50px 20px;
background: rgba(255, 255, 255, 0.85);
border: 2px dashed rgba(30, 77, 59, 0.3);
border-radius: 12px;
margin: 30px auto;
max-width: 480px;
}
.shop-empty-icon {
font-size: 3.5rem;
margin-bottom: 12px;
}
.shop-empty-title {
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 2.2rem;
color: var(--shop-green);
margin: 0 0 8px 0;
}
.shop-empty-desc {
font-size: 0.98rem;
color: #555;
margin: 0 0 20px 0;
line-height: 1.4;
}
/* Modal Form Ergänzungen */
.modal-form-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-btn-row {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
/* Toast Meldung */
.shop-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--shop-green);
color: #FFFFFF;
padding: 10px 20px;
border-radius: 30px;
font-weight: 700;
font-size: 0.95rem;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
z-index: 3000;
transition: transform 0.25s ease-out;
pointer-events: none;
}
.shop-toast.show {
transform: translateX(-50%) translateY(0);
}
/* Safe Area Inset für Mobile */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.cb-fab {
bottom: calc(24px + env(safe-area-inset-bottom));
}
.shop-main-container {
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
}
</style>
</head>
<body class="shopping-app-body">
<!-- ============================================================== -->
<!-- FIXIERTER KOPFBEREICH: HEADER & KATEGORIE-FILTERLEISTE GEMEINSAM -->
<!-- ============================================================== -->
<div class="cb-sticky-header-container">
<header class="cb-nav">
<a href="/shopping-list" class="cb-nav-brand" title="Einkaufsliste neu laden">
<span class="cb-brand-icon">🛒</span>
<h1 class="cb-nav-title">Einkaufsliste</h1>
</a>
<!-- Rechte Navigation & Status -->
<div style="display: flex; align-items: center; gap: 8px; margin-left: auto;">
<span id="shop-online-pill" class="cb-online-pill" title="Synchronisations-Status">● Live</span>
<!-- Desktop Buttons (auf Bildschirmen > 768px) -->
<div class="cb-nav-actions desktop-nav-actions">
<button type="button" id="btn-toggle-hide-checked" onclick="toggleHideChecked()" class="cb-nav-btn" title="Erledigte Artikel ausblenden / einblenden">
<span id="hide-checked-icon">👁️</span> <span id="hide-checked-text">Erledigte ausblenden</span>
</button>
<button type="button" onclick="openCategoriesModal()" class="cb-nav-btn" title="Supermarkt-Bereiche & Laufweg anpassen">
<span>🏬</span> <span>Bereiche anpassen</span>
</button>
<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>
<button type="button" onclick="openDeleteStep1Modal()" class="cb-nav-btn cb-btn-danger" style="background:#b02a37; border-color:#b02a37; color:#fff;" title="Liste leeren">
<span>🗑️</span> <span>Liste leeren</span>
</button>
<a href="/" class="cb-nav-btn" title="Zur Startseite">
<span>🏠</span> <span>Startseite</span>
</a>
<a href="/cookbook" class="cb-nav-btn" title="Kochbuch & Rezepte">
<span>📖</span> <span>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-Punkte (Kebab) Menü (auf Bildschirmen <= 768px) -->
<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">
<button type="button" onclick="handleMobileMenuAction(toggleHideChecked)" class="cb-mobile-item">
<span id="hide-checked-mobile-icon">👁️</span> <span id="hide-checked-mobile-text">Erledigte ausblenden</span>
</button>
<button type="button" onclick="handleMobileMenuAction(openCategoriesModal)" class="cb-mobile-item">
<span>🏬</span> <span>Bereiche anpassen</span>
</button>
<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>
<hr class="cb-mobile-divider">
<button type="button" onclick="handleMobileMenuAction(openDeleteStep1Modal)" class="cb-mobile-item" style="color: #b02a37; font-weight: 700;">
<span>🗑️</span> <span>Liste leeren</span>
</button>
<hr class="cb-mobile-divider">
<a href="/" class="cb-mobile-item">
<span>🏠</span> <span>Startseite</span>
</a>
<a href="/cookbook" class="cb-mobile-item">
<span>📖</span> <span>Kochbuch</span>
</a>
<hr class="cb-mobile-divider">
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
</div>
</div>
</header>
<!-- DAUERHAFT FIXIERTE KATEGORIEN-LEISTE -->
<nav class="cb-filter-bar no-print" id="shop-category-bar">
<div class="cb-filter-scroll-container" id="shop-category-chips">
<!-- Dynamisch befüllt mit Kategorien -->
</div>
</nav>
</div>
<!-- ========================================== -->
<!-- HAUPTINHALT: EINKAUFSLISTEN-EINTRÄGE -->
<!-- ========================================== -->
<main class="shop-main-container">
<div id="items-container">
<!-- Wird dynamisch aus JavaScript gerendert -->
</div>
<!-- Leerer Zustand -->
<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 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>
<!-- Floating Action Button (FAB '+') -->
<button type="button" class="cb-fab" onclick="openAddItemModal()" title="Neuen Eintrag hinzufügen">
<span>+</span>
</button>
<!-- Toast Notification -->
<div id="shop-toast" class="shop-toast">✓ Gespeichert</div>
<!-- ========================================== -->
<!-- MODAL: EINTRAG BEARBEITEN -->
<!-- ========================================== -->
<div id="modal-edit-item" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-edit-item')">
<div class="cb-modal-card" style="max-width: 500px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>✏️</span> Eintrag bearbeiten</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-edit-item')" title="Schließen">&times;</button>
</div>
<form onsubmit="onSaveEditItem(event)">
<div class="cb-modal-body">
<input type="hidden" id="edit-item-id">
<div class="modal-form-grid">
<div>
<label class="cb-label" for="edit-item-name">Artikel / Zutat *</label>
<input type="text" id="edit-item-name" class="cb-input" required autocomplete="off">
</div>
<div>
<label class="cb-label" for="edit-item-category">Bereich im Supermarkt</label>
<select id="edit-item-category" class="cb-select"></select>
</div>
<div>
<label class="cb-label" for="edit-item-amount">Menge</label>
<input type="text" id="edit-item-amount" class="cb-input" placeholder="z. B. 2 oder 500">
</div>
<div>
<label class="cb-label" for="edit-item-unit">Einheit</label>
<input type="text" id="edit-item-unit" class="cb-input" placeholder="z. B. g, l, Bund, Dose">
</div>
<div>
<label class="cb-label" for="edit-item-note">Notiz / Extra</label>
<input type="text" id="edit-item-note" class="cb-input" placeholder="z. B. Bio, laktosefrei, im Angebot">
</div>
</div>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-danger" onclick="onDeleteItemFromModal()" style="margin-right: auto; background: #b02a37; border-color: #b02a37; color: #fff;">
🗑️ Löschen
</button>
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-edit-item')">
Schließen
</button>
<button type="submit" class="cb-btn cb-btn-green">
Speichern
</button>
</div>
</div>
</form>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: NEUER EINTRAG (FAB '+') -->
<!-- ========================================== -->
<div id="modal-add-item" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-add-item')">
<div class="cb-modal-card" style="max-width: 500px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>➕</span> Neuer Eintrag</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-add-item')" title="Schließen">&times;</button>
</div>
<form onsubmit="onSaveAddItem(event)">
<div class="cb-modal-body">
<div class="modal-form-grid">
<div>
<label class="cb-label" for="add-item-name">Artikel / Zutat *</label>
<input type="text" id="add-item-name" class="cb-input" placeholder="z. B. Gurke oder 500g Quark..." required autocomplete="off">
</div>
<div>
<label class="cb-label" for="add-item-category">Bereich im Supermarkt</label>
<select id="add-item-category" class="cb-select">
<option value="">🪄 Automatisch erkennen</option>
</select>
</div>
<div>
<label class="cb-label" for="add-item-amount">Menge</label>
<input type="text" id="add-item-amount" class="cb-input" placeholder="z. B. 2 oder 500">
</div>
<div>
<label class="cb-label" for="add-item-unit">Einheit</label>
<input type="text" id="add-item-unit" class="cb-input" placeholder="z. B. g, l, Bund, Dose">
</div>
<div>
<label class="cb-label" for="add-item-note">Notiz / Extra</label>
<input type="text" id="add-item-note" class="cb-input" placeholder="z. B. Bio, im Angebot">
</div>
</div>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-add-item')">
Schließen
</button>
<button type="submit" class="cb-btn cb-btn-green">
Hinzufügen
</button>
</div>
</div>
</form>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: 2-STUFEN LÖSCHEN -->
<!-- ========================================== -->
<!-- Stufe 1: Einkaufsliste leeren? -->
<div id="modal-delete-step1" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-delete-step1')">
<div class="cb-modal-card" style="max-width: 440px;">
<div class="cb-modal-header" style="background: #b02a37;">
<h3 class="cb-modal-title"><span>🗑️</span> Einkaufsliste leeren?</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-delete-step1')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p style="margin: 0 0 20px 0; font-size: 1.05rem; line-height: 1.45;">
Möchtest du wirklich alle Artikel von der Einkaufsliste entfernen?
</p>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-delete-step1')">
Schließen
</button>
<button type="button" class="cb-btn cb-btn-danger" onclick="openDeleteStep2Modal()" style="background: #b02a37; border-color: #b02a37; color: #fff;">
Löschen
</button>
</div>
</div>
</div>
</div>
<!-- Stufe 2: Wirklich alles löschen? -->
<div id="modal-delete-step2" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-delete-step2')">
<div class="cb-modal-card" style="max-width: 440px; border-color: #800;">
<div class="cb-modal-header" style="background: #800;">
<h3 class="cb-modal-title"><span>⚠️</span> Wirklich alles löschen?</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-delete-step2')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p style="margin: 0 0 20px 0; font-size: 1.05rem; line-height: 1.45; color: #800; font-weight: 700;">
Achtung: Alle Einträge (auch noch nicht erledigte Artikel) werden unwiderruflich gelöscht!
</p>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-delete-step2')">
Abbrechen
</button>
<button type="button" class="cb-btn cb-btn-danger" onclick="confirmClearAllItems()" style="background: #800; border-color: #800; color: #fff;">
Ja, wirklich alles löschen
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: WOCHENPLAN ZUTATEN IMPORTIEREN -->
<!-- ========================================== -->
<div id="modal-import-mealplan" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-import-mealplan')">
<div class="cb-modal-card" style="max-width: 580px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>📅</span> Zutaten aus Wochenplan holen</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-import-mealplan')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<div id="mealplan-import-body" style="min-height: 120px;">
<div style="text-align: center; padding: 25px; color: #666;">
<span>⏳ Zutaten des aktuellen Wochenplans werden geladen...</span>
</div>
</div>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-import-mealplan')">
Schließen
</button>
<button type="button" class="cb-btn cb-btn-green" id="btn-confirm-import-mealplan" onclick="confirmImportMealPlanIngredients()" disabled>
Ausgewählte übernehmen
</button>
</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">&times;</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) -->
<!-- ========================================== -->
<div id="modal-quick-shelf" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-quick-shelf')">
<div class="cb-modal-card" style="max-width: 440px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>🛒</span> Regal / Bereich wählen</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-quick-shelf')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p id="quick-shelf-subtitle" style="font-size: 0.92rem; color: #555; margin: 0 0 12px 0;">
Wohin gehört dieser Artikel im Supermarkt?
</p>
<div id="quick-shelf-options" style="display: flex; flex-direction: column; gap: 6px; max-height: 55vh; overflow-y: auto; padding-right: 2px;">
<!-- Dynamisch befüllt -->
</div>
<div class="modal-btn-row" style="margin-top: 14px;">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-quick-shelf')">
Abbrechen
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: SUPERMARKT-BEREICHE & LAUFWEG -->
<!-- ========================================== -->
<div id="modal-categories" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-categories')">
<div class="cb-modal-card" style="max-width: 540px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>🏬</span> Supermarkt-Bereiche anpassen</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-categories')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p style="font-size: 0.9rem; color: #555; margin: 0 0 12px 0;">
Ordne die Bereiche mit <strong>▲</strong> und <strong>▼</strong> so an, wie du durch deinen Supermarkt gehst.
</p>
<!-- Presets Bar -->
<div style="margin-bottom: 14px; background: rgba(30, 77, 59, 0.05); border: 1.5px solid rgba(30, 77, 59, 0.15); border-radius: 8px; padding: 10px;">
<div style="font-size: 0.85rem; font-weight: 700; color: var(--shop-green); margin-bottom: 8px;">
Laufweg-Vorlage für deinen Markt:
</div>
<div class="shop-preset-bar">
<button type="button" class="shop-preset-btn" onclick="applyCategoryPreset('standard')" title="Obst/Gemüse ➔ Backwaren ➔ Vorrat ➔ Gewürze ➔ Kühlung ➔ Fleisch ➔ Tiefkühl ➔ Getränke ➔ Drogerie">
🛒 Supermarkt (Edeka / Rewe)
</button>
<button type="button" class="shop-preset-btn" onclick="applyCategoryPreset('discounter')" title="Obst/Gemüse ➔ Backwaren ➔ Kühlwand ➔ Fleisch ➔ Vorrat ➔ Tiefkühl ➔ Getränke ➔ Drogerie">
🏬 Discounter (Aldi / Lidl)
</button>
</div>
</div>
<ul id="cat-manage-list" style="list-style: none; margin: 0 0 16px 0; padding: 0; display: flex; flex-direction: column; gap: 8px;"></ul>
<div style="background: var(--shop-yellow); border: 1.5px solid var(--shop-schwarz); border-radius: 8px; padding: 12px;">
<h4 style="margin: 0 0 8px 0; font-size: 0.95rem;">Neuen Bereich anlegen</h4>
<form onsubmit="onAddCategory(event)" style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; gap: 8px;">
<input type="text" id="new-cat-icon" style="width: 44px; text-align: center; font-size: 1.2rem; border: 1.5px solid #888; border-radius: 6px;" value="🛒" title="Symbol / Emoji">
<input type="text" id="new-cat-name" placeholder="z. B. Gang 3: Nudeln & Soßen" class="cb-input" style="flex: 1; padding: 8px 12px;" required>
</div>
<div>
<label for="new-cat-shelf-type" style="display: block; font-size: 0.78rem; font-weight: 700; color: #444; margin-bottom: 3px;">
Waren-Verhalten (für automatische Zutatenerkennung):
</label>
<select id="new-cat-shelf-type" class="cb-select" style="padding: 6px 10px; font-size: 0.88rem; width: 100%;">
<option value="produce">🥦 Obst, Gemüse & Kräuter</option>
<option value="bakery">🍞 Brot & Backwaren</option>
<option value="pantry" selected>🥫 Vorrat, Nudeln, Reis & Konserven</option>
<option value="spices">🧂 Gewürze, Öle & Essig</option>
<option value="dairy">🧀 Kühlregal, Milchprodukte & Käse</option>
<option value="meat">🥩 Fleisch, Fisch & Wurst</option>
<option value="frozen">❄️ Tiefkühl</option>
<option value="drinks">🧃 Getränke</option>
<option value="drugstore">🧺 Drogerie & Haushalt</option>
<option value="other">🛒 Sonstiges</option>
</select>
</div>
<button type="submit" class="cb-btn cb-btn-green" style="width: 100%; justify-content: center; padding: 9px 14px; font-weight: 700;">+ Bereich hinzufügen</button>
</form>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 14px;">
<button type="button" onclick="resetDefaultCategories()" style="background: none; border: none; font-size: 0.82rem; color: #777; cursor: pointer; text-decoration: underline;">
↺ Standard zurücksetzen
</button>
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-categories')">
Fertig
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- JAVASCRIPT: LOGIK, WEBSOCKET & OFFLINE-SYNC-->
<!-- ========================================== -->
<script>
// State Initialisierung aus Server oder localStorage
let allItems = [];
let allCategories = [];
let currentFilterCategory = null;
let hideChecked = localStorage.getItem('wtc_hide_checked') === 'true';
let ws = null;
let reconnectTimeout = null;
let pingInterval = null;
let isSyncingQueue = false;
// Server-Daten & Offline-Cache einbinden
let serverItems = [];
let serverCats = [];
let allShelfTypes = [];
try {
serverItems = {{ items_json | safe }};
serverCats = {{ categories_json | safe }};
allShelfTypes = {{ shelf_types_json | safe }};
} catch (e) {
serverItems = [];
serverCats = [];
allShelfTypes = [];
}
const cachedQueue = JSON.parse(localStorage.getItem('wtc_shop_offline_queue') || '[]');
const cachedItems = JSON.parse(localStorage.getItem('wtc_shop_items_cache') || 'null');
const cachedCats = JSON.parse(localStorage.getItem('wtc_shop_cats_cache') || 'null');
// Wenn offline ODER noch ungesendete Offline-Änderungen vorliegen: Lokalen Cache priorisieren!
if (!navigator.onLine || cachedQueue.length > 0) {
allItems = (cachedItems !== null) ? cachedItems : (serverItems || []);
allCategories = (cachedCats !== null) ? cachedCats : (serverCats || []);
} else {
allItems = (serverItems && serverItems.length) ? serverItems : (cachedItems || []);
allCategories = (serverCats && serverCats.length) ? serverCats : (cachedCats || []);
saveLocalCache(allItems);
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
}
function deduplicateItems(items) {
if (!Array.isArray(items)) return [];
const seenIds = new Set();
const unique = [];
for (const item of items) {
if (!item) continue;
const key = item.id ? `id_${item.id}` : `name_${(item.item_name || '').toLowerCase().trim()}_${item.checked ? '1' : '0'}`;
if (!seenIds.has(key)) {
seenIds.add(key);
unique.push(item);
}
}
return unique;
}
function saveLocalCache(items) {
allItems = deduplicateItems(items);
try {
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
} catch (e) {
console.warn('LocalStorage voll oder nicht verfügbar:', e);
}
}
// ==========================================
// MOBILE NAVIGATION (3-PUNKTE-MENÜ)
// ==========================================
function toggleMobileMenu(e) {
if (e) e.stopPropagation();
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.toggle('is-open');
}
function handleMobileMenuAction(fn) {
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.remove('is-open');
if (fn) fn();
}
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');
}
});
// ==========================================
// WEBSOCKET & LIVE SYNCHRONISATION
// ==========================================
function initWebSocket() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/shopping-list`;
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
updateOnlineStatus(true);
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send('ping');
}
}, 25000);
};
ws.onmessage = (event) => {
if (event.data === 'pong') return;
try {
const data = JSON.parse(event.data);
if (data.action === 'items_updated') {
if (isSyncingQueue) return;
allItems = data.items;
saveLocalCache(allItems);
renderShoppingList();
} else if (data.action === 'categories_updated') {
if (isSyncingQueue) return;
allCategories = data.categories;
if (data.items) {
allItems = data.items;
saveLocalCache(allItems);
}
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
renderCategoryFilterChips();
renderShoppingList();
}
} catch (err) {
console.error("Fehler beim Verarbeiten der WebSocket-Nachricht:", err);
}
};
ws.onclose = () => {
if (pingInterval) clearInterval(pingInterval);
updateOnlineStatus(false);
scheduleReconnect();
};
ws.onerror = () => {
if (ws) ws.close();
};
} catch (e) {
updateOnlineStatus(false);
scheduleReconnect();
}
}
function scheduleReconnect() {
if (reconnectTimeout) clearTimeout(reconnectTimeout);
reconnectTimeout = setTimeout(initWebSocket, 3000);
}
function updateOnlineStatus(isOnline) {
const pill = document.getElementById('shop-online-pill');
if (!pill) return;
if (isOnline) {
pill.textContent = '● Live';
pill.classList.remove('is-offline');
} else {
pill.textContent = '⚡ Offline';
pill.classList.add('is-offline');
}
}
window.addEventListener('online', () => {
updateOnlineStatus(true);
initWebSocket();
flushOfflineQueue();
});
window.addEventListener('offline', () => {
updateOnlineStatus(false);
});
// ==========================================
// OFFLINE MUTATION QUEUE
// ==========================================
function getOfflineQueue() {
return JSON.parse(localStorage.getItem('wtc_shop_offline_queue') || '[]');
}
function pushOfflineQueue(action) {
const q = getOfflineQueue();
q.push(action);
localStorage.setItem('wtc_shop_offline_queue', JSON.stringify(q));
}
async function flushOfflineQueue() {
if (isSyncingQueue) return;
const q = getOfflineQueue();
if (!q.length) return;
isSyncingQueue = true;
try {
for (const act of q) {
try {
await fetch(act.url, {
method: act.method,
headers: { 'Content-Type': 'application/json' },
body: act.body ? JSON.stringify(act.body) : undefined
});
} catch (e) {
console.warn("Fehler beim Nachholen einer Offline-Aktion:", e);
}
}
localStorage.removeItem('wtc_shop_offline_queue');
await refreshDataFromServer();
showToast('✓ Offline-Änderungen synchronisiert');
} finally {
isSyncingQueue = false;
}
}
async function refreshDataFromServer() {
try {
const [itemsRes, catsRes] = await Promise.all([
fetch('/api/shopping-list'),
fetch('/api/shopping-categories')
]);
if (itemsRes.ok) {
const d = await itemsRes.json();
allItems = d.items || [];
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
}
if (catsRes.ok) {
const d = await catsRes.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
}
renderCategoryFilterChips();
renderShoppingList();
} catch (e) {
console.warn("Konnte Daten nicht vom Server aktualisieren:", e);
}
}
// ==========================================
// TOGGLE: ERLEDIGTE AUSBLENDEN / EINBLENDEN
// ==========================================
function toggleHideChecked() {
hideChecked = !hideChecked;
localStorage.setItem('wtc_hide_checked', hideChecked ? 'true' : 'false');
updateHideCheckedUI();
renderCategoryFilterChips();
renderShoppingList();
}
function updateHideCheckedUI() {
const desktopBtn = document.getElementById('btn-toggle-hide-checked');
const desktopIcon = document.getElementById('hide-checked-icon');
const desktopText = document.getElementById('hide-checked-text');
const mobileIcon = document.getElementById('hide-checked-mobile-icon');
const mobileText = document.getElementById('hide-checked-mobile-text');
if (hideChecked) {
if (desktopBtn) desktopBtn.classList.add('is-active');
if (desktopIcon) desktopIcon.textContent = '👁️🗨️';
if (desktopText) desktopText.textContent = 'Erledigte anzeigen';
if (mobileIcon) mobileIcon.textContent = '👁️🗨️';
if (mobileText) mobileText.textContent = 'Erledigte anzeigen';
} else {
if (desktopBtn) desktopBtn.classList.remove('is-active');
if (desktopIcon) desktopIcon.textContent = '👁️';
if (desktopText) desktopText.textContent = 'Erledigte ausblenden';
if (mobileIcon) mobileIcon.textContent = '👁️';
if (mobileText) mobileText.textContent = 'Erledigte ausblenden';
}
}
// ==========================================
// RENDERING: KATEGORIEN-FILTERLEISTE
// ==========================================
function renderCategoryFilterChips() {
const container = document.getElementById('shop-category-chips');
if (!container) return;
const relevantItems = hideChecked ? allItems.filter(it => !it.checked) : allItems;
const totalItems = relevantItems.length;
let html = `
<button type="button" class="cb-chip ${currentFilterCategory === null ? 'active' : ''}" onclick="selectFilterCategory(null)">
Alle (${totalItems})
</button>
`;
allCategories.forEach(cat => {
const count = relevantItems.filter(it => it.category === cat.name).length;
if (count > 0 || currentFilterCategory === cat.name) {
const isAct = currentFilterCategory === cat.name;
html += `
<button type="button" class="cb-chip ${isAct ? 'active' : ''}" onclick="selectFilterCategory('${escapeAttr(cat.name)}')">
<span>${cat.icon || '🛒'}</span> <span>${escapeHtml(cat.name)}</span> <span>(${count})</span>
</button>
`;
}
});
// 'Sonstiges' anzeigen falls vorhanden
const uncategorizedCount = relevantItems.filter(it => !it.category || it.category === 'Sonstiges').length;
if (uncategorizedCount > 0 && !allCategories.some(c => c.name === 'Sonstiges')) {
const isAct = currentFilterCategory === 'Sonstiges';
html += `
<button type="button" class="cb-chip ${isAct ? 'active' : ''}" onclick="selectFilterCategory('Sonstiges')">
<span>🛒</span> <span>Sonstiges</span> <span>(${uncategorizedCount})</span>
</button>
`;
}
container.innerHTML = html;
}
function selectFilterCategory(catName) {
currentFilterCategory = catName;
renderCategoryFilterChips();
renderShoppingList();
}
// ==========================================
// RENDERING: EINKAUFSLISTEN-ITEMS (LAUFWEG)
// ==========================================
function renderShoppingList() {
const container = document.getElementById('items-container');
const emptyState = document.getElementById('empty-state');
if (!container) return;
// Filter anwenden (dedupliziert gegen parallele WebSocket- & Fetch-Ereignisse)
let filtered = deduplicateItems(allItems);
if (currentFilterCategory !== null) {
filtered = filtered.filter(it => (it.category || 'Sonstiges') === currentFilterCategory);
}
if (hideChecked) {
filtered = filtered.filter(it => !it.checked);
}
if (filtered.length === 0) {
container.innerHTML = '';
if (emptyState) {
emptyState.style.display = 'block';
const totalOpen = allItems.filter(it => !it.checked).length;
const titleEl = emptyState.querySelector('.shop-empty-title');
const descEl = emptyState.querySelector('.shop-empty-desc');
if (hideChecked && allItems.length > 0 && totalOpen === 0) {
if (titleEl) titleEl.textContent = 'Alle offenen Artikel erledigt!';
if (descEl) descEl.innerHTML = 'Alle Artikel auf deiner Liste sind abgehakt und ausgeblendet. Klicke auf <strong>Erledigte anzeigen</strong>, um sie wieder einzublenden.';
} else {
if (titleEl) titleEl.textContent = 'Alles eingekauft!';
if (descEl) descEl.innerHTML = 'Deine Einkaufsliste ist leer. Tippe unten rechts auf das <strong>+</strong>, um einen neuen Eintrag zu notieren, oder übernimm Zutaten direkt aus dem Wochenplan.';
}
}
return;
} else {
if (emptyState) emptyState.style.display = 'none';
}
renderCategorizedList(container, filtered);
}
function renderCategorizedList(container, items) {
// 1. Kategorien nach sort_order sortieren
const catOrderMap = {};
allCategories.forEach((c, idx) => {
catOrderMap[c.name] = (c.sort_order !== null && c.sort_order !== undefined) ? c.sort_order : idx;
});
// 2. Items in Gruppen aufteilen (offen vs. abgehakt)
const groups = {};
items.forEach(it => {
const cat = it.category || 'Sonstiges';
if (!groups[cat]) groups[cat] = { open: [], checked: [] };
if (it.checked) {
groups[cat].checked.push(it);
} else {
groups[cat].open.push(it);
}
});
// 3. Gruppen-Reihenfolge bestimmen
const groupNames = Object.keys(groups).sort((a, b) => {
const orderA = catOrderMap[a] !== undefined ? catOrderMap[a] : 999;
const orderB = catOrderMap[b] !== undefined ? catOrderMap[b] : 999;
return orderA - orderB;
});
let html = '';
groupNames.forEach((catName, groupIdx) => {
const group = groups[catName];
group.open.sort((a, b) => (a.item_name || '').localeCompare(b.item_name || '', 'de', { sensitivity: 'base' }));
group.checked.sort((a, b) => (a.item_name || '').localeCompare(b.item_name || '', 'de', { sensitivity: 'base' }));
const allInGroup = hideChecked ? group.open : [...group.open, ...group.checked];
if (allInGroup.length === 0) return; // Kategorie komplett ausblenden, wenn keine Einträge!
const catObj = allCategories.find(c => c.name === catName) || { name: catName, icon: '🛒', sort_order: groupIdx + 1 };
const stepNum = catObj.sort_order ? `${catObj.sort_order}.` : `${groupIdx + 1}.`;
const countBadge = hideChecked ? `${group.open.length} offen` : `${group.open.length} offen / ${allInGroup.length}`;
html += `
<section class="shop-cat-section">
<div class="shop-cat-header">
<h2 class="shop-cat-title">
<span class="shop-cat-step-num">${stepNum}</span>
<span>${catObj.icon || '🛒'}</span>
<span>${escapeHtml(catName)}</span>
</h2>
<span class="shop-cat-count">${countBadge}</span>
</div>
<ul class="shop-items-list">
${allInGroup.map(item => renderItemRow(item)).join('')}
</ul>
</section>
`;
});
container.innerHTML = html;
}
function renderItemRow(item) {
const isChecked = item.checked ? 'is-checked' : '';
const amountStr = formatAmount(item.amount, item.unit);
const catObj = allCategories.find(c => c.name === item.category);
const catIcon = catObj ? (catObj.icon || '🛒') : '🛒';
const catName = item.category || 'Sonstiges';
return `
<li class="shop-item-row ${isChecked}" id="shop-row-${item.id}">
<label class="shop-checkbox-wrap" onclick="event.stopPropagation();">
<input type="checkbox" class="shop-checkbox-input" ${item.checked ? 'checked' : ''} onchange="toggleItemCheck(${item.id})">
<span class="shop-custom-check">✓</span>
</label>
<div class="shop-item-content" onclick="openEditModalById(${item.id})" title="Klicken zum Bearbeiten">
<div class="shop-item-title-row">
<span class="shop-item-name">${escapeHtml(item.item_name)}</span>
${amountStr ? `<span class="shop-item-amount">${escapeHtml(amountStr)}</span>` : ''}
</div>
<div class="shop-item-badges">
<button type="button" class="shop-badge-shelf" onclick="openQuickShelfPicker(${item.id}, event)" title="Regal / Bereich schnell wechseln (lernt mit)">
<span>${catIcon}</span> <span>${escapeHtml(catName)}</span> <span>▾</span>
</button>
${item.note ? `
<span class="shop-badge-note">📝 ${escapeHtml(item.note)}</span>
` : ''}
</div>
</div>
<button type="button" class="shop-item-delete" onclick="deleteItemDirectly(${item.id}, event)" title="Eintrag löschen">
🗑️
</button>
</li>
`;
}
function formatAmount(amount, unit) {
if (!amount && !unit) return '';
if (amount && unit) return `${amount} ${unit}`;
return amount || unit;
}
// ==========================================
// AKTIONEN: TOGGLE, ADD, EDIT, DELETE
// ==========================================
async function toggleItemCheck(itemId) {
const item = allItems.find(it => it.id === itemId);
if (!item) return;
// Optimistic UI Update
item.checked = !item.checked;
saveLocalCache(allItems);
renderCategoryFilterChips();
renderShoppingList();
try {
const res = await fetch(`/api/shopping-list/${itemId}/toggle?checked=${item.checked}`, { method: 'POST' });
if (!res.ok) throw new Error('Fehler beim Abhaken');
} catch (err) {
pushOfflineQueue({ method: 'POST', url: `/api/shopping-list/${itemId}/toggle?checked=${item.checked}` });
}
}
async function deleteItemDirectly(itemId, event) {
if (event) event.stopPropagation();
// Optimistic remove
allItems = allItems.filter(it => it.id !== itemId);
saveLocalCache(allItems);
renderShoppingList();
showToast('Eintrag gelöscht');
try {
const res = await fetch(`/api/shopping-list/${itemId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Fehler beim Löschen');
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: `/api/shopping-list/${itemId}` });
}
}
async function deleteCheckedItems() {
const checkedCount = allItems.filter(it => it.checked).length;
if (checkedCount === 0) {
showToast('Keine erledigten Artikel vorhanden');
return;
}
allItems = allItems.filter(it => !it.checked);
saveLocalCache(allItems);
renderShoppingList();
showToast(`${checkedCount} erledigte Artikel gelöscht`);
try {
await fetch('/api/shopping-list/checked', { method: 'DELETE' });
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/checked' });
}
}
// ==========================================
// MODAL: NEUER EINTRAG (ADD)
// ==========================================
function openAddItemModal() {
populateCategorySelect('add-item-category', '');
document.getElementById('add-item-name').value = '';
document.getElementById('add-item-amount').value = '';
document.getElementById('add-item-unit').value = '';
document.getElementById('add-item-note').value = '';
openModal('modal-add-item');
setTimeout(() => document.getElementById('add-item-name').focus(), 100);
}
async function onSaveAddItem(e) {
e.preventDefault();
const name = document.getElementById('add-item-name').value.trim();
if (!name) return;
const cat = document.getElementById('add-item-category').value || null;
const amount = document.getElementById('add-item-amount').value.trim();
const unit = document.getElementById('add-item-unit').value.trim();
const note = document.getElementById('add-item-note').value.trim();
closeModal('modal-add-item');
const payload = {
item_name: name,
category: cat,
amount: amount,
unit: unit,
note: note,
source: ''
};
try {
const res = await fetch('/api/shopping-list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
const data = await res.json();
if (data.items && Array.isArray(data.items)) {
allItems = data.items;
} else if (data.item) {
const existingIdx = allItems.findIndex(it => it.id === data.item.id);
if (existingIdx >= 0) {
allItems[existingIdx] = data.item;
} else {
allItems.push(data.item);
}
}
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
} else {
throw new Error('Server-Fehler');
}
} catch (err) {
// Offline Fallback
const tempItem = {
id: Date.now(),
item_name: name,
category: cat || 'Sonstiges',
amount: amount,
unit: unit,
note: note,
checked: false,
source: ''
};
const existingIdx = allItems.findIndex(it => (it.item_name || '').toLowerCase().trim() === name.toLowerCase().trim() && !it.checked);
if (existingIdx >= 0) {
allItems[existingIdx] = tempItem;
} else {
allItems.push(tempItem);
}
pushOfflineQueue({ method: 'POST', url: '/api/shopping-list', body: payload });
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
}
showToast('Eintrag hinzugefügt');
}
// ==========================================
// MODAL: EINTRAG BEARBEITEN (EDIT)
// ==========================================
function openEditModalById(itemId) {
const item = allItems.find(it => it.id === itemId);
if (!item) return;
document.getElementById('edit-item-id').value = item.id;
document.getElementById('edit-item-name').value = item.item_name || '';
document.getElementById('edit-item-amount').value = item.amount || '';
document.getElementById('edit-item-unit').value = item.unit || '';
document.getElementById('edit-item-note').value = item.note || '';
populateCategorySelect('edit-item-category', item.category || 'Sonstiges');
openModal('modal-edit-item');
setTimeout(() => document.getElementById('edit-item-name').focus(), 100);
}
async function onSaveEditItem(e) {
e.preventDefault();
const id = parseInt(document.getElementById('edit-item-id').value, 10);
const name = document.getElementById('edit-item-name').value.trim();
if (!name || !id) return;
const cat = document.getElementById('edit-item-category').value || 'Sonstiges';
const amount = document.getElementById('edit-item-amount').value.trim();
const unit = document.getElementById('edit-item-unit').value.trim();
const note = document.getElementById('edit-item-note').value.trim();
closeModal('modal-edit-item');
// Optimistic update
const item = allItems.find(it => it.id === id);
if (item) {
item.item_name = name;
item.category = cat;
item.amount = amount;
item.unit = unit;
item.note = note;
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
}
const payload = {
item_name: name,
category: cat,
amount: amount,
unit: unit,
note: note
};
try {
await fetch(`/api/shopping-list/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} catch (err) {
pushOfflineQueue({ method: 'PUT', url: `/api/shopping-list/${id}`, body: payload });
}
showToast('Eintrag aktualisiert');
}
function onDeleteItemFromModal() {
const id = parseInt(document.getElementById('edit-item-id').value, 10);
if (!id) return;
closeModal('modal-edit-item');
deleteItemDirectly(id);
}
// ==========================================
// 2-STUFEN LÖSCHEN MODALS
// ==========================================
function openDeleteStep1Modal() {
openModal('modal-delete-step1');
}
function openDeleteStep2Modal() {
closeModal('modal-delete-step1');
openModal('modal-delete-step2');
}
async function confirmClearAllItems() {
closeModal('modal-delete-step2');
allItems = [];
saveLocalCache([]);
renderShoppingList();
renderCategoryFilterChips();
showToast('Einkaufsliste komplett geleert');
try {
await fetch('/api/shopping-list/all', { method: 'DELETE' });
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/all' });
}
}
// ==========================================
// MODAL: WOCHENPLAN ZUTATEN IMPORTIEREN
// ==========================================
let mealPlanYear = null;
let mealPlanWeek = null;
async function openMealPlanImportModal(year, week) {
openModal('modal-import-mealplan');
const body = document.getElementById('mealplan-import-body');
const confirmBtn = document.getElementById('btn-confirm-import-mealplan');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
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);">Zutaten des Wochenplans werden geladen...</div>
</div>
`;
try {
let url = '/api/mealplan/ingredients';
if (year && week) {
url += `?year=${encodeURIComponent(year)}&week=${encodeURIComponent(week)}`;
}
const res = await fetch(url);
if (!res.ok) throw new Error('Fehler beim Laden');
const data = await res.json();
mealPlanYear = data.year;
mealPlanWeek = data.week;
const groups = data.groups || [];
let navHeader = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 8px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 6px;">
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 3px 9px; font-size: 0.85rem;" onclick="changeMealPlanImportWeek(-1)" title="Vorherige Woche">◀</button>
<span style="font-weight: 700; font-size: 0.95rem;">KW ${data.week} / ${data.year}</span>
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 3px 9px; font-size: 0.85rem;" onclick="changeMealPlanImportWeek(1)" title="Nächste Woche">▶</button>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button type="button" onclick="toggleAllMealPlanChecks(true)" style="background: none; border: none; color: var(--shop-green); font-size: 0.85rem; font-weight: 700; cursor: pointer; text-decoration: underline;">
Alle
</button>
<span style="color: #ccc;">|</span>
<button type="button" onclick="toggleAllMealPlanChecks(false)" style="background: none; border: none; color: #888; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-decoration: underline;">
Keine
</button>
</div>
</div>
`;
if (groups.length === 0) {
body.innerHTML = navHeader + `
<div style="text-align: center; padding: 25px; color: #666;">
<span style="font-size: 2.2rem; display: block; margin-bottom: 8px;">🍽️</span>
<p style="font-size: 1.05rem; margin: 0 0 6px 0; font-weight: 700; color: var(--shop-green);">Keine Rezepte in dieser Woche geplant</p>
<p style="font-size: 0.88rem; margin: 0; color: #777;">Blättere mit ◀ oder ▶ zu einer anderen Woche oder trage Gerichte im Kochbuch-Wochenmenüplan ein.</p>
</div>
`;
return;
}
let totalItems = 0;
let html = navHeader + `
<div style="max-height: 52vh; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding-right: 4px;">
`;
groups.forEach((g, gIdx) => {
const items = g.items || g.ingredients || [];
totalItems += items.length;
html += `
<div style="background: rgba(30, 77, 59, 0.05); border: 1.5px solid rgba(30, 77, 59, 0.15); border-radius: 8px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-family: var(--font-hand); font-size: 1.25rem; font-weight: 700; color: var(--shop-green);">
🍳 ${escapeHtml(g.recipe_title)}
</div>
${g.slot_label ? `<span style="font-size: 0.75rem; background: rgba(30, 77, 59, 0.12); color: var(--shop-green); padding: 2px 7px; border-radius: 6px; font-weight: 600;">${escapeHtml(g.slot_label)}</span>` : ''}
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
${items.map((ing, iIdx) => {
const ingName = ing.name || ing.item_name || ing.raw || '';
const amtStr = formatAmount(ing.amount, ing.unit);
return `
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.92rem; cursor: pointer; padding: 3px 0;">
<input type="checkbox" class="mealplan-ing-checkbox" checked
data-name="${escapeAttr(ingName)}"
data-amount="${escapeAttr(ing.amount || '')}"
data-unit="${escapeAttr(ing.unit || '')}"
data-source="${escapeAttr(g.recipe_title || '')}"
onchange="updateMealPlanConfirmBtn()">
<span>${amtStr ? `<strong>${escapeHtml(amtStr)}</strong> ` : ''}${escapeHtml(ingName)}</span>
</label>
`;
}).join('')}
</div>
</div>
`;
});
html += `</div>`;
body.innerHTML = html;
if (confirmBtn) {
confirmBtn.disabled = totalItems === 0;
confirmBtn.textContent = `Ausgewählte übernehmen (${totalItems})`;
}
} catch (err) {
console.error("Fehler beim Laden des Wochenplans:", err);
body.innerHTML = `
<div style="text-align: center; padding: 20px; color: #b02a37;">
Fehler beim Laden des Wochenplans. Bitte stelle sicher, dass du online bist.
</div>
`;
}
}
function changeMealPlanImportWeek(delta) {
if (!mealPlanYear || !mealPlanWeek) return;
let w = mealPlanWeek + delta;
let y = mealPlanYear;
if (w < 1) {
y -= 1;
w = 52;
} else if (w > 52) {
y += 1;
w = 1;
}
openMealPlanImportModal(y, w);
}
function toggleAllMealPlanChecks(val) {
document.querySelectorAll('.mealplan-ing-checkbox').forEach(cb => cb.checked = val);
updateMealPlanConfirmBtn();
}
function updateMealPlanConfirmBtn() {
const checkedBoxes = Array.from(document.querySelectorAll('.mealplan-ing-checkbox:checked'));
const btn = document.getElementById('btn-confirm-import-mealplan');
if (btn) {
btn.disabled = checkedBoxes.length === 0;
btn.textContent = `Ausgewählte übernehmen (${checkedBoxes.length})`;
}
}
async function confirmImportMealPlanIngredients() {
const cbs = Array.from(document.querySelectorAll('.mealplan-ing-checkbox:checked'));
if (cbs.length === 0) return;
const confirmBtn = document.getElementById('btn-confirm-import-mealplan');
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-mealplan');
renderCategoryFilterChips();
renderShoppingList();
showToast(`✓ ${itemsToAdd.length} Zutaten übernommen`);
} else {
showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
}
} catch (err) {
console.error("Fehler beim Importieren der Zutaten:", err);
showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
}
}
// ==========================================
// 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
// ==========================================
function openCategoriesModal() {
renderManageCategoriesList();
openModal('modal-categories');
}
function renderManageCategoriesList() {
const ul = document.getElementById('cat-manage-list');
if (!ul) return;
ul.innerHTML = allCategories.map((cat, idx) => {
const stepNum = cat.sort_order || (idx + 1);
const shelfObj = (allShelfTypes || []).find(st => st.type === cat.shelf_type);
const shelfLabel = shelfObj ? shelfObj.label : (cat.shelf_type || 'Sonstiges');
return `
<li style="display: flex; align-items: center; justify-content: space-between; padding: 7px 12px; background: #FFFFFF; border: 1.5px solid rgba(34,34,34,0.15); border-radius: 8px; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; font-weight: 600; min-width: 0; flex: 1;">
<span class="shop-cat-step-num">${stepNum}.</span>
<span style="font-size: 1.25rem;">${cat.icon || '🛒'}</span>
<div style="min-width: 0; display: flex; flex-direction: column;">
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(cat.name)}</span>
<span style="font-size: 0.72rem; color: #777; font-weight: 500;">Typ: ${escapeHtml(shelfLabel)}</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 4px; flex-shrink: 0;">
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, -1)" ${idx === 0 ? 'disabled style="opacity:0.3;"' : ''}>▲</button>
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, 1)" ${idx === allCategories.length - 1 ? 'disabled style="opacity:0.3;"' : ''}>▼</button>
<button type="button" class="cb-btn cb-btn-danger" style="padding: 2px 7px; font-size: 0.85rem; background:#b02a37; border-color:#b02a37; color:#fff;" onclick="deleteCategory(${cat.id})">✕</button>
</div>
</li>
`;
}).join('');
}
async function moveCategory(catId, dir) {
const idx = allCategories.findIndex(c => c.id === catId);
if (idx < 0) return;
const targetIdx = idx + dir;
if (targetIdx < 0 || targetIdx >= allCategories.length) return;
const [removed] = allCategories.splice(idx, 1);
allCategories.splice(targetIdx, 0, removed);
allCategories.forEach((c, i) => c.sort_order = i + 1);
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
const orderedIds = allCategories.map(c => c.id);
try {
await fetch('/api/shopping-categories/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category_ids: orderedIds })
});
} catch (e) {}
}
async function onAddCategory(e) {
e.preventDefault();
const iconInput = document.getElementById('new-cat-icon');
const nameInput = document.getElementById('new-cat-name');
const shelfSelect = document.getElementById('new-cat-shelf-type');
const name = nameInput.value.trim();
const icon = iconInput.value.trim() || '🛒';
const shelfType = shelfSelect ? shelfSelect.value : 'other';
if (!name) return;
nameInput.value = '';
try {
const res = await fetch('/api/shopping-categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
icon: icon,
shelf_type: shelfType,
sort_order: allCategories.length + 1
})
});
if (res.ok) {
const d = await res.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
showToast(`Bereich "${name}" angelegt`);
}
} catch (e) {}
}
async function deleteCategory(catId) {
if (!confirm('Diesen Supermarkt-Bereich wirklich entfernen?')) return;
try {
const res = await fetch(`/api/shopping-categories/${catId}`, { method: 'DELETE' });
if (res.ok) {
const d = await res.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
showToast('Bereich entfernt');
}
} catch (e) {}
}
async function applyCategoryPreset(presetName) {
const label = presetName === 'discounter' ? 'Discounter (Aldi/Lidl)' : 'Supermarkt (Edeka/Rewe)';
if (!confirm(`Möchtest du den Laufweg auf "${label}" umstellen?\nAlle Artikel deiner Liste werden automatisch den neuen Stationen zugeordnet.`)) {
return;
}
try {
const res = await fetch(`/api/shopping-categories/reset-defaults?preset=${encodeURIComponent(presetName)}`, {
method: 'POST'
});
if (res.ok) {
const d = await res.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
await refreshDataFromServer();
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
showToast(`✓ Laufweg "${label}" aktiviert`);
}
} catch (e) {
console.error("Fehler beim Aktivieren der Vorlage:", e);
showToast('Fehler beim Aktivieren der Vorlage');
}
}
async function resetDefaultCategories() {
applyCategoryPreset('standard');
}
// ==========================================
// SCHNELL-REGALWAHL (1-TAP PRO ARTIKEL)
// ==========================================
let quickShelfTargetItemId = null;
function openQuickShelfPicker(itemId, event) {
if (event) event.stopPropagation();
const item = allItems.find(it => it.id === itemId);
if (!item) return;
quickShelfTargetItemId = itemId;
const subTitle = document.getElementById('quick-shelf-subtitle');
if (subTitle) {
subTitle.innerHTML = `Regal für <strong>${escapeHtml(item.item_name)}</strong> wählen:`;
}
const container = document.getElementById('quick-shelf-options');
if (container) {
container.innerHTML = allCategories.map((cat, idx) => {
const isCurrent = (item.category || 'Sonstiges') === cat.name;
const stepNum = cat.sort_order || (idx + 1);
return `
<button type="button" class="quick-shelf-btn ${isCurrent ? 'is-current' : ''}" onclick="applyQuickShelfChange('${escapeAttr(cat.name)}')">
<span class="shop-cat-step-num">${stepNum}.</span>
<span style="font-size: 1.25rem;">${cat.icon || '🛒'}</span>
<span style="flex: 1; text-align: left; font-weight: 600;">${escapeHtml(cat.name)}</span>
${isCurrent ? '<span style="font-size: 0.8rem; color: var(--shop-green); font-weight: 700;">✓ Hier</span>' : ''}
</button>
`;
}).join('');
}
openModal('modal-quick-shelf');
}
async function applyQuickShelfChange(newCategory) {
if (!quickShelfTargetItemId) return;
const itemId = quickShelfTargetItemId;
const item = allItems.find(it => it.id === itemId);
closeModal('modal-quick-shelf');
if (item && item.category === newCategory) {
return;
}
// Optimistisches Update
if (item) {
item.category = newCategory;
saveLocalCache(allItems);
renderCategoryFilterChips();
renderShoppingList();
}
showToast(`✓ Zu "${newCategory}" verschoben & gelernt!`);
try {
const res = await fetch(`/api/shopping-list/${itemId}/category`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: newCategory })
});
if (!res.ok) throw new Error('Fehler beim Zuweisen der Kategorie');
} catch (err) {
console.warn("Offline oder Fehler bei Kategorieänderung:", err);
pushOfflineQueue({
method: 'POST',
url: `/api/shopping-list/${itemId}/category`,
body: { category: newCategory }
});
}
}
// ==========================================
// HELFER: MODAL OPEN / CLOSE & POPULATE
// ==========================================
function openModal(id) {
const m = document.getElementById(id);
if (m) {
m.classList.add('active');
document.body.classList.add('modal-open');
}
}
function closeModal(id) {
const m = document.getElementById(id);
if (m) {
m.classList.remove('active');
document.body.classList.remove('modal-open');
}
}
function onBackdropClick(e, id) {
if (e.target.id === id) {
closeModal(id);
}
}
function populateCategorySelect(selectId, selectedValue) {
const sel = document.getElementById(selectId);
if (!sel) return;
let html = selectId === 'add-item-category' ? '<option value="">🪄 Automatisch erkennen</option>' : '';
allCategories.forEach((cat, idx) => {
const isSel = (cat.name === selectedValue) ? 'selected' : '';
const stepNum = cat.sort_order || (idx + 1);
html += `<option value="${escapeAttr(cat.name)}" ${isSel}>${stepNum}. ${cat.icon || '🛒'} ${escapeHtml(cat.name)}</option>`;
});
if (!allCategories.some(c => c.name === 'Sonstiges')) {
const isSel = (selectedValue === 'Sonstiges') ? 'selected' : '';
html += `<option value="Sonstiges" ${isSel}>🛒 Sonstiges</option>`;
}
sel.innerHTML = html;
}
function showToast(msg) {
const toast = document.getElementById('shop-toast');
if (!toast) return;
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2200);
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function escapeAttr(str) {
if (!str) return '';
return String(str).replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// ==========================================
// SERVICE WORKER & INITIALISIERUNG
// ==========================================
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(err => console.log('SW fail:', err));
});
}
document.addEventListener('DOMContentLoaded', () => {
updateHideCheckedUI();
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>
</html>