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
Binary file not shown.
+122 -21
View File
@@ -7,10 +7,12 @@ from pathlib import Path
from urllib.parse import urlencode from urllib.parse import urlencode
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from PIL import Image, ImageOps, UnidentifiedImageError
from flask import ( from flask import (
Flask, Flask,
flash, flash,
g, g,
jsonify,
redirect, redirect,
render_template, render_template,
request, 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["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["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["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["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_NAME"] = os.environ.get("LOCATION_NAME", "Hochheimer Terrasse")
app.config["LOCATION_ADDRESS"] = os.environ.get("LOCATION_ADDRESS", "Mainzer Str. 22, 65239 Hochheim am Main") 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["WEDDING_TIMEZONE"] = os.environ.get("WEDDING_TIMEZONE", "Europe/Berlin")
app.config["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME") 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 = { ALLOWED_MIME_TYPES = {
"image/jpeg", "image/jpeg",
"image/jpg", "image/jpg",
"image/png", "image/png",
"image/gif",
"image/heic", "image/heic",
"image/heif", "image/heif",
"image/heic-sequence", "image/heic-sequence",
"image/heif-sequence", "image/heif-sequence",
} }
THUMBNAIL_EXTENSIONS = {"jpg", "jpeg", "png"}
THUMBNAIL_MAX_SIZE = (900, 900)
LOCATION_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"} LOCATION_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"}
ONBOARDING_VERSION = 2 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_multi_hint": "Du kannst mehrere Bilder auf einmal auswählen oder weitere Felder hinzufügen.",
"upload_selected_count": "{count} Bilder ausgewählt", "upload_selected_count": "{count} Bilder ausgewählt",
"upload_ready": "Bereit zum Hochladen", "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", "schedule": "Ablauf",
"hotels": "Hotels", "hotels": "Hotels",
"taxi": "Genuss", "taxi": "Genuss",
@@ -476,6 +489,9 @@ TEXTS = {
"gallery_uploader_selected": "{count} ausgewählt", "gallery_uploader_selected": "{count} ausgewählt",
"gallery_image_alt": "Upload von {name}", "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_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_enter_group_name": "Bitte Gruppenname eingeben.",
"flash_invalid_group_login": "Ungültiger Gruppenname oder Passwort.", "flash_invalid_group_login": "Ungültiger Gruppenname oder Passwort.",
"flash_rsvp_select": "Bitte für alle Mitglieder eine RSVP-Auswahl treffen.", "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_age_invalid": "Bitte ein gültiges Alter (0-17) für {name} angeben.",
"flash_rsvp_saved": "Antwort gespeichert.", "flash_rsvp_saved": "Antwort gespeichert.",
"flash_select_image": "Bitte eine Bilddatei auswählen.", "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_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_too_large": "Upload zu groß. Bitte in kleineren Paketen hochladen (max. {max_mb} MB pro Anfrage).",
"flash_upload_failed": "Upload fehlgeschlagen. Bitte erneut versuchen.", "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_multi_hint": "You can select multiple images at once or add more file fields.",
"upload_selected_count": "{count} images selected", "upload_selected_count": "{count} images selected",
"upload_ready": "Ready to upload", "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", "schedule": "Schedule",
"hotels": "Hotels", "hotels": "Hotels",
"taxi": "Culinary Delights", "taxi": "Culinary Delights",
@@ -758,6 +778,9 @@ TEXTS = {
"gallery_uploader_selected": "{count} selected", "gallery_uploader_selected": "{count} selected",
"gallery_image_alt": "Uploaded by {name}", "gallery_image_alt": "Uploaded by {name}",
"gallery_save_hint": "Tip: Touch and hold the photo to save it to your photo library.", "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_enter_group_name": "Please enter group name.",
"flash_invalid_group_login": "Invalid group name or password.", "flash_invalid_group_login": "Invalid group name or password.",
"flash_rsvp_select": "Please choose RSVP values for all members.", "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_age_invalid": "Please enter a valid age (0-17) for {name}.",
"flash_rsvp_saved": "RSVP saved.", "flash_rsvp_saved": "RSVP saved.",
"flash_select_image": "Please select an image file.", "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_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_too_large": "Upload too large. Please upload smaller batches (max {max_mb} MB per request).",
"flash_upload_failed": "Upload failed. Please try again.", "flash_upload_failed": "Upload failed. Please try again.",
@@ -1171,7 +1194,10 @@ def admin_required(view):
@app.errorhandler(RequestEntityTooLarge) @app.errorhandler(RequestEntityTooLarge)
def handle_request_too_large(_error): def handle_request_too_large(_error):
max_mb = max(1, int(app.config.get("MAX_CONTENT_LENGTH", 0)) // (1024 * 1024)) 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": if request.method == "POST":
return redirect(url_for("upload")) return redirect(url_for("upload"))
return redirect(url_for("landing")) 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 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") @app.get("/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
@@ -1409,34 +1485,35 @@ def rsvp():
@login_required @login_required
def upload(): def upload():
if request.method == "POST": if request.method == "POST":
saved_paths = []
try: try:
files = [f for f in request.files.getlist("photo") if f and f.filename] files = [f for f in request.files.getlist("photo") if f and f.filename]
if not files: if not files:
flash(t("flash_select_image")) return upload_error_response(t("flash_select_image"))
return redirect(url_for("upload"))
validated_files = []
for file in files: for file in files:
if not is_allowed_file(file.filename): if not is_allowed_file(file.filename):
flash(t("flash_allowed_types")) return upload_error_response(t("flash_allowed_types"))
return redirect(url_for("upload"))
mime_type = (file.mimetype or "").lower() mime_type = (file.mimetype or "").lower()
if mime_type and mime_type not in ALLOWED_MIME_TYPES: if mime_type and mime_type not in ALLOWED_MIME_TYPES:
flash(t("flash_allowed_types")) return upload_error_response(t("flash_allowed_types"))
return redirect(url_for("upload")) 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"] upload_dir = app.config["UPLOAD_FOLDER"]
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)
db = get_db() db = get_db()
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
upload_rows = [] upload_rows = []
for file in files: for file, ext in validated_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()
stored_name = f"{uuid.uuid4().hex}.{ext}" 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)) upload_rows.append((stored_name, int(session["group_id"]), now))
db.executemany( db.executemany(
@@ -1445,14 +1522,20 @@ def upload():
) )
db.commit() 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))) flash(t("flash_upload_success_count").format(count=len(upload_rows)))
return redirect(url_for("gallery")) return redirect(url_for("gallery"))
except RequestEntityTooLarge: except RequestEntityTooLarge:
raise raise
except Exception: except Exception:
app.logger.exception("Upload failed") app.logger.exception("Upload failed")
flash(t("flash_upload_failed")) for saved_path in saved_paths:
return redirect(url_for("upload")) try:
os.remove(saved_path)
except FileNotFoundError:
pass
return upload_error_response(t("flash_upload_failed"), 500)
return render_template("upload.html") 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"]) file_path = os.path.join(app.config["UPLOAD_FOLDER"], image["filename"])
if os.path.isfile(file_path): if os.path.isfile(file_path):
os.remove(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")) flash(t("flash_image_deleted"))
return redirect(url_for("gallery")) 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>") @app.get("/info/<page>")
@login_required @login_required
def info(page: str): def info(page: str):
+95
View File
@@ -634,6 +634,64 @@ input[type="file"]:focus {
padding: 0; 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 { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1466,6 +1524,43 @@ input[type="file"]:focus {
background: #5e2733; 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 { .lightbox {
position: fixed; position: fixed;
inset: 0; inset: 0;
+43 -2
View File
@@ -47,10 +47,14 @@
class="gallery-open" class="gallery-open"
data-image-index="{{ loop.index0 }}" data-image-index="{{ loop.index0 }}"
> >
<img src="{{ url_for('serve_upload', filename=image['filename']) }}" alt="{{ t('gallery_image_alt').format(name=image['uploaded_by_name']) }}" loading="lazy" /> <img src="{{ url_for('serve_thumbnail', filename=image['filename']) }}" alt="{{ t('gallery_image_alt').format(name=image['uploaded_by_name']) }}" loading="lazy" />
</a> </a>
{% if is_host or guest_id == image['uploaded_by'] %} {% if is_host or guest_id == image['uploaded_by'] %}
<form class="gallery-delete-form" method="post" action="{{ url_for('delete_image', image_id=image['id']) }}"> <form
class="gallery-delete-form"
method="post"
action="{{ url_for('delete_image', image_id=image['id']) }}"
>
<button class="gallery-delete-btn" type="submit" aria-label="{{ t('delete') }}" title="{{ t('delete') }}"> <button class="gallery-delete-btn" type="submit" aria-label="{{ t('delete') }}" title="{{ t('delete') }}">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9z" /> <path d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9z" />
@@ -82,8 +86,45 @@
<div class="lightbox-save-hint" id="lightbox-save-hint" role="status">{{ t('gallery_save_hint') }}</div> <div class="lightbox-save-hint" id="lightbox-save-hint" role="status">{{ t('gallery_save_hint') }}</div>
</div> </div>
<dialog class="gallery-delete-dialog" id="gallery-delete-dialog" aria-labelledby="gallery-delete-title" aria-describedby="gallery-delete-message">
<h2 id="gallery-delete-title">{{ t('gallery_delete_title') }}</h2>
<p id="gallery-delete-message">{{ t('gallery_delete_confirm') }}</p>
<div class="gallery-delete-dialog-actions">
<button class="btn btn-ghost" id="gallery-delete-cancel" type="button">{{ t('gallery_delete_cancel') }}</button>
<button class="btn btn-danger" id="gallery-delete-confirm" type="button">{{ t('delete') }}</button>
</div>
</dialog>
<script> <script>
(() => { (() => {
const deleteDialog = document.getElementById("gallery-delete-dialog");
const deleteCancel = document.getElementById("gallery-delete-cancel");
const deleteConfirm = document.getElementById("gallery-delete-confirm");
let pendingDeleteForm = null;
document.querySelectorAll(".gallery-delete-form").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
pendingDeleteForm = form;
deleteDialog.showModal();
});
});
deleteCancel.addEventListener("click", () => deleteDialog.close());
deleteConfirm.addEventListener("click", () => {
if (pendingDeleteForm) {
HTMLFormElement.prototype.submit.call(pendingDeleteForm);
}
});
deleteDialog.addEventListener("close", () => {
pendingDeleteForm = null;
});
deleteDialog.addEventListener("click", (event) => {
if (event.target === deleteDialog) {
deleteDialog.close();
}
});
const lightbox = document.getElementById("gallery-lightbox"); const lightbox = document.getElementById("gallery-lightbox");
const lightboxImage = document.getElementById("lightbox-image"); const lightboxImage = document.getElementById("lightbox-image");
const lightboxDownload = document.getElementById("lightbox-download"); const lightboxDownload = document.getElementById("lightbox-download");
+124 -13
View File
@@ -7,12 +7,27 @@
<label class="upload-picker" for="photo-input"> <label class="upload-picker" for="photo-input">
<span class="upload-picker-title">{{ t('file') }}</span> <span class="upload-picker-title">{{ t('file') }}</span>
<span class="upload-picker-subtitle">{{ t('upload_picker_hint') }}</span> <span class="upload-picker-subtitle">{{ t('upload_picker_hint') }}</span>
<input id="photo-input" class="sr-only" type="file" name="photo" accept="image/jpeg,image/png,image/jpg,image/heic,image/heif,.heic,.heif" multiple /> <input id="photo-input" class="sr-only" type="file" name="photo" accept="image/jpeg,image/png,image/jpg,image/gif,image/heic,image/heif,.gif,.heic,.heif" multiple />
</label> </label>
<p id="upload-selected-count" class="upload-count"></p> <p id="upload-selected-count" class="upload-count"></p>
<p id="upload-ready-hint" class="upload-ready">{{ t('upload_ready') }}</p> <p id="upload-ready-hint" class="upload-ready">{{ t('upload_ready') }}</p>
<ul id="upload-file-list" class="upload-file-list"></ul> <ul id="upload-file-list" class="upload-file-list"></ul>
<div id="upload-progress" class="upload-progress" hidden>
<div
id="upload-progress-track"
class="upload-progress-track"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<span id="upload-progress-bar"></span>
</div>
<p id="upload-progress-count" class="upload-progress-count" aria-live="polite"></p>
<p id="upload-progress-status" class="upload-progress-status">{{ t('upload_in_progress') }}</p>
</div>
<button id="upload-submit-btn" class="btn" type="submit" disabled>{{ t('upload_submit') }}</button> <button id="upload-submit-btn" class="btn" type="submit" disabled>{{ t('upload_submit') }}</button>
<button id="upload-retry-btn" class="btn upload-retry-btn" type="button" hidden>{{ t('upload_retry') }}</button>
</form> </form>
</section> </section>
@@ -24,17 +39,27 @@
const readyEl = document.getElementById("upload-ready-hint"); const readyEl = document.getElementById("upload-ready-hint");
const listEl = document.getElementById("upload-file-list"); const listEl = document.getElementById("upload-file-list");
const submitBtn = document.getElementById("upload-submit-btn"); const submitBtn = document.getElementById("upload-submit-btn");
const retryBtn = document.getElementById("upload-retry-btn");
const progressEl = document.getElementById("upload-progress");
const progressTrack = document.getElementById("upload-progress-track");
const progressBar = document.getElementById("upload-progress-bar");
const progressCount = document.getElementById("upload-progress-count");
const progressStatus = document.getElementById("upload-progress-status");
const countTpl = {{ t('upload_selected_count')|tojson }}; const countTpl = {{ t('upload_selected_count')|tojson }};
const progressTpl = {{ t('upload_progress')|tojson }};
const inProgressText = {{ t('upload_in_progress')|tojson }};
const batchFailedText = {{ t('upload_batch_failed')|tojson }};
const galleryUrl = {{ url_for('gallery')|tojson }};
const maxBatchFiles = 10;
const maxBatchBytes = 40 * 1024 * 1024;
const selectedFiles = []; const selectedFiles = [];
let remainingFiles = [];
let totalFiles = 0;
let uploadedFiles = 0;
let isUploading = false;
const fileKey = (file) => `${file.name}__${file.size}__${file.lastModified}`; const fileKey = (file) => `${file.name}__${file.size}__${file.lastModified}`;
const syncInputFiles = () => {
const transfer = new DataTransfer();
selectedFiles.forEach((file) => transfer.items.add(file));
fileInput.files = transfer.files;
};
const renderSelection = () => { const renderSelection = () => {
if (!selectedFiles.length) { if (!selectedFiles.length) {
countEl.textContent = ""; countEl.textContent = "";
@@ -54,7 +79,7 @@
readyEl.classList.add("is-visible"); readyEl.classList.add("is-visible");
} }
if (submitBtn) { if (submitBtn) {
submitBtn.disabled = false; submitBtn.disabled = isUploading;
} }
selectedFiles.slice(0, 20).forEach((file, index) => { selectedFiles.slice(0, 20).forEach((file, index) => {
@@ -69,7 +94,6 @@
removeBtn.textContent = "×"; removeBtn.textContent = "×";
removeBtn.addEventListener("click", () => { removeBtn.addEventListener("click", () => {
selectedFiles.splice(index, 1); selectedFiles.splice(index, 1);
syncInputFiles();
renderSelection(); renderSelection();
}); });
@@ -93,18 +117,105 @@
existingKeys.add(key); existingKeys.add(key);
} }
}); });
syncInputFiles();
fileInput.value = ""; fileInput.value = "";
renderSelection(); renderSelection();
}); });
const updateProgress = () => {
const percent = totalFiles ? Math.round((uploadedFiles / totalFiles) * 100) : 0;
progressBar.style.width = `${percent}%`;
progressTrack.setAttribute("aria-valuenow", String(percent));
progressCount.textContent = progressTpl
.replace("{uploaded}", String(uploadedFiles))
.replace("{total}", String(totalFiles));
};
const nextBatch = () => {
let batchSize = 0;
let batchLength = 0;
while (batchLength < remainingFiles.length && batchLength < maxBatchFiles) {
const nextSize = remainingFiles[batchLength].size;
if (batchLength > 0 && batchSize + nextSize > maxBatchBytes) {
break;
}
batchSize += nextSize;
batchLength += 1;
}
return remainingFiles.slice(0, batchLength);
};
const setUploadLocked = (locked) => {
fileInput.disabled = locked;
submitBtn.disabled = locked;
document.querySelectorAll(".upload-file-remove").forEach((button) => {
button.disabled = locked;
});
form.classList.toggle("is-uploading", locked);
};
const uploadRemainingFiles = async () => {
if (isUploading || !remainingFiles.length) return;
isUploading = true;
setUploadLocked(true);
retryBtn.hidden = true;
progressEl.hidden = false;
progressStatus.textContent = inProgressText;
progressStatus.classList.remove("is-error");
updateProgress();
try {
while (remainingFiles.length) {
const batch = nextBatch();
const body = new FormData();
batch.forEach((file) => body.append("photo", file, file.name));
const response = await fetch(form.action || window.location.href, {
method: "POST",
body,
credentials: "same-origin",
headers: { "X-Upload-Batch": "1" }
});
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json") ? await response.json() : null;
if (!response.ok || !payload || !payload.ok) {
throw new Error(payload?.error || batchFailedText);
}
remainingFiles.splice(0, batch.length);
uploadedFiles += batch.length;
updateProgress();
}
window.location.assign(galleryUrl);
} catch (error) {
progressStatus.textContent = `${batchFailedText} ${error.message || ""}`.trim();
progressStatus.classList.add("is-error");
retryBtn.hidden = false;
} finally {
isUploading = false;
form.classList.remove("is-uploading");
if (remainingFiles.length) {
fileInput.disabled = true;
submitBtn.disabled = true;
}
}
};
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
if (!selectedFiles.length) { event.preventDefault();
event.preventDefault(); if (!selectedFiles.length || isUploading) {
renderSelection(); renderSelection();
return; return;
} }
syncInputFiles(); totalFiles = selectedFiles.length;
uploadedFiles = 0;
remainingFiles = [...selectedFiles];
uploadRemainingFiles();
});
retryBtn.addEventListener("click", uploadRemainingFiles);
window.addEventListener("beforeunload", (event) => {
if (!isUploading) return;
event.preventDefault();
event.returnValue = "";
}); });
renderSelection(); renderSelection();
Binary file not shown.
+1
View File
@@ -23,6 +23,7 @@ services:
environment: environment:
- DB_PATH=/app/db/app.sqlite3 - DB_PATH=/app/db/app.sqlite3
- UPLOAD_FOLDER=/app/uploads - UPLOAD_FOLDER=/app/uploads
- THUMBNAIL_FOLDER=/app/uploads/thumbnails
- EVENT_PASSWORD=${EVENT_PASSWORD:-wedding2026} - EVENT_PASSWORD=${EVENT_PASSWORD:-wedding2026}
- HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026} - HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026}
- PHOTOGRAPHER_PASSWORD=${PHOTOGRAPHER_PASSWORD:-Foto!Graf26#} - PHOTOGRAPHER_PASSWORD=${PHOTOGRAPHER_PASSWORD:-Foto!Graf26#}