Massiv +
This commit is contained in:
38
README.md
38
README.md
@@ -511,154 +511,116 @@ Adminnutzer:
|
||||
|
||||
Bubus (Admingruppe)
|
||||
Mitglieder: Svenja, Dominic
|
||||
Passwort: Bubu!Herz24#
|
||||
|
||||
Standardnutzer:
|
||||
|
||||
Remi
|
||||
Mitglieder: Remi
|
||||
Passwort: Remi#Ring24!
|
||||
|
||||
Chantal
|
||||
Mitglieder: Chantal
|
||||
Passwort: Chan!Tanz24#
|
||||
|
||||
Madeleine
|
||||
Mitglieder: Madeleine
|
||||
Passwort: Madi$Rose24!
|
||||
|
||||
Julie & Daniel
|
||||
Mitglieder: Julie, Daniel
|
||||
Passwort: Juli&Dan24!#
|
||||
|
||||
Tim & Sophie
|
||||
Mitglieder: Tim, Sophie
|
||||
Passwort: Tim+Sofi24!#
|
||||
|
||||
Marcel & Kathrin
|
||||
Mitglieder: Marcel, Kathrin
|
||||
Passwort: Marc&Kath24#
|
||||
|
||||
Familie Olsem
|
||||
Mitglieder: Laura, Sven, Lena, Finn
|
||||
Passwort: Olse!Fam24#?
|
||||
|
||||
Maxime
|
||||
Mitglieder: Maxime, Freund
|
||||
Passwort: Maxi#Love24!
|
||||
|
||||
Familie Löster
|
||||
Mitglieder: Claudia, Mario, Mélodie
|
||||
Passwort: Loes@Ring24#
|
||||
|
||||
Familie Thiels
|
||||
Mitglieder: Matthias, Opa Bernd, Oma Heidi
|
||||
Passwort: Thie$Fest24!
|
||||
|
||||
Familie Gollor
|
||||
Mitglieder: Michael, Christin, Bruno
|
||||
Passwort: Goll%Herz24!
|
||||
|
||||
Monika
|
||||
Mitglieder: Monika
|
||||
Passwort: Moni!Rose24#
|
||||
|
||||
Familie Konrad
|
||||
Mitglieder: Michael, Sandra, Christoph, Alexander
|
||||
Passwort: Konr#Fest24!
|
||||
|
||||
Mark
|
||||
Mitglieder: Mark
|
||||
Passwort: Mark!Gold24#
|
||||
|
||||
Elias
|
||||
Mitglieder: Elias
|
||||
Passwort: Elia$Ring24!
|
||||
|
||||
Milan
|
||||
Mitglieder: Milan
|
||||
Passwort: Mila#Tanz24!
|
||||
|
||||
Familie Wolff
|
||||
Mitglieder: Anja, Bodo
|
||||
Passwort: Wolf!Herz24#
|
||||
|
||||
Anna & Leon
|
||||
Mitglieder: Anna, Leon
|
||||
Passwort: Anna&Leo24!#
|
||||
|
||||
Aryan
|
||||
Mitglieder: Aryan
|
||||
Passwort: Arya!Fest24#
|
||||
|
||||
Sebastian
|
||||
Mitglieder: Sebastian, Olivia
|
||||
Passwort: Seba$Ring24!
|
||||
|
||||
Leander & Heni
|
||||
Mitglieder: Leander, Heni
|
||||
Passwort: Lea&Heni24!#
|
||||
|
||||
Flo
|
||||
Mitglieder: Flo
|
||||
Passwort: Flo!Liebe24#
|
||||
|
||||
Nico & Pia
|
||||
Mitglieder: Nico, Pia
|
||||
Passwort: Nico&Pia24!#
|
||||
|
||||
Kiki
|
||||
Mitglieder: Kiki
|
||||
Passwort: Kiki!Rose24#
|
||||
|
||||
Lana & Eric
|
||||
Mitglieder: Lana, Eric
|
||||
Passwort: Lan&Eric24!#
|
||||
|
||||
Britta
|
||||
Mitglieder: Britta
|
||||
Passwort: Brit!Tanz24#
|
||||
|
||||
Holzi
|
||||
Mitglieder: Holzi
|
||||
Passwort: Holz!Ring24#
|
||||
|
||||
Eirene
|
||||
Mitglieder: Eirene
|
||||
Passwort: Eire$Fest24!
|
||||
|
||||
Family Hynes
|
||||
Mitglieder: Steven, Martha, William, Tim, Steven Jr.
|
||||
Passwort: Hyne#Love24!
|
||||
|
||||
Timbo
|
||||
Mitglieder: Timbo
|
||||
Passwort: Timb!Rose24#
|
||||
|
||||
Karen & Jay
|
||||
Mitglieder: Karen, Jay
|
||||
Passwort: Kare&Jay24!#
|
||||
|
||||
Alina
|
||||
Mitglieder: Alina
|
||||
Passwort: Alin!Gold24#
|
||||
|
||||
Max
|
||||
Mitglieder: Max
|
||||
Passwort: Max!Liebe24#
|
||||
|
||||
Paul & Alix
|
||||
Mitglieder: Paul, Alix
|
||||
Passwort: Paul&Alx24!#
|
||||
|
||||
Alfred & Nadia
|
||||
Mitglieder: Alfred, Nadia
|
||||
Passwort: Alfr&Nad24!#
|
||||
|
||||
Anne-Marie & Erny
|
||||
Mitglieder: Anne-Marie, Erny
|
||||
Passwort: Anne&Ern24!#
|
||||
|
||||
Familie Kieffer
|
||||
Mitglieder: Anny, John, Jana
|
||||
Passwort: Kief!Fest24#
|
||||
|
||||
Binary file not shown.
496
backend/app.py
496
backend/app.py
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from hmac import compare_digest
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
@@ -18,6 +17,7 @@ from flask import (
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -25,16 +25,10 @@ base_dir = Path(__file__).resolve().parent
|
||||
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "change-me-in-production")
|
||||
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(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"
|
||||
)
|
||||
app.config["LOCATION_WEBSITE_URL"] = os.environ.get(
|
||||
"LOCATION_WEBSITE_URL", "https://example.com/location"
|
||||
)
|
||||
app.config["LOCATION_ADDRESS"] = os.environ.get("LOCATION_ADDRESS", "Musterstraße 12, 12345 Musterstadt")
|
||||
app.config["LOCATION_WEBSITE_URL"] = os.environ.get("LOCATION_WEBSITE_URL", "https://example.com/location")
|
||||
app.config["GOOGLE_MAPS_EMBED_URL"] = os.environ.get(
|
||||
"GOOGLE_MAPS_EMBED_URL",
|
||||
"https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d0",
|
||||
@@ -53,37 +47,197 @@ ALLOWED_MIME_TYPES = {
|
||||
"image/heif-sequence",
|
||||
}
|
||||
|
||||
DEFAULT_INVITATION_GROUPS = [
|
||||
{
|
||||
"name": "Bubus",
|
||||
"password": "Bubu!Herz24#",
|
||||
"role": "admin",
|
||||
"members": ["Svenja", "Dominic"],
|
||||
},
|
||||
{"name": "Remi", "password": "Remi#Ring24!", "role": "guest", "members": ["Remi"]},
|
||||
{"name": "Chantal", "password": "Chan!Tanz24#", "role": "guest", "members": ["Chantal"]},
|
||||
{"name": "Madeleine", "password": "Madi$Rose24!", "role": "guest", "members": ["Madeleine"]},
|
||||
{
|
||||
"name": "Julie & Daniel",
|
||||
"password": "Juli&Dan24!#",
|
||||
"role": "guest",
|
||||
"members": ["Julie", "Daniel"],
|
||||
},
|
||||
{
|
||||
"name": "Tim & Sophie",
|
||||
"password": "Tim+Sofi24!#",
|
||||
"role": "guest",
|
||||
"members": ["Tim", "Sophie"],
|
||||
},
|
||||
{
|
||||
"name": "Marcel & Kathrin",
|
||||
"password": "Marc&Kath24#",
|
||||
"role": "guest",
|
||||
"members": ["Marcel", "Kathrin"],
|
||||
},
|
||||
{
|
||||
"name": "Familie Olsem",
|
||||
"password": "Olse!Fam24#?",
|
||||
"role": "guest",
|
||||
"members": ["Laura", "Sven", "Lena", "Finn"],
|
||||
},
|
||||
{
|
||||
"name": "Maxime",
|
||||
"password": "Maxi#Love24!",
|
||||
"role": "guest",
|
||||
"members": ["Maxime", "Freund"],
|
||||
},
|
||||
{
|
||||
"name": "Familie Löster",
|
||||
"password": "Loes@Ring24#",
|
||||
"role": "guest",
|
||||
"members": ["Claudia", "Mario", "Mélodie"],
|
||||
},
|
||||
{
|
||||
"name": "Familie Thiels",
|
||||
"password": "Thie$Fest24!",
|
||||
"role": "guest",
|
||||
"members": ["Matthias", "Opa Bernd", "Oma Heidi"],
|
||||
},
|
||||
{
|
||||
"name": "Familie Gollor",
|
||||
"password": "Goll%Herz24!",
|
||||
"role": "guest",
|
||||
"members": ["Michael", "Christin", "Bruno"],
|
||||
},
|
||||
{"name": "Monika", "password": "Moni!Rose24#", "role": "guest", "members": ["Monika"]},
|
||||
{
|
||||
"name": "Familie Konrad",
|
||||
"password": "Konr#Fest24!",
|
||||
"role": "guest",
|
||||
"members": ["Michael", "Sandra", "Christoph", "Alexander"],
|
||||
},
|
||||
{"name": "Mark", "password": "Mark!Gold24#", "role": "guest", "members": ["Mark"]},
|
||||
{"name": "Elias", "password": "Elia$Ring24!", "role": "guest", "members": ["Elias"]},
|
||||
{"name": "Milan", "password": "Mila#Tanz24!", "role": "guest", "members": ["Milan"]},
|
||||
{
|
||||
"name": "Familie Wolff",
|
||||
"password": "Wolf!Herz24#",
|
||||
"role": "guest",
|
||||
"members": ["Anja", "Bodo"],
|
||||
},
|
||||
{
|
||||
"name": "Anna & Leon",
|
||||
"password": "Anna&Leo24!#",
|
||||
"role": "guest",
|
||||
"members": ["Anna", "Leon"],
|
||||
},
|
||||
{"name": "Aryan", "password": "Arya!Fest24#", "role": "guest", "members": ["Aryan"]},
|
||||
{
|
||||
"name": "Sebastian",
|
||||
"password": "Seba$Ring24!",
|
||||
"role": "guest",
|
||||
"members": ["Sebastian", "Olivia"],
|
||||
},
|
||||
{
|
||||
"name": "Leander & Heni",
|
||||
"password": "Lea&Heni24!#",
|
||||
"role": "guest",
|
||||
"members": ["Leander", "Heni"],
|
||||
},
|
||||
{"name": "Flo", "password": "Flo!Liebe24#", "role": "guest", "members": ["Flo"]},
|
||||
{
|
||||
"name": "Nico & Pia",
|
||||
"password": "Nico&Pia24!#",
|
||||
"role": "guest",
|
||||
"members": ["Nico", "Pia"],
|
||||
},
|
||||
{"name": "Kiki", "password": "Kiki!Rose24#", "role": "guest", "members": ["Kiki"]},
|
||||
{
|
||||
"name": "Lana & Eric",
|
||||
"password": "Lan&Eric24!#",
|
||||
"role": "guest",
|
||||
"members": ["Lana", "Eric"],
|
||||
},
|
||||
{"name": "Britta", "password": "Brit!Tanz24#", "role": "guest", "members": ["Britta"]},
|
||||
{"name": "Holzi", "password": "Holz!Ring24#", "role": "guest", "members": ["Holzi"]},
|
||||
{"name": "Eirene", "password": "Eire$Fest24!", "role": "guest", "members": ["Eirene"]},
|
||||
{
|
||||
"name": "Family Hynes",
|
||||
"password": "Hyne#Love24!",
|
||||
"role": "guest",
|
||||
"members": ["Steven", "Martha", "William", "Tim", "Steven Jr."],
|
||||
},
|
||||
{"name": "Timbo", "password": "Timb!Rose24#", "role": "guest", "members": ["Timbo"]},
|
||||
{
|
||||
"name": "Karen & Jay",
|
||||
"password": "Kare&Jay24!#",
|
||||
"role": "guest",
|
||||
"members": ["Karen", "Jay"],
|
||||
},
|
||||
{"name": "Alina", "password": "Alin!Gold24#", "role": "guest", "members": ["Alina"]},
|
||||
{"name": "Max", "password": "Max!Liebe24#", "role": "guest", "members": ["Max"]},
|
||||
{
|
||||
"name": "Paul & Alix",
|
||||
"password": "Paul&Alx24!#",
|
||||
"role": "guest",
|
||||
"members": ["Paul", "Alix"],
|
||||
},
|
||||
{
|
||||
"name": "Alfred & Nadia",
|
||||
"password": "Alfr&Nad24!#",
|
||||
"role": "guest",
|
||||
"members": ["Alfred", "Nadia"],
|
||||
},
|
||||
{
|
||||
"name": "Anne-Marie & Erny",
|
||||
"password": "Anne&Ern24!#",
|
||||
"role": "guest",
|
||||
"members": ["Anne-Marie", "Erny"],
|
||||
},
|
||||
{
|
||||
"name": "Familie Kieffer",
|
||||
"password": "Kief!Fest24#",
|
||||
"role": "guest",
|
||||
"members": ["Anny", "John", "Jana"],
|
||||
},
|
||||
]
|
||||
AGE_REQUIRED_NAMES = {"Lena", "Finn", "Fin", "Bruno"}
|
||||
|
||||
TEXTS = {
|
||||
"de": {
|
||||
"brand": "Svenja & Dominic",
|
||||
"subtitle": "Willkommen zu unserer Hochzeits-App",
|
||||
"login_note": "Passwortgeschützter Zugriff für unsere Gäste.",
|
||||
"login": "Login",
|
||||
"name": "Dein Name",
|
||||
"event_password": "Event-Passwort",
|
||||
"group_name": "Gruppenname",
|
||||
"group_password": "Gruppenpasswort",
|
||||
"login_submit": "Weiter zum Gästebereich",
|
||||
"guest_area": "Gästebereich",
|
||||
"hello_guest": "Hallo {name}.",
|
||||
"logout": "Abmelden",
|
||||
"rsvp": "RSVP",
|
||||
"upload": "Upload",
|
||||
"upload_intro": "Hier könnt ihr Fotos von der Hochzeit hochladen.",
|
||||
"gallery": "Galerie",
|
||||
"host_area": "Gastgeberbereich",
|
||||
"info": "Infos",
|
||||
"save": "Speichern",
|
||||
"attending": "Ich komme",
|
||||
"not_attending": "Ich komme nicht",
|
||||
"plus_one": "Ich bringe eine Begleitperson mit",
|
||||
"member_status": "Rückmeldung",
|
||||
"member_age": "Alter",
|
||||
"member_age_hint": "Bitte Alter eintragen, wenn die Person kommt.",
|
||||
"rsvp_members_intro": "Bitte gib die Zu- oder Absage pro Mitglied an.",
|
||||
"file": "Bild auswählen",
|
||||
"upload_picker_hint": "Tippe hier, um ein oder mehrere Bilder auszuwä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_ready": "Bereit zum Hochladen",
|
||||
"upload_submit": "Foto hochladen",
|
||||
"schedule": "Ablauf",
|
||||
"hotels": "Hotels",
|
||||
"taxi": "Taxi",
|
||||
"location": "Location",
|
||||
"visit_location": "Zur Location-Webseite",
|
||||
"maps_privacy_notice": "Zur Anzeige der Karte werden Daten an Google übertragen.",
|
||||
"maps_load_button": "Google Maps anzeigen",
|
||||
"privacy": "Datenschutz",
|
||||
"imprint": "Impressum",
|
||||
"hero_headline": "Willkommen zu unserer Hochzeit",
|
||||
@@ -95,71 +249,76 @@ TEXTS = {
|
||||
"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": "Ungültiges Event-Passwort.",
|
||||
"flash_rsvp_select": "Bitte eine RSVP-Auswahl treffen.",
|
||||
"flash_enter_group_name": "Bitte Gruppenname eingeben.",
|
||||
"flash_invalid_group_login": "Ungültiger Gruppenname oder Passwort.",
|
||||
"flash_rsvp_select": "Bitte für alle Mitglieder eine RSVP-Auswahl treffen.",
|
||||
"flash_rsvp_age_missing": "Bitte Alter für {name} angeben.",
|
||||
"flash_rsvp_age_invalid": "Bitte ein gültiges Alter (0-17) für {name} angeben.",
|
||||
"flash_rsvp_saved": "RSVP gespeichert.",
|
||||
"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_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 für das Brautpaar vorgesehen.",
|
||||
"host_password": "Gastgeber-Passwort",
|
||||
"host_access_submit": "Adminbereich öffnen",
|
||||
"host_stats_title": "Übersicht",
|
||||
"total_guests": "Gäste gesamt",
|
||||
"attending_yes": "Zusagen",
|
||||
"attending_no": "Absagen",
|
||||
"attending_open": "Noch offen",
|
||||
"plus_one_total": "Begleitpersonen",
|
||||
"host_table_name": "Name",
|
||||
"host_table_group": "Gruppe",
|
||||
"host_table_member": "Mitglied",
|
||||
"host_table_status": "RSVP",
|
||||
"host_table_plus_one": "Begleitperson",
|
||||
"host_table_age": "Alter",
|
||||
"status_yes": "Kommt",
|
||||
"status_no": "Kommt nicht",
|
||||
"status_open": "Offen",
|
||||
"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.",
|
||||
"flash_admin_only": "Dieser Bereich ist nur für Admins verfügbar.",
|
||||
"dashboard": "Dashboard",
|
||||
"back": "Zurück",
|
||||
},
|
||||
"en": {
|
||||
"brand": "Svenja & Dominic",
|
||||
"subtitle": "Welcome to our wedding app",
|
||||
"login_note": "Password-protected access for our guests.",
|
||||
"login": "Login",
|
||||
"name": "Your name",
|
||||
"event_password": "Event password",
|
||||
"group_name": "Group name",
|
||||
"group_password": "Group password",
|
||||
"login_submit": "Open guest area",
|
||||
"guest_area": "Guest Area",
|
||||
"hello_guest": "Hello {name}.",
|
||||
"logout": "Logout",
|
||||
"rsvp": "RSVP",
|
||||
"upload": "Upload",
|
||||
"upload_intro": "Upload your wedding photos here.",
|
||||
"gallery": "Gallery",
|
||||
"host_area": "Host Area",
|
||||
"info": "Info",
|
||||
"save": "Save",
|
||||
"attending": "I will attend",
|
||||
"not_attending": "I cannot attend",
|
||||
"plus_one": "I will bring a plus-one",
|
||||
"member_status": "RSVP status",
|
||||
"member_age": "Age",
|
||||
"member_age_hint": "Please add age if this person attends.",
|
||||
"rsvp_members_intro": "Please submit attendance for each group member.",
|
||||
"file": "Select image",
|
||||
"upload_picker_hint": "Tap here to select one or more images.",
|
||||
"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_ready": "Ready to upload",
|
||||
"upload_submit": "Upload photo",
|
||||
"schedule": "Schedule",
|
||||
"hotels": "Hotels",
|
||||
"taxi": "Taxi",
|
||||
"location": "Location",
|
||||
"visit_location": "Visit location website",
|
||||
"maps_privacy_notice": "To display the map, data will be transferred to Google.",
|
||||
"maps_load_button": "Show Google Maps",
|
||||
"privacy": "Privacy",
|
||||
"imprint": "Imprint",
|
||||
"hero_headline": "Welcome to our wedding",
|
||||
@@ -171,40 +330,37 @@ TEXTS = {
|
||||
"gallery_uploaded_by": "by {name}",
|
||||
"gallery_empty": "No photos available yet.",
|
||||
"gallery_image_alt": "Uploaded by {name}",
|
||||
"flash_enter_name": "Please enter your name.",
|
||||
"flash_invalid_password": "Invalid event password.",
|
||||
"flash_rsvp_select": "Please choose an RSVP option.",
|
||||
"flash_enter_group_name": "Please enter group name.",
|
||||
"flash_invalid_group_login": "Invalid group name or password.",
|
||||
"flash_rsvp_select": "Please choose RSVP values for all members.",
|
||||
"flash_rsvp_age_missing": "Please enter age for {name}.",
|
||||
"flash_rsvp_age_invalid": "Please enter a valid age (0-17) for {name}.",
|
||||
"flash_rsvp_saved": "RSVP saved.",
|
||||
"flash_select_image": "Please select an image file.",
|
||||
"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.",
|
||||
"host_password": "Host password",
|
||||
"host_access_submit": "Open admin area",
|
||||
"host_stats_title": "Overview",
|
||||
"total_guests": "Total guests",
|
||||
"attending_yes": "Attending",
|
||||
"attending_no": "Declined",
|
||||
"attending_open": "Pending",
|
||||
"plus_one_total": "Plus-ones",
|
||||
"host_table_name": "Name",
|
||||
"host_table_group": "Group",
|
||||
"host_table_member": "Member",
|
||||
"host_table_status": "RSVP",
|
||||
"host_table_plus_one": "Plus-one",
|
||||
"host_table_age": "Age",
|
||||
"status_yes": "Attending",
|
||||
"status_no": "Not attending",
|
||||
"status_open": "Pending",
|
||||
"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.",
|
||||
"flash_admin_only": "This area is available to admins only.",
|
||||
"dashboard": "Dashboard",
|
||||
"back": "Back",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -236,12 +392,17 @@ def get_hero_image_asset() -> str:
|
||||
|
||||
@app.context_processor
|
||||
def inject_common() -> dict:
|
||||
role = session.get("role", "guest")
|
||||
group_id = session.get("group_id")
|
||||
return {
|
||||
"t": t,
|
||||
"lang": get_lang(),
|
||||
"guest_name": session.get("guest_name"),
|
||||
"guest_id": session.get("guest_id"),
|
||||
"is_host": bool(session.get("is_host")),
|
||||
"guest_name": session.get("group_name"),
|
||||
"guest_id": group_id,
|
||||
"group_name": session.get("group_name"),
|
||||
"group_id": group_id,
|
||||
"is_host": role == "admin",
|
||||
"is_admin": role == "admin",
|
||||
"location_name": app.config["LOCATION_NAME"],
|
||||
"location_address": app.config["LOCATION_ADDRESS"],
|
||||
"location_website_url": app.config["LOCATION_WEBSITE_URL"],
|
||||
@@ -268,6 +429,11 @@ def close_db(_error) -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
|
||||
rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||
return {str(row[1]) for row in rows}
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
db_path = app.config["DB_PATH"]
|
||||
db_dir = os.path.dirname(db_path)
|
||||
@@ -277,15 +443,32 @@ def init_db() -> None:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS guests (
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
attending INTEGER,
|
||||
plus_one INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'guest',
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
attending INTEGER,
|
||||
child_age INTEGER,
|
||||
requires_age INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(group_id) REFERENCES groups(id),
|
||||
UNIQUE(group_id, name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
@@ -293,23 +476,83 @@ def init_db() -> None:
|
||||
filename TEXT NOT NULL,
|
||||
uploaded_by INTEGER NOT NULL,
|
||||
uploaded_at TEXT NOT NULL,
|
||||
FOREIGN KEY(uploaded_by) REFERENCES guests(id)
|
||||
FOREIGN KEY(uploaded_by) REFERENCES groups(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
member_cols = table_columns(conn, "group_members")
|
||||
if "child_age" not in member_cols:
|
||||
conn.execute("ALTER TABLE group_members ADD COLUMN child_age INTEGER")
|
||||
if "requires_age" not in member_cols:
|
||||
conn.execute("ALTER TABLE group_members ADD COLUMN requires_age INTEGER NOT NULL DEFAULT 0")
|
||||
if "created_at" not in member_cols:
|
||||
conn.execute("ALTER TABLE group_members ADD COLUMN created_at TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
group_cols = table_columns(conn, "groups")
|
||||
if "role" not in group_cols:
|
||||
conn.execute("ALTER TABLE groups ADD COLUMN role TEXT NOT NULL DEFAULT 'guest'")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def seed_default_groups() -> None:
|
||||
db = get_db()
|
||||
existing = db.execute("SELECT COUNT(*) AS c FROM groups").fetchone()
|
||||
if int(existing["c"] or 0) > 0:
|
||||
return
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
for entry in DEFAULT_INVITATION_GROUPS:
|
||||
password_hash = generate_password_hash(entry["password"])
|
||||
cursor = db.execute(
|
||||
"INSERT INTO groups (name, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
|
||||
(entry["name"], password_hash, entry["role"], now),
|
||||
)
|
||||
group_id = int(cursor.lastrowid)
|
||||
member_rows = []
|
||||
for member_name in entry["members"]:
|
||||
member_rows.append(
|
||||
(
|
||||
group_id,
|
||||
member_name,
|
||||
1 if member_name in AGE_REQUIRED_NAMES else 0,
|
||||
now,
|
||||
)
|
||||
)
|
||||
|
||||
db.executemany(
|
||||
"""
|
||||
INSERT INTO group_members (group_id, name, requires_age, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
member_rows,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def login_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if "guest_id" not in session:
|
||||
if "group_id" not in session:
|
||||
return redirect(url_for("landing"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def admin_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if session.get("role") != "admin":
|
||||
flash(t("flash_admin_only"))
|
||||
return redirect(url_for("guest_area"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
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))
|
||||
@@ -323,21 +566,6 @@ def is_allowed_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def upsert_guest(name: str) -> int:
|
||||
now = datetime.utcnow().isoformat()
|
||||
db = get_db()
|
||||
row = db.execute("SELECT id FROM guests WHERE name = ?", (name,)).fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
|
||||
cursor = db.execute(
|
||||
"INSERT INTO guests (name, created_at) VALUES (?, ?)",
|
||||
(name, now),
|
||||
)
|
||||
db.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -345,28 +573,33 @@ def health():
|
||||
|
||||
@app.get("/")
|
||||
def landing():
|
||||
if "guest_id" in session:
|
||||
if "group_id" in session:
|
||||
return redirect(url_for("welcome"))
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login():
|
||||
name = (request.form.get("name") or "").strip()
|
||||
event_password = request.form.get("event_password") or ""
|
||||
group_name = (request.form.get("group_name") or "").strip()
|
||||
group_password = request.form.get("group_password") or ""
|
||||
|
||||
if not name:
|
||||
flash(t("flash_enter_name"))
|
||||
if not group_name:
|
||||
flash(t("flash_enter_group_name"))
|
||||
return redirect(url_for("landing"))
|
||||
|
||||
if event_password != app.config["EVENT_PASSWORD"]:
|
||||
flash(t("flash_invalid_password"))
|
||||
db = get_db()
|
||||
group = db.execute(
|
||||
"SELECT id, name, password_hash, role FROM groups WHERE LOWER(name) = LOWER(?)",
|
||||
(group_name,),
|
||||
).fetchone()
|
||||
|
||||
if group is None or not check_password_hash(str(group["password_hash"]), group_password):
|
||||
flash(t("flash_invalid_group_login"))
|
||||
return redirect(url_for("landing"))
|
||||
|
||||
guest_id = upsert_guest(name)
|
||||
session["guest_id"] = guest_id
|
||||
session["guest_name"] = name
|
||||
session.pop("is_host", None)
|
||||
session["group_id"] = int(group["id"])
|
||||
session["group_name"] = str(group["name"])
|
||||
session["role"] = str(group["role"] or "guest")
|
||||
return redirect(url_for("welcome"))
|
||||
|
||||
|
||||
@@ -396,21 +629,10 @@ def guest_area():
|
||||
return render_template("guest_area.html")
|
||||
|
||||
|
||||
@app.route("/gastgeberbereich", methods=["GET", "POST"])
|
||||
@app.route("/gastgeberbereich", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def host_area():
|
||||
if request.method == "POST":
|
||||
host_password = request.form.get("host_password") or ""
|
||||
expected = app.config.get("HOST_PASSWORD", "")
|
||||
if not expected or not compare_digest(host_password, expected):
|
||||
flash(t("flash_invalid_host_password"))
|
||||
return redirect(url_for("host_area"))
|
||||
session["is_host"] = True
|
||||
return redirect(url_for("host_area"))
|
||||
|
||||
if not session.get("is_host"):
|
||||
return render_template("host_area.html", unlocked=False)
|
||||
|
||||
db = get_db()
|
||||
stats_row = db.execute(
|
||||
"""
|
||||
@@ -418,16 +640,17 @@ def host_area():
|
||||
COUNT(*) AS total_guests,
|
||||
SUM(CASE WHEN attending = 1 THEN 1 ELSE 0 END) AS attending_yes,
|
||||
SUM(CASE WHEN attending = 0 THEN 1 ELSE 0 END) AS attending_no,
|
||||
SUM(CASE WHEN attending IS NULL THEN 1 ELSE 0 END) AS attending_open,
|
||||
SUM(CASE WHEN attending = 1 AND plus_one = 1 THEN 1 ELSE 0 END) AS plus_one_total
|
||||
FROM guests
|
||||
SUM(CASE WHEN attending IS NULL THEN 1 ELSE 0 END) AS attending_open
|
||||
FROM group_members
|
||||
"""
|
||||
).fetchone()
|
||||
guests = db.execute(
|
||||
|
||||
members = db.execute(
|
||||
"""
|
||||
SELECT name, attending, plus_one
|
||||
FROM guests
|
||||
ORDER BY name COLLATE NOCASE ASC
|
||||
SELECT groups.name AS group_name, group_members.name, group_members.attending, group_members.child_age, group_members.requires_age
|
||||
FROM group_members
|
||||
JOIN groups ON groups.id = group_members.group_id
|
||||
ORDER BY groups.name COLLATE NOCASE ASC, group_members.name COLLATE NOCASE ASC
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
@@ -436,9 +659,8 @@ def host_area():
|
||||
"attending_yes": int(stats_row["attending_yes"] or 0),
|
||||
"attending_no": int(stats_row["attending_no"] or 0),
|
||||
"attending_open": int(stats_row["attending_open"] or 0),
|
||||
"plus_one_total": int(stats_row["plus_one_total"] or 0),
|
||||
}
|
||||
return render_template("host_area.html", unlocked=True, stats=stats, guests=guests)
|
||||
return render_template("host_area.html", stats=stats, members=members)
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
@@ -451,33 +673,59 @@ def dashboard():
|
||||
@login_required
|
||||
def rsvp():
|
||||
db = get_db()
|
||||
current_group_id = int(session["group_id"])
|
||||
|
||||
members = db.execute(
|
||||
"""
|
||||
SELECT id, name, attending, child_age, requires_age
|
||||
FROM group_members
|
||||
WHERE group_id = ?
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(current_group_id,),
|
||||
).fetchall()
|
||||
|
||||
if request.method == "POST":
|
||||
attending_raw = request.form.get("attending")
|
||||
plus_one = 1 if request.form.get("plus_one") == "on" else 0
|
||||
updates = []
|
||||
for member in members:
|
||||
member_id = int(member["id"])
|
||||
attending_raw = request.form.get(f"attending_{member_id}")
|
||||
|
||||
if attending_raw not in {"yes", "no"}:
|
||||
flash(t("flash_rsvp_select"))
|
||||
return redirect(url_for("rsvp"))
|
||||
if attending_raw not in {"yes", "no"}:
|
||||
flash(t("flash_rsvp_select"))
|
||||
return redirect(url_for("rsvp"))
|
||||
|
||||
attending = 1 if attending_raw == "yes" else 0
|
||||
if not attending:
|
||||
plus_one = 0
|
||||
attending = 1 if attending_raw == "yes" else 0
|
||||
child_age = None
|
||||
if attending == 1 and int(member["requires_age"] or 0) == 1:
|
||||
age_raw = (request.form.get(f"age_{member_id}") or "").strip()
|
||||
if not age_raw:
|
||||
flash(t("flash_rsvp_age_missing").format(name=member["name"]))
|
||||
return redirect(url_for("rsvp"))
|
||||
if not age_raw.isdigit():
|
||||
flash(t("flash_rsvp_age_invalid").format(name=member["name"]))
|
||||
return redirect(url_for("rsvp"))
|
||||
age_value = int(age_raw)
|
||||
if age_value < 0 or age_value > 17:
|
||||
flash(t("flash_rsvp_age_invalid").format(name=member["name"]))
|
||||
return redirect(url_for("rsvp"))
|
||||
child_age = age_value
|
||||
|
||||
db.execute(
|
||||
"UPDATE guests SET attending = ?, plus_one = ? WHERE id = ?",
|
||||
(attending, plus_one, session["guest_id"]),
|
||||
updates.append((attending, child_age, member_id, current_group_id))
|
||||
|
||||
db.executemany(
|
||||
"""
|
||||
UPDATE group_members
|
||||
SET attending = ?, child_age = ?
|
||||
WHERE id = ? AND group_id = ?
|
||||
""",
|
||||
updates,
|
||||
)
|
||||
db.commit()
|
||||
flash(t("flash_rsvp_saved"))
|
||||
return redirect(url_for("rsvp"))
|
||||
|
||||
guest = db.execute(
|
||||
"SELECT attending, plus_one FROM guests WHERE id = ?",
|
||||
(session["guest_id"],),
|
||||
).fetchone()
|
||||
|
||||
return render_template("rsvp.html", guest=guest)
|
||||
return render_template("rsvp.html", members=members)
|
||||
|
||||
|
||||
@app.route("/upload", methods=["GET", "POST"])
|
||||
@@ -495,7 +743,6 @@ def upload():
|
||||
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"))
|
||||
@@ -513,7 +760,7 @@ def 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))
|
||||
upload_rows.append((stored_name, int(session["group_id"]), now))
|
||||
|
||||
db.executemany(
|
||||
"INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)",
|
||||
@@ -539,9 +786,10 @@ def gallery():
|
||||
db = get_db()
|
||||
images = db.execute(
|
||||
"""
|
||||
SELECT uploads.id, uploads.filename, uploads.uploaded_by, uploads.uploaded_at, guests.name AS uploaded_by_name
|
||||
SELECT uploads.id, uploads.filename, uploads.uploaded_by, uploads.uploaded_at,
|
||||
COALESCE(groups.name, 'Unbekannt') AS uploaded_by_name
|
||||
FROM uploads
|
||||
JOIN guests ON guests.id = uploads.uploaded_by
|
||||
LEFT JOIN groups ON groups.id = uploads.uploaded_by
|
||||
ORDER BY uploads.id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
@@ -559,9 +807,9 @@ def delete_image(image_id: int):
|
||||
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:
|
||||
current_group_id = int(session["group_id"])
|
||||
is_admin = session.get("role") == "admin"
|
||||
if not is_admin and int(image["uploaded_by"]) != current_group_id:
|
||||
flash(t("flash_delete_not_allowed"))
|
||||
return redirect(url_for("gallery"))
|
||||
|
||||
@@ -608,6 +856,8 @@ def impressum():
|
||||
|
||||
|
||||
init_db()
|
||||
with app.app_context():
|
||||
seed_default_groups()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
|
||||
Binary file not shown.
42
backend/static/assets/location-map-preview.svg
Normal file
42
backend/static/assets/location-map-preview.svg
Normal file
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 700" role="img" aria-label="Kartenvorschau">
|
||||
<defs>
|
||||
<linearGradient id="land" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#d8e6de" />
|
||||
<stop offset="1" stop-color="#cadcd2" />
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#1f2a24" flood-opacity="0.16"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1600" height="700" fill="url(#land)"/>
|
||||
|
||||
<path d="M0 420 C120 380, 200 420, 300 390 C420 360, 530 440, 670 410 C770 390, 860 350, 980 380 C1110 420, 1210 370, 1330 410 C1430 440, 1500 410, 1600 430 L1600 700 L0 700 Z" fill="#b8d2c2" opacity="0.8"/>
|
||||
|
||||
<path d="M40 80 L520 560" stroke="#f5f5f2" stroke-width="28" stroke-linecap="round"/>
|
||||
<path d="M580 50 L980 650" stroke="#f7f7f4" stroke-width="26" stroke-linecap="round"/>
|
||||
<path d="M1050 70 L1540 520" stroke="#f4f5f2" stroke-width="24" stroke-linecap="round"/>
|
||||
<path d="M160 640 L620 170" stroke="#f2f4ef" stroke-width="20" stroke-linecap="round"/>
|
||||
|
||||
<path d="M30 120 L500 590" stroke="#9eb8aa" stroke-width="5" stroke-dasharray="12 14"/>
|
||||
<path d="M600 90 L1000 670" stroke="#9fb9ab" stroke-width="5" stroke-dasharray="12 14"/>
|
||||
<path d="M1070 100 L1555 540" stroke="#9db6a8" stroke-width="5" stroke-dasharray="12 14"/>
|
||||
<path d="M180 655 L640 185" stroke="#9db7a9" stroke-width="5" stroke-dasharray="12 14"/>
|
||||
|
||||
<circle cx="1080" cy="240" r="55" fill="#b0cbba" opacity="0.9"/>
|
||||
<circle cx="1200" cy="500" r="48" fill="#abc6b5" opacity="0.85"/>
|
||||
<circle cx="860" cy="540" r="45" fill="#aec8b8" opacity="0.88"/>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="105" y="78" width="560" height="168" rx="16" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="135" y="125" font-size="34" font-family="Arial, Helvetica, sans-serif" fill="#20372d" font-weight="700">Klostermühle Kiedrich</text>
|
||||
<text x="135" y="168" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#385246">An d. Klostermühle 3, 65399 Kiedrich</text>
|
||||
<text x="135" y="210" font-size="28" font-family="Arial, Helvetica, sans-serif" fill="#4f6a5d">Kartenvorschau</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(980 320)">
|
||||
<circle cx="0" cy="0" r="34" fill="#c83c38"/>
|
||||
<circle cx="0" cy="0" r="14" fill="#f8d9d7"/>
|
||||
<path d="M0 34 L-17 98 L17 98 Z" fill="#c83c38"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -55,6 +55,8 @@ h3 {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -185,6 +187,89 @@ input[type="file"]:focus {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
border: 1px solid rgba(39, 66, 53, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.member-choice-row {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.member-age-wrap {
|
||||
display: none;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.member-age-wrap.is-visible {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.member-age-wrap small {
|
||||
color: rgba(31, 31, 31, 0.68);
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
max-width: 760px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.upload-intro {
|
||||
margin-top: -0.15rem;
|
||||
margin-bottom: 0.3rem;
|
||||
color: rgba(31, 31, 31, 0.82);
|
||||
}
|
||||
|
||||
.upload-picker {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border: 1px dashed rgba(39, 66, 53, 0.35);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.upload-picker:hover {
|
||||
border-color: rgba(39, 66, 53, 0.55);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.upload-picker:focus-within {
|
||||
border-color: rgba(184, 145, 76, 0.88);
|
||||
box-shadow: 0 0 0 3px rgba(184, 145, 76, 0.18);
|
||||
}
|
||||
|
||||
.upload-picker-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upload-picker-subtitle {
|
||||
color: rgba(31, 31, 31, 0.66);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
margin: -0.2rem 0 0.1rem;
|
||||
color: rgba(31, 31, 31, 0.72);
|
||||
@@ -193,6 +278,7 @@ input[type="file"]:focus {
|
||||
|
||||
#extra-file-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
@@ -200,17 +286,61 @@ input[type="file"]:focus {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.upload-add-wrap {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.upload-add-wrap.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-count {
|
||||
margin: 0.2rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-ready {
|
||||
margin: -0.2rem 0 0;
|
||||
color: rgba(39, 66, 53, 0.88);
|
||||
font-weight: 600;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-ready.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-file-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
color: rgba(31, 31, 31, 0.82);
|
||||
max-height: 9rem;
|
||||
max-height: 10rem;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.upload-file-list li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
border: 1px solid rgba(39, 66, 53, 0.2);
|
||||
border-radius: 999px;
|
||||
padding: 0.32rem 0.68rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upload-file-remove {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(39, 66, 53, 0.82);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -233,6 +363,14 @@ input[type="file"]:focus {
|
||||
box-shadow: 0 10px 24px rgba(39, 66, 53, 0.2);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
background: rgba(39, 66, 53, 0.45);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--forest);
|
||||
background: transparent;
|
||||
@@ -250,6 +388,16 @@ input[type="file"]:focus {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar-nav-btn {
|
||||
padding: 0.42rem 0.62rem;
|
||||
}
|
||||
|
||||
.toolbar-nav-btn svg {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 10px;
|
||||
@@ -501,6 +649,80 @@ input[type="file"]:focus {
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.map-consent {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(39, 66, 53, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.map-consent p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 7;
|
||||
min-height: 0;
|
||||
border: 1px solid rgba(39, 66, 53, 0.16);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
background-color: rgba(221, 230, 225, 0.95);
|
||||
background-image:
|
||||
linear-gradient(180deg, rgba(28, 45, 37, 0.22), rgba(28, 45, 37, 0.22)),
|
||||
var(--map-preview-image);
|
||||
background-size: contain;
|
||||
background-position: top left;
|
||||
background-repeat: no-repeat;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.map-preview:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 24px rgba(39, 66, 53, 0.14);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.map-preview-overlay {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: rgba(39, 66, 53, 0.9);
|
||||
border-radius: 999px;
|
||||
padding: 0.48rem 0.82rem;
|
||||
}
|
||||
|
||||
.map-embed-target:not(:empty) {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.location-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.55rem;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.location-actions .btn {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
padding: 0.58rem 0.88rem;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.location-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid rgba(39, 66, 53, 0.12);
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
<button class="btn btn-ghost" type="submit">EN</button>
|
||||
</form>
|
||||
{% if guest_name %}
|
||||
<a class="btn btn-ghost toolbar-nav-btn" href="{{ url_for('guest_area') }}" aria-label="{{ t('dashboard') }}" title="{{ t('dashboard') }}">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3 3h8v8H3V3zm10 0h8v5h-8V3zM3 13h5v8H3v-8zm7 0h11v8H10v-8z" />
|
||||
</svg>
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('logout') }}">
|
||||
<button class="btn btn-ghost" type="submit">{{ t('logout') }}</button>
|
||||
</form>
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<a class="card link-card" href="{{ url_for('info', page='hotels') }}">{{ t('hotels') }}</a>
|
||||
<a class="card link-card" href="{{ url_for('info', page='taxi') }}">{{ t('taxi') }}</a>
|
||||
<a class="card link-card" href="{{ url_for('info', page='location') }}">{{ t('location') }}</a>
|
||||
{% if is_admin %}
|
||||
<a class="card link-card" href="{{ url_for('host_area') }}">{{ t('host_area') }}</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1>{{ t('host_access_title') }}</h1>
|
||||
<p>{{ t('host_access_note') }}</p>
|
||||
</section>
|
||||
|
||||
{% if not unlocked %}
|
||||
<section class="card form-card">
|
||||
<form method="post" action="{{ url_for('host_area') }}" class="form-grid">
|
||||
<label>
|
||||
{{ t('host_password') }}
|
||||
<input type="password" name="host_password" required />
|
||||
</label>
|
||||
<button class="btn" type="submit">{{ t('host_access_submit') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="stats-grid">
|
||||
<article class="card stat-card">
|
||||
<h2>{{ t('total_guests') }}</h2>
|
||||
@@ -33,10 +17,6 @@
|
||||
<h2>{{ t('attending_open') }}</h2>
|
||||
<p>{{ stats.attending_open }}</p>
|
||||
</article>
|
||||
<article class="card stat-card">
|
||||
<h2>{{ t('plus_one_total') }}</h2>
|
||||
<p>{{ stats.plus_one_total }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@@ -45,29 +25,31 @@
|
||||
<table class="guest-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('host_table_name') }}</th>
|
||||
<th>{{ t('host_table_group') }}</th>
|
||||
<th>{{ t('host_table_member') }}</th>
|
||||
<th>{{ t('host_table_status') }}</th>
|
||||
<th>{{ t('host_table_plus_one') }}</th>
|
||||
<th>{{ t('host_table_age') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for guest in guests %}
|
||||
{% for member in members %}
|
||||
<tr>
|
||||
<td>{{ guest["name"] }}</td>
|
||||
<td>{{ member["group_name"] }}</td>
|
||||
<td>{{ member["name"] }}</td>
|
||||
<td>
|
||||
{% if guest["attending"] == 1 %}
|
||||
{% if member["attending"] == 1 %}
|
||||
{{ t('status_yes') }}
|
||||
{% elif guest["attending"] == 0 %}
|
||||
{% elif member["attending"] == 0 %}
|
||||
{{ t('status_no') }}
|
||||
{% else %}
|
||||
{{ t('status_open') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if guest["attending"] == 1 and guest["plus_one"] == 1 %}
|
||||
{{ t('yes') }}
|
||||
{% if member["child_age"] is not none %}
|
||||
{{ member["child_age"] }}
|
||||
{% else %}
|
||||
{{ t('no') }}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -76,5 +58,4 @@
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,15 +17,54 @@
|
||||
{% elif page == 'location' %}
|
||||
<p><strong>{{ location_name }}</strong></p>
|
||||
<p>{{ location_address }}</p>
|
||||
<div class="map-wrap">
|
||||
<iframe
|
||||
src="{{ google_maps_embed_url }}"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
<div class="map-wrap map-consent" data-map-consent>
|
||||
<p>{{ t('maps_privacy_notice') }}</p>
|
||||
<button
|
||||
class="map-preview"
|
||||
type="button"
|
||||
data-map-load
|
||||
aria-label="{{ t('maps_load_button') }}"
|
||||
title="{{ t('maps_load_button') }}"
|
||||
style="--map-preview-image: url('{{ url_for('static', filename='assets/location-map-preview.svg') }}');"
|
||||
>
|
||||
<span class="map-preview-overlay">{{ t('maps_load_button') }}</span>
|
||||
</button>
|
||||
<div class="map-embed-target" data-map-embed-target></div>
|
||||
<div class="location-actions">
|
||||
<a class="btn" href="{{ location_website_url }}" target="_blank" rel="noopener">{{ t('visit_location') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn" href="{{ location_website_url }}" target="_blank" rel="noopener">{{ t('visit_location') }}</a>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const wrapper = document.querySelector("[data-map-consent]");
|
||||
if (!wrapper) return;
|
||||
const loadButtons = Array.from(wrapper.querySelectorAll("[data-map-load]"));
|
||||
const previewButton = wrapper.querySelector(".map-preview");
|
||||
const target = wrapper.querySelector("[data-map-embed-target]");
|
||||
const src = {{ google_maps_embed_url|tojson }};
|
||||
let loaded = false;
|
||||
|
||||
const loadMap = () => {
|
||||
if (loaded) return;
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.src = src;
|
||||
iframe.loading = "lazy";
|
||||
iframe.referrerPolicy = "no-referrer-when-downgrade";
|
||||
iframe.allowFullscreen = true;
|
||||
target.appendChild(iframe);
|
||||
if (previewButton) {
|
||||
previewButton.remove();
|
||||
}
|
||||
loadButtons.forEach((button) => button.remove());
|
||||
loaded = true;
|
||||
};
|
||||
|
||||
loadButtons.forEach((button) => {
|
||||
button.addEventListener("click", loadMap);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<h2>{{ t('login') }}</h2>
|
||||
<form method="post" action="{{ url_for('login') }}" class="form-grid">
|
||||
<label>
|
||||
{{ t('name') }}
|
||||
<input type="text" name="name" required />
|
||||
{{ t('group_name') }}
|
||||
<input type="text" name="group_name" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{{ t('event_password') }}
|
||||
<input type="password" name="event_password" required />
|
||||
{{ t('group_password') }}
|
||||
<input type="password" name="group_password" required />
|
||||
</label>
|
||||
|
||||
<button class="btn" type="submit">{{ t('login_submit') }}</button>
|
||||
|
||||
@@ -1,24 +1,82 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="card form-card">
|
||||
<section class="card">
|
||||
<h1>{{ t('rsvp') }}</h1>
|
||||
<p>{{ t('rsvp_members_intro') }}</p>
|
||||
<form method="post" class="form-grid">
|
||||
<label class="radio-row">
|
||||
<input type="radio" name="attending" value="yes" {% if guest and guest['attending'] == 1 %}checked{% endif %} />
|
||||
{{ t('attending') }}
|
||||
</label>
|
||||
{% for member in members %}
|
||||
<article class="member-card" data-member-card>
|
||||
<h2 class="member-name">{{ member['name'] }}</h2>
|
||||
<div class="member-choice-row">
|
||||
<label class="radio-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="attending_{{ member['id'] }}"
|
||||
value="yes"
|
||||
{% if member['attending'] == 1 %}checked{% endif %}
|
||||
data-attendance-input
|
||||
data-member-id="{{ member['id'] }}"
|
||||
/>
|
||||
{{ t('attending') }}
|
||||
</label>
|
||||
|
||||
<label class="radio-row">
|
||||
<input type="radio" name="attending" value="no" {% if guest and guest['attending'] == 0 %}checked{% endif %} />
|
||||
{{ t('not_attending') }}
|
||||
</label>
|
||||
<label class="radio-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="attending_{{ member['id'] }}"
|
||||
value="no"
|
||||
{% if member['attending'] == 0 %}checked{% endif %}
|
||||
data-attendance-input
|
||||
data-member-id="{{ member['id'] }}"
|
||||
/>
|
||||
{{ t('not_attending') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="plus_one" {% if guest and guest['plus_one'] == 1 %}checked{% endif %} />
|
||||
{{ t('plus_one') }}
|
||||
</label>
|
||||
{% if member['requires_age'] == 1 %}
|
||||
<label class="member-age-wrap" data-age-wrap data-member-id="{{ member['id'] }}">
|
||||
{{ t('member_age') }}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="17"
|
||||
step="1"
|
||||
name="age_{{ member['id'] }}"
|
||||
value="{{ member['child_age'] if member['child_age'] is not none else '' }}"
|
||||
/>
|
||||
<small>{{ t('member_age_hint') }}</small>
|
||||
</label>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
<button class="btn" type="submit">{{ t('save') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const inputs = Array.from(document.querySelectorAll("[data-attendance-input]"));
|
||||
const ageWraps = Array.from(document.querySelectorAll("[data-age-wrap]"));
|
||||
|
||||
const updateAgeVisibility = () => {
|
||||
ageWraps.forEach((wrap) => {
|
||||
const memberId = wrap.getAttribute("data-member-id");
|
||||
const yesRadio = document.querySelector(`input[name="attending_${memberId}"][value="yes"]`);
|
||||
const ageInput = wrap.querySelector("input[type='number']");
|
||||
const shouldShow = Boolean(yesRadio && yesRadio.checked);
|
||||
wrap.classList.toggle("is-visible", shouldShow);
|
||||
if (ageInput) {
|
||||
ageInput.required = shouldShow;
|
||||
if (!shouldShow) {
|
||||
ageInput.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
inputs.forEach((input) => input.addEventListener("change", updateAgeVisibility));
|
||||
updateAgeVisibility();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,77 +1,113 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="card form-card">
|
||||
<section class="card upload-card">
|
||||
<h1>{{ t('upload') }}</h1>
|
||||
<form method="post" enctype="multipart/form-data" class="form-grid">
|
||||
<label>
|
||||
{{ t('file') }}
|
||||
<input id="photo-input" type="file" name="photo" accept="image/jpeg,image/png,image/jpg,image/heic,image/heif,.heic,.heif" multiple />
|
||||
<p class="upload-intro">{{ t('upload_intro') }}</p>
|
||||
<form id="upload-form" method="post" enctype="multipart/form-data" class="form-grid">
|
||||
<label class="upload-picker" for="photo-input">
|
||||
<span class="upload-picker-title">{{ t('file') }}</span>
|
||||
<span class="upload-picker-subtitle">{{ t('upload_picker_hint') }}</span>
|
||||
<input id="photo-input" class="sr-only" 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>
|
||||
<p id="upload-ready-hint" class="upload-ready">{{ t('upload_ready') }}</p>
|
||||
<ul id="upload-file-list" class="upload-file-list"></ul>
|
||||
<button class="btn" type="submit">{{ t('upload_submit') }}</button>
|
||||
<button id="upload-submit-btn" class="btn" type="submit" disabled>{{ t('upload_submit') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const addBtn = document.getElementById("add-file-input");
|
||||
const extraInputs = document.getElementById("extra-file-inputs");
|
||||
const form = document.getElementById("upload-form");
|
||||
const fileInput = document.getElementById("photo-input");
|
||||
const countEl = document.getElementById("upload-selected-count");
|
||||
const readyEl = document.getElementById("upload-ready-hint");
|
||||
const listEl = document.getElementById("upload-file-list");
|
||||
const submitBtn = document.getElementById("upload-submit-btn");
|
||||
const countTpl = {{ t('upload_selected_count')|tojson }};
|
||||
const selectedFiles = [];
|
||||
|
||||
const allInputs = () => Array.from(document.querySelectorAll('input[name="photo"]'));
|
||||
const fileKey = (file) => `${file.name}__${file.size}__${file.lastModified}`;
|
||||
|
||||
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 syncInputFiles = () => {
|
||||
const transfer = new DataTransfer();
|
||||
selectedFiles.forEach((file) => transfer.items.add(file));
|
||||
fileInput.files = transfer.files;
|
||||
};
|
||||
|
||||
const renderSelection = () => {
|
||||
const names = [];
|
||||
allInputs().forEach((input) => {
|
||||
Array.from(input.files || []).forEach((file) => names.push(file.name));
|
||||
});
|
||||
|
||||
if (!names.length) {
|
||||
if (!selectedFiles.length) {
|
||||
countEl.textContent = "";
|
||||
listEl.innerHTML = "";
|
||||
if (readyEl) {
|
||||
readyEl.classList.remove("is-visible");
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
countEl.textContent = countTpl.replace("{count}", String(names.length));
|
||||
countEl.textContent = countTpl.replace("{count}", String(selectedFiles.length));
|
||||
listEl.innerHTML = "";
|
||||
names.slice(0, 20).forEach((name) => {
|
||||
if (readyEl) {
|
||||
readyEl.classList.add("is-visible");
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
selectedFiles.slice(0, 20).forEach((file, index) => {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = name;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = file.name;
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "upload-file-remove";
|
||||
removeBtn.setAttribute("aria-label", "remove file");
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", () => {
|
||||
selectedFiles.splice(index, 1);
|
||||
syncInputFiles();
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
item.appendChild(label);
|
||||
item.appendChild(removeBtn);
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
if (names.length > 20) {
|
||||
if (selectedFiles.length > 20) {
|
||||
const more = document.createElement("li");
|
||||
more.textContent = `+ ${names.length - 20} weitere`;
|
||||
more.textContent = `+ ${selectedFiles.length - 20} weitere`;
|
||||
listEl.appendChild(more);
|
||||
}
|
||||
};
|
||||
|
||||
allInputs().forEach((input) => input.addEventListener("change", renderSelection));
|
||||
addBtn.addEventListener("click", () => {
|
||||
createExtraInput();
|
||||
fileInput.addEventListener("change", () => {
|
||||
const existingKeys = new Set(selectedFiles.map(fileKey));
|
||||
Array.from(fileInput.files || []).forEach((file) => {
|
||||
const key = fileKey(file);
|
||||
if (!existingKeys.has(key)) {
|
||||
selectedFiles.push(file);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
});
|
||||
syncInputFiles();
|
||||
fileInput.value = "";
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
if (!selectedFiles.length) {
|
||||
event.preventDefault();
|
||||
renderSelection();
|
||||
return;
|
||||
}
|
||||
syncInputFiles();
|
||||
});
|
||||
|
||||
renderSelection();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Binary file not shown.
@@ -27,3 +27,7 @@ services:
|
||||
- WEDDING_DATE=${WEDDING_DATE:-}
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-268435456}
|
||||
- LOCATION_NAME=${LOCATION_NAME:-Klostermühle}
|
||||
- LOCATION_ADDRESS=${LOCATION_ADDRESS:-An d. Klostermühle 3, 65399 Kiedrich}
|
||||
- LOCATION_WEBSITE_URL=${LOCATION_WEBSITE_URL:-https://www.klostermuehle.de/}
|
||||
- GOOGLE_MAPS_EMBED_URL=${GOOGLE_MAPS_EMBED_URL:-https://www.google.com/maps?q=Klostermuehle+Kiedrich+Eltville&output=embed}
|
||||
|
||||
Reference in New Issue
Block a user