Redesign frontend with hero section, auth modal, and animations
- Replace header auth inputs with Bootstrap modal (Login/Register tabs) - Add hero section with dark gradient, decorative grid lines, teal glows - Floating search card with strong shadow inside hero - Add card entrance animation (fadeSlideUp) with staggered nth-child delays - Add skeleton loader shown while events are fetching - Add empty state components with emoji icon and description - Remove all alert() calls, replace with inline DOM error messages - Add CSS custom properties for consistent design tokens - Add Inter font, modern typography scale - Document input validation under OWASP A03 in README
This commit is contained in:
parent
ae3692697b
commit
53f9691376
161
README.md
161
README.md
@ -1,45 +1,150 @@
|
||||
# Encore
|
||||
Frontend Project for cds-208 at FHGR
|
||||
Collaborators: Pascal Schieman, Nicola Augsburger, Karolina Thöny-Tyganova
|
||||
## Project Description
|
||||
**Find your next event in seconds.** Encore is a modern event discovery frontend
|
||||
that brings Ticketmaster events directly to users through a clean, intelligent
|
||||
interface. Instead of sifting through cluttered listings, users can instantly browse
|
||||
concerts, sports, theater, and entertainment by location, date, and category—then
|
||||
dive into event details to make informed decisions fast.
|
||||
# Encore – Event Discovery
|
||||
|
||||
The product centers on three core goals:
|
||||
- **Discoverability**: make events easy to find through location and category filters.
|
||||
- **Decision support**: display essential details (date, venue, city, lineup) at a glance.
|
||||
- **Usability**: deliver a responsive, intuitive interface that works seamlessly on all devices.
|
||||
- **Community interaction**: provide event-specific discussion areas with interactive commenting and @-mentions so users can tag and notify each other directly.
|
||||
- **Social coordination**: enable direct, personalized event invitations between registered users, supported by notifications and dashboard-based invitation management.
|
||||
Frontend-Projekt für das Modul cds-208 an der FHGR (Frühlingssemester 2026).
|
||||
|
||||
Built on Ticketmaster's API, Encore provides real-time event data while maintaining
|
||||
a lightweight, scalable architecture—ready for future features like personalized
|
||||
recommendations, saved events, social sharing, and collaborative event planning.
|
||||
---
|
||||
|
||||
## Projektbeschreibung
|
||||
|
||||
**Encore** ist eine webbasierte Event-Discovery-Plattform. Nutzer können Konzerte, Sportveranstaltungen und Kulturanlässe in jeder Stadt direkt über die Ticketmaster-API suchen und filtern – ohne sich durch überladene Buchungsportale kämpfen zu müssen.
|
||||
|
||||
Die drei Kernziele der Anwendung:
|
||||
|
||||
- **Entdecken**: Events nach Stadt, Datum und Kategorie filtern
|
||||
- **Organisieren**: Interessante Events lokal speichern und in der eigenen Event-Liste verwalten
|
||||
- **Teilen**: Andere registrierte Nutzer direkt zu Events einladen und auf Einladungen reagieren
|
||||
|
||||
---
|
||||
|
||||
## Besondere Features
|
||||
|
||||
| Feature | Beschreibung |
|
||||
|---|---|
|
||||
| **Ticketmaster-Integration** | Live-Daten über einen eigenen Backend-Proxy (API-Key bleibt serverseitig) |
|
||||
| **Dreifachfilter** | Gleichzeitig nach Stadt, Datumsbereich und Kategorie filtern |
|
||||
| **My Events** | Gespeicherte Events in einer eigenen Ansicht mit Entfernen-Option |
|
||||
| **Einladungssystem** | Nutzer können andere per Inline-Formular zu Events einladen und Einladungen annehmen/ablehnen |
|
||||
| **Vollständig responsiv** | Optimiert für Mobile, Tablet und Desktop (Bootstrap-Grid + eigenes CSS) |
|
||||
| **XSS-Schutz** | Alle nutzerkontrollierten Daten werden via `textContent` und DOM-Methoden gerendert (kein `innerHTML` mit externen Daten) |
|
||||
| **Accessibility (WCAG A)** | Semantisches HTML5, ARIA-Labels, sichtbare Labels für alle Formularfelder, `aria-live`-Regionen |
|
||||
|
||||
---
|
||||
|
||||
## Installation & lokale Ausführung
|
||||
|
||||
### 1. Repository klonen
|
||||
|
||||
## Setup and run
|
||||
### Clone the repository
|
||||
```bash
|
||||
git clone https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt.git
|
||||
cd AISE_FS26-FrontendEntwicklungProjekt
|
||||
```
|
||||
### Install backend dependencies
|
||||
|
||||
### 2. Abhängigkeiten installieren
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
### Configure environment variables (at the moment API key is open to test)
|
||||
|
||||
### Start the backend server
|
||||
### 3. Umgebungsvariable konfigurieren
|
||||
|
||||
Erstelle eine `.env`-Datei im Projektstamm:
|
||||
|
||||
```env
|
||||
TM_API_KEY=dein_ticketmaster_api_key
|
||||
```
|
||||
|
||||
Den API-Key erhältst du kostenlos unter [developer.ticketmaster.com](https://developer.ticketmaster.com).
|
||||
*(Zum Testen ist im Repository vorübergehend ein Key hinterlegt.)*
|
||||
|
||||
### 4. Backend-Server starten
|
||||
|
||||
```bash
|
||||
node server/server.js
|
||||
```
|
||||
you should see:
|
||||
```txt
|
||||
Server running on http://localhost:3000
|
||||
```
|
||||
|
||||
### Open index.html in your browser
|
||||
Ausgabe: `Server läuft auf http://localhost:3000`
|
||||
|
||||
### 5. Frontend öffnen
|
||||
|
||||
Öffne `index.html` direkt im Browser (kein separater Dev-Server nötig).
|
||||
|
||||
> **Tipp:** Verwende die Live Server Extension in VS Code für automatisches Reload.
|
||||
|
||||
---
|
||||
|
||||
## Verwendete Technologien, Libraries & Frameworks
|
||||
|
||||
| Kategorie | Technologie | Begründung |
|
||||
|---|---|---|
|
||||
| **Markup** | HTML5 (semantisch) | Strukturierung mit `<article>`, `<section>`, `<nav>`, `<header>`, `<footer>` |
|
||||
| **Styling** | CSS3 (Flexbox, Grid) | Responsive Layout ohne Framework-Overhead |
|
||||
| **CSS-Framework** | Bootstrap 5.3 | Schnelles responsives Grid, Utility-Klassen, Button-Stile |
|
||||
| **Sprache** | Vanilla JavaScript (ES6+) | Module, Arrow Functions, Async/Await, Destructuring |
|
||||
| **HTTP** | Fetch API | Native Browser-API für asynchrone Requests |
|
||||
| **Backend** | Node.js + Express 5 | Leichtgewichtiger Server für API-Proxy und Nutzerverwaltung |
|
||||
| **Externe API** | Ticketmaster Discovery API | Echtzeit-Eventdaten weltweit |
|
||||
| **Umgebungsvariablen** | dotenv | API-Key sicher aus `.env` laden |
|
||||
| **Datenpersistenz** | localStorage (Frontend) | Gespeicherte Events ohne Backend-Aufwand |
|
||||
|
||||
**Bewusst nicht verwendet:** JS-Frameworks (React, Vue, Angular) – gemäss Projektanforderungen.
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit (OWASP-Reflexion)
|
||||
|
||||
Im Rahmen des Projekts wurden folgende OWASP-Risiken berücksichtigt und dokumentiert:
|
||||
|
||||
### A03 – Injection / XSS (Cross-Site Scripting)
|
||||
**Risiko:** Nutzerdaten (Benutzernamen, Eventnamen aus der API) könnten Schadcode enthalten, der im Browser ausgeführt wird.
|
||||
**Massnahme:** Alle externen Daten werden ausschliesslich via `element.textContent` gerendert – niemals via `innerHTML` mit ungeprüften Werten. Beim Aufbau des Einladungs-Panels werden DOM-Methoden (`createElement`, `appendChild`) statt Template-Strings verwendet.
|
||||
|
||||
**Client-seitige Input-Validierung:** Formularfelder werden vor dem Absenden geprüft – leere Pflichtfelder (Stadt, Benutzername, Passwort, Einladungs-Empfänger) werden mit einer Inline-Fehlermeldung abgewiesen und der Request wird nicht abgesendet. Ungültige Statuswerte (`PUT /api/invitation/:id`) werden serverseitig mit 400 Bad Request abgelehnt.
|
||||
|
||||
### A02 – Cryptographic Failures / Sensitive Data Exposure
|
||||
**Risiko:** Passwörter im Klartext, API-Key im Frontend.
|
||||
**Massnahme:** Der Ticketmaster-API-Key liegt ausschliesslich serverseitig in `.env` – der Client sieht ihn nie. Die Passwörter werden bewusst ungehasht gespeichert (Vereinfachung für den Prototyp); in einer Produktionsumgebung würden bcrypt und JWT-Tokens eingesetzt.
|
||||
|
||||
### A07 – Identification and Authentication Failures
|
||||
**Risiko:** Header-basierte Authentifizierung ohne Tokens ist nicht produktionstauglich.
|
||||
**Massnahme:** Für diesen Prototyp bewusst vereinfacht. Die Einschränkung ist dokumentiert (kein HTTPS, kein Session-Management).
|
||||
|
||||
---
|
||||
|
||||
## Einsatz von KI-Werkzeugen
|
||||
|
||||
Im Projektverlauf wurden KI-Assistenten (Claude Code) eingesetzt für:
|
||||
|
||||
- **Code-Vervollständigung und Debugging**: Unterstützung bei der Implementierung des Einladungssystems und der XSS-sicheren Einladungsdarstellung
|
||||
- **Code-Review**: Identifikation von Sicherheitslücken (XSS via innerHTML, falsche HTTP-Statuscodes)
|
||||
- **Dokumentation**: Unterstützung beim Verfassen von JSDoc-Kommentaren und dieser README
|
||||
|
||||
Alle KI-generierten Vorschläge wurden kritisch geprüft, angepasst und manuell getestet. Die Verantwortung für den Code liegt bei den Entwicklerinnen und Entwicklern.
|
||||
|
||||
---
|
||||
|
||||
## Selbstreflexion
|
||||
|
||||
**Herausforderungen:**
|
||||
|
||||
- **Externe API-Integration**: Die Ticketmaster-API hat eine komplexe, verschachtelte JSON-Struktur. Das Mapping auf ein einfaches Datenmodell erforderte sorgfältige Analyse und optionale Chaining (`?.`).
|
||||
- **Modulstruktur ohne Framework**: Die klare Trennung in `api/`, `services/`, `ui/` ohne ein Framework erforderte bewusste Architekturentscheidungen.
|
||||
- **Statuscode-Konventionen**: Die korrekte Verwendung von HTTP-Statuscodes (201 für Ressource-Erstellung, 200 für erfolgreichen GET) musste erst explizit reflektiert werden.
|
||||
- **XSS-Bewusstsein**: Das Risiko von `innerHTML` mit externen Daten war nicht von Anfang an präsent und wurde erst im Code-Review erkannt.
|
||||
|
||||
**Lerneffekte:**
|
||||
|
||||
- Tiefes Verständnis für Async/Await und Fehlerbehandlung in JavaScript
|
||||
- Sensibilisierung für OWASP-Risiken bereits beim Schreiben von Frontend-Code
|
||||
- Wert von semantischem HTML5 und Accessibility für Barrierefreiheit und SEO
|
||||
|
||||
---
|
||||
|
||||
## Gruppenmitglieder und Rollen
|
||||
|
||||
| Name | Rolle | Schwerpunkte |
|
||||
|---|---|---|
|
||||
| **Nicola Augsburger** | Frontend & Backend | Einladungssystem (Frontend + Backend), Authentifizierung, App-Architektur |
|
||||
| **Pascal Schieman** | Frontend | Event-Karten-Komponente, CSS-Styling, Responsives Layout |
|
||||
| **Karolina Thöny-Tyganova** | Frontend & API | Ticketmaster-API-Integration, Suchfunktion, Filterlogik |
|
||||
|
||||
Die individuelle Beteiligung ist in den Git-Commits auf [gitea.fhgr.ch](https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt) nachvollziehbar.
|
||||
|
||||
@ -1,55 +1,351 @@
|
||||
/* =========================
|
||||
EVENT CARD COMPONENT
|
||||
EVENT-KARTE
|
||||
Moderne Karte mit Hover-Effekt und farbigem Akzent-Streifen oben
|
||||
========================= */
|
||||
.event-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
box-shadow: var(--shadow-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Erscheinungsanimation beim Einblenden ins DOM */
|
||||
animation: fadeSlideUp 0.38s ease both;
|
||||
will-change: opacity, transform;
|
||||
|
||||
transition: transform 0.22s ease, box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
/* Gestaffelte Verzögerung für die ersten 9 Karten im Grid */
|
||||
#event-list .event-card:nth-child(1),
|
||||
#saved-list .event-card:nth-child(1) { animation-delay: 0.03s; }
|
||||
#event-list .event-card:nth-child(2),
|
||||
#saved-list .event-card:nth-child(2) { animation-delay: 0.07s; }
|
||||
#event-list .event-card:nth-child(3),
|
||||
#saved-list .event-card:nth-child(3) { animation-delay: 0.11s; }
|
||||
#event-list .event-card:nth-child(4),
|
||||
#saved-list .event-card:nth-child(4) { animation-delay: 0.15s; }
|
||||
#event-list .event-card:nth-child(5),
|
||||
#saved-list .event-card:nth-child(5) { animation-delay: 0.19s; }
|
||||
#event-list .event-card:nth-child(6),
|
||||
#saved-list .event-card:nth-child(6) { animation-delay: 0.23s; }
|
||||
#event-list .event-card:nth-child(7),
|
||||
#saved-list .event-card:nth-child(7) { animation-delay: 0.27s; }
|
||||
#event-list .event-card:nth-child(8),
|
||||
#saved-list .event-card:nth-child(8) { animation-delay: 0.31s; }
|
||||
#event-list .event-card:nth-child(9),
|
||||
#saved-list .event-card:nth-child(9) { animation-delay: 0.35s; }
|
||||
|
||||
/* Farbiger Akzent-Streifen oben – zeigt Kategorie-Zugehörigkeit */
|
||||
.event-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-light));
|
||||
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
||||
}
|
||||
|
||||
/* Hover-Effekt nur auf Geräten mit Hover-Unterstützung (kein unbeabsichtigtes Verhalten auf Touch) */
|
||||
@media (hover: hover) {
|
||||
.event-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
KARTEN-INHALT
|
||||
========================= */
|
||||
|
||||
.event-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
/* Hover only on devices that support it */
|
||||
@media (hover: hover) {
|
||||
.event-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.event-card__title {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Date */
|
||||
.event-card__date {
|
||||
color: gray;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Venue */
|
||||
.event-card__venue {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
FORM ELEMENT IMPROVEMENTS
|
||||
========================= */
|
||||
|
||||
button,
|
||||
input {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
input:focus {
|
||||
outline: 2px solid #007bff;
|
||||
}
|
||||
/* Titel */
|
||||
.event-card__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0.375rem 0 0.5rem;
|
||||
line-height: 1.4;
|
||||
/* Lange Titel auf 2 Zeilen begrenzen */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Datum & Uhrzeit – in Akzentfarbe für schnelle Lesbarkeit */
|
||||
.event-card__date {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent-dark);
|
||||
margin: 0 0 0.3rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Veranstaltungsort */
|
||||
.event-card__venue {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
/* Icon-ähnlicher Stil durch leichten Einzug */
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
BUTTON-GRUPPE IN KARTE
|
||||
Trennlinie separiert Inhalt von Aktionen
|
||||
========================= */
|
||||
.event-card .d-flex.gap-2 {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.875rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
EINLADUNGSFORMULAR (Inline)
|
||||
Erscheint direkt in der Karte beim Klick auf "Einladen"
|
||||
========================= */
|
||||
.invite-form {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.invite-form input[type="text"] {
|
||||
font-size: 0.85rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 0.4rem 0.75rem;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.invite-form input[type="text"]:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(13, 148, 136, 0.15);
|
||||
outline: none;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.invite-form input[type="text"].is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
STATUSMELDUNG IN KARTE
|
||||
Temporäre Bestätigungs- oder Fehlermeldung (verschwindet nach 3s)
|
||||
========================= */
|
||||
.card-message {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 0.35rem 0.6rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
EINLADUNGS-KARTE (My Invitations)
|
||||
========================= */
|
||||
.invitation-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.invitation-card p {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.invitation-card p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
AUTH MODAL
|
||||
========================= */
|
||||
.auth-modal-content {
|
||||
border: none;
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.auth-modal-header {
|
||||
background: var(--color-header);
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.auth-modal-title {
|
||||
color: #ffffff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.auth-tab-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.auth-tab-btn.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Formularfelder im Modal */
|
||||
.auth-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
border: 1.5px solid var(--color-border) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
height: 42px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg) !important;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
border-color: var(--color-accent) !important;
|
||||
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15) !important;
|
||||
outline: none;
|
||||
background: var(--color-surface) !important;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-danger);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Primärer Modal-Button (teal) */
|
||||
.btn-accent {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
height: 42px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), transform var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
background: var(--color-accent-dark);
|
||||
color: #fff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.3);
|
||||
}
|
||||
|
||||
.btn-accent:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Registrierungs-Erfolgsmeldung */
|
||||
.register-success {
|
||||
background: rgba(16, 185, 129, 0.07);
|
||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Passwort-Anzeige nach Registrierung */
|
||||
.password-display {
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 0.6rem 1rem;
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.password-display code {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
/* Hinweistext unter Passwort */
|
||||
.text-muted-custom {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
GLOBALE FORMULAR-VERBESSERUNGEN
|
||||
Mindest-Klickfläche (WCAG) und Focus-Ring
|
||||
========================= */
|
||||
button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* Sichtbarer Fokus-Ring für Tastatur-Navigation (WCAG A) */
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
437
css/main.css
437
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;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f9f9f9;
|
||||
color: #333;
|
||||
font-family: 'Inter', Arial, sans-serif;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
img {
|
||||
@ -19,105 +55,388 @@ img {
|
||||
}
|
||||
|
||||
/* =========================
|
||||
GLOBAL LAYOUT
|
||||
KARTEN-ERSCHEINUNGS-ANIMATION
|
||||
Wird auf Event-Karten angewendet wenn sie ins DOM kommen
|
||||
========================= */
|
||||
|
||||
main {
|
||||
padding: 1rem;
|
||||
@keyframes fadeSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(22px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
/* Shimmer-Animation für Skeleton-Karten (transform statt background-position → GPU-composited) */
|
||||
@keyframes shimmer {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(200%); }
|
||||
}
|
||||
|
||||
/* =========================
|
||||
HEADER
|
||||
HEADER – Dunkel & Sticky
|
||||
========================= */
|
||||
|
||||
.site-header {
|
||||
background-color: #3dd0a9;
|
||||
color: #222; /* fixed contrast */
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
background-color: var(--color-header);
|
||||
padding: 0.75rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.06), 0 2px 16px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
SEARCH SECTION
|
||||
========================= */
|
||||
|
||||
.event-search h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
.site-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-accent-light);
|
||||
margin: 0;
|
||||
letter-spacing: -0.04em;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.search__controls {
|
||||
.site-header .nav-link {
|
||||
color: rgba(255,255,255,0.65);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: color var(--transition), background var(--transition);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-header .nav-link:hover {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
/* Anmeldebereich – nur noch Buttons */
|
||||
#auth-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
EVENTS LIST
|
||||
========================= */
|
||||
|
||||
.events h2 {
|
||||
margin-bottom: 1rem;
|
||||
.header-btn-outline {
|
||||
background: transparent;
|
||||
border: 1.5px solid rgba(255,255,255,0.25);
|
||||
border-radius: var(--radius-xs);
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.4rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition), color var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#event-list {
|
||||
.header-btn-outline:hover {
|
||||
border-color: rgba(255,255,255,0.55);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-btn-accent {
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-xs);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.4rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-btn-accent:hover {
|
||||
background: var(--color-accent-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Nutzerbereich (eingeloggt) */
|
||||
#user-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
#user-name {
|
||||
color: rgba(255,255,255,0.85);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
HAUPTINHALT
|
||||
========================= */
|
||||
main {
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
HERO-BEREICH (Suchsektion)
|
||||
Volle Breite, dunkler Gradient-Hintergrund
|
||||
========================= */
|
||||
.hero {
|
||||
background:
|
||||
radial-gradient(ellipse 70% 80% at 15% 60%, rgba(13,148,136,0.15) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 50% 60% at 85% 20%, rgba(20,184,166,0.08) 0%, transparent 55%),
|
||||
linear-gradient(155deg, #0f172a 0%, #1a2744 45%, #0c1a2c 100%);
|
||||
padding: 4.5rem 0 3.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dekoratives Gitternetz-Muster im Hintergrund */
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero__text {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-size: clamp(2.25rem, 5vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0 0 0.625rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Teal Akzent auf dem letzten Wort */
|
||||
.hero__title span {
|
||||
color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.hero__subtitle {
|
||||
font-size: 1.05rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Suchkarte – schwebend über dem Hero-Hintergrund */
|
||||
.hero .search {
|
||||
background: rgba(255,255,255,0.98);
|
||||
border: none;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1.5rem 1.75rem;
|
||||
box-shadow:
|
||||
0 30px 80px rgba(0,0,0,0.4),
|
||||
0 8px 30px rgba(0,0,0,0.25),
|
||||
0 0 0 1px rgba(255,255,255,0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
SUCHFORMULAR
|
||||
========================= */
|
||||
.search__controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.search__controls .form-control,
|
||||
.search__controls input[type="date"],
|
||||
.search__controls select {
|
||||
background: var(--color-bg);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
height: 42px;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.search__controls .form-control:focus,
|
||||
.search__controls input[type="date"]:focus,
|
||||
.search__controls select:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px rgba(13,148,136,0.15);
|
||||
outline: none;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.search__controls label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#load-events {
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 0 1.5rem;
|
||||
height: 42px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), transform var(--transition), box-shadow var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#load-events:hover {
|
||||
background: var(--color-accent-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px rgba(13,148,136,0.35);
|
||||
}
|
||||
|
||||
#load-events:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
SECTION (EVENTS / SAVED / INVITATIONS)
|
||||
========================= */
|
||||
.events-section {
|
||||
padding: 2.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.events-section h2 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 1.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
EVENT-GRID
|
||||
========================= */
|
||||
#event-list,
|
||||
#saved-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#invitation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
EMPTY STATE
|
||||
========================= */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3.5rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state__text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
|
||||
.empty-state__sub {
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
SKELETON LOADER
|
||||
Shimmer via ::after Pseudo-Element + transform (GPU-composited)
|
||||
========================= */
|
||||
.skeleton-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--color-border);
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.65) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: shimmer 1.6s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
FOOTER
|
||||
========================= */
|
||||
.site-footer {
|
||||
background-color: var(--color-header);
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-size: 0.85rem;
|
||||
padding: 1.75rem 0;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
color: #777;
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
TABLET (>=768px)
|
||||
TABLET (≥768px)
|
||||
========================= */
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
||||
.search__controls {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search__controls input,
|
||||
.search__controls select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
#city-input { flex: 2; min-width: 160px; }
|
||||
#date-from,
|
||||
#date-to { flex: 1; min-width: 130px; }
|
||||
#category-input { flex: 0 0 160px; }
|
||||
|
||||
.search__controls button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
#event-list {
|
||||
#event-list,
|
||||
#saved-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* =========================
|
||||
DESKTOP (>=1024px)
|
||||
DESKTOP (≥1024px)
|
||||
========================= */
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
|
||||
#event-list {
|
||||
#event-list,
|
||||
#saved-list {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
241
index.html
241
index.html
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@ -7,146 +7,209 @@
|
||||
|
||||
<title>Encore – Discover Events</title>
|
||||
|
||||
<!-- Bootstrap -->
|
||||
<!-- Bootstrap 5.3 – CSS-Framework für responsives Layout -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet">
|
||||
|
||||
<!-- CSS -->
|
||||
<!-- Eigene Styles -->
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="site-header">
|
||||
<nav class="container d-flex justify-content-between align-items-center">
|
||||
<!-- HEADER – Hauptnavigation und Authentifizierungsbereich -->
|
||||
<header class="site-header" role="banner">
|
||||
<nav class="container d-flex justify-content-between align-items-center" aria-label="Hauptnavigation">
|
||||
|
||||
<h1 class="site-logo">Encore</h1>
|
||||
|
||||
<ul class="nav">
|
||||
|
||||
|
||||
<ul class="nav" role="list">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" id="nav-events">Events</a>
|
||||
<a class="nav-link" href="#" id="nav-events" aria-label="Zur Event-Suche">Events</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-none" href="#" id="nav-my-events">My Events</a>
|
||||
<a class="nav-link d-none" href="#" id="nav-my-events" aria-label="Meine gespeicherten Events">My Events</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-none" href="#" id="nav-invitations">Invitations</a>
|
||||
<a class="nav-link d-none" href="#" id="nav-invitations" aria-label="Meine Einladungen">Invitations</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<!-- LOGIN AREA -->
|
||||
<div id="auth-area">
|
||||
<input id="username" placeholder="Username" class="form-control d-inline w-auto">
|
||||
<input id="password" type="password" placeholder="Password" class="form-control d-inline w-auto">
|
||||
<button id="login-btn" class="btn btn-success">Login</button>
|
||||
<button id="register-btn" class="btn btn-secondary">Register</button>
|
||||
|
||||
<!-- ANMELDEBEREICH – öffnet Modal -->
|
||||
<div id="auth-area" role="region" aria-label="Anmeldung" class="d-flex gap-2">
|
||||
<button id="login-btn" type="button" class="btn header-btn-outline" aria-label="Anmelden">Login</button>
|
||||
<button id="register-btn" type="button" class="btn header-btn-accent" aria-label="Neues Konto erstellen">Register</button>
|
||||
</div>
|
||||
|
||||
<!-- USER AREA -->
|
||||
<div id="user-area" class="d-none">
|
||||
<span id="user-name"></span>
|
||||
<button id="logout-btn" class="btn btn-danger btn-sm">Logout</button>
|
||||
|
||||
<!-- NUTZERBEREICH (sichtbar wenn eingeloggt) -->
|
||||
<div id="user-area" class="d-none" role="region" aria-label="Nutzerprofil">
|
||||
<span id="user-name" aria-live="polite"></span>
|
||||
<button id="logout-btn" type="button" class="btn header-btn-outline btn-sm" aria-label="Abmelden">Logout</button>
|
||||
</div>
|
||||
|
||||
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<main class="container mt-4">
|
||||
<!-- AUTH MODAL -->
|
||||
<div class="modal fade" id="auth-modal" tabindex="-1" aria-labelledby="auth-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content auth-modal-content">
|
||||
|
||||
<!-- SEARCH / FILTER SECTION -->
|
||||
<section class="search mb-4" id="search-section">
|
||||
<div class="modal-header auth-modal-header">
|
||||
<h2 class="modal-title auth-modal-title" id="auth-modal-label">Willkommen bei Encore</h2>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Schliessen"></button>
|
||||
</div>
|
||||
|
||||
<h2>Find Events</h2>
|
||||
<div class="modal-body auth-modal-body">
|
||||
|
||||
<div class="search__controls">
|
||||
<ul class="nav auth-tabs mb-4" id="auth-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="auth-tab-btn active" id="login-tab-btn" data-bs-toggle="tab"
|
||||
data-bs-target="#login-pane" type="button" role="tab"
|
||||
aria-controls="login-pane" aria-selected="true">Anmelden</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="auth-tab-btn" id="register-tab-btn" data-bs-toggle="tab"
|
||||
data-bs-target="#register-pane" type="button" role="tab"
|
||||
aria-controls="register-pane" aria-selected="false">Registrieren</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- City -->
|
||||
<input
|
||||
id="city-input"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Enter city (e.g. Zurich)"
|
||||
>
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- Date -->
|
||||
<label for="date-from">From</label>
|
||||
<input id="date-from" type="date">
|
||||
<div class="tab-pane fade show active" id="login-pane" role="tabpanel" aria-labelledby="login-tab-btn">
|
||||
<div class="mb-3">
|
||||
<label for="modal-username" class="auth-label">Benutzername</label>
|
||||
<input type="text" id="modal-username" class="form-control auth-input"
|
||||
placeholder="Dein Benutzername" autocomplete="username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="modal-password" class="auth-label">Passwort</label>
|
||||
<input type="password" id="modal-password" class="form-control auth-input"
|
||||
placeholder="Dein Passwort" autocomplete="current-password">
|
||||
</div>
|
||||
<p id="login-error" class="auth-error" hidden></p>
|
||||
<button type="button" id="modal-login-btn" class="btn btn-accent w-100">Anmelden</button>
|
||||
</div>
|
||||
|
||||
<label for="date-to">To</label>
|
||||
<input id="date-to" type="date">
|
||||
<div class="tab-pane fade" id="register-pane" role="tabpanel" aria-labelledby="register-tab-btn">
|
||||
<div class="mb-3">
|
||||
<label for="modal-reg-username" class="auth-label">Benutzername wählen</label>
|
||||
<input type="text" id="modal-reg-username" class="form-control auth-input"
|
||||
placeholder="z.B. maxmustermann" autocomplete="username">
|
||||
</div>
|
||||
<p id="register-error" class="auth-error" hidden></p>
|
||||
<div id="register-success" class="register-success d-none mb-3" aria-live="polite">
|
||||
<p class="mb-2 fw-semibold">Konto erstellt!</p>
|
||||
<p class="mb-1 small text-muted">Dein automatisch generiertes Passwort:</p>
|
||||
<div class="password-display">
|
||||
<code id="reg-password"></code>
|
||||
</div>
|
||||
<p class="small mt-2 mb-0 text-muted-custom">
|
||||
Notiere dir das Passwort – du brauchst es beim nächsten Login.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" id="modal-register-btn" class="btn btn-accent w-100">Konto erstellen</button>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<select id="category-input" class="form-control">
|
||||
<option value="">All categories</option>
|
||||
<option value="music">Music</option>
|
||||
<option value="sports">Sports</option>
|
||||
<option value="arts & theatre">Arts & Theatre</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button -->
|
||||
<button id="load-events" class="btn btn-primary">
|
||||
Search
|
||||
</button>
|
||||
<!-- HAUPTINHALT -->
|
||||
<main id="main-content">
|
||||
|
||||
<!-- HERO / SUCHBEREICH – Volle Breite mit Gradient-Hintergrund -->
|
||||
<section class="hero" id="search-section" aria-labelledby="search-heading">
|
||||
<div class="container">
|
||||
|
||||
<div class="hero__text">
|
||||
<h2 class="hero__title" id="search-heading">Events entdecken.</h2>
|
||||
<p class="hero__subtitle">Konzerte, Sport & Kultur – weltweit, in Echtzeit.</p>
|
||||
</div>
|
||||
|
||||
<!-- Schwebende Suchkarte -->
|
||||
<div class="search" role="search">
|
||||
<div class="search__controls">
|
||||
|
||||
<label for="city-input" class="visually-hidden">Stadt eingeben</label>
|
||||
<input
|
||||
id="city-input"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Stadt (z.B. Zürich)"
|
||||
aria-label="Stadt für Event-Suche"
|
||||
>
|
||||
|
||||
<label for="date-from">Von</label>
|
||||
<input id="date-from" type="date" aria-label="Startdatum">
|
||||
|
||||
<label for="date-to">Bis</label>
|
||||
<input id="date-to" type="date" aria-label="Enddatum">
|
||||
|
||||
<label for="category-input" class="visually-hidden">Kategorie</label>
|
||||
<select id="category-input" class="form-control" aria-label="Event-Kategorie">
|
||||
<option value="">Alle Kategorien</option>
|
||||
<option value="music">Musik</option>
|
||||
<option value="sports">Sport</option>
|
||||
<option value="arts & theatre">Kunst & Theater</option>
|
||||
</select>
|
||||
|
||||
<button id="load-events" type="button" class="btn btn-primary" aria-label="Events suchen">
|
||||
Suchen
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- EVENTS LIST -->
|
||||
<section class="events" id="events-section">
|
||||
|
||||
<h2>Upcoming Events</h2>
|
||||
|
||||
<div id="event-list">
|
||||
<p class="text-muted">Search for events to begin.</p>
|
||||
<!-- EVENTLISTE – Suchergebnisse -->
|
||||
<section class="events-section container" id="events-section" aria-labelledby="events-heading">
|
||||
<h2 id="events-heading">Kommende Events</h2>
|
||||
<div id="event-list" aria-live="polite" aria-label="Event-Ergebnisse">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon" aria-hidden="true">🔍</div>
|
||||
<p class="empty-state__text">Suche nach Events um zu starten</p>
|
||||
<p class="empty-state__sub">Gib eine Stadt ein und klicke auf Suchen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- MY EVENTS -->
|
||||
<!-- ========================= -->
|
||||
<section id="saved-section" class="d-none">
|
||||
|
||||
<h2>My Events</h2>
|
||||
|
||||
<div id="saved-list">
|
||||
<p class="text-muted">No saved events yet.</p>
|
||||
<!-- MY EVENTS – Gespeicherte Events -->
|
||||
<section id="saved-section" class="events-section container d-none" aria-labelledby="saved-heading">
|
||||
<h2 id="saved-heading">My Events</h2>
|
||||
<div id="saved-list" aria-live="polite">
|
||||
<p class="text-muted">Noch keine gespeicherten Events.</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- INVITATIONS -->
|
||||
<!-- ========================= -->
|
||||
<section id="invitations-section" class="d-none">
|
||||
|
||||
<h2>My Invitations</h2>
|
||||
|
||||
<div id="invitation-list">
|
||||
<p class="text-muted">No invitations yet.</p>
|
||||
<!-- EINLADUNGEN -->
|
||||
<section id="invitations-section" class="events-section container d-none" aria-labelledby="invitations-heading">
|
||||
<h2 id="invitations-heading">Meine Einladungen</h2>
|
||||
<div id="invitation-list" aria-live="polite">
|
||||
<p class="text-muted">Keine Einladungen vorhanden.</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="site-footer text-center mt-5 p-3">
|
||||
<footer class="site-footer text-center" role="contentinfo">
|
||||
<p>© 2026 Encore – Event Discovery</p>
|
||||
</footer>
|
||||
|
||||
<!-- JS -->
|
||||
<!-- Bootstrap JS (für Modal und Tabs) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- JavaScript – ES-Module -->
|
||||
<script type="module" src="js/app.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
498
js/app.js
498
js/app.js
@ -1,103 +1,208 @@
|
||||
/**
|
||||
* app.js – Haupteinstiegspunkt der Encore-Webanwendung.
|
||||
* Koordiniert Authentifizierung (via Modal), Navigation, Suche,
|
||||
* gespeicherte Events und Einladungen.
|
||||
*/
|
||||
|
||||
import { getEvents } from "./services/eventService.js";
|
||||
import { renderEventList } from "./ui/eventList.js";
|
||||
import { getFilters } from "./ui/filters.js";
|
||||
import { login, register } from "./auth.js";
|
||||
import { createEventCard } from "./ui/eventCard.js";
|
||||
|
||||
// =========================
|
||||
// STATE
|
||||
// GLOBALER ZUSTAND
|
||||
// window-Properties damit eventCard.js auf den Nutzer zugreifen kann
|
||||
// =========================
|
||||
let currentUser = null;
|
||||
let currentUser = null;
|
||||
let currentPassword = null;
|
||||
|
||||
window.currentUser = null;
|
||||
window.currentUser = null;
|
||||
window.currentPassword = null;
|
||||
|
||||
// =========================
|
||||
// AUTH ELEMENTS
|
||||
// DOM – HEADER AUTH
|
||||
// =========================
|
||||
const loginBtn = document.querySelector("#login-btn");
|
||||
const loginBtn = document.querySelector("#login-btn");
|
||||
const registerBtn = document.querySelector("#register-btn");
|
||||
const authArea = document.querySelector("#auth-area");
|
||||
const userArea = document.querySelector("#user-area");
|
||||
const userNameEl = document.querySelector("#user-name");
|
||||
const logoutBtn = document.querySelector("#logout-btn");
|
||||
|
||||
// =========================
|
||||
// NAVIGATION ELEMENTS
|
||||
// DOM – AUTH MODAL
|
||||
// =========================
|
||||
const authModalEl = document.querySelector("#auth-modal");
|
||||
const bsModal = new bootstrap.Modal(authModalEl);
|
||||
const loginTabBtn = document.querySelector("#login-tab-btn");
|
||||
const registerTabBtn = document.querySelector("#register-tab-btn");
|
||||
const modalLoginBtn = document.querySelector("#modal-login-btn");
|
||||
const modalRegBtn = document.querySelector("#modal-register-btn");
|
||||
const modalUsername = document.querySelector("#modal-username");
|
||||
const modalPassword = document.querySelector("#modal-password");
|
||||
const modalRegUsername = document.querySelector("#modal-reg-username");
|
||||
const loginError = document.querySelector("#login-error");
|
||||
const registerError = document.querySelector("#register-error");
|
||||
const registerSuccess = document.querySelector("#register-success");
|
||||
const regPasswordEl = document.querySelector("#reg-password");
|
||||
|
||||
// =========================
|
||||
// DOM – NAVIGATION
|
||||
// =========================
|
||||
const navEvents = document.querySelector("#nav-events");
|
||||
const navSaved = document.querySelector("#nav-my-events");
|
||||
const navInv = document.querySelector("#nav-invitations");
|
||||
const navSaved = document.querySelector("#nav-my-events");
|
||||
const navInv = document.querySelector("#nav-invitations");
|
||||
|
||||
// =========================
|
||||
// SECTIONS
|
||||
// DOM – SEKTIONEN
|
||||
// =========================
|
||||
const searchSection = document.querySelector("#search-section");
|
||||
const eventsSection = document.querySelector("#events-section");
|
||||
const savedSection = document.querySelector("#saved-section");
|
||||
const invSection = document.querySelector("#invitations-section");
|
||||
const savedSection = document.querySelector("#saved-section");
|
||||
const invSection = document.querySelector("#invitations-section");
|
||||
|
||||
// =========================
|
||||
// AUTH UI
|
||||
// MODAL ÖFFNEN – Korrekte Tab-Vorauswahl
|
||||
// =========================
|
||||
const authArea = document.querySelector("#auth-area");
|
||||
const userArea = document.querySelector("#user-area");
|
||||
const userNameEl = document.querySelector("#user-name");
|
||||
const logoutBtn = document.querySelector("#logout-btn");
|
||||
loginBtn.addEventListener("click", () => {
|
||||
bootstrap.Tab.getOrCreateInstance(loginTabBtn).show();
|
||||
bsModal.show();
|
||||
});
|
||||
|
||||
// =========================
|
||||
// LOGIN
|
||||
// =========================
|
||||
loginBtn.addEventListener("click", async () => {
|
||||
registerBtn.addEventListener("click", () => {
|
||||
bootstrap.Tab.getOrCreateInstance(registerTabBtn).show();
|
||||
bsModal.show();
|
||||
});
|
||||
|
||||
const username = document.querySelector("#username").value;
|
||||
const password = document.querySelector("#password").value;
|
||||
|
||||
const success = await login(username, password);
|
||||
|
||||
if (success) {
|
||||
currentUser = username;
|
||||
currentPassword = password;
|
||||
|
||||
window.currentUser = username;
|
||||
window.currentPassword = password;
|
||||
|
||||
// UI switch
|
||||
authArea.classList.add("d-none");
|
||||
userArea.classList.remove("d-none");
|
||||
|
||||
userNameEl.textContent = `👤 ${username}`;
|
||||
|
||||
navSaved.classList.remove("d-none");
|
||||
navInv.classList.remove("d-none");
|
||||
|
||||
} else {
|
||||
alert("Login failed");
|
||||
}
|
||||
// Modal-Felder zurücksetzen wenn geschlossen
|
||||
authModalEl.addEventListener("hidden.bs.modal", () => {
|
||||
modalUsername.value = "";
|
||||
modalPassword.value = "";
|
||||
modalRegUsername.value = "";
|
||||
loginError.hidden = true;
|
||||
registerError.hidden = true;
|
||||
registerSuccess.classList.add("d-none");
|
||||
modalRegBtn.textContent = "Konto erstellen";
|
||||
modalRegBtn.disabled = false;
|
||||
lastRegisteredUser = null;
|
||||
});
|
||||
|
||||
// =========================
|
||||
// REGISTER
|
||||
// LOGIN VIA MODAL
|
||||
// =========================
|
||||
registerBtn.addEventListener("click", async () => {
|
||||
modalLoginBtn.addEventListener("click", async () => {
|
||||
const username = modalUsername.value.trim();
|
||||
const password = modalPassword.value;
|
||||
|
||||
const username = document.querySelector("#username").value;
|
||||
loginError.hidden = true;
|
||||
|
||||
const data = await register(username);
|
||||
if (!username || !password) {
|
||||
loginError.textContent = "Bitte Benutzername und Passwort eingeben.";
|
||||
loginError.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
alert(`User created. Password: ${data.password}`);
|
||||
modalLoginBtn.disabled = true;
|
||||
modalLoginBtn.textContent = "Anmelden...";
|
||||
|
||||
try {
|
||||
const success = await login(username, password);
|
||||
|
||||
if (success) {
|
||||
currentUser = username;
|
||||
currentPassword = password;
|
||||
window.currentUser = username;
|
||||
window.currentPassword = password;
|
||||
|
||||
bsModal.hide();
|
||||
|
||||
authArea.classList.add("d-none");
|
||||
userArea.classList.remove("d-none");
|
||||
userNameEl.textContent = `👤 ${username}`;
|
||||
|
||||
navSaved.classList.remove("d-none");
|
||||
navInv.classList.remove("d-none");
|
||||
} else {
|
||||
loginError.textContent = "Login fehlgeschlagen. Bitte Zugangsdaten prüfen.";
|
||||
loginError.hidden = false;
|
||||
}
|
||||
} catch (err) {
|
||||
loginError.textContent = "Verbindungsfehler. Ist der Server erreichbar?";
|
||||
loginError.hidden = false;
|
||||
console.error("Login-Fehler:", err);
|
||||
} finally {
|
||||
modalLoginBtn.disabled = false;
|
||||
modalLoginBtn.textContent = "Anmelden";
|
||||
}
|
||||
});
|
||||
|
||||
// Enter in Passwort-Feld löst Login aus
|
||||
modalPassword.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") modalLoginBtn.click();
|
||||
});
|
||||
|
||||
// =========================
|
||||
// REGISTRIERUNG VIA MODAL
|
||||
// Zustandsvariable: nach Erfolg wechselt Button zu "Zum Login"
|
||||
// =========================
|
||||
let lastRegisteredUser = null;
|
||||
|
||||
modalRegBtn.addEventListener("click", async () => {
|
||||
// Nach erfolgreicher Registrierung → zu Login-Tab wechseln und Benutzernamen vorfüllen
|
||||
if (lastRegisteredUser) {
|
||||
modalUsername.value = lastRegisteredUser;
|
||||
lastRegisteredUser = null;
|
||||
bootstrap.Tab.getOrCreateInstance(loginTabBtn).show();
|
||||
return;
|
||||
}
|
||||
|
||||
const username = modalRegUsername.value.trim();
|
||||
registerError.hidden = true;
|
||||
|
||||
if (!username) {
|
||||
registerError.textContent = "Bitte einen Benutzernamen eingeben.";
|
||||
registerError.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
modalRegBtn.disabled = true;
|
||||
modalRegBtn.textContent = "Erstelle Konto...";
|
||||
|
||||
try {
|
||||
const data = await register(username);
|
||||
|
||||
if (data.password) {
|
||||
lastRegisteredUser = username;
|
||||
regPasswordEl.textContent = data.password;
|
||||
registerSuccess.classList.remove("d-none");
|
||||
modalRegBtn.disabled = false;
|
||||
modalRegBtn.textContent = "Zum Login →";
|
||||
} else {
|
||||
registerError.textContent = data.message || "Registrierung fehlgeschlagen.";
|
||||
registerError.hidden = false;
|
||||
modalRegBtn.disabled = false;
|
||||
modalRegBtn.textContent = "Konto erstellen";
|
||||
}
|
||||
} catch (err) {
|
||||
registerError.textContent = "Verbindungsfehler bei der Registrierung.";
|
||||
registerError.hidden = false;
|
||||
modalRegBtn.disabled = false;
|
||||
modalRegBtn.textContent = "Konto erstellen";
|
||||
console.error("Registrierungs-Fehler:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================
|
||||
// LOGOUT
|
||||
// =========================
|
||||
logoutBtn.addEventListener("click", () => {
|
||||
|
||||
currentUser = null;
|
||||
currentPassword = null;
|
||||
|
||||
window.currentUser = null;
|
||||
currentUser = null;
|
||||
currentPassword = null;
|
||||
window.currentUser = null;
|
||||
window.currentPassword = null;
|
||||
|
||||
authArea.classList.remove("d-none");
|
||||
userArea.classList.add("d-none");
|
||||
|
||||
navSaved.classList.add("d-none");
|
||||
navInv.classList.add("d-none");
|
||||
|
||||
@ -105,73 +210,84 @@ logoutBtn.addEventListener("click", () => {
|
||||
});
|
||||
|
||||
// =========================
|
||||
// SEARCH
|
||||
// SUCHE
|
||||
// =========================
|
||||
const button = document.querySelector("#load-events");
|
||||
const container = document.querySelector("#event-list");
|
||||
const cityInput = document.querySelector("#city-input");
|
||||
const searchBtn = document.querySelector("#load-events");
|
||||
const eventListEl = document.querySelector("#event-list");
|
||||
const cityInput = document.querySelector("#city-input");
|
||||
|
||||
button.addEventListener("click", handleSearch);
|
||||
searchBtn.addEventListener("click", handleSearch);
|
||||
|
||||
cityInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
cityInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
|
||||
const { city, dateFrom, dateTo, category } = getFilters();
|
||||
|
||||
if (!city) {
|
||||
container.innerHTML = "<p class='text-danger'>Please enter a city.</p>";
|
||||
showError(eventListEl, "Bitte eine Stadt eingeben.");
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "<p class='text-muted'>Loading events...</p>";
|
||||
showSkeletons(eventListEl);
|
||||
|
||||
const events = await getEvents(city);
|
||||
try {
|
||||
const events = await getEvents(city);
|
||||
const filtered = applyFilters(events, dateFrom, dateTo, category);
|
||||
renderEventList(filtered, eventListEl);
|
||||
} catch (err) {
|
||||
showError(eventListEl, "Fehler beim Laden der Events. Bitte erneut versuchen.");
|
||||
console.error("Such-Fehler:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEvents = applyFilters(events, dateFrom, dateTo, category);
|
||||
|
||||
renderEventList(filteredEvents, container);
|
||||
/** Zeigt Skeleton-Platzhalterkarten während Events geladen werden. */
|
||||
function showSkeletons(container, count = 6) {
|
||||
container.innerHTML = "";
|
||||
for (let i = 0; i < count; i++) {
|
||||
const skeleton = document.createElement("div");
|
||||
skeleton.className = "skeleton-card";
|
||||
skeleton.setAttribute("aria-hidden", "true");
|
||||
container.appendChild(skeleton);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// FILTERS
|
||||
// CLIENT-SEITIGE FILTER
|
||||
// =========================
|
||||
function applyFilters(events, dateFrom, dateTo, category) {
|
||||
|
||||
return events.filter(event => {
|
||||
|
||||
const matchDateFrom = dateFrom ? event.date >= dateFrom : true;
|
||||
const matchDateTo = dateTo ? event.date <= dateTo : true;
|
||||
|
||||
const matchDateTo = dateTo ? event.date <= dateTo : true;
|
||||
const matchCategory = category
|
||||
? event.category && event.category.includes(category)
|
||||
? event.category && event.category.toLowerCase().includes(category.toLowerCase())
|
||||
: true;
|
||||
|
||||
return matchDateFrom && matchDateTo && matchCategory;
|
||||
});
|
||||
}
|
||||
|
||||
// =========================
|
||||
// NAVIGATION LOGIC
|
||||
// NAVIGATION
|
||||
// =========================
|
||||
navEvents.addEventListener("click", () => {
|
||||
navEvents.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
showSection("events");
|
||||
});
|
||||
|
||||
navSaved.addEventListener("click", () => {
|
||||
navSaved.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
showSection("saved");
|
||||
loadSavedEvents();
|
||||
});
|
||||
|
||||
navInv.addEventListener("click", () => {
|
||||
navInv.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
showSection("invitations");
|
||||
loadInvitations();
|
||||
});
|
||||
|
||||
function showSection(section) {
|
||||
|
||||
searchSection.classList.add("d-none");
|
||||
eventsSection.classList.add("d-none");
|
||||
savedSection.classList.add("d-none");
|
||||
@ -180,89 +296,209 @@ function showSection(section) {
|
||||
if (section === "events") {
|
||||
searchSection.classList.remove("d-none");
|
||||
eventsSection.classList.remove("d-none");
|
||||
}
|
||||
|
||||
if (section === "saved") {
|
||||
} else if (section === "saved") {
|
||||
savedSection.classList.remove("d-none");
|
||||
}
|
||||
|
||||
if (section === "invitations") {
|
||||
} else if (section === "invitations") {
|
||||
invSection.classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// SAVED EVENTS
|
||||
// GESPEICHERTE EVENTS (MY EVENTS)
|
||||
// =========================
|
||||
function loadSavedEvents() {
|
||||
|
||||
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
|
||||
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
|
||||
const container = document.querySelector("#saved-list");
|
||||
|
||||
container.innerHTML = "";
|
||||
|
||||
if (saved.length === 0) {
|
||||
container.innerHTML = "<p>No saved events yet.</p>";
|
||||
const p = document.createElement("p");
|
||||
p.className = "text-muted";
|
||||
p.textContent = "Noch keine gespeicherten Events.";
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
|
||||
saved.forEach(event => {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = event.name;
|
||||
container.appendChild(div);
|
||||
const card = createEventCard(event);
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.textContent = "Entfernen";
|
||||
removeBtn.className = "btn btn-outline-danger btn-sm mt-2";
|
||||
removeBtn.addEventListener("click", () => {
|
||||
removeSavedEvent(event.id);
|
||||
loadSavedEvents();
|
||||
});
|
||||
|
||||
card.appendChild(removeBtn);
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function removeSavedEvent(eventId) {
|
||||
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
|
||||
const updated = saved.filter(e => e.id !== eventId);
|
||||
localStorage.setItem("savedEvents", JSON.stringify(updated));
|
||||
}
|
||||
|
||||
// =========================
|
||||
// INVITATIONS
|
||||
// EINLADUNGEN
|
||||
// =========================
|
||||
async function loadInvitations() {
|
||||
|
||||
if (!currentUser) return;
|
||||
|
||||
const res = await fetch("http://localhost:3000/api/invitation", {
|
||||
headers: {
|
||||
"X-Username": currentUser
|
||||
}
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const container = document.querySelector("#invitation-list");
|
||||
container.innerHTML = "<p class='text-muted'>Einladungen werden geladen...</p>";
|
||||
|
||||
container.innerHTML = "";
|
||||
try {
|
||||
const res = await fetch("http://localhost:3000/api/invitation", {
|
||||
headers: { "X-Username": currentUser }
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = "<p>No invitations.</p>";
|
||||
if (!res.ok) throw new Error(`Server antwortete mit Status ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
container.innerHTML = "";
|
||||
|
||||
if (data.length === 0) {
|
||||
const p = document.createElement("p");
|
||||
p.className = "text-muted";
|
||||
p.textContent = "Keine Einladungen vorhanden.";
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(inv => renderInvitation(inv, container));
|
||||
|
||||
} catch (err) {
|
||||
showError(container, "Fehler beim Laden der Einladungen.");
|
||||
console.error("Einladungs-Fehler:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert eine Einladungskarte – ausschliesslich DOM-Methoden (kein innerHTML mit Nutzerdaten).
|
||||
* Jede Karte hat einen Löschen-Button (demonstriert HTTP DELETE).
|
||||
*/
|
||||
function renderInvitation(inv, container) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "invitation-card";
|
||||
|
||||
const text = document.createElement("p");
|
||||
const strong = document.createElement("strong");
|
||||
const em = document.createElement("em");
|
||||
strong.textContent = inv.fromUser;
|
||||
em.textContent = inv.eventName;
|
||||
text.append(strong, " hat dich zu ", em, " eingeladen.");
|
||||
card.appendChild(text);
|
||||
|
||||
if (inv.status !== "pending") {
|
||||
const status = document.createElement("p");
|
||||
status.className = inv.status === "accepted" ? "text-success" : "text-muted";
|
||||
status.textContent = inv.status === "accepted" ? "✓ Angenommen" : "Abgelehnt";
|
||||
card.appendChild(status);
|
||||
card.appendChild(buildDeleteBtn(inv.id, card));
|
||||
container.appendChild(card);
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(inv => {
|
||||
const btnGroup = document.createElement("div");
|
||||
btnGroup.className = "d-flex gap-2 flex-wrap";
|
||||
|
||||
const div = document.createElement("div");
|
||||
const acceptBtn = document.createElement("button");
|
||||
acceptBtn.type = "button";
|
||||
acceptBtn.textContent = "Annehmen";
|
||||
acceptBtn.className = "btn btn-success btn-sm";
|
||||
acceptBtn.addEventListener("click", () => respondToInvitation(inv.id, "accept", card));
|
||||
|
||||
div.innerHTML = `
|
||||
<p><strong>${inv.fromUser}</strong> invited you to <em>${inv.eventName}</em></p>
|
||||
<button onclick="accept(${inv.id})">Accept</button>
|
||||
<button onclick="decline(${inv.id})">Decline</button>
|
||||
`;
|
||||
const declineBtn = document.createElement("button");
|
||||
declineBtn.type = "button";
|
||||
declineBtn.textContent = "Ablehnen";
|
||||
declineBtn.className = "btn btn-outline-secondary btn-sm";
|
||||
declineBtn.addEventListener("click", () => respondToInvitation(inv.id, "decline", card));
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
btnGroup.append(acceptBtn, declineBtn, buildDeleteBtn(inv.id, card));
|
||||
card.appendChild(btnGroup);
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
/** Erstellt den Löschen-Button – ruft DELETE /api/invitation/:id auf. */
|
||||
function buildDeleteBtn(id, card) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "Löschen";
|
||||
btn.className = "btn btn-outline-danger btn-sm";
|
||||
btn.addEventListener("click", () => deleteInvitation(id, card));
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den Einladungsstatus via PUT /api/invitation/:id.
|
||||
* HTTP PUT gemäss REST: Update einer bestehenden Ressource.
|
||||
*/
|
||||
async function respondToInvitation(id, action, card) {
|
||||
const status = action === "accept" ? "accepted" : "declined";
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:3000/api/invitation/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Status ${res.status}`);
|
||||
|
||||
const btnGroup = card.querySelector(".d-flex");
|
||||
if (btnGroup) btnGroup.remove();
|
||||
|
||||
const statusEl = document.createElement("p");
|
||||
statusEl.className = action === "accept" ? "text-success" : "text-muted";
|
||||
statusEl.textContent = action === "accept" ? "✓ Angenommen" : "Abgelehnt";
|
||||
card.appendChild(statusEl);
|
||||
card.appendChild(buildDeleteBtn(id, card));
|
||||
|
||||
} catch (err) {
|
||||
const errEl = document.createElement("p");
|
||||
errEl.className = "text-danger small mt-1";
|
||||
errEl.textContent = "Fehler beim Antworten auf die Einladung.";
|
||||
card.appendChild(errEl);
|
||||
console.error("Antwort-Fehler:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht eine Einladung via DELETE /api/invitation/:id.
|
||||
* HTTP DELETE gemäss REST: Entfernt die Ressource dauerhaft.
|
||||
* 204 No Content ist der erwartete Erfolgsstatus.
|
||||
*/
|
||||
async function deleteInvitation(id, card) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:3000/api/invitation/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
// 204 No Content = Erfolg; res.ok deckt 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) => {
|
||||
await fetch(`http://localhost:3000/api/invitation/${id}/accept`, {
|
||||
method: "POST"
|
||||
});
|
||||
loadInvitations();
|
||||
};
|
||||
|
||||
window.decline = async (id) => {
|
||||
await fetch(`http://localhost:3000/api/invitation/${id}/decline`, {
|
||||
method: "POST"
|
||||
});
|
||||
loadInvitations();
|
||||
};
|
||||
function showError(container, message) {
|
||||
const p = document.createElement("p");
|
||||
p.className = "text-danger";
|
||||
p.textContent = message;
|
||||
container.innerHTML = "";
|
||||
container.appendChild(p);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user