Compare commits

..

No commits in common. "main" and "minor-changes" have entirely different histories.

18 changed files with 229 additions and 3027 deletions

1
.env
View File

@ -1 +0,0 @@
TM_API_KEY=0FLYFe9BnzGlk2OPHrWleCulNzHpWgtC

View File

@ -1 +0,0 @@
TM_API_KEY=your_ticketmaster_api_key

3
.gitignore vendored
View File

@ -1,3 +0,0 @@
node_modules/
.DS_Store
__MACOSX/

174
README.md
View File

@ -1,158 +1,20 @@
# Encore Event Discovery
# 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.
Frontend-Projekt für das Modul cds-208 an der FHGR (Frühlingssemester 2026).
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.
---
## 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
```bash
git clone https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt.git
cd AISE_FS26-FrontendEntwicklungProjekt
```
### 2. Abhängigkeiten installieren
```bash
npm install
```
### 3. Umgebungsvariablen / Ticketmaster API
Für die Bewertung enthält die Abgabe bereits eine vorkonfigurierte `.env`-Datei, sodass die Anwendung ohne zusätzliche Konfiguration gestartet werden kann.
In einer regulären Installation muss eine eigene `.env`-Datei im Projektstamm erstellt werden:
```env
TM_API_KEY=your_ticketmaster_api_key
```
Ein kostenloser API-Key kann über das Ticketmaster Developer Portal bezogen werden:
https://developer.ticketmaster.com/
Zusätzlich ist eine `.env.example`-Datei enthalten, welche die erwartete Struktur der Umgebungsvariablen dokumentiert.
### 4. Backend-Server starten
```bash
npm start
```
Ausgabe:
```text
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
* **Frontend-Backend-Integration:** Die Kommunikation zwischen Frontend, Backend und Ticketmaster API musste zuverlässig koordiniert werden.
* **Verschachtelte Ticketmaster-Daten:** Die komplexe JSON-Struktur der API erforderte eine zusätzliche Service-Schicht zur Datenaufbereitung.
* **Modularisierung:** Mit wachsendem Funktionsumfang wurde eine klare Trennung der Verantwortlichkeiten immer wichtiger.
### Lerneffekte
* **Integration erhöht die Komplexität:** Mehrere Systeme müssen konsistent zusammenarbeiten.
* **Kleine Änderungen können grosse Auswirkungen haben:** Sichere Praktiken wie die Verwendung von `textContent` helfen, Fehler und Sicherheitsrisiken zu vermeiden.
* **Modulare Architektur verbessert die Wartbarkeit:** Klare Strukturen erleichtern Entwicklung, Fehlersuche und Erweiterungen.
---
## Gruppenmitglieder und Rollen
| Name | Rolle | Schwerpunkte |
| --------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------- |
| **Nicola Augsburger** | Frontend & Backend | Einladungssystem (Frontend + Backend), Authentifizierung, App-Architektur |
| **Karolina Thöny-Tyganova** | Frontend & API | Ticketmaster-API-Integration, Suchfunktion, Filterlogik, Event-Karten-Komponente, CSS-Styling, Responsives Layout |
Die individuelle Beteiligung ist in den Git-Commits auf [gitea.fhgr.ch](https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt) nachvollziehbar.
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.

View File

@ -1,351 +1,55 @@
/* =========================
EVENT-KARTE
Moderne Karte mit Hover-Effekt und farbigem Akzent-Streifen oben
EVENT CARD COMPONENT
========================= */
.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);
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;
}
}
/* =========================
KARTEN-INHALT
========================= */
/* Titel */
.event-card__title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin: 0.375rem 0 0.5rem;
line-height: 1.4;
/* Lange Titel auf 2 Zeilen begrenzen */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Datum & Uhrzeit in Akzentfarbe für schnelle Lesbarkeit */
.event-card__date {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-accent-dark);
margin: 0 0 0.3rem;
letter-spacing: 0.01em;
}
/* Veranstaltungsort */
.event-card__venue {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0 0 1rem;
/* Icon-ähnlicher Stil durch leichten Einzug */
padding-left: 0;
}
/* =========================
BUTTON-GRUPPE IN KARTE
Trennlinie separiert Inhalt von Aktionen
========================= */
.event-card .d-flex.gap-2 {
border-top: 1px solid var(--color-border);
padding-top: 0.875rem;
margin-top: auto;
}
/* =========================
EINLADUNGSFORMULAR (Inline)
Erscheint direkt in der Karte beim Klick auf "Einladen"
========================= */
.invite-form {
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
margin-top: 0.5rem;
}
.invite-form input[type="text"] {
font-size: 0.85rem;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-xs);
padding: 0.4rem 0.75rem;
height: 36px;
width: 100%;
font-family: inherit;
background: var(--color-bg);
color: var(--color-text);
transition: border-color 0.2s, box-shadow 0.2s;
}
.invite-form input[type="text"]:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(13, 148, 136, 0.15);
outline: none;
background: var(--color-surface);
}
.invite-form input[type="text"].is-invalid {
border-color: var(--color-danger);
}
/* =========================
STATUSMELDUNG IN KARTE
Temporäre Bestätigungs- oder Fehlermeldung (verschwindet nach 3s)
========================= */
.card-message {
font-size: 0.8rem;
font-weight: 500;
border-radius: var(--radius-xs);
padding: 0.35rem 0.6rem;
margin-top: 0.5rem;
margin-bottom: 0;
background: rgba(0, 0, 0, 0.03);
}
/* =========================
EINLADUNGS-KARTE (My Invitations)
========================= */
.invitation-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.invitation-card p {
margin: 0 0 0.75rem;
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.5;
}
.invitation-card p:last-child {
margin-bottom: 0;
}
/* =========================
AUTH MODAL
========================= */
.auth-modal-content {
border: none;
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.auth-modal-header {
background: var(--color-header);
padding: 1.25rem 1.5rem;
border-bottom: none;
}
.auth-modal-title {
color: #ffffff;
font-size: 1.1rem;
font-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-modal-body .form-control.auth-input {
border: 1.5px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.9rem;
height: 42px;
color: var(--color-text);
background: var(--color-bg);
transition: border-color var(--transition), box-shadow var(--transition);
}
.auth-modal-body .form-control.auth-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15);
outline: none;
background: var(--color-surface);
}
.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;
}
/* 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;
}

View File

@ -1,52 +1,16 @@
/* =========================
INTER FONT (Google Fonts)
RESET & BASE
========================= */
@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: '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;
font-family: Arial, sans-serif;
background-color: #f9f9f9;
color: #333;
}
img {
@ -55,414 +19,105 @@ img {
}
/* =========================
KARTEN-ERSCHEINUNGS-ANIMATION
Wird auf Event-Karten angewendet wenn sie ins DOM kommen
GLOBAL LAYOUT
========================= */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(22px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Shimmer-Animation für Skeleton-Karten (transform statt background-position → GPU-composited) */
@keyframes shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(200%); }
}
/* =========================
HEADER Dunkel & Sticky
========================= */
.site-header {
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);
}
.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;
}
.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;
align-items: center;
gap: 0.5rem;
}
.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;
}
.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);
padding: 1rem;
}
section {
margin-bottom: 2rem;
}
/* =========================
HERO-BEREICH (Suchsektion)
Volle Breite, dunkler Gradient-Hintergrund
HEADER
========================= */
.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 {
.site-header {
background-color: #3dd0a9;
color: #222; /* fixed contrast */
padding: 1rem;
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 SECTION
========================= */
.event-search h2 {
margin-bottom: 0.5rem;
}
.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);
gap: 0.5rem;
}
/* =========================
SECTION (EVENTS / SAVED / INVITATIONS)
EVENTS LIST
========================= */
.events-section {
/* Keep content away from the phone edges.
The section also has Bootstrap's .container class, but this rule appears later
and would otherwise overwrite the container's left/right padding with 0. */
padding: 2.5rem 1rem 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;
width: 100%;
}
/* =========================
EMPTY STATE
========================= */
.empty-state {
text-align: center;
padding: 3.5rem 1rem;
color: var(--color-text-muted);
}
.empty-state__icon {
font-size: 3rem;
.events h2 {
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;
#event-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* =========================
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;
}
.site-footer p {
margin: 0;
}
/* =========================
MOBILE SPACING FIX
Prevent cards/text from touching the viewport edges
========================= */
@media (max-width: 575.98px) {
.events-section {
padding-left: 1rem;
padding-right: 1rem;
}
.event-card,
.invitation-card {
padding: 1rem;
}
.invitation-card .d-flex,
.event-card .d-flex {
flex-wrap: wrap;
}
footer {
text-align: center;
padding: 1rem;
margin-top: 2rem;
font-size: 0.9rem;
color: #777;
}
/* =========================
TABLET (768px)
TABLET (>=768px)
========================= */
@media (min-width: 768px) {
.search__controls {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
#city-input { flex: 2; min-width: 160px; }
#date-from,
#date-to { flex: 1; min-width: 130px; }
#category-input { flex: 0 0 160px; }
.search__controls input,
.search__controls select {
flex: 1;
min-width: 120px;
}
#event-list,
#saved-list {
.search__controls button {
flex: 0 0 auto;
min-width: 120px;
}
#event-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
}
}
/* =========================
DESKTOP (1024px)
DESKTOP (>=1024px)
========================= */
@media (min-width: 1024px) {
#event-list,
#saved-list {
#event-list {
grid-template-columns: repeat(3, 1fr);
}
}
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8">
@ -7,207 +7,101 @@
<title>Encore Discover Events</title>
<!-- Bootstrap 5.3 CSS-Framework für responsives Layout -->
<!-- Bootstrap -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet">
<!-- Eigene Styles -->
<!-- CSS -->
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/main.css">
</head>
<body>
<!-- HEADER Hauptnavigation und Authentifizierungsbereich -->
<header class="site-header" role="banner">
<nav class="container d-flex justify-content-between align-items-center" aria-label="Hauptnavigation">
<!-- HEADER -->
<header class="site-header">
<nav class="container d-flex justify-content-between align-items-center">
<h1 class="site-logo">Encore</h1>
<ul class="nav" role="list">
<ul class="nav">
<li class="nav-item">
<a class="nav-link d-none" href="#" id="nav-events" aria-label="Zur Event-Suche">Events</a>
<a class="nav-link" href="index.html">Home</a>
</li>
<li class="nav-item">
<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" aria-label="Meine Einladungen">Invitations</a>
<a class="nav-link" href="#">Events</a>
</li>
</ul>
<!-- 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>
<!-- 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>
<!-- 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">
<!-- MAIN CONTENT -->
<main class="container mt-4">
<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>
<!-- SEARCH / FILTER SECTION -->
<section class="search mb-4">
<div class="modal-body auth-modal-body">
<h2>Find Events</h2>
<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="search__controls">
<div class="tab-content">
<!-- City -->
<input
id="city-input"
type="text"
class="form-control"
placeholder="Enter city (e.g. Zurich)"
>
<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>
<!-- Date -->
<label for="date-from">From</label>
<input id="date-from" 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>
<label for="date-to">To</label>
<input id="date-to" type="date">
</div>
</div>
</div>
</div>
</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>
<!-- HAUPTINHALT -->
<main id="main-content">
<!-- HERO / SUCHBEREICH Volle Breite mit Gradient-Hintergrund -->
<section class="hero" id="search-section" aria-labelledby="search-heading">
<div class="container">
<div class="hero__text">
<h2 class="hero__title" id="search-heading">Events entdecken.</h2>
<p class="hero__subtitle">Konzerte, Sport &amp; Kultur weltweit, in Echtzeit.</p>
</div>
<!-- Schwebende Suchkarte -->
<div class="search" role="search">
<div class="search__controls">
<label for="city-input" class="visually-hidden">Stadt eingeben</label>
<input
id="city-input"
type="text"
class="form-control"
placeholder="Stadt (z.B. Zürich)"
aria-label="Stadt für Event-Suche"
>
<label for="date-from">Von</label>
<input id="date-from" type="date" aria-label="Startdatum">
<label for="date-to">Bis</label>
<input id="date-to" type="date" aria-label="Enddatum">
<label for="category-input" class="visually-hidden">Kategorie</label>
<select id="category-input" class="form-control" aria-label="Event-Kategorie">
<option value="">Alle Kategorien</option>
<option value="music">Musik</option>
<option value="sports">Sport</option>
<option value="arts & theatre">Kunst & Theater</option>
</select>
<button id="load-events" type="button" class="btn btn-primary" aria-label="Events suchen">
Suchen
</button>
</div>
</div>
<!-- Button -->
<button
id="load-events"
class="btn btn-primary"
>
Search
</button>
</div>
</section>
<!-- 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>
<!-- EVENTS LIST -->
<section class="events">
<!-- 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>
<h2>Upcoming Events</h2>
<!-- 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 id="event-list">
<p class="text-muted">Search for events to begin.</p>
</div>
</section>
</main>
<!-- FOOTER -->
<footer class="site-footer text-center" role="contentinfo">
<footer class="site-footer text-center mt-5 p-3">
<p>© 2026 Encore Event Discovery</p>
</footer>
<!-- 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 -->
<!-- JS -->
<script type="module" src="js/app.js"></script>
</body>

BIN
js/.DS_Store vendored

Binary file not shown.

View File

@ -1,31 +1,21 @@
/**
* 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).
*/
const API_KEY = "0FLYFe9BnzGlk2OPHrWleCulNzHpWgtC";
/**
* 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) {
const url = `http://localhost:3000/api/events?city=${encodeURIComponent(city)}`;
const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${API_KEY}&city=${city}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Backend antwortete mit Status ${response.status}`);
throw new Error("API request failed");
}
const data = await response.json();
// Ticketmaster gibt Events unter _embedded.events zurück; leeres Array als Fallback
return data._embedded?.events || [];
} catch (error) {
// Fehler nach oben weitergeben, damit app.js ihn dem Nutzer anzeigen kann
throw error;
console.error("Error fetching events:", error);
return [];
}
}
}

518
js/app.js
View File

@ -1,522 +1,46 @@
/**
* 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";
// =========================
// GLOBALER ZUSTAND
// window-Properties damit eventCard.js auf den Nutzer zugreifen kann
// =========================
let currentUser = null;
let currentPassword = null;
let lastRenderedEvents = [];
window.currentUser = null;
window.currentPassword = null;
const button = document.querySelector("#load-events");
const container = document.querySelector("#event-list");
const cityInput = document.querySelector("#city-input");
// =========================
// DOM HEADER AUTH
// =========================
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");
button.addEventListener("click", handleSearch);
// =========================
// 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");
// =========================
// 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");
// =========================
// MODAL ÖFFNEN Korrekte Tab-Vorauswahl
// =========================
loginBtn.addEventListener("click", () => {
bootstrap.Tab.getOrCreateInstance(loginTabBtn).show();
bsModal.show();
});
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 VIA MODAL
// =========================
modalLoginBtn.addEventListener("click", async () => {
const username = modalUsername.value.trim();
const password = modalPassword.value;
loginError.hidden = true;
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);
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}`;
navEvents.classList.remove("d-none");
navSaved.classList.remove("d-none");
navInv.classList.remove("d-none");
refreshVisibleEventCards();
} else {
loginError.textContent = "Login fehlgeschlagen. Bitte Zugangsdaten prüfen.";
loginError.hidden = false;
}
} catch (err) {
loginError.textContent = "Verbindungsfehler. Ist der Server erreichbar?";
loginError.hidden = false;
} 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";
}
});
// =========================
// LOGOUT
// =========================
logoutBtn.addEventListener("click", () => {
currentUser = null;
currentPassword = null;
window.currentUser = null;
window.currentPassword = null;
authArea.classList.remove("d-none");
userArea.classList.add("d-none");
navEvents.classList.add("d-none");
navSaved.classList.add("d-none");
navInv.classList.add("d-none");
showSection("events");
refreshVisibleEventCards();
});
// =========================
// SUCHE
// =========================
const searchBtn = document.querySelector("#load-events");
const eventListEl = document.querySelector("#event-list");
const cityInput = document.querySelector("#city-input");
searchBtn.addEventListener("click", handleSearch);
cityInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") handleSearch();
cityInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") handleSearch();
});
async function handleSearch() {
const { city, dateFrom, dateTo, category } = getFilters();
if (!city) {
showError(eventListEl, "Bitte eine Stadt eingeben.");
container.innerHTML = "<p class='text-danger'>Please enter a city.</p>";
return;
}
showSkeletons(eventListEl);
container.innerHTML = "<p class='text-muted'>Loading events...</p>";
try {
const events = await getEvents(city);
const filtered = applyFilters(events, dateFrom, dateTo, category);
lastRenderedEvents = filtered;
renderEventList(filtered, eventListEl);
} catch (err) {
showError(eventListEl, "Fehler beim Laden der Events. Bitte erneut versuchen.");
}
const events = await getEvents(city);
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);
}
}
// =========================
// 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.toLowerCase().includes(category.toLowerCase())
? event.category && event.category.includes(category)
: true;
return matchDateFrom && matchDateTo && matchCategory;
});
}
// =========================
// NAVIGATION
// =========================
navEvents.addEventListener("click", (e) => {
e.preventDefault();
showSection("events");
});
navSaved.addEventListener("click", (e) => {
e.preventDefault();
showSection("saved");
loadSavedEvents();
});
navInv.addEventListener("click", (e) => {
e.preventDefault();
showSection("invitations");
loadInvitations();
});
function refreshVisibleEventCards() {
if (!eventsSection.classList.contains("d-none") && lastRenderedEvents.length > 0) {
renderEventList(lastRenderedEvents, eventListEl);
}
}
function showSection(section) {
searchSection.classList.add("d-none");
eventsSection.classList.add("d-none");
savedSection.classList.add("d-none");
invSection.classList.add("d-none");
if (section === "events") {
searchSection.classList.remove("d-none");
eventsSection.classList.remove("d-none");
} else if (section === "saved") {
savedSection.classList.remove("d-none");
} else if (section === "invitations") {
invSection.classList.remove("d-none");
}
}
// =========================
// GESPEICHERTE EVENTS (MY EVENTS)
// =========================
function loadSavedEvents() {
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
const container = document.querySelector("#saved-list");
container.innerHTML = "";
if (saved.length === 0) {
const p = document.createElement("p");
p.className = "text-muted";
p.textContent = "Noch keine gespeicherten Events.";
container.appendChild(p);
return;
}
saved.forEach(event => {
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));
}
// =========================
// EINLADUNGEN
// =========================
async function loadInvitations() {
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", {
headers: { "X-Username": currentUser }
});
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.");
}
}
/**
* 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.eventUrl) {
const link = document.createElement("a");
link.href = inv.eventUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.className = "btn btn-outline-primary btn-sm mb-2";
link.textContent = "Event öffnen";
card.appendChild(link);
}
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);
}
}
/**
* Löscht eine Einladung via DELETE /api/invitation/:id.
* HTTP DELETE gemäss REST: Entfernt die Ressource dauerhaft.
* 204 No Content ist der erwartete Erfolgsstatus.
*/
async function deleteInvitation(id, card) {
try {
const res = await fetch(`http://localhost:3000/api/invitation/${id}`, {
method: "DELETE"
});
// 204 No Content = Erfolg; res.ok deckt 200299 ab
if (!res.ok) throw new Error(`Status ${res.status}`);
card.remove();
} catch (err) {
const errEl = document.createElement("p");
errEl.className = "text-danger small mt-1";
errEl.textContent = "Fehler beim Löschen der Einladung.";
card.appendChild(errEl);
}
}
// =========================
// UTILITY Fehlermeldung (XSS-sicher via textContent)
// =========================
function showError(container, message) {
const p = document.createElement("p");
p.className = "text-danger";
p.textContent = message;
container.innerHTML = "";
container.appendChild(p);
}
}

