diff --git a/backend/__pycache__/app.cpython-312.pyc b/backend/__pycache__/app.cpython-312.pyc index 13b0fe0..2532eb2 100644 Binary files a/backend/__pycache__/app.cpython-312.pyc and b/backend/__pycache__/app.cpython-312.pyc differ diff --git a/backend/app.py b/backend/app.py index dfa7081..5f6a708 100644 --- a/backend/app.py +++ b/backend/app.py @@ -7,10 +7,12 @@ from pathlib import Path from urllib.parse import urlencode from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from PIL import Image, ImageOps, UnidentifiedImageError from flask import ( Flask, flash, g, + jsonify, redirect, render_template, request, @@ -27,6 +29,10 @@ base_dir = Path(__file__).resolve().parent app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "change-me-in-production") app.config["DB_PATH"] = os.environ.get("DB_PATH", str(base_dir / "app.sqlite3")) app.config["UPLOAD_FOLDER"] = os.environ.get("UPLOAD_FOLDER", str(base_dir / "uploads")) +app.config["THUMBNAIL_FOLDER"] = os.environ.get( + "THUMBNAIL_FOLDER", + str(Path(app.config["UPLOAD_FOLDER"]) / "thumbnails"), +) app.config["MAX_CONTENT_LENGTH"] = int(os.environ.get("MAX_UPLOAD_BYTES", str(64 * 1024 * 1024))) app.config["LOCATION_NAME"] = os.environ.get("LOCATION_NAME", "Hochheimer Terrasse") app.config["LOCATION_ADDRESS"] = os.environ.get("LOCATION_ADDRESS", "Mainzer Str. 22, 65239 Hochheim am Main") @@ -41,16 +47,19 @@ app.config["WEDDING_COUNTDOWN_LOCAL"] = os.environ.get("WEDDING_COUNTDOWN_LOCAL" app.config["WEDDING_TIMEZONE"] = os.environ.get("WEDDING_TIMEZONE", "Europe/Berlin") app.config["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME") -ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "heic", "heif"} +ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "heic", "heif"} ALLOWED_MIME_TYPES = { "image/jpeg", "image/jpg", "image/png", + "image/gif", "image/heic", "image/heif", "image/heic-sequence", "image/heif-sequence", } +THUMBNAIL_EXTENSIONS = {"jpg", "jpeg", "png"} +THUMBNAIL_MAX_SIZE = (900, 900) LOCATION_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"} ONBOARDING_VERSION = 2 @@ -322,7 +331,11 @@ TEXTS = { "upload_multi_hint": "Du kannst mehrere Bilder auf einmal auswählen oder weitere Felder hinzufügen.", "upload_selected_count": "{count} Bilder ausgewählt", "upload_ready": "Bereit zum Hochladen", - "upload_submit": "Foto hochladen", + "upload_submit": "Bilder hochladen", + "upload_progress": "{uploaded} von {total} Bildern hochgeladen", + "upload_in_progress": "Upload läuft – bitte diese Seite geöffnet lassen.", + "upload_retry": "Upload fortsetzen", + "upload_batch_failed": "Der Upload wurde unterbrochen. Bereits hochgeladene Bilder bleiben gespeichert.", "schedule": "Ablauf", "hotels": "Hotels", "taxi": "Genuss", @@ -476,6 +489,9 @@ TEXTS = { "gallery_uploader_selected": "{count} ausgewählt", "gallery_image_alt": "Upload von {name}", "gallery_save_hint": "Tipp: Halte das Bild länger gedrückt, um es in deiner Mediathek zu speichern.", + "gallery_delete_title": "Bild löschen", + "gallery_delete_confirm": "Möchtest du dieses Bild wirklich löschen?", + "gallery_delete_cancel": "Abbrechen", "flash_enter_group_name": "Bitte Gruppenname eingeben.", "flash_invalid_group_login": "Ungültiger Gruppenname oder Passwort.", "flash_rsvp_select": "Bitte für alle Mitglieder eine RSVP-Auswahl treffen.", @@ -483,7 +499,7 @@ TEXTS = { "flash_rsvp_age_invalid": "Bitte ein gültiges Alter (0-17) für {name} angeben.", "flash_rsvp_saved": "Antwort gespeichert.", "flash_select_image": "Bitte eine Bilddatei auswählen.", - "flash_allowed_types": "Nur JPG/JPEG/PNG/HEIC/HEIF sind erlaubt.", + "flash_allowed_types": "Nur JPG/JPEG/PNG/GIF/HEIC/HEIF sind erlaubt.", "flash_upload_success_count": "{count} Bilder erfolgreich hochgeladen.", "flash_upload_too_large": "Upload zu groß. Bitte in kleineren Paketen hochladen (max. {max_mb} MB pro Anfrage).", "flash_upload_failed": "Upload fehlgeschlagen. Bitte erneut versuchen.", @@ -604,7 +620,11 @@ TEXTS = { "upload_multi_hint": "You can select multiple images at once or add more file fields.", "upload_selected_count": "{count} images selected", "upload_ready": "Ready to upload", - "upload_submit": "Upload photo", + "upload_submit": "Upload photos", + "upload_progress": "{uploaded} of {total} images uploaded", + "upload_in_progress": "Upload in progress – please keep this page open.", + "upload_retry": "Continue upload", + "upload_batch_failed": "The upload was interrupted. Images already uploaded remain saved.", "schedule": "Schedule", "hotels": "Hotels", "taxi": "Culinary Delights", @@ -758,6 +778,9 @@ TEXTS = { "gallery_uploader_selected": "{count} selected", "gallery_image_alt": "Uploaded by {name}", "gallery_save_hint": "Tip: Touch and hold the photo to save it to your photo library.", + "gallery_delete_title": "Delete photo", + "gallery_delete_confirm": "Are you sure you want to delete this photo?", + "gallery_delete_cancel": "Cancel", "flash_enter_group_name": "Please enter group name.", "flash_invalid_group_login": "Invalid group name or password.", "flash_rsvp_select": "Please choose RSVP values for all members.", @@ -765,7 +788,7 @@ TEXTS = { "flash_rsvp_age_invalid": "Please enter a valid age (0-17) for {name}.", "flash_rsvp_saved": "RSVP saved.", "flash_select_image": "Please select an image file.", - "flash_allowed_types": "Only JPG/JPEG/PNG/HEIC/HEIF are allowed.", + "flash_allowed_types": "Only JPG/JPEG/PNG/GIF/HEIC/HEIF are allowed.", "flash_upload_success_count": "{count} images uploaded successfully.", "flash_upload_too_large": "Upload too large. Please upload smaller batches (max {max_mb} MB per request).", "flash_upload_failed": "Upload failed. Please try again.", @@ -1171,7 +1194,10 @@ def admin_required(view): @app.errorhandler(RequestEntityTooLarge) def handle_request_too_large(_error): max_mb = max(1, int(app.config.get("MAX_CONTENT_LENGTH", 0)) // (1024 * 1024)) - flash(t("flash_upload_too_large").format(max_mb=max_mb)) + message = t("flash_upload_too_large").format(max_mb=max_mb) + if request.headers.get("X-Upload-Batch") == "1": + return jsonify(ok=False, error=message), 413 + flash(message) if request.method == "POST": return redirect(url_for("upload")) return redirect(url_for("landing")) @@ -1181,6 +1207,56 @@ def is_allowed_file(filename: str) -> bool: return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS +def upload_error_response(message: str, status: int = 400): + if request.headers.get("X-Upload-Batch") == "1": + return jsonify(ok=False, error=message), status + flash(message) + return redirect(url_for("upload")) + + +def thumbnail_filename(filename: str) -> str | None: + if Path(filename).name != filename or "." not in filename: + return None + extension = filename.rsplit(".", 1)[1].lower() + if extension not in THUMBNAIL_EXTENSIONS: + return None + return f"{filename.rsplit('.', 1)[0]}.webp" + + +def ensure_thumbnail(filename: str) -> str | None: + thumbnail_name = thumbnail_filename(filename) + if thumbnail_name is None: + return None + + source_path = Path(app.config["UPLOAD_FOLDER"]) / filename + thumbnail_dir = Path(app.config["THUMBNAIL_FOLDER"]) + thumbnail_path = thumbnail_dir / thumbnail_name + if thumbnail_path.is_file(): + return thumbnail_name + if not source_path.is_file(): + return None + + thumbnail_dir.mkdir(parents=True, exist_ok=True) + temporary_path = thumbnail_dir / f".{thumbnail_name}.{uuid.uuid4().hex}.tmp" + try: + with Image.open(source_path) as source_image: + preview = ImageOps.exif_transpose(source_image) + if preview.mode not in {"RGB", "RGBA"}: + preview = preview.convert("RGBA" if "transparency" in preview.info else "RGB") + preview.thumbnail(THUMBNAIL_MAX_SIZE, Image.Resampling.LANCZOS) + preview.save(temporary_path, format="WEBP", quality=84, method=4) + os.replace(temporary_path, thumbnail_path) + return thumbnail_name + except (OSError, UnidentifiedImageError, ValueError): + app.logger.warning("Could not create thumbnail for %s", filename, exc_info=True) + return None + finally: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + + @app.get("/health") def health(): return {"status": "ok"} @@ -1409,34 +1485,35 @@ def rsvp(): @login_required def upload(): if request.method == "POST": + saved_paths = [] try: files = [f for f in request.files.getlist("photo") if f and f.filename] if not files: - flash(t("flash_select_image")) - return redirect(url_for("upload")) + return upload_error_response(t("flash_select_image")) + validated_files = [] for file in files: if not is_allowed_file(file.filename): - flash(t("flash_allowed_types")) - return redirect(url_for("upload")) + return upload_error_response(t("flash_allowed_types")) mime_type = (file.mimetype or "").lower() if mime_type and mime_type not in ALLOWED_MIME_TYPES: - flash(t("flash_allowed_types")) - return redirect(url_for("upload")) + return upload_error_response(t("flash_allowed_types")) + safe_name = secure_filename(file.filename) + if "." not in safe_name: + return upload_error_response(t("flash_allowed_types")) + validated_files.append((file, safe_name.rsplit(".", 1)[1].lower())) upload_dir = app.config["UPLOAD_FOLDER"] os.makedirs(upload_dir, exist_ok=True) db = get_db() now = datetime.utcnow().isoformat() upload_rows = [] - for file in files: - safe_name = secure_filename(file.filename) - if "." not in safe_name: - flash(t("flash_allowed_types")) - return redirect(url_for("upload")) - ext = safe_name.rsplit(".", 1)[1].lower() + for file, ext in validated_files: stored_name = f"{uuid.uuid4().hex}.{ext}" - file.save(os.path.join(upload_dir, stored_name)) + stored_path = os.path.join(upload_dir, stored_name) + file.save(stored_path) + saved_paths.append(stored_path) + ensure_thumbnail(stored_name) upload_rows.append((stored_name, int(session["group_id"]), now)) db.executemany( @@ -1445,14 +1522,20 @@ def upload(): ) db.commit() + if request.headers.get("X-Upload-Batch") == "1": + return jsonify(ok=True, count=len(upload_rows)) flash(t("flash_upload_success_count").format(count=len(upload_rows))) return redirect(url_for("gallery")) except RequestEntityTooLarge: raise except Exception: app.logger.exception("Upload failed") - flash(t("flash_upload_failed")) - return redirect(url_for("upload")) + for saved_path in saved_paths: + try: + os.remove(saved_path) + except FileNotFoundError: + pass + return upload_error_response(t("flash_upload_failed"), 500) return render_template("upload.html") @@ -1500,6 +1583,11 @@ def delete_image(image_id: int): file_path = os.path.join(app.config["UPLOAD_FOLDER"], image["filename"]) if os.path.isfile(file_path): os.remove(file_path) + thumbnail_name = thumbnail_filename(str(image["filename"])) + if thumbnail_name: + thumbnail_path = os.path.join(app.config["THUMBNAIL_FOLDER"], thumbnail_name) + if os.path.isfile(thumbnail_path): + os.remove(thumbnail_path) flash(t("flash_image_deleted")) return redirect(url_for("gallery")) @@ -1517,6 +1605,19 @@ def serve_upload(filename: str): ) +@app.get("/uploads/thumbnails/") +@login_required +def serve_thumbnail(filename: str): + generated_name = ensure_thumbnail(filename) + if generated_name: + return send_from_directory( + app.config["THUMBNAIL_FOLDER"], + generated_name, + max_age=31536000, + ) + return send_from_directory(app.config["UPLOAD_FOLDER"], filename) + + @app.get("/info/") @login_required def info(page: str): diff --git a/backend/static/styles.css b/backend/static/styles.css index ffde7fb..3344695 100644 --- a/backend/static/styles.css +++ b/backend/static/styles.css @@ -634,6 +634,64 @@ input[type="file"]:focus { padding: 0; } +.upload-progress { + padding: 0.85rem 1rem; + border: 1px solid rgba(31, 58, 47, 0.14); + border-radius: 14px; + background: rgba(255, 253, 248, 0.9); +} + +.upload-progress[hidden] { + display: none; +} + +.upload-progress-track { + height: 0.65rem; + overflow: hidden; + border-radius: 999px; + background: rgba(31, 58, 47, 0.12); +} + +.upload-progress-track span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--gold), var(--forest)); + transition: width 0.25s ease; +} + +.upload-progress-count { + margin: 0.65rem 0 0; + color: var(--forest); + font-weight: 700; + text-align: center; +} + +.upload-progress-status { + margin: 0.2rem 0 0; + color: rgba(31, 31, 31, 0.7); + text-align: center; +} + +.upload-progress-status.is-error { + color: var(--burgundy); +} + +.upload-retry-btn[hidden] { + display: none; +} + +.form-grid.is-uploading .upload-picker { + cursor: wait; + opacity: 0.62; +} + +.upload-file-remove:disabled { + cursor: not-allowed; + opacity: 0.45; +} + .btn { display: inline-flex; align-items: center; @@ -1466,6 +1524,43 @@ input[type="file"]:focus { background: #5e2733; } +.gallery-delete-dialog { + width: min(24rem, calc(100% - 2rem)); + margin: auto; + padding: 1.5rem; + border: 1px solid rgba(109, 47, 61, 0.2); + border-radius: 18px; + background: #fffdf8; + color: var(--forest); + text-align: center; + box-shadow: 0 22px 60px rgba(18, 31, 24, 0.3); +} + +.gallery-delete-dialog::backdrop { + background: rgba(18, 31, 24, 0.62); + backdrop-filter: blur(2px); +} + +.gallery-delete-dialog h2 { + margin: 0 0 0.65rem; +} + +.gallery-delete-dialog p { + margin: 0; +} + +.gallery-delete-dialog-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-top: 1.35rem; +} + +.gallery-delete-dialog-actions .btn { + min-width: 7.5rem; +} + .lightbox { position: fixed; inset: 0; diff --git a/backend/templates/gallery.html b/backend/templates/gallery.html index 3f12cb3..845fbcd 100644 --- a/backend/templates/gallery.html +++ b/backend/templates/gallery.html @@ -47,10 +47,14 @@ class="gallery-open" data-image-index="{{ loop.index0 }}" > - {{ t('gallery_image_alt').format(name=image['uploaded_by_name']) }} + {{ t('gallery_image_alt').format(name=image['uploaded_by_name']) }} {% if is_host or guest_id == image['uploaded_by'] %} -