Viele neue Features
This commit is contained in:
371
AGENTS.md
371
AGENTS.md
@@ -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.
|
||||||
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>
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Binary file not shown.
153
backend/app.py
153
backend/app.py
@@ -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>")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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.
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user