Viele neue Features

This commit is contained in:
2026-03-01 13:01:46 +00:00
parent 04a0d2b54d
commit 832199a44d
13 changed files with 903 additions and 210 deletions

View File

@@ -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"]

View File

@@ -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>")

View File

@@ -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;
}
}

View File

@@ -30,6 +30,7 @@
</header>
<main class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}