From e2fec5d3dc87d203ef47326799aa0f136377075e Mon Sep 17 00:00:00 2001 From: Kai Piekny Date: Tue, 25 Aug 2026 04:08:13 +0000 Subject: [PATCH] Initial commit --- .gitignore | 33 + app/Dockerfile | 9 + app/main.py | 1381 +++++++++++++++++++++++++++ app/static/css/style.css | 317 ++++++ app/static/style.css | 161 ++++ app/templates/concert_detail.html | 197 ++++ app/templates/index.html | 209 ++++ app/templates/new_concert.html | 815 ++++++++++++++++ app/templates/register.html | 87 ++ compose.yml | 26 + db/init/01_initial.sql | 30 + db/migrations/02_venue_external.sql | 8 + 12 files changed, 3273 insertions(+) create mode 100644 .gitignore create mode 100644 app/Dockerfile create mode 100644 app/main.py create mode 100644 app/static/css/style.css create mode 100644 app/static/style.css create mode 100644 app/templates/concert_detail.html create mode 100644 app/templates/index.html create mode 100644 app/templates/new_concert.html create mode 100644 app/templates/register.html create mode 100644 compose.yml create mode 100644 db/init/01_initial.sql create mode 100644 db/migrations/02_venue_external.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65229c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo + +# Virtual environments +.venv/ +venv/ +env/ + +# Environment / secrets +.env +.env.* +!.env.example + +# Logs +*.log + +# Backup / alte Arbeitskopien +*.backup +*.old +*.save +*.before-* + +# Hochgeladene Dateien +app/static/uploads/ + +# Lokale Laufzeitdaten +db/data/ + +# IDE +.vscode/ +.idea/ diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..6174b32 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.13-slim + +WORKDIR /app + +RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx + +COPY . . + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4162e97 --- /dev/null +++ b/app/main.py @@ -0,0 +1,1381 @@ +import os +import uuid +import secrets +import hashlib + +import httpx +import psycopg + +from datetime import datetime +from fastapi import FastAPI, Form, UploadFile, File +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles + +from jinja2 import Environment, FileSystemLoader, select_autoescape + + +app = FastAPI(title="Pingu Concerts") + +DATABASE_URL = os.environ["DATABASE_URL"] + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +UPLOAD_DIR = os.path.join( + BASE_DIR, + "static", + "uploads", + "flyers" +) + +os.makedirs( + UPLOAD_DIR, + exist_ok=True +) + + +# ============================================================ +# Templates +# ============================================================ + +templates = Environment( + loader=FileSystemLoader( + os.path.join(BASE_DIR, "templates") + ), + autoescape=select_autoescape(["html"]) +) + + +# ============================================================ +# Static files +# ============================================================ + +app.mount( + "/static", + StaticFiles( + directory=os.path.join(BASE_DIR, "static") + ), + name="static" +) + + +# ============================================================ +# Database +# ============================================================ + +def get_db_connection(): + return psycopg.connect(DATABASE_URL) + +# ============================================================ +# Token helpers +# ============================================================ + +def hash_token(token: str) -> str: + return hashlib.sha256( + token.encode("utf-8") + ).hexdigest() + + +def generate_token() -> str: + return secrets.token_urlsafe(32) + +@app.get("/admin/invites") +def create_invite(): + + token = secrets.token_urlsafe(32) + + token_hash = hash_token(token) + + with get_db_connection() as connection: + + with connection.cursor() as cursor: + + cursor.execute(""" + INSERT INTO registration_invites ( + token_hash + ) + VALUES (%s) + RETURNING id + """, ( + token_hash, + )) + + invite_id = cursor.fetchone()[0] + + connection.commit() + + return { + "invite_id": invite_id, + "invite_url": f"/register/{token}" + } + +@app.post("/register") +def register_user( + token: str = Form(...), + username: str = Form(...), + display_name: str = Form(""), + email: str = Form(...), + password: str = Form(...) +): + + username = username.strip() + display_name = display_name.strip() + email = email.strip().lower() + + if len(username) < 3: + return HTMLResponse( + "

Fehler

Der Benutzername muss mindestens 3 Zeichen lang sein.