View File

@ -1,46 +0,0 @@
/**
* 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";
/**
* 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) {
const res = await fetch(`${BASE_URL}/user`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username })
});
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) {
const res = await fetch(`${BASE_URL}/user`, {
method: "GET",
headers: {
"X-Username": username,
"X-Password": password
}
});
// HTTP 200 = Anmeldung erfolgreich, 401 = ungültige Credentials
return res.status === 200;
}

View File

@ -1,27 +1,14 @@
/**
* 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";
/**
* Lädt und transformiert Events für eine gegebene Stadt.
* @param {string} city - Stadtname
* @returns {Promise<Array<{id, name, date, time, venue, category, url}>>}
*/
export async function getEvents(city) {
const events = await fetchEvents(city);
// Rohe API-Daten auf ein einheitliches, einfaches Format abbilden
return events.map(event => ({
id: event.id,
name: event.name,
date: event.dates?.start?.localDate || null,
time: event.dates?.start?.localTime || null,
venue: event._embedded?.venues?.[0]?.name || "Unbekannter Ort",
url: event.url || null,
// Kategorie aus dem ersten Klassifizierungs-Segment extrahieren (z.B. "Music", "Sports")
id: event.id,
name: event.name,
date: event.dates?.start?.localDate || null,
time: event.dates?.start?.localTime || null,
venue: event._embedded?.venues?.[0]?.name || "Unknown venue",
category: event.classifications?.[0]?.segment?.name
? event.classifications[0].segment.name.toLowerCase()
: null

View File

@ -1,220 +1,37 @@
/**
* 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) {
const article = document.createElement("article");
article.className = "event-card";
// --- TITEL ---
const title = document.createElement("h3");
title.className = "event-card__title";
title.textContent = event.name;
// --- DATUM & UHRZEIT formatieren (Schweizer Format: dd.mm.yyyy) ---
const formattedDate = event.date
? new Date(event.date).toLocaleDateString("de-CH", {
day: "2-digit",
month: "2-digit",
year: "numeric"
})
: "Datum unbekannt";
: "Date not available";
const formattedTime = event.time ? event.time.slice(0, 5) : "";
const dateTime = formattedTime ? `${formattedDate}, ${formattedTime}` : formattedDate;
const formattedTime = event.time
? event.time.slice(0, 5)
: "";
const dateTime = formattedTime
? `${formattedDate}, ${formattedTime}`
: formattedDate;
const date = document.createElement("p");
date.className = "event-card__date";
date.textContent = dateTime;
// --- VERANSTALTUNGSORT ---
const venue = document.createElement("p");
venue.className = "event-card__venue";
venue.textContent = event.venue || "Ort unbekannt";
venue.textContent = event.venue;
article.append(title, date, venue);
if (event.url) {
const link = document.createElement("a");
link.href = event.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.className = "btn btn-outline-secondary btn-sm mt-2";
link.textContent = "Event ansehen";
article.appendChild(link);
}
// Gäste sehen nur Event-Informationen. Speichern und Einladen sind Login-Funktionen.
if (!window.currentUser) {
return article;
}
// --- BUTTON-GRUPPE ---
const buttonContainer = document.createElement("div");
buttonContainer.className = "d-flex gap-2 mt-2 flex-wrap";
// =========================
// SPEICHERN-BUTTON
// Speichert das Event im localStorage, verhindert Duplikate
// =========================
const saveBtn = document.createElement("button");
saveBtn.type = "button";
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", () => {
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
// Duplikat-Schutz
if (saved.find(e => e.id === event.id)) {
saveBtn.textContent = "Gespeichert ✓";
saveBtn.disabled = true;
return;
}
saved.push(event);
localStorage.setItem("savedEvents", JSON.stringify(saved));
// Visuelles Feedback statt alert()
saveBtn.textContent = "Gespeichert ✓";
saveBtn.disabled = true;
saveBtn.classList.replace("btn-outline-primary", "btn-success");
});
// =========================
// EINLADEN-BUTTON
// Zeigt ein Inline-Formular an (kein browser-prompt())
// =========================
const inviteBtn = document.createElement("button");
inviteBtn.type = "button";
inviteBtn.textContent = "Einladen";
inviteBtn.className = "btn btn-primary btn-sm";
inviteBtn.disabled = false;
inviteBtn.addEventListener("click", () => {
if (!window.currentUser) {
showCardMessage(article, "Bitte einloggen um Einladungen zu senden.", "warning");
return;
}
// Formular ein-/ausblenden (Toggle)
const existing = article.querySelector(".invite-form");
if (existing) {
existing.remove();
} else {
article.appendChild(createInviteForm(event, article));
}
});
buttonContainer.append(saveBtn, inviteBtn);
article.appendChild(buttonContainer);
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",
headers: {
"Content-Type": "application/json",
"X-Username": window.currentUser
},
body: JSON.stringify({
toUser,
eventId: event.id,
eventName: event.name,
eventUrl: event.url || null
})
});
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");
}
});
form.append(input, sendBtn, cancelBtn);
return form;
}
// =========================
// HILFSFUNKTION Status-Nachricht in Karte anzeigen
// Zeigt eine kurze Meldung innerhalb der Karte an und blendet sie nach 3s aus
// =========================
function showCardMessage(card, message, type) {
// Alte Nachrichten entfernen
card.querySelectorAll(".card-message").forEach(el => el.remove());
const msg = document.createElement("p");
msg.className = `card-message text-${type} mt-1 mb-0`;
msg.textContent = message;
card.appendChild(msg);
setTimeout(() => msg.remove(), 3000);
}
// =========================
// HILFSFUNKTION Prüft ob Event bereits gespeichert ist
// =========================
function isAlreadySaved(eventId) {
const saved = JSON.parse(localStorage.getItem("savedEvents") || "[]");
return saved.some(e => e.id === eventId);
}
}

View File

@ -1,38 +1,11 @@
/**
* eventList.js Rendert eine Liste von Event-Karten in einen Container.
* Delegiert die Erstellung einzelner Karten an createEventCard().
*/
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) {
container.innerHTML = "";
if (events.length === 0) {
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);
container.innerHTML = "No events found.";
return;
}
@ -40,4 +13,5 @@ export function renderEventList(events, container) {
const card = createEventCard(event);
container.appendChild(card);
});
}
}

