Bugfix + Feat Besseres Laden + Bildervorschau und Uploadbalken + Popup wirklich bild löschen?
This commit is contained in:
Binary file not shown.
+122
-21
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,10 +47,14 @@
|
||||
class="gallery-open"
|
||||
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>
|
||||
{% 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') }}">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
(() => {
|
||||
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 lightboxImage = document.getElementById("lightbox-image");
|
||||
const lightboxDownload = document.getElementById("lightbox-download");
|
||||
|
||||
+123
-12
@@ -7,12 +7,27 @@
|
||||
<label class="upload-picker" for="photo-input">
|
||||
<span class="upload-picker-title">{{ t('file') }}</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>
|
||||
<p id="upload-selected-count" class="upload-count"></p>
|
||||
<p id="upload-ready-hint" class="upload-ready">{{ t('upload_ready') }}</p>
|
||||
<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-retry-btn" class="btn upload-retry-btn" type="button" hidden>{{ t('upload_retry') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -24,17 +39,27 @@
|
||||
const readyEl = document.getElementById("upload-ready-hint");
|
||||
const listEl = document.getElementById("upload-file-list");
|
||||
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 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 = [];
|
||||
let remainingFiles = [];
|
||||
let totalFiles = 0;
|
||||
let uploadedFiles = 0;
|
||||
let isUploading = false;
|
||||
|
||||
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 = () => {
|
||||
if (!selectedFiles.length) {
|
||||
countEl.textContent = "";
|
||||
@@ -54,7 +79,7 @@
|
||||
readyEl.classList.add("is-visible");
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.disabled = isUploading;
|
||||
}
|
||||
|
||||
selectedFiles.slice(0, 20).forEach((file, index) => {
|
||||
@@ -69,7 +94,6 @@
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", () => {
|
||||
selectedFiles.splice(index, 1);
|
||||
syncInputFiles();
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
@@ -93,18 +117,105 @@
|
||||
existingKeys.add(key);
|
||||
}
|
||||
});
|
||||
syncInputFiles();
|
||||
fileInput.value = "";
|
||||
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) => {
|
||||
if (!selectedFiles.length) {
|
||||
event.preventDefault();
|
||||
if (!selectedFiles.length || isUploading) {
|
||||
renderSelection();
|
||||
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();
|
||||
|
||||
Binary file not shown.
@@ -23,6 +23,7 @@ services:
|
||||
environment:
|
||||
- DB_PATH=/app/db/app.sqlite3
|
||||
- UPLOAD_FOLDER=/app/uploads
|
||||
- THUMBNAIL_FOLDER=/app/uploads/thumbnails
|
||||
- EVENT_PASSWORD=${EVENT_PASSWORD:-wedding2026}
|
||||
- HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026}
|
||||
- PHOTOGRAPHER_PASSWORD=${PHOTOGRAPHER_PASSWORD:-Foto!Graf26#}
|
||||
|
||||
Reference in New Issue
Block a user