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

371
AGENTS.md
View File

@@ -1,6 +1,6 @@
💍 AGENTS.md 💍 AGENTS.md
Wedding App Agent Specification (Hero + Secure Edition) Wedding App Agent Specification (Hero + Secure + Group Edition)
1. Project Overview 1. Project Overview
@@ -13,13 +13,13 @@ https://www.svenja-dominic-hochzeit.de/
The entire platform is protected by login. The entire platform is protected by login.
There is no public content accessible without authentication. 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) modern information pages (schedule, hotels, taxi, location)
@@ -31,33 +31,101 @@ language switch (German / English)
visually polished, modern, mobile-first UI visually polished, modern, mobile-first UI
2. Access Model (IMPORTANT) 2. Access Model (IMPORTANT UPDATED)
The entire site must be login-protected. The entire site must be login-protected.
No publicly accessible landing page. 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 visits root URL → redirected to login
User enters: User enters:
event password group password
guest name group name (predefined)
On success: On success:
guest stored in database (if new) group loaded from database
session created session created
redirect to internal start page 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: After login, the start page consists of two sections:
@@ -77,6 +145,7 @@ short personal text
smooth scroll transition to dashboard smooth scroll transition to dashboard
Purpose: Purpose:
Make the platform feel emotional and elegant, not like a business app. Make the platform feel emotional and elegant, not like a business app.
Section 2 Dashboard Area Section 2 Dashboard Area
@@ -99,7 +168,7 @@ Taxi
Location Location
The dashboard must: Dashboard must:
use rounded cards use rounded cards
@@ -109,7 +178,19 @@ consistent spacing
mobile-first responsive layout 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 Python 3.12
Flask Flask
@@ -121,14 +202,11 @@ Docker + Docker Compose
Frontend: Frontend:
Jinja2 templates Jinja2 templates
Tailwind via CDN OR lightweight custom CSS Tailwind via CDN OR lightweight custom CSS
No heavy JS frameworks No heavy JS frameworks
Minimal JavaScript only where needed Minimal JavaScript only where needed
5. UI / UX Requirements (Very Important) 6. UI / UX Requirements (Very Important)
Visual style: Visual style:
@@ -149,10 +227,9 @@ clean typography (Google Fonts allowed)
Mobile-first design required. Mobile-first design required.
Minimal clutter. Minimal clutter.
Smooth hover transitions. Smooth hover transitions.
6. Language Switch (DE / EN) 7. Language Switch (DE / EN)
Must include: Must include:
@@ -162,112 +239,37 @@ switch stored in session
no automatic geo-detection 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 9. Image Upload & Gallery System (UPDATED)
LOCATION_ADDRESS Upload Requirements
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
Allowed types: Allowed types:
jpg jpg
jpeg jpeg
png png
Must: Must:
@@ -286,26 +288,123 @@ store files in /uploads
store reference in database store reference in database
Uploads must support:
mobile gallery uploads (iOS / Android compatible input field)
Optional but recommended: Optional but recommended:
remove EXIF metadata before saving 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: Must implement:
/datenschutz /datenschutz
/impressum /impressum
Both: Both:
@@ -314,15 +413,9 @@ accessible without login (legal requirement)
linked in footer linked in footer
always visible in footer always visible
No cookie banner required because: 13. Dependency Management Rules
only technically necessary session cookies used
Google Maps loaded via 2-click solution
14. Dependency Management Rules
Use uv. Use uv.
@@ -340,7 +433,7 @@ Docker must run:
uv sync --frozen --no-dev uv sync --frozen --no-dev
15. Docker Requirements 14. Docker Requirements
Base image: Base image:
@@ -356,36 +449,38 @@ run uv sync --frozen --no-dev
expose port 8000 expose port 8000
start with: Start with:
uv run gunicorn -b 0.0.0.0:8000 app:app uv run gunicorn -b 0.0.0.0:8000 app:app
Uploads + SQLite database must use persistent volumes. Uploads + SQLite database must use persistent volumes.
16. Non-Goals (Strict) 15. Non-Goals (Strict)
Do NOT implement: Do NOT implement:
Admin dashboards email systemsnur
Email systems payment systems
Payment systems
OAuth 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. Aesthetic first, but not overengineered.
Simple, maintainable code. Simple, maintainable code.
Minimal dependencies. Minimal dependencies.
Excellent mobile UX. Excellent mobile UX.
Elegant, but not playful. Elegant, mature, emotionally warm.
Not playful.
Not corporate.
Not overcomplex.

View File

