feature/ui-redesign #8

Merged
thoenykaroli merged 3 commits from feature/ui-redesign into main 2026-06-01 09:42:42 +02:00
3 changed files with 324 additions and 165 deletions
Showing only changes of commit ae3692697b - Show all commits

View File

@ -1,92 +1,151 @@
/**
* eventCard.js Erstellt interaktive Event-Karten als DOM-Elemente.
* Jede Karte zeigt Titel, Datum, Veranstaltungsort sowie Speichern- und Einladen-Buttons.
*/
/**
* Erstellt eine vollständige Event-Karte als <article>-Element.
* @param {Object} event - Event-Objekt mit id, name, date, time, venue, category
* @returns {HTMLElement} Fertig aufgebautes Karten-Element
*/
export function createEventCard(event) { export function createEventCard(event) {
const article = document.createElement("article"); const article = document.createElement("article");
article.className = "event-card"; article.className = "event-card";
// --- TITEL ---
const title = document.createElement("h3"); const title = document.createElement("h3");
title.className = "event-card__title"; title.className = "event-card__title";
title.textContent = event.name; title.textContent = event.name;
// --- DATUM & UHRZEIT formatieren (Schweizer Format: dd.mm.yyyy) ---
const formattedDate = event.date const formattedDate = event.date
? new Date(event.date).toLocaleDateString("de-CH", { ? new Date(event.date).toLocaleDateString("de-CH", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric" year: "numeric"
}) })
: "Date not available"; : "Datum unbekannt";
const formattedTime = event.time const formattedTime = event.time ? event.time.slice(0, 5) : "";
? event.time.slice(0, 5) const dateTime = formattedTime ? `${formattedDate}, ${formattedTime}` : formattedDate;
: "";
const dateTime = formattedTime
? `${formattedDate}, ${formattedTime}`
: formattedDate;
const date = document.createElement("p"); const date = document.createElement("p");
date.className = "event-card__date"; date.className = "event-card__date";
date.textContent = dateTime; date.textContent = dateTime;
// --- VERANSTALTUNGSORT ---
const venue = document.createElement("p"); const venue = document.createElement("p");
venue.className = "event-card__venue"; venue.className = "event-card__venue";
venue.textContent = event.venue; venue.textContent = event.venue || "Ort unbekannt";
article.append(title, date, venue); article.append(title, date, venue);
// ========================= // --- BUTTON-GRUPPE ---
// BUTTON CONTAINER
// =========================
const buttonContainer = document.createElement("div"); const buttonContainer = document.createElement("div");
buttonContainer.className = "d-flex gap-2 mt-2"; buttonContainer.className = "d-flex gap-2 mt-2 flex-wrap";
// ========================= // =========================
// SAVE BUTTON // SPEICHERN-BUTTON
// Speichert das Event im localStorage, verhindert Duplikate
// ========================= // =========================
const saveBtn = document.createElement("button"); const saveBtn = document.createElement("button");
saveBtn.textContent = "Save"; saveBtn.type = "button";
saveBtn.className = "btn btn-outline-primary btn-sm"; saveBtn.className = "btn btn-outline-primary btn-sm";
// Prüfen ob bereits gespeichert → Button-Text entsprechend setzen
const alreadySaved = isAlreadySaved(event.id);
saveBtn.textContent = alreadySaved ? "Gespeichert ✓" : "Speichern";
if (alreadySaved) saveBtn.disabled = true;
saveBtn.addEventListener("click", () => { saveBtn.addEventListener("click", () => {
if (!window.currentUser) {
alert("Please login to save events");
return;
}
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]"); const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
// prevent duplicates // Duplikat-Schutz
if (saved.find(e => e.id === event.id)) { if (saved.find(e => e.id === event.id)) {
alert("Event already saved"); saveBtn.textContent = "Gespeichert ✓";
saveBtn.disabled = true;
return; return;
} }
saved.push(event); saved.push(event);
localStorage.setItem("savedEvents", JSON.stringify(saved)); localStorage.setItem("savedEvents", JSON.stringify(saved));
alert("Event saved!"); // Visuelles Feedback statt alert()
saveBtn.textContent = "Gespeichert ✓";
saveBtn.disabled = true;
saveBtn.classList.replace("btn-outline-primary", "btn-success");
}); });
saveBtn.disabled = !window.currentUser;
// ========================= // =========================
// INVITE BUTTON // EINLADEN-BUTTON
// Zeigt ein Inline-Formular an (kein browser-prompt())
// ========================= // =========================
const inviteBtn = document.createElement("button"); const inviteBtn = document.createElement("button");
inviteBtn.textContent = "Invite"; inviteBtn.type = "button";
inviteBtn.textContent = "Einladen";
inviteBtn.className = "btn btn-primary btn-sm"; inviteBtn.className = "btn btn-primary btn-sm";
inviteBtn.disabled = false;
inviteBtn.addEventListener("click", async () => { inviteBtn.addEventListener("click", () => {
if (!window.currentUser) { if (!window.currentUser) {
alert("Please login to invite users"); showCardMessage(article, "Bitte einloggen um Einladungen zu senden.", "warning");
return; return;
} }
// Formular ein-/ausblenden (Toggle)
const existing = article.querySelector(".invite-form");
if (existing) {
existing.remove();
} else {
article.appendChild(createInviteForm(event, article));
}
});
const toUser = prompt("Enter username to invite:"); buttonContainer.append(saveBtn, inviteBtn);
if (!toUser) return; article.appendChild(buttonContainer);
await fetch("http://localhost:3000/api/invitation", { return article;
}
// =========================
// HILFSFUNKTION Einladungsformular
// Erstellt ein kleines Inline-Formular mit Benutzername-Eingabe
// =========================
function createInviteForm(event, card) {
const form = document.createElement("div");
form.className = "invite-form mt-2 d-flex gap-2 align-items-center flex-wrap";
const input = document.createElement("input");
input.type = "text";
input.className = "form-control form-control-sm";
input.placeholder = "Benutzername eingeben";
input.setAttribute("aria-label", "Benutzername für Einladung");
const sendBtn = document.createElement("button");
sendBtn.type = "button";
sendBtn.textContent = "Senden";
sendBtn.className = "btn btn-primary btn-sm";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.textContent = "Abbrechen";
cancelBtn.className = "btn btn-outline-secondary btn-sm";
cancelBtn.addEventListener("click", () => form.remove());
sendBtn.addEventListener("click", async () => {
const toUser = input.value.trim();
if (!toUser) {
input.classList.add("is-invalid");
return;
}
input.classList.remove("is-invalid");
sendBtn.disabled = true;
sendBtn.textContent = "Sende...";
try {
const res = await fetch("http://localhost:3000/api/invitation", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -99,16 +158,48 @@ export function createEventCard(event) {
}) })
}); });
alert("Invitation sent!"); if (!res.ok) {
// Fehlermeldung vom Server auslesen (z.B. "Nutzer nicht registriert")
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `Fehler ${res.status}`);
}
// Formular entfernen und Erfolgsmeldung anzeigen
form.remove();
showCardMessage(card, `Einladung an "${toUser}" gesendet.`, "success");
} catch (err) {
sendBtn.disabled = false;
sendBtn.textContent = "Senden";
showCardMessage(card, err.message || "Fehler beim Senden der Einladung.", "danger");
console.error("Einladungs-Fehler:", err);
}
}); });
inviteBtn.disabled = !window.currentUser; form.append(input, sendBtn, cancelBtn);
return form;
// ========================= }
// APPEND BUTTONS
// ========================= // =========================
buttonContainer.append(saveBtn, inviteBtn); // HILFSFUNKTION Status-Nachricht in Karte anzeigen
article.appendChild(buttonContainer); // Zeigt eine kurze Meldung innerhalb der Karte an und blendet sie nach 3s aus
// =========================
return article; function showCardMessage(card, message, type) {
// Alte Nachrichten entfernen
card.querySelectorAll(".card-message").forEach(el => el.remove());
const msg = document.createElement("p");
msg.className = `card-message text-${type} mt-1 mb-0`;
msg.textContent = message;
card.appendChild(msg);
setTimeout(() => msg.remove(), 3000);
}
// =========================
// HILFSFUNKTION Prüft ob Event bereits gespeichert ist
// =========================
function isAlreadySaved(eventId) {
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
return saved.some(e => e.id === eventId);
} }

