Add diary photos and linked patch history

This commit is contained in:
kai
2026-08-29 10:46:48 +02:00
parent 159515b0eb
commit 0b0b19b53a
8 changed files with 190 additions and 41 deletions
+157 -36
View File
@@ -25,7 +25,7 @@ except ImportError:
Image = ImageOps = UnidentifiedImageError = None
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
@@ -64,10 +64,16 @@ PATCH_DIR = os.path.join(
"patches"
)
DIARY_PHOTO_DIR = os.path.join(
os.environ.get("PRIVATE_UPLOAD_DIR", os.path.join(BASE_DIR, "private_uploads")),
"diary"
)
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(PHOTO_DIR, exist_ok=True)
os.makedirs(AVATAR_DIR, exist_ok=True)
os.makedirs(PATCH_DIR, exist_ok=True)
os.makedirs(DIARY_PHOTO_DIR, exist_ok=True)
SESSION_COOKIE = "pingu_session"
SESSION_DAYS = 30
@@ -224,11 +230,15 @@ def ensure_schema():
CREATE TABLE IF NOT EXISTS user_badges (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
badge_code VARCHAR(50) NOT NULL,
trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL,
awarded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, badge_code)
)
""",
"""
ALTER TABLE user_badges ADD COLUMN IF NOT EXISTS trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL
""",
"""
CREATE TABLE IF NOT EXISTS badge_assets (
badge_code VARCHAR(50) PRIMARY KEY,
path TEXT NOT NULL,
@@ -419,12 +429,16 @@ def ensure_schema():
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
favorite_song VARCHAR(255),
notes TEXT,
photo_path TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, concert_id)
)
""",
"""
ALTER TABLE concert_diary ADD COLUMN IF NOT EXISTS photo_path TEXT
""",
"""
CREATE TABLE IF NOT EXISTS concert_bands (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
band_key VARCHAR(255) NOT NULL,
@@ -1313,14 +1327,15 @@ def attended_concert_stats(user_id: int) -> dict:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT concerts.venue_id, concerts.event_type,
concerts.start_datetime::date, COALESCE(venues.country, '')
SELECT concerts.id, concerts.venue_id, concerts.event_type,
concerts.start_datetime, COALESCE(venues.country, '')
FROM concert_attendance
JOIN concerts ON concerts.id = concert_attendance.concert_id
LEFT JOIN venues ON venues.id = concerts.venue_id
WHERE concert_attendance.user_id = %s
AND concert_attendance.status = 'attending'
AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP
ORDER BY concerts.start_datetime, concerts.id
""",
(user_id,),
)
@@ -1330,16 +1345,42 @@ def attended_concert_stats(user_id: int) -> dict:
countries = set()
dates = []
weekend_counts = {}
for venue_id, event_type, concert_date, country_value in rows:
badge_triggers = {}
seen_countries = set()
venue_event_ids = {}
weekend_event_ids = {}
previous_date = None
for concert_id, venue_id, event_type, concert_datetime, country_value in rows:
concert_date = concert_datetime.date()
if venue_id is not None and event_type != "other":
venue_counts[venue_id] = venue_counts.get(venue_id, 0) + 1
venue_event_ids.setdefault(venue_id, []).append(concert_id)
if venue_counts[venue_id] == 5:
badge_triggers.setdefault("regular", concert_id)
country = normalize_country(country_value)
if country:
countries.add(country)
if country not in seen_countries:
seen_countries.add(country)
if country != "deutschland":
badge_triggers.setdefault("border_breaker", concert_id)
if len(seen_countries) == 5:
badge_triggers.setdefault("globe_banger", concert_id)
dates.append(concert_date)
if previous_date and concert_date - previous_date == timedelta(days=1):
badge_triggers.setdefault("double_trouble", concert_id)
previous_date = concert_date
if concert_date.isoweekday() >= 5:
weekend_key = concert_date.isocalendar()[:2]
weekend_counts[weekend_key] = weekend_counts.get(weekend_key, 0) + 1
weekend_event_ids.setdefault(weekend_key, []).append(concert_id)
if weekend_counts[weekend_key] == 3:
badge_triggers.setdefault("iron_weekend", concert_id)
attendance_number = len(dates)
for code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
if category == "attendance" and threshold == attendance_number:
badge_triggers[code] = concert_id
distinct_dates = sorted(set(dates))
return {
@@ -1352,6 +1393,7 @@ def attended_concert_stats(user_id: int) -> dict:
for earlier, later in zip(distinct_dates, distinct_dates[1:])
),
"has_iron_weekend": max(weekend_counts.values(), default=0) >= 3,
"badge_triggers": badge_triggers,
}
@@ -1376,25 +1418,25 @@ def grant_earned_badges(user_id: int, stats: dict, registered_at):
(user_id,),
)
# Konzert-Patches sind eine Level-Leiste: immer nur die höchste
# erreichte Stufe anzeigen (ältere Stufen werden ersetzt).
cursor.execute(
"DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)",
(user_id, list(ATTENDANCE_BADGE_CODES + VENUE_BADGE_CODES)),
)
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
if category == "attendance" and threshold is not None and stats["total"] >= threshold:
cursor.execute(
"""
INSERT INTO user_badges (user_id, badge_code, trigger_concert_id)
VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET
trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id)
""",
(user_id, badge_code, stats["badge_triggers"].get(badge_code)),
)
if highest_attendance_badge:
cursor.execute(
"""
INSERT INTO user_badges (user_id, badge_code)
VALUES (%s, %s)
ON CONFLICT (user_id, badge_code) DO NOTHING
""",
(user_id, highest_attendance_badge),
"UPDATE user_badges SET trigger_concert_id = COALESCE(trigger_concert_id, %s) WHERE user_id = %s AND badge_code = %s",
(stats["badge_triggers"].get(highest_attendance_badge), user_id, highest_attendance_badge),
)
if venue_badge:
cursor.execute(
"INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, venue_badge),
"INSERT INTO user_badges (user_id, badge_code, trigger_concert_id) VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id)",
(user_id, venue_badge, stats["badge_triggers"].get(venue_badge)),
)
achievement_codes = []
if stats["has_foreign_concert"]:
@@ -1407,8 +1449,8 @@ def grant_earned_badges(user_id: int, stats: dict, registered_at):
achievement_codes.append("iron_weekend")
for badge_code in achievement_codes:
cursor.execute(
"INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, badge_code),
"INSERT INTO user_badges (user_id, badge_code, trigger_concert_id) VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id)",
(user_id, badge_code, stats["badge_triggers"].get(badge_code)),
)
connection.commit()
@@ -1438,12 +1480,13 @@ def load_profile(username: str):
return None
cursor.execute(
"SELECT badge_code, awarded_at FROM user_badges WHERE user_id = %s",
"SELECT badge_code, awarded_at, trigger_concert_id FROM user_badges WHERE user_id = %s",
(row[0],),
)
badge_rows = cursor.fetchall()
earned_codes = {badge_row[0] for badge_row in badge_rows}
badge_awarded_at = {badge_row[0]: badge_row[1] for badge_row in badge_rows}
badge_triggers = {badge_row[0]: badge_row[2] for badge_row in badge_rows}
is_founder = row[1].casefold() == "kai"
if is_founder:
@@ -1467,6 +1510,7 @@ def load_profile(username: str):
"is_founder": is_founder,
"earned_codes": earned_codes,
"badge_awarded_at": badge_awarded_at,
"badge_triggers": badge_triggers,
}
@@ -2566,6 +2610,11 @@ def render_profile(
)
profile = load_profile(username)
badge_assets = load_badge_assets()
highest_visible_attendance = next(
(code for code, _name, _icon, threshold, _description, category in reversed(BADGE_DEFINITIONS)
if category == "attendance" and threshold is not None and attendance_stats["total"] >= threshold),
None,
)
badges = [
{
"code": code,
@@ -2574,12 +2623,23 @@ def render_profile(
"threshold": threshold,
"description": description,
"category": category,
"earned": code in profile["earned_codes"],
"earned": code in profile["earned_codes"] and (category != "attendance" or code == highest_visible_attendance),
"image_path": badge_assets.get(code),
"awarded_at": (profile["badge_awarded_at"].get(code) or profile["registered_at"]).strftime("%d.%m.%Y"),
"trigger_concert": None,
"sort_key": (0, datetime.min) if code == "founder" else (1, datetime.min) if code == "admin" else (2, profile["badge_awarded_at"].get(code) or datetime.max),
}
for code, name, icon, threshold, description, category in BADGE_DEFINITIONS
]
for badge in badges:
trigger_id = profile["badge_triggers"].get(badge["code"])
if trigger_id:
trigger_concert = load_concert(trigger_id)
if trigger_concert and can_view_event(viewer, trigger_concert):
badge["trigger_concert"] = {
"id": trigger_id, "artist": trigger_concert["artist"],
"date": trigger_concert["start_datetime"].strftime("%d.%m.%Y"),
}
badges.sort(key=lambda badge: badge["sort_key"])
template = templates.get_template("profile.html")
@@ -2729,14 +2789,14 @@ def export_profile_data(request: Request):
)
followed_venues = cursor.fetchall()
cursor.execute(
"SELECT concert_id, rating, favorite_song, notes, created_at, updated_at FROM concert_diary WHERE user_id = %s ORDER BY updated_at",
"SELECT concert_id, rating, favorite_song, notes, photo_path, created_at, updated_at FROM concert_diary WHERE user_id = %s ORDER BY updated_at",
(user_id,),
)
diary_entries = cursor.fetchall()
cursor.execute(
"""
SELECT badge_code, awarded_at FROM user_badges
SELECT badge_code, awarded_at, trigger_concert_id FROM user_badges
WHERE user_id = %s ORDER BY awarded_at
""",
(user_id,),
@@ -2769,8 +2829,8 @@ def export_profile_data(request: Request):
"photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")),
"followed_bands": rows_to_dicts(followed_bands, ("band_key", "display_name", "created_at")),
"followed_venues": rows_to_dicts(followed_venues, ("venue_id", "created_at")),
"concert_diary": rows_to_dicts(diary_entries, ("concert_id", "rating", "favorite_song", "notes", "created_at", "updated_at")),
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at")),
"concert_diary": rows_to_dicts(diary_entries, ("concert_id", "rating", "favorite_song", "notes", "photo_path", "created_at", "updated_at")),
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at", "trigger_concert_id")),
}
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
return JSONResponse(
@@ -2794,11 +2854,13 @@ def delete_own_account(request: Request):
photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("SELECT flyer_path FROM concerts WHERE created_by = %s AND flyer_path IS NOT NULL", (user_id,))
flyer_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("SELECT photo_path FROM concert_diary WHERE user_id = %s AND photo_path IS NOT NULL", (user_id,))
diary_photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("UPDATE registration_invites SET used_by = NULL WHERE used_by = %s", (user_id,))
cursor.execute("UPDATE concerts SET flyer_path = NULL WHERE created_by = %s", (user_id,))
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit()
for path, directory, prefix in [(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")] + [(p, PHOTO_DIR, "/static/uploads/photos/") for p in photo_paths] + [(p, UPLOAD_DIR, "/static/uploads/flyers/") for p in flyer_paths]:
for path, directory, prefix in [(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")] + [(p, PHOTO_DIR, "/static/uploads/photos/") for p in photo_paths] + [(p, UPLOAD_DIR, "/static/uploads/flyers/") for p in flyer_paths] + [(p, DIARY_PHOTO_DIR, "/diary/photo/") for p in diary_photo_paths]:
remove_uploaded_file(path, directory, prefix)
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax")
@@ -3337,7 +3399,7 @@ def diary_page(request: Request):
"""
SELECT d.concert_id, c.artist, c.start_datetime, d.rating,
COALESCE(d.favorite_song, ''), COALESCE(d.notes, ''),
COALESCE(v.name, ''), COALESCE(v.city, '')
COALESCE(v.name, ''), COALESCE(v.city, ''), d.photo_path
FROM concert_diary d JOIN concerts c ON c.id = d.concert_id
LEFT JOIN venues v ON v.id = c.venue_id
WHERE d.user_id = %s ORDER BY c.start_datetime DESC
@@ -3347,16 +3409,57 @@ def diary_page(request: Request):
entries = [
{"concert_id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
"rating": row[3], "favorite_song": row[4], "notes": row[5],
"venue": ", ".join(filter(None, (row[6], row[7])))}
"venue": ", ".join(filter(None, (row[6], row[7]))), "photo_path": row[8]}
for row in cursor.fetchall()
]
cursor.execute(
"""
SELECT ub.badge_code, ub.trigger_concert_id, ub.awarded_at, ba.path
FROM user_badges ub LEFT JOIN badge_assets ba ON ba.badge_code = ub.badge_code
WHERE ub.user_id = %s AND ub.trigger_concert_id IS NOT NULL
ORDER BY ub.awarded_at
""",
(user["id"],),
)
diary_badges = {}
for badge_code, concert_id, awarded_at, image_path in cursor.fetchall():
definition = BADGE_BY_CODE.get(badge_code)
if definition:
name, icon, _threshold, description, _category = definition
diary_badges.setdefault(concert_id, []).append({
"name": name, "icon": icon, "description": description,
"image_path": image_path, "awarded_at": awarded_at.strftime("%d.%m.%Y"),
})
for entry in entries:
entry["badges"] = diary_badges.get(entry["concert_id"], [])
return templates.get_template("diary.html").render(user=user, entries=entries)
@app.get("/diary/photo/{filename}")
def diary_photo(request: Request, filename: str):
user = get_current_user(request)
if not filename or os.path.basename(filename) != filename:
return HTMLResponse("Bild nicht gefunden.", status_code=404)
photo_path = f"/diary/photo/{filename}"
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"SELECT 1 FROM concert_diary WHERE user_id = %s AND photo_path = %s",
(user["id"], photo_path),
)
if not cursor.fetchone():
return HTMLResponse("Bild nicht gefunden.", status_code=404)
file_path = os.path.join(DIARY_PHOTO_DIR, filename)
if not os.path.isfile(file_path):
return HTMLResponse("Bild nicht gefunden.", status_code=404)
return FileResponse(file_path)
@app.post("/concerts/{concert_id}/diary")
def save_diary_entry(
request: Request, concert_id: int, rating: int = Form(...),
favorite_song: str = Form(""), notes: str = Form("")
favorite_song: str = Form(""), notes: str = Form(""),
photo: UploadFile | None = File(None), remove_photo: str = Form("")
):
user = get_current_user(request)
concert = load_concert(concert_id)
@@ -3376,17 +3479,30 @@ def save_diary_entry(
)
if not cursor.fetchone():
return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403)
cursor.execute(
"SELECT photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s",
(user["id"], concert_id),
)
previous_row = cursor.fetchone()
previous_photo = previous_row[0] if previous_row else None
new_photo, error = save_image(photo, DIARY_PHOTO_DIR, "/diary/photo/")
if error:
return error
photo_path = new_photo or (None if remove_photo else previous_photo)
cursor.execute(
"""
INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes)
VALUES (%s, %s, %s, %s, %s)
INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes, photo_path)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (user_id, concert_id) DO UPDATE SET
rating = EXCLUDED.rating, favorite_song = EXCLUDED.favorite_song,
notes = EXCLUDED.notes, updated_at = CURRENT_TIMESTAMP
notes = EXCLUDED.notes, photo_path = EXCLUDED.photo_path,
updated_at = CURRENT_TIMESTAMP
""",
(user["id"], concert_id, rating, favorite_song or None, notes or None),
(user["id"], concert_id, rating, favorite_song or None, notes or None, photo_path),
)
connection.commit()
if previous_photo and previous_photo != photo_path:
remove_uploaded_file(previous_photo, DIARY_PHOTO_DIR, "/diary/photo/")
return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303)
@@ -3395,8 +3511,12 @@ def delete_diary_entry(request: Request, concert_id: int):
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
row = cursor.fetchone()
cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
connection.commit()
if row and row[0]:
remove_uploaded_file(row[0], DIARY_PHOTO_DIR, "/diary/photo/")
return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303)
@@ -3533,7 +3653,7 @@ def concert_detail(request: Request, concert_id: int):
else:
follows_venue = False
cursor.execute(
"SELECT rating, COALESCE(favorite_song, ''), COALESCE(notes, '') FROM concert_diary WHERE user_id = %s AND concert_id = %s",
"SELECT rating, COALESCE(favorite_song, ''), COALESCE(notes, ''), photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s",
(user["id"], concert_id),
)
diary_row = cursor.fetchone()
@@ -3577,7 +3697,8 @@ def concert_detail(request: Request, concert_id: int):
None,
)
diary_entry = {
"rating": diary_row[0], "favorite_song": diary_row[1], "notes": diary_row[2]
"rating": diary_row[0], "favorite_song": diary_row[1], "notes": diary_row[2],
"photo_path": diary_row[3]
} if diary_row else None
concert_bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"])
for band in concert_bands:
+7 -1
View File
@@ -392,7 +392,7 @@
<h2>📓 Mein Konzerttagebuch</h2>
{% if can_write_diary %}
<p>Dieser Eintrag ist privat und nur für dich sichtbar.</p>
<form class="diary-form" method="post" action="/concerts/{{ concert.id }}/diary">
<form class="diary-form" method="post" action="/concerts/{{ concert.id }}/diary" enctype="multipart/form-data">
<label for="diary-rating">Bewertung</label>
<select id="diary-rating" name="rating" required>
{% for value in range(1, 6) %}<option value="{{ value }}" {% if diary_entry and diary_entry.rating == value %}selected{% endif %}>{{ value }} von 5 Sternen</option>{% endfor %}
@@ -401,6 +401,12 @@
<input id="favorite-song" name="favorite_song" maxlength="255" value="{{ diary_entry.favorite_song if diary_entry else '' }}">
<label for="diary-notes">Erinnerungen <small>(optional)</small></label>
<textarea id="diary-notes" name="notes" maxlength="5000" placeholder="Was ist dir von diesem Abend geblieben?">{{ diary_entry.notes if diary_entry else '' }}</textarea>
<label for="diary-photo">Erinnerungsfoto <small>(optional, JPG, PNG oder WEBP, max. 10 MB)</small></label>
{% if diary_entry and diary_entry.photo_path %}
<img src="{{ diary_entry.photo_path }}" alt="Erinnerungsfoto zu {{ concert.artist }}" style="display:block;max-width:min(100%,520px);max-height:420px;object-fit:cover;border-radius:10px;margin-bottom:8px">
<label><input type="checkbox" name="remove_photo" value="1" style="width:auto"> Vorhandenes Foto entfernen</label>
{% endif %}
<input id="diary-photo" name="photo" type="file" accept="image/jpeg,image/png,image/webp">
<button class="attendance-option" type="submit">{% if diary_entry %}Eintrag aktualisieren{% else %}Im Tagebuch speichern{% endif %}</button>
</form>
{% if diary_entry %}<form method="post" action="/concerts/{{ concert.id }}/diary/delete" onsubmit="return confirm('Tagebucheintrag wirklich löschen?');"><button class="button button-secondary" type="submit">Tagebucheintrag löschen</button></form>{% endif %}
+2 -2
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Konzerttagebuch · MetalCircle</title><link rel="stylesheet" href="/static/css/style.css"><style>
.diary-list{display:grid;gap:14px}.diary-entry{padding:18px;background:linear-gradient(145deg,#101010,#211111);border:1px solid var(--border);border-left:3px solid #b91c1c;border-radius:12px}.diary-entry h2{margin:0 0 5px}.diary-meta{color:var(--muted)}.diary-rating{color:#fbbf24;font-size:1.2rem}.diary-notes{white-space:pre-wrap}.diary-song{color:#fca5a5}
.diary-list{display:grid;gap:14px}.diary-entry{padding:18px;background:linear-gradient(145deg,#101010,#211111);border:1px solid var(--border);border-left:3px solid #b91c1c;border-radius:12px}.diary-entry h2{margin:0 0 5px}.diary-meta{color:var(--muted)}.diary-rating{color:#fbbf24;font-size:1.2rem}.diary-notes{white-space:pre-wrap}.diary-song{color:#fca5a5}.diary-photo{display:block;width:100%;max-width:680px;max-height:520px;object-fit:cover;border-radius:10px;margin:12px 0}.diary-patches{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.diary-patch{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#080808;border:1px solid #7f1d1d;border-radius:9px}.diary-patch img{width:48px;height:48px;object-fit:contain}.diary-patch-icon{font-size:1.7rem}
</style></head><body><header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header><main>
<div class="page-title"><h1>📓 Konzerttagebuch</h1><p>Deine privaten Erinnerungen an besuchte Konzerte.</p></div><div class="diary-list">
{% for entry in entries %}<article class="diary-entry"><h2><a href="/concerts/{{ entry.concert_id }}">{{ entry.artist }}</a></h2><p class="diary-meta">{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}</p><div class="diary-rating" aria-label="{{ entry.rating }} von 5 Sternen">{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}</div>{% if entry.favorite_song %}<p class="diary-song">🎵 Lieblingssong: {{ entry.favorite_song }}</p>{% endif %}{% if entry.notes %}<p class="diary-notes">{{ entry.notes }}</p>{% endif %}</article>
{% for entry in entries %}<article class="diary-entry"><h2><a href="/concerts/{{ entry.concert_id }}">{{ entry.artist }}</a></h2><p class="diary-meta">{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}</p><div class="diary-rating" aria-label="{{ entry.rating }} von 5 Sternen">{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}</div>{% if entry.photo_path %}<img class="diary-photo" src="{{ entry.photo_path }}" alt="Erinnerungsfoto zu {{ entry.artist }}" loading="lazy">{% endif %}{% if entry.favorite_song %}<p class="diary-song">🎵 Lieblingssong: {{ entry.favorite_song }}</p>{% endif %}{% if entry.notes %}<p class="diary-notes">{{ entry.notes }}</p>{% endif %}{% if entry.badges %}<div class="diary-patches">{% for badge in entry.badges %}<div class="diary-patch" title="{{ badge.description }}">{% if badge.image_path %}<img src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">{% else %}<span class="diary-patch-icon">{{ badge.icon }}</span>{% endif %}<span><strong>{{ badge.name }}</strong><br><small>Hier erhalten · {{ badge.awarded_at }}</small></span></div>{% endfor %}</div>{% endif %}</article>
{% else %}<section class="empty"><p>Noch keine Einträge. Markiere ein vergangenes Konzert als besucht und halte dort deine Erinnerung fest.</p></section>{% endfor %}</div>
</main></body></html>
+15 -2
View File
@@ -34,6 +34,11 @@
.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: 0; background: transparent; box-shadow: 2px 3px 7px rgba(0,0,0,.7); }
.patch-button { padding:0; border:0; background:transparent; color:inherit; cursor:pointer; }
.patch-dialog { width:min(92vw,460px); padding:24px; color:#f8fafc; background:#171717; border:1px solid var(--border); border-radius:14px; box-shadow:0 20px 60px #000; }
.patch-dialog::backdrop { background:rgba(0,0,0,.78); }
.patch-dialog-preview { max-width:150px; max-height:150px; object-fit:contain; }
.patch-dialog-close { float:right; margin:0; }
.profile-edit { margin-top: 24px; }
.account-actions { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-top:22px; padding-top:16px; border-top:1px solid var(--border); }
.account-delete { flex:1; color:var(--muted); }
@@ -127,13 +132,21 @@
<div class="patch-grid">
{% for badge in badges %}
{% if badge.earned %}
<div class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} {{ badge.description }}" aria-label="{{ badge.name }}: {{ badge.description }}">
<button class="patch-button" type="button" onclick="document.getElementById('patch-{{ badge.code }}').showModal()" aria-label="Details zu {{ badge.name }} anzeigen">
<span class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} {{ badge.description }}">
{% if badge.image_path %}
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">
{% else %}
<span class="patch-icon">{{ badge.icon }}</span>
{% endif %}
</div>
</span></button>
<dialog class="patch-dialog" id="patch-{{ badge.code }}">
<form method="dialog"><button class="button button-secondary patch-dialog-close" aria-label="Schließen">×</button></form>
{% if badge.image_path %}<img class="patch-dialog-preview" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">{% else %}<div class="patch-icon">{{ badge.icon }}</div>{% endif %}
<h3>{{ badge.name }}</h3><p>{{ badge.description }}</p>
<p><strong>Erhalten am:</strong> {{ badge.awarded_at }}</p>
{% if badge.trigger_concert %}<p><strong>Ausgelöst durch:</strong> <a href="/concerts/{{ badge.trigger_concert.id }}">{{ badge.trigger_concert.artist }} · {{ badge.trigger_concert.date }}</a></p>{% else %}<p><strong>Grund:</strong> {{ badge.description }}</p>{% endif %}
</dialog>
{% endif %}
{% endfor %}