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