Rebrand application as MetalCircle

This commit is contained in:
kai
2026-08-27 18:20:40 +02:00
parent eb71ea5a0e
commit 94e14ad3e8
83 changed files with 2561 additions and 51 deletions
+189 -8
View File
@@ -21,7 +21,8 @@ except ImportError:
Image = ImageOps = UnidentifiedImageError = None
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
@@ -77,8 +78,8 @@ INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD")
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL")
BADGE_DEFINITIONS = (
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und Pingu Concerts mit aufgebaut", "special"),
("admin", "Admin", "🏴‍☠️", None, "Verantwortung für Pingu Concerts", "special"),
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und MetalCircle mit aufgebaut", "special"),
("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "special"),
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "attendance"),
@@ -405,7 +406,7 @@ async def lifespan(_app: FastAPI):
yield
app = FastAPI(title="Pingu Concerts", lifespan=lifespan)
app = FastAPI(title="MetalCircle", lifespan=lifespan)
@app.middleware("http")
@@ -458,6 +459,8 @@ async def require_login(request: Request, call_next):
or request.url.path == "/register"
or request.url.path.startswith("/register/")
or request.url.path.startswith("/password-reset")
or request.url.path in {"/impressum", "/datenschutz"}
or request.url.path == "/profile/export"
or request.url.path.startswith("/static/")
):
return await call_next(request)
@@ -2037,6 +2040,18 @@ def register_page(token: str):
# ============================================================
# Rechtliche Hinweise
@app.get("/impressum", response_class=HTMLResponse)
def impressum_page():
return templates.get_template("impressum.html").render()
@app.get("/datenschutz", response_class=HTMLResponse)
def privacy_page():
return templates.get_template("datenschutz.html").render()
# Login
# ============================================================
@@ -2280,6 +2295,134 @@ def own_profile(request: Request, saved: str = ""):
)
@app.get("/profile/export")
def export_profile_data(request: Request):
"""Download the authenticated user's application data as JSON."""
user = get_current_user(request)
if not user:
return login_redirect("/profile/export")
user_id = user["id"]
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, username, email, display_name, avatar_path,
instagram_url, profile_visibility, is_admin, created_at
FROM users WHERE id = %s
""",
(user_id,),
)
account = cursor.fetchone()
cursor.execute(
"""
SELECT c.id, c.artist, c.event_type, c.start_datetime, c.end_datetime,
c.venue_id, c.description, c.ticket_url, c.ticket_price,
c.flyer_path, c.flyer_url, c.visibility, c.created_at
FROM concerts c WHERE c.created_by = %s ORDER BY c.start_datetime
""",
(user_id,),
)
created_events = cursor.fetchall()
cursor.execute(
"""
SELECT concert_id, status, updated_at
FROM concert_attendance WHERE user_id = %s ORDER BY updated_at
""",
(user_id,),
)
attendance = cursor.fetchall()
cursor.execute(
"""
SELECT id, requester_id, addressee_id, status, created_at, updated_at
FROM friendships WHERE requester_id = %s OR addressee_id = %s
ORDER BY created_at
""",
(user_id, user_id),
)
friendships = cursor.fetchall()
cursor.execute(
"""
SELECT id, sender_id, recipient_id, body, read_at, created_at
FROM direct_messages WHERE sender_id = %s OR recipient_id = %s
ORDER BY created_at
""",
(user_id, user_id),
)
messages = cursor.fetchall()
cursor.execute(
"""
SELECT concert_id, invited_by, viewed_at, created_at
FROM event_invitations WHERE user_id = %s ORDER BY created_at
""",
(user_id,),
)
invitations = cursor.fetchall()
cursor.execute(
"""
SELECT id, concert_id, body, created_at
FROM concert_comments WHERE user_id = %s ORDER BY created_at
""",
(user_id,),
)
comments = cursor.fetchall()
cursor.execute(
"""
SELECT id, concert_id, path, created_at
FROM concert_photos WHERE user_id = %s ORDER BY created_at
""",
(user_id,),
)
photos = cursor.fetchall()
cursor.execute(
"""
SELECT badge_code, awarded_at FROM user_badges
WHERE user_id = %s ORDER BY awarded_at
""",
(user_id,),
)
badges = cursor.fetchall()
def rows_to_dicts(rows, keys):
return [dict(zip(keys, row)) for row in rows]
data = {
"export_version": 1,
"exported_at": datetime.now(),
"account": dict(zip(
("id", "username", "email", "display_name", "avatar_path",
"instagram_url", "profile_visibility", "is_admin", "created_at"),
account,
)) if account else None,
"created_events": rows_to_dicts(
created_events,
("id", "artist", "event_type", "start_datetime", "end_datetime", "venue_id",
"description", "ticket_url", "ticket_price", "flyer_path", "flyer_url",
"visibility", "created_at"),
),
"attendance": rows_to_dicts(attendance, ("concert_id", "status", "updated_at")),
"friendships": rows_to_dicts(friendships, ("id", "requester_id", "addressee_id", "status", "created_at", "updated_at")),
"messages": rows_to_dicts(messages, ("id", "sender_id", "recipient_id", "body", "read_at", "created_at")),
"event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")),
"comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")),
"photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")),
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at")),
}
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
return JSONResponse(
content=jsonable_encoder(data),
headers={"Content-Disposition": f'attachment; filename="pingu-concerts-{filename}-daten.json"'},
)
@app.get("/users/{username}", response_class=HTMLResponse)
def user_profile(request: Request, username: str):
return render_profile(request, username)
@@ -2375,7 +2518,7 @@ def send_friend_request(request: Request, username: str):
@app.post("/friendships/{friendship_id}/{action}")
def manage_friendship(request: Request, friendship_id: int, action: str):
def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")):
user = get_current_user(request)
if action not in {"accept", "decline", "remove"}:
return HTMLResponse("Ungültige Aktion.", status_code=400)
@@ -2405,6 +2548,8 @@ def manage_friendship(request: Request, friendship_id: int, action: str):
with connection.cursor() as cursor:
cursor.execute("SELECT username FROM users WHERE id = %s", (other_id,))
other = cursor.fetchone()
if return_to == "/messages":
return RedirectResponse("/messages", status_code=303)
return RedirectResponse(f"/users/{other[0]}" if other else "/", status_code=303)
@@ -2440,8 +2585,41 @@ def load_chat_partner(cursor, user, username: str):
def message_inbox(request: Request):
user = get_current_user(request)
conversations = []
friend_requests = []
event_invitations = []
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT f.id, u.username, COALESCE(u.display_name, u.username), f.created_at
FROM friendships f JOIN users u ON u.id = f.requester_id
WHERE f.addressee_id = %s AND f.status = 'pending'
ORDER BY f.created_at DESC
""",
(user["id"],),
)
friend_requests = [
{"id": row[0], "username": row[1], "display_name": row[2],
"created_at": row[3].strftime("%d.%m.%Y %H:%M")}
for row in cursor.fetchall()
]
cursor.execute(
"""
SELECT ei.concert_id, c.artist, c.start_datetime,
COALESCE(u.display_name, u.username)
FROM event_invitations ei
JOIN concerts c ON c.id = ei.concert_id
LEFT JOIN users u ON u.id = ei.invited_by
WHERE ei.user_id = %s AND ei.viewed_at IS NULL
ORDER BY ei.created_at DESC
""",
(user["id"],),
)
event_invitations = [
{"concert_id": row[0], "artist": row[1],
"date": row[2].strftime("%d.%m.%Y %H:%M"), "invited_by": row[3] or "Ein Mitglied"}
for row in cursor.fetchall()
]
cursor.execute(
"""
SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path
@@ -2475,11 +2653,14 @@ def message_inbox(request: Request):
"id": row[0], "username": row[1], "display_name": row[2],
"avatar_path": row[3], "last_message": latest[0] if latest else None,
"last_at": latest[1].strftime("%d.%m.%Y %H:%M") if latest else None,
"last_at_raw": latest[1] if latest else None,
"last_from_me": bool(latest and latest[2] == user["id"]),
"unread_count": latest[3] if latest else 0,
})
conversations.sort(key=lambda item: (item["unread_count"] > 0, item["last_at_raw"] or datetime.min), reverse=True)
template = templates.get_template("messages.html")
return template.render(user=user, conversations=conversations, partner=None, messages=[])
return template.render(user=user, conversations=conversations, friend_requests=friend_requests,
event_invitations=event_invitations, partner=None, messages=[])
@app.get("/messages/{username}", response_class=HTMLResponse)
@@ -3375,7 +3556,7 @@ def legacy_search_venues(q: str):
if not results:
headers = {
"User-Agent": "PinguConcerts/1.0"
"User-Agent": "MetalCircle/1.0"
}
external_query = q
@@ -3811,7 +3992,7 @@ def search_venues(q: str):
"namedetails": 1,
"limit": 25,
},
headers={"User-Agent": "PinguConcerts/1.0"},
headers={"User-Agent": "MetalCircle/1.0"},
timeout=8,
)
response.raise_for_status()
+20 -9
View File
@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Metal+Mania&family=Roboto+Condensed:wght@400;500;700&display=swap');
:root {
--bg: #050505;
--surface: #101010;
@@ -59,11 +57,16 @@ header {
}
.logo {
font-size: 1.4rem;
font-weight: 800;
font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
letter-spacing: .06em;
text-transform: uppercase;
display: inline-flex;
align-items: center;
}
.brand-logo {
display: block;
width: min(300px, 48vw);
height: auto;
max-height: 74px;
object-fit: contain;
}
.logo span {
@@ -378,6 +381,14 @@ form .button {
.admin-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.admin-danger { padding: 9px 12px; color: #fecaca; border: 1px solid #7f1d1d; background: #450a0a; border-radius: 8px; cursor: pointer; }
.admin-danger:disabled { opacity: .4; cursor: not-allowed; }
.data-export { margin: 18px 0 0; text-align: right; font-size: .8rem; }
.data-export a { color: var(--muted); text-decoration: underline; }
.data-export a:hover { color: var(--text); }
.legal-copy { max-width: 820px; margin-left: auto; margin-right: auto; line-height: 1.65; }
.legal-copy h2 { margin-top: 28px; color: #fca5a5; }
.legal-copy h2:first-child { margin-top: 0; }
.legal-copy a { color: #f87171; text-decoration: underline; }
.legal-note { margin-top: 28px; padding: 12px 14px; color: var(--muted); background: rgba(5,5,5,.45); border-left: 3px solid var(--accent); font-size: .9rem; }
/* Heavy-Metal-Theme: Poster, Bühne und Backstage-Pass statt Standard-UI */
body::after {
@@ -397,12 +408,12 @@ body::after {
header, main, .container { position: relative; z-index: 1; }
h1, h2, h3, .logo, .page-title h1 {
font-family: "Metal Mania", Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
font-family: "Nimbus Sans Narrow", Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
letter-spacing: .055em;
}
.button, button {
font-family: "Roboto Condensed", "Nimbus Sans Narrow", system-ui, sans-serif;
font-family: "Nimbus Sans Narrow", system-ui, sans-serif;
letter-spacing: .06em;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+3 -2
View File
@@ -2,9 +2,10 @@
<summary>☠ {{ user.display_name }}{% if user.notification_count %} <span class="metal-notification" title="{{ user.notification_count }} neue Nachricht(en) oder Anfrage(n)" aria-label="Neue Benachrichtigungen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</summary>
<div class="user-menu-panel">
<a href="/profile">Mein Profil</a>
<a href="/messages">Nachrichten{% if user.unread_message_count %} <span class="menu-count">{{ user.unread_message_count }}</span>{% endif %}</a>
{% if user.event_invitation_count %}<a href="/#event-invitations">Veranstaltungseinladungen <span class="menu-count">{{ user.event_invitation_count }}</span></a>{% endif %}
<a href="/messages">Nachrichten{% if user.notification_count %} <span class="metal-notification" title="Neue Nachrichten oder Anfragen" aria-label="Neue Nachrichten oder Anfragen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</a>
{% if user.is_admin %}<a href="/admin">⚙️ Verwaltung</a>{% endif %}
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
<form method="post" action="/logout">
<button type="submit">Abmelden</button>
</form>
+2 -2
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Account-Link · Pingu Concerts</title><link rel="stylesheet" href="/static/css/style.css"></head><body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>{% include '_user_menu.html' %}</div></header>
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Account-Link · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head><body>
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
<main><div class="page-title"><h1>Passwort zurücksetzen</h1><p>Reset-Link für @{{ target_username }} · {{ target_email }}</p></div>
<section class="admin-section"><p>Diesen persönlichen Link sicher an den User senden. Er ist zwei Stunden gültig und nur einmal verwendbar.</p><label class="invite-link" style="display:block;overflow-wrap:anywhere">{{ account_url }}</label><p><a class="button button-secondary" href="/admin/users">Zurück</a></p></section></main></body></html>
+3 -2
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Patch-Bilder · Pingu Concerts</title>
<title>Patch-Bilder · MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.patch-admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
@@ -15,7 +16,7 @@
</style>
</head>
<body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>{% include '_user_menu.html' %}</div></header>
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
<main>
<div class="page-title"><h1>🧵 Patch-Bilder</h1><p>Grafiken für die Patches auf der virtuellen Kutte verwalten.</p></div>
{% set admin_section = 'patches' %}{% include '_admin_nav.html' %}
+3 -2
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benutzerverwaltung · Pingu Concerts</title>
<title>Benutzerverwaltung · MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.invite-link { display: block; margin-top: 14px; padding: 12px; overflow-wrap: anywhere; background: #0f1420; border: 1px solid var(--border); border-radius: 9px; color: #c4b5fd; }
@@ -17,7 +18,7 @@
</style>
</head>
<body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>{% include '_user_menu.html' %}</div></header>
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
<main>
<div class="page-title"><h1>⚙️ Verwaltung</h1><p>Benutzerkonten und Einladungen verwalten.</p></div>
{% set admin_section = 'users' %}{% include '_admin_nav.html' %}
+3 -2
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Veranstaltungsorte · Pingu Concerts</title>
<title>Veranstaltungsorte · MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.venue-fields { display: grid; grid-template-columns: 2fr 2fr .8fr 1.4fr 1.2fr; gap: 8px; }
@@ -29,7 +30,7 @@
</style>
</head>
<body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>{% include '_user_menu.html' %}</div></header>
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
<main>
<div class="page-title"><h1>📍 Veranstaltungsorte</h1><p>Orte korrigieren, bestätigen, zusammenführen oder entfernen.</p></div>
{% set admin_section = 'venues' %}{% include '_admin_nav.html' %}
+2 -1
View File
@@ -10,7 +10,8 @@
content="width=device-width, initial-scale=1.0"
>
<title>{{ concert.artist }} | Pingu Concerts</title>
<title>{{ concert.artist }} | MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link
rel="stylesheet"
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Datenschutz · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head>
<body>
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div></header>
<main>
<div class="page-title"><h1>Datenschutz</h1><p>Hinweise zur Verarbeitung deiner Daten</p></div>
<section class="admin-section legal-copy">
<h2>Verantwortlicher</h2>
<p>Kai Piekny, Fleyerstr. 33, 58097 Hagen<br><a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a></p>
<h2>Welche Daten werden gespeichert?</h2>
<p>Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.</p>
<h2>Wofür werden sie verwendet?</h2>
<p>Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.</p>
<h2>Rechtsgrundlage und Speicherdauer</h2>
<p>Die Verarbeitung erfolgt im geschlossenen Projektbetrieb zur Bereitstellung der gewünschten Funktionen und soweit erforderlich auf Grundlage deiner Einwilligung. Daten bleiben gespeichert, solange dein Konto besteht oder gesetzliche Aufbewahrungspflichten gelten. Nicht mehr benötigte Daten werden gelöscht.</p>
<h2>Weitergabe und externe Dienste</h2>
<p>Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.</p>
<h2>Deine Rechte</h2>
<p>Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und soweit anwendbar Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a>.</p>
<h2>Cookies und Sicherheit</h2>
<p>Für die Anmeldung wird ausschließlich ein technisch notwendiges, HttpOnly-Sitzungscookie verwendet. Im späteren HTTPS-Betrieb wird es zusätzlich mit dem Secure-Flag gesetzt. Passwörter werden nicht im Klartext gespeichert.</p>
<p class="legal-note">Diese Information ist eine technische Projektgrundlage und ersetzt keine individuelle rechtliche Prüfung. Vor einer öffentlichen Veröffentlichung sollten Hostinganbieter, Auftragsverarbeiter, Löschfristen und der externe Geocoding-Dienst konkret ergänzt und geprüft werden.</p>
</section>
</main>
</body>
</html>
+3 -2
View File
@@ -3,13 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ concert.artist }} bearbeiten · Pingu Concerts</title>
<title>{{ concert.artist }} bearbeiten · MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<div class="header-inner">
<a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>
<a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>
{% include '_user_menu.html' %}
</div>
</header>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Impressum · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head>
<body>
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div></header>
<main>
<div class="page-title"><h1>Impressum</h1><p>Anbieterkennzeichnung</p></div>
<section class="admin-section legal-copy">
<h2>MetalCircle</h2>
<p><strong>Verantwortlich für den Inhalt</strong></p>
<p>Kai Piekny<br>Fleyerstr. 33<br>58097 Hagen<br>Deutschland</p>
<p>E-Mail: <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a></p>
<p class="legal-note">Dieses private Projekt befindet sich derzeit im geschlossenen Betrieb. Vor einer Veröffentlichung werden die Angaben und rechtlichen Anforderungen nochmals geprüft.</p>
</section>
</main>
</body>
</html>
+5 -4
View File
@@ -10,7 +10,8 @@
content="width=device-width, initial-scale=1.0"
>
<title>Pingu Concerts</title>
<title>MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link
rel="stylesheet"
@@ -175,7 +176,7 @@
}
.page-header { padding: 24px; border: 1px solid #5f1515; border-left: 4px solid #dc2626; border-radius: 14px; background: linear-gradient(110deg, rgba(10,10,10,.9), rgba(69,10,10,.28)); box-shadow: 0 12px 30px rgba(0,0,0,.3); }
.page-header h1 { font-family: "Metal Mania", Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif; letter-spacing: .05em; text-transform: uppercase; text-shadow: 0 2px 18px rgba(185, 28, 28, .45); }
.page-header h1 { font-family: "Nimbus Sans Narrow", Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif; letter-spacing: .05em; text-transform: uppercase; text-shadow: 0 2px 18px rgba(185, 28, 28, .45); }
.new-concert-button { background: #991b1b; border: 1px solid #dc2626; }
.new-concert-button:hover { background: #b91c1c; }
.admin-button:hover { border-color: #dc2626; }
@@ -232,11 +233,11 @@
<div>
<h1>
{% if archive %}☠ Vergangene Veranstaltungen{% else %}🐧 Pingu Concerts{% endif %}
{% if archive %}☠ Vergangene Veranstaltungen{% else %}<img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle">{% endif %}
</h1>
<div class="subtitle">
{% if archive %}Das Archiv vergangener Nächte{% else %}Konzerte, Festivals und alles davor{% endif %}
<strong>Enter the Pit. Join the Circle.</strong>
</div>
</div>
+3 -3
View File
@@ -3,13 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden · Pingu Concerts</title>
<title>Anmelden · MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<div class="header-inner">
<a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>
<a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>
</div>
</header>
<main>
@@ -33,7 +34,6 @@
</p>
<button type="submit" class="button">Anmelden</button>
<p><small>Passwort vergessen? Bitte einen Admin um einen zeitlich begrenzten Reset-Link.</small></p>
<a href="/" class="button button-secondary">Abbrechen</a>
</form>
</section>
</main>
+35 -2
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nachrichten · Pingu Concerts</title>
<title>Nachrichten · MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.conversation-list { display: grid; gap: 10px; }
@@ -23,11 +24,23 @@
.chat-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; margin-top: 12px; max-width: none; }
.chat-form textarea { min-height: 76px; margin: 0; resize: vertical; }
.empty-chat { color: var(--muted); text-align: center; margin: auto; }
.message-notifications { margin-bottom: 24px; padding: 16px; background: rgba(69,10,10,.45); border: 1px solid #7f1d1d; border-radius: 14px; }
.message-notifications h2, .conversation-heading { margin: 0 0 12px; font-size: 1.15rem; }
.notification-row { display: flex; align-items: center; gap: 11px; padding: 11px; color: var(--text); border-top: 1px solid rgba(127,29,29,.55); }
a.notification-row:hover { background: rgba(127,29,29,.25); }
.notification-icon { flex: 0 0 auto; font-size: 1.35rem; }
.notification-row > span:nth-child(2) { min-width: 0; flex: 1; }
.notification-row small { display: block; margin-top: 3px; color: var(--muted); }
.notification-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.notification-actions form { margin: 0; }
.notification-actions .button { padding: 7px 10px; font-size: .82rem; }
.notification-link { color: #fca5a5; white-space: nowrap; }
.conversation-heading { margin-top: 20px; }
@media (max-width: 600px) { .chat-form { grid-template-columns: 1fr; } .chat-bubble { max-width: 90%; } }
</style>
</head>
<body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>{% include '_user_menu.html' %}</div></header>
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
<main>
{% if partner %}
<div class="chat-header">
@@ -45,6 +58,26 @@
<p><a class="button button-secondary" href="/messages">← Alle Nachrichten</a></p>
{% else %}
<div class="page-title"><h1>🤘 Nachrichten</h1><p>Private Chats mit deinen Freunden.</p></div>
{% if friend_requests or event_invitations %}
<section class="message-notifications">
<h2>Neue Anfragen</h2>
{% for request in friend_requests %}
<div class="notification-row">
<span class="notification-icon">🤝</span>
<span><strong>{{ request.display_name }}</strong> möchte sich mit dir befreunden.<small>{{ request.created_at }}</small></span>
<span class="notification-actions"><form method="post" action="/friendships/{{ request.id }}/accept"><input type="hidden" name="return_to" value="/messages"><button class="button" type="submit">Annehmen</button></form><form method="post" action="/friendships/{{ request.id }}/decline"><input type="hidden" name="return_to" value="/messages"><button class="button button-secondary" type="submit">Ablehnen</button></form></span>
</div>
{% endfor %}
{% for invitation in event_invitations %}
<a class="notification-row" href="/concerts/{{ invitation.concert_id }}">
<span class="notification-icon">✉️</span>
<span><strong>{{ invitation.artist }}</strong> Einladung von {{ invitation.invited_by }}<small>{{ invitation.date }}</small></span>
<span class="notification-link">Ansehen →</span>
</a>
{% endfor %}
</section>
{% endif %}
<h2 class="conversation-heading">Unterhaltungen</h2>
<div class="conversation-list">
{% for conversation in conversations %}
<a class="conversation-card" href="/messages/{{ conversation.username }}">
+4 -3
View File
@@ -10,7 +10,8 @@
content="width=device-width, initial-scale=1.0"
>
<title>Veranstaltung hinzufügen · Pingu Concerts</title>
<title>Veranstaltung hinzufügen · MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link
rel="stylesheet"
@@ -75,7 +76,7 @@
<div class="header-inner">
<a href="/" class="logo">
🎸 Pingu <span>Concerts</span>
<img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle">
</a>
{% include '_user_menu.html' %}
@@ -322,7 +323,7 @@
<label for="flyer-url">🔗 Flyer extern verlinken <small>(bevorzugt)</small></label>
<input type="url" id="flyer-url" name="flyer_url" placeholder="https://veranstalter.example/flyer.jpg">
<div class="flyer-help">Bitte möglichst den offiziellen Flyer des Veranstalters oder der Band verlinken. Das Bild wird nicht auf Pingu Concerts kopiert; der Link wird als Quelle ausgewiesen.</div>
<div class="flyer-help">Bitte möglichst den offiziellen Flyer des Veranstalters oder der Band verlinken. Das Bild wird nicht auf MetalCircle kopiert; der Link wird als Quelle ausgewiesen.</div>
<label for="flyer">
+2 -2
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Passwort zurücksetzen · Pingu Concerts</title><link rel="stylesheet" href="/static/css/style.css"></head><body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a></div></header><main><div class="page-title"><h1>Passwort zurücksetzen</h1></div><section class="empty">
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Passwort zurücksetzen · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head><body>
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div></header><main><div class="page-title"><h1>Passwort zurücksetzen</h1></div><section class="empty">
{% if error %}<p role="alert">{{ error }}</p>{% endif %}<form method="post" action="/password-reset/{{ token }}"><label>Neues Passwort<br><input type="password" name="password" minlength="10" required autocomplete="new-password"></label><br><label>Passwort wiederholen<br><input type="password" name="password_repeat" minlength="10" required autocomplete="new-password"></label><br><button class="button" type="submit">Passwort speichern</button></form>
</section></main></body></html>
+4 -2
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ profile.display_name }} · Pingu Concerts</title>
<title>{{ profile.display_name }} · MetalCircle</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.profile-layout { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(0, 1.4fr); gap: 24px; align-items: start; }
@@ -57,7 +58,7 @@
<body>
<header>
<div class="header-inner">
<a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>
<a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>
{% include '_user_menu.html' %}
</div>
</header>
@@ -186,6 +187,7 @@
<span class="avatar-upload-status" id="avatar-upload-status">Große Bilder werden vor dem Upload automatisch optimiert.</span>
<button class="button" type="submit">Profil speichern</button>
</form>
<p class="data-export"><a href="/profile/export">Meine gespeicherten Daten exportieren (JSON)</a></p>
</section>
{% endif %}
</main>
+4 -3
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pingu Concerts - Registrierung</title>
<title>MetalCircle - Registrierung</title>
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
<style>
body {
@@ -31,12 +32,12 @@
<body>
<h1>🎸 Pingu Concerts</h1>
<p><img src="/static/images/metalcircle-full.png" alt="MetalCircle" style="display:block;max-width:100%;height:auto;margin:0 auto 24px"></p>
<h2>Einladung angenommen</h2>
<p>
Erstelle deinen Account für Pingu Concerts.
Erstelle deinen Account für MetalCircle.
</p>
<form method="post" action="/register">