Files
cookbook/app/static/app.js
T

696 lines
23 KiB
JavaScript
Executable File
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.
/**
* What To Cook? - Frontend Application
*/
// Category icons mapping
const CATEGORY_ICONS = {
"Reis": "🍚",
"Nudeln": "🍝",
"Kartoffeln": "🥔",
"Teigig": "🍕",
"asiatisch": "🥢",
"Hausmannskost": "🥩",
"Suppen": "🍲",
"Eintöpfe": "🥘",
"Süßspeisen": "🥞",
"Fisch": "🐟"
};
const CATEGORIES = [
"Reis",
"Nudeln",
"Kartoffeln",
"Teigig",
"asiatisch",
"Hausmannskost",
"Suppen",
"Eintöpfe",
"Süßspeisen",
"Fisch"
];
const EFFORTS = ["gering", "mittel", "hoch"];
function getDishCategories(dish) {
if (!dish) return [];
if (Array.isArray(dish.categories) && dish.categories.length > 0) {
return dish.categories;
}
if (dish.category) {
return dish.category.split(",").map(c => c.trim()).filter(Boolean);
}
return [];
}
function renderCategoryBadges(dish) {
const cats = getDishCategories(dish);
if (cats.length === 0) return "";
return cats.map(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
return `<span class="badge-tag badge-category"><span>${icon}</span> ${cat}</span>`;
}).join("");
}
// Application State
const state = {
activeTab: "generator",
slots: [
{ slot_number: 1, active: true, category: "", efforts: [], current_dish: null },
{ slot_number: 2, active: true, category: "", efforts: [], current_dish: null },
{ slot_number: 3, active: false, category: "", efforts: [], current_dish: null },
{ slot_number: 4, active: false, category: "", efforts: [], current_dish: null },
{ slot_number: 5, active: false, category: "", efforts: [], current_dish: null },
],
admin: {
dishes: [],
search: "",
category: "alle",
effort: "alle",
deleteTargetId: null
}
};
// --- Initialization ---
document.addEventListener("DOMContentLoaded", () => {
initTabs();
renderSlots();
populateDropdowns();
initGeneratorEvents();
initAdminEvents();
loadAdminDishes();
});
// --- Tab Navigation ---
function initTabs() {
const btnGenerator = document.getElementById("tab-btn-generator");
const btnAdmin = document.getElementById("tab-btn-admin");
const panelGenerator = document.getElementById("tab-generator");
const panelAdmin = document.getElementById("tab-admin");
btnGenerator.addEventListener("click", () => switchTab("generator"));
btnAdmin.addEventListener("click", () => switchTab("admin"));
function switchTab(tabName) {
state.activeTab = tabName;
if (tabName === "generator") {
btnGenerator.classList.add("active");
btnAdmin.classList.remove("active");
panelGenerator.classList.add("active");
panelAdmin.classList.remove("active");
panelAdmin.hidden = true;
panelGenerator.hidden = false;
} else {
btnAdmin.classList.add("active");
btnGenerator.classList.remove("active");
panelAdmin.classList.add("active");
panelGenerator.classList.remove("active");
panelGenerator.hidden = true;
panelAdmin.hidden = false;
loadAdminDishes();
}
}
}
// --- Populate Category Dropdowns ---
function populateDropdowns() {
const adminCatSelect = document.getElementById("admin-filter-category");
const modalCatGroup = document.getElementById("modal-category-group");
if (modalCatGroup) {
modalCatGroup.innerHTML = "";
}
CATEGORIES.forEach(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
// Admin filter
if (adminCatSelect) {
const optFilter = document.createElement("option");
optFilter.value = cat;
optFilter.textContent = `${icon} ${cat}`;
adminCatSelect.appendChild(optFilter);
}
// Modal checkbox pill
if (modalCatGroup) {
const label = document.createElement("label");
label.className = "category-checkbox-label";
label.innerHTML = `
<input type="checkbox" name="modal-category" value="${cat}">
<span class="cat-pill">
<span>${icon}</span> ${cat}
</span>
`;
modalCatGroup.appendChild(label);
}
});
}
// --- Generator Slots Rendering ---
function renderSlots() {
const container = document.getElementById("slots-container");
container.innerHTML = "";
state.slots.forEach(slot => {
const card = document.createElement("div");
card.className = `slot-card ${slot.active ? "active" : "inactive"}`;
card.id = `slot-card-${slot.slot_number}`;
card.innerHTML = `
<div class="slot-card-header">
<div class="slot-title-group">
<span class="slot-number-badge">${slot.slot_number}</span>
<span class="slot-name">Gericht ${slot.slot_number}</span>
</div>
<label class="slot-toggle-label">
<span>${slot.active ? "Aktiv" : "Freigelassen"}</span>
<label class="switch">
<input type="checkbox" id="slot-toggle-${slot.slot_number}" ${slot.active ? "checked" : ""}>
<span class="slider"></span>
</label>
</label>
</div>
<div class="slot-body">
<div class="form-group">
<label for="slot-cat-${slot.slot_number}" class="form-label">Kategorie:</label>
<select id="slot-cat-${slot.slot_number}" class="custom-select">
<option value="" ${slot.category === "" ? "selected" : ""}>🎲 Beliebig (Alle Kategorien)</option>
${CATEGORIES.map(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
return `<option value="${cat}" ${slot.category === cat ? "selected" : ""}>${icon} ${cat}</option>`;
}).join("")}
</select>
</div>
<div class="form-group">
<label class="form-label">Aufwand (Mehrfachauswahl möglich):</label>
<div class="effort-chips-group" id="effort-chips-${slot.slot_number}">
<button type="button" class="effort-chip ${slot.efforts.includes("gering") ? "selected" : ""}" data-effort="gering">
<span>🟢</span> Gering
</button>
<button type="button" class="effort-chip ${slot.efforts.includes("mittel") ? "selected" : ""}" data-effort="mittel">
<span>🟡</span> Mittel
</button>
<button type="button" class="effort-chip ${slot.efforts.includes("hoch") ? "selected" : ""}" data-effort="hoch">
<span>🔴</span> Hoch
</button>
</div>
</div>
</div>
`;
container.appendChild(card);
// Event: Toggle Active
const toggleInput = card.querySelector(`#slot-toggle-${slot.slot_number}`);
toggleInput.addEventListener("change", (e) => {
slot.active = e.target.checked;
renderSlots();
updateActiveCounter();
});
// Event: Category Change
const catSelect = card.querySelector(`#slot-cat-${slot.slot_number}`);
catSelect.addEventListener("change", (e) => {
slot.category = e.target.value;
});
// Event: Effort Multi-Select Chips
const chips = card.querySelectorAll(`#effort-chips-${slot.slot_number} .effort-chip`);
chips.forEach(chip => {
chip.addEventListener("click", () => {
const effortVal = chip.getAttribute("data-effort");
const idx = slot.efforts.indexOf(effortVal);
if (idx > -1) {
slot.efforts.splice(idx, 1);
chip.classList.remove("selected");
} else {
slot.efforts.push(effortVal);
chip.classList.add("selected");
}
});
});
});
updateActiveCounter();
}
function updateActiveCounter() {
const activeCount = state.slots.filter(s => s.active).length;
const countEl = document.getElementById("active-slots-count");
if (countEl) countEl.textContent = activeCount;
const btnHero = document.getElementById("btn-roll-all");
if (btnHero) {
const btnText = btnHero.querySelector(".btn-text");
if (activeCount === 0) {
btnText.textContent = "Bitte mindestens 1 Gericht aktivieren";
btnHero.disabled = true;
} else if (activeCount === 1) {
btnText.textContent = "1 Gericht auslosen";
btnHero.disabled = false;
} else {
btnText.textContent = `${activeCount} Gerichte auslosen`;
btnHero.disabled = false;
}
}
}
// --- Generator Events (Roll Logic) ---
function initGeneratorEvents() {
const btnRollAll = document.getElementById("btn-roll-all");
const btnRerollAll = document.getElementById("btn-reroll-all");
const btnBack = document.getElementById("btn-back-to-config");
btnRollAll.addEventListener("click", () => executeRoll(false));
btnRerollAll.addEventListener("click", () => executeRoll(true));
btnBack.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
async function executeRoll(isReroll = false) {
const activeSlots = state.slots.filter(s => s.active);
if (activeSlots.length === 0) {
showToast("Bitte aktiviere mindestens 1 Gericht.", "error");
return;
}
const btnRollAll = document.getElementById("btn-roll-all");
const btnRerollAll = document.getElementById("btn-reroll-all");
btnRollAll.disabled = true;
btnRerollAll.disabled = true;
try {
const payload = {
slots: activeSlots.map(s => ({
slot_number: s.slot_number,
category: s.category || null,
efforts: s.efforts.length > 0 ? s.efforts : null,
current_dish_id: isReroll && s.current_dish ? s.current_dish.id : null
}))
};
const res = await fetch("/api/roll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Fehler beim Auslosen.");
}
// Update slots state with rolled dishes
data.results.forEach(resItem => {
const slot = state.slots.find(s => s.slot_number === resItem.slot_number);
if (slot) {
slot.current_dish = resItem.dish;
}
});
renderResults(data.results);
showToast(isReroll ? "Alle Gerichte neu ausgelost!" : "Zufallsauswahl erfolgreich!", "success");
// Scroll smoothly to results
const resultsSection = document.getElementById("results-section");
resultsSection.classList.remove("hidden");
resultsSection.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (err) {
showToast(err.message, "error");
} finally {
btnRollAll.disabled = false;
btnRerollAll.disabled = false;
}
}
// Render Results Grid
function renderResults(results) {
const container = document.getElementById("results-list");
const countBadge = document.getElementById("results-count-badge");
container.innerHTML = "";
const validDishes = results.filter(r => r.dish !== null);
countBadge.textContent = `${validDishes.length} ${validDishes.length === 1 ? "Gericht" : "Gerichte"}`;
results.forEach(item => {
const slot = state.slots.find(s => s.slot_number === item.slot_number);
const dish = item.dish;
const card = document.createElement("div");
card.className = "result-card";
card.id = `result-card-${item.slot_number}`;
if (!dish) {
card.innerHTML = `
<div class="result-card-top">
<span class="result-slot-label">Gericht ${item.slot_number}</span>
</div>
<div class="result-dish-name" style="color: var(--text-dim);">
⚠️ Keine passenden Gerichte gefunden
</div>
<p style="font-size: 0.85rem; color: var(--text-muted);">
Für die gewählte Kombination (Kategorie / Aufwand) gibt es noch keine Einträge.
</p>
`;
container.appendChild(card);
return;
}
const effortLabel = dish.effort.charAt(0).toUpperCase() + dish.effort.slice(1);
card.innerHTML = `
<div class="result-card-top">
<span class="result-slot-label">Gericht ${item.slot_number}</span>
</div>
<div class="result-dish-name" id="dish-name-${item.slot_number}">${dish.name}</div>
<div class="result-tags">
${renderCategoryBadges(dish)}
<span class="badge-tag badge-effort-${dish.effort}">
${effortLabel}
</span>
</div>
<div class="result-card-actions">
<button class="btn-reroll-single" data-slot="${item.slot_number}" title="Nur dieses Gericht neu würfeln">
<span class="dice-icon">🎲</span> Neu würfeln
</button>
</div>
`;
container.appendChild(card);
// Event: Single Reroll Button
const btnRerollSingle = card.querySelector(".btn-reroll-single");
btnRerollSingle.addEventListener("click", () => rerollSingleSlot(item.slot_number, btnRerollSingle, card));
});
}
// --- Single Slot Reroll Logic ---
async function rerollSingleSlot(slotNumber, btnElement, cardElement) {
const slot = state.slots.find(s => s.slot_number === slotNumber);
if (!slot || !slot.current_dish) return;
btnElement.classList.add("spinning");
cardElement.classList.add("rerolling");
// Collect other dishes currently on screen so we don't duplicate
const otherDishIds = state.slots
.filter(s => s.active && s.slot_number !== slotNumber && s.current_dish)
.map(s => s.current_dish.id);
try {
const payload = {
slot_number: slotNumber,
category: slot.category || null,
efforts: slot.efforts.length > 0 ? slot.efforts : null,
current_dish_id: slot.current_dish.id,
other_dish_ids: otherDishIds
};
const res = await fetch("/api/roll-single", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Neu-Würfeln.");
if (data.status === "ok" && data.dish) {
slot.current_dish = data.dish;
const effortLabel = data.dish.effort.charAt(0).toUpperCase() + data.dish.effort.slice(1);
// Smoothly update the card content
const nameEl = cardElement.querySelector(`#dish-name-${slotNumber}`);
if (nameEl) nameEl.textContent = data.dish.name;
const tagsEl = cardElement.querySelector(".result-tags");
if (tagsEl) {
tagsEl.innerHTML = `
${renderCategoryBadges(data.dish)}
<span class="badge-tag badge-effort-${data.dish.effort}">
${effortLabel}
</span>
`;
}
showToast(`Gericht ${slotNumber} neu gewürfelt: ${data.dish.name}`, "success");
} else {
showToast("Kein anderes passendes Gericht in dieser Kategorie gefunden.", "error");
}
} catch (err) {
showToast(err.message, "error");
} finally {
setTimeout(() => {
btnElement.classList.remove("spinning");
cardElement.classList.remove("rerolling");
}, 450);
}
}
// --- Admin Section & CRUD ---
function initAdminEvents() {
const searchInput = document.getElementById("admin-search-input");
const clearSearchBtn = document.getElementById("admin-search-clear");
const catFilter = document.getElementById("admin-filter-category");
const effortFilter = document.getElementById("admin-filter-effort");
searchInput.addEventListener("input", (e) => {
state.admin.search = e.target.value.trim();
clearSearchBtn.classList.toggle("hidden", state.admin.search.length === 0);
loadAdminDishes();
});
clearSearchBtn.addEventListener("click", () => {
searchInput.value = "";
state.admin.search = "";
clearSearchBtn.classList.add("hidden");
loadAdminDishes();
});
catFilter.addEventListener("change", (e) => {
state.admin.category = e.target.value;
loadAdminDishes();
});
effortFilter.addEventListener("change", (e) => {
state.admin.effort = e.target.value;
loadAdminDishes();
});
// Modal Open/Close
const dishModal = document.getElementById("dish-modal");
const btnOpenAdd = document.getElementById("btn-open-add-dish");
const btnCloseModal = document.getElementById("btn-close-modal");
const btnCancelModal = document.getElementById("btn-cancel-modal");
const dishForm = document.getElementById("dish-form");
btnOpenAdd.addEventListener("click", () => {
openDishModal();
});
btnCloseModal.addEventListener("click", () => dishModal.close());
btnCancelModal.addEventListener("click", () => dishModal.close());
dishForm.addEventListener("submit", async (e) => {
e.preventDefault();
await saveDishFromModal();
});
// Delete Modal
const deleteModal = document.getElementById("delete-modal");
const btnCancelDelete = document.getElementById("btn-cancel-delete");
const btnConfirmDelete = document.getElementById("btn-confirm-delete");
btnCancelDelete.addEventListener("click", () => deleteModal.close());
btnConfirmDelete.addEventListener("click", async () => {
if (state.admin.deleteTargetId) {
await executeDeleteDish(state.admin.deleteTargetId);
deleteModal.close();
}
});
}
async function loadAdminDishes() {
const listContainer = document.getElementById("admin-dishes-list");
const statsSummary = document.getElementById("admin-stats-summary");
const countLabel = document.getElementById("dishes-count-label");
try {
const params = new URLSearchParams();
if (state.admin.category && state.admin.category !== "alle") {
params.append("category", state.admin.category);
}
if (state.admin.effort && state.admin.effort !== "alle") {
params.append("effort", state.admin.effort);
}
if (state.admin.search) {
params.append("search", state.admin.search);
}
const res = await fetch(`/api/dishes?${params.toString()}`);
const dishes = await res.json();
state.admin.dishes = dishes;
// Load overall stats
const statsRes = await fetch("/api/stats");
const stats = await statsRes.json();
statsSummary.textContent = `${stats.total} Gerichte in der Datenbank`;
countLabel.textContent = `${dishes.length} ${dishes.length === 1 ? "Gericht" : "Gerichte"} angezeigt`;
if (dishes.length === 0) {
listContainer.innerHTML = `
<div class="loading-spinner">Keine Gerichte mit diesen Suchkriterien gefunden.</div>
`;
return;
}
listContainer.innerHTML = "";
dishes.forEach(dish => {
const row = document.createElement("div");
row.className = "admin-dish-row";
const effortLabel = dish.effort.charAt(0).toUpperCase() + dish.effort.slice(1);
row.innerHTML = `
<div class="admin-dish-info">
<div class="admin-dish-name">${dish.name}</div>
<div class="admin-dish-badges">
${renderCategoryBadges(dish)}
<span class="badge-tag badge-effort-${dish.effort}">${effortLabel}</span>
</div>
</div>
<div class="admin-dish-actions">
<button class="btn-action-icon btn-edit" title="Bearbeiten" data-id="${dish.id}">✏️</button>
<button class="btn-action-icon btn-delete" title="Löschen" data-id="${dish.id}">🗑️</button>
</div>
`;
listContainer.appendChild(row);
row.querySelector(".btn-edit").addEventListener("click", () => openDishModal(dish));
row.querySelector(".btn-delete").addEventListener("click", () => confirmDeleteDish(dish));
});
} catch (err) {
listContainer.innerHTML = `<div class="loading-spinner" style="color: var(--danger);">Fehler beim Laden: ${err.message}</div>`;
}
}
function openDishModal(dish = null) {
const modal = document.getElementById("dish-modal");
const modalTitle = document.getElementById("modal-title");
const formId = document.getElementById("dish-form-id");
const nameInput = document.getElementById("dish-name-input");
const catCheckboxes = document.querySelectorAll('input[name="modal-category"]');
if (dish) {
modalTitle.textContent = "Gericht bearbeiten";
formId.value = dish.id;
nameInput.value = dish.name;
const cats = getDishCategories(dish);
catCheckboxes.forEach(cb => {
cb.checked = cats.includes(cb.value);
});
const radio = document.querySelector(`input[name="modal-effort"][value="${dish.effort}"]`);
if (radio) radio.checked = true;
} else {
modalTitle.textContent = "Neues Gericht anlegen";
formId.value = "";
nameInput.value = "";
catCheckboxes.forEach(cb => {
cb.checked = false;
});
const radio = document.querySelector(`input[name="modal-effort"][value="mittel"]`);
if (radio) radio.checked = true;
}
modal.showModal();
}
async function saveDishFromModal() {
const modal = document.getElementById("dish-modal");
const formId = document.getElementById("dish-form-id").value;
const name = document.getElementById("dish-name-input").value.trim();
const selectedCats = Array.from(document.querySelectorAll('input[name="modal-category"]:checked')).map(cb => cb.value);
const effortRadio = document.querySelector('input[name="modal-effort"]:checked');
const effort = effortRadio ? effortRadio.value : "mittel";
if (!name) {
showToast("Bitte gib einen Namen für das Gericht ein.", "error");
return;
}
if (selectedCats.length === 0) {
showToast("Bitte wähle mindestens eine Kategorie aus.", "error");
return;
}
if (!effort) {
showToast("Bitte wähle den Aufwand aus.", "error");
return;
}
const isEdit = !!formId;
const url = isEdit ? `/api/dishes/${formId}` : "/api/dishes";
const method = isEdit ? "PUT" : "POST";
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, categories: selectedCats, effort })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Speichern.");
showToast(isEdit ? "Gericht aktualisiert!" : "Gericht erfolgreich hinzugefügt!", "success");
modal.close();
loadAdminDishes();
} catch (err) {
showToast(err.message, "error");
}
}
function confirmDeleteDish(dish) {
state.admin.deleteTargetId = dish.id;
const deleteModal = document.getElementById("delete-modal");
const textEl = document.getElementById("delete-modal-text");
textEl.innerHTML = `Möchtest du <strong>"${dish.name}"</strong> wirklich löschen?`;
deleteModal.showModal();
}
async function executeDeleteDish(dishId) {
try {
const res = await fetch(`/api/dishes/${dishId}`, { method: "DELETE" });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Löschen.");
showToast("Gericht wurde gelöscht.", "success");
loadAdminDishes();
} catch (err) {
showToast(err.message, "error");
}
}
// --- Toast System ---
function showToast(message, type = "info") {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
const icon = type === "success" ? "✅" : (type === "error" ? "⚠️" : "ℹ️");
toast.innerHTML = `<span>${icon}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.transition = "opacity 0.3s, transform 0.3s";
toast.style.opacity = "0";
toast.style.transform = "translateY(-10px)";
setTimeout(() => toast.remove(), 300);
}, 3500);
}