Bugfix + Feat Besseres Laden + Bildervorschau und Uploadbalken + Popup wirklich bild löschen?

This commit is contained in:
2026-09-20 14:53:49 +00:00
parent 77b549c960
commit 2ae0d254a3
7 changed files with 385 additions and 36 deletions
+122 -21
View File
@@ -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/<path:filename>")
@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/<page>")
@login_required
def info(page: str):