feature/ui-redesign #8

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

161
README.md
View File

@ -1,45 +1,150 @@
# Encore # Encore Event Discovery
Frontend Project for cds-208 at FHGR
Collaborators: Pascal Schieman, Nicola Augsburger, Karolina Thöny-Tyganova
## Project Description
**Find your next event in seconds.** Encore is a modern event discovery frontend
that brings Ticketmaster events directly to users through a clean, intelligent
interface. Instead of sifting through cluttered listings, users can instantly browse
concerts, sports, theater, and entertainment by location, date, and category—then
dive into event details to make informed decisions fast.
The product centers on three core goals: Frontend-Projekt für das Modul cds-208 an der FHGR (Frühlingssemester 2026).
- **Discoverability**: make events easy to find through location and category filters.
- **Decision support**: display essential details (date, venue, city, lineup) at a glance.
- **Usability**: deliver a responsive, intuitive interface that works seamlessly on all devices.
- **Community interaction**: provide event-specific discussion areas with interactive commenting and @-mentions so users can tag and notify each other directly.
- **Social coordination**: enable direct, personalized event invitations between registered users, supported by notifications and dashboard-based invitation management.
Built on Ticketmaster's API, Encore provides real-time event data while maintaining ---
a lightweight, scalable architecture—ready for future features like personalized
recommendations, saved events, social sharing, and collaborative event planning. ## Projektbeschreibung
**Encore** ist eine webbasierte Event-Discovery-Plattform. Nutzer können Konzerte, Sportveranstaltungen und Kulturanlässe in jeder Stadt direkt über die Ticketmaster-API suchen und filtern ohne sich durch überladene Buchungsportale kämpfen zu müssen.
Die drei Kernziele der Anwendung:
- **Entdecken**: Events nach Stadt, Datum und Kategorie filtern
- **Organisieren**: Interessante Events lokal speichern und in der eigenen Event-Liste verwalten
- **Teilen**: Andere registrierte Nutzer direkt zu Events einladen und auf Einladungen reagieren
---
## Besondere Features
| Feature | Beschreibung |
|---|---|
| **Ticketmaster-Integration** | Live-Daten über einen eigenen Backend-Proxy (API-Key bleibt serverseitig) |
| **Dreifachfilter** | Gleichzeitig nach Stadt, Datumsbereich und Kategorie filtern |
| **My Events** | Gespeicherte Events in einer eigenen Ansicht mit Entfernen-Option |
| **Einladungssystem** | Nutzer können andere per Inline-Formular zu Events einladen und Einladungen annehmen/ablehnen |
| **Vollständig responsiv** | Optimiert für Mobile, Tablet und Desktop (Bootstrap-Grid + eigenes CSS) |
| **XSS-Schutz** | Alle nutzerkontrollierten Daten werden via `textContent` und DOM-Methoden gerendert (kein `innerHTML` mit externen Daten) |
| **Accessibility (WCAG A)** | Semantisches HTML5, ARIA-Labels, sichtbare Labels für alle Formularfelder, `aria-live`-Regionen |
---
## Installation & lokale Ausführung
### 1. Repository klonen
## Setup and run
### Clone the repository
```bash ```bash
git clone https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt.git git clone https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt.git
cd AISE_FS26-FrontendEntwicklungProjekt cd AISE_FS26-FrontendEntwicklungProjekt
``` ```
### Install backend dependencies
### 2. Abhängigkeiten installieren
```bash ```bash
npm install npm install
``` ```
### Configure environment variables (at the moment API key is open to test)
### Start the backend server ### 3. Umgebungsvariable konfigurieren
Erstelle eine `.env`-Datei im Projektstamm:
```env
TM_API_KEY=dein_ticketmaster_api_key
```
Den API-Key erhältst du kostenlos unter [developer.ticketmaster.com](https://developer.ticketmaster.com).
*(Zum Testen ist im Repository vorübergehend ein Key hinterlegt.)*
### 4. Backend-Server starten
```bash ```bash
node server/server.js node server/server.js
``` ```
you should see:
```txt
Server running on http://localhost:3000
```
### Open index.html in your browser Ausgabe: `Server läuft auf http://localhost:3000`
### 5. Frontend öffnen
Öffne `index.html` direkt im Browser (kein separater Dev-Server nötig).
> **Tipp:** Verwende die Live Server Extension in VS Code für automatisches Reload.
---
## Verwendete Technologien, Libraries & Frameworks
| Kategorie | Technologie | Begründung |
|---|---|---|
| **Markup** | HTML5 (semantisch) | Strukturierung mit `<article>`, `<section>`, `<nav>`, `<header>`, `<footer>` |
| **Styling** | CSS3 (Flexbox, Grid) | Responsive Layout ohne Framework-Overhead |
| **CSS-Framework** | Bootstrap 5.3 | Schnelles responsives Grid, Utility-Klassen, Button-Stile |
| **Sprache** | Vanilla JavaScript (ES6+) | Module, Arrow Functions, Async/Await, Destructuring |
| **HTTP** | Fetch API | Native Browser-API für asynchrone Requests |
| **Backend** | Node.js + Express 5 | Leichtgewichtiger Server für API-Proxy und Nutzerverwaltung |
| **Externe API** | Ticketmaster Discovery API | Echtzeit-Eventdaten weltweit |
| **Umgebungsvariablen** | dotenv | API-Key sicher aus `.env` laden |
| **Datenpersistenz** | localStorage (Frontend) | Gespeicherte Events ohne Backend-Aufwand |
**Bewusst nicht verwendet:** JS-Frameworks (React, Vue, Angular) gemäss Projektanforderungen.
---
## Sicherheit (OWASP-Reflexion)
Im Rahmen des Projekts wurden folgende OWASP-Risiken berücksichtigt und dokumentiert:
### A03 Injection / XSS (Cross-Site Scripting)
**Risiko:** Nutzerdaten (Benutzernamen, Eventnamen aus der API) könnten Schadcode enthalten, der im Browser ausgeführt wird.
**Massnahme:** Alle externen Daten werden ausschliesslich via `element.textContent` gerendert niemals via `innerHTML` mit ungeprüften Werten. Beim Aufbau des Einladungs-Panels werden DOM-Methoden (`createElement`, `appendChild`) statt Template-Strings verwendet.
**Client-seitige Input-Validierung:** Formularfelder werden vor dem Absenden geprüft leere Pflichtfelder (Stadt, Benutzername, Passwort, Einladungs-Empfänger) werden mit einer Inline-Fehlermeldung abgewiesen und der Request wird nicht abgesendet. Ungültige Statuswerte (`PUT /api/invitation/:id`) werden serverseitig mit 400 Bad Request abgelehnt.
### A02 Cryptographic Failures / Sensitive Data Exposure
**Risiko:** Passwörter im Klartext, API-Key im Frontend.
**Massnahme:** Der Ticketmaster-API-Key liegt ausschliesslich serverseitig in `.env` der Client sieht ihn nie. Die Passwörter werden bewusst ungehasht gespeichert (Vereinfachung für den Prototyp); in einer Produktionsumgebung würden bcrypt und JWT-Tokens eingesetzt.
### A07 Identification and Authentication Failures
**Risiko:** Header-basierte Authentifizierung ohne Tokens ist nicht produktionstauglich.
**Massnahme:** Für diesen Prototyp bewusst vereinfacht. Die Einschränkung ist dokumentiert (kein HTTPS, kein Session-Management).
---
## Einsatz von KI-Werkzeugen
Im Projektverlauf wurden KI-Assistenten (Claude Code) eingesetzt für:
- **Code-Vervollständigung und Debugging**: Unterstützung bei der Implementierung des Einladungssystems und der XSS-sicheren Einladungsdarstellung
- **Code-Review**: Identifikation von Sicherheitslücken (XSS via innerHTML, falsche HTTP-Statuscodes)
- **Dokumentation**: Unterstützung beim Verfassen von JSDoc-Kommentaren und dieser README
Alle KI-generierten Vorschläge wurden kritisch geprüft, angepasst und manuell getestet. Die Verantwortung für den Code liegt bei den Entwicklerinnen und Entwicklern.
---
## Selbstreflexion
**Herausforderungen:**
- **Externe API-Integration**: Die Ticketmaster-API hat eine komplexe, verschachtelte JSON-Struktur. Das Mapping auf ein einfaches Datenmodell erforderte sorgfältige Analyse und optionale Chaining (`?.`).
- **Modulstruktur ohne Framework**: Die klare Trennung in `api/`, `services/`, `ui/` ohne ein Framework erforderte bewusste Architekturentscheidungen.
- **Statuscode-Konventionen**: Die korrekte Verwendung von HTTP-Statuscodes (201 für Ressource-Erstellung, 200 für erfolgreichen GET) musste erst explizit reflektiert werden.
- **XSS-Bewusstsein**: Das Risiko von `innerHTML` mit externen Daten war nicht von Anfang an präsent und wurde erst im Code-Review erkannt.
**Lerneffekte:**
- Tiefes Verständnis für Async/Await und Fehlerbehandlung in JavaScript
- Sensibilisierung für OWASP-Risiken bereits beim Schreiben von Frontend-Code
- Wert von semantischem HTML5 und Accessibility für Barrierefreiheit und SEO
---
## Gruppenmitglieder und Rollen
| Name | Rolle | Schwerpunkte |
|---|---|---|
| **Nicola Augsburger** | Frontend & Backend | Einladungssystem (Frontend + Backend), Authentifizierung, App-Architektur |
| **Pascal Schieman** | Frontend | Event-Karten-Komponente, CSS-Styling, Responsives Layout |
| **Karolina Thöny-Tyganova** | Frontend & API | Ticketmaster-API-Integration, Suchfunktion, Filterlogik |
Die individuelle Beteiligung ist in den Git-Commits auf [gitea.fhgr.ch](https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt) nachvollziehbar.

View File

@ -1,55 +1,351 @@
/* ========================= /* =========================
EVENT CARD COMPONENT EVENT-KARTE
Moderne Karte mit Hover-Effekt und farbigem Akzent-Streifen oben
========================= */ ========================= */
.event-card { .event-card {
background: white; background: var(--color-surface);
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 10px; border-radius: var(--radius-card);
padding: 1rem; padding: 1.25rem 1.25rem 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem;
transition: transform 0.2s, box-shadow 0.2s; box-shadow: var(--shadow-sm);
} position: relative;
overflow: hidden;
/* Hover only on devices that support it */ /* Erscheinungsanimation beim Einblenden ins DOM */
@media (hover: hover) { animation: fadeSlideUp 0.38s ease both;
will-change: opacity, transform;
transition: transform 0.22s ease, box-shadow 0.22s ease;
}
/* Gestaffelte Verzögerung für die ersten 9 Karten im Grid */
#event-list .event-card:nth-child(1),
#saved-list .event-card:nth-child(1) { animation-delay: 0.03s; }
#event-list .event-card:nth-child(2),
#saved-list .event-card:nth-child(2) { animation-delay: 0.07s; }
#event-list .event-card:nth-child(3),
#saved-list .event-card:nth-child(3) { animation-delay: 0.11s; }
#event-list .event-card:nth-child(4),
#saved-list .event-card:nth-child(4) { animation-delay: 0.15s; }
#event-list .event-card:nth-child(5),
#saved-list .event-card:nth-child(5) { animation-delay: 0.19s; }
#event-list .event-card:nth-child(6),
#saved-list .event-card:nth-child(6) { animation-delay: 0.23s; }
#event-list .event-card:nth-child(7),
#saved-list .event-card:nth-child(7) { animation-delay: 0.27s; }
#event-list .event-card:nth-child(8),
#saved-list .event-card:nth-child(8) { animation-delay: 0.31s; }
#event-list .event-card:nth-child(9),
#saved-list .event-card:nth-child(9) { animation-delay: 0.35s; }
/* Farbiger Akzent-Streifen oben zeigt Kategorie-Zugehörigkeit */
.event-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-light));
border-radius: var(--radius-card) var(--radius-card) 0 0;
}
/* Hover-Effekt nur auf Geräten mit Hover-Unterstützung (kein unbeabsichtigtes Verhalten auf Touch) */
@media (hover: hover) {
.event-card:hover { .event-card:hover {
transform: translateY(-3px); transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
}
} }
}
/* Title */ /* =========================
.event-card__title { KARTEN-INHALT
========================= */
/* Titel */
.event-card__title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin: 0.375rem 0 0.5rem;
line-height: 1.4;
/* Lange Titel auf 2 Zeilen begrenzen */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Datum & Uhrzeit in Akzentfarbe für schnelle Lesbarkeit */
.event-card__date {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-accent-dark);
margin: 0 0 0.3rem;
letter-spacing: 0.01em;
}
/* Veranstaltungsort */
.event-card__venue {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0 0 1rem;
/* Icon-ähnlicher Stil durch leichten Einzug */
padding-left: 0;
}
/* =========================
BUTTON-GRUPPE IN KARTE
Trennlinie separiert Inhalt von Aktionen
========================= */
.event-card .d-flex.gap-2 {
border-top: 1px solid var(--color-border);
padding-top: 0.875rem;
margin-top: auto;
}
/* =========================
EINLADUNGSFORMULAR (Inline)
Erscheint direkt in der Karte beim Klick auf "Einladen"
========================= */
.invite-form {
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
margin-top: 0.5rem;
}
.invite-form input[type="text"] {
font-size: 0.85rem;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-xs);
padding: 0.4rem 0.75rem;
height: 36px;
width: 100%;
font-family: inherit;
background: var(--color-bg);
color: var(--color-text);
transition: border-color 0.2s, box-shadow 0.2s;
}
.invite-form input[type="text"]:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(13, 148, 136, 0.15);
outline: none;
background: var(--color-surface);
}
.invite-form input[type="text"].is-invalid {
border-color: var(--color-danger);
}
/* =========================
STATUSMELDUNG IN KARTE
Temporäre Bestätigungs- oder Fehlermeldung (verschwindet nach 3s)
========================= */
.card-message {
font-size: 0.8rem;
font-weight: 500;
border-radius: var(--radius-xs);
padding: 0.35rem 0.6rem;
margin-top: 0.5rem;
margin-bottom: 0;
background: rgba(0, 0, 0, 0.03);
}
/* =========================
EINLADUNGS-KARTE (My Invitations)
========================= */
.invitation-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.invitation-card p {
margin: 0 0 0.75rem;
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.5;
}
.invitation-card p:last-child {
margin-bottom: 0;
}
/* =========================
AUTH MODAL
========================= */
.auth-modal-content {
border: none;
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.auth-modal-header {
background: var(--color-header);
padding: 1.25rem 1.5rem;
border-bottom: none;
}
.auth-modal-title {
color: #ffffff;
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 0.5rem; font-weight: 700;
} margin: 0;
}
/* Date */ .auth-modal-body {
.event-card__date { padding: 1.5rem;
color: gray; }
/* Tabs */
.auth-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border);
list-style: none;
padding: 0;
margin: 0;
}
.auth-tab-btn {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
padding: 0.5rem 1.25rem;
font-family: inherit;
font-size: 0.9rem; font-size: 0.9rem;
} font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
}
/* Venue */ .auth-tab-btn:hover {
.event-card__venue { color: var(--color-text);
}
.auth-tab-btn.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
/* Formularfelder im Modal */
.auth-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
margin-bottom: 0.35rem;
}
.auth-input {
border: 1.5px solid var(--color-border) !important;
border-radius: var(--radius-sm) !important;
font-family: inherit;
font-size: 0.9rem; font-size: 0.9rem;
} height: 42px;
color: var(--color-text);
background: var(--color-bg) !important;
transition: border-color var(--transition), box-shadow var(--transition);
}
/* ========================= .auth-input:focus {
FORM ELEMENT IMPROVEMENTS border-color: var(--color-accent) !important;
========================= */ box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15) !important;
outline: none;
background: var(--color-surface) !important;
}
button, .auth-error {
input { font-size: 0.85rem;
min-height: 44px; color: var(--color-danger);
} margin-bottom: 0.75rem;
}
button:focus, /* Primärer Modal-Button (teal) */
input:focus { .btn-accent {
outline: 2px solid #007bff; background: var(--color-accent);
} color: #fff;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
height: 42px;
cursor: pointer;
transition: background var(--transition), transform var(--transition), box-shadow var(--transition);
}
.btn-accent:hover {
background: var(--color-accent-dark);
color: #fff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.3);
}
.btn-accent:active {
transform: translateY(0);
}
/* Registrierungs-Erfolgsmeldung */
.register-success {
background: rgba(16, 185, 129, 0.07);
border: 1px solid rgba(16, 185, 129, 0.25);
border-radius: var(--radius-sm);
padding: 1rem;
color: var(--color-text);
}
/* Passwort-Anzeige nach Registrierung */
.password-display {
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-xs);
padding: 0.6rem 1rem;
text-align: center;
margin-top: 0.5rem;
}
.password-display code {
font-size: 1.375rem;
font-weight: 700;
color: var(--color-accent);
letter-spacing: 0.15em;
}
/* Hinweistext unter Passwort */
.text-muted-custom {
color: var(--color-text-muted);
}
/* =========================
GLOBALE FORMULAR-VERBESSERUNGEN
Mindest-Klickfläche (WCAG) und Focus-Ring
========================= */
button {
min-height: 36px;
}
input,
select,
textarea {
min-height: 36px;
}
/* Sichtbarer Fokus-Ring für Tastatur-Navigation (WCAG A) */
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}

