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

Binary file not shown.

View File

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

View File

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