/** * app.js – Haupteinstiegspunkt der Encore-Webanwendung. * Koordiniert Authentifizierung (via Modal), Navigation, Suche, * gespeicherte Events und Einladungen. */ import { getEvents } from "./services/eventService.js"; import { renderEventList } from "./ui/eventList.js"; import { getFilters } from "./ui/filters.js"; import { login, register } from "./auth.js"; import { createEventCard } from "./ui/eventCard.js"; // ========================= // GLOBALER ZUSTAND // window-Properties damit eventCard.js auf den Nutzer zugreifen kann // ========================= let currentUser = null; let currentPassword = null; let lastRenderedEvents = []; window.currentUser = null; window.currentPassword = null; // ========================= // DOM – HEADER AUTH // ========================= const loginBtn = document.querySelector("#login-btn"); const registerBtn = document.querySelector("#register-btn"); const authArea = document.querySelector("#auth-area"); const userArea = document.querySelector("#user-area"); const userNameEl = document.querySelector("#user-name"); const logoutBtn = document.querySelector("#logout-btn"); // ========================= // DOM – AUTH MODAL // ========================= const authModalEl = document.querySelector("#auth-modal"); const bsModal = new bootstrap.Modal(authModalEl); const loginTabBtn = document.querySelector("#login-tab-btn"); const registerTabBtn = document.querySelector("#register-tab-btn"); const modalLoginBtn = document.querySelector("#modal-login-btn"); const modalRegBtn = document.querySelector("#modal-register-btn"); const modalUsername = document.querySelector("#modal-username"); const modalPassword = document.querySelector("#modal-password"); const modalRegUsername = document.querySelector("#modal-reg-username"); const loginError = document.querySelector("#login-error"); const registerError = document.querySelector("#register-error"); const registerSuccess = document.querySelector("#register-success"); const regPasswordEl = document.querySelector("#reg-password"); // ========================= // DOM – NAVIGATION // ========================= const navEvents = document.querySelector("#nav-events"); const navSaved = document.querySelector("#nav-my-events"); const navInv = document.querySelector("#nav-invitations"); // ========================= // DOM – SEKTIONEN // ========================= const searchSection = document.querySelector("#search-section"); const eventsSection = document.querySelector("#events-section"); const savedSection = document.querySelector("#saved-section"); const invSection = document.querySelector("#invitations-section"); // ========================= // MODAL ÖFFNEN – Korrekte Tab-Vorauswahl // ========================= loginBtn.addEventListener("click", () => { bootstrap.Tab.getOrCreateInstance(loginTabBtn).show(); bsModal.show(); }); registerBtn.addEventListener("click", () => { bootstrap.Tab.getOrCreateInstance(registerTabBtn).show(); bsModal.show(); }); // Modal-Felder zurücksetzen wenn geschlossen authModalEl.addEventListener("hidden.bs.modal", () => { modalUsername.value = ""; modalPassword.value = ""; modalRegUsername.value = ""; loginError.hidden = true; registerError.hidden = true; registerSuccess.classList.add("d-none"); modalRegBtn.textContent = "Konto erstellen"; modalRegBtn.disabled = false; lastRegisteredUser = null; }); // ========================= // LOGIN VIA MODAL // ========================= modalLoginBtn.addEventListener("click", async () => { const username = modalUsername.value.trim(); const password = modalPassword.value; loginError.hidden = true; if (!username || !password) { loginError.textContent = "Bitte Benutzername und Passwort eingeben."; loginError.hidden = false; return; } modalLoginBtn.disabled = true; modalLoginBtn.textContent = "Anmelden..."; try { const success = await login(username, password); if (success) { currentUser = username; currentPassword = password; window.currentUser = username; window.currentPassword = password; bsModal.hide(); authArea.classList.add("d-none"); userArea.classList.remove("d-none"); userNameEl.textContent = `👤 ${username}`; navEvents.classList.remove("d-none"); navSaved.classList.remove("d-none"); navInv.classList.remove("d-none"); refreshVisibleEventCards(); } else { loginError.textContent = "Login fehlgeschlagen. Bitte Zugangsdaten prüfen."; loginError.hidden = false; } } catch (err) { loginError.textContent = "Verbindungsfehler. Ist der Server erreichbar?"; loginError.hidden = false; } finally { modalLoginBtn.disabled = false; modalLoginBtn.textContent = "Anmelden"; } }); // Enter in Passwort-Feld löst Login aus modalPassword.addEventListener("keydown", (e) => { if (e.key === "Enter") modalLoginBtn.click(); }); // ========================= // REGISTRIERUNG VIA MODAL // Zustandsvariable: nach Erfolg wechselt Button zu "Zum Login" // ========================= let lastRegisteredUser = null; modalRegBtn.addEventListener("click", async () => { // Nach erfolgreicher Registrierung → zu Login-Tab wechseln und Benutzernamen vorfüllen if (lastRegisteredUser) { modalUsername.value = lastRegisteredUser; lastRegisteredUser = null; bootstrap.Tab.getOrCreateInstance(loginTabBtn).show(); return; } const username = modalRegUsername.value.trim(); registerError.hidden = true; if (!username) { registerError.textContent = "Bitte einen Benutzernamen eingeben."; registerError.hidden = false; return; } modalRegBtn.disabled = true; modalRegBtn.textContent = "Erstelle Konto..."; try { const data = await register(username); if (data.password) { lastRegisteredUser = username; regPasswordEl.textContent = data.password; registerSuccess.classList.remove("d-none"); modalRegBtn.disabled = false; modalRegBtn.textContent = "Zum Login →"; } else { registerError.textContent = data.message || "Registrierung fehlgeschlagen."; registerError.hidden = false; modalRegBtn.disabled = false; modalRegBtn.textContent = "Konto erstellen"; } } catch (err) { registerError.textContent = "Verbindungsfehler bei der Registrierung."; registerError.hidden = false; modalRegBtn.disabled = false; modalRegBtn.textContent = "Konto erstellen"; } }); // ========================= // LOGOUT // ========================= logoutBtn.addEventListener("click", () => { currentUser = null; currentPassword = null; window.currentUser = null; window.currentPassword = null; authArea.classList.remove("d-none"); userArea.classList.add("d-none"); navEvents.classList.add("d-none"); navSaved.classList.add("d-none"); navInv.classList.add("d-none"); showSection("events"); refreshVisibleEventCards(); }); // ========================= // SUCHE // ========================= const searchBtn = document.querySelector("#load-events"); const eventListEl = document.querySelector("#event-list"); const cityInput = document.querySelector("#city-input"); searchBtn.addEventListener("click", handleSearch); cityInput.addEventListener("keydown", (e) => { if (e.key === "Enter") handleSearch(); }); async function handleSearch() { const { city, dateFrom, dateTo, category } = getFilters(); if (!city) { showError(eventListEl, "Bitte eine Stadt eingeben."); return; } showSkeletons(eventListEl); try { const events = await getEvents(city); const filtered = applyFilters(events, dateFrom, dateTo, category); lastRenderedEvents = filtered; renderEventList(filtered, eventListEl); } catch (err) { showError(eventListEl, "Fehler beim Laden der Events. Bitte erneut versuchen."); } } /** Zeigt Skeleton-Platzhalterkarten während Events geladen werden. */ function showSkeletons(container, count = 6) { container.innerHTML = ""; for (let i = 0; i < count; i++) { const skeleton = document.createElement("div"); skeleton.className = "skeleton-card"; skeleton.setAttribute("aria-hidden", "true"); container.appendChild(skeleton); } } // ========================= // CLIENT-SEITIGE FILTER // ========================= function applyFilters(events, dateFrom, dateTo, category) { return events.filter(event => { const matchDateFrom = dateFrom ? event.date >= dateFrom : true; const matchDateTo = dateTo ? event.date <= dateTo : true; const matchCategory = category ? event.category && event.category.toLowerCase().includes(category.toLowerCase()) : true; return matchDateFrom && matchDateTo && matchCategory; }); } // ========================= // NAVIGATION // ========================= navEvents.addEventListener("click", (e) => { e.preventDefault(); showSection("events"); }); navSaved.addEventListener("click", (e) => { e.preventDefault(); showSection("saved"); loadSavedEvents(); }); navInv.addEventListener("click", (e) => { e.preventDefault(); showSection("invitations"); loadInvitations(); }); function refreshVisibleEventCards() { if (!eventsSection.classList.contains("d-none") && lastRenderedEvents.length > 0) { renderEventList(lastRenderedEvents, eventListEl); } } function showSection(section) { searchSection.classList.add("d-none"); eventsSection.classList.add("d-none"); savedSection.classList.add("d-none"); invSection.classList.add("d-none"); if (section === "events") { searchSection.classList.remove("d-none"); eventsSection.classList.remove("d-none"); } else if (section === "saved") { savedSection.classList.remove("d-none"); } else if (section === "invitations") { invSection.classList.remove("d-none"); } } // ========================= // GESPEICHERTE EVENTS (MY EVENTS) // ========================= function loadSavedEvents() { const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]"); const container = document.querySelector("#saved-list"); container.innerHTML = ""; if (saved.length === 0) { const p = document.createElement("p"); p.className = "text-muted"; p.textContent = "Noch keine gespeicherten Events."; container.appendChild(p); return; } saved.forEach(event => { const card = createEventCard(event); const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.textContent = "Entfernen"; removeBtn.className = "btn btn-outline-danger btn-sm mt-2"; removeBtn.addEventListener("click", () => { removeSavedEvent(event.id); loadSavedEvents(); }); card.appendChild(removeBtn); container.appendChild(card); }); } function removeSavedEvent(eventId) { const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]"); const updated = saved.filter(e => e.id !== eventId); localStorage.setItem("savedEvents", JSON.stringify(updated)); } // ========================= // EINLADUNGEN // ========================= async function loadInvitations() { if (!currentUser) return; const container = document.querySelector("#invitation-list"); container.innerHTML = "
Einladungen werden geladen...
"; try { const res = await fetch("http://localhost:3000/api/invitation", { headers: { "X-Username": currentUser } }); if (!res.ok) throw new Error(`Server antwortete mit Status ${res.status}`); const data = await res.json(); container.innerHTML = ""; if (data.length === 0) { const p = document.createElement("p"); p.className = "text-muted"; p.textContent = "Keine Einladungen vorhanden."; container.appendChild(p); return; } data.forEach(inv => renderInvitation(inv, container)); } catch (err) { showError(container, "Fehler beim Laden der Einladungen."); } } /** * Rendert eine Einladungskarte – ausschliesslich DOM-Methoden (kein innerHTML mit Nutzerdaten). * Jede Karte hat einen Löschen-Button (demonstriert HTTP DELETE). */ function renderInvitation(inv, container) { const card = document.createElement("div"); card.className = "invitation-card"; const text = document.createElement("p"); const strong = document.createElement("strong"); const em = document.createElement("em"); strong.textContent = inv.fromUser; em.textContent = inv.eventName; text.append(strong, " hat dich zu ", em, " eingeladen."); card.appendChild(text); if (inv.eventUrl) { const link = document.createElement("a"); link.href = inv.eventUrl; link.target = "_blank"; link.rel = "noopener noreferrer"; link.className = "btn btn-outline-primary btn-sm mb-2"; link.textContent = "Event öffnen"; card.appendChild(link); } if (inv.status !== "pending") { const status = document.createElement("p"); status.className = inv.status === "accepted" ? "text-success" : "text-muted"; status.textContent = inv.status === "accepted" ? "✓ Angenommen" : "Abgelehnt"; card.appendChild(status); card.appendChild(buildDeleteBtn(inv.id, card)); container.appendChild(card); return; } const btnGroup = document.createElement("div"); btnGroup.className = "d-flex gap-2 flex-wrap"; const acceptBtn = document.createElement("button"); acceptBtn.type = "button"; acceptBtn.textContent = "Annehmen"; acceptBtn.className = "btn btn-success btn-sm"; acceptBtn.addEventListener("click", () => respondToInvitation(inv.id, "accept", card)); const declineBtn = document.createElement("button"); declineBtn.type = "button"; declineBtn.textContent = "Ablehnen"; declineBtn.className = "btn btn-outline-secondary btn-sm"; declineBtn.addEventListener("click", () => respondToInvitation(inv.id, "decline", card)); btnGroup.append(acceptBtn, declineBtn, buildDeleteBtn(inv.id, card)); card.appendChild(btnGroup); container.appendChild(card); } /** Erstellt den Löschen-Button – ruft DELETE /api/invitation/:id auf. */ function buildDeleteBtn(id, card) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = "Löschen"; btn.className = "btn btn-outline-danger btn-sm"; btn.addEventListener("click", () => deleteInvitation(id, card)); return btn; } /** * Aktualisiert den Einladungsstatus via PUT /api/invitation/:id. * HTTP PUT gemäss REST: Update einer bestehenden Ressource. */ async function respondToInvitation(id, action, card) { const status = action === "accept" ? "accepted" : "declined"; try { const res = await fetch(`http://localhost:3000/api/invitation/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status }) }); if (!res.ok) throw new Error(`Status ${res.status}`); const btnGroup = card.querySelector(".d-flex"); if (btnGroup) btnGroup.remove(); const statusEl = document.createElement("p"); statusEl.className = action === "accept" ? "text-success" : "text-muted"; statusEl.textContent = action === "accept" ? "✓ Angenommen" : "Abgelehnt"; card.appendChild(statusEl); card.appendChild(buildDeleteBtn(id, card)); } catch (err) { const errEl = document.createElement("p"); errEl.className = "text-danger small mt-1"; errEl.textContent = "Fehler beim Antworten auf die Einladung."; card.appendChild(errEl); } } /** * Löscht eine Einladung via DELETE /api/invitation/:id. * HTTP DELETE gemäss REST: Entfernt die Ressource dauerhaft. * 204 No Content ist der erwartete Erfolgsstatus. */ async function deleteInvitation(id, card) { try { const res = await fetch(`http://localhost:3000/api/invitation/${id}`, { method: "DELETE" }); // 204 No Content = Erfolg; res.ok deckt 200–299 ab if (!res.ok) throw new Error(`Status ${res.status}`); card.remove(); } catch (err) { const errEl = document.createElement("p"); errEl.className = "text-danger small mt-1"; errEl.textContent = "Fehler beim Löschen der Einladung."; card.appendChild(errEl); } } // ========================= // UTILITY – Fehlermeldung (XSS-sicher via textContent) // ========================= function showError(container, message) { const p = document.createElement("p"); p.className = "text-danger"; p.textContent = message; container.innerHTML = ""; container.appendChild(p); }