Automate diary entries and add photo galleries

This commit is contained in:
kai
2026-08-29 11:58:56 +02:00
parent 0b0b19b53a
commit b2af6c6857
7 changed files with 238 additions and 62 deletions
+105 -32
View File
@@ -426,7 +426,7 @@ def ensure_schema():
CREATE TABLE IF NOT EXISTS concert_diary (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
rating SMALLINT CHECK (rating BETWEEN 1 AND 5),
favorite_song VARCHAR(255),
notes TEXT,
photo_path TEXT,
@@ -439,6 +439,27 @@ def ensure_schema():
ALTER TABLE concert_diary ADD COLUMN IF NOT EXISTS photo_path TEXT
""",
"""
ALTER TABLE concert_diary ALTER COLUMN rating DROP NOT NULL
""",
"""
CREATE TABLE IF NOT EXISTS concert_diary_photos (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
path TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE INDEX IF NOT EXISTS idx_concert_diary_photos_entry
ON concert_diary_photos (user_id, concert_id, created_at)
""",
"""
INSERT INTO concert_diary_photos (user_id, concert_id, path)
SELECT user_id, concert_id, photo_path FROM concert_diary WHERE photo_path IS NOT NULL
ON CONFLICT (path) DO NOTHING
""",
"""
CREATE TABLE IF NOT EXISTS concert_bands (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
band_key VARCHAR(255) NOT NULL,
@@ -2793,6 +2814,11 @@ def export_profile_data(request: Request):
(user_id,),
)
diary_entries = cursor.fetchall()
cursor.execute(
"SELECT id, concert_id, path, created_at FROM concert_diary_photos WHERE user_id = %s ORDER BY created_at",
(user_id,),
)
diary_photos = cursor.fetchall()
cursor.execute(
"""
@@ -2830,6 +2856,7 @@ def export_profile_data(request: Request):
"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", "photo_path", "created_at", "updated_at")),
"concert_diary_photos": rows_to_dicts(diary_photos, ("id", "concert_id", "path", "created_at")),
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at", "trigger_concert_id")),
}
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
@@ -2854,7 +2881,7 @@ 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,))
cursor.execute("SELECT path FROM concert_diary_photos WHERE user_id = %s", (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,))
@@ -3395,11 +3422,22 @@ def diary_page(request: Request):
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO concert_diary (user_id, concert_id)
SELECT ca.user_id, ca.concert_id
FROM concert_attendance ca JOIN concerts c ON c.id = ca.concert_id
WHERE ca.user_id = %s AND ca.status = 'attending'
AND COALESCE(c.end_datetime, c.start_datetime) < CURRENT_TIMESTAMP
ON CONFLICT (user_id, concert_id) DO NOTHING
""",
(user["id"],),
)
cursor.execute(
"""
SELECT d.concert_id, c.artist, c.start_datetime, d.rating,
COALESCE(d.favorite_song, ''), COALESCE(d.notes, ''),
COALESCE(v.name, ''), COALESCE(v.city, ''), d.photo_path
COALESCE(v.name, ''), COALESCE(v.city, '')
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
@@ -3409,9 +3447,17 @@ 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]))), "photo_path": row[8]}
"venue": ", ".join(filter(None, (row[6], row[7]))), "photos": []}
for row in cursor.fetchall()
]
entry_by_concert = {entry["concert_id"]: entry for entry in entries}
cursor.execute(
"SELECT id, concert_id, path FROM concert_diary_photos WHERE user_id = %s ORDER BY created_at, id",
(user["id"],),
)
for photo_id, concert_id, path in cursor.fetchall():
if concert_id in entry_by_concert:
entry_by_concert[concert_id]["photos"].append({"id": photo_id, "path": path})
cursor.execute(
"""
SELECT ub.badge_code, ub.trigger_concert_id, ub.awarded_at, ba.path
@@ -3432,6 +3478,7 @@ def diary_page(request: Request):
})
for entry in entries:
entry["badges"] = diary_badges.get(entry["concert_id"], [])
connection.commit()
return templates.get_template("diary.html").render(user=user, entries=entries)
@@ -3444,7 +3491,7 @@ def diary_photo(request: Request, filename: str):
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",
"SELECT 1 FROM concert_diary_photos WHERE user_id = %s AND path = %s",
(user["id"], photo_path),
)
if not cursor.fetchone():
@@ -3457,16 +3504,20 @@ def diary_photo(request: Request, filename: str):
@app.post("/concerts/{concert_id}/diary")
def save_diary_entry(
request: Request, concert_id: int, rating: int = Form(...),
request: Request, concert_id: int, rating: str = Form(""),
favorite_song: str = Form(""), notes: str = Form(""),
photo: UploadFile | None = File(None), remove_photo: str = Form("")
photos: list[UploadFile] = File(default=[])
):
user = get_current_user(request)
concert = load_concert(concert_id)
if not concert or not can_view_event(user, concert):
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
if not concert["is_past"] or rating not in range(1, 6):
return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer Bewertung von 1 bis 5 verfügbar.", status_code=400)
try:
rating_value = int(rating) if rating else None
except ValueError:
rating_value = None
if not concert["is_past"] or (rating_value is not None and rating_value not in range(1, 6)):
return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar.", status_code=400)
favorite_song = favorite_song.strip()
notes = notes.strip()
if len(favorite_song) > 255 or len(notes) > 5000:
@@ -3479,31 +3530,47 @@ 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("SELECT COUNT(*) FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
existing_photo_count = cursor.fetchone()[0]
uploads = [photo for photo in photos if photo and photo.filename]
if existing_photo_count + len(uploads) > 3:
return HTMLResponse("Pro Tagebucheintrag sind maximal drei Bilder möglich.", status_code=400)
saved_paths = []
for photo in uploads:
saved_path, error = save_image(photo, DIARY_PHOTO_DIR, "/diary/photo/")
if error:
for path in saved_paths:
remove_uploaded_file(path, DIARY_PHOTO_DIR, "/diary/photo/")
return error
saved_paths.append(saved_path)
cursor.execute(
"""
INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes, photo_path)
VALUES (%s, %s, %s, %s, %s, %s)
INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes)
VALUES (%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, photo_path = EXCLUDED.photo_path,
updated_at = CURRENT_TIMESTAMP
notes = EXCLUDED.notes, updated_at = CURRENT_TIMESTAMP
""",
(user["id"], concert_id, rating, favorite_song or None, notes or None, photo_path),
(user["id"], concert_id, rating_value, favorite_song or None, notes or None),
)
for path in saved_paths:
cursor.execute("INSERT INTO concert_diary_photos (user_id, concert_id, path) VALUES (%s, %s, %s)", (user["id"], concert_id, 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)
return RedirectResponse(f"/diary#concert-{concert_id}", status_code=303)
@app.post("/diary/photos/{photo_id}/delete")
def delete_diary_photo(request: Request, photo_id: int):
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM concert_diary_photos WHERE id = %s AND user_id = %s RETURNING path, concert_id", (photo_id, user["id"]))
deleted = cursor.fetchone()
connection.commit()
if not deleted:
return HTMLResponse("Bild nicht gefunden.", status_code=404)
remove_uploaded_file(deleted[0], DIARY_PHOTO_DIR, "/diary/photo/")
return RedirectResponse(f"/diary#concert-{deleted[1]}", status_code=303)
@app.post("/concerts/{concert_id}/diary/delete")
@@ -3511,13 +3578,14 @@ 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("SELECT path FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("DELETE FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
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)
for path in photo_paths:
remove_uploaded_file(path, DIARY_PHOTO_DIR, "/diary/photo/")
return RedirectResponse("/diary", status_code=303)
# ============================================================
@@ -4190,6 +4258,11 @@ def set_attendance(
""",
(concert_id, user["id"], status),
)
if status == "attending" and concert["is_past"]:
cursor.execute(
"INSERT INTO concert_diary (user_id, concert_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user["id"], concert_id),
)
connection.commit()
return RedirectResponse(f"/concerts/{concert_id}#attendance", status_code=303)
+2 -2
View File
@@ -2,9 +2,9 @@
<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="/following">Gefolgte Bands &amp; Locations</a>
<a href="/diary">Konzerttagebuch</a>
<a href="/messages">Nachrichten{% if user.notification_count %} <span class="metal-notification" title="Neue Nachrichten oder Anfragen" aria-label="Neue Nachrichten oder Anfragen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</a>
<a href="/diary">Konzerttagebuch</a>
<a href="/following">Gefolgte Bands &amp; Locations</a>
{% if user.is_admin %}<a href="/admin">⚙️ Verwaltung</a>{% endif %}
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
+2 -20
View File
@@ -391,27 +391,9 @@
<section class="diary-section" id="diary">
<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" 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 %}
</select>
<label for="favorite-song">Lieblingssong <small>(optional)</small></label>
<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 %}
<p>Der private Eintrag wurde automatisch angelegt. <a class="button" href="/diary#concert-{{ concert.id }}">Im Konzerttagebuch bearbeiten</a></p>
{% else %}
<p>Das private Tagebuch ist verfügbar, wenn du dieses vergangene Konzert als „Zugesagt“ markiert hattest.</p>
<p>Ein Tagebucheintrag wird automatisch angelegt, sobald du das vergangene Konzert als „Zugesagt“ markierst.</p>
{% endif %}
</section>
{% endif %}
+91 -7
View File
@@ -1,7 +1,91 @@
<!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-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.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>
<!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-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:14px 0}.diary-gallery-item{position:relative;aspect-ratio:4/3;overflow:hidden;border-radius:9px;background:#050505}.diary-gallery-item img{width:100%;height:100%;object-fit:cover}.diary-gallery-delete{position:absolute;right:5px;top:5px;margin:0}.diary-gallery-delete button{width:30px;height:30px;padding:0;border-radius:50%;background:rgba(0,0,0,.82);color:#fff;border:1px solid #ef4444}.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}.diary-edit{margin-top:16px;padding-top:12px;border-top:1px solid var(--border)}.diary-edit summary{color:#fca5a5;cursor:pointer;font-weight:700}.diary-form{display:grid;gap:8px;margin-top:14px;max-width:none}.diary-form select,.diary-form input,.diary-form textarea{width:100%}.diary-form textarea{min-height:130px;resize:vertical}.diary-empty-note{color:var(--muted)}
</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>Besuchte Konzerte werden automatisch eingetragen. Deine Erinnerungen bleiben privat.</p></div>
<div class="diary-list">
{% for entry in entries %}
<article class="diary-entry" id="concert-{{ entry.concert_id }}">
<h2><a href="/concerts/{{ entry.concert_id }}">{{ entry.artist }}</a></h2>
<p class="diary-meta">{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}</p>
{% if entry.rating %}<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>{% else %}<p class="diary-empty-note">Noch nicht bewertet.</p>{% 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.photos %}<div class="diary-gallery" aria-label="Bildergalerie zu {{ entry.artist }}">{% for photo in entry.photos %}<div class="diary-gallery-item"><img src="{{ photo.path }}" alt="Erinnerungsfoto {{ loop.index }} zu {{ entry.artist }}" loading="lazy"><form class="diary-gallery-delete" method="post" action="/diary/photos/{{ photo.id }}/delete" onsubmit="return confirm('Dieses Bild entfernen?');"><button type="submit" aria-label="Bild {{ loop.index }} entfernen">×</button></form></div>{% endfor %}</div>{% 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 %}
<details class="diary-edit" {% if not entry.rating and not entry.notes and not entry.favorite_song and not entry.photos %}open{% endif %}>
<summary>Eintrag bearbeiten</summary>
<form class="diary-form" method="post" action="/concerts/{{ entry.concert_id }}/diary" enctype="multipart/form-data">
<label>Bewertung <small>(optional)</small></label>
<select name="rating"><option value="">Noch nicht bewertet</option>{% for value in range(1,6) %}<option value="{{ value }}" {% if entry.rating == value %}selected{% endif %}>{{ value }} von 5 Sternen</option>{% endfor %}</select>
<label>Lieblingssong <small>(optional)</small></label>
<input name="favorite_song" maxlength="255" value="{{ entry.favorite_song }}">
<label>Erinnerungen <small>(optional)</small></label>
<textarea name="notes" maxlength="5000" placeholder="Was ist dir von diesem Abend geblieben?">{{ entry.notes }}</textarea>
<label>Galeriebilder <small>({{ entry.photos|length }}/3, optional)</small></label>
<input class="diary-photo-input" name="photos" type="file" accept="image/jpeg,image/png,image/webp" multiple data-free-slots="{{ 3-entry.photos|length }}" {% if entry.photos|length >= 3 %}disabled{% endif %}>
<small class="diary-photo-status">Bis zu drei Bilder; große Bilder werden automatisch optimiert.</small>
<button class="button" type="submit">Eintrag speichern</button>
</form>
</details>
</article>
{% else %}
<section class="empty"><p>Noch keine Einträge. Vergangene zugesagte Konzerte erscheinen hier automatisch.</p></section>
{% endfor %}
</div>
</main>
<script>
document.querySelectorAll(".diary-form").forEach(form => {
form.addEventListener("submit", async event => {
if (form.dataset.optimized === "true") return;
const input = form.querySelector(".diary-photo-input");
const files = Array.from(input.files);
if (!files.length) return;
event.preventDefault();
const status = form.querySelector(".diary-photo-status");
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
if (files.length > Number(input.dataset.freeSlots)) {
status.textContent = `Du kannst noch ${input.dataset.freeSlots} Bild(er) hinzufügen.`;
button.disabled = false; return;
}
status.textContent = "Erinnerungsfoto wird optimiert …";
try {
const transfer = new DataTransfer();
let totalSize = 0;
for (const [index, file] of files.entries()) {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, 1800 / 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();
let quality = .88; let blob;
do { blob = await new Promise(resolve => canvas.toBlob(resolve, "image/jpeg", quality)); quality -= .08; } while (blob && blob.size > 900 * 1024 && quality >= .48);
if (!blob) throw new Error("Bild konnte nicht verarbeitet werden");
totalSize += blob.size;
transfer.items.add(new File([blob], `konzert-erinnerung-${index+1}.jpg`, {type:"image/jpeg"}));
}
input.files = transfer.files; form.dataset.optimized = "true";
status.textContent = `${files.length} Bild(er) optimiert: ${Math.ceil(totalSize / 1024)} KB wird hochgeladen …`;
form.requestSubmit();
} catch (error) {
console.error("Diary image optimization failed:", error);
status.textContent = "Das Bild konnte nicht optimiert werden. Bitte ein kleineres JPG, PNG oder WebP wählen.";
button.disabled = false;
}
});
});
</script>
</body>
</html>