View File

@ -1,11 +1,38 @@
/**
* eventList.js Rendert eine Liste von Event-Karten in einen Container.
* Delegiert die Erstellung einzelner Karten an createEventCard().
*/
import { createEventCard } from "./eventCard.js"; import { createEventCard } from "./eventCard.js";
/**
* Leert den Container und fügt für jedes Event eine Karte ein.
* Zeigt eine Meldung an, wenn keine Events gefunden wurden.
* @param {Array} events - Array von Event-Objekten
* @param {HTMLElement} container - Ziel-DOM-Element
*/
export function renderEventList(events, container) { export function renderEventList(events, container) {
container.innerHTML = ""; container.innerHTML = "";
if (events.length === 0) { if (events.length === 0) {
container.innerHTML = "No events found."; const state = document.createElement("div");
state.className = "empty-state";
const icon = document.createElement("div");
icon.className = "empty-state__icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "🎭";
const title = document.createElement("p");
title.className = "empty-state__text";
title.textContent = "Keine Events gefunden";
const sub = document.createElement("p");
sub.className = "empty-state__sub";
sub.textContent = "Versuche eine andere Stadt oder erweitere den Datumsbereich.";
state.append(icon, title, sub);
container.appendChild(state);
return; return;
} }
@ -13,5 +40,4 @@ export function renderEventList(events, container) {
const card = createEventCard(event); const card = createEventCard(event);
container.appendChild(card); container.appendChild(card);
}); });
} }

