Viele neue Features
This commit is contained in:
@@ -10,4 +10,4 @@ RUN uv sync --frozen --no-dev
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "gunicorn", "-b", "0.0.0.0:8000", "app:app"]
|
||||
CMD ["uv", "run", "gunicorn", "-b", "0.0.0.0:8000", "--timeout", "300", "--graceful-timeout", "30", "--keep-alive", "5", "app:app"]
|
||||
|
||||
Binary file not shown.
181
backend/app.py
181
backend/app.py
@@ -17,6 +17,7 @@ from flask import (
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -26,7 +27,7 @@ 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["EVENT_PASSWORD"] = os.environ.get("EVENT_PASSWORD", "wedding2026")
|
||||
app.config["HOST_PASSWORD"] = os.environ.get("HOST_PASSWORD", "gastgeber2026")
|
||||
app.config["MAX_CONTENT_LENGTH"] = int(os.environ.get("MAX_UPLOAD_BYTES", str(8 * 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", "Schlossgarten Venue")
|
||||
app.config["LOCATION_ADDRESS"] = os.environ.get(
|
||||
"LOCATION_ADDRESS", "Musterstraße 12, 12345 Musterstadt"
|
||||
@@ -41,18 +42,27 @@ app.config["GOOGLE_MAPS_EMBED_URL"] = os.environ.get(
|
||||
app.config["WEDDING_DATE"] = os.environ.get("WEDDING_DATE", "")
|
||||
app.config["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME")
|
||||
|
||||
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png"}
|
||||
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "heic", "heif"}
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/heic-sequence",
|
||||
"image/heif-sequence",
|
||||
}
|
||||
|
||||
TEXTS = {
|
||||
"de": {
|
||||
"brand": "Svenja & Dominic",
|
||||
"subtitle": "Willkommen zu unserer Hochzeits-App",
|
||||
"login_note": "Passwortgeschuetzter Zugriff fuer unsere Gaeste.",
|
||||
"login_note": "Passwortgeschützter Zugriff für unsere Gäste.",
|
||||
"login": "Login",
|
||||
"name": "Dein Name",
|
||||
"event_password": "Event-Passwort",
|
||||
"login_submit": "Weiter zum Gaestebereich",
|
||||
"guest_area": "Gaestebereich",
|
||||
"login_submit": "Weiter zum Gästebereich",
|
||||
"guest_area": "Gästebereich",
|
||||
"hello_guest": "Hallo {name}.",
|
||||
"logout": "Abmelden",
|
||||
"rsvp": "RSVP",
|
||||
@@ -65,6 +75,9 @@ TEXTS = {
|
||||
"not_attending": "Ich komme nicht",
|
||||
"plus_one": "Ich bringe eine Begleitperson mit",
|
||||
"file": "Bild auswählen",
|
||||
"add_more_files": "Weitere Datei 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_submit": "Foto hochladen",
|
||||
"schedule": "Ablauf",
|
||||
"hotels": "Hotels",
|
||||
@@ -75,27 +88,30 @@ TEXTS = {
|
||||
"imprint": "Impressum",
|
||||
"hero_headline": "Willkommen zu unserer Hochzeit",
|
||||
"hero_text": "Wir freuen uns riesig, diesen besonderen Tag mit euch zu feiern.",
|
||||
"to_guest_area": "Zum Gaestebereich",
|
||||
"to_guest_area": "Zum Gästebereich",
|
||||
"schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.",
|
||||
"hotels_text": "Empfehlungen folgen. Bitte fruehzeitig buchen.",
|
||||
"hotels_text": "Empfehlungen folgen. Bitte frühzeitig buchen.",
|
||||
"taxi_text": "Taxi-Service: 01234 / 567890 (24/7).",
|
||||
"gallery_uploaded_by": "von {name}",
|
||||
"gallery_empty": "Noch keine Bilder vorhanden.",
|
||||
"gallery_image_alt": "Upload von {name}",
|
||||
"flash_enter_name": "Bitte Namen eingeben.",
|
||||
"flash_invalid_password": "Ungueltiges Event-Passwort.",
|
||||
"flash_invalid_password": "Ungültiges Event-Passwort.",
|
||||
"flash_rsvp_select": "Bitte eine RSVP-Auswahl treffen.",
|
||||
"flash_rsvp_saved": "RSVP gespeichert.",
|
||||
"flash_select_image": "Bitte eine Bilddatei auswaehlen.",
|
||||
"flash_allowed_types": "Nur JPG/JPEG/PNG sind erlaubt.",
|
||||
"flash_select_image": "Bitte eine Bilddatei auswählen.",
|
||||
"flash_allowed_types": "Nur JPG/JPEG/PNG/HEIC/HEIF sind erlaubt.",
|
||||
"flash_upload_success": "Upload erfolgreich.",
|
||||
"flash_invalid_host_password": "Ungueltiges Gastgeber-Passwort.",
|
||||
"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.",
|
||||
"flash_invalid_host_password": "Ungültiges Gastgeber-Passwort.",
|
||||
"host_access_title": "Gastgeberbereich",
|
||||
"host_access_note": "Dieser Bereich ist nur fuer das Brautpaar vorgesehen.",
|
||||
"host_access_note": "Dieser Bereich ist nur für das Brautpaar vorgesehen.",
|
||||
"host_password": "Gastgeber-Passwort",
|
||||
"host_access_submit": "Adminbereich oeffnen",
|
||||
"host_stats_title": "Uebersicht",
|
||||
"total_guests": "Gaeste gesamt",
|
||||
"host_access_submit": "Adminbereich öffnen",
|
||||
"host_stats_title": "Übersicht",
|
||||
"total_guests": "Gäste gesamt",
|
||||
"attending_yes": "Zusagen",
|
||||
"attending_no": "Absagen",
|
||||
"attending_open": "Noch offen",
|
||||
@@ -109,6 +125,10 @@ TEXTS = {
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"legal_de_authoritative": "Bei rechtlichen Unklarheiten gilt die deutsche Fassung.",
|
||||
"download": "Download",
|
||||
"delete": "Löschen",
|
||||
"flash_delete_not_allowed": "Du darfst dieses Bild nicht löschen.",
|
||||
"flash_image_deleted": "Bild gelöscht.",
|
||||
},
|
||||
"en": {
|
||||
"brand": "Svenja & Dominic",
|
||||
@@ -131,6 +151,9 @@ TEXTS = {
|
||||
"not_attending": "I cannot attend",
|
||||
"plus_one": "I will bring a plus-one",
|
||||
"file": "Select image",
|
||||
"add_more_files": "Add more files",
|
||||
"upload_multi_hint": "You can select multiple images at once or add more file fields.",
|
||||
"upload_selected_count": "{count} images selected",
|
||||
"upload_submit": "Upload photo",
|
||||
"schedule": "Schedule",
|
||||
"hotels": "Hotels",
|
||||
@@ -153,8 +176,11 @@ TEXTS = {
|
||||
"flash_rsvp_select": "Please choose an RSVP option.",
|
||||
"flash_rsvp_saved": "RSVP saved.",
|
||||
"flash_select_image": "Please select an image file.",
|
||||
"flash_allowed_types": "Only JPG/JPEG/PNG are allowed.",
|
||||
"flash_allowed_types": "Only JPG/JPEG/PNG/HEIC/HEIF are allowed.",
|
||||
"flash_upload_success": "Upload successful.",
|
||||
"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.",
|
||||
"flash_invalid_host_password": "Invalid host password.",
|
||||
"host_access_title": "Host Area",
|
||||
"host_access_note": "This section is intended for the wedding hosts only.",
|
||||
@@ -175,6 +201,10 @@ TEXTS = {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"legal_de_authoritative": "In case of legal ambiguity, the German version shall prevail.",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"flash_delete_not_allowed": "You are not allowed to delete this image.",
|
||||
"flash_image_deleted": "Image deleted.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -210,6 +240,8 @@ def inject_common() -> dict:
|
||||
"t": t,
|
||||
"lang": get_lang(),
|
||||
"guest_name": session.get("guest_name"),
|
||||
"guest_id": session.get("guest_id"),
|
||||
"is_host": bool(session.get("is_host")),
|
||||
"location_name": app.config["LOCATION_NAME"],
|
||||
"location_address": app.config["LOCATION_ADDRESS"],
|
||||
"location_website_url": app.config["LOCATION_WEBSITE_URL"],
|
||||
@@ -278,6 +310,15 @@ def login_required(view):
|
||||
return wrapped
|
||||
|
||||
|
||||
@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))
|
||||
if request.method == "POST":
|
||||
return redirect(url_for("upload"))
|
||||
return redirect(url_for("landing"))
|
||||
|
||||
|
||||
def is_allowed_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@@ -349,6 +390,7 @@ def welcome():
|
||||
|
||||
|
||||
@app.get("/gaestebereich")
|
||||
@app.get("/gästebereich")
|
||||
@login_required
|
||||
def guest_area():
|
||||
return render_template("guest_area.html")
|
||||
@@ -442,33 +484,52 @@ def rsvp():
|
||||
@login_required
|
||||
def upload():
|
||||
if request.method == "POST":
|
||||
file = request.files.get("photo")
|
||||
if file is None or file.filename == "":
|
||||
flash(t("flash_select_image"))
|
||||
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"))
|
||||
|
||||
for file in files:
|
||||
if not is_allowed_file(file.filename):
|
||||
flash(t("flash_allowed_types"))
|
||||
return redirect(url_for("upload"))
|
||||
mime_type = (file.mimetype or "").lower()
|
||||
# Some mobile browsers may omit or vary MIME types for valid images.
|
||||
if mime_type and mime_type not in ALLOWED_MIME_TYPES:
|
||||
flash(t("flash_allowed_types"))
|
||||
return redirect(url_for("upload"))
|
||||
|
||||
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()
|
||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
file.save(os.path.join(upload_dir, stored_name))
|
||||
upload_rows.append((stored_name, session["guest_id"], now))
|
||||
|
||||
db.executemany(
|
||||
"INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)",
|
||||
upload_rows,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
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"))
|
||||
|
||||
if not is_allowed_file(file.filename):
|
||||
flash(t("flash_allowed_types"))
|
||||
return redirect(url_for("upload"))
|
||||
|
||||
safe_name = secure_filename(file.filename)
|
||||
ext = safe_name.rsplit(".", 1)[1].lower()
|
||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
upload_dir = app.config["UPLOAD_FOLDER"]
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
file.save(os.path.join(upload_dir, stored_name))
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)",
|
||||
(stored_name, session["guest_id"], datetime.utcnow().isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
flash(t("flash_upload_success"))
|
||||
return redirect(url_for("gallery"))
|
||||
|
||||
return render_template("upload.html")
|
||||
|
||||
|
||||
@@ -478,7 +539,7 @@ def gallery():
|
||||
db = get_db()
|
||||
images = db.execute(
|
||||
"""
|
||||
SELECT uploads.filename, uploads.uploaded_at, guests.name AS uploaded_by
|
||||
SELECT uploads.id, uploads.filename, uploads.uploaded_by, uploads.uploaded_at, guests.name AS uploaded_by_name
|
||||
FROM uploads
|
||||
JOIN guests ON guests.id = uploads.uploaded_by
|
||||
ORDER BY uploads.id DESC
|
||||
@@ -487,10 +548,44 @@ def gallery():
|
||||
return render_template("gallery.html", images=images)
|
||||
|
||||
|
||||
@app.post("/gallery/delete/<int:image_id>")
|
||||
@login_required
|
||||
def delete_image(image_id: int):
|
||||
db = get_db()
|
||||
image = db.execute(
|
||||
"SELECT id, filename, uploaded_by FROM uploads WHERE id = ?",
|
||||
(image_id,),
|
||||
).fetchone()
|
||||
if image is None:
|
||||
return redirect(url_for("gallery"))
|
||||
|
||||
current_guest_id = int(session["guest_id"])
|
||||
is_host = bool(session.get("is_host"))
|
||||
if not is_host and int(image["uploaded_by"]) != current_guest_id:
|
||||
flash(t("flash_delete_not_allowed"))
|
||||
return redirect(url_for("gallery"))
|
||||
|
||||
db.execute("DELETE FROM uploads WHERE id = ?", (image_id,))
|
||||
db.commit()
|
||||
|
||||
file_path = os.path.join(app.config["UPLOAD_FOLDER"], image["filename"])
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
flash(t("flash_image_deleted"))
|
||||
return redirect(url_for("gallery"))
|
||||
|
||||
|
||||
@app.get("/uploads/<path:filename>")
|
||||
@login_required
|
||||
def serve_upload(filename: str):
|
||||
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||
as_attachment = request.args.get("download") == "1"
|
||||
return send_from_directory(
|
||||
app.config["UPLOAD_FOLDER"],
|
||||
filename,
|
||||
as_attachment=as_attachment,
|
||||
download_name=filename if as_attachment else None,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/info/<page>")
|
||||
|
||||
@@ -133,6 +133,11 @@ h3 {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
@@ -155,6 +160,34 @@ input[type="file"] {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
margin: -0.2rem 0 0.1rem;
|
||||
color: rgba(31, 31, 31, 0.72);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#extra-file-inputs {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.extra-file-input {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.upload-count {
|
||||
margin: 0.2rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-file-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: rgba(31, 31, 31, 0.82);
|
||||
max-height: 9rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
@@ -186,19 +219,199 @@ input[type="file"] {
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gallery-card {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(39, 66, 53, 0.12);
|
||||
border-radius: 14px;
|
||||
padding: 0.6rem;
|
||||
box-shadow: 0 6px 20px rgba(39, 66, 53, 0.08);
|
||||
}
|
||||
|
||||
.gallery-media {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gallery-item figcaption {
|
||||
margin-top: 0.55rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.gallery-delete-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #8a2f2f;
|
||||
}
|
||||
|
||||
.gallery-delete-btn {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.85);
|
||||
background: rgba(138, 47, 47, 0.92);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.gallery-delete-btn svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.gallery-delete-btn:hover {
|
||||
background: #7b2727;
|
||||
}
|
||||
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: none;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.lightbox.is-open {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: min(92vw, 1200px);
|
||||
max-height: 86vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.45);
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.lightbox-close,
|
||||
.lightbox-download,
|
||||
.lightbox-nav {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 20, 20, 0.6);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lightbox-close,
|
||||
.lightbox-download {
|
||||
top: 1rem;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
right: 1rem;
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lightbox-counter {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 20, 20, 0.6);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lightbox-download {
|
||||
right: 4rem;
|
||||
}
|
||||
|
||||
.lightbox-download svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.lightbox-nav {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
opacity: 1;
|
||||
transition: opacity 420ms ease, background-color 220ms ease;
|
||||
}
|
||||
|
||||
.lightbox-prev {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.lightbox-next {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.lightbox-controls-hidden .lightbox-nav {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition-delay: 120ms;
|
||||
}
|
||||
|
||||
.lightbox-close:hover,
|
||||
.lightbox-download:hover,
|
||||
.lightbox-nav:hover {
|
||||
background: rgba(20, 20, 20, 0.82);
|
||||
}
|
||||
|
||||
.lightbox-image.is-fading {
|
||||
animation: lightbox-fade 220ms ease;
|
||||
}
|
||||
|
||||
@keyframes lightbox-fade {
|
||||
from {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
@@ -283,3 +496,25 @@ input[type="file"] {
|
||||
background-position: center 24%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-layout {
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 560px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.login-layout .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-layout .hero {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-layout .form-card {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
|
||||
@@ -5,14 +5,191 @@
|
||||
{% if images %}
|
||||
<div class="gallery-grid">
|
||||
{% for image in images %}
|
||||
<figure class="gallery-item">
|
||||
<a href="{{ url_for('serve_upload', filename=image['filename']) }}" target="_blank" rel="noopener">
|
||||
<img src="{{ url_for('serve_upload', filename=image['filename']) }}" alt="{{ t('gallery_image_alt').format(name=image['uploaded_by']) }}" loading="lazy" />
|
||||
</a>
|
||||
<figcaption>{{ t('gallery_uploaded_by').format(name=image['uploaded_by']) }}</figcaption>
|
||||
<figure class="gallery-item gallery-card">
|
||||
<div class="gallery-media">
|
||||
<a
|
||||
href="{{ url_for('serve_upload', filename=image['filename']) }}"
|
||||
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" />
|
||||
</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']) }}">
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<figcaption>{{ t('gallery_uploaded_by').format(name=image['uploaded_by_name']) }}</figcaption>
|
||||
</figure>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="gallery-lightbox" aria-hidden="true">
|
||||
<button class="lightbox-close" type="button" aria-label="Close">×</button>
|
||||
<div class="lightbox-counter" id="lightbox-counter">1 / 1</div>
|
||||
<a class="lightbox-download" id="lightbox-download" href="#" aria-label="{{ t('download') }}" title="{{ t('download') }}">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 3a1 1 0 0 1 1 1v8.59l2.3-2.29a1 1 0 1 1 1.4 1.41l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.41L11 12.59V4a1 1 0 0 1 1-1zm-7 14a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button class="lightbox-nav lightbox-prev" type="button" aria-label="Previous image">‹</button>
|
||||
<img class="lightbox-image" id="lightbox-image" alt="" />
|
||||
<button class="lightbox-nav lightbox-next" type="button" aria-label="Next image">›</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const lightbox = document.getElementById("gallery-lightbox");
|
||||
const lightboxImage = document.getElementById("lightbox-image");
|
||||
const lightboxDownload = document.getElementById("lightbox-download");
|
||||
const lightboxCounter = document.getElementById("lightbox-counter");
|
||||
const closeBtn = lightbox.querySelector(".lightbox-close");
|
||||
const prevBtn = lightbox.querySelector(".lightbox-prev");
|
||||
const nextBtn = lightbox.querySelector(".lightbox-next");
|
||||
const openButtons = Array.from(document.querySelectorAll(".gallery-open"));
|
||||
const images = [
|
||||
{% for image in images %}
|
||||
{
|
||||
src: {{ url_for('serve_upload', filename=image['filename'])|tojson }},
|
||||
download: {{ url_for('serve_upload', filename=image['filename'], download=1)|tojson }},
|
||||
alt: {{ t('gallery_image_alt').format(name=image['uploaded_by_name'])|tojson }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
let currentIndex = 0;
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let didSwipe = false;
|
||||
let controlsTimer = null;
|
||||
|
||||
const scheduleHideControls = () => {
|
||||
clearTimeout(controlsTimer);
|
||||
controlsTimer = window.setTimeout(() => {
|
||||
lightbox.classList.add("lightbox-controls-hidden");
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const showControlsTemporarily = () => {
|
||||
lightbox.classList.remove("lightbox-controls-hidden");
|
||||
scheduleHideControls();
|
||||
};
|
||||
|
||||
const setImage = (index, options = {}) => {
|
||||
const { revealControls = true } = options;
|
||||
if (!images.length) {
|
||||
return;
|
||||
}
|
||||
currentIndex = (index + images.length) % images.length;
|
||||
const item = images[currentIndex];
|
||||
lightboxImage.classList.remove("is-fading");
|
||||
void lightboxImage.offsetWidth;
|
||||
lightboxImage.classList.add("is-fading");
|
||||
lightboxImage.src = item.src;
|
||||
lightboxImage.alt = item.alt;
|
||||
lightboxDownload.href = item.download;
|
||||
lightboxCounter.textContent = `${currentIndex + 1} / ${images.length}`;
|
||||
if (revealControls) {
|
||||
showControlsTemporarily();
|
||||
}
|
||||
};
|
||||
|
||||
const openLightbox = (index) => {
|
||||
setImage(index);
|
||||
lightbox.classList.add("is-open");
|
||||
lightbox.setAttribute("aria-hidden", "false");
|
||||
document.body.classList.add("no-scroll");
|
||||
showControlsTemporarily();
|
||||
};
|
||||
|
||||
const closeLightbox = () => {
|
||||
lightbox.classList.remove("is-open");
|
||||
lightbox.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("no-scroll");
|
||||
lightbox.classList.remove("lightbox-controls-hidden");
|
||||
clearTimeout(controlsTimer);
|
||||
};
|
||||
|
||||
openButtons.forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openLightbox(Number(link.dataset.imageIndex || 0));
|
||||
});
|
||||
});
|
||||
|
||||
prevBtn.addEventListener("click", () => setImage(currentIndex - 1, { revealControls: true }));
|
||||
nextBtn.addEventListener("click", () => setImage(currentIndex + 1, { revealControls: true }));
|
||||
closeBtn.addEventListener("click", closeLightbox);
|
||||
|
||||
lightbox.addEventListener("mousemove", () => {
|
||||
if (lightbox.classList.contains("is-open")) {
|
||||
showControlsTemporarily();
|
||||
}
|
||||
});
|
||||
|
||||
lightbox.addEventListener("click", (event) => {
|
||||
if (event.target === lightbox) {
|
||||
closeLightbox();
|
||||
} else if (
|
||||
lightbox.classList.contains("is-open") &&
|
||||
event.target.closest(".lightbox-close, .lightbox-download, .lightbox-nav")
|
||||
) {
|
||||
showControlsTemporarily();
|
||||
}
|
||||
});
|
||||
|
||||
lightboxImage.addEventListener("touchstart", (event) => {
|
||||
const point = event.changedTouches[0];
|
||||
touchStartX = point.clientX;
|
||||
touchStartY = point.clientY;
|
||||
didSwipe = false;
|
||||
}, { passive: true });
|
||||
|
||||
lightboxImage.addEventListener("touchend", (event) => {
|
||||
const point = event.changedTouches[0];
|
||||
const deltaX = point.clientX - touchStartX;
|
||||
const deltaY = point.clientY - touchStartY;
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
if (absX < 40 || absX <= absY) {
|
||||
return;
|
||||
}
|
||||
didSwipe = true;
|
||||
if (deltaX < 0) {
|
||||
setImage(currentIndex + 1, { revealControls: false });
|
||||
} else {
|
||||
setImage(currentIndex - 1, { revealControls: false });
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
lightboxImage.addEventListener("click", (event) => {
|
||||
if (didSwipe) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
didSwipe = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!lightbox.classList.contains("is-open")) {
|
||||
return;
|
||||
}
|
||||
showControlsTemporarily();
|
||||
if (event.key === "Escape") {
|
||||
closeLightbox();
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
setImage(currentIndex - 1, { revealControls: true });
|
||||
} else if (event.key === "ArrowRight") {
|
||||
setImage(currentIndex + 1, { revealControls: true });
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<p>{{ t('gallery_empty') }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="hero card">
|
||||
<h1>{{ t('subtitle') }}</h1>
|
||||
<p>{{ t('login_note') }}</p>
|
||||
</section>
|
||||
<div class="login-layout">
|
||||
<section class="hero card">
|
||||
<h1>{{ t('subtitle') }}</h1>
|
||||
<p>{{ t('login_note') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="card form-card">
|
||||
<h2>{{ t('login') }}</h2>
|
||||
<form method="post" action="{{ url_for('login') }}" class="form-grid">
|
||||
<label>
|
||||
{{ t('name') }}
|
||||
<input type="text" name="name" required />
|
||||
</label>
|
||||
<section class="card form-card">
|
||||
<h2>{{ t('login') }}</h2>
|
||||
<form method="post" action="{{ url_for('login') }}" class="form-grid">
|
||||
<label>
|
||||
{{ t('name') }}
|
||||
<input type="text" name="name" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{{ t('event_password') }}
|
||||
<input type="password" name="event_password" required />
|
||||
</label>
|
||||
<label>
|
||||
{{ t('event_password') }}
|
||||
<input type="password" name="event_password" required />
|
||||
</label>
|
||||
|
||||
<button class="btn" type="submit">{{ t('login_submit') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
<button class="btn" type="submit">{{ t('login_submit') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,9 +5,73 @@
|
||||
<form method="post" enctype="multipart/form-data" class="form-grid">
|
||||
<label>
|
||||
{{ t('file') }}
|
||||
<input type="file" name="photo" accept=".jpg,.jpeg,.png" required />
|
||||
<input id="photo-input" type="file" name="photo" accept="image/jpeg,image/png,image/jpg,image/heic,image/heif,.heic,.heif" multiple />
|
||||
</label>
|
||||
<p class="upload-hint">{{ t('upload_multi_hint') }}</p>
|
||||
<div id="extra-file-inputs"></div>
|
||||
<button id="add-file-input" class="btn btn-ghost" type="button">{{ t('add_more_files') }}</button>
|
||||
<p id="upload-selected-count" class="upload-count"></p>
|
||||
<ul id="upload-file-list" class="upload-file-list"></ul>
|
||||
<button class="btn" type="submit">{{ t('upload_submit') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const addBtn = document.getElementById("add-file-input");
|
||||
const extraInputs = document.getElementById("extra-file-inputs");
|
||||
const countEl = document.getElementById("upload-selected-count");
|
||||
const listEl = document.getElementById("upload-file-list");
|
||||
const countTpl = {{ t('upload_selected_count')|tojson }};
|
||||
|
||||
const allInputs = () => Array.from(document.querySelectorAll('input[name="photo"]'));
|
||||
|
||||
const createExtraInput = () => {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "extra-file-input";
|
||||
wrapper.textContent = {{ t('file')|tojson }};
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.name = "photo";
|
||||
input.accept = "image/jpeg,image/png,image/jpg,image/heic,image/heif,.heic,.heif";
|
||||
input.required = false;
|
||||
input.addEventListener("change", renderSelection);
|
||||
|
||||
wrapper.appendChild(input);
|
||||
extraInputs.appendChild(wrapper);
|
||||
};
|
||||
|
||||
const renderSelection = () => {
|
||||
const names = [];
|
||||
allInputs().forEach((input) => {
|
||||
Array.from(input.files || []).forEach((file) => names.push(file.name));
|
||||
});
|
||||
|
||||
if (!names.length) {
|
||||
countEl.textContent = "";
|
||||
listEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
countEl.textContent = countTpl.replace("{count}", String(names.length));
|
||||
listEl.innerHTML = "";
|
||||
names.slice(0, 20).forEach((name) => {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = name;
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
if (names.length > 20) {
|
||||
const more = document.createElement("li");
|
||||
more.textContent = `+ ${names.length - 20} weitere`;
|
||||
listEl.appendChild(more);
|
||||
}
|
||||
};
|
||||
|
||||
allInputs().forEach((input) => input.addEventListener("change", renderSelection));
|
||||
addBtn.addEventListener("click", () => {
|
||||
createExtraInput();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user