", + status_code=400 + ) + + if len(password) < 8: + return HTMLResponse( + "

Fehler

Das Passwort muss mindestens 8 Zeichen lang sein.

", + status_code=400 + ) + + with get_db_connection() as connection: + + with connection.cursor() as cursor: + + # Einladung prüfen + cursor.execute(""" + SELECT + id, + expires_at, + used_at + FROM registration_invites + WHERE token_hash = %s + """, ( + hash_token(token), + )) + + invite = cursor.fetchone() + + if not invite: + return HTMLResponse( + "

Ungültige Einladung

", + status_code=404 + ) + + invite_id, expires_at, used_at = invite + + if used_at: + return HTMLResponse( + "

Diese Einladung wurde bereits verwendet.

", + status_code=410 + ) + + if expires_at and datetime.now() > expires_at: + return HTMLResponse( + "

Diese Einladung ist abgelaufen.

", + status_code=410 + ) + + # Prüfen ob Username bereits existiert + cursor.execute(""" + SELECT id + FROM users + WHERE LOWER(username) = LOWER(%s) + """, ( + username, + )) + + if cursor.fetchone(): + return HTMLResponse( + "

Fehler

Dieser Benutzername ist bereits vergeben.

", + status_code=400 + ) + + # Prüfen ob E-Mail bereits existiert + cursor.execute(""" + SELECT id + FROM users + WHERE LOWER(email) = LOWER(%s) + """, ( + email, + )) + + if cursor.fetchone(): + return HTMLResponse( + "

Fehler

Diese E-Mail-Adresse ist bereits registriert.

", + status_code=400 + ) + + # Passwort hashen + import bcrypt + + password_hash = bcrypt.hashpw( + password.encode("utf-8"), + bcrypt.gensalt() + ).decode("utf-8") + + # Benutzer anlegen + cursor.execute(""" + INSERT INTO users ( + username, + email, + password_hash, + display_name + ) + VALUES ( + %s, + %s, + %s, + %s + ) + RETURNING id + """, ( + username, + email, + password_hash, + display_name or username + )) + + user_id = cursor.fetchone()[0] + + # Einladung verbrauchen + cursor.execute(""" + UPDATE registration_invites + SET + used_by = %s, + used_at = CURRENT_TIMESTAMP + WHERE id = %s + """, ( + user_id, + invite_id + )) + + connection.commit() + + return HTMLResponse( + f""" +

Account erstellt 🎸

+

Willkommen bei Pingu Concerts, {display_name or username}!

+

Dein Account wurde erfolgreich erstellt.

+

+ Zu Pingu Concerts +