View File

@ -1,66 +1,141 @@
/**
* server.js Express-Backend für Encore.
*
* Funktionen:
* - Proxy für die Ticketmaster Discovery API (API-Key bleibt serverseitig)
* - Einfache Benutzerverwaltung (In-Memory, kein Persistenz-Layer)
* - Einladungssystem zwischen registrierten Benutzern
*
* Sicherheitshinweise (OWASP):
* - A02 Cryptographic Failures: Passwörter werden nicht gehasht (bewusste Vereinfachung)
* - A07 Identification and Authentication Failures: Keine Tokens/Sessions, nur Header-Auth
* - Der API-Key liegt serverseitig in .env und wird nie an den Client übermittelt
*/
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
const app = express(); const app = express();
// Middleware // Middleware: CORS erlaubt Anfragen vom Frontend (localhost), JSON-Body parsen
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
// ========================= // =========================
// ROOT // ROOT Health-Check
// ========================= // =========================
app.get("/", (req, res) => { app.get("/", (req, res) => {
res.send("Encore API is running"); res.send("Encore API is running");
}); });
// ========================= // =========================
// TICKETMASTER EVENTS (PROXY) // TICKETMASTER-PROXY
// Versteckt den API-Key vor dem Client (A02: Sensitive Data Exposure vermeiden)
// GET /api/events?city={city}
// ========================= // =========================
const TM_API_KEY = process.env.TM_API_KEY; const TM_API_KEY = process.env.TM_API_KEY;
app.get("/api/events", async (req, res) => { app.get("/api/events", async (req, res) => {
const city = req.query.city; const city = req.query.city;
if (!city) { if (!city) {
return res.status(400).json({ message: "City is required" }); return res.status(400).json({ message: "Stadt ist erforderlich" });
} }
try { try {
const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${TM_API_KEY}&city=${city}`; const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${TM_API_KEY}&city=${encodeURIComponent(city)}`;
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json();
res.json(data); res.json(data);
} catch (error) { } catch (error) {
console.error("Event fetch error:", error); console.error("Ticketmaster-Fehler:", error);
res.status(500).json({ message: "Error fetching events" }); res.status(500).json({ message: "Fehler beim Laden der Events" });
} }
}); });
// ========================= // =========================
// INVITATIONS SYSTEM // BENUTZERVERWALTUNG
// In-Memory-Speicher (Daten gehen bei Serverneustart verloren bewusste Vereinfachung)
// =========================
let users = [];
/**
* POST /api/user Neuen Benutzer registrieren.
* Vergibt ein Standard-Passwort "1234" (vereinfacht, kein Hashing).
* Antwortet mit 201 Created bei Erfolg, 400 bei fehlendem/doppeltem Benutzernamen.
*/
app.post("/api/user", (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ message: "Benutzername erforderlich" });
}
if (users.find(u => u.username === username)) {
return res.status(400).json({ message: "Benutzer existiert bereits" });
}
const password = "1234";
users.push({ username, password });
res.status(201).json({ name: username, password });
});
/**
* GET /api/user Benutzer anmelden.
* Credentials kommen als X-Username und X-Password Header.
* Antwortet mit 200 OK bei Erfolg, 401 Unauthorized bei falschen Credentials.
*
* Hinweis: GET für eine Login-Operation ist unkonventionell (normalerweise POST).
* Hier bewusst so gewählt, da kein Session/Token-Mechanismus implementiert ist.
*/
app.get("/api/user", (req, res) => {
const username = req.header("X-Username");
const password = req.header("X-Password");
const user = users.find(u => u.username === username && u.password === password);
if (!user) return res.sendStatus(401);
res.sendStatus(200);
});
// =========================
// EINLADUNGSSYSTEM
// Verwaltet Event-Einladungen zwischen Benutzern
// ========================= // =========================
let invitations = []; let invitations = [];
let idCounter = 1; let idCounter = 1;
// SEND INVITATION /**
* POST /api/invitation Einladung senden.
* Erwartet: Header X-Username (Sender), Body { toUser, eventId, eventName }
* Antwortet mit 201 Created und dem erstellten Einladungsobjekt.
*/
app.post("/api/invitation", (req, res) => { app.post("/api/invitation", (req, res) => {
if (!req.body) { if (!req.body) {
return res.status(400).json({ message: "Missing request body" }); return res.status(400).json({ message: "Request-Body fehlt" });
} }
const fromUser = req.header("X-Username"); const fromUser = req.header("X-Username");
const { toUser, eventId, eventName } = req.body; const { toUser, eventId, eventName } = req.body;
if (!fromUser || !toUser) { if (!fromUser || !toUser) {
return res.status(400).json({ message: "Missing users" }); return res.status(400).json({ message: "Sender und Empfänger sind erforderlich" });
}
// Prüfen ob der Empfänger registriert ist
if (!users.find(u => u.username === toUser)) {
return res.status(404).json({ message: `Nutzer "${toUser}" ist nicht registriert.` });
}
// Nicht sich selbst einladen
if (fromUser === toUser) {
return res.status(400).json({ message: "Du kannst dich nicht selbst einladen." });
} }
const invitation = { const invitation = {
@ -73,95 +148,62 @@ app.post("/api/invitation", (req, res) => {
}; };
invitations.push(invitation); invitations.push(invitation);
res.status(201).json(invitation);
});
res.json(invitation); /**
}); * GET /api/invitation Alle Einladungen für einen Benutzer abrufen.
* Erwartet: Header X-Username (Empfänger)
// GET INVITATIONS FOR USER * Antwortet mit 200 und Array der Einladungen.
*/
app.get("/api/invitation", (req, res) => { app.get("/api/invitation", (req, res) => {
const user = req.header("X-Username"); const user = req.header("X-Username");
if (!user) { if (!user) {
return res.status(400).json({ message: "Missing username" }); return res.status(400).json({ message: "Benutzername fehlt" });
} }
const userInvitations = invitations.filter( const userInvitations = invitations.filter(inv => inv.toUser === user);
inv => inv.toUser === user
);
res.json(userInvitations); res.json(userInvitations);
}); });
// ACCEPT INVITATION /**
app.post("/api/invitation/:id/accept", (req, res) => { * PUT /api/invitation/:id Einladungsstatus aktualisieren (annehmen oder ablehnen).
* Erwartet: Body { status: "accepted" | "declined" }
* HTTP-Verb PUT gemäss REST: Update einer bestehenden Ressource.
* Antwortet mit 200 OK und dem aktualisierten Einladungsobjekt.
*/
app.put("/api/invitation/:id", (req, res) => {
const inv = invitations.find(i => i.id == req.params.id); const inv = invitations.find(i => i.id == req.params.id);
if (!inv) { if (!inv) return res.status(404).json({ message: "Einladung nicht gefunden" });
return res.status(404).json({ message: "Invitation not found" });
const { status } = req.body;
if (!["accepted", "declined"].includes(status)) {
return res.status(400).json({ message: "Ungültiger Status. Erlaubt: accepted, declined" });
} }
inv.status = "accepted"; inv.status = status;
res.json(inv); res.json(inv);
}); });
// DECLINE INVITATION /**
app.post("/api/invitation/:id/decline", (req, res) => { * DELETE /api/invitation/:id Einladung löschen.
* HTTP-Verb DELETE gemäss REST: Entfernt die Ressource dauerhaft.
* Antwortet mit 204 No Content bei Erfolg.
*/
app.delete("/api/invitation/:id", (req, res) => {
const index = invitations.findIndex(i => i.id == req.params.id);
const inv = invitations.find(i => i.id == req.params.id); if (index === -1) return res.status(404).json({ message: "Einladung nicht gefunden" });
if (!inv) { invitations.splice(index, 1);
return res.status(404).json({ message: "Invitation not found" }); res.sendStatus(204); // 204 No Content Ressource erfolgreich gelöscht
}
inv.status = "declined";
res.json(inv);
});
let users = [];
// CREATE USER
app.post("/api/user", (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ message: "Username required" });
}
if (users.find(u => u.username === username)) {
return res.status(400).json({ message: "User exists" });
}
const password = "1234";
const user = { username, password };
users.push(user);
res.json({ name: username, password });
});
// LOGIN
app.get("/api/user", (req, res) => {
const username = req.header("X-Username");
const password = req.header("X-Password");
const user = users.find(
u => u.username === username && u.password === password
);
if (!user) return res.sendStatus(401);
res.sendStatus(201);
}); });
// ========================= // =========================
// START SERVER // SERVER STARTEN
// ========================= // =========================
app.listen(3000, () => { app.listen(3000, () => {
console.log("Server running on http://localhost:3000"); console.log("Server läuft auf http://localhost:3000");
}); });