View File

@ -1,16 +1,52 @@
/* ========================= /* =========================
RESET & BASE INTER FONT (Google Fonts)
========================= */ ========================= */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
* { /* =========================
CSS CUSTOM PROPERTIES (Design Tokens)
========================= */
:root {
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-header: #0f172a;
--color-accent: #0d9488;
--color-accent-light: #14b8a6;
--color-accent-dark: #0f766e;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--color-danger: #ef4444;
--color-success: #10b981;
--radius-xs: 6px;
--radius-sm: 10px;
--radius-md: 14px;
--radius-card: 18px;
--shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.04);
--shadow-md: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
--transition: 0.2s ease;
}
/* =========================
RESET & BASIS
========================= */
*, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
font-family: Arial, sans-serif; font-family: 'Inter', Arial, sans-serif;
background-color: #f9f9f9; background-color: var(--color-bg);
color: #333; color: var(--color-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
img { img {
@ -19,105 +55,388 @@ img {
} }
/* ========================= /* =========================
GLOBAL LAYOUT KARTEN-ERSCHEINUNGS-ANIMATION
Wird auf Event-Karten angewendet wenn sie ins DOM kommen
========================= */ ========================= */
@keyframes fadeSlideUp {
main { from {
padding: 1rem; opacity: 0;
transform: translateY(22px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
section { /* Shimmer-Animation für Skeleton-Karten (transform statt background-position → GPU-composited) */
margin-bottom: 2rem; @keyframes shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(200%); }
} }
/* ========================= /* =========================
HEADER HEADER Dunkel & Sticky
========================= */ ========================= */
.site-header { .site-header {
background-color: #3dd0a9; background-color: var(--color-header);
color: #222; /* fixed contrast */ padding: 0.75rem 0;
padding: 1rem; position: sticky;
text-align: center; top: 0;
z-index: 200;
box-shadow: 0 1px 0 rgba(255,255,255,0.06), 0 2px 16px rgba(0,0,0,0.2);
} }
/* ========================= .site-logo {
SEARCH SECTION font-size: 1.5rem;
========================= */ font-weight: 800;
color: var(--color-accent-light);
.event-search h2 { margin: 0;
margin-bottom: 0.5rem; letter-spacing: -0.04em;
-webkit-user-select: none;
user-select: none;
} }
.search__controls { .site-header .nav-link {
color: rgba(255,255,255,0.65);
font-size: 0.875rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-xs);
transition: color var(--transition), background var(--transition);
text-decoration: none;
}
.site-header .nav-link:hover {
color: #fff;
background: rgba(255,255,255,0.08);
}
/* Anmeldebereich nur noch Buttons */
#auth-area {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
/* ========================= .header-btn-outline {
EVENTS LIST background: transparent;
========================= */ border: 1.5px solid rgba(255,255,255,0.25);
border-radius: var(--radius-xs);
.events h2 { color: rgba(255,255,255,0.8);
margin-bottom: 1rem; font-weight: 500;
font-size: 0.875rem;
padding: 0.4rem 1rem;
cursor: pointer;
transition: border-color var(--transition), color var(--transition);
white-space: nowrap;
} }
#event-list { .header-btn-outline:hover {
border-color: rgba(255,255,255,0.55);
color: #fff;
}
.header-btn-accent {
background: var(--color-accent);
border: none;
border-radius: var(--radius-xs);
color: #fff;
font-weight: 600;
font-size: 0.875rem;
padding: 0.4rem 1rem;
cursor: pointer;
transition: background var(--transition);
white-space: nowrap;
}
.header-btn-accent:hover {
background: var(--color-accent-dark);
color: #fff;
}
/* Nutzerbereich (eingeloggt) */
#user-area {
display: flex;
align-items: center;
gap: 0.625rem;
}
#user-name {
color: rgba(255,255,255,0.85);
font-size: 0.875rem;
font-weight: 500;
}
/* =========================
HAUPTINHALT
========================= */
main {
min-height: calc(100vh - 200px);
}
/* =========================
HERO-BEREICH (Suchsektion)
Volle Breite, dunkler Gradient-Hintergrund
========================= */
.hero {
background:
radial-gradient(ellipse 70% 80% at 15% 60%, rgba(13,148,136,0.15) 0%, transparent 60%),
radial-gradient(ellipse 50% 60% at 85% 20%, rgba(20,184,166,0.08) 0%, transparent 55%),
linear-gradient(155deg, #0f172a 0%, #1a2744 45%, #0c1a2c 100%);
padding: 4.5rem 0 3.5rem;
position: relative;
overflow: hidden;
}
/* Dekoratives Gitternetz-Muster im Hintergrund */
.hero::before {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
.hero__text {
text-align: center;
margin-bottom: 2.5rem;
position: relative;
}
.hero__title {
font-size: clamp(2.25rem, 5vw, 3.5rem);
font-weight: 800;
color: #ffffff;
letter-spacing: -0.04em;
margin: 0 0 0.625rem;
line-height: 1.1;
}
/* Teal Akzent auf dem letzten Wort */
.hero__title span {
color: var(--color-accent-light);
}
.hero__subtitle {
font-size: 1.05rem;
color: rgba(255,255,255,0.5);
margin: 0;
font-weight: 400;
letter-spacing: 0.01em;
}
/* Suchkarte schwebend über dem Hero-Hintergrund */
.hero .search {
background: rgba(255,255,255,0.98);
border: none;
border-radius: var(--radius-card);
padding: 1.5rem 1.75rem;
box-shadow:
0 30px 80px rgba(0,0,0,0.4),
0 8px 30px rgba(0,0,0,0.25),
0 0 0 1px rgba(255,255,255,0.1);
position: relative;
}
/* =========================
SUCHFORMULAR
========================= */
.search__controls {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.search__controls .form-control,
.search__controls input[type="date"],
.search__controls select {
background: var(--color-bg);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-family: inherit;
font-size: 0.9rem;
padding: 0.55rem 0.9rem;
height: 42px;
transition: border-color var(--transition), box-shadow var(--transition);
}
.search__controls .form-control:focus,
.search__controls input[type="date"]:focus,
.search__controls select:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(13,148,136,0.15);
outline: none;
background: var(--color-surface);
}
.search__controls label {
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
}
#load-events {
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
padding: 0 1.5rem;
height: 42px;
cursor: pointer;
transition: background var(--transition), transform var(--transition), box-shadow var(--transition);
white-space: nowrap;
}
#load-events:hover {
background: var(--color-accent-dark);
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(13,148,136,0.35);
}
#load-events:active {
transform: translateY(0);
}
/* =========================
SECTION (EVENTS / SAVED / INVITATIONS)
========================= */
.events-section {
padding: 2.5rem 0 1rem;
}
.events-section h2 {
font-size: 1.375rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 1.5rem;
letter-spacing: -0.02em;
}
/* =========================
EVENT-GRID
========================= */
#event-list,
#saved-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
#invitation-list {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 640px;
}
/* =========================
EMPTY STATE
========================= */
.empty-state {
text-align: center;
padding: 3.5rem 1rem;
color: var(--color-text-muted);
}
.empty-state__icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state__text {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text);
margin: 0 0 0.375rem;
}
.empty-state__sub {
font-size: 0.9rem;
margin: 0;
}
/* =========================
SKELETON LOADER
Shimmer via ::after Pseudo-Element + transform (GPU-composited)
========================= */
.skeleton-card {
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
min-height: 180px;
position: relative;
overflow: hidden;
}
.skeleton-card::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.65) 50%,
transparent 100%
);
transform: translateX(-100%);
animation: shimmer 1.6s ease-in-out infinite;
will-change: transform;
}
/* ========================= /* =========================
FOOTER FOOTER
========================= */ ========================= */
.site-footer {
background-color: var(--color-header);
color: rgba(255,255,255,0.35);
font-size: 0.85rem;
padding: 1.75rem 0;
margin-top: 3rem;
}
footer { .site-footer p {
text-align: center; margin: 0;
padding: 1rem;
margin-top: 2rem;
font-size: 0.9rem;
color: #777;
} }
/* ========================= /* =========================
TABLET (>=768px) TABLET (768px)
========================= */ ========================= */
@media (min-width: 768px) { @media (min-width: 768px) {
.search__controls { .search__controls {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.search__controls input, #city-input { flex: 2; min-width: 160px; }
.search__controls select { #date-from,
flex: 1; #date-to { flex: 1; min-width: 130px; }
min-width: 120px; #category-input { flex: 0 0 160px; }
}
.search__controls button { #event-list,
flex: 0 0 auto; #saved-list {
min-width: 120px;
}
#event-list {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
} }
} }
/* ========================= /* =========================
DESKTOP (>=1024px) DESKTOP (1024px)
========================= */ ========================= */
@media (min-width: 1024px) { @media (min-width: 1024px) {
#event-list,
#event-list { #saved-list {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
} }

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@ -7,144 +7,207 @@
<title>Encore Discover Events</title> <title>Encore Discover Events</title>
<!-- Bootstrap --> <!-- Bootstrap 5.3 CSS-Framework für responsives Layout -->
<link <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"> rel="stylesheet">
<!-- CSS --> <!-- Eigene Styles -->
<link rel="stylesheet" href="css/components.css"> <link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
</head> </head>
<body> <body>
<!-- HEADER --> <!-- HEADER Hauptnavigation und Authentifizierungsbereich -->
<header class="site-header"> <header class="site-header" role="banner">
<nav class="container d-flex justify-content-between align-items-center"> <nav class="container d-flex justify-content-between align-items-center" aria-label="Hauptnavigation">
<h1 class="site-logo">Encore</h1> <h1 class="site-logo">Encore</h1>
<ul class="nav"> <ul class="nav" role="list">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="#" id="nav-events">Events</a> <a class="nav-link" href="#" id="nav-events" aria-label="Zur Event-Suche">Events</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link d-none" href="#" id="nav-my-events">My Events</a> <a class="nav-link d-none" href="#" id="nav-my-events" aria-label="Meine gespeicherten Events">My Events</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link d-none" href="#" id="nav-invitations">Invitations</a> <a class="nav-link d-none" href="#" id="nav-invitations" aria-label="Meine Einladungen">Invitations</a>
</li> </li>
</ul> </ul>
<!-- LOGIN AREA --> <!-- ANMELDEBEREICH öffnet Modal -->
<div id="auth-area"> <div id="auth-area" role="region" aria-label="Anmeldung" class="d-flex gap-2">
<input id="username" placeholder="Username" class="form-control d-inline w-auto"> <button id="login-btn" type="button" class="btn header-btn-outline" aria-label="Anmelden">Login</button>
<input id="password" type="password" placeholder="Password" class="form-control d-inline w-auto"> <button id="register-btn" type="button" class="btn header-btn-accent" aria-label="Neues Konto erstellen">Register</button>
<button id="login-btn" class="btn btn-success">Login</button>
<button id="register-btn" class="btn btn-secondary">Register</button>
</div> </div>
<!-- USER AREA --> <!-- NUTZERBEREICH (sichtbar wenn eingeloggt) -->
<div id="user-area" class="d-none"> <div id="user-area" class="d-none" role="region" aria-label="Nutzerprofil">
<span id="user-name"></span> <span id="user-name" aria-live="polite"></span>
<button id="logout-btn" class="btn btn-danger btn-sm">Logout</button> <button id="logout-btn" type="button" class="btn header-btn-outline btn-sm" aria-label="Abmelden">Logout</button>
</div> </div>
</nav> </nav>
</header> </header>
<!-- MAIN CONTENT --> <!-- AUTH MODAL -->
<main class="container mt-4"> <div class="modal fade" id="auth-modal" tabindex="-1" aria-labelledby="auth-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content auth-modal-content">
<!-- SEARCH / FILTER SECTION --> <div class="modal-header auth-modal-header">
<section class="search mb-4" id="search-section"> <h2 class="modal-title auth-modal-title" id="auth-modal-label">Willkommen bei Encore</h2>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Schliessen"></button>
</div>
<h2>Find Events</h2> <div class="modal-body auth-modal-body">
<ul class="nav auth-tabs mb-4" id="auth-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="auth-tab-btn active" id="login-tab-btn" data-bs-toggle="tab"
data-bs-target="#login-pane" type="button" role="tab"
aria-controls="login-pane" aria-selected="true">Anmelden</button>
</li>
<li class="nav-item" role="presentation">
<button class="auth-tab-btn" id="register-tab-btn" data-bs-toggle="tab"
data-bs-target="#register-pane" type="button" role="tab"
aria-controls="register-pane" aria-selected="false">Registrieren</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="login-pane" role="tabpanel" aria-labelledby="login-tab-btn">
<div class="mb-3">
<label for="modal-username" class="auth-label">Benutzername</label>
<input type="text" id="modal-username" class="form-control auth-input"
placeholder="Dein Benutzername" autocomplete="username">
</div>
<div class="mb-3">
<label for="modal-password" class="auth-label">Passwort</label>
<input type="password" id="modal-password" class="form-control auth-input"
placeholder="Dein Passwort" autocomplete="current-password">
</div>
<p id="login-error" class="auth-error" hidden></p>
<button type="button" id="modal-login-btn" class="btn btn-accent w-100">Anmelden</button>
</div>
<div class="tab-pane fade" id="register-pane" role="tabpanel" aria-labelledby="register-tab-btn">
<div class="mb-3">
<label for="modal-reg-username" class="auth-label">Benutzername wählen</label>
<input type="text" id="modal-reg-username" class="form-control auth-input"
placeholder="z.B. maxmustermann" autocomplete="username">
</div>
<p id="register-error" class="auth-error" hidden></p>
<div id="register-success" class="register-success d-none mb-3" aria-live="polite">
<p class="mb-2 fw-semibold">Konto erstellt!</p>
<p class="mb-1 small text-muted">Dein automatisch generiertes Passwort:</p>
<div class="password-display">
<code id="reg-password"></code>
</div>
<p class="small mt-2 mb-0 text-muted-custom">
Notiere dir das Passwort du brauchst es beim nächsten Login.
</p>
</div>
<button type="button" id="modal-register-btn" class="btn btn-accent w-100">Konto erstellen</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- HAUPTINHALT -->
<main id="main-content">
<!-- HERO / SUCHBEREICH Volle Breite mit Gradient-Hintergrund -->
<section class="hero" id="search-section" aria-labelledby="search-heading">
<div class="container">
<div class="hero__text">
<h2 class="hero__title" id="search-heading">Events entdecken.</h2>
<p class="hero__subtitle">Konzerte, Sport &amp; Kultur weltweit, in Echtzeit.</p>
</div>
<!-- Schwebende Suchkarte -->
<div class="search" role="search">
<div class="search__controls"> <div class="search__controls">
<!-- City --> <label for="city-input" class="visually-hidden">Stadt eingeben</label>
<input <input
id="city-input" id="city-input"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Enter city (e.g. Zurich)" placeholder="Stadt (z.B. Zürich)"
aria-label="Stadt für Event-Suche"
> >
<!-- Date --> <label for="date-from">Von</label>
<label for="date-from">From</label> <input id="date-from" type="date" aria-label="Startdatum">
<input id="date-from" type="date">
<label for="date-to">To</label> <label for="date-to">Bis</label>
<input id="date-to" type="date"> <input id="date-to" type="date" aria-label="Enddatum">
<!-- Category --> <label for="category-input" class="visually-hidden">Kategorie</label>
<select id="category-input" class="form-control"> <select id="category-input" class="form-control" aria-label="Event-Kategorie">
<option value="">All categories</option> <option value="">Alle Kategorien</option>
<option value="music">Music</option> <option value="music">Musik</option>
<option value="sports">Sports</option> <option value="sports">Sport</option>
<option value="arts & theatre">Arts & Theatre</option> <option value="arts & theatre">Kunst & Theater</option>
</select> </select>
<!-- Button --> <button id="load-events" type="button" class="btn btn-primary" aria-label="Events suchen">
<button id="load-events" class="btn btn-primary"> Suchen
Search
</button> </button>
</div> </div>
</section>
<!-- EVENTS LIST -->
<section class="events" id="events-section">
<h2>Upcoming Events</h2>
<div id="event-list">
<p class="text-muted">Search for events to begin.</p>
</div> </div>
</div>
</section> </section>
<!-- ========================= --> <!-- EVENTLISTE Suchergebnisse -->
<!-- MY EVENTS --> <section class="events-section container" id="events-section" aria-labelledby="events-heading">
<!-- ========================= --> <h2 id="events-heading">Kommende Events</h2>
<section id="saved-section" class="d-none"> <div id="event-list" aria-live="polite" aria-label="Event-Ergebnisse">
<div class="empty-state">
<h2>My Events</h2> <div class="empty-state__icon" aria-hidden="true">🔍</div>
<p class="empty-state__text">Suche nach Events um zu starten</p>
<div id="saved-list"> <p class="empty-state__sub">Gib eine Stadt ein und klicke auf Suchen</p>
<p class="text-muted">No saved events yet.</p> </div>
</div> </div>
</section> </section>
<!-- ========================= --> <!-- MY EVENTS Gespeicherte Events -->
<!-- INVITATIONS --> <section id="saved-section" class="events-section container d-none" aria-labelledby="saved-heading">
<!-- ========================= --> <h2 id="saved-heading">My Events</h2>
<section id="invitations-section" class="d-none"> <div id="saved-list" aria-live="polite">
<p class="text-muted">Noch keine gespeicherten Events.</p>
<h2>My Invitations</h2>
<div id="invitation-list">
<p class="text-muted">No invitations yet.</p>
</div> </div>
</section>
<!-- EINLADUNGEN -->
<section id="invitations-section" class="events-section container d-none" aria-labelledby="invitations-heading">
<h2 id="invitations-heading">Meine Einladungen</h2>
<div id="invitation-list" aria-live="polite">
<p class="text-muted">Keine Einladungen vorhanden.</p>
</div>
</section> </section>
</main> </main>
<!-- FOOTER --> <!-- FOOTER -->
<footer class="site-footer text-center mt-5 p-3"> <footer class="site-footer text-center" role="contentinfo">
<p>© 2026 Encore Event Discovery</p> <p>© 2026 Encore Event Discovery</p>
</footer> </footer>
<!-- JS --> <!-- Bootstrap JS (für Modal und Tabs) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- JavaScript ES-Module -->
<script type="module" src="js/app.js"></script> <script type="module" src="js/app.js"></script>
</body> </body>