+ """ + ) + +# ============================================================ +# Registration +# ============================================================ + +@app.get( + "/register/{token}", + response_class=HTMLResponse +) +def register_page(token: str): + + with get_db_connection() as connection: + + with connection.cursor() as cursor: + + cursor.execute(""" + SELECT + id, + expires_at, + used_at + FROM registration_invites + WHERE token_hash = %s + """, ( + hash_token(token), + )) + + invite = cursor.fetchone() + + if not invite: + return HTMLResponse( + "

Ungültige Einladung

", + status_code=404 + ) + + invite_id, expires_at, used_at = invite + + if used_at: + return HTMLResponse( + "

Diese Einladung wurde bereits verwendet.

", + status_code=410 + ) + + if expires_at: + from datetime import datetime + + if datetime.now() > expires_at: + return HTMLResponse( + "

Diese Einladung ist abgelaufen.

", + status_code=410 + ) + + template = templates.get_template( + "register.html" + ) + + return template.render( + token=token + ) + +# ============================================================ +# Home +# ============================================================ + +@app.get("/", response_class=HTMLResponse) +def home(): + + with get_db_connection() as connection: + + with connection.cursor() as cursor: + + cursor.execute(""" + SELECT + concerts.id, + concerts.artist, + concerts.start_datetime, + venues.name, + venues.city + FROM concerts + LEFT JOIN venues + ON concerts.venue_id = venues.id + ORDER BY concerts.start_datetime + """) + + rows = cursor.fetchall() + + concerts = [] + + for row in rows: + + ( + concert_id, + artist, + start_datetime, + venue, + city + ) = row + + venue_text = venue or "Veranstaltungsort unbekannt" + + if city: + venue_text += f", {city}" + + concerts.append({ + "id": concert_id, + "artist": artist, + "date": start_datetime.strftime("%d.%m.%Y"), + "time": start_datetime.strftime("%H:%M"), + "venue": venue_text + }) + + template = templates.get_template( + "index.html" + ) + + return template.render( + concerts=concerts + ) + + +# ============================================================ +# New concert +# ============================================================ + +@app.get( + "/concerts/new", + response_class=HTMLResponse +) +def new_concert(): + + template = templates.get_template( + "new_concert.html" + ) + + return template.render() + + +# ============================================================ +# Concert detail +# ============================================================ + +@app.get( + "/concerts/{concert_id}", + response_class=HTMLResponse +) +def concert_detail(concert_id: int): + + with get_db_connection() as connection: + + with connection.cursor() as cursor: + + cursor.execute(""" + SELECT + concerts.id, + concerts.artist, + concerts.start_datetime, + concerts.end_datetime, + concerts.description, + concerts.ticket_url, + concerts.ticket_price, + concerts.flyer_path, + + venues.name, + venues.street, + venues.postal_code, + venues.city, + venues.country + + FROM concerts + + LEFT JOIN venues + ON concerts.venue_id = venues.id + + WHERE concerts.id = %s + """, ( + concert_id, + )) + + row = cursor.fetchone() + + + if not row: + + return HTMLResponse( + "

Konzert nicht gefunden

", + status_code=404 + ) + + + ( + concert_id, + artist, + start_datetime, + end_datetime, + description, + ticket_url, + ticket_price, + flyer_path, + + venue_name, + venue_street, + venue_postal_code, + venue_city, + venue_country + ) = row + + + concert = { + + "id": concert_id, + + "artist": artist, + + "date": + start_datetime.strftime( + "%d.%m.%Y" + ), + + "time": + start_datetime.strftime( + "%H:%M" + ), + + "end_date": + end_datetime.strftime( + "%d.%m.%Y" + ) + if end_datetime + else None, + + "end_time": + end_datetime.strftime( + "%H:%M" + ) + if end_datetime + else None, + + "description": + description, + + "ticket_url": + ticket_url, + + "ticket_price": + ticket_price, + + "flyer_path": + flyer_path, + + "venue": { + + "name": + venue_name + or "Veranstaltungsort unbekannt", + + "street": + venue_street, + + "postal_code": + venue_postal_code, + + "city": + venue_city, + + "country": + venue_country + + } + + } + + + template = templates.get_template( + "concert_detail.html" + ) + + return template.render( + concert=concert + ) + + +# ============================================================ +# Create concert +# ============================================================ + +@app.post("/concerts") +async def create_concert( + + artist: str = Form(...), + + venue_id: str = Form(""), + + venue_name: str = Form(""), + + city: str = Form(""), + + street: str = Form(""), + + postal_code: str = Form(""), + + country: str = Form( + "Deutschland" + ), + + latitude: str = Form(""), + + longitude: str = Form(""), + + start_datetime: str = Form(...), + + end_datetime: str = Form(""), + + description: str = Form(""), + + ticket_url: str = Form(""), + + ticket_price: str = Form(""), + + flyer: UploadFile | None = File(None) + +): + + flyer_path = None + + + # ======================================================== + # Flyer speichern + # ======================================================== + + if flyer and flyer.filename: + + allowed_extensions = { + ".jpg", + ".jpeg", + ".png", + ".webp" + } + + original_name = flyer.filename + + extension = os.path.splitext( + original_name + )[1].lower() + + + if extension not in allowed_extensions: + + return HTMLResponse( + "Ungültiges Flyer-Format. " + "Erlaubt sind JPG, JPEG, PNG und WEBP.", + status_code=400 + ) + + + filename = ( + str(uuid.uuid4()) + + extension + ) + + + destination = os.path.join( + UPLOAD_DIR, + filename + ) + + + contents = await flyer.read() + + + # 10 MB Limit + if len(contents) > 10 * 1024 * 1024: + + return HTMLResponse( + "Der Flyer darf maximal 10 MB groß sein.", + status_code=400 + ) + + + with open( + destination, + "wb" + ) as file: + + file.write(contents) + + + flyer_path = ( + "/static/uploads/flyers/" + + filename + ) + + + # ======================================================== + # Datenbank + # ======================================================== + + with get_db_connection() as connection: + + with connection.cursor() as cursor: + + selected_venue_id = None + + + # ================================================== + # Venue auswählen + # ================================================== + + if venue_id: + + if venue_id.startswith( + "nominatim:" + ): + + external_id = venue_id.split( + ":", + 1 + )[1] + + + cursor.execute(""" + SELECT id + FROM venues + WHERE + external_id = %s + AND source = 'nominatim' + LIMIT 1 + """, ( + external_id, + )) + + + existing_venue = ( + cursor.fetchone() + ) + + + if existing_venue: + + selected_venue_id = ( + existing_venue[0] + ) + + else: + + cursor.execute(""" + INSERT INTO venues ( + name, + street, + postal_code, + city, + country, + latitude, + longitude, + external_id, + source + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s + ) + RETURNING id + """, ( + + venue_name, + + street or None, + + postal_code or None, + + city or None, + + country + or "Deutschland", + + float(latitude) + if latitude + else None, + + float(longitude) + if longitude + else None, + + external_id, + + "nominatim" + + )) + + + selected_venue_id = ( + cursor.fetchone()[0] + ) + + + else: + + try: + + selected_venue_id = int( + venue_id + ) + + except ValueError: + + selected_venue_id = None + + + # ================================================== + # Manueller Venue-Fallback + # ================================================== + + if ( + not selected_venue_id + and venue_name + ): + + cursor.execute(""" + SELECT id + FROM venues + WHERE + LOWER(name) + = LOWER(%s) + AND LOWER( + COALESCE(city, '') + ) + = LOWER(%s) + LIMIT 1 + """, ( + venue_name, + city + )) + + + existing_venue = ( + cursor.fetchone() + ) + + + if existing_venue: + + selected_venue_id = ( + existing_venue[0] + ) + + else: + + cursor.execute(""" + INSERT INTO venues ( + name, + street, + postal_code, + city, + country, + latitude, + longitude + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + %s, + %s + ) + RETURNING id + """, ( + + venue_name, + + street or None, + + postal_code or None, + + city or None, + + country + or "Deutschland", + + float(latitude) + if latitude + else None, + + float(longitude) + if longitude + else None + + )) + + + selected_venue_id = ( + cursor.fetchone()[0] + ) + + + # ================================================== + # Konzert speichern + # ================================================== + + cursor.execute(""" + INSERT INTO concerts ( + artist, + venue_id, + start_datetime, + end_datetime, + description, + ticket_url, + ticket_price, + flyer_path + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s + ) + RETURNING id + """, ( + + artist, + + selected_venue_id, + + start_datetime, + + end_datetime + or None, + + description + or None, + + ticket_url + or None, + + ticket_price + or None, + + flyer_path + + )) + + + concert_id = ( + cursor.fetchone()[0] + ) + + + connection.commit() + + + return RedirectResponse( + f"/concerts/{concert_id}", + status_code=303 + ) + + +# ============================================================ +# Venue search +# ============================================================ + +@app.get("/api/venues/search") +def search_venues(q: str): + + q = q.strip() + + if len(q) < 2: + return [] + + results = [] + + + # ======================================================== + # Local database + # ======================================================== + + with get_db_connection() as connection: + + with connection.cursor() as cursor: + + cursor.execute(""" + SELECT + id, + name, + street, + postal_code, + city, + country, + latitude, + longitude, + external_id, + source + FROM venues + WHERE + name ILIKE %s + OR city ILIKE %s + OR street ILIKE %s + ORDER BY + CASE + WHEN LOWER(name) = LOWER(%s) + THEN 0 + WHEN LOWER(name) LIKE LOWER(%s) + THEN 1 + WHEN LOWER(name) LIKE LOWER(%s) + THEN 2 + ELSE 3 + END, + name + LIMIT 10 + """, ( + + f"%{q}%", + + f"%{q}%", + + f"%{q}%", + + q, + + f"{q}%", + + f"%{q}%" + + )) + + rows = cursor.fetchall() + + + for row in rows: + + ( + venue_id, + name, + street, + postal_code, + city, + country, + latitude, + longitude, + external_id, + source + ) = row + + + results.append({ + + "id": venue_id, + + "name": name, + + "street": street, + + "postal_code": postal_code, + + "city": city, + + "country": country, + + "latitude": latitude, + + "longitude": longitude, + + "external_id": external_id, + + "source": source, + + "local": True + + }) + + + # ======================================================== + # Nominatim + # ======================================================== + + if not results: + + headers = { + "User-Agent": "PinguConcerts/1.0" + } + + params = { + + "q": q, + + "format": "jsonv2", + + "addressdetails": 1, + + "limit": 20, + + "countrycodes": "de" + + } + + + try: + + response = httpx.get( + + "https://nominatim.openstreetmap.org/search", + + params=params, + + headers=headers, + + timeout=8 + + ) + + response.raise_for_status() + + data = response.json() + + candidates = [] + + + venue_types = { + + "music_venue", + "concert_hall", + "stadium", + "sports_centre", + "theatre", + "arts_centre", + "exhibition_hall", + "conference_centre", + "events_venue", + "nightclub", + "community_centre", + "social_centre", + "festival", + "arena", + "auditorium", + "dance", + "cinema" + + } + + + venue_keywords = [ + + "halle", + "arena", + "stadion", + "stadium", + "club", + "klub", + "theater", + "theatre", + "bühne", + "buehne", + "concert", + "konzert", + "music", + "musik", + "festival", + "event", + "venue", + "zentrum", + "center", + "centre", + "matrix", + "turbinenhalle", + "westfalenhalle" + + ] + + + excluded_types = { + + "street", + "road", + "residential", + "postcode", + "house", + "railway", + "bus_stop", + "station", + "person" + + } + + + for item in data: + + address = item.get( + "address", + {} + ) + + + name = ( + item.get("name") + or "" + ).strip() + + + display_name = ( + item.get("display_name") + or "" + ) + + + if not name: + + name = display_name.split( + "," + )[0].strip() + + + if not name: + continue + + + osm_type = ( + item.get("type") + or "" + ).lower() + + + osm_class = ( + item.get("class") + or "" + ).lower() + + + if osm_type in excluded_types: + continue + + + name_lower = name.lower() + + query_lower = q.lower() + + display_lower = display_name.lower() + + + score = 0 + + + if name_lower == query_lower: + + score += 120 + + elif name_lower.startswith( + query_lower + ): + + score += 100 + + elif query_lower in name_lower: + + score += 80 + + elif query_lower in display_lower: + + score += 40 + + + if osm_type in venue_types: + + score += 70 + + + for keyword in venue_keywords: + + if keyword in name_lower: + + score += 40 + + break + + + if osm_class in { + + "amenity", + "leisure", + "tourism" + + }: + + score += 20 + + + if osm_type in { + + "street", + "road", + "residential", + "person", + "postcode", + "house" + + }: + + score -= 200 + + + if score < 50: + continue + + + candidates.append({ + + "id": None, + + "name": name, + + "street": + address.get("road") + or address.get("pedestrian") + or address.get("footway"), + + "postal_code": + address.get("postcode"), + + "city": + address.get("city") + or address.get("town") + or address.get("village") + or address.get("municipality"), + + "country": + address.get( + "country", + "Deutschland" + ), + + "latitude": + float(item["lat"]) + if item.get("lat") + else None, + + "longitude": + float(item["lon"]) + if item.get("lon") + else None, + + "external_id": + item.get("osm_id"), + + "source": + "nominatim", + + "local": + False, + + "_score": + score + + }) + + + unique = {} + + + for candidate in candidates: + + key = ( + + candidate["external_id"], + + candidate["name"], + + candidate["city"] + + ) + + + if key not in unique: + + unique[key] = candidate + + elif ( + candidate["_score"] + > + unique[key]["_score"] + ): + + unique[key] = candidate + + + sorted_candidates = sorted( + + unique.values(), + + key=lambda item: + item["_score"], + + reverse=True + + ) + + + for candidate in sorted_candidates[:10]: + + candidate.pop( + "_score", + None + ) + + results.append(candidate) + + + except Exception as error: + + print( + f"Nominatim search failed: {error}" + ) + + + return results diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..33cbfc3 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,317 @@ +:root { + --bg: #0b0f19; + --surface: #151b29; + --surface-hover: #1b2333; + --border: #293449; + --text: #f3f4f6; + --muted: #9ca3af; + --accent: #8b5cf6; + --accent-hover: #7c3aed; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + + background: var(--bg); + color: var(--text); +} + +a { + color: inherit; + text-decoration: none; +} + +header { + background: rgba(21, 27, 41, 0.95); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; + backdrop-filter: blur(10px); +} + +.header-inner { + max-width: 1100px; + margin: 0 auto; + padding: 18px 20px; + + display: flex; + align-items: center; + justify-content: space-between; +} + +.logo { + font-size: 1.4rem; + font-weight: 800; +} + +.logo span { + color: var(--accent); +} + +main { + max-width: 1100px; + margin: 0 auto; + padding: 30px 20px 100px; +} + +.page-title { + margin-bottom: 25px; +} + +.page-title h1 { + margin: 0; + font-size: 2rem; +} + +.page-title p { + margin: 6px 0 0; + color: var(--muted); +} + +.concert-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +} + +.concert-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + overflow: hidden; + + transition: + transform 0.15s ease, + background 0.15s ease, + border-color 0.15s ease; +} + +.concert-card:hover { + transform: translateY(-3px); + background: var(--surface-hover); + border-color: #3b4860; +} + +.concert-image { + width: 100%; + aspect-ratio: 16 / 9; + background: #0f1420; + + display: flex; + align-items: center; + justify-content: center; + + font-size: 3rem; +} + +.concert-content { + padding: 20px; +} + +.concert-artist { + margin: 0 0 12px; + font-size: 1.35rem; +} + +.concert-info { + display: flex; + flex-direction: column; + gap: 7px; + + color: var(--muted); + font-size: 0.95rem; +} + +.empty { + text-align: center; + padding: 70px 20px; + + background: var(--surface); + border: 1px solid var(--border); + border-radius: 18px; +} + +.empty-icon { + font-size: 4rem; + margin-bottom: 15px; +} + +.empty h2 { + margin: 0 0 8px; +} + +.empty p { + color: var(--muted); + margin: 0; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + + padding: 11px 17px; + border-radius: 10px; + + background: var(--accent); + color: white; + + font-weight: 600; + border: 0; + cursor: pointer; + + transition: background 0.15s ease; +} + +.button:hover { + background: var(--accent-hover); +} + +.button-secondary { + background: var(--surface); + border: 1px solid var(--border); +} + +.button-secondary:hover { + background: var(--surface-hover); +} + +@media (max-width: 600px) { + .header-inner { + padding: 15px; + } + + main { + padding: 22px 15px 80px; + } + + .page-title h1 { + font-size: 1.6rem; + } + + .concert-grid { + grid-template-columns: 1fr; + } +} +form { + max-width: 650px; + margin: 0 auto; + text-align: left; +} + +form p { + margin: 0 0 18px; +} + +label { + display: block; + color: var(--muted); + font-size: 0.9rem; + font-weight: 600; +} + +input, +textarea { + width: 100%; + margin-top: 7px; + padding: 12px 14px; + + background: #0f1420; + color: var(--text); + + border: 1px solid var(--border); + border-radius: 9px; + + font: inherit; +} + +input:focus, +textarea:focus { + outline: none; + border-color: var(--accent); +} + +textarea { + resize: vertical; +} + +form .button { + margin-right: 8px; + margin-top: 5px; +} +.venue-search { + position: relative; + max-width: 650px; + margin: 0 auto 18px; + text-align: left; +} + +.venue-search label { + display: block; +} + +.venue-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + + margin-top: 5px; + overflow: hidden; + + z-index: 50; +} + +.venue-result { + width: 100%; + display: flex; + flex-direction: column; + gap: 3px; + + padding: 12px 14px; + + background: transparent; + color: var(--text); + + border: 0; + border-bottom: 1px solid var(--border); + + text-align: left; + cursor: pointer; + + font: inherit; +} + +.venue-result:last-child { + border-bottom: 0; +} + +.venue-result:hover { + background: var(--surface-hover); +} + +.venue-result strong { + font-size: 0.95rem; +} + +.venue-result span { + color: var(--muted); + font-size: 0.85rem; +} diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..ede13f0 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,161 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #0d1117; + color: #e6edf3; + font-family: Arial, Helvetica, sans-serif; +} + +.container { + width: min(1100px, 92%); + margin: 0 auto; + padding: 30px 0 60px; +} + +a { + color: inherit; +} + +.back-link { + display: inline-block; + margin-bottom: 25px; + color: #9da7b3; + text-decoration: none; +} + +.back-link:hover { + color: #ffffff; +} + +.concert-detail { + background: #161b22; + border: 1px solid #30363d; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} + +.flyer-container { + width: 100%; + background: #0a0d12; + display: flex; + justify-content: center; + padding: 25px; +} + +.concert-flyer { + display: block; + max-width: 100%; + max-height: 700px; + object-fit: contain; + border-radius: 10px; +} + +.concert-content { + padding: 35px; +} + +.concert-content h1 { + margin-top: 0; + margin-bottom: 30px; + font-size: clamp(2rem, 5vw, 3.5rem); + line-height: 1.1; +} + +.concert-info { + display: grid; + grid-template-columns: repeat( + auto-fit, + minmax(220px, 1fr) + ); + gap: 15px; +} + +.info-item { + display: flex; + gap: 14px; + align-items: flex-start; + padding: 18px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 12px; +} + +.info-icon { + font-size: 1.5rem; +} + +.info-item strong { + color: #ffffff; +} + +.description { + margin-top: 35px; + padding-top: 30px; + border-top: 1px solid #30363d; +} + +.description h2, +.coming-soon h2 { + margin-top: 0; +} + +.description p { + color: #b8c1cc; + line-height: 1.7; + white-space: pre-wrap; +} + +.ticket-button-container { + margin-top: 30px; +} + +.ticket-button { + display: inline-block; + padding: 13px 22px; + background: #238636; + color: white; + text-decoration: none; + border-radius: 10px; + font-weight: bold; +} + +.ticket-button:hover { + background: #2ea043; +} + +.coming-soon { + margin-top: 35px; + padding: 25px; + background: #0d1117; + border: 1px dashed #30363d; + border-radius: 12px; +} + +.coming-soon p { + color: #8b949e; +} + +@media (max-width: 600px) { + + .container { + width: 94%; + padding-top: 20px; + } + + .concert-content { + padding: 22px; + } + + .flyer-container { + padding: 12px; + } + + .concert-flyer { + max-height: 600px; + } + +} diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html new file mode 100644 index 0000000..c0deedf --- /dev/null +++ b/app/templates/concert_detail.html @@ -0,0 +1,197 @@ + + + + + + + + + + {{ concert.artist }} | Pingu Concerts + + + + + + + +
+ + + ← Zurück zum Kalender + + + +
+ + {% if concert.flyer_path %} + +
+ + Flyer {{ concert.artist }} + +
+ + {% endif %} + + +
+ +

+ {{ concert.artist }} +

+ + +
+ +
+ + + 📅 + + +
+ + + {{ concert.date }} + + +
+ + {{ concert.time }} Uhr + +
+ +
+ + +
+ + + 📍 + + +
+ + + {{ concert.venue.name }} + + + {% if concert.venue.street %} + +
+ + {{ concert.venue.street }} + + {% endif %} + + + {% if concert.venue.postal_code + or concert.venue.city %} + +
+ + {{ concert.venue.postal_code }} + {{ concert.venue.city }} + + {% endif %} + +
+ +
+ + + {% if concert.ticket_price %} + +
+ + + 🎟️ + + +
+ + + Ticket + + +
+ + {{ concert.ticket_price }} € + +
+ +
+ + {% endif %} + +
+ + + {% if concert.description %} + +
+ +

+ Über das Konzert +

+ +

+ {{ concert.description }} +

+ +
+ + {% endif %} + + + {% if concert.ticket_url %} + + + + {% endif %} + + +
+ +

+ 🐧 Community +

+ +

+ Wer geht hin, wer ist interessiert? + Kommentare und Teilnehmer folgen hier. +

+ +
+ +
+ +
+ +
+ + + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..70b3223 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,209 @@ + + + + + + + + + + Pingu Concerts + + + + + + + + + + +
+ + + + + {% if concerts %} + + + + {% else %} + +
+ +

+ Noch keine Konzerte 🎸 +

+ +

+ Leg das erste Konzert an! +

+ +
+ + {% endif %} + +
+ + + + diff --git a/app/templates/new_concert.html b/app/templates/new_concert.html new file mode 100644 index 0000000..227dd61 --- /dev/null +++ b/app/templates/new_concert.html @@ -0,0 +1,815 @@ + + + + + + + + + + Konzert hinzufügen · Pingu Concerts + + + + + + + + + + +
+ + + +
+ + +
+ +
+ +

🎤 Konzert hinzufügen

+ +

+ Trag ein, worauf wir uns freuen. +

+ +
+ + +
+ +
+ + + + + +

+ + + +

+ + + + + + + + + + + + + +

+ + + +

+ + + + + + +

+ + + +

+ + + + + + +

+ + + +

+ + + + + + +

+ + + +

+ + + + + + +
+ + + + + + + +
+ + JPG, PNG oder WEBP · maximal 10 MB + +
+ + +
+ + Flyer Vorschau + +
+ +
+ +
+ + + + + + +
+ + + + + + Abbrechen + + +
+ +
+ +
+ +
+ + + + + + + diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..47274cf --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,87 @@ + + + + + + + Pingu Concerts - Registrierung + + + + + + +

🎸 Pingu Concerts

+ +

Einladung angenommen

+ +

+ Erstelle deinen Account für Pingu Concerts. +

+ +
+ + + + + + + + + + + + + + + + + +
+ + + diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..7ca1e94 --- /dev/null +++ b/compose.yml @@ -0,0 +1,26 @@ +services: + db: + image: postgres:17 + container_name: pingu-concerts-db + restart: unless-stopped + environment: + POSTGRES_DB: concerts + POSTGRES_USER: concerts + POSTGRES_PASSWORD: change-me-later + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d:ro + + web: + build: ./app + container_name: pingu-concerts-web + restart: unless-stopped + ports: + - "8080:8000" + environment: + DATABASE_URL: postgresql://concerts:change-me-later@db:5432/concerts + depends_on: + - db + +volumes: + postgres_data: diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql new file mode 100644 index 0000000..6190f98 --- /dev/null +++ b/db/init/01_initial.sql @@ -0,0 +1,30 @@ +CREATE TABLE venues ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + street VARCHAR(255), + postal_code VARCHAR(20), + city VARCHAR(100), + country VARCHAR(100) DEFAULT 'Deutschland', + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE concerts ( + id SERIAL PRIMARY KEY, + artist VARCHAR(255) NOT NULL, + venue_id INTEGER REFERENCES venues(id) ON DELETE SET NULL, + start_datetime TIMESTAMP NOT NULL, + end_datetime TIMESTAMP, + description TEXT, + ticket_url TEXT, + ticket_price NUMERIC(10,2), + flyer_path TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_concerts_start_datetime + ON concerts(start_datetime); + +CREATE INDEX idx_venues_name + ON venues(name); diff --git a/db/migrations/02_venue_external.sql b/db/migrations/02_venue_external.sql new file mode 100644 index 0000000..3a981e4 --- /dev/null +++ b/db/migrations/02_venue_external.sql @@ -0,0 +1,8 @@ +ALTER TABLE venues +ADD COLUMN IF NOT EXISTS external_id VARCHAR(255); + +ALTER TABLE venues +ADD COLUMN IF NOT EXISTS source VARCHAR(50); + +CREATE INDEX IF NOT EXISTS idx_venues_external_id + ON venues(external_id);