@@ -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.</p> 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.</p>
<p>Erstellt mit dem <a href="https://impressum-generator.de" rel="dofollow">Impressum-Generator</a> von WebsiteWissen.com, dem Ratgeber für <a href="https://websitewissen.com/website-erstellen" rel="dofollow">Website-Erstellung</a>, <a href="https://websitewissen.com/homepage-baukasten-vergleich" rel="dofollow">Homepage-Baukästen</a> und <a href="https://websitewissen.com/shopsysteme-vergleich" rel="dofollow">Shopsysteme</a>. Rechtstext von der <a href="https://www.kanzlei-hasselbach.de/" rel="dofollow">Kanzlei Hasselbach</a>. <p>Erstellt mit dem <a href="https://impressum-generator.de" rel="dofollow">Impressum-Generator</a> von WebsiteWissen.com, dem Ratgeber für <a href="https://websitewissen.com/website-erstellen" rel="dofollow">Website-Erstellung</a>, <a href="https://websitewissen.com/homepage-baukasten-vergleich" rel="dofollow">Homepage-Baukästen</a> und <a href="https://websitewissen.com/shopsysteme-vergleich" rel="dofollow">Shopsysteme</a>. Rechtstext von der <a href="https://www.kanzlei-hasselbach.de/" rel="dofollow">Kanzlei Hasselbach</a>.
</p> </p>
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)

View File

