feature/ui-redesign #8
161
README.md
161
README.md
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
/* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover only on devices that support it */
|
/* 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) {
|
@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 {
|
|
||||||
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
|
KARTEN-INHALT
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
button,
|
/* Titel */
|
||||||
input {
|
.event-card__title {
|
||||||
min-height: 44px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus,
|
/* Datum & Uhrzeit – in Akzentfarbe für schnelle Lesbarkeit */
|
||||||
input:focus {
|
.event-card__date {
|
||||||
outline: 2px solid #007bff;
|
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;
|
||||||
}
|
}
|
||||||
435
css/main.css
435
css/main.css
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
215
index.html
215
index.html
@ -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 & 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>
|
||||||
|
|||||||
@ -1,19 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* ticketmaster.js – Schnittstelle zum Backend-Proxy für die Ticketmaster Discovery API.
|
||||||
|
* Der direkte API-Aufruf läuft über den eigenen Backend-Server, um den API-Key
|
||||||
|
* nicht im Frontend zu exponieren (OWASP: Sensitive Data Exposure vermeiden).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Events für eine Stadt über den Backend-Proxy.
|
||||||
|
* @param {string} city - Stadtname für die Suche
|
||||||
|
* @returns {Promise<Array>} Array von rohen Ticketmaster-Event-Objekten
|
||||||
|
*/
|
||||||
export async function fetchEvents(city) {
|
export async function fetchEvents(city) {
|
||||||
const url = `http://localhost:3000/api/events?city=${city}`;
|
const url = `http://localhost:3000/api/events?city=${encodeURIComponent(city)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("API request failed");
|
throw new Error(`Backend antwortete mit Status ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Ticketmaster gibt Events unter _embedded.events zurück; leeres Array als Fallback
|
||||||
return data._embedded?.events || [];
|
return data._embedded?.events || [];
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching events:", error);
|
console.error("Fehler beim Laden der Events:", error);
|
||||||
return [];
|
// Fehler nach oben weitergeben, damit app.js ihn dem Nutzer anzeigen kann
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
424
js/app.js
424
js/app.js
@ -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 200–299 ab
|
||||||
|
if (!res.ok) throw new Error(`Status ${res.status}`);
|
||||||
|
|
||||||
|
card.remove();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errEl = document.createElement("p");
|
||||||
|
errEl.className = "text-danger small mt-1";
|
||||||
|
errEl.textContent = "Fehler beim Löschen der Einladung.";
|
||||||
|
card.appendChild(errEl);
|
||||||
|
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();
|
|
||||||
};
|
|
||||||
|
|||||||
31
js/auth.js
31
js/auth.js
@ -1,20 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* auth.js – Kommunikation mit dem Backend für Registrierung und Login.
|
||||||
|
* Nutzt die Fetch-API mit einfachem Header-basiertem Authentifizierungsschema.
|
||||||
|
*
|
||||||
|
* Sicherheitshinweis: Passwörter werden unverschlüsselt als HTTP-Header übertragen.
|
||||||
|
* Dies ist bewusst vereinfacht (kein TLS/HTTPS in der lokalen Entwicklungsumgebung).
|
||||||
|
* In einer Produktionsumgebung müssten JWT-Tokens oder Sessions eingesetzt werden.
|
||||||
|
*/
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:3000/api";
|
const BASE_URL = "http://localhost:3000/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registriert einen neuen Benutzer beim Backend.
|
||||||
|
* Das Backend vergibt ein Standard-Passwort ("1234").
|
||||||
|
* @param {string} username - Gewünschter Benutzername
|
||||||
|
* @returns {Promise<Object>} Objekt mit name und password
|
||||||
|
*/
|
||||||
export async function register(username) {
|
export async function register(username) {
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/user`, {
|
const res = await fetch(`${BASE_URL}/user`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username })
|
body: JSON.stringify({ username })
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet einen Benutzer an.
|
||||||
|
* Credentials werden als HTTP-Header übermittelt (X-Username, X-Password).
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} password
|
||||||
|
* @returns {Promise<boolean>} true bei Erfolg (HTTP 200), false bei falschen Credentials
|
||||||
|
*/
|
||||||
export async function login(username, password) {
|
export async function login(username, password) {
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/user`, {
|
const res = await fetch(`${BASE_URL}/user`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@ -23,5 +41,6 @@ export async function login(username, password) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status === 201;
|
// HTTP 200 = Anmeldung erfolgreich, 401 = ungültige Credentials
|
||||||
|
return res.status === 200;
|
||||||
}
|
}
|
||||||
@ -1,14 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* eventService.js – Transformiert rohe Ticketmaster-API-Daten in ein vereinfachtes Event-Objekt.
|
||||||
|
* Kapselt die Datenstruktur der externen API, sodass der Rest der App davon unabhängig bleibt.
|
||||||
|
*/
|
||||||
|
|
||||||
import { fetchEvents } from "../api/ticketmaster.js";
|
import { fetchEvents } from "../api/ticketmaster.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt und transformiert Events für eine gegebene Stadt.
|
||||||
|
* @param {string} city - Stadtname
|
||||||
|
* @returns {Promise<Array<{id, name, date, time, venue, category}>>}
|
||||||
|
*/
|
||||||
export async function getEvents(city) {
|
export async function getEvents(city) {
|
||||||
const events = await fetchEvents(city);
|
const events = await fetchEvents(city);
|
||||||
|
|
||||||
|
// Rohe API-Daten auf ein einheitliches, einfaches Format abbilden
|
||||||
return events.map(event => ({
|
return events.map(event => ({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
date: event.dates?.start?.localDate || null,
|
date: event.dates?.start?.localDate || null,
|
||||||
time: event.dates?.start?.localTime || null,
|
time: event.dates?.start?.localTime || null,
|
||||||
venue: event._embedded?.venues?.[0]?.name || "Unknown venue",
|
venue: event._embedded?.venues?.[0]?.name || "Unbekannter Ort",
|
||||||
|
// Kategorie aus dem ersten Klassifizierungs-Segment extrahieren (z.B. "Music", "Sports")
|
||||||
category: event.classifications?.[0]?.segment?.name
|
category: event.classifications?.[0]?.segment?.name
|
||||||
? event.classifications[0].segment.name.toLowerCase()
|
? event.classifications[0].segment.name.toLowerCase()
|
||||||
: null
|
: null
|
||||||
|
|||||||
@ -1,92 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* eventCard.js – Erstellt interaktive Event-Karten als DOM-Elemente.
|
||||||
|
* Jede Karte zeigt Titel, Datum, Veranstaltungsort sowie Speichern- und Einladen-Buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine vollständige Event-Karte als <article>-Element.
|
||||||
|
* @param {Object} event - Event-Objekt mit id, name, date, time, venue, category
|
||||||
|
* @returns {HTMLElement} Fertig aufgebautes Karten-Element
|
||||||
|
*/
|
||||||
export function createEventCard(event) {
|
export function createEventCard(event) {
|
||||||
|
|
||||||
const article = document.createElement("article");
|
const article = document.createElement("article");
|
||||||
article.className = "event-card";
|
article.className = "event-card";
|
||||||
|
|
||||||
|
// --- TITEL ---
|
||||||
const title = document.createElement("h3");
|
const title = document.createElement("h3");
|
||||||
title.className = "event-card__title";
|
title.className = "event-card__title";
|
||||||
title.textContent = event.name;
|
title.textContent = event.name;
|
||||||
|
|
||||||
|
// --- DATUM & UHRZEIT formatieren (Schweizer Format: dd.mm.yyyy) ---
|
||||||
const formattedDate = event.date
|
const formattedDate = event.date
|
||||||
? new Date(event.date).toLocaleDateString("de-CH", {
|
? new Date(event.date).toLocaleDateString("de-CH", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
year: "numeric"
|
year: "numeric"
|
||||||
})
|
})
|
||||||
: "Date not available";
|
: "Datum unbekannt";
|
||||||
|
|
||||||
const formattedTime = event.time
|
const formattedTime = event.time ? event.time.slice(0, 5) : "";
|
||||||
? event.time.slice(0, 5)
|
const dateTime = formattedTime ? `${formattedDate}, ${formattedTime}` : formattedDate;
|
||||||
: "";
|
|
||||||
|
|
||||||
const dateTime = formattedTime
|
|
||||||
? `${formattedDate}, ${formattedTime}`
|
|
||||||
: formattedDate;
|
|
||||||
|
|
||||||
const date = document.createElement("p");
|
const date = document.createElement("p");
|
||||||
date.className = "event-card__date";
|
date.className = "event-card__date";
|
||||||
date.textContent = dateTime;
|
date.textContent = dateTime;
|
||||||
|
|
||||||
|
// --- VERANSTALTUNGSORT ---
|
||||||
const venue = document.createElement("p");
|
const venue = document.createElement("p");
|
||||||
venue.className = "event-card__venue";
|
venue.className = "event-card__venue";
|
||||||
venue.textContent = event.venue;
|
venue.textContent = event.venue || "Ort unbekannt";
|
||||||
|
|
||||||
article.append(title, date, venue);
|
article.append(title, date, venue);
|
||||||
|
|
||||||
// =========================
|
// --- BUTTON-GRUPPE ---
|
||||||
// BUTTON CONTAINER
|
|
||||||
// =========================
|
|
||||||
const buttonContainer = document.createElement("div");
|
const buttonContainer = document.createElement("div");
|
||||||
buttonContainer.className = "d-flex gap-2 mt-2";
|
buttonContainer.className = "d-flex gap-2 mt-2 flex-wrap";
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// SAVE BUTTON
|
// SPEICHERN-BUTTON
|
||||||
|
// Speichert das Event im localStorage, verhindert Duplikate
|
||||||
// =========================
|
// =========================
|
||||||
const saveBtn = document.createElement("button");
|
const saveBtn = document.createElement("button");
|
||||||
saveBtn.textContent = "Save";
|
saveBtn.type = "button";
|
||||||
saveBtn.className = "btn btn-outline-primary btn-sm";
|
saveBtn.className = "btn btn-outline-primary btn-sm";
|
||||||
|
|
||||||
|
// Prüfen ob bereits gespeichert → Button-Text entsprechend setzen
|
||||||
|
const alreadySaved = isAlreadySaved(event.id);
|
||||||
|
saveBtn.textContent = alreadySaved ? "Gespeichert ✓" : "Speichern";
|
||||||
|
if (alreadySaved) saveBtn.disabled = true;
|
||||||
|
|
||||||
saveBtn.addEventListener("click", () => {
|
saveBtn.addEventListener("click", () => {
|
||||||
|
|
||||||
if (!window.currentUser) {
|
|
||||||
alert("Please login to save events");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
|
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
|
||||||
|
|
||||||
// prevent duplicates
|
// Duplikat-Schutz
|
||||||
if (saved.find(e => e.id === event.id)) {
|
if (saved.find(e => e.id === event.id)) {
|
||||||
alert("Event already saved");
|
saveBtn.textContent = "Gespeichert ✓";
|
||||||
|
saveBtn.disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saved.push(event);
|
saved.push(event);
|
||||||
localStorage.setItem("savedEvents", JSON.stringify(saved));
|
localStorage.setItem("savedEvents", JSON.stringify(saved));
|
||||||
|
|
||||||
alert("Event saved!");
|
// Visuelles Feedback statt alert()
|
||||||
|
saveBtn.textContent = "Gespeichert ✓";
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.classList.replace("btn-outline-primary", "btn-success");
|
||||||
});
|
});
|
||||||
|
|
||||||
saveBtn.disabled = !window.currentUser;
|
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// INVITE BUTTON
|
// EINLADEN-BUTTON
|
||||||
|
// Zeigt ein Inline-Formular an (kein browser-prompt())
|
||||||
// =========================
|
// =========================
|
||||||
const inviteBtn = document.createElement("button");
|
const inviteBtn = document.createElement("button");
|
||||||
inviteBtn.textContent = "Invite";
|
inviteBtn.type = "button";
|
||||||
|
inviteBtn.textContent = "Einladen";
|
||||||
inviteBtn.className = "btn btn-primary btn-sm";
|
inviteBtn.className = "btn btn-primary btn-sm";
|
||||||
|
inviteBtn.disabled = false;
|
||||||
|
|
||||||
inviteBtn.addEventListener("click", async () => {
|
inviteBtn.addEventListener("click", () => {
|
||||||
|
|
||||||
if (!window.currentUser) {
|
if (!window.currentUser) {
|
||||||
alert("Please login to invite users");
|
showCardMessage(article, "Bitte einloggen um Einladungen zu senden.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Formular ein-/ausblenden (Toggle)
|
||||||
|
const existing = article.querySelector(".invite-form");
|
||||||
|
if (existing) {
|
||||||
|
existing.remove();
|
||||||
|
} else {
|
||||||
|
article.appendChild(createInviteForm(event, article));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const toUser = prompt("Enter username to invite:");
|
buttonContainer.append(saveBtn, inviteBtn);
|
||||||
if (!toUser) return;
|
article.appendChild(buttonContainer);
|
||||||
|
|
||||||
await fetch("http://localhost:3000/api/invitation", {
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// HILFSFUNKTION – Einladungsformular
|
||||||
|
// Erstellt ein kleines Inline-Formular mit Benutzername-Eingabe
|
||||||
|
// =========================
|
||||||
|
function createInviteForm(event, card) {
|
||||||
|
const form = document.createElement("div");
|
||||||
|
form.className = "invite-form mt-2 d-flex gap-2 align-items-center flex-wrap";
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.className = "form-control form-control-sm";
|
||||||
|
input.placeholder = "Benutzername eingeben";
|
||||||
|
input.setAttribute("aria-label", "Benutzername für Einladung");
|
||||||
|
|
||||||
|
const sendBtn = document.createElement("button");
|
||||||
|
sendBtn.type = "button";
|
||||||
|
sendBtn.textContent = "Senden";
|
||||||
|
sendBtn.className = "btn btn-primary btn-sm";
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement("button");
|
||||||
|
cancelBtn.type = "button";
|
||||||
|
cancelBtn.textContent = "Abbrechen";
|
||||||
|
cancelBtn.className = "btn btn-outline-secondary btn-sm";
|
||||||
|
|
||||||
|
cancelBtn.addEventListener("click", () => form.remove());
|
||||||
|
|
||||||
|
sendBtn.addEventListener("click", async () => {
|
||||||
|
const toUser = input.value.trim();
|
||||||
|
if (!toUser) {
|
||||||
|
input.classList.add("is-invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.classList.remove("is-invalid");
|
||||||
|
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.textContent = "Sende...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("http://localhost:3000/api/invitation", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -99,16 +158,48 @@ export function createEventCard(event) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
alert("Invitation sent!");
|
if (!res.ok) {
|
||||||
|
// Fehlermeldung vom Server auslesen (z.B. "Nutzer nicht registriert")
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.message || `Fehler ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formular entfernen und Erfolgsmeldung anzeigen
|
||||||
|
form.remove();
|
||||||
|
showCardMessage(card, `Einladung an "${toUser}" gesendet.`, "success");
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.textContent = "Senden";
|
||||||
|
showCardMessage(card, err.message || "Fehler beim Senden der Einladung.", "danger");
|
||||||
|
console.error("Einladungs-Fehler:", err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
inviteBtn.disabled = !window.currentUser;
|
form.append(input, sendBtn, cancelBtn);
|
||||||
|
return form;
|
||||||
// =========================
|
}
|
||||||
// APPEND BUTTONS
|
|
||||||
// =========================
|
// =========================
|
||||||
buttonContainer.append(saveBtn, inviteBtn);
|
// HILFSFUNKTION – Status-Nachricht in Karte anzeigen
|
||||||
article.appendChild(buttonContainer);
|
// Zeigt eine kurze Meldung innerhalb der Karte an und blendet sie nach 3s aus
|
||||||
|
// =========================
|
||||||
return article;
|
function showCardMessage(card, message, type) {
|
||||||
|
// Alte Nachrichten entfernen
|
||||||
|
card.querySelectorAll(".card-message").forEach(el => el.remove());
|
||||||
|
|
||||||
|
const msg = document.createElement("p");
|
||||||
|
msg.className = `card-message text-${type} mt-1 mb-0`;
|
||||||
|
msg.textContent = message;
|
||||||
|
card.appendChild(msg);
|
||||||
|
|
||||||
|
setTimeout(() => msg.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// HILFSFUNKTION – Prüft ob Event bereits gespeichert ist
|
||||||
|
// =========================
|
||||||
|
function isAlreadySaved(eventId) {
|
||||||
|
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
|
||||||
|
return saved.some(e => e.id === eventId);
|
||||||
}
|
}
|
||||||
@ -1,11 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* eventList.js – Rendert eine Liste von Event-Karten in einen Container.
|
||||||
|
* Delegiert die Erstellung einzelner Karten an createEventCard().
|
||||||
|
*/
|
||||||
|
|
||||||
import { createEventCard } from "./eventCard.js";
|
import { createEventCard } from "./eventCard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leert den Container und fügt für jedes Event eine Karte ein.
|
||||||
|
* Zeigt eine Meldung an, wenn keine Events gefunden wurden.
|
||||||
|
* @param {Array} events - Array von Event-Objekten
|
||||||
|
* @param {HTMLElement} container - Ziel-DOM-Element
|
||||||
|
*/
|
||||||
export function renderEventList(events, container) {
|
export function renderEventList(events, container) {
|
||||||
|
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
container.innerHTML = "No events found.";
|
const state = document.createElement("div");
|
||||||
|
state.className = "empty-state";
|
||||||
|
|
||||||
|
const icon = document.createElement("div");
|
||||||
|
icon.className = "empty-state__icon";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
icon.textContent = "🎭";
|
||||||
|
|
||||||
|
const title = document.createElement("p");
|
||||||
|
title.className = "empty-state__text";
|
||||||
|
title.textContent = "Keine Events gefunden";
|
||||||
|
|
||||||
|
const sub = document.createElement("p");
|
||||||
|
sub.className = "empty-state__sub";
|
||||||
|
sub.textContent = "Versuche eine andere Stadt oder erweitere den Datumsbereich.";
|
||||||
|
|
||||||
|
state.append(icon, title, sub);
|
||||||
|
container.appendChild(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -13,5 +40,4 @@ export function renderEventList(events, container) {
|
|||||||
const card = createEventCard(event);
|
const card = createEventCard(event);
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,3 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* filters.js – Liest die aktuellen Filterwerte aus den Formularfeldern.
|
||||||
|
* Gibt ein einheitliches Filter-Objekt zurück, das von app.js ausgewertet wird.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest Stadtname, Datumsbereich und Kategorie aus den Eingabefeldern.
|
||||||
|
* @returns {{ city: string, dateFrom: string, dateTo: string, category: string }}
|
||||||
|
*/
|
||||||
export function getFilters() {
|
export function getFilters() {
|
||||||
const city = document.querySelector("#city-input").value.trim();
|
const city = document.querySelector("#city-input").value.trim();
|
||||||
const dateFrom = document.querySelector("#date-from").value;
|
const dateFrom = document.querySelector("#date-from").value;
|
||||||
|
|||||||
204
server/server.js
204
server/server.js
@ -1,66 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* server.js – Express-Backend für Encore.
|
||||||
|
*
|
||||||
|
* Funktionen:
|
||||||
|
* - Proxy für die Ticketmaster Discovery API (API-Key bleibt serverseitig)
|
||||||
|
* - Einfache Benutzerverwaltung (In-Memory, kein Persistenz-Layer)
|
||||||
|
* - Einladungssystem zwischen registrierten Benutzern
|
||||||
|
*
|
||||||
|
* Sicherheitshinweise (OWASP):
|
||||||
|
* - A02 Cryptographic Failures: Passwörter werden nicht gehasht (bewusste Vereinfachung)
|
||||||
|
* - A07 Identification and Authentication Failures: Keine Tokens/Sessions, nur Header-Auth
|
||||||
|
* - Der API-Key liegt serverseitig in .env und wird nie an den Client übermittelt
|
||||||
|
*/
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Middleware
|
// Middleware: CORS erlaubt Anfragen vom Frontend (localhost), JSON-Body parsen
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// ROOT
|
// ROOT – Health-Check
|
||||||
// =========================
|
// =========================
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.send("Encore API is running");
|
res.send("Encore API is running");
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// TICKETMASTER EVENTS (PROXY)
|
// TICKETMASTER-PROXY
|
||||||
|
// Versteckt den API-Key vor dem Client (A02: Sensitive Data Exposure vermeiden)
|
||||||
|
// GET /api/events?city={city}
|
||||||
// =========================
|
// =========================
|
||||||
const TM_API_KEY = process.env.TM_API_KEY;
|
const TM_API_KEY = process.env.TM_API_KEY;
|
||||||
|
|
||||||
app.get("/api/events", async (req, res) => {
|
app.get("/api/events", async (req, res) => {
|
||||||
|
|
||||||
const city = req.query.city;
|
const city = req.query.city;
|
||||||
|
|
||||||
if (!city) {
|
if (!city) {
|
||||||
return res.status(400).json({ message: "City is required" });
|
return res.status(400).json({ message: "Stadt ist erforderlich" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${TM_API_KEY}&city=${city}`;
|
const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${TM_API_KEY}&city=${encodeURIComponent(city)}`;
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
res.json(data);
|
res.json(data);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Event fetch error:", error);
|
console.error("Ticketmaster-Fehler:", error);
|
||||||
res.status(500).json({ message: "Error fetching events" });
|
res.status(500).json({ message: "Fehler beim Laden der Events" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// INVITATIONS SYSTEM
|
// BENUTZERVERWALTUNG
|
||||||
|
// In-Memory-Speicher (Daten gehen bei Serverneustart verloren – bewusste Vereinfachung)
|
||||||
|
// =========================
|
||||||
|
let users = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user – Neuen Benutzer registrieren.
|
||||||
|
* Vergibt ein Standard-Passwort "1234" (vereinfacht, kein Hashing).
|
||||||
|
* Antwortet mit 201 Created bei Erfolg, 400 bei fehlendem/doppeltem Benutzernamen.
|
||||||
|
*/
|
||||||
|
app.post("/api/user", (req, res) => {
|
||||||
|
const { username } = req.body;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).json({ message: "Benutzername erforderlich" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.find(u => u.username === username)) {
|
||||||
|
return res.status(400).json({ message: "Benutzer existiert bereits" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = "1234";
|
||||||
|
users.push({ username, password });
|
||||||
|
|
||||||
|
res.status(201).json({ name: username, password });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user – Benutzer anmelden.
|
||||||
|
* Credentials kommen als X-Username und X-Password Header.
|
||||||
|
* Antwortet mit 200 OK bei Erfolg, 401 Unauthorized bei falschen Credentials.
|
||||||
|
*
|
||||||
|
* Hinweis: GET für eine Login-Operation ist unkonventionell (normalerweise POST).
|
||||||
|
* Hier bewusst so gewählt, da kein Session/Token-Mechanismus implementiert ist.
|
||||||
|
*/
|
||||||
|
app.get("/api/user", (req, res) => {
|
||||||
|
const username = req.header("X-Username");
|
||||||
|
const password = req.header("X-Password");
|
||||||
|
|
||||||
|
const user = users.find(u => u.username === username && u.password === password);
|
||||||
|
|
||||||
|
if (!user) return res.sendStatus(401);
|
||||||
|
|
||||||
|
res.sendStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// EINLADUNGSSYSTEM
|
||||||
|
// Verwaltet Event-Einladungen zwischen Benutzern
|
||||||
// =========================
|
// =========================
|
||||||
let invitations = [];
|
let invitations = [];
|
||||||
let idCounter = 1;
|
let idCounter = 1;
|
||||||
|
|
||||||
// SEND INVITATION
|
/**
|
||||||
|
* POST /api/invitation – Einladung senden.
|
||||||
|
* Erwartet: Header X-Username (Sender), Body { toUser, eventId, eventName }
|
||||||
|
* Antwortet mit 201 Created und dem erstellten Einladungsobjekt.
|
||||||
|
*/
|
||||||
app.post("/api/invitation", (req, res) => {
|
app.post("/api/invitation", (req, res) => {
|
||||||
|
|
||||||
if (!req.body) {
|
if (!req.body) {
|
||||||
return res.status(400).json({ message: "Missing request body" });
|
return res.status(400).json({ message: "Request-Body fehlt" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromUser = req.header("X-Username");
|
const fromUser = req.header("X-Username");
|
||||||
const { toUser, eventId, eventName } = req.body;
|
const { toUser, eventId, eventName } = req.body;
|
||||||
|
|
||||||
if (!fromUser || !toUser) {
|
if (!fromUser || !toUser) {
|
||||||
return res.status(400).json({ message: "Missing users" });
|
return res.status(400).json({ message: "Sender und Empfänger sind erforderlich" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob der Empfänger registriert ist
|
||||||
|
if (!users.find(u => u.username === toUser)) {
|
||||||
|
return res.status(404).json({ message: `Nutzer "${toUser}" ist nicht registriert.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht sich selbst einladen
|
||||||
|
if (fromUser === toUser) {
|
||||||
|
return res.status(400).json({ message: "Du kannst dich nicht selbst einladen." });
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitation = {
|
const invitation = {
|
||||||
@ -73,95 +148,62 @@ app.post("/api/invitation", (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
invitations.push(invitation);
|
invitations.push(invitation);
|
||||||
|
res.status(201).json(invitation);
|
||||||
res.json(invitation);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET INVITATIONS FOR USER
|
/**
|
||||||
|
* GET /api/invitation – Alle Einladungen für einen Benutzer abrufen.
|
||||||
|
* Erwartet: Header X-Username (Empfänger)
|
||||||
|
* Antwortet mit 200 und Array der Einladungen.
|
||||||
|
*/
|
||||||
app.get("/api/invitation", (req, res) => {
|
app.get("/api/invitation", (req, res) => {
|
||||||
|
|
||||||
const user = req.header("X-Username");
|
const user = req.header("X-Username");
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(400).json({ message: "Missing username" });
|
return res.status(400).json({ message: "Benutzername fehlt" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userInvitations = invitations.filter(
|
const userInvitations = invitations.filter(inv => inv.toUser === user);
|
||||||
inv => inv.toUser === user
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(userInvitations);
|
res.json(userInvitations);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ACCEPT INVITATION
|
/**
|
||||||
app.post("/api/invitation/:id/accept", (req, res) => {
|
* PUT /api/invitation/:id – Einladungsstatus aktualisieren (annehmen oder ablehnen).
|
||||||
|
* Erwartet: Body { status: "accepted" | "declined" }
|
||||||
|
* HTTP-Verb PUT gemäss REST: Update einer bestehenden Ressource.
|
||||||
|
* Antwortet mit 200 OK und dem aktualisierten Einladungsobjekt.
|
||||||
|
*/
|
||||||
|
app.put("/api/invitation/:id", (req, res) => {
|
||||||
const inv = invitations.find(i => i.id == req.params.id);
|
const inv = invitations.find(i => i.id == req.params.id);
|
||||||
|
|
||||||
if (!inv) {
|
if (!inv) return res.status(404).json({ message: "Einladung nicht gefunden" });
|
||||||
return res.status(404).json({ message: "Invitation not found" });
|
|
||||||
|
const { status } = req.body;
|
||||||
|
if (!["accepted", "declined"].includes(status)) {
|
||||||
|
return res.status(400).json({ message: "Ungültiger Status. Erlaubt: accepted, declined" });
|
||||||
}
|
}
|
||||||
|
|
||||||
inv.status = "accepted";
|
inv.status = status;
|
||||||
|
|
||||||
res.json(inv);
|
res.json(inv);
|
||||||
});
|
});
|
||||||
|
|
||||||
// DECLINE INVITATION
|
/**
|
||||||
app.post("/api/invitation/:id/decline", (req, res) => {
|
* DELETE /api/invitation/:id – Einladung löschen.
|
||||||
|
* HTTP-Verb DELETE gemäss REST: Entfernt die Ressource dauerhaft.
|
||||||
|
* Antwortet mit 204 No Content bei Erfolg.
|
||||||
|
*/
|
||||||
|
app.delete("/api/invitation/:id", (req, res) => {
|
||||||
|
const index = invitations.findIndex(i => i.id == req.params.id);
|
||||||
|
|
||||||
const inv = invitations.find(i => i.id == req.params.id);
|
if (index === -1) return res.status(404).json({ message: "Einladung nicht gefunden" });
|
||||||
|
|
||||||
if (!inv) {
|
invitations.splice(index, 1);
|
||||||
return res.status(404).json({ message: "Invitation not found" });
|
res.sendStatus(204); // 204 No Content – Ressource erfolgreich gelöscht
|
||||||
}
|
|
||||||
|
|
||||||
inv.status = "declined";
|
|
||||||
|
|
||||||
res.json(inv);
|
|
||||||
});
|
|
||||||
|
|
||||||
let users = [];
|
|
||||||
|
|
||||||
// CREATE USER
|
|
||||||
app.post("/api/user", (req, res) => {
|
|
||||||
|
|
||||||
const { username } = req.body;
|
|
||||||
|
|
||||||
if (!username) {
|
|
||||||
return res.status(400).json({ message: "Username required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (users.find(u => u.username === username)) {
|
|
||||||
return res.status(400).json({ message: "User exists" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = "1234";
|
|
||||||
|
|
||||||
const user = { username, password };
|
|
||||||
users.push(user);
|
|
||||||
|
|
||||||
res.json({ name: username, password });
|
|
||||||
});
|
|
||||||
|
|
||||||
// LOGIN
|
|
||||||
app.get("/api/user", (req, res) => {
|
|
||||||
|
|
||||||
const username = req.header("X-Username");
|
|
||||||
const password = req.header("X-Password");
|
|
||||||
|
|
||||||
const user = users.find(
|
|
||||||
u => u.username === username && u.password === password
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user) return res.sendStatus(401);
|
|
||||||
|
|
||||||
res.sendStatus(201);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// START SERVER
|
// SERVER STARTEN
|
||||||
// =========================
|
// =========================
|
||||||
app.listen(3000, () => {
|
app.listen(3000, () => {
|
||||||
console.log("Server running on http://localhost:3000");
|
console.log("Server läuft auf http://localhost:3000");
|
||||||
});
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user