View File

@ -1,17 +1,8 @@
/**
* 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() {
const city = document.querySelector("#city-input").value.trim();
const dateFrom = document.querySelector("#date-from").value;
const dateTo = document.querySelector("#date-to").value;
const category = document.querySelector("#category-input").value;
return { city, dateFrom, dateTo, category };
}
const city = document.querySelector("#city-input").value.trim();
const dateFrom = document.querySelector("#date-from").value;
const dateTo = document.querySelector("#date-to").value;
const category = document.querySelector("#category-input").value;
return { city, dateFrom, dateTo, category };
}

867
package-lock.json generated
View File

@ -1,867 +0,0 @@
{
"name": "aise_fs26-frontendentwicklungprojekt",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aise_fs26-frontendentwicklungprojekt",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.1",
"express": "^5.2.1"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dotenv": {
"version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

View File

@ -1,22 +0,0 @@
{
"name": "aise_fs26-frontendentwicklungprojekt",
"version": "1.0.0",
"description": "Frontend Project for cds-208 at FHGR Collaborators: 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.",
"main": "index.js",
"scripts": {
"start": "node server/server.js"
},
"repository": {
"type": "git",
"url": "https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.1",
"express": "^5.2.1"
}
}

View File

@ -1,256 +0,0 @@
/**
* 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 cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app = express();
// Middleware: CORS erlaubt Anfragen vom Frontend (localhost), JSON-Body parsen
app.use(cors());
app.use(express.json());
// =========================
// ROOT Health-Check
// =========================
app.get("/", (req, res) => {
res.send("Encore API is running");
});
// =========================
// 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;
app.get("/api/events", async (req, res) => {
const city = req.query.city;
if (!city) {
return res.status(400).json({ message: "Stadt ist erforderlich" });
}
try {
const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${TM_API_KEY}&city=${encodeURIComponent(city)}`;
const response = await fetch(url);
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ message: "Fehler beim Laden der Events" });
}
});
// =========================
// 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 idCounter = 1;
/**
* Normalisiert Benutzernamen für Vergleiche.
* Dadurch werden z.B. "Anna" und " anna " als derselbe Benutzer behandelt.
*/
function normalizeUsername(username) {
return String(username || "").trim().toLowerCase();
}
/**
* Erstellt einen stabilen Event-Schlüssel für den Duplikat-Check.
* Ticketmaster liefert normalerweise eine id. Falls diese fehlt, verwenden wir URL oder Namen.
*/
function getEventKey({ eventId, eventUrl, eventName }) {
return String(eventId || eventUrl || eventName || "").trim().toLowerCase();
}
/**
* POST /api/invitation Einladung senden.
* Erwartet: Header X-Username (Sender), Body { toUser, eventId, eventName, eventUrl }
* Antwortet mit 201 Created und dem erstellten Einladungsobjekt.
*/
app.post("/api/invitation", (req, res) => {
if (!req.body) {
return res.status(400).json({ message: "Request-Body fehlt" });
}
const fromUserRaw = req.header("X-Username");
const { toUser: toUserRaw, eventId, eventName, eventUrl } = req.body;
const fromUser = String(fromUserRaw || "").trim();
const toUser = String(toUserRaw || "").trim();
const fromUserKey = normalizeUsername(fromUser);
const toUserKey = normalizeUsername(toUser);
const eventKey = getEventKey({ eventId, eventUrl, eventName });
if (!fromUser || !toUser) {
return res.status(400).json({ message: "Sender und Empfänger sind erforderlich" });
}
if (!eventKey) {
return res.status(400).json({ message: "Event fehlt. Einladung kann nicht gespeichert werden." });
}
// Prüfen ob der Empfänger registriert ist.
// Case-insensitive, damit "Anna" und "anna" nicht als verschiedene Nutzer behandelt werden.
const recipient = users.find(u => normalizeUsername(u.username) === toUserKey);
if (!recipient) {
return res.status(404).json({ message: `Nutzer "${toUser}" ist nicht registriert.` });
}
// Nicht sich selbst einladen
if (fromUserKey === toUserKey) {
return res.status(400).json({ message: "Du kannst dich nicht selbst einladen." });
}
// Doppelte Einladung verhindern:
// Derselbe Sender darf denselben Empfänger nicht mehrfach zum selben Event einladen.
// Wichtig: Der Vergleich läuft über normalisierte Keys, nicht über direkte Strings.
const duplicateInvitation = invitations.find(inv =>
inv.fromUserKey === fromUserKey &&
inv.toUserKey === toUserKey &&
inv.eventKey === eventKey
);
if (duplicateInvitation) {
return res.status(409).json({
message: `Du hast "${recipient.username}" bereits zu diesem Event eingeladen.`
});
}
const invitation = {
id: idCounter++,
fromUser,
fromUserKey,
toUser: recipient.username,
toUserKey,
eventId: eventId || null,
eventKey,
eventName,
eventUrl: eventUrl || null,
status: "pending"
};
invitations.push(invitation);
res.status(201).json(invitation);
});
/**
* 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) => {
const user = req.header("X-Username");
if (!user) {
return res.status(400).json({ message: "Benutzername fehlt" });
}
const userKey = normalizeUsername(user);
const userInvitations = invitations.filter(inv => inv.toUserKey === userKey);
res.json(userInvitations);
});
/**
* 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);
if (!inv) return res.status(404).json({ message: "Einladung nicht gefunden" });
const { status } = req.body;
if (!["accepted", "declined"].includes(status)) {
return res.status(400).json({ message: "Ungültiger Status. Erlaubt: accepted, declined" });
}
inv.status = status;
res.json(inv);
});
/**
* 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);
if (index === -1) return res.status(404).json({ message: "Einladung nicht gefunden" });
invitations.splice(index, 1);
res.sendStatus(204); // 204 No Content Ressource erfolgreich gelöscht
});
// =========================
// SERVER STARTEN
// =========================
app.listen(3000, () => {
console.log("Server läuft auf http://localhost:3000");
});