Add social features and harden beta release
This commit is contained in:
@@ -12,6 +12,7 @@ env/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
compose.dev.yml
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
||||
|
||||
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 . .
|
||||
|
||||
|
||||
+1006
-72
File diff suppressed because it is too large
Load Diff
+27
-10
@@ -1,12 +1,12 @@
|
||||
:root {
|
||||
--bg: #0b0f19;
|
||||
--surface: #151b29;
|
||||
--surface-hover: #1b2333;
|
||||
--border: #293449;
|
||||
--text: #f3f4f6;
|
||||
--muted: #9ca3af;
|
||||
--accent: #8b5cf6;
|
||||
--accent-hover: #7c3aed;
|
||||
--bg: #050505;
|
||||
--surface: #101010;
|
||||
--surface-hover: #1a1111;
|
||||
--border: #3a3030;
|
||||
--text: #f5f5f4;
|
||||
--muted: #a8a29e;
|
||||
--accent: #b91c1c;
|
||||
--accent-hover: #dc2626;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -23,7 +23,7 @@ body {
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
|
||||
background: var(--bg);
|
||||
background: radial-gradient(circle at 50% -15%, #320909 0, transparent 32rem), var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ a {
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(21, 27, 41, 0.95);
|
||||
background: rgba(8, 8, 8, 0.96);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -54,12 +54,27 @@ header {
|
||||
.logo {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
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 {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
@@ -73,6 +88,8 @@ main {
|
||||
.page-title h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -93,6 +93,8 @@ a {
|
||||
width: 100%;
|
||||
background: #0a0d12;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -15,7 +15,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<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' %}
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
<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; }
|
||||
.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; }
|
||||
.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; } }
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<div class="page-title"><h1>⚙️ Verwaltung</h1><p>Benutzerkonten und Einladungen verwalten.</p></div>
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
</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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -13,19 +13,37 @@
|
||||
.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); }
|
||||
.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: 560px) { .venue-fields, .merge-form { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<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' %}
|
||||
<section class="admin-section">
|
||||
<div class="admin-list">
|
||||
{% 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 }}">
|
||||
<div class="venue-fields">
|
||||
<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?');">
|
||||
<button class="admin-danger" type="submit">Ort löschen{% if venue.concert_count %} ({{ venue.concert_count }} Verknüpfung(en)){% endif %}</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</details>
|
||||
{% else %}<p>Noch keine Veranstaltungsorte vorhanden.</p>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -17,28 +17,44 @@
|
||||
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>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<a href="/" class="back-link">
|
||||
← Zurück zum Kalender
|
||||
</a>
|
||||
<div class="detail-topbar">
|
||||
<a href="/" class="back-link">← Zurück zum Kalender</a>
|
||||
{% include '_user_menu.html' %}
|
||||
</div>
|
||||
|
||||
<article class="concert-detail">
|
||||
|
||||
{% if concert.flyer_path %}
|
||||
{% if concert.flyer_url or concert.flyer_path %}
|
||||
|
||||
<div class="flyer-container">
|
||||
|
||||
<img
|
||||
src="{{ concert.flyer_path }}"
|
||||
src="{{ concert.flyer_url or concert.flyer_path }}"
|
||||
alt="Flyer {{ concert.artist }}"
|
||||
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>
|
||||
|
||||
{% endif %}
|
||||
@@ -46,6 +62,8 @@
|
||||
|
||||
<div class="concert-content">
|
||||
|
||||
<span class="event-type-badge">{{ concert.event_type_label }}</span>
|
||||
|
||||
<h1>
|
||||
{{ concert.artist }}
|
||||
</h1>
|
||||
@@ -69,6 +87,11 @@
|
||||
|
||||
{{ 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>
|
||||
@@ -96,12 +119,19 @@
|
||||
|
||||
|
||||
{% 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>
|
||||
|
||||
{{ concert.venue.postal_code }}
|
||||
{{ 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 %}
|
||||
|
||||
@@ -136,13 +166,22 @@
|
||||
|
||||
</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 %}
|
||||
|
||||
<section class="description">
|
||||
|
||||
<h2>
|
||||
Über das Konzert
|
||||
Über die Veranstaltung
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
@@ -173,9 +212,9 @@
|
||||
|
||||
{% 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">
|
||||
✏️ Konzert bearbeiten
|
||||
✏️ Veranstaltung bearbeiten
|
||||
</a>
|
||||
|
||||
{% if user.is_admin and can_delete %}
|
||||
@@ -183,10 +222,10 @@
|
||||
<form
|
||||
method="post"
|
||||
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">
|
||||
🗑️ Konzert löschen
|
||||
🗑️ Veranstaltung löschen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -234,7 +273,17 @@
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
|
||||
@@ -245,6 +294,7 @@
|
||||
{{ attending_count }} Zusage{% if attending_count != 1 %}n{% endif %}
|
||||
· {{ maybe_count }} Vielleicht
|
||||
· {{ ticket_seeker_count }} sucht Ticket
|
||||
· {{ ticket_offer_count }} bietet Ticket
|
||||
</summary>
|
||||
|
||||
<div class="attendance-groups">
|
||||
@@ -278,7 +328,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>🎟️ Sucht ein Ticket</h3>
|
||||
<h3>🔎 Sucht ein Ticket</h3>
|
||||
|
||||
{% if ticket_seekers %}
|
||||
<ul>
|
||||
@@ -291,6 +341,20 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
</details>
|
||||
|
||||
|
||||
@@ -10,17 +10,39 @@
|
||||
<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>✏️ Konzert bearbeiten</h1>
|
||||
<p>Änderungen werden direkt beim Konzert gespeichert.</p>
|
||||
<h1>✏️ Veranstaltung bearbeiten</h1>
|
||||
<p>Änderungen werden direkt bei der Veranstaltung gespeichert.</p>
|
||||
</div>
|
||||
<section class="empty">
|
||||
<form method="post" action="/concerts/{{ concert.id }}/edit" enctype="multipart/form-data">
|
||||
{% if can_edit_details %}
|
||||
<p>
|
||||
<label>Künstler / Band<br>
|
||||
<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>
|
||||
<label>Titel / Künstler / Band<br>
|
||||
<input type="text" name="artist" value="{{ concert.artist }}" {% if not can_edit_title %}disabled{% endif %} required>
|
||||
</label>
|
||||
</p>
|
||||
@@ -53,9 +75,9 @@
|
||||
<input type="datetime-local" name="start_datetime" value="{{ concert.start_local }}" required>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<p id="festival-end-field" {% if concert.event_type != 'festival' %}hidden{% endif %}>
|
||||
<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>
|
||||
</p>
|
||||
<p>
|
||||
@@ -73,14 +95,20 @@
|
||||
<input type="number" name="ticket_price" value="{{ concert.ticket_price or '' }}" step="0.01" min="0">
|
||||
</label>
|
||||
</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>
|
||||
<label>Neuen Flyer hochladen<br>
|
||||
<input type="file" name="flyer" accept="image/jpeg,image/png,image/webp">
|
||||
</label>
|
||||
<small>Ohne neue Datei bleibt der bisherige Flyer erhalten.</small>
|
||||
</p>
|
||||
{% if concert.flyer_path %}
|
||||
<p><img src="{{ concert.flyer_path }}" alt="Aktueller Flyer" style="max-width: 300px; max-height: 400px; border-radius: 10px;"></p>
|
||||
{% if concert.flyer_url or concert.flyer_path %}
|
||||
<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 %}
|
||||
<div>
|
||||
@@ -90,5 +118,26 @@
|
||||
</form>
|
||||
</section>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
+118
-55
@@ -78,6 +78,33 @@
|
||||
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 {
|
||||
margin: 28px 0 0;
|
||||
}
|
||||
@@ -127,6 +154,33 @@
|
||||
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) {
|
||||
|
||||
.page-header {
|
||||
@@ -139,12 +193,31 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.linked-events { margin-left: 16px; }
|
||||
.search-form { grid-template-columns: 1fr; }
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
</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>
|
||||
|
||||
@@ -155,11 +228,11 @@
|
||||
<div>
|
||||
|
||||
<h1>
|
||||
🐧 Pingu Concerts
|
||||
{% if archive %}☠ Vergangene Veranstaltungen{% else %}🐧 Pingu Concerts{% endif %}
|
||||
</h1>
|
||||
|
||||
<div class="subtitle">
|
||||
Unser Konzertkalender
|
||||
{% if archive %}Das Archiv vergangener Nächte{% else %}Konzerte, Festivals und alles davor{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -167,8 +240,8 @@
|
||||
|
||||
<div class="header-actions">
|
||||
|
||||
<a href="/profile" class="admin-button">
|
||||
👤 Mein Profil
|
||||
<a href="{% if archive %}/{% else %}/events/past{% endif %}" class="admin-button">
|
||||
{% if archive %}⚡ Bevorstehend{% else %}☠ Vergangene{% endif %}
|
||||
</a>
|
||||
|
||||
{% if user.is_admin %}
|
||||
@@ -183,80 +256,70 @@
|
||||
href="/concerts/new"
|
||||
class="new-concert-button"
|
||||
>
|
||||
+ Konzert hinzufügen
|
||||
+ Veranstaltung hinzufügen
|
||||
</a>
|
||||
|
||||
{% include '_user_menu.html' %}
|
||||
|
||||
</div>
|
||||
|
||||
</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 %}
|
||||
|
||||
<div class="concert-list">
|
||||
|
||||
{% for concert in upcoming_concerts %}
|
||||
|
||||
<a
|
||||
href="/concerts/{{ concert.id }}"
|
||||
class="concert-card"
|
||||
>
|
||||
|
||||
<div class="concert-artist">
|
||||
🎸 {{ concert.artist }}
|
||||
<div class="event-group">
|
||||
{{ event_card(concert) }}
|
||||
{% if concert.children %}
|
||||
<div class="linked-events">
|
||||
{% for child in concert.children %}{{ event_card(child, true) }}{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="concert-meta">
|
||||
|
||||
<div class="concert-date">
|
||||
📅 {{ concert.date }}
|
||||
um {{ concert.time }} Uhr
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
📍 {{ concert.venue }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</a>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% if past_concerts %}
|
||||
{% if archive and past_concerts %}
|
||||
|
||||
<h2 class="past-concerts-title">
|
||||
Vergangene Konzerte
|
||||
Archiv
|
||||
</h2>
|
||||
|
||||
{% for concert in past_concerts %}
|
||||
|
||||
<a
|
||||
href="/concerts/{{ concert.id }}"
|
||||
class="concert-card"
|
||||
>
|
||||
|
||||
<div class="concert-artist">
|
||||
🎸 {{ concert.artist }}
|
||||
<div class="event-group">
|
||||
{{ event_card(concert) }}
|
||||
{% if concert.children %}
|
||||
<div class="linked-events">
|
||||
{% for child in concert.children %}{{ event_card(child, true) }}{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="concert-meta">
|
||||
|
||||
<div class="concert-date">
|
||||
📅 {{ concert.date }}
|
||||
um {{ concert.time }} Uhr
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
📍 {{ concert.venue }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</a>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
@@ -268,11 +331,11 @@
|
||||
<div class="empty-state">
|
||||
|
||||
<h2>
|
||||
Noch keine Konzerte 🎸
|
||||
{% if archive %}Noch keine vergangenen Veranstaltungen ☠{% else %}Noch keine Veranstaltungen 🎸{% endif %}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Leg das erste Konzert an!
|
||||
{% if archive %}Hier erscheinen abgeschlossene Veranstaltungen.{% else %}Leg die erste Veranstaltung an!{% endif %}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
</label>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -10,7 +10,7 @@
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
>
|
||||
|
||||
<title>Konzert hinzufügen · Pingu Concerts</title>
|
||||
<title>Veranstaltung hinzufügen · Pingu Concerts</title>
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
@@ -78,6 +78,8 @@
|
||||
🎸 Pingu <span>Concerts</span>
|
||||
</a>
|
||||
|
||||
{% include '_user_menu.html' %}
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
@@ -87,7 +89,7 @@
|
||||
|
||||
<div class="page-title">
|
||||
|
||||
<h1>🎤 Konzert hinzufügen</h1>
|
||||
<h1>🎤 Veranstaltung hinzufügen</h1>
|
||||
|
||||
<p>
|
||||
Trag ein, worauf wir uns freuen.
|
||||
@@ -104,6 +106,26 @@
|
||||
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 -->
|
||||
<!-- ================================================= -->
|
||||
@@ -111,7 +133,7 @@
|
||||
<p>
|
||||
|
||||
<label>
|
||||
Künstler / Band<br>
|
||||
Titel / Künstler / Band<br>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
@@ -124,7 +146,6 @@
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
<!-- ================================================= -->
|
||||
<!-- Veranstaltungsort -->
|
||||
<!-- ================================================= -->
|
||||
@@ -208,6 +229,13 @@
|
||||
|
||||
</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 -->
|
||||
@@ -221,7 +249,7 @@
|
||||
<textarea
|
||||
name="description"
|
||||
rows="5"
|
||||
placeholder="Optional: Infos zum Konzert, Tour, Supportbands usw."
|
||||
placeholder="Optional: Infos zur Veranstaltung, Tour, Supportbands usw."
|
||||
></textarea>
|
||||
|
||||
</label>
|
||||
@@ -277,9 +305,13 @@
|
||||
|
||||
<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">
|
||||
|
||||
🎨 Konzert-Flyer
|
||||
🎨 Veranstaltungs-Flyer
|
||||
|
||||
</label>
|
||||
|
||||
@@ -294,7 +326,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -330,7 +362,7 @@
|
||||
type="submit"
|
||||
class="button"
|
||||
>
|
||||
💾 Konzert speichern
|
||||
💾 Veranstaltung speichern
|
||||
</button>
|
||||
|
||||
|
||||
@@ -352,6 +384,25 @@
|
||||
|
||||
<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
|
||||
============================================================ */
|
||||
|
||||
@@ -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
@@ -13,9 +13,15 @@
|
||||
.avatar-fallback { display: grid; place-items: center; background: #283048; font-size: 3rem; font-weight: 800; }
|
||||
.profile-name { margin: 0; font-size: 1.7rem; }
|
||||
.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 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 { left: 0; border-radius: 24px 0 45% 0; }
|
||||
.kutte::after { right: 0; border-radius: 0 24px 0 45%; }
|
||||
@@ -26,10 +32,25 @@
|
||||
.patch-icon { font-size: 2.5rem; }
|
||||
.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.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 form { margin: 0; max-width: none; }
|
||||
.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; } }
|
||||
</style>
|
||||
</head>
|
||||
@@ -37,7 +58,7 @@
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>
|
||||
<a href="/profile">Mein Profil</a>
|
||||
{% include '_user_menu.html' %}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
@@ -54,8 +75,30 @@
|
||||
{% endif %}
|
||||
<h2 class="profile-name">{{ profile.display_name }}</h2>
|
||||
<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>
|
||||
{% 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 class="badges-card">
|
||||
<h2>🥋 Virtuelle Kutte</h2>
|
||||
@@ -81,18 +124,110 @@
|
||||
</div>
|
||||
</section>
|
||||
</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 %}
|
||||
<section class="profile-edit">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
minlength="10"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
|
||||
<button type="submit">
|
||||
|
||||
@@ -28,6 +28,7 @@ services:
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
|
||||
volumes:
|
||||
- concert_uploads:/app/static/uploads
|
||||
|
||||
+55
-1
@@ -5,10 +5,57 @@ CREATE TABLE users (
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
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,
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
@@ -51,6 +98,9 @@ CREATE TABLE venue_aliases (
|
||||
CREATE TABLE concerts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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,
|
||||
start_datetime TIMESTAMP NOT NULL,
|
||||
end_datetime TIMESTAMP,
|
||||
@@ -58,6 +108,7 @@ CREATE TABLE concerts (
|
||||
ticket_url TEXT,
|
||||
ticket_price NUMERIC(10,2),
|
||||
flyer_path TEXT,
|
||||
flyer_url TEXT,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
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,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
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,
|
||||
PRIMARY KEY (concert_id, user_id)
|
||||
@@ -107,6 +158,9 @@ CREATE INDEX idx_venues_external_id
|
||||
CREATE INDEX idx_venue_aliases_alias
|
||||
ON venue_aliases (LOWER(alias));
|
||||
|
||||
CREATE INDEX idx_concerts_parent_event_id
|
||||
ON concerts(parent_event_id);
|
||||
|
||||
CREATE INDEX idx_concert_comments_concert
|
||||
ON concert_comments(concert_id, created_at);
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
Reference in New Issue
Block a user