From 832199a44d7f3fbb5b9388b70bacca1d8c1d8fcd Mon Sep 17 00:00:00 2001
From: Dominic
Date: Sun, 1 Mar 2026 13:01:46 +0000
Subject: [PATCH] Viele neue Features
---
AGENTS.md | 371 +++++++++++++++---------
README.md | 17 ++
backend/Dockerfile | 2 +-
backend/__pycache__/app.cpython-312.pyc | Bin 24251 -> 30057 bytes
backend/app.py | 181 +++++++++---
backend/static/styles.css | 241 ++++++++++++++-
backend/templates/base.html | 1 +
backend/templates/gallery.html | 187 +++++++++++-
backend/templates/login.html | 38 +--
backend/templates/upload.html | 66 ++++-
data/db/app.sqlite3 | Bin 24576 -> 24576 bytes
docker-compose.yml | 1 +
frontend/nginx/default.conf | 8 +-
13 files changed, 903 insertions(+), 210 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index ba71ea0..019a555 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
đź’Ť AGENTS.md
-Wedding App – Agent Specification (Hero + Secure Edition)
+Wedding App – Agent Specification (Hero + Secure + Group Edition)
1. Project Overview
@@ -13,13 +13,13 @@ https://www.svenja-dominic-hochzeit.de/
The entire platform is protected by login.
There is no public content accessible without authentication.
-Core goals:
+Core Goals
-password-protected access (event password)
+invitation-based access system
-RSVP + plus-one selection
+group-based RSVP handling
-photo upload + shared gallery
+photo upload + shared gallery with permission control
modern information pages (schedule, hotels, taxi, location)
@@ -31,33 +31,101 @@ language switch (German / English)
visually polished, modern, mobile-first UI
-2. Access Model (IMPORTANT)
+2. Access Model (IMPORTANT – UPDATED)
The entire site must be login-protected.
No publicly accessible landing page.
-Flow:
+Invitation-Based Authentication Model
+
+Each account represents one invitation.
+
+An invitation can be:
+
+a single person
+
+a family
+
+a couple
+
+any defined group
+
+Even single guests are technically treated as a group with one member.
+
+Login Flow
User visits root URL → redirected to login
User enters:
-event password
+group password
-guest name
+group name (predefined)
On success:
-guest stored in database (if new)
+group loaded from database
session created
redirect to internal start page
-All internal routes must require authentication.
+No free registration allowed.
-3. Internal Start Page Structure (Hero + Dashboard Concept)
+Each group has:
+
+a predefined group name
+
+an individual password
+
+Different groups → different passwords.
+
+Group Management After Login
+
+After login, the group sees:
+
+all predefined members of their invitation
+
+RSVP selection per member
+
+For each member:
+
+attending Yes / No
+
+Only one person per group must log in and manage the responses.
+
+3. Roles System (NEW)
+
+Two roles must exist:
+
+Role: Guest (Default)
+
+Can:
+
+manage RSVP for their group
+
+upload images
+
+delete only their own uploaded images
+
+Cannot:
+
+delete images uploaded by others
+
+Role: Admin (Hosts)
+
+Can:
+
+delete any uploaded image
+
+manage all groups
+
+optionally view attendance overview
+
+No public admin panel required, but role logic must exist internally.
+
+4. Internal Start Page Structure (Hero + Dashboard Concept)
After login, the start page consists of two sections:
@@ -77,6 +145,7 @@ short personal text
smooth scroll transition to dashboard
Purpose:
+
Make the platform feel emotional and elegant, not like a business app.
Section 2 – Dashboard Area
@@ -99,7 +168,7 @@ Taxi
Location
-The dashboard must:
+Dashboard must:
use rounded cards
@@ -109,7 +178,19 @@ consistent spacing
mobile-first responsive layout
-4. Tech Stack (Required)
+Navigation Requirement (NEW)
+
+A back button (arrow icon) must exist:
+
+visible in header area
+
+allows navigation back to previous page
+
+must NOT replace logout button
+
+Logout remains separate and visible.
+
+5. Tech Stack (Required)
Python 3.12
Flask
@@ -121,14 +202,11 @@ Docker + Docker Compose
Frontend:
Jinja2 templates
-
Tailwind via CDN OR lightweight custom CSS
-
No heavy JS frameworks
-
Minimal JavaScript only where needed
-5. UI / UX Requirements (Very Important)
+6. UI / UX Requirements (Very Important)
Visual style:
@@ -149,10 +227,9 @@ clean typography (Google Fonts allowed)
Mobile-first design required.
Minimal clutter.
-
Smooth hover transitions.
-6. Language Switch (DE / EN)
+7. Language Switch (DE / EN)
Must include:
@@ -162,112 +239,37 @@ switch stored in session
no automatic geo-detection
-static text controlled via simple translation dictionary or structure
+static text controlled via simple translation dictionary
-7. Location Page Requirements
+8. RSVP Logic (UPDATED – GROUP BASED)
-Must include:
+Database structure must support:
-Location name (env variable)
+group entity
-Address (env variable)
+group members
-Google Maps embed (iframe)
+Each group contains:
-Prominent button:
+multiple persons
-“Zur Location-Webseite” / “Visit Location Website”
+For each person:
-target="_blank"
+attending (boolean)
-rel="noopener"
+Optional plus-one logic may be removed since groups now define structure.
-Environment variables:
+Persist responses per individual.
-LOCATION_NAME
-LOCATION_ADDRESS
-LOCATION_WEBSITE_URL
-GOOGLE_MAPS_EMBED_URL
-
-Google Maps Privacy Requirement
-
-Google Maps must NOT load automatically.
-
-Implement a 2-click solution:
-
-Show placeholder container
-
-Display privacy notice:
-“Zur Anzeige der Karte werden Daten an Google übertragen.”
-
-Only after user click → load iframe dynamically
-
-No global cookie banner required.
-
-8. Authentication Requirements
-
-Event password stored in environment variable:
-
-EVENT_PASSWORD
-
-Guest provides:
-
-event password
-
-name
-
-Use Flask sessions.
-
-No:
-
-email verification
-
-role system
-
-admin panel
-
-9. Database Schema
-
-Table: guests
-
-id (PK)
-
-name (required)
-
-attending (boolean, nullable)
-
-plus_one (boolean, default False)
-
-created_at (timestamp)
-
-Table: uploads
-
-id (PK)
-
-filename
-
-uploaded_by (guest id)
-
-uploaded_at (timestamp)
-
-SQLite only.
-
-10. RSVP Logic
-
-Guest selects:
-
-attending Yes / No
-
-plus_one only visible if attending Yes
-
-Persist to database.
-
-11. Upload Requirements
+9. Image Upload & Gallery System (UPDATED)
+Upload Requirements
Allowed types:
jpg
+
jpeg
+
png
Must:
@@ -286,26 +288,123 @@ store files in /uploads
store reference in database
+Uploads must support:
+
+mobile gallery uploads (iOS / Android compatible input field)
+
Optional but recommended:
remove EXIF metadata before saving
-12. Gallery Requirements
+Gallery Requirements
-All guests see all images
+All authenticated guests see all images.
-Responsive grid layout
+Responsive grid layout.
-Click → larger view (simple modal)
+Click → larger modal view.
-No download tracking
+Image Permissions (NEW)
-13. Legal Pages (Important)
+Guest:
+
+may delete only images where:
+image.uploaded_by == current_user.id
+
+Admin:
+
+may delete any image
+
+Every image must have:
+
+visible download button
+
+direct file download (no right-click dependency)
+
+Optional:
+
+future ZIP export of all images
+
+10. Location Page Requirements
+
+Must include:
+
+Location name (env variable)
+
+Address (env variable)
+
+Google Maps embed (iframe)
+
+Prominent button:
+
+“Zur Location-Webseite” / “Visit Location Website”
+
+target="_blank"
+rel="noopener"
+
+Environment variables:
+
+LOCATION_NAME
+LOCATION_ADDRESS
+LOCATION_WEBSITE_URL
+GOOGLE_MAPS_EMBED_URL
+
+Google Maps Privacy Requirement
+
+Google Maps must NOT load automatically.
+
+Implement 2-click solution:
+
+Show placeholder container
+
+Display privacy notice
+“Zur Anzeige der Karte werden Daten an Google übertragen.”
+
+Only after user click → load iframe dynamically
+
+No global cookie banner required.
+
+11. Database Schema (UPDATED)
+
+Table: groups
+
+id (PK)
+
+name (required)
+
+password_hash
+
+role (guest / admin)
+
+created_at
+
+Table: group_members
+
+id (PK)
+
+group_id (FK)
+
+name
+
+attending (boolean, nullable)
+
+Table: uploads
+
+id (PK)
+
+filename
+
+uploaded_by (group id)
+
+uploaded_at (timestamp)
+
+SQLite only.
+
+12. Legal Pages (Important)
Must implement:
/datenschutz
-
/impressum
Both:
@@ -314,15 +413,9 @@ accessible without login (legal requirement)
linked in footer
-always visible in footer
+always visible
-No cookie banner required because:
-
-only technically necessary session cookies used
-
-Google Maps loaded via 2-click solution
-
-14. Dependency Management Rules
+13. Dependency Management Rules
Use uv.
@@ -340,7 +433,7 @@ Docker must run:
uv sync --frozen --no-dev
-15. Docker Requirements
+14. Docker Requirements
Base image:
@@ -356,36 +449,38 @@ run uv sync --frozen --no-dev
expose port 8000
-start with:
+Start with:
uv run gunicorn -b 0.0.0.0:8000 app:app
Uploads + SQLite database must use persistent volumes.
-16. Non-Goals (Strict)
+15. Non-Goals (Strict)
Do NOT implement:
-Admin dashboards
+email systemsnur
-Email systems
-
-Payment systems
+payment systems
OAuth
-Cloud storage
+cloud storage
-Microservices
+microservices
-Tracking tools
+tracking tools
-Analytics tools
+analytics tools
-17. Design Philosophy
+16. Design Philosophy
Aesthetic first, but not overengineered.
Simple, maintainable code.
Minimal dependencies.
Excellent mobile UX.
-Elegant, but not playful.
\ No newline at end of file
+Elegant, mature, emotionally warm.
+
+Not playful.
+Not corporate.
+Not overcomplex.
\ No newline at end of file
diff --git a/README.md b/README.md
index 6c2935d..221a087 100644
--- a/README.md
+++ b/README.md
@@ -492,3 +492,20 @@ Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wi
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.
Erstellt mit dem Impressum-Generator von WebsiteWissen.com, dem Ratgeber für Website-Erstellung, Homepage-Baukästen und Shopsysteme. Rechtstext von der Kanzlei Hasselbach.
+
+
+Neu:
+
+Nutzer Rollen Authentifikationsorinzip:
+
+Jeder account = Eine einladung (einladungen gehen an Familien und an Einzelpersonen. Familien trotzdem ein zugang.)
+Auch einzelpersonen also personen die ohne begleitung zur hochzeit kommen gelten als Gruppe.
+
+Ziel ist es das sich zum beispiel aus einer Familie nur einer Anmelden muss und fĂĽr die vordefinierten mitglieder der familie angeben kann ob diese kommen oder nicht. zum beispiel anhand einer ccrollbar oder karten.
+Jede Gruppe erhält ein Passwort. Verschiedene Gruppen haben aber verschiedene Passwörter.
+
+Bilder Uploads: Bilder sollen aus der Galerie wieder gelöscht werden. Aber nur von dem Nutzer der Sie hochgeladen hat. Besonderheit die Gastgeber (Admins) können alle Bilder löschen.
+Bilder Downloadfunktion: Einen Button um Bilder zu downloaden. -> Bilder sollen auch aus der Äppel oder Androidbilder Gallerie geuploadet werden.
+
+Zusätzlicher Button: Zurück beispielsweise in form eines Pfeils. damit man im dashboard zurück manövirieren kann. (Soll den Abmeldebutton aber nicht ersetzen)
+
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 23f7c10..11b8103 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -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"]
diff --git a/backend/__pycache__/app.cpython-312.pyc b/backend/__pycache__/app.cpython-312.pyc
index ed3ca0cce02228a9c026371e184b6084b9dd3f92..ea7ac04defe78c7597c1620cab451d4eb8c36644 100644
GIT binary patch
delta 14068
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
delta 8802
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
ST8^~8rCBPgIZX+>l>RRe4MrsZ
diff --git a/backend/app.py b/backend/app.py
index c2d9608..0ecb65a 100644
--- a/backend/app.py
+++ b/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/")
+@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/")
@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/")
diff --git a/backend/static/styles.css b/backend/static/styles.css
index 82981b2..6b608f7 100644
--- a/backend/static/styles.css
+++ b/backend/static/styles.css
@@ -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;
+ }
+}
diff --git a/backend/templates/base.html b/backend/templates/base.html
index 3c65b70..42b7337 100644
--- a/backend/templates/base.html
+++ b/backend/templates/base.html
@@ -30,6 +30,7 @@
+
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
diff --git a/backend/templates/gallery.html b/backend/templates/gallery.html
index bf2b003..ebc7f89 100644
--- a/backend/templates/gallery.html
+++ b/backend/templates/gallery.html
@@ -5,14 +5,191 @@
{% if images %}
{% for image in images %}
-
-
-
-
- {{ t('gallery_uploaded_by').format(name=image['uploaded_by']) }}
+
+
+ {{ t('gallery_uploaded_by').format(name=image['uploaded_by_name']) }}
{% endfor %}
+
+
+
+
1 / 1
+
+
+
+
+
![]()
+
+
+
+
{% else %}
{{ t('gallery_empty') }}
{% endif %}
diff --git a/backend/templates/login.html b/backend/templates/login.html
index 4f5a421..dded186 100644
--- a/backend/templates/login.html
+++ b/backend/templates/login.html
@@ -1,24 +1,26 @@
{% extends 'base.html' %}
{% block content %}
-
- {{ t('subtitle') }}
- {{ t('login_note') }}
-
+
+
+ {{ t('subtitle') }}
+ {{ t('login_note') }}
+
-
+
{% endblock %}
diff --git a/backend/templates/upload.html b/backend/templates/upload.html
index 38376aa..003e3e0 100644
--- a/backend/templates/upload.html
+++ b/backend/templates/upload.html
@@ -5,9 +5,73 @@
+
+
{% endblock %}
diff --git a/data/db/app.sqlite3 b/data/db/app.sqlite3
index 5eb4dd402231e3475706d32c89d996fcc5710e88..aa2d81d1ef7b2bfdfae7eb55b508a5d571e4894d 100644
GIT binary patch
delta 126
zcmV-^0D=F2zyW~30gxL3Dv=yR0V=UzlP>`hv-B?#AQE!{54sO?4=)bt4y6uY4jT>D
zvk?$m4@X221prqGL1Z#9W-&Q8G%zw|WHdK7V_`8cHDWR_W??vEGcq_hIWB5&WoI%l
gGBzzRGc7PNR53C-H8(mkHZC_fI59Ie0F%03Mz3EXkpKVy
delta 112
zcmZoTz}Rqrae_1>&qNt#MxKocll2*yHh<9-;lli$^uiIpLg)6v8z
z&BQRpz|=I&%+SQx(7?jXEG^O4*wP}&(AX$3#ZoV;Al=Bo$V}J3NY~IZ#K_Rf#N5i%
QSkJ)7)X3Ci@~(I{0G2Er0{{R3
diff --git a/docker-compose.yml b/docker-compose.yml
index 6589797..d46e700 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -26,3 +26,4 @@ services:
- HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026}
- WEDDING_DATE=${WEDDING_DATE:-}
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
+ - MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-268435456}
diff --git a/frontend/nginx/default.conf b/frontend/nginx/default.conf
index 605a77f..30f3058 100644
--- a/frontend/nginx/default.conf
+++ b/frontend/nginx/default.conf
@@ -2,14 +2,20 @@ server {
listen 80;
server_name _;
- client_max_body_size 10M;
+ client_max_body_size 256M;
+ client_body_timeout 300s;
+ send_timeout 300s;
location / {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
+ proxy_request_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+ proxy_connect_timeout 60s;
}
}