Redesign frontend with hero section, auth modal, and animations

- Replace header auth inputs with Bootstrap modal (Login/Register tabs)
- Add hero section with dark gradient, decorative grid lines, teal glows
- Floating search card with strong shadow inside hero
- Add card entrance animation (fadeSlideUp) with staggered nth-child delays
- Add skeleton loader shown while events are fetching
- Add empty state components with emoji icon and description
- Remove all alert() calls, replace with inline DOM error messages
- Add CSS custom properties for consistent design tokens
- Add Inter font, modern typography scale
- Document input validation under OWASP A03 in README
This commit is contained in:
Nandrx 2026-05-31 17:52:40 +02:00
parent ae3692697b
commit 53f9691376
5 changed files with 1378 additions and 359 deletions

161
README.md
View File

@ -1,45 +1,150 @@
# Encore
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.
# Encore Event Discovery
The product centers on three core goals:
- **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.
Frontend-Projekt für das Modul cds-208 an der FHGR (Frühlingssemester 2026).
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
git clone https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt.git
cd AISE_FS26-FrontendEntwicklungProjekt
```
### Install backend dependencies
### 2. Abhängigkeiten installieren
```bash
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
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 {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 1.25rem 1.25rem 1rem;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
/* Erscheinungsanimation beim Einblenden ins DOM */
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 {
transform: translateY(-5px);
box-shadow: var(--shadow-lg);
}
}
/* =========================
KARTEN-INHALT
========================= */
.event-card {
background: white;
border: 1px solid #ddd;
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
transition: transform 0.2s, box-shadow 0.2s;
}
/* Hover only on devices that support it */
@media (hover: hover) {
.event-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
/* Title */
.event-card__title {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
/* Date */
.event-card__date {
color: gray;
font-size: 0.9rem;
}
/* Venue */
.event-card__venue {
font-size: 0.9rem;
}
/* =========================
FORM ELEMENT IMPROVEMENTS
========================= */
button,
input {
min-height: 44px;
}
button:focus,
input:focus {
outline: 2px solid #007bff;
}
/* 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-weight: 700;
margin: 0;
}
.auth-modal-body {
padding: 1.5rem;
}
/* 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-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
}
.auth-tab-btn:hover {
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;
height: 42px;
color: var(--color-text);
background: var(--color-bg) !important;
transition: border-color var(--transition), box-shadow var(--transition);
}
.auth-input:focus {
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;
}
.auth-error {
font-size: 0.85rem;
color: var(--color-danger);
margin-bottom: 0.75rem;
}
/* Primärer Modal-Button (teal) */
.btn-accent {
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;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background-color: #f9f9f9;
color: #333;
font-family: 'Inter', Arial, sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img {
@ -19,105 +55,388 @@ img {
}
/* =========================
GLOBAL LAYOUT
KARTEN-ERSCHEINUNGS-ANIMATION
Wird auf Event-Karten angewendet wenn sie ins DOM kommen
========================= */
main {
padding: 1rem;
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(22px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
section {
margin-bottom: 2rem;
/* Shimmer-Animation für Skeleton-Karten (transform statt background-position → GPU-composited) */
@keyframes shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(200%); }
}
/* =========================
HEADER
HEADER Dunkel & Sticky
========================= */
.site-header {
background-color: #3dd0a9;
color: #222; /* fixed contrast */
padding: 1rem;
text-align: center;
background-color: var(--color-header);
padding: 0.75rem 0;
position: sticky;
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);
}
/* =========================
SEARCH SECTION
========================= */
.event-search h2 {
margin-bottom: 0.5rem;
.site-logo {
font-size: 1.5rem;
font-weight: 800;
color: var(--color-accent-light);
margin: 0;
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;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
/* =========================
EVENTS LIST
========================= */
.events h2 {
margin-bottom: 1rem;
.header-btn-outline {
background: transparent;
border: 1.5px solid rgba(255,255,255,0.25);
border-radius: var(--radius-xs);
color: rgba(255,255,255,0.8);
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;
flex-direction: column;
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
========================= */
.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 {
text-align: center;
padding: 1rem;
margin-top: 2rem;
font-size: 0.9rem;
color: #777;
.site-footer p {
margin: 0;
}
/* =========================
TABLET (>=768px)
TABLET (768px)
========================= */
@media (min-width: 768px) {
.search__controls {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
.search__controls input,
.search__controls select {
flex: 1;
min-width: 120px;
}
#city-input { flex: 2; min-width: 160px; }
#date-from,
#date-to { flex: 1; min-width: 130px; }
#category-input { flex: 0 0 160px; }
.search__controls button {
flex: 0 0 auto;
min-width: 120px;
}
#event-list {
#event-list,
#saved-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
}
}
/* =========================
DESKTOP (>=1024px)
DESKTOP (1024px)
========================= */
@media (min-width: 1024px) {
#event-list {
#event-list,
#saved-list {
grid-template-columns: repeat(3, 1fr);
}
}
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8">
@ -7,146 +7,209 @@
<title>Encore Discover Events</title>
<!-- Bootstrap -->
<!-- Bootstrap 5.3 CSS-Framework für responsives Layout -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet">
<!-- CSS -->
<!-- Eigene Styles -->
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/main.css">
</head>
<body>
<!-- HEADER -->
<header class="site-header">
<nav class="container d-flex justify-content-between align-items-center">
<!-- HEADER Hauptnavigation und Authentifizierungsbereich -->
<header class="site-header" role="banner">
<nav class="container d-flex justify-content-between align-items-center" aria-label="Hauptnavigation">
<h1 class="site-logo">Encore</h1>
<ul class="nav">
<ul class="nav" role="list">
<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 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 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>
</ul>
<!-- LOGIN AREA -->
<div id="auth-area">
<input id="username" placeholder="Username" class="form-control d-inline w-auto">
<input id="password" type="password" placeholder="Password" class="form-control d-inline w-auto">
<button id="login-btn" class="btn btn-success">Login</button>
<button id="register-btn" class="btn btn-secondary">Register</button>
<!-- ANMELDEBEREICH öffnet Modal -->
<div id="auth-area" role="region" aria-label="Anmeldung" class="d-flex gap-2">
<button id="login-btn" type="button" class="btn header-btn-outline" aria-label="Anmelden">Login</button>
<button id="register-btn" type="button" class="btn header-btn-accent" aria-label="Neues Konto erstellen">Register</button>
</div>
<!-- USER AREA -->
<div id="user-area" class="d-none">
<span id="user-name"></span>
<button id="logout-btn" class="btn btn-danger btn-sm">Logout</button>
<!-- NUTZERBEREICH (sichtbar wenn eingeloggt) -->
<div id="user-area" class="d-none" role="region" aria-label="Nutzerprofil">
<span id="user-name" aria-live="polite"></span>
<button id="logout-btn" type="button" class="btn header-btn-outline btn-sm" aria-label="Abmelden">Logout</button>
</div>
</nav>
</header>
<!-- MAIN CONTENT -->
<main class="container mt-4">
<!-- AUTH MODAL -->
<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 -->
<section class="search mb-4" id="search-section">
<div class="modal-header auth-modal-header">
<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">
<div class="search__controls">
<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>
<!-- City -->
<input
id="city-input"
type="text"
class="form-control"
placeholder="Enter city (e.g. Zurich)"
>
<div class="tab-content">
<!-- Date -->
<label for="date-from">From</label>
<input id="date-from" type="date">
<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>
<label for="date-to">To</label>
<input id="date-to" type="date">
<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>
<!-- Category -->
<select id="category-input" class="form-control">
<option value="">All categories</option>
<option value="music">Music</option>
<option value="sports">Sports</option>
<option value="arts & theatre">Arts & Theatre</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Button -->
<button id="load-events" class="btn btn-primary">
Search
</button>
<!-- 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">
<label for="city-input" class="visually-hidden">Stadt eingeben</label>
<input
id="city-input"
type="text"
class="form-control"
placeholder="Stadt (z.B. Zürich)"
aria-label="Stadt für Event-Suche"
>
<label for="date-from">Von</label>
<input id="date-from" type="date" aria-label="Startdatum">
<label for="date-to">Bis</label>
<input id="date-to" type="date" aria-label="Enddatum">
<label for="category-input" class="visually-hidden">Kategorie</label>
<select id="category-input" class="form-control" aria-label="Event-Kategorie">
<option value="">Alle Kategorien</option>
<option value="music">Musik</option>
<option value="sports">Sport</option>
<option value="arts & theatre">Kunst & Theater</option>
</select>
<button id="load-events" type="button" class="btn btn-primary" aria-label="Events suchen">
Suchen
</button>
</div>
</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>
<!-- EVENTLISTE Suchergebnisse -->
<section class="events-section container" id="events-section" aria-labelledby="events-heading">
<h2 id="events-heading">Kommende Events</h2>
<div id="event-list" aria-live="polite" aria-label="Event-Ergebnisse">
<div class="empty-state">
<div class="empty-state__icon" aria-hidden="true">🔍</div>
<p class="empty-state__text">Suche nach Events um zu starten</p>
<p class="empty-state__sub">Gib eine Stadt ein und klicke auf Suchen</p>
</div>
</div>
</section>
<!-- ========================= -->
<!-- MY EVENTS -->
<!-- ========================= -->
<section id="saved-section" class="d-none">
<h2>My Events</h2>
<div id="saved-list">
<p class="text-muted">No saved events yet.</p>
<!-- MY EVENTS Gespeicherte Events -->
<section id="saved-section" class="events-section container d-none" aria-labelledby="saved-heading">
<h2 id="saved-heading">My Events</h2>
<div id="saved-list" aria-live="polite">
<p class="text-muted">Noch keine gespeicherten Events.</p>
</div>
</section>
<!-- ========================= -->
<!-- INVITATIONS -->
<!-- ========================= -->
<section id="invitations-section" class="d-none">
<h2>My Invitations</h2>
<div id="invitation-list">
<p class="text-muted">No invitations yet.</p>
<!-- 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>
</main>
<!-- 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>
</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>
</body>
</html>
</html>

498
js/app.js
View File

@ -1,103 +1,208 @@
/**
* 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";
// =========================
// STATE
// GLOBALER ZUSTAND
// window-Properties damit eventCard.js auf den Nutzer zugreifen kann
// =========================
let currentUser = null;
let currentUser = null;
let currentPassword = null;
window.currentUser = null;
window.currentUser = 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 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 navSaved = document.querySelector("#nav-my-events");
const navInv = document.querySelector("#nav-invitations");
const navSaved = document.querySelector("#nav-my-events");
const navInv = document.querySelector("#nav-invitations");
// =========================
// SECTIONS
// 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");
const savedSection = document.querySelector("#saved-section");
const invSection = document.querySelector("#invitations-section");
// =========================
// AUTH UI
// MODAL ÖFFNEN Korrekte Tab-Vorauswahl
// =========================
const authArea = document.querySelector("#auth-area");
const userArea = document.querySelector("#user-area");
const userNameEl = document.querySelector("#user-name");
const logoutBtn = document.querySelector("#logout-btn");
loginBtn.addEventListener("click", () => {
bootstrap.Tab.getOrCreateInstance(loginTabBtn).show();
bsModal.show();
});
// =========================
// LOGIN
// =========================
loginBtn.addEventListener("click", async () => {
registerBtn.addEventListener("click", () => {
bootstrap.Tab.getOrCreateInstance(registerTabBtn).show();
bsModal.show();
});
const username = document.querySelector("#username").value;
const password = document.querySelector("#password").value;
const success = await login(username, password);
if (success) {
currentUser = username;
currentPassword = password;
window.currentUser = username;
window.currentPassword = password;
// UI switch
authArea.classList.add("d-none");
userArea.classList.remove("d-none");
userNameEl.textContent = `👤 ${username}`;
navSaved.classList.remove("d-none");
navInv.classList.remove("d-none");
} else {
alert("Login failed");
}
// 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;
});
// =========================
// REGISTER
// LOGIN VIA MODAL
// =========================
registerBtn.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 data = await register(username);
if (!username || !password) {
loginError.textContent = "Bitte Benutzername und Passwort eingeben.";
loginError.hidden = false;
return;
}
alert(`User created. Password: ${data.password}`);
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}`;
navSaved.classList.remove("d-none");
navInv.classList.remove("d-none");
} else {
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
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";
console.error("Registrierungs-Fehler:", err);
}
});
// =========================
// LOGOUT
// =========================
logoutBtn.addEventListener("click", () => {
currentUser = null;
currentPassword = null;
window.currentUser = null;
currentUser = null;
currentPassword = null;
window.currentUser = null;
window.currentPassword = null;
authArea.classList.remove("d-none");
userArea.classList.add("d-none");
navSaved.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 container = document.querySelector("#event-list");
const cityInput = document.querySelector("#city-input");
const searchBtn = document.querySelector("#load-events");
const eventListEl = document.querySelector("#event-list");
const cityInput = document.querySelector("#city-input");
button.addEventListener("click", handleSearch);
searchBtn.addEventListener("click", handleSearch);
cityInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") handleSearch();
cityInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") handleSearch();
});
async function handleSearch() {
const { city, dateFrom, dateTo, category } = getFilters();
if (!city) {
container.innerHTML = "<p class='text-danger'>Please enter a city.</p>";
showError(eventListEl, "Bitte eine Stadt eingeben.");
return;
}
container.innerHTML = "<p class='text-muted'>Loading events...</p>";
showSkeletons(eventListEl);
const events = await getEvents(city);
try {
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);
renderEventList(filteredEvents, container);
/** 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);
}
}
// =========================
// FILTERS
// 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 matchDateTo = dateTo ? event.date <= dateTo : true;
const matchCategory = category
? event.category && event.category.includes(category)
? event.category && event.category.toLowerCase().includes(category.toLowerCase())
: true;
return matchDateFrom && matchDateTo && matchCategory;
});
}
// =========================
// NAVIGATION LOGIC
// NAVIGATION
// =========================
navEvents.addEventListener("click", () => {
navEvents.addEventListener("click", (e) => {
e.preventDefault();
showSection("events");
});
navSaved.addEventListener("click", () => {
navSaved.addEventListener("click", (e) => {
e.preventDefault();
showSection("saved");
loadSavedEvents();
});
navInv.addEventListener("click", () => {
navInv.addEventListener("click", (e) => {
e.preventDefault();
showSection("invitations");
loadInvitations();
});
function showSection(section) {
searchSection.classList.add("d-none");
eventsSection.classList.add("d-none");
savedSection.classList.add("d-none");
@ -180,89 +296,209 @@ function showSection(section) {
if (section === "events") {
searchSection.classList.remove("d-none");
eventsSection.classList.remove("d-none");
}
if (section === "saved") {
} else if (section === "saved") {
savedSection.classList.remove("d-none");
}
if (section === "invitations") {
} else if (section === "invitations") {
invSection.classList.remove("d-none");
}
}
// =========================
// SAVED EVENTS
// GESPEICHERTE EVENTS (MY EVENTS)
// =========================
function loadSavedEvents() {
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
const container = document.querySelector("#saved-list");
container.innerHTML = "";
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;
}
saved.forEach(event => {
const div = document.createElement("div");
div.textContent = event.name;
container.appendChild(div);
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));
}
// =========================
// INVITATIONS
// EINLADUNGEN
// =========================
async function loadInvitations() {
if (!currentUser) return;
const res = await fetch("http://localhost:3000/api/invitation", {
headers: {
"X-Username": currentUser
}
});
const data = await res.json();
const container = document.querySelector("#invitation-list");
container.innerHTML = "<p class='text-muted'>Einladungen werden geladen...</p>";
container.innerHTML = "";
try {
const res = await fetch("http://localhost:3000/api/invitation", {
headers: { "X-Username": currentUser }
});
if (data.length === 0) {
container.innerHTML = "<p>No invitations.</p>";
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.");
console.error("Einladungs-Fehler:", err);
}
}
/**
* 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.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;
}
data.forEach(inv => {
const btnGroup = document.createElement("div");
btnGroup.className = "d-flex gap-2 flex-wrap";
const div = document.createElement("div");
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));
div.innerHTML = `
<p><strong>${inv.fromUser}</strong> invited you to <em>${inv.eventName}</em></p>
<button onclick="accept(${inv.id})">Accept</button>
<button onclick="decline(${inv.id})">Decline</button>
`;
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));
container.appendChild(div);
});
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) => {
await fetch(`http://localhost:3000/api/invitation/${id}/accept`, {
method: "POST"
});
loadInvitations();
};
window.decline = async (id) => {
await fetch(`http://localhost:3000/api/invitation/${id}/decline`, {
method: "POST"
});
loadInvitations();
};
function showError(container, message) {
const p = document.createElement("p");
p.className = "text-danger";
p.textContent = message;
container.innerHTML = "";
container.appendChild(p);
}