424
js/app.js
View File

@ -1,32 +1,60 @@
/**
* app.js Haupteinstiegspunkt der Encore-Webanwendung.
* Koordiniert Authentifizierung (via Modal), Navigation, Suche,
* gespeicherte Events und Einladungen.
*/
import { getEvents } from "./services/eventService.js"; import { getEvents } from "./services/eventService.js";
import { renderEventList } from "./ui/eventList.js"; import { renderEventList } from "./ui/eventList.js";
import { getFilters } from "./ui/filters.js"; import { getFilters } from "./ui/filters.js";
import { login, register } from "./auth.js"; import { login, register } from "./auth.js";
import { createEventCard } from "./ui/eventCard.js";
// ========================= // =========================
// STATE // GLOBALER ZUSTAND
// window-Properties damit eventCard.js auf den Nutzer zugreifen kann
// ========================= // =========================
let currentUser = null; let currentUser = null;
let currentPassword = null; let currentPassword = null;
window.currentUser = null; window.currentUser = null;
window.currentPassword = null; window.currentPassword = null;
// ========================= // =========================
// AUTH ELEMENTS // DOM HEADER AUTH
// ========================= // =========================
const loginBtn = document.querySelector("#login-btn"); const loginBtn = document.querySelector("#login-btn");
const registerBtn = document.querySelector("#register-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");
// ========================= // =========================
// NAVIGATION ELEMENTS // 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 navEvents = document.querySelector("#nav-events");
const navSaved = document.querySelector("#nav-my-events"); const navSaved = document.querySelector("#nav-my-events");
const navInv = document.querySelector("#nav-invitations"); const navInv = document.querySelector("#nav-invitations");
// ========================= // =========================
// SECTIONS // DOM SEKTIONEN
// ========================= // =========================
const searchSection = document.querySelector("#search-section"); const searchSection = document.querySelector("#search-section");
const eventsSection = document.querySelector("#events-section"); const eventsSection = document.querySelector("#events-section");
@ -34,70 +62,147 @@ const savedSection = document.querySelector("#saved-section");
const invSection = document.querySelector("#invitations-section"); const invSection = document.querySelector("#invitations-section");
// ========================= // =========================
// AUTH UI // MODAL ÖFFNEN Korrekte Tab-Vorauswahl
// ========================= // =========================
const authArea = document.querySelector("#auth-area"); loginBtn.addEventListener("click", () => {
const userArea = document.querySelector("#user-area"); bootstrap.Tab.getOrCreateInstance(loginTabBtn).show();
const userNameEl = document.querySelector("#user-name"); bsModal.show();
const logoutBtn = document.querySelector("#logout-btn"); });
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 // LOGIN VIA MODAL
// ========================= // =========================
loginBtn.addEventListener("click", async () => { modalLoginBtn.addEventListener("click", async () => {
const username = modalUsername.value.trim();
const password = modalPassword.value;
const username = document.querySelector("#username").value; loginError.hidden = true;
const password = document.querySelector("#password").value;
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); const success = await login(username, password);
if (success) { if (success) {
currentUser = username; currentUser = username;
currentPassword = password; currentPassword = password;
window.currentUser = username; window.currentUser = username;
window.currentPassword = password; window.currentPassword = password;
// UI switch bsModal.hide();
authArea.classList.add("d-none"); authArea.classList.add("d-none");
userArea.classList.remove("d-none"); userArea.classList.remove("d-none");
userNameEl.textContent = `👤 ${username}`; userNameEl.textContent = `👤 ${username}`;
navSaved.classList.remove("d-none"); navSaved.classList.remove("d-none");
navInv.classList.remove("d-none"); navInv.classList.remove("d-none");
} else { } else {
alert("Login failed"); loginError.textContent = "Login fehlgeschlagen. Bitte Zugangsdaten prüfen.";
loginError.hidden = false;
}
} catch (err) {
loginError.textContent = "Verbindungsfehler. Ist der Server erreichbar?";
loginError.hidden = false;
console.error("Login-Fehler:", err);
} finally {
modalLoginBtn.disabled = false;
modalLoginBtn.textContent = "Anmelden";
} }
}); });
// ========================= // Enter in Passwort-Feld löst Login aus
// REGISTER modalPassword.addEventListener("keydown", (e) => {
// ========================= if (e.key === "Enter") modalLoginBtn.click();
registerBtn.addEventListener("click", async () => { });
const username = document.querySelector("#username").value; // =========================
// 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); const data = await register(username);
alert(`User created. Password: ${data.password}`); 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";
console.error("Registrierungs-Fehler:", err);
}
}); });
// ========================= // =========================
// LOGOUT // LOGOUT
// ========================= // =========================
logoutBtn.addEventListener("click", () => { logoutBtn.addEventListener("click", () => {
currentUser = null; currentUser = null;
currentPassword = null; currentPassword = null;
window.currentUser = null; window.currentUser = null;
window.currentPassword = null; window.currentPassword = null;
authArea.classList.remove("d-none"); authArea.classList.remove("d-none");
userArea.classList.add("d-none"); userArea.classList.add("d-none");
navSaved.classList.add("d-none"); navSaved.classList.add("d-none");
navInv.classList.add("d-none"); navInv.classList.add("d-none");
@ -105,73 +210,84 @@ logoutBtn.addEventListener("click", () => {
}); });
// ========================= // =========================
// SEARCH // SUCHE
// ========================= // =========================
const button = document.querySelector("#load-events"); const searchBtn = document.querySelector("#load-events");
const container = document.querySelector("#event-list"); const eventListEl = document.querySelector("#event-list");
const cityInput = document.querySelector("#city-input"); const cityInput = document.querySelector("#city-input");
button.addEventListener("click", handleSearch); searchBtn.addEventListener("click", handleSearch);
cityInput.addEventListener("keydown", (event) => { cityInput.addEventListener("keydown", (e) => {
if (event.key === "Enter") handleSearch(); if (e.key === "Enter") handleSearch();
}); });
async function handleSearch() { async function handleSearch() {
const { city, dateFrom, dateTo, category } = getFilters(); const { city, dateFrom, dateTo, category } = getFilters();
if (!city) { if (!city) {
container.innerHTML = "<p class='text-danger'>Please enter a city.</p>"; showError(eventListEl, "Bitte eine Stadt eingeben.");
return; return;
} }
container.innerHTML = "<p class='text-muted'>Loading events...</p>"; showSkeletons(eventListEl);
try {
const events = await getEvents(city); const events = await getEvents(city);
const filtered = applyFilters(events, dateFrom, dateTo, category);
renderEventList(filtered, eventListEl);
} catch (err) {
showError(eventListEl, "Fehler beim Laden der Events. Bitte erneut versuchen.");
console.error("Such-Fehler:", err);
}
}
const filteredEvents = applyFilters(events, dateFrom, dateTo, category); /** Zeigt Skeleton-Platzhalterkarten während Events geladen werden. */
function showSkeletons(container, count = 6) {
renderEventList(filteredEvents, container); 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);
}
} }
// ========================= // =========================
// FILTERS // CLIENT-SEITIGE FILTER
// ========================= // =========================
function applyFilters(events, dateFrom, dateTo, category) { function applyFilters(events, dateFrom, dateTo, category) {
return events.filter(event => { return events.filter(event => {
const matchDateFrom = dateFrom ? event.date >= dateFrom : true; const matchDateFrom = dateFrom ? event.date >= dateFrom : true;
const matchDateTo = dateTo ? event.date <= dateTo : true; const matchDateTo = dateTo ? event.date <= dateTo : true;
const matchCategory = category const matchCategory = category
? event.category && event.category.includes(category) ? event.category && event.category.toLowerCase().includes(category.toLowerCase())
: true; : true;
return matchDateFrom && matchDateTo && matchCategory; return matchDateFrom && matchDateTo && matchCategory;
}); });
} }
// ========================= // =========================
// NAVIGATION LOGIC // NAVIGATION
// ========================= // =========================
navEvents.addEventListener("click", () => { navEvents.addEventListener("click", (e) => {
e.preventDefault();
showSection("events"); showSection("events");
}); });
navSaved.addEventListener("click", () => { navSaved.addEventListener("click", (e) => {
e.preventDefault();
showSection("saved"); showSection("saved");
loadSavedEvents(); loadSavedEvents();
}); });
navInv.addEventListener("click", () => { navInv.addEventListener("click", (e) => {
e.preventDefault();
showSection("invitations"); showSection("invitations");
loadInvitations(); loadInvitations();
}); });
function showSection(section) { function showSection(section) {
searchSection.classList.add("d-none"); searchSection.classList.add("d-none");
eventsSection.classList.add("d-none"); eventsSection.classList.add("d-none");
savedSection.classList.add("d-none"); savedSection.classList.add("d-none");
@ -180,89 +296,209 @@ function showSection(section) {
if (section === "events") { if (section === "events") {
searchSection.classList.remove("d-none"); searchSection.classList.remove("d-none");
eventsSection.classList.remove("d-none"); eventsSection.classList.remove("d-none");
} } else if (section === "saved") {
if (section === "saved") {
savedSection.classList.remove("d-none"); savedSection.classList.remove("d-none");
} } else if (section === "invitations") {
if (section === "invitations") {
invSection.classList.remove("d-none"); invSection.classList.remove("d-none");
} }
} }
// ========================= // =========================
// SAVED EVENTS // GESPEICHERTE EVENTS (MY EVENTS)
// ========================= // =========================
function loadSavedEvents() { function loadSavedEvents() {
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]"); const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
const container = document.querySelector("#saved-list"); const container = document.querySelector("#saved-list");
container.innerHTML = ""; container.innerHTML = "";
if (saved.length === 0) { if (saved.length === 0) {
container.innerHTML = "<p>No saved events yet.</p>"; const p = document.createElement("p");
p.className = "text-muted";
p.textContent = "Noch keine gespeicherten Events.";
container.appendChild(p);
return; return;
} }
saved.forEach(event => { saved.forEach(event => {
const div = document.createElement("div"); const card = createEventCard(event);
div.textContent = event.name;
container.appendChild(div); 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));
}
// ========================= // =========================
// INVITATIONS // EINLADUNGEN
// ========================= // =========================
async function loadInvitations() { async function loadInvitations() {
if (!currentUser) return; if (!currentUser) return;
const container = document.querySelector("#invitation-list");
container.innerHTML = "<p class='text-muted'>Einladungen werden geladen...</p>";
try {
const res = await fetch("http://localhost:3000/api/invitation", { const res = await fetch("http://localhost:3000/api/invitation", {
headers: { headers: { "X-Username": currentUser }
"X-Username": currentUser
}
}); });
const data = await res.json(); if (!res.ok) throw new Error(`Server antwortete mit Status ${res.status}`);
const container = document.querySelector("#invitation-list");
const data = await res.json();
container.innerHTML = ""; container.innerHTML = "";
if (data.length === 0) { if (data.length === 0) {
container.innerHTML = "<p>No invitations.</p>"; const p = document.createElement("p");
p.className = "text-muted";
p.textContent = "Keine Einladungen vorhanden.";
container.appendChild(p);
return; return;
} }
data.forEach(inv => { data.forEach(inv => renderInvitation(inv, container));
const div = document.createElement("div"); } catch (err) {
showError(container, "Fehler beim Laden der Einladungen.");
console.error("Einladungs-Fehler:", err);
}
}
div.innerHTML = ` /**
<p><strong>${inv.fromUser}</strong> invited you to <em>${inv.eventName}</em></p> * Rendert eine Einladungskarte ausschliesslich DOM-Methoden (kein innerHTML mit Nutzerdaten).
<button onclick="accept(${inv.id})">Accept</button> * Jede Karte hat einen Löschen-Button (demonstriert HTTP DELETE).
<button onclick="decline(${inv.id})">Decline</button> */
`; function renderInvitation(inv, container) {
const card = document.createElement("div");
card.className = "invitation-card";
container.appendChild(div); 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.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);
console.error("Antwort-Fehler:", err);
}
}
/**
* 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 200299 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);
console.error("Lösch-Fehler:", err);
}
} }
// ========================= // =========================
// ACCEPT / DECLINE // UTILITY Fehlermeldung (XSS-sicher via textContent)
// ========================= // =========================
window.accept = async (id) => { function showError(container, message) {
await fetch(`http://localhost:3000/api/invitation/${id}/accept`, { const p = document.createElement("p");
method: "POST" p.className = "text-danger";
}); p.textContent = message;
loadInvitations(); container.innerHTML = "";
}; container.appendChild(p);
}
window.decline = async (id) => {
await fetch(`http://localhost:3000/api/invitation/${id}/decline`, {
method: "POST"
});
loadInvitations();
};