Add social features and harden beta release

This commit is contained in:
kai
2026-08-26 17:08:10 +02:00
parent 3181b2fb35
commit a9741b6859
28 changed files with 1804 additions and 184 deletions
+1
View File
@@ -12,6 +12,7 @@ env/
.env .env
.env.* .env.*
!.env.example !.env.example
compose.dev.yml
# Logs # Logs
*.log *.log
+1 -1
View File
@@ -2,7 +2,7 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow
COPY . . COPY . .
+1010 -76
View File
File diff suppressed because it is too large Load Diff
+27 -10
View File
@@ -1,12 +1,12 @@
:root { :root {
--bg: #0b0f19; --bg: #050505;
--surface: #151b29; --surface: #101010;
--surface-hover: #1b2333; --surface-hover: #1a1111;
--border: #293449; --border: #3a3030;
--text: #f3f4f6; --text: #f5f5f4;
--muted: #9ca3af; --muted: #a8a29e;
--accent: #8b5cf6; --accent: #b91c1c;
--accent-hover: #7c3aed; --accent-hover: #dc2626;
} }
* { * {
@@ -23,7 +23,7 @@ body {
"Segoe UI", "Segoe UI",
sans-serif; sans-serif;
background: var(--bg); background: radial-gradient(circle at 50% -15%, #320909 0, transparent 32rem), var(--bg);
color: var(--text); color: var(--text);
} }
@@ -33,7 +33,7 @@ a {
} }
header { header {
background: rgba(21, 27, 41, 0.95); background: rgba(8, 8, 8, 0.96);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
position: sticky; position: sticky;
top: 0; top: 0;
@@ -54,12 +54,27 @@ header {
.logo { .logo {
font-size: 1.4rem; font-size: 1.4rem;
font-weight: 800; font-weight: 800;
font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
letter-spacing: .06em;
text-transform: uppercase;
} }
.logo span { .logo span {
color: var(--accent); color: var(--accent);
} }
.user-menu { position: relative; margin-left: auto; }
.user-menu summary { padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; cursor: pointer; list-style: none; font-weight: 700; }
.user-menu summary::-webkit-details-marker { display: none; }
.user-menu[open] summary { border-color: var(--accent); background: var(--surface-hover); }
.user-menu-panel { position: absolute; top: calc(100% + 7px); right: 0; z-index: 100; min-width: 170px; padding: 7px; background: #090909; border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 35px rgba(0,0,0,.6); }
.user-menu-panel a, .user-menu-panel button { display: block; width: 100%; padding: 10px 11px; color: var(--text); background: transparent; border: 0; border-radius: 7px; text-align: left; font: inherit; cursor: pointer; }
.user-menu-panel a:hover, .user-menu-panel button:hover { background: var(--surface-hover); color: #f87171; }
.user-menu-panel form { max-width: none; margin: 0; }
.metal-notification { position: relative; display: inline-block; margin-left: 5px; color: #ef4444; filter: saturate(1.5); }
.metal-notification b { position: absolute; top: -7px; right: -9px; min-width: 16px; padding: 1px 4px; color: #fff; background: #b91c1c; border-radius: 999px; font: 700 .65rem/1.3 system-ui, sans-serif; text-align: center; }
.menu-count { float: right; min-width: 22px; padding: 1px 6px; color: #fff; background: #b91c1c; border-radius: 999px; text-align: center; }
main { main {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
@@ -73,6 +88,8 @@ main {
.page-title h1 { .page-title h1 {
margin: 0; margin: 0;
font-size: 2rem; font-size: 2rem;
font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
letter-spacing: .04em;
} }
.page-title p { .page-title p {
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+60
View File
@@ -93,6 +93,8 @@ a {
width: 100%; width: 100%;
background: #0a0d12; background: #0a0d12;
display: flex; display: flex;
flex-direction: column;
align-items: center;
justify-content: center; justify-content: center;
padding: 25px; padding: 25px;
} }
@@ -363,3 +365,61 @@ a {
} }
} }
/* Dark metal theme */
body {
background: radial-gradient(circle at 50% -15%, #320909 0, transparent 34rem), #050505;
color: #f5f5f4;
}
.concert-detail,
.comment,
.attendance-option {
background-color: #101010;
border-color: #3a3030;
}
.info-item,
.attendance-section,
.comments-section,
.coming-soon {
background-color: #090909;
border-color: #3a3030;
}
.concert-content h1,
.description h2,
.attendance-section h2,
.comments-section h2 {
font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
letter-spacing: .04em;
}
.concert-action-edit,
.ticket-button,
.attendance-option:hover,
.attendance-option.is-selected {
background: #7f1d1d;
border-color: #dc2626;
}
.concert-action:hover,
.concert-action-edit:hover,
.ticket-button:hover {
background: #991b1b;
border-color: #ef4444;
}
.attendance-list summary { color: #ef4444; }
.user-menu { position: relative; margin-left: auto; }
.user-menu summary { padding: 9px 12px; border: 1px solid #3a3030; border-radius: 9px; cursor: pointer; list-style: none; font-weight: 700; }
.user-menu summary::-webkit-details-marker { display: none; }
.user-menu[open] summary { border-color: #dc2626; background: #1a1111; }
.user-menu-panel { position: absolute; top: calc(100% + 7px); right: 0; z-index: 100; min-width: 170px; padding: 7px; background: #090909; border: 1px solid #3a3030; border-radius: 10px; box-shadow: 0 14px 35px rgba(0,0,0,.6); }
.user-menu-panel a, .user-menu-panel button { display: block; width: 100%; padding: 10px 11px; color: #f5f5f4; background: transparent; border: 0; border-radius: 7px; text-align: left; font: inherit; cursor: pointer; }
.user-menu-panel a:hover, .user-menu-panel button:hover { background: #1a1111; color: #f87171; }
.user-menu-panel form { margin: 0; }
.metal-notification { position: relative; display: inline-block; margin-left: 5px; color: #ef4444; filter: saturate(1.5); }
.metal-notification b { position: absolute; top: -7px; right: -9px; min-width: 16px; padding: 1px 4px; color: #fff; background: #b91c1c; border-radius: 999px; font: 700 .65rem/1.3 system-ui, sans-serif; text-align: center; }
.menu-count { float: right; min-width: 22px; padding: 1px 6px; color: #fff; background: #b91c1c; border-radius: 999px; text-align: center; }
+10
View File
@@ -0,0 +1,10 @@
<details class="user-menu">
<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>
<form method="post" action="/logout">
<button type="submit">Abmelden</button>
</form>
</div>
</details>
+4
View File
@@ -0,0 +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>
<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>
+1 -1
View File
@@ -15,7 +15,7 @@
</style> </style>
</head> </head>
<body> <body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a><span>{{ user.display_name }}</span></div></header> <header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>{% include '_user_menu.html' %}</div></header>
<main> <main>
<div class="page-title"><h1>🧵 Patch-Bilder</h1><p>Grafiken für die Patches auf der virtuellen Kutte verwalten.</p></div> <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' %} {% set admin_section = 'patches' %}{% include '_admin_nav.html' %}
+8 -2
View File
@@ -7,14 +7,16 @@
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<style> <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; } .invite-link { display: block; margin-top: 14px; padding: 12px; overflow-wrap: anywhere; background: #0f1420; border: 1px solid var(--border); border-radius: 9px; color: #c4b5fd; }
.user-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 12px; align-items: center; } .user-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 12px; align-items: center; }
.user-row form { margin: 0; } .user-row form { margin: 0; }
.role-form { display: flex; gap: 8px; } .role-form { display: flex; gap: 8px; }
.role-form label { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.role-form label input { width: auto; margin: 0; }
@media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } } @media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } }
</style> </style>
</head> </head>
<body> <body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a><span>{{ user.display_name }}</span></div></header> <header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>{% include '_user_menu.html' %}</div></header>
<main> <main>
<div class="page-title"><h1>⚙️ Verwaltung</h1><p>Benutzerkonten und Einladungen verwalten.</p></div> <div class="page-title"><h1>⚙️ Verwaltung</h1><p>Benutzerkonten und Einladungen verwalten.</p></div>
{% set admin_section = 'users' %}{% include '_admin_nav.html' %} {% set admin_section = 'users' %}{% include '_admin_nav.html' %}
@@ -32,8 +34,12 @@
<div><strong>{{ managed_user.display_name }}</strong><div class="admin-meta">@{{ managed_user.username }} · {{ managed_user.email }} · seit {{ managed_user.created_at }}</div></div> <div><strong>{{ managed_user.display_name }}</strong><div class="admin-meta">@{{ managed_user.username }} · {{ managed_user.email }} · seit {{ managed_user.created_at }}</div></div>
<form class="role-form" method="post" action="/admin/users/{{ managed_user.id }}"> <form class="role-form" method="post" action="/admin/users/{{ managed_user.id }}">
<select name="role" aria-label="Rolle für {{ managed_user.username }}"><option value="user" {% if not managed_user.is_admin %}selected{% endif %}>Benutzer</option><option value="admin" {% if managed_user.is_admin %}selected{% endif %}>Admin</option></select> <select name="role" aria-label="Rolle für {{ managed_user.username }}"><option value="user" {% if not managed_user.is_admin %}selected{% endif %}>Benutzer</option><option value="admin" {% if managed_user.is_admin %}selected{% endif %}>Admin</option></select>
<label><input type="checkbox" name="founder_badge" {% if managed_user.has_founder_badge %}checked{% endif %}> Gründer-Patch</label>
<button class="button" type="submit">Speichern</button> <button class="button" type="submit">Speichern</button>
</form> </form>
<div class="role-form">
<form method="post" action="/admin/users/{{ managed_user.id }}/account-link"><input type="hidden" name="purpose" value="password_reset"><button class="button button-secondary" type="submit">Passwort-Reset</button></form>
</div>
{% if managed_user.id != user.id %}<form method="post" action="/admin/users/{{ managed_user.id }}/delete" onsubmit="return confirm('Diesen Benutzer wirklich löschen?');"><button class="admin-danger" type="submit">Löschen</button></form>{% endif %} {% if managed_user.id != user.id %}<form method="post" action="/admin/users/{{ managed_user.id }}/delete" onsubmit="return confirm('Diesen Benutzer wirklich löschen?');"><button class="admin-danger" type="submit">Löschen</button></form>{% endif %}
</div> </div>
{% endfor %} {% endfor %}
+22 -3
View File
@@ -13,19 +13,37 @@
.venue-actions label { display: flex; align-items: center; gap: 6px; } .venue-actions label { display: flex; align-items: center; gap: 6px; }
.merge-form { display: grid; grid-template-columns: minmax(180px, 1fr) auto; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } .merge-form { display: grid; grid-template-columns: minmax(180px, 1fr) auto; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.delete-form { margin-top: 10px; text-align: right; } .delete-form { margin-top: 10px; text-align: right; }
.venue-card { padding: 0; overflow: hidden; }
.venue-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 16px; cursor: pointer; list-style: none; }
.venue-summary::-webkit-details-marker { display: none; }
.venue-summary::after { content: "▾"; color: var(--muted); transition: transform .15s ease; }
.venue-card[open] .venue-summary::after { transform: rotate(180deg); }
.venue-card[open] .venue-summary { border-bottom: 1px solid var(--border); background: var(--surface-hover); }
.venue-summary-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.venue-summary-meta { display: block; margin-top: 3px; color: var(--muted); font-size: .83rem; }
.venue-verified { color: #86efac; }
.venue-unverified { color: #fca5a5; }
.venue-editor { padding: 16px; }
@media (max-width: 900px) { .venue-fields { grid-template-columns: 1fr 1fr; } } @media (max-width: 900px) { .venue-fields { grid-template-columns: 1fr 1fr; } }
@media (max-width: 560px) { .venue-fields, .merge-form { grid-template-columns: 1fr; } } @media (max-width: 560px) { .venue-fields, .merge-form { grid-template-columns: 1fr; } }
</style> </style>
</head> </head>
<body> <body>
<header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a><span>{{ user.display_name }}</span></div></header> <header><div class="header-inner"><a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>{% include '_user_menu.html' %}</div></header>
<main> <main>
<div class="page-title"><h1>📍 Veranstaltungsorte</h1><p>Orte korrigieren, bestätigen, zusammenführen oder entfernen.</p></div> <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' %} {% set admin_section = 'venues' %}{% include '_admin_nav.html' %}
<section class="admin-section"> <section class="admin-section">
<div class="admin-list"> <div class="admin-list">
{% for venue in venues %} {% for venue in venues %}
<article class="admin-row"> <details class="admin-row venue-card" {% if not venue.is_verified %}open{% endif %}>
<summary class="venue-summary">
<span>
<strong class="venue-summary-title">{{ venue.name }}{% if venue.city %} · {{ venue.city }}{% endif %}{% if venue.country %} · {{ venue.country }}{% endif %}</strong>
<span class="venue-summary-meta"><span class="{% if venue.is_verified %}venue-verified{% else %}venue-unverified{% endif %}">{% if venue.is_verified %}✓ bestätigt{% else %}● unbestätigt{% endif %}</span> · {{ venue.concert_count }} Veranstaltung(en) · ID {{ venue.id }}</span>
</span>
</summary>
<div class="venue-editor">
<form method="post" action="/admin/venues/{{ venue.id }}"> <form method="post" action="/admin/venues/{{ venue.id }}">
<div class="venue-fields"> <div class="venue-fields">
<input name="name" value="{{ venue.name }}" aria-label="Name" required> <input name="name" value="{{ venue.name }}" aria-label="Name" required>
@@ -50,7 +68,8 @@
<form class="delete-form" method="post" action="/admin/venues/{{ venue.id }}/delete" onsubmit="return confirm('{% if venue.concert_count %}Der Ort ist mit {{ venue.concert_count }} Konzert(en) verknüpft. Diese Konzerte haben danach keinen Veranstaltungsort mehr. {% endif %}Ort endgültig löschen?');"> <form class="delete-form" method="post" action="/admin/venues/{{ venue.id }}/delete" onsubmit="return confirm('{% if venue.concert_count %}Der Ort ist mit {{ venue.concert_count }} Konzert(en) verknüpft. Diese Konzerte haben danach keinen Veranstaltungsort mehr. {% endif %}Ort endgültig löschen?');">
<button class="admin-danger" type="submit">Ort löschen{% if venue.concert_count %} ({{ venue.concert_count }} Verknüpfung(en)){% endif %}</button> <button class="admin-danger" type="submit">Ort löschen{% if venue.concert_count %} ({{ venue.concert_count }} Verknüpfung(en)){% endif %}</button>
</form> </form>
</article> </div>
</details>
{% else %}<p>Noch keine Veranstaltungsorte vorhanden.</p>{% endfor %} {% else %}<p>Noch keine Veranstaltungsorte vorhanden.</p>{% endfor %}
</div> </div>
</section> </section>
+77 -13
View File
@@ -17,28 +17,44 @@
href="/static/style.css" href="/static/style.css"
> >
<style>
.detail-topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.event-type-badge { display: inline-block; margin-bottom: 8px; padding: 4px 9px; border: 1px solid #4b5563; border-radius: 999px; color: #c4b5fd; font-size: .8rem; font-weight: 700; text-transform: uppercase; }
.parent-event-link { color: #c4b5fd; }
.flyer-source { display: block; width: 100%; flex: 0 0 100%; margin: 10px 0 0; color: #a8a29e; font-size: .85rem; text-align: center; }
.flyer-source a { color: #f87171; text-decoration: underline; }
.flyer-disclaimer { display: block; margin-top: 4px; font-size: .75rem; }
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<a href="/" class="back-link"> <div class="detail-topbar">
← Zurück zum Kalender <a href="/" class="back-link">← Zurück zum Kalender</a>
</a> {% include '_user_menu.html' %}
</div>
<article class="concert-detail"> <article class="concert-detail">
{% if concert.flyer_path %} {% if concert.flyer_url or concert.flyer_path %}
<div class="flyer-container"> <div class="flyer-container">
<img <img
src="{{ concert.flyer_path }}" src="{{ concert.flyer_url or concert.flyer_path }}"
alt="Flyer {{ concert.artist }}" alt="Flyer {{ concert.artist }}"
class="concert-flyer" class="concert-flyer"
referrerpolicy="no-referrer"
{% if concert.flyer_url and concert.flyer_path %}onerror="this.onerror=null;this.src='{{ concert.flyer_path }}'"{% endif %}
> >
{% if concert.flyer_url %}
<p class="flyer-source"><a href="{{ concert.flyer_url }}" target="_blank" rel="noopener noreferrer nofollow external">Quelle</a><span class="flyer-disclaimer">Extern eingebundener Flyer. Rechte liegen beim jeweiligen Veranstalter bzw. Rechteinhaber. Hinweise zur Entfernung bitte an die Administration.</span></p>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -46,6 +62,8 @@
<div class="concert-content"> <div class="concert-content">
<span class="event-type-badge">{{ concert.event_type_label }}</span>
<h1> <h1>
{{ concert.artist }} {{ concert.artist }}
</h1> </h1>
@@ -69,6 +87,11 @@
{{ concert.time }} Uhr {{ concert.time }} Uhr
{% if concert.event_type == 'festival' and concert.end_date %}
<br>
bis {{ concert.end_date }}{% if concert.end_time %}, {{ concert.end_time }} Uhr{% endif %}
{% endif %}
</div> </div>
</div> </div>
@@ -96,12 +119,19 @@
{% if concert.venue.postal_code {% if concert.venue.postal_code
or concert.venue.city %} or concert.venue.city
or (concert.venue.country
and concert.venue.country not in ('Deutschland', 'Germany')) %}
<br> <br>
{{ concert.venue.postal_code }} {{ concert.venue.postal_code }}
{{ concert.venue.city }} {{ concert.venue.city }}
{% if concert.venue.country
and concert.venue.country not in ('Deutschland', 'Germany') %}
{% if concert.venue.postal_code or concert.venue.city %} · {% endif %}
{{ concert.venue.country }}
{% endif %}
{% endif %} {% endif %}
@@ -136,13 +166,22 @@
</div> </div>
{% if concert.parent_event %}
<p>
Gehört zu:
<a class="parent-event-link" href="/concerts/{{ concert.parent_event.id }}">
{{ concert.parent_event.event_type_label }} · {{ concert.parent_event.title }}
</a>
</p>
{% endif %}
{% if concert.description %} {% if concert.description %}
<section class="description"> <section class="description">
<h2> <h2>
Über das Konzert Über die Veranstaltung
</h2> </h2>
<p> <p>
@@ -173,9 +212,9 @@
{% if can_edit %} {% if can_edit %}
<div class="detail-actions" aria-label="Konzert verwalten"> <div class="detail-actions" aria-label="Veranstaltung verwalten">
<a href="/concerts/{{ concert.id }}/edit" class="concert-action concert-action-edit"> <a href="/concerts/{{ concert.id }}/edit" class="concert-action concert-action-edit">
✏️ Konzert bearbeiten ✏️ Veranstaltung bearbeiten
</a> </a>
{% if user.is_admin and can_delete %} {% if user.is_admin and can_delete %}
@@ -183,10 +222,10 @@
<form <form
method="post" method="post"
action="/concerts/{{ concert.id }}/delete" action="/concerts/{{ concert.id }}/delete"
onsubmit="return confirm('Dieses Konzert wirklich löschen?');" onsubmit="return confirm('Diese Veranstaltung wirklich löschen?');"
> >
<button type="submit" class="concert-action concert-action-delete"> <button type="submit" class="concert-action concert-action-delete">
🗑️ Konzert löschen 🗑️ Veranstaltung löschen
</button> </button>
</form> </form>
@@ -234,7 +273,17 @@
type="submit" type="submit"
class="attendance-option {% if current_attendance == 'ticket_search' %}is-selected{% endif %}" class="attendance-option {% if current_attendance == 'ticket_search' %}is-selected{% endif %}"
> >
🎟️ Suche Ticket 🔎 Suche Ticket
</button>
</form>
<form method="post" action="/concerts/{{ concert.id }}/attendance">
<input type="hidden" name="status" value="ticket_offer">
<button
type="submit"
class="attendance-option {% if current_attendance == 'ticket_offer' %}is-selected{% endif %}"
>
🎟️ Biete Ticket
</button> </button>
</form> </form>
@@ -245,6 +294,7 @@
{{ attending_count }} Zusage{% if attending_count != 1 %}n{% endif %} {{ attending_count }} Zusage{% if attending_count != 1 %}n{% endif %}
· {{ maybe_count }} Vielleicht · {{ maybe_count }} Vielleicht
· {{ ticket_seeker_count }} sucht Ticket · {{ ticket_seeker_count }} sucht Ticket
· {{ ticket_offer_count }} bietet Ticket
</summary> </summary>
<div class="attendance-groups"> <div class="attendance-groups">
@@ -278,7 +328,7 @@
</div> </div>
<div> <div>
<h3>🎟️ Sucht ein Ticket</h3> <h3>🔎 Sucht ein Ticket</h3>
{% if ticket_seekers %} {% if ticket_seekers %}
<ul> <ul>
@@ -291,6 +341,20 @@
{% endif %} {% endif %}
</div> </div>
<div>
<h3>🎟️ Bietet ein Ticket</h3>
{% if ticket_offers %}
<ul>
{% for offer in ticket_offers %}
<li><a href="/users/{{ offer.username }}">{{ offer.name }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>Aktuell bietet niemand ein Ticket an.</p>
{% endif %}
</div>
</div> </div>
</details> </details>
+56 -7
View File
@@ -10,17 +10,39 @@
<header> <header>
<div class="header-inner"> <div class="header-inner">
<a href="/" class="logo">🎸 Pingu <span>Concerts</span></a> <a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>
{% include '_user_menu.html' %}
</div> </div>
</header> </header>
<main> <main>
<div class="page-title"> <div class="page-title">
<h1>✏️ Konzert bearbeiten</h1> <h1>✏️ Veranstaltung bearbeiten</h1>
<p>Änderungen werden direkt beim Konzert gespeichert.</p> <p>Änderungen werden direkt bei der Veranstaltung gespeichert.</p>
</div> </div>
<section class="empty"> <section class="empty">
<form method="post" action="/concerts/{{ concert.id }}/edit" enctype="multipart/form-data"> <form method="post" action="/concerts/{{ concert.id }}/edit" enctype="multipart/form-data">
{% if can_edit_details %}
<p>
<label>Kategorie<br>
<select name="event_type" id="event-type" required>
<option value="concert" {% if concert.event_type == 'concert' %}selected{% endif %}>Konzert</option>
<option value="festival" {% if concert.event_type == 'festival' %}selected{% endif %}>Festival</option>
<option value="other" {% if concert.event_type == 'other' %}selected{% endif %}>Sonstiges</option>
</select>
</label>
</p>
<p id="parent-event-field" {% if concert.event_type != 'other' %}hidden{% endif %}>
<label>Gehört zu<br>
<select name="parent_event_id" id="parent-event-id">
<option value="">Keine Verknüpfung eigenständige Karte</option>
{% for event in linkable_events %}
<option value="{{ event.id }}" {% if concert.parent_event and concert.parent_event.id == event.id %}selected{% endif %}>{{ event.event_type_label }} · {{ event.title }} · {{ event.date }}</option>
{% endfor %}
</select>
</label>
</p>
{% endif %}
<p> <p>
<label>Künstler / Band<br> <label>Titel / Künstler / Band<br>
<input type="text" name="artist" value="{{ concert.artist }}" {% if not can_edit_title %}disabled{% endif %} required> <input type="text" name="artist" value="{{ concert.artist }}" {% if not can_edit_title %}disabled{% endif %} required>
</label> </label>
</p> </p>
@@ -53,9 +75,9 @@
<input type="datetime-local" name="start_datetime" value="{{ concert.start_local }}" required> <input type="datetime-local" name="start_datetime" value="{{ concert.start_local }}" required>
</label> </label>
</p> </p>
<p> <p id="festival-end-field" {% if concert.event_type != 'festival' %}hidden{% endif %}>
<label>Ende<br> <label>Ende<br>
<input type="datetime-local" name="end_datetime" value="{{ concert.end_local }}"> <input type="datetime-local" name="end_datetime" id="festival-end-datetime" value="{{ concert.end_local }}" {% if concert.event_type == 'festival' %}required{% endif %}>
</label> </label>
</p> </p>
<p> <p>
@@ -73,14 +95,20 @@
<input type="number" name="ticket_price" value="{{ concert.ticket_price or '' }}" step="0.01" min="0"> <input type="number" name="ticket_price" value="{{ concert.ticket_price or '' }}" step="0.01" min="0">
</label> </label>
</p> </p>
<p>
<label>Flyer extern verlinken <small>(bevorzugt)</small><br>
<input type="url" name="flyer_url" value="{{ concert.flyer_url or '' }}" placeholder="https://veranstalter.example/flyer.jpg">
</label>
<small>Bitte möglichst die offizielle Quelle verwenden. Ein externer Link wird gegenüber einem Upload bevorzugt und unter dem Bild genannt. Feld leeren, um wieder den Upload zu verwenden.</small>
</p>
<p> <p>
<label>Neuen Flyer hochladen<br> <label>Neuen Flyer hochladen<br>
<input type="file" name="flyer" accept="image/jpeg,image/png,image/webp"> <input type="file" name="flyer" accept="image/jpeg,image/png,image/webp">
</label> </label>
<small>Ohne neue Datei bleibt der bisherige Flyer erhalten.</small> <small>Ohne neue Datei bleibt der bisherige Flyer erhalten.</small>
</p> </p>
{% if concert.flyer_path %} {% if concert.flyer_url or concert.flyer_path %}
<p><img src="{{ concert.flyer_path }}" alt="Aktueller Flyer" style="max-width: 300px; max-height: 400px; border-radius: 10px;"></p> <p><img src="{{ concert.flyer_url or concert.flyer_path }}" alt="Aktueller Flyer" referrerpolicy="no-referrer" style="max-width: 300px; max-height: 400px; border-radius: 10px;"></p>
{% endif %} {% endif %}
{% endif %} {% endif %}
<div> <div>
@@ -90,5 +118,26 @@
</form> </form>
</section> </section>
</main> </main>
{% if can_edit_details %}
<script>
const eventType = document.getElementById("event-type");
const parentEventField = document.getElementById("parent-event-field");
const parentEventId = document.getElementById("parent-event-id");
const festivalEndField = document.getElementById("festival-end-field");
const festivalEndDatetime = document.getElementById("festival-end-datetime");
function updateEventFields() {
const isFestival = eventType.value === "festival";
const isOther = eventType.value === "other";
festivalEndField.hidden = !isFestival;
festivalEndDatetime.required = isFestival;
parentEventField.hidden = !isOther;
if (!isFestival) festivalEndDatetime.value = "";
if (!isOther) parentEventId.value = "";
}
eventType.addEventListener("change", updateEventFields);
</script>
{% endif %}
</body> </body>
</html> </html>
+120 -57
View File
@@ -78,6 +78,33 @@
gap: 15px; gap: 15px;
} }
.event-group { display: grid; gap: 8px; }
.linked-events {
display: grid;
gap: 7px;
margin-left: 30px;
}
.concert-card.linked-event {
padding: 14px 18px;
border-left: 3px solid #a78bfa;
background: #121720;
}
.linked-event .concert-artist { font-size: 1.05rem; margin-bottom: 5px; }
.linked-event .concert-meta { font-size: .9rem; }
.event-category {
display: inline-block;
margin-bottom: 7px;
color: #aab4c3;
font-size: .78rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
}
.past-concerts-title { .past-concerts-title {
margin: 28px 0 0; margin: 28px 0 0;
} }
@@ -127,6 +154,33 @@
color: #8b949e; color: #8b949e;
} }
.search-panel { margin-bottom: 26px; padding: 16px; background: rgba(16,16,16,.92); border: 1px solid #3a3030; border-radius: 14px; }
.search-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
.search-form input { margin: 0; }
.search-results-title { margin: 24px 0 12px; font-size: 1.15rem; }
.user-results { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; margin-bottom: 24px; }
.user-result { display: flex; align-items: center; gap: 11px; padding: 12px; background: #101010; border: 1px solid #3a3030; border-radius: 11px; }
.user-result:hover { border-color: #dc2626; }
.user-result-avatar { width: 44px; height: 44px; flex: 0 0 44px; object-fit: cover; border-radius: 50%; background: #292524; display: grid; place-items: center; font-weight: 800; }
.user-result-name { min-width: 0; }
.user-result-name strong, .user-result-name span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-result-name span { color: #a8a29e; font-size: .86rem; }
body {
background: radial-gradient(circle at 50% -20%, #3a0909 0, transparent 34rem), #050505;
min-height: 100vh;
}
.page-header { padding-bottom: 22px; border-bottom: 1px solid #3f2424; }
.page-header h1 { font-family: 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; }
.concert-card { background: linear-gradient(145deg, #101010, #181313); border-color: #3a3030; }
.concert-card:hover { border-color: #dc2626; background: #1c1212; }
.concert-date { color: #ef4444; }
.concert-card.linked-event { border-left-color: #dc2626; background: #0d0d0d; }
@media (max-width: 600px) { @media (max-width: 600px) {
.page-header { .page-header {
@@ -139,12 +193,31 @@
text-align: center; text-align: center;
} }
.linked-events { margin-left: 16px; }
.search-form { grid-template-columns: 1fr; }
} }
</style> </style>
</head> </head>
{% macro event_card(concert, linked=false) %}
<a href="/concerts/{{ concert.id }}" class="concert-card{% if linked %} linked-event{% endif %}">
<span class="event-category">{{ concert.event_type_label }}</span>
<div class="concert-artist">
{% if concert.event_type == 'festival' %}🎪{% elif concert.event_type == 'other' %}🥂{% else %}🎸{% endif %}
{{ concert.artist }}
</div>
<div class="concert-meta">
<div class="concert-date">
📅 {{ concert.date }}{% if concert.end_date %} {{ concert.end_date }}{% else %} um {{ concert.time }} Uhr{% endif %}
</div>
<div>📍 {{ concert.venue }}</div>
</div>
</a>
{% endmacro %}
<body> <body>
@@ -155,11 +228,11 @@
<div> <div>
<h1> <h1>
🐧 Pingu Concerts {% if archive %}☠ Vergangene Veranstaltungen{% else %}🐧 Pingu Concerts{% endif %}
</h1> </h1>
<div class="subtitle"> <div class="subtitle">
Unser Konzertkalender {% if archive %}Das Archiv vergangener Nächte{% else %}Konzerte, Festivals und alles davor{% endif %}
</div> </div>
</div> </div>
@@ -167,8 +240,8 @@
<div class="header-actions"> <div class="header-actions">
<a href="/profile" class="admin-button"> <a href="{% if archive %}/{% else %}/events/past{% endif %}" class="admin-button">
👤 Mein Profil {% if archive %}⚡ Bevorstehend{% else %}☠ Vergangene{% endif %}
</a> </a>
{% if user.is_admin %} {% if user.is_admin %}
@@ -183,79 +256,69 @@
href="/concerts/new" href="/concerts/new"
class="new-concert-button" class="new-concert-button"
> >
+ Konzert hinzufügen + Veranstaltung hinzufügen
</a> </a>
{% include '_user_menu.html' %}
</div> </div>
</header> </header>
<section class="search-panel" aria-label="Suche">
<form class="search-form" method="get" action="{% if archive %}/events/past{% else %}/{% endif %}">
<input type="search" name="q" value="{{ search_query }}" placeholder="Veranstaltungen, Orte oder User suchen …" aria-label="Veranstaltungen oder User suchen">
<button class="new-concert-button" type="submit">Suchen</button>
</form>
</section>
{% if search_query %}
<h2 class="search-results-title">User zu „{{ search_query }}“</h2>
{% if user_results %}
<div class="user-results">
{% for result in user_results %}
<a class="user-result" href="/users/{{ result.username }}">
{% if result.avatar_path %}<img class="user-result-avatar" src="{{ result.avatar_path }}" alt="">{% else %}<span class="user-result-avatar">{{ result.display_name[:1] }}</span>{% endif %}
<span class="user-result-name"><strong>{{ result.display_name }}</strong><span>@{{ result.username }}</span></span>
</a>
{% endfor %}
</div>
{% else %}<p class="subtitle">Keine passenden User gefunden.</p>{% endif %}
<h2 class="search-results-title">Veranstaltungen zu „{{ search_query }}“</h2>
{% endif %}
{% if upcoming_concerts or past_concerts %} {% if upcoming_concerts or past_concerts %}
<div class="concert-list"> <div class="concert-list">
{% for concert in upcoming_concerts %} {% for concert in upcoming_concerts %}
<div class="event-group">
<a {{ event_card(concert) }}
href="/concerts/{{ concert.id }}" {% if concert.children %}
class="concert-card" <div class="linked-events">
> {% for child in concert.children %}{{ event_card(child, true) }}{% endfor %}
<div class="concert-artist">
🎸 {{ concert.artist }}
</div>
<div class="concert-meta">
<div class="concert-date">
📅 {{ concert.date }}
um {{ concert.time }} Uhr
</div> </div>
{% endif %}
</div>
<div>
📍 {{ concert.venue }}
</div>
</div>
</a>
{% endfor %} {% endfor %}
{% if past_concerts %} {% if archive and past_concerts %}
<h2 class="past-concerts-title"> <h2 class="past-concerts-title">
Vergangene Konzerte Archiv
</h2> </h2>
{% for concert in past_concerts %} {% for concert in past_concerts %}
<div class="event-group">
<a {{ event_card(concert) }}
href="/concerts/{{ concert.id }}" {% if concert.children %}
class="concert-card" <div class="linked-events">
> {% for child in concert.children %}{{ event_card(child, true) }}{% endfor %}
<div class="concert-artist">
🎸 {{ concert.artist }}
</div>
<div class="concert-meta">
<div class="concert-date">
📅 {{ concert.date }}
um {{ concert.time }} Uhr
</div> </div>
{% endif %}
<div> </div>
📍 {{ concert.venue }}
</div>
</div>
</a>
{% endfor %} {% endfor %}
@@ -268,11 +331,11 @@
<div class="empty-state"> <div class="empty-state">
<h2> <h2>
Noch keine Konzerte 🎸 {% if archive %}Noch keine vergangenen Veranstaltungen ☠{% else %}Noch keine Veranstaltungen 🎸{% endif %}
</h2> </h2>
<p> <p>
Leg das erste Konzert an! {% if archive %}Hier erscheinen abgeschlossene Veranstaltungen.{% else %}Leg die erste Veranstaltung an!{% endif %}
</p> </p>
</div> </div>
+1
View File
@@ -32,6 +32,7 @@
</label> </label>
</p> </p>
<button type="submit" class="button">Anmelden</button> <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> <a href="/" class="button button-secondary">Abbrechen</a>
</form> </form>
</section> </section>
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nachrichten · Pingu Concerts</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.conversation-list { display: grid; gap: 10px; }
.conversation-card { display: flex; align-items: center; gap: 12px; padding: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 13px; }
.conversation-card:hover { border-color: var(--accent); }
.chat-avatar { width: 48px; height: 48px; flex: 0 0 48px; border-radius: 50%; object-fit: cover; background: #292524; display: grid; place-items: center; font-weight: 800; }
.conversation-copy { min-width: 0; flex: 1; }
.conversation-copy strong, .conversation-copy span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conversation-copy span { margin-top: 3px; color: var(--muted); font-size: .9rem; }
.unread-badge { min-width: 26px; padding: 3px 7px; color: #fff; background: #b91c1c; border-radius: 999px; text-align: center; font-weight: 800; }
.chat-header { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.chat-header h1 { margin: 0; }
.chat-log { display: flex; flex-direction: column; gap: 9px; min-height: 260px; padding: 18px; background: #090909; border: 1px solid var(--border); border-radius: 15px; }
.chat-bubble { align-self: flex-start; max-width: min(76%, 650px); padding: 10px 13px; background: #262020; border-radius: 4px 13px 13px 13px; white-space: pre-wrap; overflow-wrap: anywhere; }
.chat-bubble.mine { align-self: flex-end; background: #5f1515; border-radius: 13px 4px 13px 13px; }
.chat-time { display: block; margin-top: 5px; color: #a8a29e; font-size: .72rem; }
.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; }
@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>
<main>
{% if partner %}
<div class="chat-header">
{% if partner.avatar_path %}<img class="chat-avatar" src="{{ partner.avatar_path }}" alt="">{% else %}<span class="chat-avatar">{{ partner.display_name[:1] }}</span>{% endif %}
<div><h1>{{ partner.display_name }}</h1><a href="/users/{{ partner.username }}">@{{ partner.username }}</a></div>
</div>
<div class="chat-log">
{% for message in messages %}<div class="chat-bubble{% if message.from_me %} mine{% endif %}"{% if loop.last %} id="latest"{% endif %}>{{ message.body }}<span class="chat-time">{{ message.created_at }}</span></div>
{% else %}<p class="empty-chat">Noch keine Nachrichten. Schreib die erste!</p>{% endfor %}
</div>
<form class="chat-form" method="post" action="/messages/{{ partner.username }}">
<textarea name="body" maxlength="2000" required placeholder="Nachricht schreiben …" aria-label="Nachricht"></textarea>
<button class="button" type="submit">Senden</button>
</form>
<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>
<div class="conversation-list">
{% for conversation in conversations %}
<a class="conversation-card" href="/messages/{{ conversation.username }}">
{% if conversation.avatar_path %}<img class="chat-avatar" src="{{ conversation.avatar_path }}" alt="">{% else %}<span class="chat-avatar">{{ conversation.display_name[:1] }}</span>{% endif %}
<span class="conversation-copy"><strong>{{ conversation.display_name }}</strong><span>{% if conversation.last_message %}{% if conversation.last_from_me %}Du: {% endif %}{{ conversation.last_message }} · {{ conversation.last_at }}{% else %}Chat beginnen{% endif %}</span></span>
{% if conversation.unread_count %}<span class="unread-badge">{{ conversation.unread_count }}</span>{% endif %}
</a>
{% else %}<div class="empty"><h2>Noch keine Chats</h2><p>Chats sind mit bestätigten Freunden und mit Admins verfügbar.</p></div>{% endfor %}
</div>
{% endif %}
</main>
</body>
</html>
+59 -8
View File
@@ -10,7 +10,7 @@
content="width=device-width, initial-scale=1.0" content="width=device-width, initial-scale=1.0"
> >
<title>Konzert hinzufügen · Pingu Concerts</title> <title>Veranstaltung hinzufügen · Pingu Concerts</title>
<link <link
rel="stylesheet" rel="stylesheet"
@@ -78,6 +78,8 @@
🎸 Pingu <span>Concerts</span> 🎸 Pingu <span>Concerts</span>
</a> </a>
{% include '_user_menu.html' %}
</div> </div>
</header> </header>
@@ -87,7 +89,7 @@
<div class="page-title"> <div class="page-title">
<h1>🎤 Konzert hinzufügen</h1> <h1>🎤 Veranstaltung hinzufügen</h1>
<p> <p>
Trag ein, worauf wir uns freuen. Trag ein, worauf wir uns freuen.
@@ -104,6 +106,26 @@
enctype="multipart/form-data" enctype="multipart/form-data"
> >
<p>
<label for="event-type">Kategorie</label><br>
<select name="event_type" id="event-type" required>
<option value="concert">Konzert</option>
<option value="festival">Festival</option>
<option value="other">Sonstiges</option>
</select>
</p>
<p id="parent-event-field" hidden>
<label for="parent-event-id">Gehört zu</label><br>
<select name="parent_event_id" id="parent-event-id">
<option value="">Keine Verknüpfung eigenständige Karte</option>
{% for event in linkable_events %}
<option value="{{ event.id }}">{{ event.event_type_label }} · {{ event.title }} · {{ event.date }}</option>
{% endfor %}
</select>
<small>Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.</small>
</p>
<!-- ================================================= --> <!-- ================================================= -->
<!-- Künstler --> <!-- Künstler -->
<!-- ================================================= --> <!-- ================================================= -->
@@ -111,7 +133,7 @@
<p> <p>
<label> <label>
Künstler / Band<br> Titel / Künstler / Band<br>
<input <input
type="text" type="text"
@@ -124,7 +146,6 @@
</p> </p>
<!-- ================================================= --> <!-- ================================================= -->
<!-- Veranstaltungsort --> <!-- Veranstaltungsort -->
<!-- ================================================= --> <!-- ================================================= -->
@@ -208,6 +229,13 @@
</p> </p>
<p id="festival-end-field" hidden>
<label>
Enddatum und Uhrzeit<br>
<input type="datetime-local" name="end_datetime" id="festival-end-datetime">
</label>
</p>
<!-- ================================================= --> <!-- ================================================= -->
<!-- Beschreibung --> <!-- Beschreibung -->
@@ -221,7 +249,7 @@
<textarea <textarea
name="description" name="description"
rows="5" rows="5"
placeholder="Optional: Infos zum Konzert, Tour, Supportbands usw." placeholder="Optional: Infos zur Veranstaltung, Tour, Supportbands usw."
></textarea> ></textarea>
</label> </label>
@@ -277,9 +305,13 @@
<div class="flyer-upload"> <div class="flyer-upload">
<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>
<label for="flyer"> <label for="flyer">
🎨 Konzert-Flyer 🎨 Veranstaltungs-Flyer
</label> </label>
@@ -294,7 +326,7 @@
<div class="flyer-help"> <div class="flyer-help">
JPG, PNG oder WEBP · maximal 10 MB Alternative, falls kein offizieller externer Link verfügbar ist: JPG, PNG oder WEBP · maximal 10 MB. Ist ein Link angegeben, wird er bevorzugt dargestellt.
</div> </div>
@@ -330,7 +362,7 @@
type="submit" type="submit"
class="button" class="button"
> >
💾 Konzert speichern 💾 Veranstaltung speichern
</button> </button>
@@ -352,6 +384,25 @@
<script> <script>
const eventType = document.getElementById("event-type");
const parentEventField = document.getElementById("parent-event-field");
const parentEventId = document.getElementById("parent-event-id");
const festivalEndField = document.getElementById("festival-end-field");
const festivalEndDatetime = document.getElementById("festival-end-datetime");
function updateEventFields() {
const isFestival = eventType.value === "festival";
const isOther = eventType.value === "other";
festivalEndField.hidden = !isFestival;
festivalEndDatetime.required = isFestival;
parentEventField.hidden = !isOther;
if (!isFestival) festivalEndDatetime.value = "";
if (!isOther) parentEventId.value = "";
}
eventType.addEventListener("change", updateEventFields);
updateEventFields();
/* ============================================================ /* ============================================================
VENUE SEARCH VENUE SEARCH
============================================================ */ ============================================================ */
+4
View File
@@ -0,0 +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">
{% 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>
+140 -5
View File
@@ -13,9 +13,15 @@
.avatar-fallback { display: grid; place-items: center; background: #283048; font-size: 3rem; font-weight: 800; } .avatar-fallback { display: grid; place-items: center; background: #283048; font-size: 3rem; font-weight: 800; }
.profile-name { margin: 0; font-size: 1.7rem; } .profile-name { margin: 0; font-size: 1.7rem; }
.profile-handle, .profile-since { color: var(--muted); margin: 6px 0 0; } .profile-handle, .profile-since { color: var(--muted); margin: 6px 0 0; }
.instagram-link { display: inline-flex; margin-top: 12px; padding: 8px 12px; color: #fff; background: #7f1d1d; border: 1px solid #dc2626; border-radius: 9px; font-weight: 700; }
.instagram-link:hover { color: #f87171; }
.profile-error { margin-bottom: 18px; padding: 12px 14px; color: #fecaca; background: #450a0a; border: 1px solid #b91c1c; border-radius: 9px; }
.profile-success { margin-bottom: 18px; padding: 12px 14px; color: #bbf7d0; background: #052e16; border: 1px solid #15803d; border-radius: 9px; }
.instagram-current { display: block; margin: -8px 0 14px; color: #f87171; }
.avatar-upload-status { display: block; min-height: 1.2em; margin: -8px 0 12px; color: var(--muted); font-size: .85rem; }
.stat { margin-top: 22px; padding: 16px; background: #0f1420; border-radius: 12px; } .stat { margin-top: 22px; padding: 16px; background: #0f1420; border-radius: 12px; }
.stat strong { display: block; color: #c4b5fd; font-size: 2.2rem; } .stat strong { display: block; color: #c4b5fd; font-size: 2.2rem; }
.kutte { position: relative; min-height: 320px; padding: 58px 36px 28px; background: linear-gradient(90deg, #151515 0 46%, #222 46% 54%, #151515 54%); border: 4px solid #353535; border-radius: 28px 28px 14px 14px; box-shadow: inset 0 0 0 2px #090909; } .kutte { position: relative; min-height: 320px; padding: 58px 36px 28px; background-image: linear-gradient(90deg, rgba(0,0,0,.2) 0 46%, rgba(255,255,255,.035) 46% 54%, rgba(0,0,0,.2) 54%), url('/static/images/black-denim-texture.webp'); background-size: 100% 100%, 520px auto; background-repeat: no-repeat, repeat; border: 4px solid #292524; border-radius: 28px 28px 14px 14px; box-shadow: inset 0 0 28px 8px rgba(0,0,0,.8), 0 10px 30px rgba(0,0,0,.45); }
.kutte::before, .kutte::after { content: ""; position: absolute; top: 0; width: 30%; height: 62px; background: #111; border-bottom: 3px solid #404040; } .kutte::before, .kutte::after { content: ""; position: absolute; top: 0; width: 30%; height: 62px; background: #111; border-bottom: 3px solid #404040; }
.kutte::before { left: 0; border-radius: 24px 0 45% 0; } .kutte::before { left: 0; border-radius: 24px 0 45% 0; }
.kutte::after { right: 0; border-radius: 0 24px 0 45%; } .kutte::after { right: 0; border-radius: 0 24px 0 45%; }
@@ -26,10 +32,25 @@
.patch-icon { font-size: 2.5rem; } .patch-icon { font-size: 2.5rem; }
.patch-image { width: auto; height: auto; max-width: 140px; max-height: 140px; object-fit: contain; display: block; } .patch-image { width: auto; height: auto; max-width: 140px; max-height: 140px; object-fit: contain; display: block; }
.patch.locked { opacity: .38; filter: grayscale(1); } .patch.locked { opacity: .38; filter: grayscale(1); }
.patch.earned { border-style: solid; border-color: #a78bfa; box-shadow: 0 0 12px rgba(167, 139, 250, .35); } .patch.earned { border: 0; background: transparent; box-shadow: 2px 3px 7px rgba(0,0,0,.7); }
.profile-edit { margin-top: 24px; } .profile-edit { margin-top: 24px; }
.profile-edit form { margin: 0; max-width: none; } .profile-edit form { margin: 0; max-width: none; }
.profile-edit input { margin-bottom: 14px; } .profile-edit input { margin-bottom: 14px; }
.friend-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 16px; }
.friend-actions form { margin: 0; }
.friend-note { margin: 14px 0 0; color: var(--muted); font-size: .9rem; }
.private-note { margin: 12px 0 0; padding: 10px; color: var(--muted); background: #0a0a0a; border-radius: 9px; }
.visibility-options { display: grid; gap: 8px; margin: 6px 0 18px; }
.visibility-options label { display: flex; align-items: flex-start; gap: 9px; margin: 0; }
.visibility-options input { width: auto; margin: 4px 0 0; }
.connections { margin-top: 24px; padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; }
.connection-list { display: grid; gap: 9px; }
.connection-row { display: flex; align-items: center; gap: 11px; padding: 11px; background: #090909; border: 1px solid var(--border); border-radius: 10px; }
.connection-row > a { display: flex; min-width: 0; flex: 1; align-items: center; gap: 10px; }
.connection-avatar { width: 40px; height: 40px; flex: 0 0 40px; border-radius: 50%; object-fit: cover; background: #292524; display: grid; place-items: center; }
.connection-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.connection-actions form { margin: 0; }
.connection-actions .button { padding: 7px 10px; font-size: .85rem; }
@media (max-width: 720px) { .profile-layout { grid-template-columns: 1fr; } .kutte { padding-left: 20px; padding-right: 20px; } } @media (max-width: 720px) { .profile-layout { grid-template-columns: 1fr; } .kutte { padding-left: 20px; padding-right: 20px; } }
</style> </style>
</head> </head>
@@ -37,7 +58,7 @@
<header> <header>
<div class="header-inner"> <div class="header-inner">
<a href="/" class="logo">🎸 Pingu <span>Concerts</span></a> <a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>
<a href="/profile">Mein Profil</a> {% include '_user_menu.html' %}
</div> </div>
</header> </header>
<main> <main>
@@ -54,8 +75,30 @@
{% endif %} {% endif %}
<h2 class="profile-name">{{ profile.display_name }}</h2> <h2 class="profile-name">{{ profile.display_name }}</h2>
<p class="profile-handle">@{{ profile.username }}</p> <p class="profile-handle">@{{ profile.username }}</p>
<p class="profile-since">Dabei seit {{ profile.created_at }}</p> {% if can_view_details %}<p class="profile-since">Dabei seit {{ profile.created_at }}</p>{% endif %}
{% if can_view_details and profile.instagram_url %}
<a class="instagram-link" href="{{ profile.instagram_url }}" target="_blank" rel="noopener noreferrer">◎ Instagram · @{{ profile.instagram_handle }}</a>
{% endif %}
<div class="stat"><strong>{{ attended_count }}</strong>besuchte Konzerte</div> <div class="stat"><strong>{{ attended_count }}</strong>besuchte Konzerte</div>
{% if not is_own_profile %}
<div class="friend-actions">
{% if (user.is_admin or profile.is_admin) and (not friendship or friendship.status != 'accepted') %}
<a class="button" href="/messages/{{ profile.username }}">Nachricht senden</a>
{% endif %}
{% if not friendship %}
<form method="post" action="/users/{{ profile.username }}/friend-request"><button class="button" type="submit">Freundschaft anfragen</button></form>
{% elif friendship.status == 'accepted' %}
<a class="button" href="/messages/{{ profile.username }}">Nachricht senden</a>
<form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">Freundschaft beenden</button></form>
{% elif friendship.addressee_id == user.id %}
<form method="post" action="/friendships/{{ friendship.id }}/accept"><button class="button" type="submit">Annehmen</button></form>
<form method="post" action="/friendships/{{ friendship.id }}/decline"><button class="button button-secondary" type="submit">Ablehnen</button></form>
{% else %}
<form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">Anfrage zurückziehen</button></form>
{% endif %}
</div>
{% if not can_view_details %}<p class="private-note">Weitere Profildetails sind nur für Freunde sichtbar.</p>{% endif %}
{% endif %}
</section> </section>
<section class="badges-card"> <section class="badges-card">
<h2>🥋 Virtuelle Kutte</h2> <h2>🥋 Virtuelle Kutte</h2>
@@ -81,18 +124,110 @@
</div> </div>
</section> </section>
</div> </div>
{% if is_own_profile %}
<section class="connections">
<h2>🤝 Freunde</h2>
{% if connections.incoming %}
<h3>Offene Anfragen</h3>
<div class="connection-list">
{% for connection in connections.incoming %}
<div class="connection-row">
<a href="/users/{{ connection.username }}">
{% if connection.avatar_path %}<img class="connection-avatar" src="{{ connection.avatar_path }}" alt="">{% else %}<span class="connection-avatar">{{ connection.display_name[:1] }}</span>{% endif %}
<strong>{{ connection.display_name }} <small>@{{ connection.username }}</small></strong>
</a>
<div class="connection-actions">
<form method="post" action="/friendships/{{ connection.id }}/accept"><button class="button" type="submit">Annehmen</button></form>
<form method="post" action="/friendships/{{ connection.id }}/decline"><button class="button button-secondary" type="submit">Ablehnen</button></form>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if connections.friends %}
<h3>Deine Freunde</h3>
<div class="connection-list">
{% for connection in connections.friends %}
<div class="connection-row">
<a href="/users/{{ connection.username }}">
{% if connection.avatar_path %}<img class="connection-avatar" src="{{ connection.avatar_path }}" alt="">{% else %}<span class="connection-avatar">{{ connection.display_name[:1] }}</span>{% endif %}
<strong>{{ connection.display_name }} <small>@{{ connection.username }}</small></strong>
</a>
<div class="connection-actions"><form method="post" action="/friendships/{{ connection.id }}/remove"><button class="button button-secondary" type="submit">Entfernen</button></form></div>
</div>
{% endfor %}
</div>
{% endif %}
{% if connections.outgoing %}<p class="friend-note">{{ connections.outgoing|length }} gesendete Anfrage(n) warten noch auf Antwort.</p>{% endif %}
{% if not connections.incoming and not connections.friends and not connections.outgoing %}<p class="friend-note">Noch keine Verbindungen. Finde andere User über die Suche auf der Startseite.</p>{% endif %}
</section>
{% endif %}
{% if is_own_profile %} {% if is_own_profile %}
<section class="profile-edit"> <section class="profile-edit">
<h2>Profil bearbeiten</h2> <h2>Profil bearbeiten</h2>
<form method="post" action="/profile" enctype="multipart/form-data"> {% if form_error %}<p class="profile-error" role="alert">{{ form_error }}</p>{% endif %}
{% if form_success %}<p class="profile-success" role="status">{{ form_success }}</p>{% endif %}
<form method="post" action="/profile" enctype="multipart/form-data" id="profile-form">
<label for="display-name">Anzeigename</label> <label for="display-name">Anzeigename</label>
<input id="display-name" name="display_name" type="text" value="{{ profile.display_name }}" maxlength="100"> <input id="display-name" name="display_name" type="text" value="{{ profile.display_name }}" maxlength="100">
<label for="instagram-url">Instagram-Profil <small>(optional)</small></label>
<input id="instagram-url" name="instagram_url" type="text" value="{{ instagram_input if instagram_input is not none else (profile.instagram_url or '') }}" placeholder="@name oder https://instagram.com/name" autocomplete="url">
{% if profile.instagram_url %}<a class="instagram-current" href="{{ profile.instagram_url }}" target="_blank" rel="noopener noreferrer">Aktuell verknüpft: @{{ profile.instagram_handle }}</a>{% endif %}
<fieldset>
<legend>Wer darf alle Profildetails sehen?</legend>
<div class="visibility-options">
<label><input type="radio" name="profile_visibility" value="public" {% if profile.profile_visibility == 'public' %}checked{% endif %}> <span><strong>Alle User</strong><br><small>Jeder angemeldete User sieht auch Instagram und Beitrittsdatum.</small></span></label>
<label><input type="radio" name="profile_visibility" value="friends" {% if profile.profile_visibility == 'friends' %}checked{% endif %}> <span><strong>Nur Freunde</strong><br><small>Username, Profilbild, Konzertanzahl und Kutte bleiben immer sichtbar.</small></span></label>
<label><input type="radio" name="profile_visibility" value="nobody" {% if profile.profile_visibility == 'nobody' %}checked{% endif %}> <span><strong>Niemand</strong><br><small>Zusätzliche Details siehst nur du; Admins behalten administrativen Zugriff.</small></span></label>
</div>
</fieldset>
<label for="avatar">Profilbild</label> <label for="avatar">Profilbild</label>
<input id="avatar" name="avatar" type="file" accept="image/jpeg,image/png,image/webp"> <input id="avatar" name="avatar" type="file" accept="image/jpeg,image/png,image/webp">
<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> <button class="button" type="submit">Profil speichern</button>
</form> </form>
</section> </section>
{% endif %} {% endif %}
</main> </main>
<script>
const profileForm = document.getElementById("profile-form");
if (profileForm) {
profileForm.addEventListener("submit", async event => {
if (profileForm.dataset.optimized === "true") return;
const input = document.getElementById("avatar");
const file = input.files[0];
if (!file) return;
event.preventDefault();
const status = document.getElementById("avatar-upload-status");
const button = profileForm.querySelector('button[type="submit"]');
button.disabled = true;
status.textContent = "Profilbild wird optimiert …";
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, 1000 / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/webp", .86));
if (!blob) throw new Error("Bild konnte nicht verarbeitet werden");
const transfer = new DataTransfer();
transfer.items.add(new File([blob], "profilbild.webp", {type: "image/webp"}));
input.files = transfer.files;
profileForm.dataset.optimized = "true";
status.textContent = `Optimiert: ${Math.ceil(blob.size / 1024)} KB wird hochgeladen …`;
profileForm.requestSubmit();
} catch (error) {
console.error("Avatar optimization failed:", error);
status.textContent = "Das Bild konnte nicht verarbeitet werden. Bitte ein kleineres JPG, PNG oder WebP wählen.";
button.disabled = false;
}
});
}
</script>
</body> </body>
</html> </html>
+2
View File
@@ -75,6 +75,8 @@
type="password" type="password"
name="password" name="password"
required required
minlength="10"
autocomplete="new-password"
> >
<button type="submit"> <button type="submit">
+1
View File
@@ -28,6 +28,7 @@ services:
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL} INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
volumes: volumes:
- concert_uploads:/app/static/uploads - concert_uploads:/app/static/uploads
+55 -1
View File
@@ -5,10 +5,57 @@ CREATE TABLE users (
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
display_name VARCHAR(100), display_name VARCHAR(100),
avatar_path TEXT, avatar_path TEXT,
instagram_url VARCHAR(500),
profile_visibility VARCHAR(20) NOT NULL DEFAULT 'friends'
CHECK (profile_visibility IN ('public', 'friends', 'nobody')),
is_admin BOOLEAN NOT NULL DEFAULT FALSE, is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE account_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose VARCHAR(30) NOT NULL CHECK (purpose = 'password_reset'),
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_account_tokens_lookup
ON account_tokens (token_hash, purpose, expires_at);
CREATE TABLE friendships (
id SERIAL PRIMARY KEY,
requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
addressee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (requester_id <> addressee_id),
UNIQUE (requester_id, addressee_id)
);
CREATE UNIQUE INDEX idx_friendships_pair
ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id));
CREATE TABLE direct_messages (
id SERIAL PRIMARY KEY,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
read_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (sender_id <> recipient_id)
);
CREATE INDEX idx_direct_messages_conversation
ON direct_messages (sender_id, recipient_id, created_at);
CREATE INDEX idx_direct_messages_unread
ON direct_messages (recipient_id, read_at);
CREATE TABLE registration_invites ( CREATE TABLE registration_invites (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
@@ -51,6 +98,9 @@ CREATE TABLE venue_aliases (
CREATE TABLE concerts ( CREATE TABLE concerts (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
artist VARCHAR(255) NOT NULL, artist VARCHAR(255) NOT NULL,
event_type VARCHAR(20) NOT NULL DEFAULT 'concert'
CHECK (event_type IN ('concert', 'festival', 'other')),
parent_event_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL,
venue_id INTEGER REFERENCES venues(id) ON DELETE SET NULL, venue_id INTEGER REFERENCES venues(id) ON DELETE SET NULL,
start_datetime TIMESTAMP NOT NULL, start_datetime TIMESTAMP NOT NULL,
end_datetime TIMESTAMP, end_datetime TIMESTAMP,
@@ -58,6 +108,7 @@ CREATE TABLE concerts (
ticket_url TEXT, ticket_url TEXT,
ticket_price NUMERIC(10,2), ticket_price NUMERIC(10,2),
flyer_path TEXT, flyer_path TEXT,
flyer_url TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -82,7 +133,7 @@ CREATE TABLE concert_attendance (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK ( status VARCHAR(20) NOT NULL CHECK (
status IN ('attending', 'maybe', 'ticket_search') status IN ('attending', 'maybe', 'ticket_search', 'ticket_offer')
), ),
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (concert_id, user_id) PRIMARY KEY (concert_id, user_id)
@@ -107,6 +158,9 @@ CREATE INDEX idx_venues_external_id
CREATE INDEX idx_venue_aliases_alias CREATE INDEX idx_venue_aliases_alias
ON venue_aliases (LOWER(alias)); ON venue_aliases (LOWER(alias));
CREATE INDEX idx_concerts_parent_event_id
ON concerts(parent_event_id);
CREATE INDEX idx_concert_comments_concert CREATE INDEX idx_concert_comments_concert
ON concert_comments(concert_id, created_at); ON concert_comments(concert_id, created_at);
+10
View File
@@ -0,0 +1,10 @@
ALTER TABLE concerts
ADD COLUMN IF NOT EXISTS event_type VARCHAR(20) NOT NULL DEFAULT 'concert'
CHECK (event_type IN ('concert', 'festival', 'other'));
ALTER TABLE concerts
ADD COLUMN IF NOT EXISTS parent_event_id INTEGER
REFERENCES concerts(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_concerts_parent_event_id
ON concerts(parent_event_id);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS instagram_url VARCHAR(500);
@@ -0,0 +1,18 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS profile_visibility VARCHAR(20) NOT NULL DEFAULT 'public'
CHECK (profile_visibility IN ('public', 'friends'));
CREATE TABLE IF NOT EXISTS friendships (
id SERIAL PRIMARY KEY,
requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
addressee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (requester_id <> addressee_id),
UNIQUE (requester_id, addressee_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_friendships_pair
ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id));
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS direct_messages (
id SERIAL PRIMARY KEY,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
read_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (sender_id <> recipient_id)
);
CREATE INDEX IF NOT EXISTS idx_direct_messages_conversation
ON direct_messages (sender_id, recipient_id, created_at);
CREATE INDEX IF NOT EXISTS idx_direct_messages_unread
ON direct_messages (recipient_id, read_at);
@@ -0,0 +1,12 @@
ALTER TABLE users
ALTER COLUMN profile_visibility SET DEFAULT 'friends';
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_profile_visibility_check;
ALTER TABLE users
ADD CONSTRAINT users_profile_visibility_check
CHECK (profile_visibility IN ('public', 'friends', 'nobody'));
ALTER TABLE concerts
ADD COLUMN IF NOT EXISTS flyer_url TEXT;
+28
View File
@@ -0,0 +1,28 @@
ALTER TABLE users
DROP COLUMN IF EXISTS email_verified;
CREATE TABLE IF NOT EXISTS account_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose VARCHAR(30) NOT NULL CHECK (purpose = 'password_reset'),
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
DELETE FROM account_tokens WHERE purpose = 'verify_email';
ALTER TABLE account_tokens DROP CONSTRAINT IF EXISTS account_tokens_purpose_check;
ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_purpose_check
CHECK (purpose = 'password_reset');
CREATE INDEX IF NOT EXISTS idx_account_tokens_lookup
ON account_tokens (token_hash, purpose, expires_at);
ALTER TABLE concert_attendance
DROP CONSTRAINT IF EXISTS concert_attendance_status_check;
ALTER TABLE concert_attendance
ADD CONSTRAINT concert_attendance_status_check
CHECK (status IN ('attending', 'maybe', 'ticket_search', 'ticket_offer'));