@@ -10,4 +10,4 @@ RUN uv sync --frozen --no-dev
COPY . . COPY . .
EXPOSE 8000 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, session,
url_for, url_for,
) )
from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
app = Flask(__name__) 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["UPLOAD_FOLDER"] = os.environ.get("UPLOAD_FOLDER", str(base_dir / "uploads"))
app.config["EVENT_PASSWORD"] = os.environ.get("EVENT_PASSWORD", "wedding2026") app.config["EVENT_PASSWORD"] = os.environ.get("EVENT_PASSWORD", "wedding2026")
app.config["HOST_PASSWORD"] = os.environ.get("HOST_PASSWORD", "gastgeber2026") 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_NAME"] = os.environ.get("LOCATION_NAME", "Schlossgarten Venue")
app.config["LOCATION_ADDRESS"] = os.environ.get( app.config["LOCATION_ADDRESS"] = os.environ.get(
"LOCATION_ADDRESS", "Musterstraße 12, 12345 Musterstadt" "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["WEDDING_DATE"] = os.environ.get("WEDDING_DATE", "")
app.config["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME") app.config["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME")
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png"} 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 = { TEXTS = {
"de": { "de": {
"brand": "Svenja & Dominic", "brand": "Svenja & Dominic",
"subtitle": "Willkommen zu unserer Hochzeits-App", "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", "login": "Login",
"name": "Dein Name", "name": "Dein Name",
"event_password": "Event-Passwort", "event_password": "Event-Passwort",
"login_submit": "Weiter zum Gaestebereich", "login_submit": "Weiter zum Gästebereich",
"guest_area": "Gaestebereich", "guest_area": "Gästebereich",
"hello_guest": "Hallo {name}.", "hello_guest": "Hallo {name}.",
"logout": "Abmelden", "logout": "Abmelden",
"rsvp": "RSVP", "rsvp": "RSVP",
@@ -65,6 +75,9 @@ TEXTS = {
"not_attending": "Ich komme nicht", "not_attending": "Ich komme nicht",
"plus_one": "Ich bringe eine Begleitperson mit", "plus_one": "Ich bringe eine Begleitperson mit",
"file": "Bild auswählen", "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", "upload_submit": "Foto hochladen",
"schedule": "Ablauf", "schedule": "Ablauf",
"hotels": "Hotels", "hotels": "Hotels",
@@ -75,27 +88,30 @@ TEXTS = {
"imprint": "Impressum", "imprint": "Impressum",
"hero_headline": "Willkommen zu unserer Hochzeit", "hero_headline": "Willkommen zu unserer Hochzeit",
"hero_text": "Wir freuen uns riesig, diesen besonderen Tag mit euch zu feiern.", "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.", "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).", "taxi_text": "Taxi-Service: 01234 / 567890 (24/7).",
"gallery_uploaded_by": "von {name}", "gallery_uploaded_by": "von {name}",
"gallery_empty": "Noch keine Bilder vorhanden.", "gallery_empty": "Noch keine Bilder vorhanden.",
"gallery_image_alt": "Upload von {name}", "gallery_image_alt": "Upload von {name}",
"flash_enter_name": "Bitte Namen eingeben.", "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_select": "Bitte eine RSVP-Auswahl treffen.",
"flash_rsvp_saved": "RSVP gespeichert.", "flash_rsvp_saved": "RSVP gespeichert.",
"flash_select_image": "Bitte eine Bilddatei auswaehlen.", "flash_select_image": "Bitte eine Bilddatei auswählen.",
"flash_allowed_types": "Nur JPG/JPEG/PNG sind erlaubt.", "flash_allowed_types": "Nur JPG/JPEG/PNG/HEIC/HEIF sind erlaubt.",
"flash_upload_success": "Upload erfolgreich.", "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_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_password": "Gastgeber-Passwort",
"host_access_submit": "Adminbereich oeffnen", "host_access_submit": "Adminbereich öffnen",
"host_stats_title": "Uebersicht", "host_stats_title": "Übersicht",
"total_guests": "Gaeste gesamt", "total_guests": "Gäste gesamt",
"attending_yes": "Zusagen", "attending_yes": "Zusagen",
"attending_no": "Absagen", "attending_no": "Absagen",
"attending_open": "Noch offen", "attending_open": "Noch offen",
@@ -109,6 +125,10 @@ TEXTS = {
"yes": "Ja", "yes": "Ja",
"no": "Nein", "no": "Nein",
"legal_de_authoritative": "Bei rechtlichen Unklarheiten gilt die deutsche Fassung.", "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": { "en": {
"brand": "Svenja & Dominic", "brand": "Svenja & Dominic",
@@ -131,6 +151,9 @@ TEXTS = {
"not_attending": "I cannot attend", "not_attending": "I cannot attend",
"plus_one": "I will bring a plus-one", "plus_one": "I will bring a plus-one",
"file": "Select image", "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", "upload_submit": "Upload photo",
"schedule": "Schedule", "schedule": "Schedule",
"hotels": "Hotels", "hotels": "Hotels",
@@ -153,8 +176,11 @@ TEXTS = {
"flash_rsvp_select": "Please choose an RSVP option.", "flash_rsvp_select": "Please choose an RSVP option.",
"flash_rsvp_saved": "RSVP saved.", "flash_rsvp_saved": "RSVP saved.",
"flash_select_image": "Please select an image file.", "flash_select_image": "Please select an image file.",
"flash_allowed_types": "Only JPG/JPEG/PNG are allowed.", "flash_allowed_types": "Only JPG/JPEG/PNG/HEIC/HEIF are allowed.",
"flash_upload_success": "Upload successful.", "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.", "flash_invalid_host_password": "Invalid host password.",
"host_access_title": "Host Area", "host_access_title": "Host Area",
"host_access_note": "This section is intended for the wedding hosts only.", "host_access_note": "This section is intended for the wedding hosts only.",
@@ -175,6 +201,10 @@ TEXTS = {
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
"legal_de_authoritative": "In case of legal ambiguity, the German version shall prevail.", "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, "t": t,
"lang": get_lang(), "lang": get_lang(),
"guest_name": session.get("guest_name"), "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_name": app.config["LOCATION_NAME"],
"location_address": app.config["LOCATION_ADDRESS"], "location_address": app.config["LOCATION_ADDRESS"],
"location_website_url": app.config["LOCATION_WEBSITE_URL"], "location_website_url": app.config["LOCATION_WEBSITE_URL"],
@@ -278,6 +310,15 @@ def login_required(view):
return wrapped 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: def is_allowed_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@@ -349,6 +390,7 @@ def welcome():
@app.get("/gaestebereich") @app.get("/gaestebereich")
@app.get("/gästebereich")
@login_required @login_required
def guest_area(): def guest_area():
return render_template("guest_area.html") return render_template("guest_area.html")
@@ -442,32 +484,51 @@ def rsvp():
@login_required @login_required
def upload(): def upload():
if request.method == "POST": if request.method == "POST":
file = request.files.get("photo") try:
if file is None or file.filename == "": files = [f for f in request.files.getlist("photo") if f and f.filename]
if not files:
flash(t("flash_select_image")) flash(t("flash_select_image"))
return redirect(url_for("upload")) return redirect(url_for("upload"))
for file in files:
if not is_allowed_file(file.filename): if not is_allowed_file(file.filename):
flash(t("flash_allowed_types")) flash(t("flash_allowed_types"))
return redirect(url_for("upload")) return redirect(url_for("upload"))
mime_type = (file.mimetype or "").lower()
safe_name = secure_filename(file.filename) # Some mobile browsers may omit or vary MIME types for valid images.
ext = safe_name.rsplit(".", 1)[1].lower() if mime_type and mime_type not in ALLOWED_MIME_TYPES:
stored_name = f"{uuid.uuid4().hex}.{ext}" flash(t("flash_allowed_types"))
return redirect(url_for("upload"))
upload_dir = app.config["UPLOAD_FOLDER"] upload_dir = app.config["UPLOAD_FOLDER"]
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)
file.save(os.path.join(upload_dir, stored_name))
db = get_db() db = get_db()
db.execute( 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 (?, ?, ?)", "INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)",
(stored_name, session["guest_id"], datetime.utcnow().isoformat()), upload_rows,
) )
db.commit() db.commit()
flash(t("flash_upload_success")) flash(t("flash_upload_success_count").format(count=len(upload_rows)))
return redirect(url_for("gallery")) 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"))
return render_template("upload.html") return render_template("upload.html")
@@ -478,7 +539,7 @@ def gallery():
db = get_db() db = get_db()
images = db.execute( 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 FROM uploads
JOIN guests ON guests.id = uploads.uploaded_by JOIN guests ON guests.id = uploads.uploaded_by
ORDER BY uploads.id DESC ORDER BY uploads.id DESC
@@ -487,10 +548,44 @@ def gallery():
return render_template("gallery.html", images=images) 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>") @app.get("/uploads/<path:filename>")
@login_required @login_required
def serve_upload(filename: str): 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>") @app.get("/info/<page>")

View File

@@ -133,6 +133,11 @@ h3 {
max-width: 560px; max-width: 560px;
} }
.login-layout {
display: grid;
gap: 1rem;
}
.form-grid { .form-grid {
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
@@ -155,6 +160,34 @@ input[type="file"] {
gap: 0.45rem; 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 { .btn {
display: inline-block; display: inline-block;
border: 0; border: 0;
@@ -186,19 +219,199 @@ input[type="file"] {
.gallery-grid { .gallery-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.8rem; gap: 1rem;
} }
.gallery-item { .gallery-item {
margin: 0; 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 { .gallery-item img {
width: 100%; width: 100%;
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
object-fit: cover; 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 { .stats-grid {
@@ -283,3 +496,25 @@ input[type="file"] {
background-position: center 24%; 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> </header>
<main class="container"> <main class="container">
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}

View File

@@ -5,14 +5,191 @@
{% if images %} {% if images %}
<div class="gallery-grid"> <div class="gallery-grid">
{% for image in images %} {% for image in images %}
<figure class="gallery-item"> <figure class="gallery-item gallery-card">
<a href="{{ url_for('serve_upload', filename=image['filename']) }}" target="_blank" rel="noopener"> <div class="gallery-media">
<img src="{{ url_for('serve_upload', filename=image['filename']) }}" alt="{{ t('gallery_image_alt').format(name=image['uploaded_by']) }}" loading="lazy" /> <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> </a>
<figcaption>{{ t('gallery_uploaded_by').format(name=image['uploaded_by']) }}</figcaption> {% 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> </figure>
{% endfor %} {% endfor %}
</div> </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 %} {% else %}
<p>{{ t('gallery_empty') }}</p> <p>{{ t('gallery_empty') }}</p>
{% endif %} {% endif %}

View File

@@ -1,5 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<div class="login-layout">
<section class="hero card"> <section class="hero card">
<h1>{{ t('subtitle') }}</h1> <h1>{{ t('subtitle') }}</h1>
<p>{{ t('login_note') }}</p> <p>{{ t('login_note') }}</p>
@@ -21,4 +22,5 @@
<button class="btn" type="submit">{{ t('login_submit') }}</button> <button class="btn" type="submit">{{ t('login_submit') }}</button>
</form> </form>
</section> </section>
</div>
{% endblock %} {% endblock %}

View File

@@ -5,9 +5,73 @@
<form method="post" enctype="multipart/form-data" class="form-grid"> <form method="post" enctype="multipart/form-data" class="form-grid">
<label> <label>
{{ t('file') }} {{ 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> </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> <button class="btn" type="submit">{{ t('upload_submit') }}</button>
</form> </form>
</section> </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 %} {% endblock %}

Binary file not shown.

View File

@@ -26,3 +26,4 @@ services:
- HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026} - HOST_PASSWORD=${HOST_PASSWORD:-gastgeber2026}
- WEDDING_DATE=${WEDDING_DATE:-} - WEDDING_DATE=${WEDDING_DATE:-}
- SECRET_KEY=${SECRET_KEY:-change-me-in-production} - SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-268435456}

View File

@@ -2,14 +2,20 @@ server {
listen 80; listen 80;
server_name _; server_name _;
client_max_body_size 10M; client_max_body_size 256M;
client_body_timeout 300s;
send_timeout 300s;
location / { location / {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 60s;
} }
} }