Add bilingual UI and improve event actions layout
Add persistent German/English switching, translated UI messages and English 12-hour times. Align desktop event actions while preserving mobile layouts and record bilingual project guidance. Include pending diary deletion exclusions. Validate both languages with nine tests.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Projektvorgaben
|
||||
|
||||
- Bei allen Änderungen Deutsch und Englisch berücksichtigen. Neue oder geänderte Oberflächentexte über die vorhandene Übersetzungsfunktion und `app/locales/en.json` pflegen und beide Sprachen prüfen.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"""Request-local UI translations. User content and stored identifiers stay unchanged."""
|
||||
|
||||
import json
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
|
||||
LANGUAGE_COOKIE = "metalcircle_language"
|
||||
current_language = ContextVar("current_language", default="de")
|
||||
current_page = ContextVar("current_page", default="/")
|
||||
ENGLISH = json.loads((Path(__file__).parent / "locales" / "en.json").read_text())
|
||||
|
||||
|
||||
def gettext(message):
|
||||
if not isinstance(message, str):
|
||||
return message
|
||||
return ENGLISH.get(message, message) if current_language.get() == "en" else message
|
||||
|
||||
|
||||
def format_time(value):
|
||||
"""Format display times without changing stored or form-submitted values."""
|
||||
if current_language.get() == "en":
|
||||
period = "AM" if value.hour < 12 else "PM"
|
||||
return f"{value.hour % 12 or 12}:{value.minute:02d} {period}"
|
||||
return value.strftime("%H:%M")
|
||||
|
||||
|
||||
def format_datetime(value):
|
||||
return f"{value.strftime('%d.%m.%Y')} {format_time(value)}"
|
||||
|
||||
|
||||
def safe_return_path(value):
|
||||
"""Only allow local absolute paths, including their query string and fragment."""
|
||||
if not value or not value.startswith("/") or value.startswith("//"):
|
||||
return "/"
|
||||
if "\\" in value or any(ord(char) < 32 or ord(char) == 127 for char in value):
|
||||
return "/"
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme or parsed.netloc or parsed.path.startswith("/language/"):
|
||||
return "/"
|
||||
return value
|
||||
|
||||
|
||||
def language_url(language):
|
||||
return f"/language/{language}?{urlencode({'next': current_page.get()})}"
|
||||
@@ -0,0 +1,463 @@
|
||||
{
|
||||
"Adminbereiche": "Administration sections",
|
||||
"👥 Benutzer": "👥 Users",
|
||||
"📍 Veranstaltungsorte": "📍 Venues",
|
||||
"🧵 Patch-Bilder": "🧵 Patch images",
|
||||
"📊 Nutzungsstatistik": "📊 Usage statistics",
|
||||
"neue Nachricht(en) oder Anfrage(n)": "new message(s) or request(s)",
|
||||
"Neue Benachrichtigungen": "New notifications",
|
||||
"Mein Profil": "My profile",
|
||||
"Nachrichten": "Messages",
|
||||
"Neue Nachrichten oder Anfragen": "New messages or requests",
|
||||
"Konzerttagebuch": "Concert diary",
|
||||
"Gefolgte Bands & Locations": "Followed bands & venues",
|
||||
"⚙️ Verwaltung": "⚙️ Administration",
|
||||
"Datenschutz": "Privacy policy",
|
||||
"Impressum": "Legal notice",
|
||||
"Abmelden": "Log out",
|
||||
"Account-Link · MetalCircle": "Account link · MetalCircle",
|
||||
"Passwort zurücksetzen": "Reset password",
|
||||
"Reset-Link für @": "Reset link for @",
|
||||
"Diesen persönlichen Link sicher an den User senden. Er ist zwei Stunden gültig und nur einmal verwendbar.": "Send this personal link securely to the user. It is valid for two hours and can only be used once.",
|
||||
"Zurück": "Back",
|
||||
"Das Bild konnte nicht optimiert werden. Bitte ein kleineres JPG, PNG oder WebP wählen.": "The image could not be optimized. Please choose a smaller JPG, PNG or WebP.",
|
||||
"Patch-Bilder · MetalCircle": "Patch images · MetalCircle",
|
||||
"Grafiken für die Patches auf der virtuellen Kutte verwalten.": "Manage images for the patches on the virtual battle vest.",
|
||||
"Das Bild wird vor dem Upload automatisch optimiert.": "The image is automatically optimized before upload.",
|
||||
"Bild ersetzen": "Replace image",
|
||||
"Bild hochladen": "Upload image",
|
||||
"Bild entfernen": "Remove image",
|
||||
"Benutzer": "User",
|
||||
"Veranstaltungen": "Events",
|
||||
"Bevorstehende Veranstaltungen": "Upcoming events",
|
||||
"Kommentare": "Comments",
|
||||
"Teilnahmen": "Attendance",
|
||||
"Freundschaften": "Friendships",
|
||||
"Nutzungsstatistik · MetalCircle": "Usage statistics · MetalCircle",
|
||||
"Aktuelle Gesamtzahlen der Plattform.": "Current platform totals.",
|
||||
"Benutzerverwaltung · MetalCircle": "User management · MetalCircle",
|
||||
"Benutzerkonten und Einladungen verwalten.": "Manage user accounts and invitations.",
|
||||
"Einladung erstellen": "Create invitation",
|
||||
"Der Link kann einmalig verwendet werden.": "The link can only be used once.",
|
||||
"+ Einladungslink erzeugen": "+ Generate invitation link",
|
||||
"· seit": "· since",
|
||||
"🏴☠️ Gründer · Admin (fest)": "🏴☠️ Founder · Admin (permanent)",
|
||||
"Rolle für": "Role for",
|
||||
"Speichern": "Save",
|
||||
"Passwort-Reset": "Password reset",
|
||||
"Löschen": "Delete",
|
||||
"Veranstaltungsorte · MetalCircle": "Venues · MetalCircle",
|
||||
"Orte korrigieren, bestätigen, zusammenführen oder entfernen.": "Correct, verify, merge or remove venues.",
|
||||
"✓ bestätigt": "✓ verified",
|
||||
"● unbestätigt": "● unverified",
|
||||
"Veranstaltung(en) · ID": "event(s) · ID",
|
||||
"Straße": "Street",
|
||||
"PLZ": "Postal code",
|
||||
"Ort": "City",
|
||||
"Land": "Country",
|
||||
"Aliase, durch Komma getrennt": "Aliases, separated by commas",
|
||||
"Aliase": "Aliases",
|
||||
"bestätigt": "verified",
|
||||
"Quelle:": "Source:",
|
||||
"Konzert(e) · ID": "concert(s) · ID",
|
||||
"Mit anderem Ort zusammenführen …": "Merge with another venue …",
|
||||
"Zusammenführen": "Merge",
|
||||
"Der Ort ist mit": "This venue is linked to",
|
||||
"Konzert(en) verknüpft. Diese Konzerte haben danach keinen Veranstaltungsort mehr.": "concert(s). These concerts will no longer have a venue afterwards.",
|
||||
"Ort löschen": "Delete venue",
|
||||
"Verknüpfung(en))": "link(s))",
|
||||
"Noch keine Veranstaltungsorte vorhanden.": "No venues yet.",
|
||||
"Deutschland": "Germany",
|
||||
"← Zurück zum Kalender": "← Back to calendar",
|
||||
"Quelle": "Source",
|
||||
"Extern eingebundener Flyer. Rechte liegen beim jeweiligen Veranstalter bzw. Rechteinhaber. Hinweise zur Entfernung bitte an die Administration.": "Externally embedded flyer. Rights belong to the respective organizer or rights holder. Please contact the administrators with removal requests.",
|
||||
"🔒 Nur Freunde": "🔒 Friends only",
|
||||
"🔐 Privat / eingeladen": "🔐 Private / invited",
|
||||
"gefolgt": "followed",
|
||||
"folgen": "follow",
|
||||
"★ Location gefolgt": "★ Following venue",
|
||||
"☆ Location folgen": "☆ Follow venue",
|
||||
"Meine gefolgten Inhalte": "My followed content",
|
||||
"Uhr": " ",
|
||||
"bis": "to",
|
||||
"Gehört zu:": "Part of:",
|
||||
"Über die Veranstaltung": "About the event",
|
||||
"📅 In Kalender speichern (.ics)": "📅 Save to calendar (.ics)",
|
||||
"Veranstaltung verwalten": "Manage event",
|
||||
"✏️ Veranstaltung bearbeiten": "✏️ Edit event",
|
||||
"🗑️ Veranstaltung löschen": "🗑️ Delete event",
|
||||
"🐧 Wer ist dabei?": "🐧 Who is going?",
|
||||
"Wähle, wie es bei dir aussieht. Deine Auswahl kann jederzeit geändert werden.": "Choose your status. You can change your selection at any time.",
|
||||
"✓ Zugesagt": "✓ Going",
|
||||
"? Vielleicht": "? Maybe",
|
||||
"🔎 Suche Ticket": "🔎 Looking for a ticket",
|
||||
"🎟️ Biete Ticket": "🎟️ Offering a ticket",
|
||||
"Zusage": "going",
|
||||
"bietet Ticket": "offering a ticket",
|
||||
"Noch niemand hat zugesagt.": "Nobody is going yet.",
|
||||
"Noch keine Vielleicht-Angaben.": "No maybes yet.",
|
||||
"🔎 Sucht ein Ticket": "🔎 Looking for a ticket",
|
||||
"Aktuell sucht niemand ein Ticket.": "Nobody is looking for a ticket right now.",
|
||||
"🎟️ Bietet ein Ticket": "🎟️ Offering a ticket",
|
||||
"Aktuell bietet niemand ein Ticket an.": "Nobody is offering a ticket right now.",
|
||||
"📓 Mein Konzerttagebuch": "📓 My concert diary",
|
||||
"Der private Eintrag wurde automatisch angelegt.": "The private entry was created automatically.",
|
||||
"Im Konzerttagebuch bearbeiten": "Edit in concert diary",
|
||||
"Ein Tagebucheintrag wird automatisch angelegt, sobald du das vergangene Konzert als „Zugesagt“ markierst.": "A diary entry is created automatically when you mark a past concert as “Going”.",
|
||||
"💬 Kommentare": "💬 Comments",
|
||||
"Mit anderen Konzertbesuchern austauschen": "Chat with other concertgoers",
|
||||
"Schreib einen Kommentar …": "Write a comment …",
|
||||
"Kommentar senden": "Send comment",
|
||||
"Noch keine Kommentare. Mach den Anfang!": "No comments yet. Be the first!",
|
||||
"Datenschutz · MetalCircle": "Privacy policy · MetalCircle",
|
||||
"Hinweise zur Verarbeitung deiner Daten": "Information about how your data is processed",
|
||||
"Verantwortlicher": "Data controller",
|
||||
"Welche Daten werden gespeichert?": "What data is stored?",
|
||||
"Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands und Locations, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.": "Using the platform involves storing your username, email address, password hash, display name, optional profile and Instagram details, profile picture, friendships, messages, event attendance, followed bands and venues, private concert diary entries, comments, photos, patches and events you create.",
|
||||
"Wofür werden sie verwendet?": "What is it used for?",
|
||||
"Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.": "The data is processed exclusively for login, account management, event features, friendships, messages, notifications and your chosen visibility settings.",
|
||||
"Rechtsgrundlage und Speicherdauer": "Legal basis and retention period",
|
||||
"Die Verarbeitung erfolgt im geschlossenen Projektbetrieb zur Bereitstellung der gewünschten Funktionen und – soweit erforderlich – auf Grundlage deiner Einwilligung. Daten bleiben gespeichert, solange dein Konto besteht oder gesetzliche Aufbewahrungspflichten gelten. Nicht mehr benötigte Daten werden gelöscht.": "Data is processed within this closed project to provide the requested features and, where required, on the basis of your consent. Data remains stored for as long as your account exists or statutory retention requirements apply. Data that is no longer needed is deleted.",
|
||||
"Weitergabe und externe Dienste": "Sharing and external services",
|
||||
"Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.": "No advertising or tracking services are used. Venue searches may send search queries to an external geocoding service. External flyer and Instagram links are loaded directly by your browser when opened; the respective provider’s privacy policy applies.",
|
||||
"Deine Rechte": "Your rights",
|
||||
"Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und – soweit anwendbar – Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an": "You may request access, rectification, erasure, restriction of processing and, where applicable, data portability. You can find an export of your stored application data at the bottom of your own profile. Please send requests to",
|
||||
"Cookies und Sicherheit": "Cookies and security",
|
||||
"Für die Anmeldung wird ausschließlich ein technisch notwendiges, HttpOnly-Sitzungscookie verwendet. Im späteren HTTPS-Betrieb wird es zusätzlich mit dem Secure-Flag gesetzt. Passwörter werden nicht im Klartext gespeichert.": "Login uses only a technically necessary HttpOnly session cookie. When HTTPS is enabled, the Secure flag is also set. Passwords are not stored in plain text.",
|
||||
"Diese Information ist eine technische Projektgrundlage und ersetzt keine individuelle rechtliche Prüfung. Vor einer öffentlichen Veröffentlichung sollten Hostinganbieter, Auftragsverarbeiter, Löschfristen und der externe Geocoding-Dienst konkret ergänzt und geprüft werden.": "This information provides a technical basis for the project and does not replace an individual legal review. Before public release, hosting providers, data processors, deletion periods and the external geocoding service should be specified and reviewed.",
|
||||
"Konzerttagebuch · MetalCircle": "Concert diary · MetalCircle",
|
||||
"📓 Konzerttagebuch": "📓 Concert diary",
|
||||
"Besuchte Konzerte werden automatisch eingetragen. Deine Erinnerungen bleiben privat.": "Attended concerts are added automatically. Your memories stay private.",
|
||||
"von 5 Sternen": "out of 5 stars",
|
||||
"Noch nicht bewertet.": "Not rated yet.",
|
||||
"🎵 Lieblingssong:": "🎵 Favorite song:",
|
||||
"Bildergalerie zu": "Image gallery for",
|
||||
"Erinnerungsfoto": "Memory photo",
|
||||
"zu": "for",
|
||||
"Bild": "Image",
|
||||
"entfernen": "remove",
|
||||
"Hier erhalten ·": "Earned here ·",
|
||||
"Eintrag bearbeiten": "Edit entry",
|
||||
"Bewertung": "Rating",
|
||||
"Noch nicht bewertet": "Not rated yet",
|
||||
"Lieblingssong": "Favorite song",
|
||||
"Erinnerungen": "Memories",
|
||||
"Was ist dir von diesem Abend geblieben?": "What do you remember about this night?",
|
||||
"Galeriebilder": "Gallery images",
|
||||
"Bis zu drei Bilder; große Bilder werden automatisch optimiert.": "Up to three images; large images are automatically optimized.",
|
||||
"Eintrag speichern": "Save entry",
|
||||
"Konzerteintrag löschen": "Delete concert entry",
|
||||
"Noch keine Einträge. Vergangene zugesagte Konzerte erscheinen hier automatisch.": "No entries yet. Past concerts marked as going appear here automatically.",
|
||||
"bearbeiten · MetalCircle": "edit · MetalCircle",
|
||||
"Änderungen werden direkt bei der Veranstaltung gespeichert.": "Changes are saved directly to the event.",
|
||||
"Kategorie": "Category",
|
||||
"Konzert": "Concert",
|
||||
"Sonstiges": "Other",
|
||||
"Gehört zu": "Part of",
|
||||
"Keine Verknüpfung – eigenständige Karte": "No link – standalone event",
|
||||
"Sichtbarkeit": "Visibility",
|
||||
"Öffentlich": "Public",
|
||||
"Nur Freunde": "Friends only",
|
||||
"Privat – nur eingeladene Mitglieder": "Private – invited members only",
|
||||
"Mitglieder einladen": "Invite members",
|
||||
"Noch keine weiteren Mitglieder vorhanden.": "No other members yet.",
|
||||
"Titel / Künstler / Band": "Title / artist / band",
|
||||
"Bands / Line-up": "Bands / line-up",
|
||||
"(eine Band pro Zeile)": "(one band per line)",
|
||||
"Diese Namen werden einzeln für die Folgen-Funktion verwendet.": "These names are used individually for the follow feature.",
|
||||
"Veranstaltungsort": "Venue",
|
||||
"Name des Veranstaltungsorts": "Venue name",
|
||||
"PLZ und Ort": "Postal code and city",
|
||||
"Beginn": "Start",
|
||||
"Ende": "End",
|
||||
"Beschreibung": "Description",
|
||||
"Ticket-Link": "Ticket link",
|
||||
"Ticketpreis": "Ticket price",
|
||||
"Flyer extern verlinken": "Link an external flyer",
|
||||
"(bevorzugt)": "(preferred)",
|
||||
"Bitte möglichst die offizielle Quelle verwenden. Ein externer Link wird gegenüber einem Upload bevorzugt und unter dem Bild genannt. Feld leeren, um wieder den Upload zu verwenden.": "Please use the official source if possible. An external link takes priority over an upload and is shown below the image. Clear the field to use the upload again.",
|
||||
"Neuen Flyer hochladen": "Upload new flyer",
|
||||
"Ohne neue Datei bleibt der bisherige Flyer erhalten.": "The existing flyer is kept unless you upload a new file.",
|
||||
"Aktueller Flyer": "Current flyer",
|
||||
"💾 Änderungen speichern": "💾 Save changes",
|
||||
"Abbrechen": "Cancel",
|
||||
"Gefolgt · MetalCircle": "Following · MetalCircle",
|
||||
"⭐ Gefolgt": "⭐ Following",
|
||||
"Bands, Locations und passende kommende Veranstaltungen.": "Bands, venues and matching upcoming events.",
|
||||
"Nicht mehr folgen": "Unfollow",
|
||||
"nicht mehr folgen": "unfollow",
|
||||
"Noch keine Band gefolgt.": "No bands followed yet.",
|
||||
"Locations": "Venues",
|
||||
"Noch keiner Location gefolgt.": "No venues followed yet.",
|
||||
"Kommende Treffer": "Upcoming matches",
|
||||
"und": "and",
|
||||
"Location": "Venue",
|
||||
"Momentan gibt es keine kommenden Treffer.": "There are no upcoming matches right now.",
|
||||
"Impressum · MetalCircle": "Legal notice · MetalCircle",
|
||||
"Anbieterkennzeichnung": "Provider information",
|
||||
"Verantwortlich für den Inhalt": "Responsible for the content",
|
||||
"E-Mail:": "Email:",
|
||||
"Dieses private Projekt befindet sich derzeit im geschlossenen Betrieb. Vor einer Veröffentlichung werden die Angaben und rechtlichen Anforderungen nochmals geprüft.": "This private project is currently closed to the public. The information and legal requirements will be reviewed again before public release.",
|
||||
"· ✉ Eingeladen": "· ✉ Invited",
|
||||
"· 🔒 Nur Freunde": "· 🔒 Friends only",
|
||||
"· 🔐 Privat": "· 🔐 Private",
|
||||
"um": "at",
|
||||
"☠ Vergangene Veranstaltungen": "☠ Past events",
|
||||
"⚡ Bevorstehend": "⚡ Upcoming",
|
||||
"☠ Vergangene": "☠ Past",
|
||||
"+ Veranstaltung hinzufügen": "+ Add event",
|
||||
"Suche": "Search",
|
||||
"Veranstaltungen, Orte oder User suchen …": "Search events, venues or users …",
|
||||
"Veranstaltungen oder User suchen": "Search events or users",
|
||||
"Suchen": "Search",
|
||||
"Weitere Filter": "More filters",
|
||||
"· aktiv": "· active",
|
||||
"Von Datum": "From date",
|
||||
"Bis Datum": "To date",
|
||||
"Ort / Location": "City / venue",
|
||||
"z. B. Düsseldorf": "e.g. Düsseldorf",
|
||||
"z. B. Deutschland": "e.g. Germany",
|
||||
"Alle Kategorien": "All categories",
|
||||
"Filter löschen": "Clear filters",
|
||||
"User zu „": "Users matching “",
|
||||
"Keine passenden User gefunden.": "No matching users found.",
|
||||
"Veranstaltungen zu „": "Events matching “",
|
||||
"Archiv": "Archive",
|
||||
"Noch keine vergangenen Veranstaltungen ☠": "No past events yet ☠",
|
||||
"Noch keine Veranstaltungen 🎸": "No events yet 🎸",
|
||||
"Hier erscheinen abgeschlossene Veranstaltungen.": "Completed events appear here.",
|
||||
"Leg die erste Veranstaltung an!": "Create the first event!",
|
||||
"Anmelden · MetalCircle": "Log in · MetalCircle",
|
||||
"Anmelden": "Log in",
|
||||
"Melde dich an, um Konzerte hinzuzufügen oder zu bearbeiten.": "Log in to add or edit concerts.",
|
||||
"Benutzername oder E-Mail": "Username or email",
|
||||
"Passwort": "Password",
|
||||
"Passwort vergessen? Bitte einen Admin um einen zeitlich begrenzten Reset-Link.": "Forgot your password? Ask an admin for a temporary reset link.",
|
||||
"Nachrichten · MetalCircle": "Messages · MetalCircle",
|
||||
"Noch keine Nachrichten. Schreib die erste!": "No messages yet. Write the first one!",
|
||||
"Nachricht schreiben …": "Write a message …",
|
||||
"Nachricht": "Message",
|
||||
"Senden": "Send",
|
||||
"← Alle Nachrichten": "← All messages",
|
||||
"🤘 Nachrichten": "🤘 Messages",
|
||||
"Private Chats mit deinen Freunden.": "Private chats with your friends.",
|
||||
"Neue Anfragen": "New requests",
|
||||
"möchte sich mit dir befreunden.": "would like to be your friend.",
|
||||
"Annehmen": "Accept",
|
||||
"Ablehnen": "Decline",
|
||||
"– Einladung von": "– Invited by",
|
||||
"Ansehen →": "View →",
|
||||
"Unterhaltungen": "Conversations",
|
||||
"Du:": "You:",
|
||||
"Chat beginnen": "Start chat",
|
||||
"Noch keine Chats": "No chats yet",
|
||||
"Chats sind mit bestätigten Freunden und mit Admins verfügbar.": "Chats are available with confirmed friends and admins.",
|
||||
"Bitte JPG, PNG oder WEBP auswählen.": "Please choose JPG, PNG or WEBP.",
|
||||
"Der Flyer darf maximal 10 MB groß sein.": "The flyer must not exceed 10 MB.",
|
||||
"⚠️ Möglicherweise existiert diese Veranstaltung bereits:": "⚠️ This event may already exist:",
|
||||
"Am selben Tag gibt es bereits eine Veranstaltung mit einem sehr ähnlichen Bandnamen. Trotzdem speichern?": "An event with a very similar band name already exists on the same day. Save anyway?",
|
||||
"Veranstaltung hinzufügen · MetalCircle": "Add event · MetalCircle",
|
||||
"🎤 Veranstaltung hinzufügen": "🎤 Add event",
|
||||
"Trag ein, worauf wir uns freuen.": "Add something to look forward to.",
|
||||
"Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.": "For example, meeting for drinks before a concert.",
|
||||
"Öffentlich – alle können die Veranstaltung sehen und teilnehmen": "Public – everyone can see and attend the event",
|
||||
"Nur Freunde – nur bestätigte Freunde können sie sehen und teilnehmen": "Friends only – only confirmed friends can see and attend it",
|
||||
"Privat – nur gezielt eingeladene Mitglieder": "Private – specifically invited members only",
|
||||
"Eingeladene erhalten eine Benachrichtigung und sehen die Veranstaltung in ihrer Übersicht.": "Invitees receive a notification and see the event in their overview.",
|
||||
"z. B. Iron Maiden": "e.g. Iron Maiden",
|
||||
"dArtagnan\nSupportband": "dArtagnan\nSupport band",
|
||||
"Der Veranstaltungstitel bleibt frei. Diese Namen werden einzeln für „Band folgen“ verwendet.": "The event title is up to you. These names are used individually for “Follow band”.",
|
||||
"z. B. Turbinenhalle": "e.g. Turbinenhalle",
|
||||
"Optional für eine bessere Zuordnung:": "Optional details for a better match:",
|
||||
"Ort / Stadt": "Town / city",
|
||||
"Datum und Uhrzeit": "Date and time",
|
||||
"Enddatum und Uhrzeit": "End date and time",
|
||||
"Optional: Infos zur Veranstaltung, Tour, Supportbands usw.": "Optional: event details, tour, support bands, etc.",
|
||||
"z. B. 59.90": "e.g. 59.90",
|
||||
"🔗 Flyer extern verlinken": "🔗 Link an external flyer",
|
||||
"Bitte möglichst den offiziellen Flyer des Veranstalters oder der Band verlinken. Das Bild wird nicht auf MetalCircle kopiert; der Link wird als Quelle ausgewiesen.": "Please link the official flyer from the organizer or band if possible. The image is not copied to MetalCircle; the link is shown as the source.",
|
||||
"🎨 Veranstaltungs-Flyer": "🎨 Event flyer",
|
||||
"Alternative, falls kein offizieller externer Link verfügbar ist: JPG, PNG oder WEBP · maximal 10 MB. Ist ein Link angegeben, wird er bevorzugt dargestellt.": "Alternative if no official external link is available: JPG, PNG or WEBP · maximum 10 MB. If a link is provided, it takes priority.",
|
||||
"Flyer Vorschau": "Flyer preview",
|
||||
"💾 Veranstaltung speichern": "💾 Save event",
|
||||
"Passwort zurücksetzen · MetalCircle": "Reset password · MetalCircle",
|
||||
"Neues Passwort": "New password",
|
||||
"Passwort wiederholen": "Repeat password",
|
||||
"Passwort speichern": "Save password",
|
||||
"Das Bild konnte nicht verarbeitet werden. Bitte ein kleineres JPG, PNG oder WebP wählen.": "The image could not be processed. Please choose a smaller JPG, PNG or WebP.",
|
||||
"Profil": "Profile",
|
||||
"Deine Konzertgeschichte und deine Kutte.": "Your concert history and your battle vest.",
|
||||
"Profilbild von": "Profile picture of",
|
||||
"Kein Profilbild": "No profile picture",
|
||||
"Dabei seit": "Joined",
|
||||
"besuchte Konzerte": "concerts attended",
|
||||
"Nachricht senden": "Send message",
|
||||
"Freundschaft anfragen": "Send friend request",
|
||||
"Freundschaft beenden": "Unfriend",
|
||||
"Anfrage zurückziehen": "Cancel request",
|
||||
"Blockierung aufheben": "Unblock",
|
||||
"Nutzer blockieren": "Block user",
|
||||
"Interaktionen mit diesem Nutzer sind nicht verfügbar.": "Interactions with this user are unavailable.",
|
||||
"Weitere Profildetails sind nur für Freunde sichtbar.": "Further profile details are only visible to friends.",
|
||||
"🥋 Virtuelle Kutte": "🥋 Virtual battle vest",
|
||||
"Deine freigeschalteten Patches. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.": "Your unlocked patches. Attendance patches are replaced by the highest level you have earned.",
|
||||
"Details zu": "Show details for",
|
||||
"anzeigen": " ",
|
||||
"Schließen": "Close",
|
||||
"Erhalten am:": "Earned on:",
|
||||
"Ausgelöst durch:": "Triggered by:",
|
||||
"Grund:": "Reason:",
|
||||
"Noch keine Patches – dein erstes Konzert wartet!": "No patches yet – your first concert awaits!",
|
||||
"🤝 Freunde": "🤝 Friends",
|
||||
"Offene Anfragen": "Pending requests",
|
||||
"Deine Freunde": "Your friends",
|
||||
"Entfernen": "Remove",
|
||||
"gesendete Anfrage(n) warten noch auf Antwort.": "sent request(s) are still awaiting a reply.",
|
||||
"Noch keine Verbindungen. Finde andere User über die Suche auf der Startseite.": "No connections yet. Find other users through the search on the home page.",
|
||||
"Blockierte Nutzer": "Blocked users",
|
||||
"Freigeben": "Unblock",
|
||||
"Keine blockierten Nutzer.": "No blocked users.",
|
||||
"Profil bearbeiten": "Edit profile",
|
||||
"Anzeigename": "Display name",
|
||||
"Instagram-Profil": "Instagram profile",
|
||||
"@name oder https://instagram.com/name": "@name or https://instagram.com/name",
|
||||
"Aktuell verknüpft: @": "Currently linked: @",
|
||||
"Wer darf alle Profildetails sehen?": "Who can see all profile details?",
|
||||
"Alle User": "All users",
|
||||
"Jeder angemeldete User sieht auch Instagram und Beitrittsdatum.": "Every logged-in user can also see your Instagram and join date.",
|
||||
"Username, Profilbild, Konzertanzahl und Kutte bleiben immer sichtbar.": "Username, profile picture, concert count and battle vest are always visible.",
|
||||
"Niemand": "Nobody",
|
||||
"Zusätzliche Details siehst nur du; Admins behalten administrativen Zugriff.": "Only you can see additional details; admins retain administrative access.",
|
||||
"Profilbild": "Profile picture",
|
||||
"Große Bilder werden vor dem Upload automatisch optimiert.": "Large images are automatically optimized before upload.",
|
||||
"Profil speichern": "Save profile",
|
||||
"Account löschen": "Delete account",
|
||||
"Diese Aktion löscht dein Konto dauerhaft und kann nicht rückgängig gemacht werden.": "This action permanently deletes your account and cannot be undone.",
|
||||
"Account dauerhaft löschen": "Permanently delete account",
|
||||
"Meine gespeicherten Daten exportieren (JSON)": "Export my stored data (JSON)",
|
||||
"MetalCircle - Registrierung": "MetalCircle - Registration",
|
||||
"Einladung angenommen": "Invitation accepted",
|
||||
"Erstelle deinen Account für MetalCircle.": "Create your MetalCircle account.",
|
||||
"Benutzername": "Username",
|
||||
"E-Mail": "Email",
|
||||
"Account erstellen": "Create account",
|
||||
"🎸 Konzert-Meilensteine": "🎸 Concert milestones",
|
||||
"Gründer": "Founder",
|
||||
"Verantwortung für MetalCircle": "Responsibility for MetalCircle",
|
||||
"Die treue Gefährtin des Captains": "The Captain’s loyal companion",
|
||||
"Dein erstes besuchtes Konzert": "Your first concert attended",
|
||||
"Erstes Konzert im Ausland": "First concert abroad",
|
||||
"Konzerte in 5 Ländern": "Concerts in 5 countries",
|
||||
"Ungültige Veranstaltungskategorie.": "Invalid event category.",
|
||||
"Eine Veranstaltung kann nicht mit sich selbst verknüpft werden.": "An event cannot be linked to itself.",
|
||||
"Die Hauptveranstaltung muss ein Konzert oder Festival sein.": "The parent event must be a concert or festival.",
|
||||
"Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden.": "The parent event has already ended and can no longer be linked.",
|
||||
"Bitte einen gültigen Instagram-Profillink eingeben.": "Please enter a valid Instagram profile link.",
|
||||
"<h1>Nicht erlaubt</h1>": "<h1>Not allowed</h1>",
|
||||
"<h1>Ein Ort kann nicht mit sich selbst zusammengeführt werden.</h1>": "<h1>A venue cannot be merged with itself.</h1>",
|
||||
"<h1>Bitte ein Bild auswählen.</h1>": "<h1>Please select an image.</h1>",
|
||||
"<h1>Ungültige Rolle</h1>": "<h1>Invalid role</h1>",
|
||||
"<h1>Die eigenen Adminrechte können nicht entfernt werden.</h1>": "<h1>You cannot remove your own admin rights.</h1>",
|
||||
"<h1>Der eigene Account kann nicht gelöscht werden.</h1>": "<h1>You cannot delete your own account.</h1>",
|
||||
"<h1>Ungültiger Linktyp</h1>": "<h1>Invalid link type</h1>",
|
||||
"<h1>Benutzer nicht gefunden</h1>": "<h1>User not found</h1>",
|
||||
"<h1>Reset-Link ungültig oder abgelaufen.</h1>": "<h1>Reset link invalid or expired.</h1>",
|
||||
"<h1>Fehler</h1><p>Der Benutzername muss mindestens 3 Zeichen lang sein.</p>": "<h1>Error</h1><p>The username must contain at least 3 characters.</p>",
|
||||
"<h1>Fehler</h1><p>Das Passwort muss mindestens 10 Zeichen lang sein.</p>": "<h1>Error</h1><p>The password must contain at least 10 characters.</p>",
|
||||
"<h1>Ungültige Einladung</h1>": "<h1>Invalid invitation</h1>",
|
||||
"Instagram verknüpft": "Instagram linked",
|
||||
"Ungültige Profilsichtbarkeit.": "Invalid profile visibility.",
|
||||
"Benutzer nicht gefunden.": "User not found.",
|
||||
"Ungültige Aktion.": "Invalid action.",
|
||||
"Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein.": "A message must contain between 1 and 2000 characters.",
|
||||
"Veranstaltung nicht gefunden.": "Event not found.",
|
||||
"Diese Veranstaltung hat keine zugeordnete Location.": "This event has no assigned venue.",
|
||||
"Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar.": "The diary is only available for past concerts, with an optional rating from 1 to 5.",
|
||||
"Veranstaltung nicht gefunden": "Event not found",
|
||||
"<h1>Veranstaltung nicht gefunden</h1>": "<h1>Event not found</h1>",
|
||||
"<h1>Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.</h1>": "<h1>The title or artist name must contain between 2 and 255 characters.</h1>",
|
||||
"<h1>Ungültige Veranstaltungskategorie.</h1>": "<h1>Invalid event category.</h1>",
|
||||
"<h1>Ungültige Sichtbarkeit.</h1>": "<h1>Invalid visibility.</h1>",
|
||||
"<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>": "<h1>The end date must not be before the start.</h1>",
|
||||
"<h1>Vergangene Veranstaltungen dürfen nur Admins bearbeiten.</h1>": "<h1>Only admins may edit past events.</h1>",
|
||||
"<h1>Ungültige Auswahl</h1>": "<h1>Invalid selection</h1>",
|
||||
"CSRF-Prüfung fehlgeschlagen.": "CSRF check failed.",
|
||||
"Ungültige Hauptveranstaltung.": "Invalid parent event.",
|
||||
"Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP.": "Invalid image format. JPG, JPEG, PNG and WEBP are allowed.",
|
||||
"Die Datei darf maximal 10 MB groß sein.": "The file must not exceed 10 MB.",
|
||||
"Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen.": "Secure image validation is not installed yet. Please rebuild the web container.",
|
||||
"Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein.": "Passwords must match and contain at least 10 characters.",
|
||||
"<h1>Mögliche doppelte Veranstaltung</h1><p>Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.</p><ul>": "<h1>Possible duplicate event</h1><p>An event with a very similar artist name already exists on the same day.</p><ul>",
|
||||
"</ul><p>Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.</p>": "</ul><p>Please go back, check the match and confirm the warning in the form if you still want to save.</p>",
|
||||
"Zu viele Anfragen. Bitte später erneut versuchen.": "Too many requests. Please try again later.",
|
||||
"Bildauflösung zu groß": "Image resolution too large",
|
||||
"Die Datei ist kein gültiges oder unterstütztes Bild.": "The file is not a valid or supported image.",
|
||||
"<h1>Der Gründer Kai muss Admin bleiben.</h1>": "<h1>Founder Kai must remain an admin.</h1>",
|
||||
"<h1>Der Gründer Kai kann nicht gelöscht werden.</h1>": "<h1>Founder Kai cannot be deleted.</h1>",
|
||||
"<h1>Fehler</h1><p>Dieser Benutzername ist bereits vergeben.</p>": "<h1>Error</h1><p>This username is already taken.</p>",
|
||||
"<h1>Fehler</h1><p>Diese E-Mail-Adresse ist bereits registriert.</p>": "<h1>Error</h1><p>This email address is already registered.</h1>",
|
||||
"Freundschaftsanfrage wegen einer Blockierung nicht möglich.": "A friend request is not possible because of a block.",
|
||||
"Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.": "Chat not allowed. It is available for friends and conversations with admins.",
|
||||
"Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.": "Diary entries are only available for attended concerts.",
|
||||
"Pro Tagebucheintrag sind maximal drei Bilder möglich.": "Each diary entry can have a maximum of three images.",
|
||||
"Ein Mitglied": "A member",
|
||||
"Von Anfang an dabei und MetalCircle aufgebaut": "Here from the start and helped build MetalCircle",
|
||||
"In der Beta dabei": "Joined during the beta",
|
||||
"Erster Gig": "First gig",
|
||||
"Stammgast": "Regular",
|
||||
"5 Konzerte am selben Veranstaltungsort besucht": "Attended 5 concerts at the same venue",
|
||||
"10 besuchte Konzerte": "10 concerts attended",
|
||||
"25 besuchte Konzerte": "25 concerts attended",
|
||||
"50 besuchte Konzerte": "50 concerts attended",
|
||||
"100 besuchte Konzerte": "100 concerts attended",
|
||||
"Zwei Konzerte an zwei aufeinanderfolgenden Tagen": "Two concerts on two consecutive days",
|
||||
"Drei Konzerte innerhalb eines Wochenendes": "Three concerts in one weekend",
|
||||
"⚔️ Spezial-Patches": "⚔️ Special patches",
|
||||
"🤘 Stammorte": "🤘 Regular venues",
|
||||
"🌍 Unterwegs": "🌍 On the road",
|
||||
"⚡ Tourmodus": "⚡ Tour mode",
|
||||
"Anfrage aus fremder Quelle abgelehnt.": "Request from an external source rejected.",
|
||||
"<h1>Der Name darf nicht leer sein.</h1>": "<h1>The name must not be empty.</h1>",
|
||||
"<h1>Veranstaltungsort nicht gefunden.</h1>": "<h1>Venue not found.</h1>",
|
||||
"<h1>Patch nicht gefunden</h1>": "<h1>Patch not found</h1>",
|
||||
"<h1>Diese Einladung wurde bereits verwendet.</h1>": "<h1>This invitation has already been used.</h1>",
|
||||
"<h1>Diese Einladung ist abgelaufen.</h1>": "<h1>This invitation has expired.</h1>",
|
||||
"Benutzername oder Passwort ist falsch.": "Incorrect username or password.",
|
||||
"<h1>Profil konnte nicht gespeichert werden.</h1>": "<h1>Profile could not be saved.</h1>",
|
||||
"Du kannst dir nicht selbst eine Anfrage schicken.": "You cannot send yourself a friend request.",
|
||||
"Du kannst dich nicht selbst blockieren.": "You cannot block yourself.",
|
||||
"Freundschaft nicht gefunden.": "Friendship not found.",
|
||||
"Diese Anfrage kann nicht angenommen werden.": "This request cannot be accepted.",
|
||||
"Diese Anfrage kann nicht abgelehnt werden.": "This request cannot be declined.",
|
||||
"Band nicht gefunden.": "Band not found.",
|
||||
"Bild nicht gefunden.": "Image not found.",
|
||||
"Tagebucheintrag ist zu lang.": "Diary entry is too long.",
|
||||
"<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>": "<h1>Festivals require an end date.</h1>",
|
||||
"Keine passenden Treffer – Eingabe trotzdem speichern": "No matching results – save your entry anyway",
|
||||
"Als eigenen Veranstaltungsort anlegen": "Create as a new venue",
|
||||
"✏️ „{name}“ verwenden": "✏️ Use “{name}”",
|
||||
"Du kannst noch {count} Bild(er) hinzufügen.": "You can add {count} more image(s).",
|
||||
"Bitte für {field} eine vollständige HTTP- oder HTTPS-Adresse eingeben.": "Please enter a complete HTTP or HTTPS address for {field}.",
|
||||
"Sprache": "Language",
|
||||
"Sprache wechseln": "Switch language",
|
||||
"Ein technisch notwendiges Cookie speichert außerdem deine gewählte Sprache (Deutsch oder Englisch) für ein Jahr.": "A technically necessary cookie also stores your chosen language (German or English) for one year.",
|
||||
"Ungültige Sprache.": "Invalid language.",
|
||||
"den Flyer": "the flyer",
|
||||
"Zugesagt": "Going",
|
||||
"<h1>Kommentar ist zu lang (maximal 2000 Zeichen).</h1>": "<h1>Comment is too long (maximum 2000 characters).</h1>",
|
||||
"Profil gespeichert": "Profile saved",
|
||||
"Profilbild aktualisiert": "Profile picture updated",
|
||||
"Zusagen": "going",
|
||||
"Vielleicht": "Maybe",
|
||||
"sucht Ticket": "looking for a ticket",
|
||||
"Ort endgültig löschen?": "Permanently delete this venue?",
|
||||
"Der Ort ist mit {count} Konzert(en) verknüpft. Diese Konzerte haben danach keinen Veranstaltungsort mehr. Ort endgültig löschen?": "This venue is linked to {count} concert(s). These concerts will no longer have a venue afterwards. Permanently delete this venue?",
|
||||
"Patch-Bild entfernen und wieder das Symbol verwenden?": "Remove the patch image and use the symbol again?",
|
||||
"Diese Veranstaltung wirklich löschen?": "Really delete this event?",
|
||||
"Alle Konzerte werden dem Zielort zugeordnet und dieser Eintrag wird gelöscht. Fortfahren?": "All concerts will be assigned to the target venue and this entry will be deleted. Continue?",
|
||||
"Diesen Benutzer wirklich löschen?": "Really delete this user?",
|
||||
"Dieses Bild entfernen?": "Remove this image?",
|
||||
"Wirklich unwiderruflich löschen? Die Veranstaltung kann nicht mehr eingetragen werden.": "Really delete permanently? This event cannot be added again.",
|
||||
"Diesen Nutzer wirklich blockieren?": "Really block this user?",
|
||||
"Account wirklich dauerhaft löschen?": "Really delete your account permanently?"
|
||||
}
|
||||
+199
-131
@@ -30,6 +30,10 @@ from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from i18n import (
|
||||
LANGUAGE_COOKIE, current_language, current_page, gettext as _,
|
||||
language_url, safe_return_path, format_time, format_datetime,
|
||||
)
|
||||
|
||||
|
||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
@@ -436,6 +440,14 @@ def ensure_schema():
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS concert_diary_exclusions (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, concert_id)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE concert_diary ADD COLUMN IF NOT EXISTS photo_path TEXT
|
||||
""",
|
||||
"""
|
||||
@@ -531,9 +543,9 @@ async def security_controls(request: Request, call_next):
|
||||
referer = request.headers.get("referer")
|
||||
referer_host = urlparse(referer).netloc if referer else None
|
||||
if fetch_site == "cross-site" or (origin_host and origin_host != expected_host) or (referer_host and referer_host != expected_host):
|
||||
return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403)
|
||||
return HTMLResponse(_("Anfrage aus fremder Quelle abgelehnt."), status_code=403)
|
||||
if request.url.path not in {"/login", "/register"} and request.url.path.startswith("/password-reset") is False and not origin and not referer:
|
||||
return HTMLResponse("CSRF-Prüfung fehlgeschlagen.", status_code=403)
|
||||
return HTMLResponse(_("CSRF-Prüfung fehlgeschlagen."), status_code=403)
|
||||
|
||||
path = request.url.path
|
||||
if path == "/login":
|
||||
@@ -554,7 +566,7 @@ async def security_controls(request: Request, call_next):
|
||||
while bucket and bucket[0] <= now - window:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= limit:
|
||||
return HTMLResponse("Zu viele Anfragen. Bitte später erneut versuchen.", status_code=429,
|
||||
return HTMLResponse(_("Zu viele Anfragen. Bitte später erneut versuchen."), status_code=429,
|
||||
headers={"Retry-After": str(window)})
|
||||
bucket.append(now)
|
||||
|
||||
@@ -576,6 +588,7 @@ async def require_login(request: Request, call_next):
|
||||
or request.url.path.startswith("/register/")
|
||||
or request.url.path.startswith("/password-reset")
|
||||
or request.url.path in {"/impressum", "/datenschutz"}
|
||||
or request.url.path.startswith("/language/")
|
||||
or request.url.path == "/profile/export"
|
||||
or request.url.path.startswith("/static/")
|
||||
):
|
||||
@@ -587,6 +600,39 @@ async def require_login(request: Request, call_next):
|
||||
return login_redirect("/")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def localize_request(request: Request, call_next):
|
||||
language = request.cookies.get(LANGUAGE_COOKIE, "de")
|
||||
language = language if language in {"de", "en"} else "de"
|
||||
language_token = current_language.set(language)
|
||||
page_token = current_page.set(safe_return_path(
|
||||
request.url.path + ("?" + request.url.query if request.url.query else "")
|
||||
))
|
||||
try:
|
||||
response = await call_next(request)
|
||||
response.headers["Content-Language"] = language
|
||||
vary = response.headers.get("Vary", "")
|
||||
if "cookie" not in {item.strip().lower() for item in vary.split(",")}:
|
||||
response.headers["Vary"] = (vary + ", Cookie").lstrip(", ")
|
||||
return response
|
||||
finally:
|
||||
current_page.reset(page_token)
|
||||
current_language.reset(language_token)
|
||||
|
||||
|
||||
@app.get("/language/{language}")
|
||||
def change_language(language: str, next: str = "/"):
|
||||
if language not in {"de", "en"}:
|
||||
return HTMLResponse(_("Ungültige Sprache."), status_code=400)
|
||||
response = RedirectResponse(safe_return_path(next), status_code=303)
|
||||
response.set_cookie(
|
||||
LANGUAGE_COOKIE, language, max_age=365 * 24 * 60 * 60,
|
||||
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Templates
|
||||
# ============================================================
|
||||
@@ -597,6 +643,8 @@ templates = Environment(
|
||||
),
|
||||
autoescape=select_autoescape(["html"])
|
||||
)
|
||||
templates.globals.update(_=_, language=current_language.get, language_url=language_url)
|
||||
templates.filters["t"] = _
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -786,16 +834,16 @@ def get_linkable_events(exclude_id: int | None = None):
|
||||
|
||||
def resolve_event_relationship(cursor, event_type: str, parent_event_id: str, event_id=None):
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise ValueError("Ungültige Veranstaltungskategorie.")
|
||||
raise ValueError(_("Ungültige Veranstaltungskategorie."))
|
||||
if event_type != "other" or not parent_event_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
parent_id = int(parent_event_id)
|
||||
except ValueError as error:
|
||||
raise ValueError("Ungültige Hauptveranstaltung.") from error
|
||||
raise ValueError(_("Ungültige Hauptveranstaltung.")) from error
|
||||
if event_id is not None and parent_id == event_id:
|
||||
raise ValueError("Eine Veranstaltung kann nicht mit sich selbst verknüpft werden.")
|
||||
raise ValueError(_("Eine Veranstaltung kann nicht mit sich selbst verknüpft werden."))
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -815,9 +863,9 @@ def resolve_event_relationship(cursor, event_type: str, parent_event_id: str, ev
|
||||
)
|
||||
parent = cursor.fetchone()
|
||||
if not parent or parent[0] not in {"concert", "festival"}:
|
||||
raise ValueError("Die Hauptveranstaltung muss ein Konzert oder Festival sein.")
|
||||
raise ValueError(_("Die Hauptveranstaltung muss ein Konzert oder Festival sein."))
|
||||
if not parent[1]:
|
||||
raise ValueError("Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden.")
|
||||
raise ValueError(_("Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden."))
|
||||
return parent_id
|
||||
|
||||
|
||||
@@ -882,10 +930,10 @@ def load_concert(concert_id: int):
|
||||
"created_by": row[8],
|
||||
"is_past": is_past,
|
||||
"date": start_datetime.strftime("%d.%m.%Y"),
|
||||
"time": start_datetime.strftime("%H:%M"),
|
||||
"time": format_time(start_datetime),
|
||||
"start_local": start_datetime.strftime("%Y-%m-%dT%H:%M"),
|
||||
"end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None,
|
||||
"end_time": end_datetime.strftime("%H:%M") if end_datetime else None,
|
||||
"end_time": format_time(end_datetime) if end_datetime else None,
|
||||
"end_local": end_datetime.strftime("%Y-%m-%dT%H:%M") if end_datetime else "",
|
||||
"event_type": row[17],
|
||||
"event_type_label": EVENT_TYPES[row[17]],
|
||||
@@ -950,7 +998,7 @@ def serialize_concert_card(row):
|
||||
"id": concert_id,
|
||||
"artist": artist,
|
||||
"date": start_datetime.strftime("%d.%m.%Y"),
|
||||
"time": start_datetime.strftime("%H:%M"),
|
||||
"time": format_time(start_datetime),
|
||||
"end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None,
|
||||
"venue": venue_text,
|
||||
"city": city or "",
|
||||
@@ -1124,10 +1172,10 @@ def normalize_instagram_url(value: str):
|
||||
candidate = value if "://" in value else f"https://{value}"
|
||||
parsed = urlparse(candidate)
|
||||
if (parsed.hostname or "").lower() not in {"instagram.com", "www.instagram.com"}:
|
||||
raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.")
|
||||
raise ValueError(_("Bitte einen gültigen Instagram-Profillink eingeben."))
|
||||
username = parsed.path.strip("/").split("/", 1)[0]
|
||||
if not re.fullmatch(r"[A-Za-z0-9._]{1,30}", username):
|
||||
raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.")
|
||||
raise ValueError(_("Bitte einen gültigen Instagram-Profillink eingeben."))
|
||||
return f"https://www.instagram.com/{username}/"
|
||||
|
||||
|
||||
@@ -1137,7 +1185,7 @@ def normalize_external_url(value: str, field_name: str):
|
||||
return None
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise ValueError(f"Bitte für {field_name} eine vollständige HTTP- oder HTTPS-Adresse eingeben.")
|
||||
raise ValueError(_("Bitte für {field} eine vollständige HTTP- oder HTTPS-Adresse eingeben.").format(field=_(field_name)))
|
||||
return value
|
||||
|
||||
|
||||
@@ -1248,7 +1296,7 @@ def find_duplicate_concerts(user, artist: str, start_date: str, band_names: str
|
||||
"id": row[0],
|
||||
"artist": row[1],
|
||||
"date": row[2].strftime("%d.%m.%Y"),
|
||||
"time": row[2].strftime("%H:%M"),
|
||||
"time": format_time(row[2]),
|
||||
"event_type": row[3],
|
||||
"venue": ", ".join(part for part in (row[4], row[5]) if part),
|
||||
}
|
||||
@@ -1269,7 +1317,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
|
||||
|
||||
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
return None, HTMLResponse(
|
||||
"Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP.",
|
||||
_("Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP."),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -1277,13 +1325,13 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
|
||||
|
||||
if len(contents) > MAX_IMAGE_BYTES:
|
||||
return None, HTMLResponse(
|
||||
"Die Datei darf maximal 10 MB groß sein.",
|
||||
_("Die Datei darf maximal 10 MB groß sein."),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if Image is None:
|
||||
return None, HTMLResponse(
|
||||
"Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen.",
|
||||
_("Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen."),
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
@@ -1294,7 +1342,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
|
||||
with Image.open(BytesIO(contents)) as candidate:
|
||||
image = ImageOps.exif_transpose(candidate)
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise ValueError("Bildauflösung zu groß")
|
||||
raise ValueError(_("Bildauflösung zu groß"))
|
||||
image.thumbnail((2400, 2400))
|
||||
has_alpha = image.mode in {"RGBA", "LA"} or (
|
||||
image.mode == "P" and "transparency" in image.info
|
||||
@@ -1313,7 +1361,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
|
||||
safe_contents = output.getvalue()
|
||||
except (ValueError, OSError, UnidentifiedImageError, Image.DecompressionBombError):
|
||||
return None, HTMLResponse(
|
||||
"Die Datei ist kein gültiges oder unterstütztes Bild.", status_code=400
|
||||
_("Die Datei ist kein gültiges oder unterstütztes Bild."), status_code=400
|
||||
)
|
||||
|
||||
filename = str(uuid.uuid4()) + (".png" if output_format == "PNG" else ".jpg")
|
||||
@@ -1706,7 +1754,7 @@ def get_admin_venues():
|
||||
@app.get("/admin")
|
||||
def admin_page(request: Request):
|
||||
if not require_admin(request):
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
return RedirectResponse("/admin/users", status_code=303)
|
||||
|
||||
|
||||
@@ -1715,7 +1763,7 @@ def admin_users_page(request: Request):
|
||||
user = require_admin(request)
|
||||
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -1759,7 +1807,7 @@ def admin_users_page(request: Request):
|
||||
def admin_venues_page(request: Request):
|
||||
user = require_admin(request)
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
template = templates.get_template("admin_venues.html")
|
||||
return template.render(user=user, venues=get_admin_venues())
|
||||
|
||||
@@ -1768,7 +1816,7 @@ def admin_venues_page(request: Request):
|
||||
def admin_patches_page(request: Request):
|
||||
user = require_admin(request)
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
assets = load_badge_assets()
|
||||
patches = [
|
||||
{
|
||||
@@ -1798,7 +1846,7 @@ def admin_patches_page(request: Request):
|
||||
def admin_statistics_page(request: Request):
|
||||
user = require_admin(request)
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
queries = {
|
||||
@@ -1827,7 +1875,7 @@ def create_invite(request: Request):
|
||||
user = require_admin(request)
|
||||
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
@@ -1902,11 +1950,11 @@ def update_venue(
|
||||
is_verified: str = Form(""),
|
||||
):
|
||||
if not require_admin(request):
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return HTMLResponse("<h1>Der Name darf nicht leer sein.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Der Name darf nicht leer sein.</h1>"), status_code=400)
|
||||
|
||||
normalized_aliases = sorted({
|
||||
alias.strip()
|
||||
@@ -1950,15 +1998,15 @@ def merge_venue(
|
||||
target_venue_id: int = Form(...),
|
||||
):
|
||||
if not require_admin(request):
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
if venue_id == target_venue_id:
|
||||
return HTMLResponse("<h1>Ein Ort kann nicht mit sich selbst zusammengeführt werden.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Ein Ort kann nicht mit sich selbst zusammengeführt werden.</h1>"), status_code=400)
|
||||
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT id FROM venues WHERE id IN (%s, %s)", (venue_id, target_venue_id))
|
||||
if len(cursor.fetchall()) != 2:
|
||||
return HTMLResponse("<h1>Veranstaltungsort nicht gefunden.</h1>", status_code=404)
|
||||
return HTMLResponse(_("<h1>Veranstaltungsort nicht gefunden.</h1>"), status_code=404)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO venue_aliases (venue_id, alias)
|
||||
@@ -1988,7 +2036,7 @@ def merge_venue(
|
||||
@app.post("/admin/venues/{venue_id}/delete")
|
||||
def delete_venue(request: Request, venue_id: int):
|
||||
if not require_admin(request):
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -2011,15 +2059,15 @@ async def update_patch_image(
|
||||
user = require_admin(request)
|
||||
valid_codes = {definition[0] for definition in BADGE_DEFINITIONS}
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
if badge_code not in valid_codes:
|
||||
return HTMLResponse("<h1>Patch nicht gefunden</h1>", status_code=404)
|
||||
return HTMLResponse(_("<h1>Patch nicht gefunden</h1>"), status_code=404)
|
||||
|
||||
image_path, error = save_image(image, PATCH_DIR, "/static/uploads/patches/")
|
||||
if error:
|
||||
return error
|
||||
if not image_path:
|
||||
return HTMLResponse("<h1>Bitte ein Bild auswählen.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Bitte ein Bild auswählen.</h1>"), status_code=400)
|
||||
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -2045,9 +2093,9 @@ async def update_patch_image(
|
||||
@app.post("/admin/patches/{badge_code}/delete")
|
||||
def delete_patch_image(request: Request, badge_code: str):
|
||||
if not require_admin(request):
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
if badge_code not in {definition[0] for definition in BADGE_DEFINITIONS}:
|
||||
return HTMLResponse("<h1>Patch nicht gefunden</h1>", status_code=404)
|
||||
return HTMLResponse(_("<h1>Patch nicht gefunden</h1>"), status_code=404)
|
||||
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -2069,12 +2117,12 @@ def update_user_role(
|
||||
user = require_admin(request)
|
||||
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
if role not in {"admin", "user"}:
|
||||
return HTMLResponse("<h1>Ungültige Rolle</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Ungültige Rolle</h1>"), status_code=400)
|
||||
if user_id == user["id"] and role != "admin":
|
||||
return HTMLResponse(
|
||||
"<h1>Die eigenen Adminrechte können nicht entfernt werden.</h1>",
|
||||
_("<h1>Die eigenen Adminrechte können nicht entfernt werden.</h1>"),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -2084,7 +2132,7 @@ def update_user_role(
|
||||
target_user = cursor.fetchone()
|
||||
if target_user and (target_user[0] or "").casefold() == "kai" and role != "admin":
|
||||
return HTMLResponse(
|
||||
"<h1>Der Gründer Kai muss Admin bleiben.</h1>",
|
||||
_("<h1>Der Gründer Kai muss Admin bleiben.</h1>"),
|
||||
status_code=400,
|
||||
)
|
||||
cursor.execute(
|
||||
@@ -2101,10 +2149,10 @@ def delete_user(request: Request, user_id: int):
|
||||
user = require_admin(request)
|
||||
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
if user_id == user["id"]:
|
||||
return HTMLResponse(
|
||||
"<h1>Der eigene Account kann nicht gelöscht werden.</h1>",
|
||||
_("<h1>Der eigene Account kann nicht gelöscht werden.</h1>"),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -2114,7 +2162,7 @@ def delete_user(request: Request, user_id: int):
|
||||
target_user = cursor.fetchone()
|
||||
if target_user and (target_user[0] or "").casefold() == "kai":
|
||||
return HTMLResponse(
|
||||
"<h1>Der Gründer Kai kann nicht gelöscht werden.</h1>",
|
||||
_("<h1>Der Gründer Kai kann nicht gelöscht werden.</h1>"),
|
||||
status_code=400,
|
||||
)
|
||||
cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,))
|
||||
@@ -2134,15 +2182,15 @@ def delete_user(request: Request, user_id: int):
|
||||
def create_user_account_link(request: Request, user_id: int, purpose: str = Form(...)):
|
||||
user = require_admin(request)
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||||
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
|
||||
if purpose != "password_reset":
|
||||
return HTMLResponse("<h1>Ungültiger Linktyp</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Ungültiger Linktyp</h1>"), status_code=400)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT username, email FROM users WHERE id = %s", (user_id,))
|
||||
target = cursor.fetchone()
|
||||
if not target:
|
||||
return HTMLResponse("<h1>Benutzer nicht gefunden</h1>", status_code=404)
|
||||
return HTMLResponse(_("<h1>Benutzer nicht gefunden</h1>"), status_code=404)
|
||||
token = create_account_token(user_id, purpose, 2)
|
||||
path = f"/password-reset/{token}"
|
||||
template = templates.get_template("account_link.html")
|
||||
@@ -2166,7 +2214,7 @@ def password_reset_page(token: str):
|
||||
)
|
||||
valid = cursor.fetchone()
|
||||
if not valid:
|
||||
return HTMLResponse("<h1>Reset-Link ungültig oder abgelaufen.</h1>", status_code=410)
|
||||
return HTMLResponse(_("<h1>Reset-Link ungültig oder abgelaufen.</h1>"), status_code=410)
|
||||
return templates.get_template("password_reset.html").render(token=token, error=None)
|
||||
|
||||
|
||||
@@ -2174,7 +2222,7 @@ def password_reset_page(token: str):
|
||||
def password_reset(token: str, password: str = Form(...), password_repeat: str = Form(...)):
|
||||
if len(password) < 10 or password != password_repeat:
|
||||
return templates.get_template("password_reset.html").render(
|
||||
token=token, error="Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein."
|
||||
token=token, error=_("Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein.")
|
||||
)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -2188,7 +2236,7 @@ def password_reset(token: str, password: str = Form(...), password_repeat: str =
|
||||
)
|
||||
account_token = cursor.fetchone()
|
||||
if not account_token:
|
||||
return HTMLResponse("<h1>Reset-Link ungültig oder abgelaufen.</h1>", status_code=410)
|
||||
return HTMLResponse(_("<h1>Reset-Link ungültig oder abgelaufen.</h1>"), status_code=410)
|
||||
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, account_token[1]))
|
||||
cursor.execute("UPDATE account_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = %s", (account_token[0],))
|
||||
@@ -2211,13 +2259,13 @@ def register_user(
|
||||
|
||||
if len(username) < 3:
|
||||
return HTMLResponse(
|
||||
"<h1>Fehler</h1><p>Der Benutzername muss mindestens 3 Zeichen lang sein.</p>",
|
||||
_("<h1>Fehler</h1><p>Der Benutzername muss mindestens 3 Zeichen lang sein.</p>"),
|
||||
status_code=400
|
||||
)
|
||||
|
||||
if len(password) < 10:
|
||||
return HTMLResponse(
|
||||
"<h1>Fehler</h1><p>Das Passwort muss mindestens 10 Zeichen lang sein.</p>",
|
||||
_("<h1>Fehler</h1><p>Das Passwort muss mindestens 10 Zeichen lang sein.</p>"),
|
||||
status_code=400
|
||||
)
|
||||
|
||||
@@ -2241,7 +2289,7 @@ def register_user(
|
||||
|
||||
if not invite:
|
||||
return HTMLResponse(
|
||||
"<h1>Ungültige Einladung</h1>",
|
||||
_("<h1>Ungültige Einladung</h1>"),
|
||||
status_code=404
|
||||
)
|
||||
|
||||
@@ -2249,13 +2297,13 @@ def register_user(
|
||||
|
||||
if used_at:
|
||||
return HTMLResponse(
|
||||
"<h1>Diese Einladung wurde bereits verwendet.</h1>",
|
||||
_("<h1>Diese Einladung wurde bereits verwendet.</h1>"),
|
||||
status_code=410
|
||||
)
|
||||
|
||||
if expires_at and datetime.now() > expires_at:
|
||||
return HTMLResponse(
|
||||
"<h1>Diese Einladung ist abgelaufen.</h1>",
|
||||
_("<h1>Diese Einladung ist abgelaufen.</h1>"),
|
||||
status_code=410
|
||||
)
|
||||
|
||||
@@ -2270,7 +2318,7 @@ def register_user(
|
||||
|
||||
if cursor.fetchone():
|
||||
return HTMLResponse(
|
||||
"<h1>Fehler</h1><p>Dieser Benutzername ist bereits vergeben.</p>",
|
||||
_("<h1>Fehler</h1><p>Dieser Benutzername ist bereits vergeben.</p>"),
|
||||
status_code=400
|
||||
)
|
||||
|
||||
@@ -2285,7 +2333,7 @@ def register_user(
|
||||
|
||||
if cursor.fetchone():
|
||||
return HTMLResponse(
|
||||
"<h1>Fehler</h1><p>Diese E-Mail-Adresse ist bereits registriert.</p>",
|
||||
_("<h1>Fehler</h1><p>Diese E-Mail-Adresse ist bereits registriert.</p>"),
|
||||
status_code=400
|
||||
)
|
||||
|
||||
@@ -2370,7 +2418,7 @@ def register_page(token: str):
|
||||
|
||||
if not invite:
|
||||
return HTMLResponse(
|
||||
"<h1>Ungültige Einladung</h1>",
|
||||
_("<h1>Ungültige Einladung</h1>"),
|
||||
status_code=404
|
||||
)
|
||||
|
||||
@@ -2378,7 +2426,7 @@ def register_page(token: str):
|
||||
|
||||
if used_at:
|
||||
return HTMLResponse(
|
||||
"<h1>Diese Einladung wurde bereits verwendet.</h1>",
|
||||
_("<h1>Diese Einladung wurde bereits verwendet.</h1>"),
|
||||
status_code=410
|
||||
)
|
||||
|
||||
@@ -2387,7 +2435,7 @@ def register_page(token: str):
|
||||
|
||||
if datetime.now() > expires_at:
|
||||
return HTMLResponse(
|
||||
"<h1>Diese Einladung ist abgelaufen.</h1>",
|
||||
_("<h1>Diese Einladung ist abgelaufen.</h1>"),
|
||||
status_code=410
|
||||
)
|
||||
|
||||
@@ -2455,7 +2503,7 @@ def login(
|
||||
return HTMLResponse(
|
||||
template.render(
|
||||
next_path=next_path,
|
||||
error="Benutzername oder Passwort ist falsch.",
|
||||
error=_("Benutzername oder Passwort ist falsch."),
|
||||
),
|
||||
status_code=401,
|
||||
)
|
||||
@@ -2536,7 +2584,7 @@ def render_profile(
|
||||
profile = load_profile(username)
|
||||
|
||||
if not profile:
|
||||
return HTMLResponse("<h1>Benutzer nicht gefunden</h1>", status_code=404)
|
||||
return HTMLResponse(_("<h1>Benutzer nicht gefunden</h1>"), status_code=404)
|
||||
|
||||
is_own_profile = bool(viewer and viewer["id"] == profile["id"])
|
||||
friendship = None
|
||||
@@ -2689,11 +2737,11 @@ def own_profile(request: Request, saved: str = ""):
|
||||
saved_items = set(saved.split(","))
|
||||
messages = []
|
||||
if "profile" in saved_items:
|
||||
messages.append("Profil gespeichert")
|
||||
messages.append(_("Profil gespeichert"))
|
||||
if "avatar" in saved_items:
|
||||
messages.append("Profilbild aktualisiert")
|
||||
messages.append(_("Profilbild aktualisiert"))
|
||||
if "instagram" in saved_items:
|
||||
messages.append("Instagram verknüpft")
|
||||
messages.append(_("Instagram verknüpft"))
|
||||
return render_profile(
|
||||
request,
|
||||
user["username"],
|
||||
@@ -2920,7 +2968,7 @@ async def update_profile(
|
||||
status_code=400,
|
||||
)
|
||||
if profile_visibility not in {"public", "friends", "nobody"}:
|
||||
return HTMLResponse("Ungültige Profilsichtbarkeit.", status_code=400)
|
||||
return HTMLResponse(_("Ungültige Profilsichtbarkeit."), status_code=400)
|
||||
avatar_path, error = save_image(
|
||||
avatar,
|
||||
AVATAR_DIR,
|
||||
@@ -2952,7 +3000,7 @@ async def update_profile(
|
||||
|
||||
if not saved_profile:
|
||||
remove_uploaded_file(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")
|
||||
return HTMLResponse("<h1>Profil konnte nicht gespeichert werden.</h1>", status_code=500)
|
||||
return HTMLResponse(_("<h1>Profil konnte nicht gespeichert werden.</h1>"), status_code=500)
|
||||
if avatar_path and previous_profile and previous_profile[0] != avatar_path:
|
||||
remove_uploaded_file(previous_profile[0], AVATAR_DIR, "/static/uploads/avatars/")
|
||||
saved_parts = ["profile"]
|
||||
@@ -2971,9 +3019,9 @@ def send_friend_request(request: Request, username: str):
|
||||
user = get_current_user(request)
|
||||
profile = load_profile(username)
|
||||
if not profile:
|
||||
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404)
|
||||
if profile["id"] == user["id"]:
|
||||
return HTMLResponse("Du kannst dir nicht selbst eine Anfrage schicken.", status_code=400)
|
||||
return HTMLResponse(_("Du kannst dir nicht selbst eine Anfrage schicken."), status_code=400)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
@@ -2985,7 +3033,7 @@ def send_friend_request(request: Request, username: str):
|
||||
(user["id"], profile["id"], profile["id"], user["id"]),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
return HTMLResponse("Freundschaftsanfrage wegen einer Blockierung nicht möglich.", status_code=403)
|
||||
return HTMLResponse(_("Freundschaftsanfrage wegen einer Blockierung nicht möglich."), status_code=403)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO friendships (requester_id, addressee_id)
|
||||
@@ -3003,9 +3051,9 @@ def block_user(request: Request, username: str):
|
||||
user = get_current_user(request)
|
||||
profile = load_profile(username)
|
||||
if not profile:
|
||||
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404)
|
||||
if profile["id"] == user["id"]:
|
||||
return HTMLResponse("Du kannst dich nicht selbst blockieren.", status_code=400)
|
||||
return HTMLResponse(_("Du kannst dich nicht selbst blockieren."), status_code=400)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
@@ -3032,7 +3080,7 @@ def unblock_user(request: Request, username: str):
|
||||
user = get_current_user(request)
|
||||
profile = load_profile(username)
|
||||
if not profile:
|
||||
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
@@ -3047,7 +3095,7 @@ def unblock_user(request: Request, username: str):
|
||||
def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")):
|
||||
user = get_current_user(request)
|
||||
if action not in {"accept", "decline", "remove"}:
|
||||
return HTMLResponse("Ungültige Aktion.", status_code=400)
|
||||
return HTMLResponse(_("Ungültige Aktion."), status_code=400)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
@@ -3056,17 +3104,17 @@ def manage_friendship(request: Request, friendship_id: int, action: str, return_
|
||||
)
|
||||
friendship = cursor.fetchone()
|
||||
if not friendship or user["id"] not in friendship[:2]:
|
||||
return HTMLResponse("Freundschaft nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Freundschaft nicht gefunden."), status_code=404)
|
||||
if action == "accept":
|
||||
if friendship[1] != user["id"] or friendship[2] != "pending":
|
||||
return HTMLResponse("Diese Anfrage kann nicht angenommen werden.", status_code=403)
|
||||
return HTMLResponse(_("Diese Anfrage kann nicht angenommen werden."), status_code=403)
|
||||
cursor.execute(
|
||||
"UPDATE friendships SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(friendship_id,),
|
||||
)
|
||||
else:
|
||||
if action == "decline" and (friendship[1] != user["id"] or friendship[2] != "pending"):
|
||||
return HTMLResponse("Diese Anfrage kann nicht abgelehnt werden.", status_code=403)
|
||||
return HTMLResponse(_("Diese Anfrage kann nicht abgelehnt werden."), status_code=403)
|
||||
cursor.execute("DELETE FROM friendships WHERE id = %s", (friendship_id,))
|
||||
connection.commit()
|
||||
other_id = friendship[1] if friendship[0] == user["id"] else friendship[0]
|
||||
@@ -3131,7 +3179,7 @@ def message_inbox(request: Request):
|
||||
)
|
||||
friend_requests = [
|
||||
{"id": row[0], "username": row[1], "display_name": row[2],
|
||||
"created_at": row[3].strftime("%d.%m.%Y %H:%M")}
|
||||
"created_at": format_datetime(row[3])}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
cursor.execute(
|
||||
@@ -3148,7 +3196,7 @@ def message_inbox(request: Request):
|
||||
)
|
||||
event_invitations = [
|
||||
{"concert_id": row[0], "artist": row[1],
|
||||
"date": row[2].strftime("%d.%m.%Y %H:%M"), "invited_by": row[3] or "Ein Mitglied"}
|
||||
"date": format_datetime(row[2]), "invited_by": row[3] or _("Ein Mitglied")}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
cursor.execute(
|
||||
@@ -3188,7 +3236,7 @@ def message_inbox(request: Request):
|
||||
conversations.append({
|
||||
"id": row[0], "username": row[1], "display_name": row[2],
|
||||
"avatar_path": row[3], "last_message": latest[0] if latest else None,
|
||||
"last_at": latest[1].strftime("%d.%m.%Y %H:%M") if latest else None,
|
||||
"last_at": format_datetime(latest[1]) if latest else None,
|
||||
"last_at_raw": latest[1] if latest else None,
|
||||
"last_from_me": bool(latest and latest[2] == user["id"]),
|
||||
"unread_count": latest[3] if latest else 0,
|
||||
@@ -3206,7 +3254,7 @@ def message_conversation(request: Request, username: str):
|
||||
with connection.cursor() as cursor:
|
||||
partner = load_chat_partner(cursor, user, username)
|
||||
if not partner:
|
||||
return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403)
|
||||
return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403)
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE direct_messages SET read_at = CURRENT_TIMESTAMP
|
||||
@@ -3227,7 +3275,7 @@ def message_conversation(request: Request, username: str):
|
||||
)
|
||||
messages = [
|
||||
{"from_me": row[0] == user["id"], "body": row[1],
|
||||
"created_at": row[2].strftime("%d.%m.%Y %H:%M")}
|
||||
"created_at": format_datetime(row[2])}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
connection.commit()
|
||||
@@ -3240,12 +3288,12 @@ def send_message(request: Request, username: str, body: str = Form(...)):
|
||||
user = get_current_user(request)
|
||||
body = body.strip()
|
||||
if not body or len(body) > 2000:
|
||||
return HTMLResponse("Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein.", status_code=400)
|
||||
return HTMLResponse(_("Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein."), status_code=400)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
partner = load_chat_partner(cursor, user, username)
|
||||
if not partner:
|
||||
return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403)
|
||||
return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403)
|
||||
cursor.execute(
|
||||
"INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s)",
|
||||
(user["id"], partner["id"], body),
|
||||
@@ -3331,7 +3379,7 @@ def following_page(request: Request):
|
||||
events = [
|
||||
{
|
||||
"id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
|
||||
"time": row[2].strftime("%H:%M"), "venue": ", ".join(filter(None, (row[4], row[5]))),
|
||||
"time": format_time(row[2]), "venue": ", ".join(filter(None, (row[4], row[5]))),
|
||||
"matched_band": any(
|
||||
artist_names_similar(event_band["name"], followed_band)
|
||||
for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8]))
|
||||
@@ -3376,11 +3424,11 @@ def follow_band(request: Request, concert_id: int, band_key: str = Form(...), ac
|
||||
user = get_current_user(request)
|
||||
concert = load_concert(concert_id)
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Veranstaltung nicht gefunden."), status_code=404)
|
||||
bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"])
|
||||
selected_band = next((band for band in bands if band["key"] == band_key), None)
|
||||
if not selected_band:
|
||||
return HTMLResponse("Band nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Band nicht gefunden."), status_code=404)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
if action == "unfollow":
|
||||
@@ -3391,7 +3439,7 @@ def follow_band(request: Request, concert_id: int, band_key: str = Form(...), ac
|
||||
(user["id"], band_key, selected_band["name"]),
|
||||
)
|
||||
else:
|
||||
return HTMLResponse("Ungültige Aktion.", status_code=400)
|
||||
return HTMLResponse(_("Ungültige Aktion."), status_code=400)
|
||||
connection.commit()
|
||||
return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303)
|
||||
|
||||
@@ -3401,10 +3449,10 @@ def follow_venue(request: Request, concert_id: int, action: str = Form("follow")
|
||||
user = get_current_user(request)
|
||||
concert = load_concert(concert_id)
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Veranstaltung nicht gefunden."), status_code=404)
|
||||
venue_id = concert["venue"]["id"]
|
||||
if not venue_id:
|
||||
return HTMLResponse("Diese Veranstaltung hat keine zugeordnete Location.", status_code=400)
|
||||
return HTMLResponse(_("Diese Veranstaltung hat keine zugeordnete Location."), status_code=400)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
if action == "unfollow":
|
||||
@@ -3412,7 +3460,7 @@ def follow_venue(request: Request, concert_id: int, action: str = Form("follow")
|
||||
elif action == "follow":
|
||||
cursor.execute("INSERT INTO followed_venues (user_id, venue_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (user["id"], venue_id))
|
||||
else:
|
||||
return HTMLResponse("Ungültige Aktion.", status_code=400)
|
||||
return HTMLResponse(_("Ungültige Aktion."), status_code=400)
|
||||
connection.commit()
|
||||
return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303)
|
||||
|
||||
@@ -3429,6 +3477,10 @@ def diary_page(request: Request):
|
||||
FROM concert_attendance ca JOIN concerts c ON c.id = ca.concert_id
|
||||
WHERE ca.user_id = %s AND ca.status = 'attending'
|
||||
AND COALESCE(c.end_datetime, c.start_datetime) < CURRENT_TIMESTAMP
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM concert_diary_exclusions de
|
||||
WHERE de.user_id = ca.user_id AND de.concert_id = ca.concert_id
|
||||
)
|
||||
ON CONFLICT (user_id, concert_id) DO NOTHING
|
||||
""",
|
||||
(user["id"],),
|
||||
@@ -3486,7 +3538,7 @@ def diary_page(request: Request):
|
||||
def diary_photo(request: Request, filename: str):
|
||||
user = get_current_user(request)
|
||||
if not filename or os.path.basename(filename) != filename:
|
||||
return HTMLResponse("Bild nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Bild nicht gefunden."), status_code=404)
|
||||
photo_path = f"/diary/photo/{filename}"
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -3495,10 +3547,10 @@ def diary_photo(request: Request, filename: str):
|
||||
(user["id"], photo_path),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
return HTMLResponse("Bild nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Bild nicht gefunden."), status_code=404)
|
||||
file_path = os.path.join(DIARY_PHOTO_DIR, filename)
|
||||
if not os.path.isfile(file_path):
|
||||
return HTMLResponse("Bild nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Bild nicht gefunden."), status_code=404)
|
||||
return FileResponse(file_path)
|
||||
|
||||
|
||||
@@ -3511,17 +3563,17 @@ def save_diary_entry(
|
||||
user = get_current_user(request)
|
||||
concert = load_concert(concert_id)
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Veranstaltung nicht gefunden."), status_code=404)
|
||||
try:
|
||||
rating_value = int(rating) if rating else None
|
||||
except ValueError:
|
||||
rating_value = None
|
||||
if not concert["is_past"] or (rating_value is not None and rating_value not in range(1, 6)):
|
||||
return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar.", status_code=400)
|
||||
return HTMLResponse(_("Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar."), status_code=400)
|
||||
favorite_song = favorite_song.strip()
|
||||
notes = notes.strip()
|
||||
if len(favorite_song) > 255 or len(notes) > 5000:
|
||||
return HTMLResponse("Tagebucheintrag ist zu lang.", status_code=400)
|
||||
return HTMLResponse(_("Tagebucheintrag ist zu lang."), status_code=400)
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
@@ -3529,12 +3581,12 @@ def save_diary_entry(
|
||||
(concert_id, user["id"]),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403)
|
||||
return HTMLResponse(_("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich."), status_code=403)
|
||||
cursor.execute("SELECT COUNT(*) FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
|
||||
existing_photo_count = cursor.fetchone()[0]
|
||||
uploads = [photo for photo in photos if photo and photo.filename]
|
||||
if existing_photo_count + len(uploads) > 3:
|
||||
return HTMLResponse("Pro Tagebucheintrag sind maximal drei Bilder möglich.", status_code=400)
|
||||
return HTMLResponse(_("Pro Tagebucheintrag sind maximal drei Bilder möglich."), status_code=400)
|
||||
saved_paths = []
|
||||
for photo in uploads:
|
||||
saved_path, error = save_image(photo, DIARY_PHOTO_DIR, "/diary/photo/")
|
||||
@@ -3568,7 +3620,7 @@ def delete_diary_photo(request: Request, photo_id: int):
|
||||
deleted = cursor.fetchone()
|
||||
connection.commit()
|
||||
if not deleted:
|
||||
return HTMLResponse("Bild nicht gefunden.", status_code=404)
|
||||
return HTMLResponse(_("Bild nicht gefunden."), status_code=404)
|
||||
remove_uploaded_file(deleted[0], DIARY_PHOTO_DIR, "/diary/photo/")
|
||||
return RedirectResponse(f"/diary#concert-{deleted[1]}", status_code=303)
|
||||
|
||||
@@ -3580,6 +3632,15 @@ def delete_diary_entry(request: Request, concert_id: int):
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT path FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
|
||||
photo_paths = [row[0] for row in cursor.fetchall()]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO concert_diary_exclusions (user_id, concert_id)
|
||||
SELECT user_id, concert_id FROM concert_diary
|
||||
WHERE user_id = %s AND concert_id = %s
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(user["id"], concert_id),
|
||||
)
|
||||
cursor.execute("DELETE FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
|
||||
cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
|
||||
connection.commit()
|
||||
@@ -3600,12 +3661,12 @@ def _ics_escape(value: str) -> str:
|
||||
def concert_ics(request: Request, concert_id: int):
|
||||
concert = load_concert(concert_id)
|
||||
if not concert:
|
||||
return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404)
|
||||
return PlainTextResponse(_("Veranstaltung nicht gefunden"), status_code=404)
|
||||
# Kalenderdaten unterliegen denselben Sichtbarkeitsregeln wie die Detailseite.
|
||||
# Ohne Sitzung bzw. ohne Berechtigung keine Informationen preisgeben.
|
||||
user = get_current_user(request)
|
||||
if not user or not can_view_event(user, concert):
|
||||
return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404)
|
||||
return PlainTextResponse(_("Veranstaltung nicht gefunden"), status_code=404)
|
||||
local_zone = ZoneInfo("Europe/Berlin")
|
||||
start = concert["start_datetime"].replace(tzinfo=local_zone)
|
||||
end_value = concert.get("end_datetime")
|
||||
@@ -3635,7 +3696,7 @@ def concert_detail(request: Request, concert_id: int):
|
||||
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
_("<h1>Veranstaltung nicht gefunden</h1>"),
|
||||
status_code=404
|
||||
)
|
||||
|
||||
@@ -3730,7 +3791,7 @@ def concert_detail(request: Request, concert_id: int):
|
||||
{
|
||||
"id": row[0],
|
||||
"body": row[1],
|
||||
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
|
||||
"created_at": format_datetime(row[2]),
|
||||
"author": row[3] or row[4],
|
||||
"username": row[4],
|
||||
}
|
||||
@@ -3741,7 +3802,7 @@ def concert_detail(request: Request, concert_id: int):
|
||||
{
|
||||
"id": row[0],
|
||||
"path": row[1],
|
||||
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
|
||||
"created_at": format_datetime(row[2]),
|
||||
"author": row[3] or row[4],
|
||||
}
|
||||
for row in photo_rows
|
||||
@@ -3833,16 +3894,16 @@ async def create_concert(
|
||||
|
||||
artist = artist.strip()
|
||||
if len(artist) < 2 or len(artist) > 255:
|
||||
return HTMLResponse("<h1>Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.</h1>"), status_code=400)
|
||||
if event_type not in EVENT_TYPES:
|
||||
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Ungültige Veranstaltungskategorie.</h1>"), status_code=400)
|
||||
parsed_bands = parse_band_names(band_names, artist, event_type)
|
||||
if event_type == "festival" and not end_datetime:
|
||||
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>"), status_code=400)
|
||||
if event_type != "festival":
|
||||
end_datetime = ""
|
||||
if visibility not in {"public", "friends", "private"}:
|
||||
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Ungültige Sichtbarkeit.</h1>"), status_code=400)
|
||||
if event_type != "other":
|
||||
visibility = "public"
|
||||
duplicate_matches = find_duplicate_concerts(user, artist, start_datetime, band_names)
|
||||
@@ -3852,14 +3913,13 @@ async def create_concert(
|
||||
for match in duplicate_matches
|
||||
)
|
||||
return HTMLResponse(
|
||||
"<h1>Mögliche doppelte Veranstaltung</h1>"
|
||||
"<p>Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.</p>"
|
||||
f"<ul>{match_items}</ul>"
|
||||
"<p>Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.</p>",
|
||||
_("<h1>Mögliche doppelte Veranstaltung</h1><p>Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.</p><ul>")
|
||||
+ match_items
|
||||
+ _("</ul><p>Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.</p>"),
|
||||
status_code=409,
|
||||
)
|
||||
if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime):
|
||||
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>"), status_code=400)
|
||||
try:
|
||||
normalized_flyer_url = normalize_external_url(flyer_url, "den Flyer")
|
||||
except ValueError as error:
|
||||
@@ -3975,13 +4035,13 @@ def edit_concert_page(request: Request, concert_id: int):
|
||||
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
_("<h1>Veranstaltung nicht gefunden</h1>"),
|
||||
status_code=404
|
||||
)
|
||||
|
||||
if not can_edit_concert(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Vergangene Veranstaltungen dürfen nur Admins bearbeiten.</h1>",
|
||||
_("<h1>Vergangene Veranstaltungen dürfen nur Admins bearbeiten.</h1>"),
|
||||
status_code=403
|
||||
)
|
||||
|
||||
@@ -4035,13 +4095,13 @@ async def edit_concert(
|
||||
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
_("<h1>Veranstaltung nicht gefunden</h1>"),
|
||||
status_code=404
|
||||
)
|
||||
|
||||
if not can_edit_concert(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Nicht erlaubt</h1>",
|
||||
_("<h1>Nicht erlaubt</h1>"),
|
||||
status_code=403
|
||||
)
|
||||
|
||||
@@ -4065,21 +4125,21 @@ async def edit_concert(
|
||||
if not can_manage_event_access(user, concert):
|
||||
event_type = concert["event_type"]
|
||||
if event_type not in EVENT_TYPES:
|
||||
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Ungültige Veranstaltungskategorie.</h1>"), status_code=400)
|
||||
if event_type == "festival" and not end_datetime:
|
||||
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>"), status_code=400)
|
||||
if event_type != "festival":
|
||||
end_datetime = ""
|
||||
if can_manage_event_access(user, concert):
|
||||
if visibility not in {"public", "friends", "private"}:
|
||||
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Ungültige Sichtbarkeit.</h1>"), status_code=400)
|
||||
if event_type != "other":
|
||||
visibility = "public"
|
||||
else:
|
||||
visibility = concert["visibility"]
|
||||
effective_start = start_datetime or concert["start_local"]
|
||||
if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(effective_start):
|
||||
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>"), status_code=400)
|
||||
try:
|
||||
next_flyer_url = normalize_external_url(flyer_url, "den Flyer")
|
||||
except ValueError as error:
|
||||
@@ -4202,13 +4262,13 @@ def delete_concert(request: Request, concert_id: int):
|
||||
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
_("<h1>Veranstaltung nicht gefunden</h1>"),
|
||||
status_code=404
|
||||
)
|
||||
|
||||
if not can_delete_concert(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Nicht erlaubt</h1>",
|
||||
_("<h1>Nicht erlaubt</h1>"),
|
||||
status_code=403
|
||||
)
|
||||
|
||||
@@ -4240,10 +4300,10 @@ def set_attendance(
|
||||
if not user:
|
||||
return login_redirect(f"/concerts/{concert_id}")
|
||||
if status not in {"attending", "maybe", "ticket_search", "ticket_offer"}:
|
||||
return HTMLResponse("<h1>Ungültige Auswahl</h1>", status_code=400)
|
||||
return HTMLResponse(_("<h1>Ungültige Auswahl</h1>"), status_code=400)
|
||||
concert = load_concert(concert_id)
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse("<h1>Veranstaltung nicht gefunden</h1>", status_code=404)
|
||||
return HTMLResponse(_("<h1>Veranstaltung nicht gefunden</h1>"), status_code=404)
|
||||
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -4260,8 +4320,16 @@ def set_attendance(
|
||||
)
|
||||
if status == "attending" and concert["is_past"]:
|
||||
cursor.execute(
|
||||
"INSERT INTO concert_diary (user_id, concert_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(user["id"], concert_id),
|
||||
"""
|
||||
INSERT INTO concert_diary (user_id, concert_id)
|
||||
SELECT %s, %s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM concert_diary_exclusions
|
||||
WHERE user_id = %s AND concert_id = %s
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(user["id"], concert_id, user["id"], concert_id),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
@@ -4283,7 +4351,7 @@ def add_comment(
|
||||
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
_("<h1>Veranstaltung nicht gefunden</h1>"),
|
||||
status_code=404
|
||||
)
|
||||
|
||||
@@ -4297,7 +4365,7 @@ def add_comment(
|
||||
|
||||
if len(text) > 2000:
|
||||
return HTMLResponse(
|
||||
"<h1>Kommentar ist zu lang (maximal 2000 Zeichen).</h1>",
|
||||
_("<h1>Kommentar ist zu lang (maximal 2000 Zeichen).</h1>"),
|
||||
status_code=400
|
||||
)
|
||||
|
||||
@@ -4337,7 +4405,7 @@ async def add_photo(
|
||||
|
||||
if not concert or not can_view_event(user, concert):
|
||||
return HTMLResponse(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
_("<h1>Veranstaltung nicht gefunden</h1>"),
|
||||
status_code=404
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Persistent language choice, also available before login. */
|
||||
.language-switch {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
max-width: 1100px;
|
||||
margin: 8px auto 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.language-switch a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #222);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.language-switch a.active { background: #922525; color: #fff; border-color: #c74747; }
|
||||
.language-switch a:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
|
||||
.header-actions .language-switch { margin: 0; padding: 0; }
|
||||
|
||||
.detail-topbar .language-switch { margin: 0 0 0 auto; padding: 0; }
|
||||
@@ -509,6 +509,38 @@ h1, .page-title h1 {
|
||||
.calendar-export { display:flex; justify-content:center; margin-top:12px; }
|
||||
.ticket-button, .concert-action { display:inline-flex; padding:11px 16px; border:1px solid #b91c1c; border-radius:9px; background:#7f1d1d; color:#fff; font-weight:700; }
|
||||
.concert-action-delete { background:#450a0a; cursor:pointer; }
|
||||
@media (min-width: 701px) {
|
||||
.event-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.event-actions .ticket-button-container,
|
||||
.event-actions .calendar-export,
|
||||
.event-actions .detail-actions { margin: 0; }
|
||||
.event-actions .calendar-export:first-child { grid-column: 1 / -1; }
|
||||
.event-actions .detail-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.event-actions .detail-actions > a:only-child { grid-column: 1 / -1; }
|
||||
.event-actions .detail-actions form { margin: 0; width: 100%; max-width: none; }
|
||||
.event-actions .ticket-button,
|
||||
.event-actions .concert-action {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
}
|
||||
@media (max-width:600px) { .detail-topbar { align-items:flex-start; } .concert-detail { padding:16px; } .comment-meta { flex-direction:column; gap:4px; } }
|
||||
@media (max-width:700px) { .advanced-filter-fields { grid-template-columns:1fr; } }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<nav class="admin-nav" aria-label="Adminbereiche">
|
||||
<a href="/admin/users" class="{% if admin_section == 'users' %}active{% endif %}">👥 Benutzer</a>
|
||||
<a href="/admin/venues" class="{% if admin_section == 'venues' %}active{% endif %}">📍 Veranstaltungsorte</a>
|
||||
<a href="/admin/patches" class="{% if admin_section == 'patches' %}active{% endif %}">🧵 Patch-Bilder</a>
|
||||
<a href="/admin/statistics" class="{% if admin_section == 'statistics' %}active{% endif %}">📊 Nutzungsstatistik</a>
|
||||
<nav class="admin-nav" aria-label="{{ _('Adminbereiche') }}">
|
||||
<a href="/admin/users" class="{% if admin_section == 'users' %}active{% endif %}">{{ _('👥 Benutzer') }}</a>
|
||||
<a href="/admin/venues" class="{% if admin_section == 'venues' %}active{% endif %}">{{ _('📍 Veranstaltungsorte') }}</a>
|
||||
<a href="/admin/patches" class="{% if admin_section == 'patches' %}active{% endif %}">{{ _('🧵 Patch-Bilder') }}</a>
|
||||
<a href="/admin/statistics" class="{% if admin_section == 'statistics' %}active{% endif %}">{{ _('📊 Nutzungsstatistik') }}</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<nav class="language-switch" aria-label="{{ _('Sprache') }}">
|
||||
<a href="{{ language_url('de') }}" lang="de" hreflang="de" aria-label="Deutsch"{% if language() == 'de' %} aria-current="true" class="active"{% endif %}>DE</a>
|
||||
<a href="{{ language_url('en') }}" lang="en" hreflang="en" aria-label="English"{% if language() == 'en' %} aria-current="true" class="active"{% endif %}>EN</a>
|
||||
</nav>
|
||||
@@ -1,15 +1,15 @@
|
||||
<details class="user-menu">
|
||||
<summary>☠ {{ user.display_name }}{% if user.notification_count %} <span class="metal-notification" title="{{ user.notification_count }} neue Nachricht(en) oder Anfrage(n)" aria-label="Neue Benachrichtigungen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</summary>
|
||||
<summary>☠ {{ user.display_name }}{% if user.notification_count %} <span class="metal-notification" title="{{ user.notification_count }} {{ _('neue Nachricht(en) oder Anfrage(n)') }}" aria-label="{{ _('Neue Benachrichtigungen') }}">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</summary>
|
||||
<div class="user-menu-panel">
|
||||
<a href="/profile">Mein Profil</a>
|
||||
<a href="/messages">Nachrichten{% if user.notification_count %} <span class="metal-notification" title="Neue Nachrichten oder Anfragen" aria-label="Neue Nachrichten oder Anfragen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</a>
|
||||
<a href="/diary">Konzerttagebuch</a>
|
||||
<a href="/following">Gefolgte Bands & Locations</a>
|
||||
{% if user.is_admin %}<a href="/admin">⚙️ Verwaltung</a>{% endif %}
|
||||
<a href="/datenschutz">Datenschutz</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/profile">{{ _('Mein Profil') }}</a>
|
||||
<a href="/messages">{{ _('Nachrichten') }}{% if user.notification_count %} <span class="metal-notification" title="{{ _('Neue Nachrichten oder Anfragen') }}" aria-label="{{ _('Neue Nachrichten oder Anfragen') }}">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</a>
|
||||
<a href="/diary">{{ _('Konzerttagebuch') }}</a>
|
||||
<a href="/following">{{ _('Gefolgte Bands & Locations') }}</a>
|
||||
{% if user.is_admin %}<a href="/admin">{{ _('⚙️ Verwaltung') }}</a>{% endif %}
|
||||
<a href="/datenschutz">{{ _('Datenschutz') }}</a>
|
||||
<a href="/impressum">{{ _('Impressum') }}</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Abmelden</button>
|
||||
<button type="submit">{{ _('Abmelden') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Account-Link · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head><body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
|
||||
<main><div class="page-title"><h1>Passwort zurücksetzen</h1><p>Reset-Link für @{{ target_username }} · {{ target_email }}</p></div>
|
||||
<section class="admin-section"><p>Diesen persönlichen Link sicher an den User senden. Er ist zwei Stunden gültig und nur einmal verwendbar.</p><label class="invite-link" style="display:block;overflow-wrap:anywhere">{{ account_url }}</label><p><a class="button button-secondary" href="/admin/users">Zurück</a></p></section></main></body></html>
|
||||
<!DOCTYPE html><html lang="{{ language() }}"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ _('Account-Link · MetalCircle') }}</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"><link rel="stylesheet" href="/static/css/language.css">
|
||||
</head><body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main><div class="page-title"><h1>{{ _('Passwort zurücksetzen') }}</h1><p>{{ _('Reset-Link für @') }}{{ target_username }} · {{ target_email }}</p></div>
|
||||
<section class="admin-section"><p>{{ _('Diesen persönlichen Link sicher an den User senden. Er ist zwei Stunden gültig und nur einmal verwendbar.') }}</p><label class="invite-link" style="display:block;overflow-wrap:anywhere">{{ account_url }}</label><p><a class="button button-secondary" href="/admin/users">{{ _('Zurück') }}</a></p></section></main></body></html>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Patch-Bilder · MetalCircle</title>
|
||||
<title>{{ _('Patch-Bilder · MetalCircle') }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
@@ -18,28 +18,30 @@
|
||||
.patch-upload { display: grid; gap: 8px; }
|
||||
.patch-upload-status { min-height: 1.2em; color: var(--muted); font-size: .85rem; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title"><h1>🧵 Patch-Bilder</h1><p>Grafiken für die Patches auf der virtuellen Kutte verwalten.</p></div>
|
||||
<div class="page-title"><h1>{{ _('🧵 Patch-Bilder') }}</h1><p>{{ _('Grafiken für die Patches auf der virtuellen Kutte verwalten.') }}</p></div>
|
||||
{% set admin_section = 'patches' %}{% include '_admin_nav.html' %}
|
||||
<section class="admin-section">
|
||||
<div class="patch-admin-groups">
|
||||
{% for group in patch_groups %}
|
||||
<details class="patch-admin-group">
|
||||
<summary>{{ group.label }} · {{ group.patches|length }}</summary>
|
||||
<summary>{{ group.label | t }} · {{ group.patches|length }}</summary>
|
||||
<div class="patch-admin-grid">
|
||||
{% for patch in group.patches %}
|
||||
<article class="admin-row patch-admin-card">
|
||||
{% if patch.image_path %}<img class="patch-preview" src="{{ patch.image_path }}" alt="{{ patch.name }}">{% else %}<div class="patch-preview patch-placeholder">{{ patch.icon }}</div>{% endif %}
|
||||
<div><strong>{{ patch.name }}</strong><div class="admin-meta">{{ patch.description }} · {{ patch.code }}</div></div>
|
||||
{% if patch.image_path %}<img class="patch-preview" src="{{ patch.image_path }}" alt="{{ patch.name | t }}">{% else %}<div class="patch-preview patch-placeholder">{{ patch.icon }}</div>{% endif %}
|
||||
<div><strong>{{ patch.name | t }}</strong><div class="admin-meta">{{ patch.description | t }} · {{ patch.code }}</div></div>
|
||||
<form class="patch-upload" method="post" action="/admin/patches/{{ patch.code }}" enctype="multipart/form-data">
|
||||
<input type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
|
||||
<span class="patch-upload-status">Das Bild wird vor dem Upload automatisch optimiert.</span>
|
||||
<button class="button" type="submit">{% if patch.image_path %}Bild ersetzen{% else %}Bild hochladen{% endif %}</button>
|
||||
<span class="patch-upload-status">{{ _('Das Bild wird vor dem Upload automatisch optimiert.') }}</span>
|
||||
<button class="button" type="submit">{% if patch.image_path %}{{ _('Bild ersetzen') }}{% else %}{{ _('Bild hochladen') }}{% endif %}</button>
|
||||
</form>
|
||||
{% if patch.image_path %}<form method="post" action="/admin/patches/{{ patch.code }}/delete" onsubmit="return confirm('Patch-Bild entfernen und wieder das Symbol verwenden?');"><button class="admin-danger" type="submit">Bild entfernen</button></form>{% endif %}
|
||||
{% if patch.image_path %}<form method="post" action="/admin/patches/{{ patch.code }}/delete" onsubmit="return confirm({{ _('Patch-Bild entfernen und wieder das Symbol verwenden?') | tojson | forceescape }});"><button class="admin-danger" type="submit">{{ _('Bild entfernen') }}</button></form>{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -99,7 +101,7 @@ patchForms.forEach(form => {
|
||||
form.requestSubmit();
|
||||
} catch (error) {
|
||||
console.error("Patch image optimization failed:", error);
|
||||
status.textContent = "Das Bild konnte nicht optimiert werden. Bitte ein kleineres JPG, PNG oder WebP wählen.";
|
||||
status.textContent = {{ _('Das Bild konnte nicht optimiert werden. Bitte ein kleineres JPG, PNG oder WebP wählen.') | tojson }};
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nutzungsstatistik · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head><body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
|
||||
<main><div class="page-title"><h1>📊 Nutzungsstatistik</h1><p>Aktuelle Gesamtzahlen der Plattform.</p></div>{% set admin_section = 'statistics' %}{% include '_admin_nav.html' %}
|
||||
<!DOCTYPE html><html lang="{{ language() }}"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{{ _('Nutzungsstatistik · MetalCircle') }}</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"><link rel="stylesheet" href="/static/css/language.css">
|
||||
</head><body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main><div class="page-title"><h1>{{ _('📊 Nutzungsstatistik') }}</h1><p>{{ _('Aktuelle Gesamtzahlen der Plattform.') }}</p></div>{% set admin_section = 'statistics' %}{% include '_admin_nav.html' %}
|
||||
<section class="stats-grid">
|
||||
{% for key, label in [('users','Benutzer'),('events','Veranstaltungen'),('upcoming','Bevorstehende Veranstaltungen'),('comments','Kommentare'),('attendance','Teilnahmen'),('friendships','Freundschaften'),('messages','Nachrichten')] %}<div class="stat-card"><strong>{{ stats[key] }}</strong><span>{{ label }}</span></div>{% endfor %}
|
||||
{% for key, label in [('users',_('Benutzer')),('events',_('Veranstaltungen')),('upcoming',_('Bevorstehende Veranstaltungen')),('comments',_('Kommentare')),('attendance',_('Teilnahmen')),('friendships',_('Freundschaften')),('messages',_('Nachrichten'))] %}<div class="stat-card"><strong>{{ stats[key] }}</strong><span>{{ label | t }}</span></div>{% endfor %}
|
||||
</section></main></body></html>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Benutzerverwaltung · MetalCircle</title>
|
||||
<title>{{ _('Benutzerverwaltung · MetalCircle') }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
@@ -16,36 +16,38 @@
|
||||
.founder-lock { color: #fbbf24; font-weight: 700; white-space: nowrap; }
|
||||
@media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title"><h1>⚙️ Verwaltung</h1><p>Benutzerkonten und Einladungen verwalten.</p></div>
|
||||
<div class="page-title"><h1>{{ _('⚙️ Verwaltung') }}</h1><p>{{ _('Benutzerkonten und Einladungen verwalten.') }}</p></div>
|
||||
{% set admin_section = 'users' %}{% include '_admin_nav.html' %}
|
||||
<section class="admin-section">
|
||||
<h2>Einladung erstellen</h2>
|
||||
<p>Der Link kann einmalig verwendet werden.</p>
|
||||
<form method="post" action="/admin/invites"><button class="button" type="submit">+ Einladungslink erzeugen</button></form>
|
||||
<h2>{{ _('Einladung erstellen') }}</h2>
|
||||
<p>{{ _('Der Link kann einmalig verwendet werden.') }}</p>
|
||||
<form method="post" action="/admin/invites"><button class="button" type="submit">{{ _('+ Einladungslink erzeugen') }}</button></form>
|
||||
{% if invite_url %}<label class="invite-link">{{ invite_url }}</label>{% endif %}
|
||||
</section>
|
||||
<section class="admin-section">
|
||||
<h2>Benutzer</h2>
|
||||
<h2>{{ _('Benutzer') }}</h2>
|
||||
<div class="admin-list">
|
||||
{% for managed_user in users %}
|
||||
<div class="admin-row user-row">
|
||||
<div><strong>{% if managed_user.username|lower == 'kai' %}🏴☠️ {% endif %}{{ managed_user.display_name }}</strong><div class="admin-meta">@{{ managed_user.username }} · {{ managed_user.email }} · seit {{ managed_user.created_at }}</div></div>
|
||||
<div><strong>{% if managed_user.username|lower == 'kai' %}🏴☠️ {% endif %}{{ managed_user.display_name }}</strong><div class="admin-meta">@{{ managed_user.username }} · {{ managed_user.email }} {{ _('· seit') }} {{ managed_user.created_at }}</div></div>
|
||||
{% if managed_user.username|lower == 'kai' %}
|
||||
<span class="founder-lock">🏴☠️ Gründer · Admin (fest)</span>
|
||||
<span class="founder-lock">{{ _('🏴\u200d☠️ Gründer · Admin (fest)') }}</span>
|
||||
{% else %}
|
||||
<form class="role-form" method="post" action="/admin/users/{{ managed_user.id }}">
|
||||
<select name="role" aria-label="Rolle für {{ managed_user.username }}"><option value="user" {% if not managed_user.is_admin %}selected{% endif %}>Benutzer</option><option value="admin" {% if managed_user.is_admin %}selected{% endif %}>🏴☠️ Admin</option></select>
|
||||
<button class="button" type="submit">Speichern</button>
|
||||
<select name="role" aria-label="{{ _('Rolle für') }} {{ managed_user.username }}"><option value="user" {% if not managed_user.is_admin %}selected{% endif %}>{{ _('Benutzer') }}</option><option value="admin" {% if managed_user.is_admin %}selected{% endif %}>🏴☠️ Admin</option></select>
|
||||
<button class="button" type="submit">{{ _('Speichern') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="role-form">
|
||||
<form method="post" action="/admin/users/{{ managed_user.id }}/account-link"><input type="hidden" name="purpose" value="password_reset"><button class="button button-secondary" type="submit">Passwort-Reset</button></form>
|
||||
<form method="post" action="/admin/users/{{ managed_user.id }}/account-link"><input type="hidden" name="purpose" value="password_reset"><button class="button button-secondary" type="submit">{{ _('Passwort-Reset') }}</button></form>
|
||||
</div>
|
||||
{% if managed_user.id != user.id and managed_user.username|lower != 'kai' %}<form method="post" action="/admin/users/{{ managed_user.id }}/delete" onsubmit="return confirm('Diesen Benutzer wirklich löschen?');"><button class="admin-danger" type="submit">Löschen</button></form>{% endif %}
|
||||
{% if managed_user.id != user.id and managed_user.username|lower != 'kai' %}<form method="post" action="/admin/users/{{ managed_user.id }}/delete" onsubmit="return confirm({{ _('Diesen Benutzer wirklich löschen?') | tojson | forceescape }});"><button class="admin-danger" type="submit">{{ _('Löschen') }}</button></form>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Veranstaltungsorte · MetalCircle</title>
|
||||
<title>{{ _('Veranstaltungsorte · MetalCircle') }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
@@ -28,11 +28,13 @@
|
||||
@media (max-width: 900px) { .venue-fields { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 560px) { .venue-fields, .merge-form { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title"><h1>📍 Veranstaltungsorte</h1><p>Orte korrigieren, bestätigen, zusammenführen oder entfernen.</p></div>
|
||||
<div class="page-title"><h1>{{ _('📍 Veranstaltungsorte') }}</h1><p>{{ _('Orte korrigieren, bestätigen, zusammenführen oder entfernen.') }}</p></div>
|
||||
{% set admin_section = 'venues' %}{% include '_admin_nav.html' %}
|
||||
<section class="admin-section">
|
||||
<div class="admin-list">
|
||||
@@ -41,37 +43,37 @@
|
||||
<summary class="venue-summary">
|
||||
<span>
|
||||
<strong class="venue-summary-title">{{ venue.name }}{% if venue.city %} · {{ venue.city }}{% endif %}{% if venue.country %} · {{ venue.country }}{% endif %}</strong>
|
||||
<span class="venue-summary-meta"><span class="{% if venue.is_verified %}venue-verified{% else %}venue-unverified{% endif %}">{% if venue.is_verified %}✓ bestätigt{% else %}● unbestätigt{% endif %}</span> · {{ venue.concert_count }} Veranstaltung(en) · ID {{ venue.id }}</span>
|
||||
<span class="venue-summary-meta"><span class="{% if venue.is_verified %}venue-verified{% else %}venue-unverified{% endif %}">{% if venue.is_verified %}{{ _('✓ bestätigt') }}{% else %}{{ _('● unbestätigt') }}{% endif %}</span> · {{ venue.concert_count }} {{ _('Veranstaltung(en) · ID') }} {{ venue.id }}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="venue-editor">
|
||||
<form method="post" action="/admin/venues/{{ venue.id }}">
|
||||
<div class="venue-fields">
|
||||
<input name="name" value="{{ venue.name }}" aria-label="Name" required>
|
||||
<input name="street" value="{{ venue.street }}" placeholder="Straße" aria-label="Straße">
|
||||
<input name="postal_code" value="{{ venue.postal_code }}" placeholder="PLZ" aria-label="PLZ">
|
||||
<input name="city" value="{{ venue.city }}" placeholder="Ort" aria-label="Ort">
|
||||
<input name="country" value="{{ venue.country }}" placeholder="Land" aria-label="Land">
|
||||
<input name="street" value="{{ venue.street }}" placeholder="{{ _('Straße') }}" aria-label="{{ _('Straße') }}">
|
||||
<input name="postal_code" value="{{ venue.postal_code }}" placeholder="{{ _('PLZ') }}" aria-label="{{ _('PLZ') }}">
|
||||
<input name="city" value="{{ venue.city }}" placeholder="{{ _('Ort') }}" aria-label="{{ _('Ort') }}">
|
||||
<input name="country" value="{{ venue.country }}" placeholder="{{ _('Land') }}" aria-label="{{ _('Land') }}">
|
||||
</div>
|
||||
<input class="venue-aliases" name="aliases" value="{{ venue.aliases }}" placeholder="Aliase, durch Komma getrennt" aria-label="Aliase">
|
||||
<input class="venue-aliases" name="aliases" value="{{ venue.aliases }}" placeholder="{{ _('Aliase, durch Komma getrennt') }}" aria-label="{{ _('Aliase') }}">
|
||||
<div class="admin-actions venue-actions">
|
||||
<label><input type="checkbox" name="is_verified" {% if venue.is_verified %}checked{% endif %}> bestätigt</label>
|
||||
<span class="admin-meta">Quelle: {{ venue.source }} · {{ venue.concert_count }} Konzert(e) · ID {{ venue.id }}</span>
|
||||
<button class="button" type="submit">Speichern</button>
|
||||
<label><input type="checkbox" name="is_verified" {% if venue.is_verified %}checked{% endif %}> {{ _('bestätigt') }}</label>
|
||||
<span class="admin-meta">{{ _('Quelle:') }} {{ venue.source }} · {{ venue.concert_count }} {{ _('Konzert(e) · ID') }} {{ venue.id }}</span>
|
||||
<button class="button" type="submit">{{ _('Speichern') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if venues|length > 1 %}
|
||||
<form class="merge-form" method="post" action="/admin/venues/{{ venue.id }}/merge" onsubmit="return confirm('Alle Konzerte werden dem Zielort zugeordnet und dieser Eintrag wird gelöscht. Fortfahren?');">
|
||||
<select name="target_venue_id" required><option value="">Mit anderem Ort zusammenführen …</option>{% for target in venues %}{% if target.id != venue.id %}<option value="{{ target.id }}">{{ target.name }}{% if target.city %}, {{ target.city }}{% endif %}</option>{% endif %}{% endfor %}</select>
|
||||
<button class="admin-danger" type="submit">Zusammenführen</button>
|
||||
<form class="merge-form" method="post" action="/admin/venues/{{ venue.id }}/merge" onsubmit="return confirm({{ _('Alle Konzerte werden dem Zielort zugeordnet und dieser Eintrag wird gelöscht. Fortfahren?') | tojson | forceescape }});">
|
||||
<select name="target_venue_id" required><option value="">{{ _('Mit anderem Ort zusammenführen …') }}</option>{% for target in venues %}{% if target.id != venue.id %}<option value="{{ target.id }}">{{ target.name }}{% if target.city %}, {{ target.city }}{% endif %}</option>{% endif %}{% endfor %}</select>
|
||||
<button class="admin-danger" type="submit">{{ _('Zusammenführen') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form class="delete-form" method="post" action="/admin/venues/{{ venue.id }}/delete" onsubmit="return confirm('{% if venue.concert_count %}Der Ort ist mit {{ venue.concert_count }} Konzert(en) verknüpft. Diese Konzerte haben danach keinen Veranstaltungsort mehr. {% endif %}Ort endgültig löschen?');">
|
||||
<button class="admin-danger" type="submit">Ort löschen{% if venue.concert_count %} ({{ venue.concert_count }} Verknüpfung(en)){% endif %}</button>
|
||||
<form class="delete-form" method="post" action="/admin/venues/{{ venue.id }}/delete" onsubmit="return confirm({{ (_('Der Ort ist mit {count} Konzert(en) verknüpft. Diese Konzerte haben danach keinen Veranstaltungsort mehr. Ort endgültig löschen?').format(count=venue.concert_count) if venue.concert_count else _('Ort endgültig löschen?')) | tojson | forceescape }});">
|
||||
<button class="admin-danger" type="submit">{{ _('Ort löschen') }}{% if venue.concert_count %} ({{ venue.concert_count }} {{ _('Verknüpfung(en))') }}{% endif %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% else %}<p>Noch keine Veranstaltungsorte vorhanden.</p>{% endfor %}
|
||||
{% else %}<p>{{ _('Noch keine Veranstaltungsorte vorhanden.') }}</p>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
|
||||
<head>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
.diary-form textarea { min-height:130px; resize:vertical; }
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -42,7 +43,8 @@
|
||||
<div class="container">
|
||||
|
||||
<div class="detail-topbar">
|
||||
<a href="/" class="back-link">← Zurück zum Kalender</a>
|
||||
<a href="/" class="back-link">{{ _('← Zurück zum Kalender') }}</a>
|
||||
{% include '_language_switch.html' %}
|
||||
{% include '_user_menu.html' %}
|
||||
</div>
|
||||
|
||||
@@ -61,7 +63,7 @@
|
||||
>
|
||||
|
||||
{% if concert.flyer_url %}
|
||||
<p class="flyer-source"><a href="{{ concert.flyer_url }}" target="_blank" rel="noopener noreferrer nofollow external">Quelle</a><span class="flyer-disclaimer">Extern eingebundener Flyer. Rechte liegen beim jeweiligen Veranstalter bzw. Rechteinhaber. Hinweise zur Entfernung bitte an die Administration.</span></p>
|
||||
<p class="flyer-source"><a href="{{ concert.flyer_url }}" target="_blank" rel="noopener noreferrer nofollow external">{{ _('Quelle') }}</a><span class="flyer-disclaimer">{{ _('Extern eingebundener Flyer. Rechte liegen beim jeweiligen Veranstalter bzw. Rechteinhaber. Hinweise zur Entfernung bitte an die Administration.') }}</span></p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -71,8 +73,8 @@
|
||||
|
||||
<div class="concert-content">
|
||||
|
||||
<span class="event-type-badge">{{ concert.event_type_label }}</span>
|
||||
{% if concert.visibility == 'friends' %}<span class="event-type-badge">🔒 Nur Freunde</span>{% elif concert.visibility == 'private' %}<span class="event-type-badge">🔐 Privat / eingeladen</span>{% endif %}
|
||||
<span class="event-type-badge">{{ concert.event_type_label | t }}</span>
|
||||
{% if concert.visibility == 'friends' %}<span class="event-type-badge">{{ _('🔒 Nur Freunde') }}</span>{% elif concert.visibility == 'private' %}<span class="event-type-badge">{{ _('🔐 Privat / eingeladen') }}</span>{% endif %}
|
||||
|
||||
<h1>
|
||||
{{ concert.artist }}
|
||||
@@ -82,13 +84,13 @@
|
||||
{% for band in concert_bands %}<form method="post" action="/concerts/{{ concert.id }}/follow-band">
|
||||
<input type="hidden" name="band_key" value="{{ band.key }}">
|
||||
<input type="hidden" name="action" value="{% if band.is_following %}unfollow{% else %}follow{% endif %}">
|
||||
<button class="follow-button {% if band.is_following %}is-following{% endif %}" type="submit">{% if band.is_following %}★ {{ band.name }} gefolgt{% else %}☆ {{ band.name }} folgen{% endif %}</button>
|
||||
<button class="follow-button {% if band.is_following %}is-following{% endif %}" type="submit">{% if band.is_following %}★ {{ band.name }} {{ _('gefolgt') }}{% else %}☆ {{ band.name }} {{ _('folgen') }}{% endif %}</button>
|
||||
</form>{% endfor %}
|
||||
{% if concert.venue.id %}<form method="post" action="/concerts/{{ concert.id }}/follow-venue">
|
||||
<input type="hidden" name="action" value="{% if follows_venue %}unfollow{% else %}follow{% endif %}">
|
||||
<button class="follow-button {% if follows_venue %}is-following{% endif %}" type="submit">{% if follows_venue %}★ Location gefolgt{% else %}☆ Location folgen{% endif %}</button>
|
||||
<button class="follow-button {% if follows_venue %}is-following{% endif %}" type="submit">{% if follows_venue %}{{ _('★ Location gefolgt') }}{% else %}{{ _('☆ Location folgen') }}{% endif %}</button>
|
||||
</form>{% endif %}
|
||||
<a class="follow-button" href="/following">Meine gefolgten Inhalte</a>
|
||||
<a class="follow-button" href="/following">{{ _('Meine gefolgten Inhalte') }}</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -108,11 +110,11 @@
|
||||
|
||||
<br>
|
||||
|
||||
{{ concert.time }} Uhr
|
||||
{{ concert.time }} {{ _('Uhr') }}
|
||||
|
||||
{% if concert.event_type == 'festival' and concert.end_date %}
|
||||
<br>
|
||||
bis {{ concert.end_date }}{% if concert.end_time %}, {{ concert.end_time }} Uhr{% endif %}
|
||||
{{ _('bis') }} {{ concert.end_date }}{% if concert.end_time %}, {{ concert.end_time }} {{ _('Uhr') }}{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -191,9 +193,9 @@
|
||||
|
||||
{% if concert.parent_event %}
|
||||
<p>
|
||||
Gehört zu:
|
||||
{{ _('Gehört zu:') }}
|
||||
<a class="parent-event-link" href="/concerts/{{ concert.parent_event.id }}">
|
||||
{{ concert.parent_event.event_type_label }} · {{ concert.parent_event.title }}
|
||||
{{ concert.parent_event.event_type_label | t }} · {{ concert.parent_event.title }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -204,7 +206,7 @@
|
||||
<section class="description">
|
||||
|
||||
<h2>
|
||||
Über die Veranstaltung
|
||||
{{ _('Über die Veranstaltung') }}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
@@ -216,6 +218,7 @@
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="event-actions">
|
||||
{% if concert.ticket_url %}
|
||||
|
||||
<div class="ticket-button-container">
|
||||
@@ -233,13 +236,13 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div class="calendar-export"><a href="/concerts/{{ concert.id }}.ics" class="concert-action">📅 In Kalender speichern (.ics)</a></div>
|
||||
<div class="calendar-export"><a href="/concerts/{{ concert.id }}.ics" class="concert-action">{{ _('📅 In Kalender speichern (.ics)') }}</a></div>
|
||||
|
||||
{% if can_edit %}
|
||||
|
||||
<div class="detail-actions" aria-label="Veranstaltung verwalten">
|
||||
<div class="detail-actions" aria-label="{{ _('Veranstaltung verwalten') }}">
|
||||
<a href="/concerts/{{ concert.id }}/edit" class="concert-action concert-action-edit">
|
||||
✏️ Veranstaltung bearbeiten
|
||||
{{ _('✏️ Veranstaltung bearbeiten') }}
|
||||
</a>
|
||||
|
||||
{% if user.is_admin and can_delete %}
|
||||
@@ -247,10 +250,10 @@
|
||||
<form
|
||||
method="post"
|
||||
action="/concerts/{{ concert.id }}/delete"
|
||||
onsubmit="return confirm('Diese Veranstaltung wirklich löschen?');"
|
||||
onsubmit="return confirm({{ _('Diese Veranstaltung wirklich löschen?') | tojson | forceescape }});"
|
||||
>
|
||||
<button type="submit" class="concert-action concert-action-delete">
|
||||
🗑️ Veranstaltung löschen
|
||||
{{ _('🗑️ Veranstaltung löschen') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -260,15 +263,17 @@
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% if not concert.is_past %}
|
||||
|
||||
<section class="attendance-section" id="attendance">
|
||||
|
||||
<h2>
|
||||
🐧 Wer ist dabei?
|
||||
{{ _('🐧 Wer ist dabei?') }}
|
||||
</h2>
|
||||
|
||||
<p>Wähle, wie es bei dir aussieht. Deine Auswahl kann jederzeit geändert werden.</p>
|
||||
<p>{{ _('Wähle, wie es bei dir aussieht. Deine Auswahl kann jederzeit geändert werden.') }}</p>
|
||||
|
||||
<div class="attendance-actions">
|
||||
|
||||
@@ -278,7 +283,7 @@
|
||||
type="submit"
|
||||
class="attendance-option {% if current_attendance == 'attending' %}is-selected{% endif %}"
|
||||
>
|
||||
✓ Zugesagt
|
||||
{{ _('✓ Zugesagt') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -288,7 +293,7 @@
|
||||
type="submit"
|
||||
class="attendance-option {% if current_attendance == 'maybe' %}is-selected{% endif %}"
|
||||
>
|
||||
? Vielleicht
|
||||
{{ _('? Vielleicht') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -298,7 +303,7 @@
|
||||
type="submit"
|
||||
class="attendance-option {% if current_attendance == 'ticket_search' %}is-selected{% endif %}"
|
||||
>
|
||||
🔎 Suche Ticket
|
||||
{{ _('🔎 Suche Ticket') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -308,7 +313,7 @@
|
||||
type="submit"
|
||||
class="attendance-option {% if current_attendance == 'ticket_offer' %}is-selected{% endif %}"
|
||||
>
|
||||
🎟️ Biete Ticket
|
||||
{{ _('🎟️ Biete Ticket') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -316,16 +321,14 @@
|
||||
|
||||
<details class="attendance-list">
|
||||
<summary>
|
||||
{{ attending_count }} Zusage{% if attending_count != 1 %}n{% endif %}
|
||||
· {{ maybe_count }} Vielleicht
|
||||
· {{ ticket_seeker_count }} sucht Ticket
|
||||
· {{ ticket_offer_count }} bietet Ticket
|
||||
{{ attending_count }} {{ _('Zusage') if attending_count == 1 else _('Zusagen') }}
|
||||
· {{ maybe_count }} {{ _('Vielleicht') }} · {{ ticket_seeker_count }} {{ _('sucht Ticket') }} · {{ ticket_offer_count }} {{ _('bietet Ticket') }}
|
||||
</summary>
|
||||
|
||||
<div class="attendance-groups">
|
||||
|
||||
<div>
|
||||
<h3>✓ Zugesagt</h3>
|
||||
<h3>{{ _('✓ Zugesagt') }}</h3>
|
||||
|
||||
{% if attending_users %}
|
||||
<ul>
|
||||
@@ -334,12 +337,12 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>Noch niemand hat zugesagt.</p>
|
||||
<p>{{ _('Noch niemand hat zugesagt.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>? Vielleicht</h3>
|
||||
<h3>{{ _('? Vielleicht') }}</h3>
|
||||
|
||||
{% if maybe_users %}
|
||||
<ul>
|
||||
@@ -348,12 +351,12 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>Noch keine Vielleicht-Angaben.</p>
|
||||
<p>{{ _('Noch keine Vielleicht-Angaben.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>🔎 Sucht ein Ticket</h3>
|
||||
<h3>{{ _('🔎 Sucht ein Ticket') }}</h3>
|
||||
|
||||
{% if ticket_seekers %}
|
||||
<ul>
|
||||
@@ -362,12 +365,12 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>Aktuell sucht niemand ein Ticket.</p>
|
||||
<p>{{ _('Aktuell sucht niemand ein Ticket.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>🎟️ Bietet ein Ticket</h3>
|
||||
<h3>{{ _('🎟️ Bietet ein Ticket') }}</h3>
|
||||
|
||||
{% if ticket_offers %}
|
||||
<ul>
|
||||
@@ -376,7 +379,7 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>Aktuell bietet niemand ein Ticket an.</p>
|
||||
<p>{{ _('Aktuell bietet niemand ein Ticket an.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -389,31 +392,31 @@
|
||||
|
||||
{% if concert.is_past %}
|
||||
<section class="diary-section" id="diary">
|
||||
<h2>📓 Mein Konzerttagebuch</h2>
|
||||
<h2>{{ _('📓 Mein Konzerttagebuch') }}</h2>
|
||||
{% if can_write_diary %}
|
||||
<p>Der private Eintrag wurde automatisch angelegt. <a class="button" href="/diary#concert-{{ concert.id }}">Im Konzerttagebuch bearbeiten</a></p>
|
||||
<p>{{ _('Der private Eintrag wurde automatisch angelegt.') }} <a class="button" href="/diary#concert-{{ concert.id }}">{{ _('Im Konzerttagebuch bearbeiten') }}</a></p>
|
||||
{% else %}
|
||||
<p>Ein Tagebucheintrag wird automatisch angelegt, sobald du das vergangene Konzert als „Zugesagt“ markierst.</p>
|
||||
<p>{{ _('Ein Tagebucheintrag wird automatisch angelegt, sobald du das vergangene Konzert als „Zugesagt“ markierst.') }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="comments-section" id="comments">
|
||||
|
||||
<h2>💬 Kommentare</h2>
|
||||
<h2>{{ _('💬 Kommentare') }}</h2>
|
||||
|
||||
<form method="post" action="/concerts/{{ concert.id }}/comments" class="comment-form">
|
||||
<label for="comment-body">Mit anderen Konzertbesuchern austauschen</label>
|
||||
<label for="comment-body">{{ _('Mit anderen Konzertbesuchern austauschen') }}</label>
|
||||
<textarea
|
||||
id="comment-body"
|
||||
name="body"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
required
|
||||
placeholder="Schreib einen Kommentar …"
|
||||
placeholder="{{ _('Schreib einen Kommentar …') }}"
|
||||
></textarea>
|
||||
<button type="submit" class="attendance-option comment-submit">
|
||||
Kommentar senden
|
||||
{{ _('Kommentar senden') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -433,7 +436,7 @@
|
||||
|
||||
{% else %}
|
||||
|
||||
<p class="comments-empty">Noch keine Kommentare. Mach den Anfang!</p>
|
||||
<p class="comments-empty">{{ _('Noch keine Kommentare. Mach den Anfang!') }}</p>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Datenschutz · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head>
|
||||
<html lang="{{ language() }}">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{{ _('Datenschutz · MetalCircle') }}</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"><link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div></header>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title"><h1>Datenschutz</h1><p>Hinweise zur Verarbeitung deiner Daten</p></div>
|
||||
<div class="page-title"><h1>{{ _('Datenschutz') }}</h1><p>{{ _('Hinweise zur Verarbeitung deiner Daten') }}</p></div>
|
||||
<section class="admin-section legal-copy">
|
||||
<h2>Verantwortlicher</h2>
|
||||
<h2>{{ _('Verantwortlicher') }}</h2>
|
||||
<p>Kai Piekny, Fleyerstr. 33, 58097 Hagen<br><a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a></p>
|
||||
<h2>Welche Daten werden gespeichert?</h2>
|
||||
<p>Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands und Locations, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.</p>
|
||||
<h2>Wofür werden sie verwendet?</h2>
|
||||
<p>Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.</p>
|
||||
<h2>Rechtsgrundlage und Speicherdauer</h2>
|
||||
<p>Die Verarbeitung erfolgt im geschlossenen Projektbetrieb zur Bereitstellung der gewünschten Funktionen und – soweit erforderlich – auf Grundlage deiner Einwilligung. Daten bleiben gespeichert, solange dein Konto besteht oder gesetzliche Aufbewahrungspflichten gelten. Nicht mehr benötigte Daten werden gelöscht.</p>
|
||||
<h2>Weitergabe und externe Dienste</h2>
|
||||
<p>Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.</p>
|
||||
<h2>Deine Rechte</h2>
|
||||
<p>Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und – soweit anwendbar – Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a>.</p>
|
||||
<h2>Cookies und Sicherheit</h2>
|
||||
<p>Für die Anmeldung wird ausschließlich ein technisch notwendiges, HttpOnly-Sitzungscookie verwendet. Im späteren HTTPS-Betrieb wird es zusätzlich mit dem Secure-Flag gesetzt. Passwörter werden nicht im Klartext gespeichert.</p>
|
||||
<p class="legal-note">Diese Information ist eine technische Projektgrundlage und ersetzt keine individuelle rechtliche Prüfung. Vor einer öffentlichen Veröffentlichung sollten Hostinganbieter, Auftragsverarbeiter, Löschfristen und der externe Geocoding-Dienst konkret ergänzt und geprüft werden.</p>
|
||||
<h2>{{ _('Welche Daten werden gespeichert?') }}</h2>
|
||||
<p>{{ _('Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands und Locations, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.') }}</p>
|
||||
<h2>{{ _('Wofür werden sie verwendet?') }}</h2>
|
||||
<p>{{ _('Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.') }}</p>
|
||||
<h2>{{ _('Rechtsgrundlage und Speicherdauer') }}</h2>
|
||||
<p>{{ _('Die Verarbeitung erfolgt im geschlossenen Projektbetrieb zur Bereitstellung der gewünschten Funktionen und – soweit erforderlich – auf Grundlage deiner Einwilligung. Daten bleiben gespeichert, solange dein Konto besteht oder gesetzliche Aufbewahrungspflichten gelten. Nicht mehr benötigte Daten werden gelöscht.') }}</p>
|
||||
<h2>{{ _('Weitergabe und externe Dienste') }}</h2>
|
||||
<p>{{ _('Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.') }}</p>
|
||||
<h2>{{ _('Deine Rechte') }}</h2>
|
||||
<p>{{ _('Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und – soweit anwendbar – Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an') }} <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a>.</p>
|
||||
<h2>{{ _('Cookies und Sicherheit') }}</h2>
|
||||
<p>{{ _('Für die Anmeldung wird ausschließlich ein technisch notwendiges, HttpOnly-Sitzungscookie verwendet. Im späteren HTTPS-Betrieb wird es zusätzlich mit dem Secure-Flag gesetzt. Passwörter werden nicht im Klartext gespeichert.') }}</p>
|
||||
<p>{{ _('Ein technisch notwendiges Cookie speichert außerdem deine gewählte Sprache (Deutsch oder Englisch) für ein Jahr.') }}</p>
|
||||
<p class="legal-note">{{ _('Diese Information ist eine technische Projektgrundlage und ersetzt keine individuelle rechtliche Prüfung. Vor einer öffentlichen Veröffentlichung sollten Hostinganbieter, Auftragsverarbeiter, Löschfristen und der externe Geocoding-Dienst konkret ergänzt und geprüft werden.') }}</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
+35
-23
@@ -1,50 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Konzerttagebuch · MetalCircle</title>
|
||||
<title>{{ _('Konzerttagebuch · MetalCircle') }}</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
.diary-list{display:grid;gap:14px}.diary-entry{padding:18px;background:linear-gradient(145deg,#101010,#211111);border:1px solid var(--border);border-left:3px solid #b91c1c;border-radius:12px}.diary-entry h2{margin:0 0 5px}.diary-meta{color:var(--muted)}.diary-rating{color:#fbbf24;font-size:1.2rem}.diary-notes{white-space:pre-wrap}.diary-song{color:#fca5a5}.diary-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:14px 0}.diary-gallery-item{position:relative;aspect-ratio:4/3;overflow:hidden;border-radius:9px;background:#050505}.diary-gallery-item img{width:100%;height:100%;object-fit:cover}.diary-gallery-delete{position:absolute;right:5px;top:5px;margin:0}.diary-gallery-delete button{width:30px;height:30px;padding:0;border-radius:50%;background:rgba(0,0,0,.82);color:#fff;border:1px solid #ef4444}.diary-patches{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.diary-patch{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#080808;border:1px solid #7f1d1d;border-radius:9px}.diary-patch img{width:48px;height:48px;object-fit:contain}.diary-patch-icon{font-size:1.7rem}.diary-edit{margin-top:16px;padding-top:12px;border-top:1px solid var(--border)}.diary-edit summary{color:#fca5a5;cursor:pointer;font-weight:700}.diary-form{display:grid;gap:8px;margin-top:14px;max-width:none}.diary-form select,.diary-form input,.diary-form textarea{width:100%}.diary-form textarea{min-height:130px;resize:vertical}.diary-empty-note{color:var(--muted)}
|
||||
.diary-list{display:grid;gap:14px}.diary-entry{background:linear-gradient(145deg,#101010,#211111);border:1px solid var(--border);border-left:3px solid #b91c1c;border-radius:12px}.diary-summary{position:relative;padding:18px 48px 18px 18px;cursor:pointer;list-style:none}.diary-summary::-webkit-details-marker{display:none}.diary-summary::after{content:"⌄";position:absolute;right:20px;top:50%;color:#fca5a5;font-size:1.5rem;transform:translateY(-50%);transition:transform .2s}.diary-entry[open]>.diary-summary::after{transform:translateY(-50%) rotate(180deg)}.diary-entry[open]>.diary-summary{border-bottom:1px solid var(--border)}.diary-entry h2{margin:0 0 5px}.diary-meta{color:var(--muted)}.diary-summary .diary-meta,.diary-summary .diary-empty-note{margin:5px 0}.diary-content{padding:4px 18px 18px}.diary-rating{color:#fbbf24;font-size:1.2rem}.diary-notes{white-space:pre-wrap}.diary-song{color:#fca5a5}.diary-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:14px 0}.diary-gallery-item{position:relative;aspect-ratio:4/3;overflow:hidden;border-radius:9px;background:#050505}.diary-gallery-item img{width:100%;height:100%;object-fit:cover}.diary-gallery-delete{position:absolute;right:5px;top:5px;margin:0}.diary-gallery-delete button{width:30px;height:30px;padding:0;border-radius:50%;background:rgba(0,0,0,.82);color:#fff;border:1px solid #ef4444}.diary-patches{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.diary-patch{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#080808;border:1px solid #7f1d1d;border-radius:9px}.diary-patch img{width:48px;height:48px;object-fit:contain}.diary-patch-icon{font-size:1.7rem}.diary-edit{margin-top:16px;padding-top:12px;border-top:1px solid var(--border)}.diary-edit summary{color:#fca5a5;cursor:pointer;font-weight:700}.diary-form{display:grid;gap:8px;margin-top:14px;max-width:none}.diary-form select,.diary-form input,.diary-form textarea{width:100%}.diary-form textarea{min-height:130px;resize:vertical}.diary-delete-form{display:flex;justify-content:flex-end;width:100%;max-width:none;margin:18px 0 0}.diary-delete-link{padding:0;border:0;background:none;color:var(--muted);font-size:.78rem;text-decoration:underline;cursor:pointer}.diary-delete-link:hover{color:#fca5a5}.diary-empty-note{color:var(--muted)}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title"><h1>📓 Konzerttagebuch</h1><p>Besuchte Konzerte werden automatisch eingetragen. Deine Erinnerungen bleiben privat.</p></div>
|
||||
<div class="page-title"><h1>{{ _('📓 Konzerttagebuch') }}</h1><p>{{ _('Besuchte Konzerte werden automatisch eingetragen. Deine Erinnerungen bleiben privat.') }}</p></div>
|
||||
<div class="diary-list">
|
||||
{% for entry in entries %}
|
||||
<article class="diary-entry" id="concert-{{ entry.concert_id }}">
|
||||
<details class="diary-entry" id="concert-{{ entry.concert_id }}">
|
||||
<summary class="diary-summary">
|
||||
<h2><a href="/concerts/{{ entry.concert_id }}">{{ entry.artist }}</a></h2>
|
||||
<p class="diary-meta">{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}</p>
|
||||
{% if entry.rating %}<div class="diary-rating" aria-label="{{ entry.rating }} von 5 Sternen">{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}</div>{% else %}<p class="diary-empty-note">Noch nicht bewertet.</p>{% endif %}
|
||||
{% if entry.favorite_song %}<p class="diary-song">🎵 Lieblingssong: {{ entry.favorite_song }}</p>{% endif %}
|
||||
{% if entry.rating %}<div class="diary-rating" aria-label="{{ entry.rating }} {{ _('von 5 Sternen') }}">{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}</div>{% else %}<p class="diary-empty-note">{{ _('Noch nicht bewertet.') }}</p>{% endif %}
|
||||
</summary>
|
||||
<div class="diary-content">
|
||||
{% if entry.favorite_song %}<p class="diary-song">{{ _('🎵 Lieblingssong:') }} {{ entry.favorite_song }}</p>{% endif %}
|
||||
{% if entry.notes %}<p class="diary-notes">{{ entry.notes }}</p>{% endif %}
|
||||
{% if entry.photos %}<div class="diary-gallery" aria-label="Bildergalerie zu {{ entry.artist }}">{% for photo in entry.photos %}<div class="diary-gallery-item"><img src="{{ photo.path }}" alt="Erinnerungsfoto {{ loop.index }} zu {{ entry.artist }}" loading="lazy"><form class="diary-gallery-delete" method="post" action="/diary/photos/{{ photo.id }}/delete" onsubmit="return confirm('Dieses Bild entfernen?');"><button type="submit" aria-label="Bild {{ loop.index }} entfernen">×</button></form></div>{% endfor %}</div>{% endif %}
|
||||
{% if entry.badges %}<div class="diary-patches">{% for badge in entry.badges %}<div class="diary-patch" title="{{ badge.description }}">{% if badge.image_path %}<img src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">{% else %}<span class="diary-patch-icon">{{ badge.icon }}</span>{% endif %}<span><strong>{{ badge.name }}</strong><br><small>Hier erhalten · {{ badge.awarded_at }}</small></span></div>{% endfor %}</div>{% endif %}
|
||||
{% if entry.photos %}<div class="diary-gallery" aria-label="{{ _('Bildergalerie zu') }} {{ entry.artist }}">{% for photo in entry.photos %}<div class="diary-gallery-item"><img src="{{ photo.path }}" alt="{{ _('Erinnerungsfoto') }} {{ loop.index }} {{ _('zu') }} {{ entry.artist }}" loading="lazy"><form class="diary-gallery-delete" method="post" action="/diary/photos/{{ photo.id }}/delete" onsubmit="return confirm({{ _('Dieses Bild entfernen?') | tojson | forceescape }});"><button type="submit" aria-label="{{ _('Bild') }} {{ loop.index }} {{ _('entfernen') }}">×</button></form></div>{% endfor %}</div>{% endif %}
|
||||
{% if entry.badges %}<div class="diary-patches">{% for badge in entry.badges %}<div class="diary-patch" title="{{ badge.description | t }}">{% if badge.image_path %}<img src="{{ badge.image_path }}" alt="Patch {{ badge.name | t }}">{% else %}<span class="diary-patch-icon">{{ badge.icon }}</span>{% endif %}<span><strong>{{ badge.name | t }}</strong><br><small>{{ _('Hier erhalten ·') }} {{ badge.awarded_at }}</small></span></div>{% endfor %}</div>{% endif %}
|
||||
<details class="diary-edit" {% if not entry.rating and not entry.notes and not entry.favorite_song and not entry.photos %}open{% endif %}>
|
||||
<summary>Eintrag bearbeiten</summary>
|
||||
<summary>{{ _('Eintrag bearbeiten') }}</summary>
|
||||
<form class="diary-form" method="post" action="/concerts/{{ entry.concert_id }}/diary" enctype="multipart/form-data">
|
||||
<label>Bewertung <small>(optional)</small></label>
|
||||
<select name="rating"><option value="">Noch nicht bewertet</option>{% for value in range(1,6) %}<option value="{{ value }}" {% if entry.rating == value %}selected{% endif %}>{{ value }} von 5 Sternen</option>{% endfor %}</select>
|
||||
<label>Lieblingssong <small>(optional)</small></label>
|
||||
<label>{{ _('Bewertung') }} <small>(optional)</small></label>
|
||||
<select name="rating"><option value="">{{ _('Noch nicht bewertet') }}</option>{% for value in range(1,6) %}<option value="{{ value }}" {% if entry.rating == value %}selected{% endif %}>{{ value }} {{ _('von 5 Sternen') }}</option>{% endfor %}</select>
|
||||
<label>{{ _('Lieblingssong') }} <small>(optional)</small></label>
|
||||
<input name="favorite_song" maxlength="255" value="{{ entry.favorite_song }}">
|
||||
<label>Erinnerungen <small>(optional)</small></label>
|
||||
<textarea name="notes" maxlength="5000" placeholder="Was ist dir von diesem Abend geblieben?">{{ entry.notes }}</textarea>
|
||||
<label>Galeriebilder <small>({{ entry.photos|length }}/3, optional)</small></label>
|
||||
<label>{{ _('Erinnerungen') }} <small>(optional)</small></label>
|
||||
<textarea name="notes" maxlength="5000" placeholder="{{ _('Was ist dir von diesem Abend geblieben?') }}">{{ entry.notes }}</textarea>
|
||||
<label>{{ _('Galeriebilder') }} <small>({{ entry.photos|length }}/3, optional)</small></label>
|
||||
<input class="diary-photo-input" name="photos" type="file" accept="image/jpeg,image/png,image/webp" multiple data-free-slots="{{ 3-entry.photos|length }}" {% if entry.photos|length >= 3 %}disabled{% endif %}>
|
||||
<small class="diary-photo-status">Bis zu drei Bilder; große Bilder werden automatisch optimiert.</small>
|
||||
<button class="button" type="submit">Eintrag speichern</button>
|
||||
<small class="diary-photo-status">{{ _('Bis zu drei Bilder; große Bilder werden automatisch optimiert.') }}</small>
|
||||
<button class="button" type="submit">{{ _('Eintrag speichern') }}</button>
|
||||
</form>
|
||||
<form class="diary-delete-form" method="post" action="/concerts/{{ entry.concert_id }}/diary/delete" onsubmit="return confirm({{ _('Wirklich unwiderruflich löschen? Die Veranstaltung kann nicht mehr eingetragen werden.') | tojson | forceescape }});">
|
||||
<button class="diary-delete-link" type="submit">{{ _('Konzerteintrag löschen') }}</button>
|
||||
</form>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<section class="empty"><p>Noch keine Einträge. Vergangene zugesagte Konzerte erscheinen hier automatisch.</p></section>
|
||||
<section class="empty"><p>{{ _('Noch keine Einträge. Vergangene zugesagte Konzerte erscheinen hier automatisch.') }}</p></section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const selectedEntry = document.querySelector(location.hash);
|
||||
if (selectedEntry?.classList.contains("diary-entry")) selectedEntry.open = true;
|
||||
|
||||
document.querySelectorAll(".diary-form").forEach(form => {
|
||||
form.addEventListener("submit", async event => {
|
||||
if (form.dataset.optimized === "true") return;
|
||||
@@ -56,7 +68,7 @@ document.querySelectorAll(".diary-form").forEach(form => {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
button.disabled = true;
|
||||
if (files.length > Number(input.dataset.freeSlots)) {
|
||||
status.textContent = `Du kannst noch ${input.dataset.freeSlots} Bild(er) hinzufügen.`;
|
||||
status.textContent = {{ _('Du kannst noch {count} Bild(er) hinzufügen.') | tojson }}.replace('{count}', input.dataset.freeSlots);
|
||||
button.disabled = false; return;
|
||||
}
|
||||
status.textContent = "Erinnerungsfoto wird optimiert …";
|
||||
@@ -81,7 +93,7 @@ document.querySelectorAll(".diary-form").forEach(form => {
|
||||
form.requestSubmit();
|
||||
} catch (error) {
|
||||
console.error("Diary image optimization failed:", error);
|
||||
status.textContent = "Das Bild konnte nicht optimiert werden. Bitte ein kleineres JPG, PNG oder WebP wählen.";
|
||||
status.textContent = {{ _('Das Bild konnte nicht optimiert werden. Bitte ein kleineres JPG, PNG oder WebP wählen.') | tojson }};
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ concert.artist }} bearbeiten · MetalCircle</title>
|
||||
<title>{{ concert.artist }} {{ _('bearbeiten · MetalCircle') }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -13,127 +14,128 @@
|
||||
<a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>
|
||||
{% include '_user_menu.html' %}
|
||||
</div>
|
||||
{% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title">
|
||||
<h1>✏️ Veranstaltung bearbeiten</h1>
|
||||
<p>Änderungen werden direkt bei der Veranstaltung gespeichert.</p>
|
||||
<h1>{{ _('✏️ Veranstaltung bearbeiten') }}</h1>
|
||||
<p>{{ _('Änderungen werden direkt bei der Veranstaltung gespeichert.') }}</p>
|
||||
</div>
|
||||
<section class="empty">
|
||||
<form method="post" action="/concerts/{{ concert.id }}/edit" enctype="multipart/form-data">
|
||||
{% if can_edit_details %}
|
||||
<p>
|
||||
<label>Kategorie<br>
|
||||
<label>{{ _('Kategorie') }}<br>
|
||||
<select name="event_type" id="event-type" required {% if not can_manage_access %}disabled{% endif %}>
|
||||
<option value="concert" {% if concert.event_type == 'concert' %}selected{% endif %}>Konzert</option>
|
||||
<option value="concert" {% if concert.event_type == 'concert' %}selected{% endif %}>{{ _('Konzert') }}</option>
|
||||
<option value="festival" {% if concert.event_type == 'festival' %}selected{% endif %}>Festival</option>
|
||||
<option value="other" {% if concert.event_type == 'other' %}selected{% endif %}>Sonstiges</option>
|
||||
<option value="other" {% if concert.event_type == 'other' %}selected{% endif %}>{{ _('Sonstiges') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p id="parent-event-field" {% if concert.event_type != 'other' %}hidden{% endif %}>
|
||||
<label>Gehört zu<br>
|
||||
<label>{{ _('Gehört zu') }}<br>
|
||||
<select name="parent_event_id" id="parent-event-id">
|
||||
<option value="">Keine Verknüpfung – eigenständige Karte</option>
|
||||
<option value="">{{ _('Keine Verknüpfung – eigenständige Karte') }}</option>
|
||||
{% for event in linkable_events %}
|
||||
<option value="{{ event.id }}" {% if concert.parent_event and concert.parent_event.id == event.id %}selected{% endif %}>{{ event.event_type_label }} · {{ event.title }} · {{ event.date }}</option>
|
||||
<option value="{{ event.id }}" {% if concert.parent_event and concert.parent_event.id == event.id %}selected{% endif %}>{{ event.event_type_label | t }} · {{ event.title }} · {{ event.date }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
{% if can_manage_access %}<fieldset id="event-visibility-field" {% if concert.event_type != 'other' %}hidden{% endif %}>
|
||||
<legend>Sichtbarkeit</legend>
|
||||
<label><input type="radio" name="visibility" value="public" {% if concert.visibility == 'public' %}checked{% endif %}> Öffentlich</label><br>
|
||||
<label><input type="radio" name="visibility" value="friends" {% if concert.visibility == 'friends' %}checked{% endif %}> Nur Freunde</label><br>
|
||||
<label><input type="radio" name="visibility" value="private" {% if concert.visibility == 'private' %}checked{% endif %}> Privat – nur eingeladene Mitglieder</label>
|
||||
<legend>{{ _('Sichtbarkeit') }}</legend>
|
||||
<label><input type="radio" name="visibility" value="public" {% if concert.visibility == 'public' %}checked{% endif %}> {{ _('Öffentlich') }}</label><br>
|
||||
<label><input type="radio" name="visibility" value="friends" {% if concert.visibility == 'friends' %}checked{% endif %}> {{ _('Nur Freunde') }}</label><br>
|
||||
<label><input type="radio" name="visibility" value="private" {% if concert.visibility == 'private' %}checked{% endif %}> {{ _('Privat – nur eingeladene Mitglieder') }}</label>
|
||||
</fieldset>
|
||||
<fieldset id="event-invitations-field" {% if concert.event_type != 'other' or concert.visibility != 'private' %}hidden{% endif %}>
|
||||
<legend>Mitglieder einladen</legend>
|
||||
<legend>{{ _('Mitglieder einladen') }}</legend>
|
||||
{% for member in invitable_users %}
|
||||
<label style="display:block;margin:7px 0"><input type="checkbox" name="invited_user_ids" value="{{ member.id }}" {% if member.id in invited_user_ids %}checked{% endif %}> {{ member.display_name }} <small>@{{ member.username }}</small></label>
|
||||
{% else %}<p>Noch keine weiteren Mitglieder vorhanden.</p>{% endfor %}
|
||||
{% else %}<p>{{ _('Noch keine weiteren Mitglieder vorhanden.') }}</p>{% endfor %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p>
|
||||
<label>Titel / Künstler / Band<br>
|
||||
<label>{{ _('Titel / Künstler / Band') }}<br>
|
||||
<input type="text" name="artist" value="{{ concert.artist }}" {% if not can_edit_title %}disabled{% endif %} required>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Bands / Line-up <small>(eine Band pro Zeile)</small><br>
|
||||
<label>{{ _('Bands / Line-up') }} <small>{{ _('(eine Band pro Zeile)') }}</small><br>
|
||||
<textarea name="band_names" rows="3" maxlength="4000" {% if not can_edit_title %}disabled{% endif %}>{{ band_names }}</textarea>
|
||||
</label>
|
||||
<small>Diese Namen werden einzeln für die Folgen-Funktion verwendet.</small>
|
||||
<small>{{ _('Diese Namen werden einzeln für die Folgen-Funktion verwendet.') }}</small>
|
||||
</p>
|
||||
{% if can_edit_details %}
|
||||
<p>
|
||||
<label>Veranstaltungsort<br>
|
||||
<input type="text" name="venue_name" value="{{ concert.venue.name if concert.venue.id else '' }}" placeholder="Name des Veranstaltungsorts">
|
||||
<label>{{ _('Veranstaltungsort') }}<br>
|
||||
<input type="text" name="venue_name" value="{{ concert.venue.name if concert.venue.id else '' }}" placeholder="{{ _('Name des Veranstaltungsorts') }}">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Straße<br>
|
||||
<label>{{ _('Straße') }}<br>
|
||||
<input type="text" name="street" value="{{ concert.venue.street or '' }}">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>PLZ und Ort<br>
|
||||
<input type="text" name="postal_code" value="{{ concert.venue.postal_code or '' }}" placeholder="PLZ">
|
||||
<input type="text" name="city" value="{{ concert.venue.city or '' }}" placeholder="Ort">
|
||||
<label>{{ _('PLZ und Ort') }}<br>
|
||||
<input type="text" name="postal_code" value="{{ concert.venue.postal_code or '' }}" placeholder="{{ _('PLZ') }}">
|
||||
<input type="text" name="city" value="{{ concert.venue.city or '' }}" placeholder="{{ _('Ort') }}">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Land<br>
|
||||
<input type="text" name="country" value="{{ concert.venue.country or '' }}" placeholder="Land">
|
||||
<label>{{ _('Land') }}<br>
|
||||
<input type="text" name="country" value="{{ concert.venue.country or '' }}" placeholder="{{ _('Land') }}">
|
||||
</label>
|
||||
</p>
|
||||
<input type="hidden" name="latitude" value="{{ concert.venue.latitude or '' }}">
|
||||
<input type="hidden" name="longitude" value="{{ concert.venue.longitude or '' }}">
|
||||
<p>
|
||||
<label>Beginn<br>
|
||||
<label>{{ _('Beginn') }}<br>
|
||||
<input type="datetime-local" name="start_datetime" value="{{ concert.start_local }}" required>
|
||||
</label>
|
||||
</p>
|
||||
<p id="festival-end-field" {% if concert.event_type != 'festival' %}hidden{% endif %}>
|
||||
<label>Ende<br>
|
||||
<label>{{ _('Ende') }}<br>
|
||||
<input type="datetime-local" name="end_datetime" id="festival-end-datetime" value="{{ concert.end_local }}" {% if concert.event_type == 'festival' %}required{% endif %}>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Beschreibung<br>
|
||||
<label>{{ _('Beschreibung') }}<br>
|
||||
<textarea name="description" rows="5">{{ concert.description or '' }}</textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Ticket-Link<br>
|
||||
<label>{{ _('Ticket-Link') }}<br>
|
||||
<input type="url" name="ticket_url" value="{{ concert.ticket_url or '' }}" placeholder="https://...">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Ticketpreis<br>
|
||||
<label>{{ _('Ticketpreis') }}<br>
|
||||
<input type="number" name="ticket_price" value="{{ concert.ticket_price or '' }}" step="0.01" min="0">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Flyer extern verlinken <small>(bevorzugt)</small><br>
|
||||
<label>{{ _('Flyer extern verlinken') }} <small>{{ _('(bevorzugt)') }}</small><br>
|
||||
<input type="url" name="flyer_url" value="{{ concert.flyer_url or '' }}" placeholder="https://veranstalter.example/flyer.jpg">
|
||||
</label>
|
||||
<small>Bitte möglichst die offizielle Quelle verwenden. Ein externer Link wird gegenüber einem Upload bevorzugt und unter dem Bild genannt. Feld leeren, um wieder den Upload zu verwenden.</small>
|
||||
<small>{{ _('Bitte möglichst die offizielle Quelle verwenden. Ein externer Link wird gegenüber einem Upload bevorzugt und unter dem Bild genannt. Feld leeren, um wieder den Upload zu verwenden.') }}</small>
|
||||
</p>
|
||||
<p>
|
||||
<label>Neuen Flyer hochladen<br>
|
||||
<label>{{ _('Neuen Flyer hochladen') }}<br>
|
||||
<input type="file" name="flyer" accept="image/jpeg,image/png,image/webp">
|
||||
</label>
|
||||
<small>Ohne neue Datei bleibt der bisherige Flyer erhalten.</small>
|
||||
<small>{{ _('Ohne neue Datei bleibt der bisherige Flyer erhalten.') }}</small>
|
||||
</p>
|
||||
{% if concert.flyer_url or concert.flyer_path %}
|
||||
<p><img src="{{ concert.flyer_url or concert.flyer_path }}" alt="Aktueller Flyer" referrerpolicy="no-referrer" style="max-width: 300px; max-height: 400px; border-radius: 10px;"></p>
|
||||
<p><img src="{{ concert.flyer_url or concert.flyer_path }}" alt="{{ _('Aktueller Flyer') }}" referrerpolicy="no-referrer" style="max-width: 300px; max-height: 400px; border-radius: 10px;"></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div>
|
||||
<button type="submit" class="button">💾 Änderungen speichern</button>
|
||||
<a href="/concerts/{{ concert.id }}" class="button button-secondary">Abbrechen</a>
|
||||
<button type="submit" class="button">{{ _('💾 Änderungen speichern') }}</button>
|
||||
<a href="/concerts/{{ concert.id }}" class="button button-secondary">{{ _('Abbrechen') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gefolgt · MetalCircle</title><link rel="stylesheet" href="/static/css/style.css"><style>
|
||||
<!DOCTYPE html><html lang="{{ language() }}"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ _('Gefolgt · MetalCircle') }}</title><link rel="stylesheet" href="/static/css/style.css"><style>
|
||||
.follow-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.follow-card{padding:18px;background:var(--surface);border:1px solid var(--border);border-radius:12px}.follow-list{display:flex;flex-wrap:wrap;gap:8px;padding:0;list-style:none}.follow-list li{display:flex;align-items:center;gap:6px;padding:7px 10px;background:#171212;border:1px solid var(--border);border-radius:999px}.follow-list form{margin:0}.follow-remove{padding:0;color:#fca5a5;background:none;border:0;cursor:pointer;font-size:1rem}.follow-events{display:grid;gap:10px}.follow-event{display:block;padding:14px;background:#0b0b0b;border:1px solid var(--border);border-radius:10px}.follow-event:hover{border-color:#ef4444}.follow-reason{color:var(--muted);font-size:.82rem}
|
||||
</style></head><body><header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header><main>
|
||||
<div class="page-title"><h1>⭐ Gefolgt</h1><p>Bands, Locations und passende kommende Veranstaltungen.</p></div>
|
||||
<div class="follow-grid"><section class="follow-card"><h2>Bands</h2><ul class="follow-list">{% for band in followed_bands %}<li>{{ band.name }}<form method="post" action="/following/bands/remove"><input type="hidden" name="band_key" value="{{ band.key }}"><button class="follow-remove" type="submit" title="Nicht mehr folgen" aria-label="{{ band.name }} nicht mehr folgen">×</button></form></li>{% else %}<li>Noch keine Band gefolgt.</li>{% endfor %}</ul></section>
|
||||
<section class="follow-card"><h2>Locations</h2><ul class="follow-list">{% for venue in followed_venues %}<li>{{ venue.name }}{% if venue.city %}, {{ venue.city }}{% endif %}<form method="post" action="/following/venues/remove"><input type="hidden" name="venue_id" value="{{ venue.id }}"><button class="follow-remove" type="submit" title="Nicht mehr folgen" aria-label="{{ venue.name }} nicht mehr folgen">×</button></form></li>{% else %}<li>Noch keiner Location gefolgt.</li>{% endfor %}</ul></section></div>
|
||||
<section class="follow-card" style="margin-top:18px"><h2>Kommende Treffer</h2><div class="follow-events">{% for event in events %}<a class="follow-event" href="/concerts/{{ event.id }}"><strong>{{ event.artist }}</strong><br>{{ event.date }} · {{ event.time }}{% if event.venue %} · {{ event.venue }}{% endif %}<div class="follow-reason">{% if event.matched_band %}Band{% endif %}{% if event.matched_band and event.matched_venue %} und {% endif %}{% if event.matched_venue %}Location{% endif %} gefolgt</div></a>{% else %}<p>Momentan gibt es keine kommenden Treffer.</p>{% endfor %}</div></section>
|
||||
</style><link rel="stylesheet" href="/static/css/language.css">
|
||||
</head><body><header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div> {% include '_language_switch.html' %}
|
||||
</header><main>
|
||||
<div class="page-title"><h1>{{ _('⭐ Gefolgt') }}</h1><p>{{ _('Bands, Locations und passende kommende Veranstaltungen.') }}</p></div>
|
||||
<div class="follow-grid"><section class="follow-card"><h2>Bands</h2><ul class="follow-list">{% for band in followed_bands %}<li>{{ band.name }}<form method="post" action="/following/bands/remove"><input type="hidden" name="band_key" value="{{ band.key }}"><button class="follow-remove" type="submit" title="{{ _('Nicht mehr folgen') }}" aria-label="{{ band.name }} {{ _('nicht mehr folgen') }}">×</button></form></li>{% else %}<li>{{ _('Noch keine Band gefolgt.') }}</li>{% endfor %}</ul></section>
|
||||
<section class="follow-card"><h2>{{ _('Locations') }}</h2><ul class="follow-list">{% for venue in followed_venues %}<li>{{ venue.name }}{% if venue.city %}, {{ venue.city }}{% endif %}<form method="post" action="/following/venues/remove"><input type="hidden" name="venue_id" value="{{ venue.id }}"><button class="follow-remove" type="submit" title="{{ _('Nicht mehr folgen') }}" aria-label="{{ venue.name }} {{ _('nicht mehr folgen') }}">×</button></form></li>{% else %}<li>{{ _('Noch keiner Location gefolgt.') }}</li>{% endfor %}</ul></section></div>
|
||||
<section class="follow-card" style="margin-top:18px"><h2>{{ _('Kommende Treffer') }}</h2><div class="follow-events">{% for event in events %}<a class="follow-event" href="/concerts/{{ event.id }}"><strong>{{ event.artist }}</strong><br>{{ event.date }} · {{ event.time }}{% if event.venue %} · {{ event.venue }}{% endif %}<div class="follow-reason">{% if event.matched_band %}Band{% endif %}{% if event.matched_band and event.matched_venue %} {{ _('und') }} {% endif %}{% if event.matched_venue %}{{ _('Location') }}{% endif %} {{ _('gefolgt') }}</div></a>{% else %}<p>{{ _('Momentan gibt es keine kommenden Treffer.') }}</p>{% endfor %}</div></section>
|
||||
</main></body></html>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Impressum · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head>
|
||||
<html lang="{{ language() }}">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{{ _('Impressum · MetalCircle') }}</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"><link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div></header>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title"><h1>Impressum</h1><p>Anbieterkennzeichnung</p></div>
|
||||
<div class="page-title"><h1>{{ _('Impressum') }}</h1><p>{{ _('Anbieterkennzeichnung') }}</p></div>
|
||||
<section class="admin-section legal-copy">
|
||||
<h2>MetalCircle</h2>
|
||||
<p><strong>Verantwortlich für den Inhalt</strong></p>
|
||||
<p>Kai Piekny<br>Fleyerstr. 33<br>58097 Hagen<br>Deutschland</p>
|
||||
<p>E-Mail: <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a></p>
|
||||
<p class="legal-note">Dieses private Projekt befindet sich derzeit im geschlossenen Betrieb. Vor einer Veröffentlichung werden die Angaben und rechtlichen Anforderungen nochmals geprüft.</p>
|
||||
<p><strong>{{ _('Verantwortlich für den Inhalt') }}</strong></p>
|
||||
<p>Kai Piekny<br>Fleyerstr. 33<br>58097 Hagen<br>{{ _('Deutschland') }}</p>
|
||||
<p>{{ _('E-Mail:') }} <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a></p>
|
||||
<p class="legal-note">{{ _('Dieses private Projekt befindet sich derzeit im geschlossenen Betrieb. Vor einer Veröffentlichung werden die Angaben und rechtlichen Anforderungen nochmals geprüft.') }}</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
+25
-23
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
|
||||
<head>
|
||||
|
||||
@@ -204,19 +204,20 @@
|
||||
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
|
||||
{% macro event_card(concert, linked=false) %}
|
||||
<a href="/concerts/{{ concert.id }}" class="concert-card{% if linked %} linked-event{% endif %}">
|
||||
<span class="event-category">{{ concert.event_type_label }}</span>
|
||||
{% if concert.is_invited %}<span class="event-category"> · ✉ Eingeladen</span>{% elif concert.visibility == 'friends' %}<span class="event-category"> · 🔒 Nur Freunde</span>{% elif concert.visibility == 'private' %}<span class="event-category"> · 🔐 Privat</span>{% endif %}
|
||||
<span class="event-category">{{ concert.event_type_label | t }}</span>
|
||||
{% if concert.is_invited %}<span class="event-category"> {{ _('· ✉ Eingeladen') }}</span>{% elif concert.visibility == 'friends' %}<span class="event-category"> {{ _('· 🔒 Nur Freunde') }}</span>{% elif concert.visibility == 'private' %}<span class="event-category"> {{ _('· 🔐 Privat') }}</span>{% endif %}
|
||||
<div class="concert-artist">
|
||||
{% if concert.event_type == 'festival' %}🎪{% elif concert.event_type == 'other' %}🥂{% else %}🎸{% endif %}
|
||||
{{ concert.artist }}
|
||||
</div>
|
||||
<div class="concert-meta">
|
||||
<div class="concert-date">
|
||||
📅 {{ concert.date }}{% if concert.end_date %} – {{ concert.end_date }}{% else %} um {{ concert.time }} Uhr{% endif %}
|
||||
📅 {{ concert.date }}{% if concert.end_date %} – {{ concert.end_date }}{% else %} {{ _('um') }} {{ concert.time }} {{ _('Uhr') }}{% endif %}
|
||||
</div>
|
||||
<div>📍 {{ concert.venue }}</div>
|
||||
</div>
|
||||
@@ -233,7 +234,7 @@
|
||||
<div>
|
||||
|
||||
<h1>
|
||||
{% if archive %}☠ Vergangene Veranstaltungen{% else %}<img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle">{% endif %}
|
||||
{% if archive %}{{ _('☠ Vergangene Veranstaltungen') }}{% else %}<img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle">{% endif %}
|
||||
</h1>
|
||||
|
||||
<div class="subtitle">
|
||||
@@ -246,42 +247,43 @@
|
||||
<div class="header-actions">
|
||||
|
||||
<a href="{% if archive %}/{% else %}/events/past{% endif %}" class="admin-button">
|
||||
{% if archive %}⚡ Bevorstehend{% else %}☠ Vergangene{% endif %}
|
||||
{% if archive %}{{ _('⚡ Bevorstehend') }}{% else %}{{ _('☠ Vergangene') }}{% endif %}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/concerts/new"
|
||||
class="new-concert-button"
|
||||
>
|
||||
+ Veranstaltung hinzufügen
|
||||
{{ _('+ Veranstaltung hinzufügen') }}
|
||||
</a>
|
||||
|
||||
{% include '_language_switch.html' %}
|
||||
{% include '_user_menu.html' %}
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<section class="search-panel" aria-label="Suche">
|
||||
<section class="search-panel" aria-label="{{ _('Suche') }}">
|
||||
<form class="search-form" method="get" action="{% if archive %}/events/past{% else %}/{% endif %}">
|
||||
<input type="search" name="q" value="{{ search_query }}" placeholder="Veranstaltungen, Orte oder User suchen …" aria-label="Veranstaltungen oder User suchen">
|
||||
<button class="new-concert-button" type="submit">Suchen</button>
|
||||
<input type="search" name="q" value="{{ search_query }}" placeholder="{{ _('Veranstaltungen, Orte oder User suchen …') }}" aria-label="{{ _('Veranstaltungen oder User suchen') }}">
|
||||
<button class="new-concert-button" type="submit">{{ _('Suchen') }}</button>
|
||||
<details class="advanced-filters" {% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %}open{% endif %}>
|
||||
<summary>Weitere Filter{% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %} · aktiv{% endif %}</summary>
|
||||
<summary>{{ _('Weitere Filter') }}{% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %} {{ _('· aktiv') }}{% endif %}</summary>
|
||||
<div class="advanced-filter-fields">
|
||||
<label>Von Datum<input type="date" name="date_from" value="{{ filters.date_from }}"></label>
|
||||
<label>Bis Datum<input type="date" name="date_to" value="{{ filters.date_to }}"></label>
|
||||
<label>Ort / Location<input type="search" name="venue" value="{{ filters.venue }}" placeholder="z. B. Düsseldorf"></label>
|
||||
<label>Land<input type="search" name="country" value="{{ filters.country }}" placeholder="z. B. Deutschland"></label>
|
||||
<label>Kategorie<select name="category"><option value="">Alle Kategorien</option><option value="concert" {% if filters.category == 'concert' %}selected{% endif %}>Konzert</option><option value="festival" {% if filters.category == 'festival' %}selected{% endif %}>Festival</option><option value="other" {% if filters.category == 'other' %}selected{% endif %}>Sonstiges</option></select></label>
|
||||
<label>{{ _('Von Datum') }}<input type="date" name="date_from" value="{{ filters.date_from }}"></label>
|
||||
<label>{{ _('Bis Datum') }}<input type="date" name="date_to" value="{{ filters.date_to }}"></label>
|
||||
<label>{{ _('Ort / Location') }}<input type="search" name="venue" value="{{ filters.venue }}" placeholder="{{ _('z. B. Düsseldorf') }}"></label>
|
||||
<label>{{ _('Land') }}<input type="search" name="country" value="{{ filters.country }}" placeholder="{{ _('z. B. Deutschland') }}"></label>
|
||||
<label>{{ _('Kategorie') }}<select name="category"><option value="">{{ _('Alle Kategorien') }}</option><option value="concert" {% if filters.category == 'concert' %}selected{% endif %}>{{ _('Konzert') }}</option><option value="festival" {% if filters.category == 'festival' %}selected{% endif %}>Festival</option><option value="other" {% if filters.category == 'other' %}selected{% endif %}>{{ _('Sonstiges') }}</option></select></label>
|
||||
</div>
|
||||
{% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %}<a class="clear-filters" href="{% if archive %}/events/past{% else %}/{% endif %}{% if search_query %}?q={{ search_query|urlencode }}{% endif %}">Filter löschen</a>{% endif %}
|
||||
{% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %}<a class="clear-filters" href="{% if archive %}/events/past{% else %}/{% endif %}{% if search_query %}?q={{ search_query|urlencode }}{% endif %}">{{ _('Filter löschen') }}</a>{% endif %}
|
||||
</details>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if search_query %}
|
||||
<h2 class="search-results-title">User zu „{{ search_query }}“</h2>
|
||||
<h2 class="search-results-title">{{ _('User zu „') }}{{ search_query }}“</h2>
|
||||
{% if user_results %}
|
||||
<div class="user-results">
|
||||
{% for result in user_results %}
|
||||
@@ -291,8 +293,8 @@
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}<p class="subtitle">Keine passenden User gefunden.</p>{% endif %}
|
||||
<h2 class="search-results-title">Veranstaltungen zu „{{ search_query }}“</h2>
|
||||
{% else %}<p class="subtitle">{{ _('Keine passenden User gefunden.') }}</p>{% endif %}
|
||||
<h2 class="search-results-title">{{ _('Veranstaltungen zu „') }}{{ search_query }}“</h2>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -315,7 +317,7 @@
|
||||
{% if archive and past_concerts %}
|
||||
|
||||
<h2 class="past-concerts-title">
|
||||
Archiv
|
||||
{{ _('Archiv') }}
|
||||
</h2>
|
||||
|
||||
{% for concert in past_concerts %}
|
||||
@@ -339,11 +341,11 @@
|
||||
<div class="empty-state">
|
||||
|
||||
<h2>
|
||||
{% if archive %}Noch keine vergangenen Veranstaltungen ☠{% else %}Noch keine Veranstaltungen 🎸{% endif %}
|
||||
{% if archive %}{{ _('Noch keine vergangenen Veranstaltungen ☠') }}{% else %}{{ _('Noch keine Veranstaltungen 🎸') }}{% endif %}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{% if archive %}Hier erscheinen abgeschlossene Veranstaltungen.{% else %}Leg die erste Veranstaltung an!{% endif %}
|
||||
{% if archive %}{{ _('Hier erscheinen abgeschlossene Veranstaltungen.') }}{% else %}{{ _('Leg die erste Veranstaltung an!') }}{% endif %}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anmelden · MetalCircle</title>
|
||||
<title>{{ _('Anmelden · MetalCircle') }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>
|
||||
</div>
|
||||
{% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title">
|
||||
<h1>Anmelden</h1>
|
||||
<p>Melde dich an, um Konzerte hinzuzufügen oder zu bearbeiten.</p>
|
||||
<h1>{{ _('Anmelden') }}</h1>
|
||||
<p>{{ _('Melde dich an, um Konzerte hinzuzufügen oder zu bearbeiten.') }}</p>
|
||||
</div>
|
||||
<section class="empty">
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="next" value="{{ next_path }}">
|
||||
{% if error %}<p role="alert">{{ error }}</p>{% endif %}
|
||||
{% if error %}<p role="alert">{{ error | t }}</p>{% endif %}
|
||||
<p>
|
||||
<label>Benutzername oder E-Mail<br>
|
||||
<label>{{ _('Benutzername oder E-Mail') }}<br>
|
||||
<input type="text" name="username" required autocomplete="username">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Passwort<br>
|
||||
<label>{{ _('Passwort') }}<br>
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
</p>
|
||||
<button type="submit" class="button">Anmelden</button>
|
||||
<p><small>Passwort vergessen? Bitte einen Admin um einen zeitlich begrenzten Reset-Link.</small></p>
|
||||
<button type="submit" class="button">{{ _('Anmelden') }}</button>
|
||||
<p><small>{{ _('Passwort vergessen? Bitte einen Admin um einen zeitlich begrenzten Reset-Link.') }}</small></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
+18
-16
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nachrichten · MetalCircle</title>
|
||||
<title>{{ _('Nachrichten · MetalCircle') }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
@@ -38,9 +38,11 @@
|
||||
.conversation-heading { margin-top: 20px; }
|
||||
@media (max-width: 600px) { .chat-form { grid-template-columns: 1fr; } .chat-bubble { max-width: 90%; } }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div> {% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
{% if partner %}
|
||||
<div class="chat-header">
|
||||
@@ -49,43 +51,43 @@
|
||||
</div>
|
||||
<div class="chat-log">
|
||||
{% for message in messages %}<div class="chat-bubble{% if message.from_me %} mine{% endif %}"{% if loop.last %} id="latest"{% endif %}>{{ message.body }}<span class="chat-time">{{ message.created_at }}</span></div>
|
||||
{% else %}<p class="empty-chat">Noch keine Nachrichten. Schreib die erste!</p>{% endfor %}
|
||||
{% else %}<p class="empty-chat">{{ _('Noch keine Nachrichten. Schreib die erste!') }}</p>{% endfor %}
|
||||
</div>
|
||||
<form class="chat-form" method="post" action="/messages/{{ partner.username }}">
|
||||
<textarea name="body" maxlength="2000" required placeholder="Nachricht schreiben …" aria-label="Nachricht"></textarea>
|
||||
<button class="button" type="submit">Senden</button>
|
||||
<textarea name="body" maxlength="2000" required placeholder="{{ _('Nachricht schreiben …') }}" aria-label="{{ _('Nachricht') }}"></textarea>
|
||||
<button class="button" type="submit">{{ _('Senden') }}</button>
|
||||
</form>
|
||||
<p><a class="button button-secondary" href="/messages">← Alle Nachrichten</a></p>
|
||||
<p><a class="button button-secondary" href="/messages">{{ _('← Alle Nachrichten') }}</a></p>
|
||||
{% else %}
|
||||
<div class="page-title"><h1>🤘 Nachrichten</h1><p>Private Chats mit deinen Freunden.</p></div>
|
||||
<div class="page-title"><h1>{{ _('🤘 Nachrichten') }}</h1><p>{{ _('Private Chats mit deinen Freunden.') }}</p></div>
|
||||
{% if friend_requests or event_invitations %}
|
||||
<section class="message-notifications">
|
||||
<h2>Neue Anfragen</h2>
|
||||
<h2>{{ _('Neue Anfragen') }}</h2>
|
||||
{% for request in friend_requests %}
|
||||
<div class="notification-row">
|
||||
<span class="notification-icon">🤝</span>
|
||||
<span><strong>{{ request.display_name }}</strong> möchte sich mit dir befreunden.<small>{{ request.created_at }}</small></span>
|
||||
<span class="notification-actions"><form method="post" action="/friendships/{{ request.id }}/accept"><input type="hidden" name="return_to" value="/messages"><button class="button" type="submit">Annehmen</button></form><form method="post" action="/friendships/{{ request.id }}/decline"><input type="hidden" name="return_to" value="/messages"><button class="button button-secondary" type="submit">Ablehnen</button></form></span>
|
||||
<span><strong>{{ request.display_name }}</strong> {{ _('möchte sich mit dir befreunden.') }}<small>{{ request.created_at }}</small></span>
|
||||
<span class="notification-actions"><form method="post" action="/friendships/{{ request.id }}/accept"><input type="hidden" name="return_to" value="/messages"><button class="button" type="submit">{{ _('Annehmen') }}</button></form><form method="post" action="/friendships/{{ request.id }}/decline"><input type="hidden" name="return_to" value="/messages"><button class="button button-secondary" type="submit">{{ _('Ablehnen') }}</button></form></span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for invitation in event_invitations %}
|
||||
<a class="notification-row" href="/concerts/{{ invitation.concert_id }}">
|
||||
<span class="notification-icon">✉️</span>
|
||||
<span><strong>{{ invitation.artist }}</strong> – Einladung von {{ invitation.invited_by }}<small>{{ invitation.date }}</small></span>
|
||||
<span class="notification-link">Ansehen →</span>
|
||||
<span><strong>{{ invitation.artist }}</strong> {{ _('– Einladung von') }} {{ invitation.invited_by }}<small>{{ invitation.date }}</small></span>
|
||||
<span class="notification-link">{{ _('Ansehen →') }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
<h2 class="conversation-heading">Unterhaltungen</h2>
|
||||
<h2 class="conversation-heading">{{ _('Unterhaltungen') }}</h2>
|
||||
<div class="conversation-list">
|
||||
{% for conversation in conversations %}
|
||||
<a class="conversation-card" href="/messages/{{ conversation.username }}">
|
||||
{% if conversation.avatar_path %}<img class="chat-avatar" src="{{ conversation.avatar_path }}" alt="">{% else %}<span class="chat-avatar">{{ conversation.display_name[:1] }}</span>{% endif %}
|
||||
<span class="conversation-copy"><strong>{{ conversation.display_name }}</strong><span>{% if conversation.last_message %}{% if conversation.last_from_me %}Du: {% endif %}{{ conversation.last_message }} · {{ conversation.last_at }}{% else %}Chat beginnen{% endif %}</span></span>
|
||||
<span class="conversation-copy"><strong>{{ conversation.display_name }}</strong><span>{% if conversation.last_message %}{% if conversation.last_from_me %}{{ _('Du:') }} {% endif %}{{ conversation.last_message }} · {{ conversation.last_at }}{% else %}{{ _('Chat beginnen') }}{% endif %}</span></span>
|
||||
{% if conversation.unread_count %}<span class="unread-badge">{{ conversation.unread_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% else %}<div class="empty"><h2>Noch keine Chats</h2><p>Chats sind mit bestätigten Freunden und mit Admins verfügbar.</p></div>{% endfor %}
|
||||
{% else %}<div class="empty"><h2>{{ _('Noch keine Chats') }}</h2><p>{{ _('Chats sind mit bestätigten Freunden und mit Admins verfügbar.') }}</p></div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
|
||||
<head>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
>
|
||||
|
||||
<title>Veranstaltung hinzufügen · MetalCircle</title>
|
||||
<title>{{ _('Veranstaltung hinzufügen · MetalCircle') }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||||
|
||||
<link
|
||||
@@ -71,6 +71,7 @@
|
||||
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -88,6 +89,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
{% include '_language_switch.html' %}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -95,10 +97,10 @@
|
||||
|
||||
<div class="page-title">
|
||||
|
||||
<h1>🎤 Veranstaltung hinzufügen</h1>
|
||||
<h1>{{ _('🎤 Veranstaltung hinzufügen') }}</h1>
|
||||
|
||||
<p>
|
||||
Trag ein, worauf wir uns freuen.
|
||||
{{ _('Trag ein, worauf wir uns freuen.') }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
@@ -113,38 +115,38 @@
|
||||
>
|
||||
|
||||
<p>
|
||||
<label for="event-type">Kategorie</label><br>
|
||||
<label for="event-type">{{ _('Kategorie') }}</label><br>
|
||||
<select name="event_type" id="event-type" required>
|
||||
<option value="concert">Konzert</option>
|
||||
<option value="concert">{{ _('Konzert') }}</option>
|
||||
<option value="festival">Festival</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
<option value="other">{{ _('Sonstiges') }}</option>
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<p id="parent-event-field" hidden>
|
||||
<label for="parent-event-id">Gehört zu</label><br>
|
||||
<label for="parent-event-id">{{ _('Gehört zu') }}</label><br>
|
||||
<select name="parent_event_id" id="parent-event-id">
|
||||
<option value="">Keine Verknüpfung – eigenständige Karte</option>
|
||||
<option value="">{{ _('Keine Verknüpfung – eigenständige Karte') }}</option>
|
||||
{% for event in linkable_events %}
|
||||
<option value="{{ event.id }}">{{ event.event_type_label }} · {{ event.title }} · {{ event.date }}</option>
|
||||
<option value="{{ event.id }}">{{ event.event_type_label | t }} · {{ event.title }} · {{ event.date }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.</small>
|
||||
<small>{{ _('Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.') }}</small>
|
||||
</p>
|
||||
|
||||
<fieldset id="event-visibility-field" hidden>
|
||||
<legend>Sichtbarkeit</legend>
|
||||
<label><input type="radio" name="visibility" value="public" checked> Öffentlich – alle können die Veranstaltung sehen und teilnehmen</label><br>
|
||||
<label><input type="radio" name="visibility" value="friends"> Nur Freunde – nur bestätigte Freunde können sie sehen und teilnehmen</label><br>
|
||||
<label><input type="radio" name="visibility" value="private"> Privat – nur gezielt eingeladene Mitglieder</label>
|
||||
<legend>{{ _('Sichtbarkeit') }}</legend>
|
||||
<label><input type="radio" name="visibility" value="public" checked> {{ _('Öffentlich – alle können die Veranstaltung sehen und teilnehmen') }}</label><br>
|
||||
<label><input type="radio" name="visibility" value="friends"> {{ _('Nur Freunde – nur bestätigte Freunde können sie sehen und teilnehmen') }}</label><br>
|
||||
<label><input type="radio" name="visibility" value="private"> {{ _('Privat – nur gezielt eingeladene Mitglieder') }}</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="event-invitations-field" hidden>
|
||||
<legend>Mitglieder einladen</legend>
|
||||
<p class="flyer-help">Eingeladene erhalten eine Benachrichtigung und sehen die Veranstaltung in ihrer Übersicht.</p>
|
||||
<legend>{{ _('Mitglieder einladen') }}</legend>
|
||||
<p class="flyer-help">{{ _('Eingeladene erhalten eine Benachrichtigung und sehen die Veranstaltung in ihrer Übersicht.') }}</p>
|
||||
{% for member in invitable_users %}
|
||||
<label style="display:block;margin:7px 0"><input type="checkbox" name="invited_user_ids" value="{{ member.id }}"> {{ member.display_name }} <small>@{{ member.username }}</small></label>
|
||||
{% else %}<p>Noch keine weiteren Mitglieder vorhanden.</p>{% endfor %}
|
||||
{% else %}<p>{{ _('Noch keine weiteren Mitglieder vorhanden.') }}</p>{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<!-- ================================================= -->
|
||||
@@ -154,7 +156,7 @@
|
||||
<p>
|
||||
|
||||
<label>
|
||||
Titel / Künstler / Band<br>
|
||||
{{ _('Titel / Künstler / Band') }}<br>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
@@ -163,7 +165,7 @@
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="255"
|
||||
placeholder="z. B. Iron Maiden"
|
||||
placeholder="{{ _('z. B. Iron Maiden') }}"
|
||||
>
|
||||
|
||||
</label>
|
||||
@@ -171,10 +173,10 @@
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label for="band-names">Bands / Line-up <small>(eine Band pro Zeile)</small><br>
|
||||
<textarea id="band-names" name="band_names" rows="3" maxlength="4000" placeholder="dArtagnan Supportband"></textarea>
|
||||
<label for="band-names">{{ _('Bands / Line-up') }} <small>{{ _('(eine Band pro Zeile)') }}</small><br>
|
||||
<textarea id="band-names" name="band_names" rows="3" maxlength="4000" placeholder="{{ _('dArtagnan\nSupportband') }}"></textarea>
|
||||
</label>
|
||||
<small>Der Veranstaltungstitel bleibt frei. Diese Namen werden einzeln für „Band folgen“ verwendet.</small>
|
||||
<small>{{ _('Der Veranstaltungstitel bleibt frei. Diese Namen werden einzeln für „Band folgen“ verwendet.') }}</small>
|
||||
</p>
|
||||
|
||||
<!-- ================================================= -->
|
||||
@@ -184,7 +186,7 @@
|
||||
<div class="venue-search">
|
||||
|
||||
<label for="venue-search">
|
||||
Veranstaltungsort
|
||||
{{ _('Veranstaltungsort') }}
|
||||
</label>
|
||||
|
||||
<div class="venue-search-control">
|
||||
@@ -193,7 +195,7 @@
|
||||
id="venue-search"
|
||||
name="venue_name"
|
||||
autocomplete="off"
|
||||
placeholder="z. B. Turbinenhalle"
|
||||
placeholder="{{ _('z. B. Turbinenhalle') }}"
|
||||
required
|
||||
>
|
||||
|
||||
@@ -210,12 +212,12 @@
|
||||
></div>
|
||||
|
||||
<div class="venue-manual-details">
|
||||
<small>Optional für eine bessere Zuordnung:</small>
|
||||
<small>{{ _('Optional für eine bessere Zuordnung:') }}</small>
|
||||
<div class="venue-address-grid">
|
||||
<input type="text" name="street" id="venue-street" placeholder="Straße">
|
||||
<input type="text" name="postal_code" id="venue-postal-code" placeholder="PLZ">
|
||||
<input type="text" name="city" id="venue-city" placeholder="Ort / Stadt">
|
||||
<input type="text" name="country" id="venue-country" placeholder="Land">
|
||||
<input type="text" name="street" id="venue-street" placeholder="{{ _('Straße') }}">
|
||||
<input type="text" name="postal_code" id="venue-postal-code" placeholder="{{ _('PLZ') }}">
|
||||
<input type="text" name="city" id="venue-city" placeholder="{{ _('Ort / Stadt') }}">
|
||||
<input type="text" name="country" id="venue-country" placeholder="{{ _('Land') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +250,7 @@
|
||||
<p>
|
||||
|
||||
<label>
|
||||
Datum und Uhrzeit<br>
|
||||
{{ _('Datum und Uhrzeit') }}<br>
|
||||
|
||||
<input
|
||||
type="datetime-local"
|
||||
@@ -266,7 +268,7 @@
|
||||
|
||||
<p id="festival-end-field" hidden>
|
||||
<label>
|
||||
Enddatum und Uhrzeit<br>
|
||||
{{ _('Enddatum und Uhrzeit') }}<br>
|
||||
<input type="datetime-local" name="end_datetime" id="festival-end-datetime">
|
||||
</label>
|
||||
</p>
|
||||
@@ -279,12 +281,12 @@
|
||||
<p>
|
||||
|
||||
<label>
|
||||
Beschreibung<br>
|
||||
{{ _('Beschreibung') }}<br>
|
||||
|
||||
<textarea
|
||||
name="description"
|
||||
rows="5"
|
||||
placeholder="Optional: Infos zur Veranstaltung, Tour, Supportbands usw."
|
||||
placeholder="{{ _('Optional: Infos zur Veranstaltung, Tour, Supportbands usw.') }}"
|
||||
></textarea>
|
||||
|
||||
</label>
|
||||
@@ -299,7 +301,7 @@
|
||||
<p>
|
||||
|
||||
<label>
|
||||
Ticket-Link<br>
|
||||
{{ _('Ticket-Link') }}<br>
|
||||
|
||||
<input
|
||||
type="url"
|
||||
@@ -319,14 +321,14 @@
|
||||
<p>
|
||||
|
||||
<label>
|
||||
Ticketpreis<br>
|
||||
{{ _('Ticketpreis') }}<br>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
name="ticket_price"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="z. B. 59.90"
|
||||
placeholder="{{ _('z. B. 59.90') }}"
|
||||
>
|
||||
|
||||
</label>
|
||||
@@ -340,13 +342,13 @@
|
||||
|
||||
<div class="flyer-upload">
|
||||
|
||||
<label for="flyer-url">🔗 Flyer extern verlinken <small>(bevorzugt)</small></label>
|
||||
<label for="flyer-url">{{ _('🔗 Flyer extern verlinken') }} <small>{{ _('(bevorzugt)') }}</small></label>
|
||||
<input type="url" id="flyer-url" name="flyer_url" placeholder="https://veranstalter.example/flyer.jpg">
|
||||
<div class="flyer-help">Bitte möglichst den offiziellen Flyer des Veranstalters oder der Band verlinken. Das Bild wird nicht auf MetalCircle kopiert; der Link wird als Quelle ausgewiesen.</div>
|
||||
<div class="flyer-help">{{ _('Bitte möglichst den offiziellen Flyer des Veranstalters oder der Band verlinken. Das Bild wird nicht auf MetalCircle kopiert; der Link wird als Quelle ausgewiesen.') }}</div>
|
||||
|
||||
<label for="flyer">
|
||||
|
||||
🎨 Veranstaltungs-Flyer
|
||||
{{ _('🎨 Veranstaltungs-Flyer') }}
|
||||
|
||||
</label>
|
||||
|
||||
@@ -361,7 +363,7 @@
|
||||
|
||||
<div class="flyer-help">
|
||||
|
||||
Alternative, falls kein offizieller externer Link verfügbar ist: JPG, PNG oder WEBP · maximal 10 MB. Ist ein Link angegeben, wird er bevorzugt dargestellt.
|
||||
{{ _('Alternative, falls kein offizieller externer Link verfügbar ist: JPG, PNG oder WEBP · maximal 10 MB. Ist ein Link angegeben, wird er bevorzugt dargestellt.') }}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -374,7 +376,7 @@
|
||||
<img
|
||||
id="flyer-preview"
|
||||
class="flyer-preview"
|
||||
alt="Flyer Vorschau"
|
||||
alt="{{ _('Flyer Vorschau') }}"
|
||||
>
|
||||
|
||||
<div
|
||||
@@ -397,7 +399,7 @@
|
||||
type="submit"
|
||||
class="button"
|
||||
>
|
||||
💾 Veranstaltung speichern
|
||||
{{ _('💾 Veranstaltung speichern') }}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -405,7 +407,7 @@
|
||||
href="/"
|
||||
class="button button-secondary"
|
||||
>
|
||||
Abbrechen
|
||||
{{ _('Abbrechen') }}
|
||||
</a>
|
||||
|
||||
</div>
|
||||
@@ -663,8 +665,8 @@ function appendManualVenueResult(query, isOnlyResult) {
|
||||
result.type = "button";
|
||||
result.className = "venue-result venue-result-manual";
|
||||
result.innerHTML = `
|
||||
<strong>✏️ „${escapeHtml(query)}“ verwenden</strong>
|
||||
<span>${isOnlyResult ? "Keine passenden Treffer – Eingabe trotzdem speichern" : "Als eigenen Veranstaltungsort anlegen"}</span>
|
||||
<strong>${escapeHtml({{ _('✏️ „{name}“ verwenden') | tojson }}.replace('{name}', query))}</strong>
|
||||
<span>${isOnlyResult ? {{ _('Keine passenden Treffer – Eingabe trotzdem speichern') | tojson }} : {{ _('Als eigenen Veranstaltungsort anlegen') | tojson }}}</span>
|
||||
`;
|
||||
result.addEventListener("click", () => {
|
||||
selectVenue({
|
||||
@@ -839,7 +841,7 @@ flyerInput.addEventListener(
|
||||
) {
|
||||
|
||||
alert(
|
||||
"Bitte JPG, PNG oder WEBP auswählen."
|
||||
{{ _('Bitte JPG, PNG oder WEBP auswählen.') | tojson }}
|
||||
);
|
||||
|
||||
|
||||
@@ -857,7 +859,7 @@ flyerInput.addEventListener(
|
||||
) {
|
||||
|
||||
alert(
|
||||
"Der Flyer darf maximal 10 MB groß sein."
|
||||
{{ _('Der Flyer darf maximal 10 MB groß sein.') | tojson }}
|
||||
);
|
||||
|
||||
|
||||
@@ -916,7 +918,7 @@ function showDuplicateMatches(matches) {
|
||||
return;
|
||||
}
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = "⚠️ Möglicherweise existiert diese Veranstaltung bereits:";
|
||||
heading.textContent = {{ _('⚠️ Möglicherweise existiert diese Veranstaltung bereits:') | tojson }};
|
||||
const list = document.createElement("ul");
|
||||
matches.forEach(match => {
|
||||
const item = document.createElement("li");
|
||||
@@ -990,7 +992,7 @@ concertForm.addEventListener("submit", async event => {
|
||||
const matches = await checkForDuplicates(true);
|
||||
if (matches.length && duplicateConfirmed.value !== "true") {
|
||||
const proceed = window.confirm(
|
||||
"Am selben Tag gibt es bereits eine Veranstaltung mit einem sehr ähnlichen Bandnamen. Trotzdem speichern?"
|
||||
{{ _('Am selben Tag gibt es bereits eine Veranstaltung mit einem sehr ähnlichen Bandnamen. Trotzdem speichern?') | tojson }}
|
||||
);
|
||||
if (!proceed) return;
|
||||
duplicateConfirmed.value = "true";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Passwort zurücksetzen · MetalCircle</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"></head><body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div></header><main><div class="page-title"><h1>Passwort zurücksetzen</h1></div><section class="empty">
|
||||
{% if error %}<p role="alert">{{ error }}</p>{% endif %}<form method="post" action="/password-reset/{{ token }}"><label>Neues Passwort<br><input type="password" name="password" minlength="10" required autocomplete="new-password"></label><br><label>Passwort wiederholen<br><input type="password" name="password_repeat" minlength="10" required autocomplete="new-password"></label><br><button class="button" type="submit">Passwort speichern</button></form>
|
||||
<!DOCTYPE html><html lang="{{ language() }}"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ _('Passwort zurücksetzen · MetalCircle') }}</title><link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png"><link rel="stylesheet" href="/static/css/style.css"><link rel="stylesheet" href="/static/css/language.css">
|
||||
</head><body>
|
||||
<header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a></div> {% include '_language_switch.html' %}
|
||||
</header><main><div class="page-title"><h1>{{ _('Passwort zurücksetzen') }}</h1></div><section class="empty">
|
||||
{% if error %}<p role="alert">{{ error | t }}</p>{% endif %}<form method="post" action="/password-reset/{{ token }}"><label>{{ _('Neues Passwort') }}<br><input type="password" name="password" minlength="10" required autocomplete="new-password"></label><br><label>{{ _('Passwort wiederholen') }}<br><input type="password" name="password_repeat" minlength="10" required autocomplete="new-password"></label><br><button class="button" type="submit">{{ _('Passwort speichern') }}</button></form>
|
||||
</section></main></body></html>
|
||||
|
||||
+61
-59
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -70,6 +70,7 @@
|
||||
.connection-actions .button { padding: 7px 10px; font-size: .85rem; }
|
||||
@media (max-width: 720px) { .profile-layout { grid-template-columns: 1fr; } .kutte { padding-left: 20px; padding-right: 20px; } .account-actions { flex-direction:column; } .data-export { text-align:left; } }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -77,81 +78,82 @@
|
||||
<a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>
|
||||
{% include '_user_menu.html' %}
|
||||
</div>
|
||||
{% include '_language_switch.html' %}
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-title">
|
||||
<h1>Profil</h1>
|
||||
<p>Deine Konzertgeschichte und deine Kutte.</p>
|
||||
<h1>{{ _('Profil') }}</h1>
|
||||
<p>{{ _('Deine Konzertgeschichte und deine Kutte.') }}</p>
|
||||
</div>
|
||||
<div class="profile-layout">
|
||||
<section class="profile-card">
|
||||
{% if profile.avatar_path %}
|
||||
<img class="avatar" src="{{ profile.avatar_path }}" alt="Profilbild von {{ profile.display_name }}">
|
||||
<img class="avatar" src="{{ profile.avatar_path }}" alt="{{ _('Profilbild von') }} {{ profile.display_name }}">
|
||||
{% else %}
|
||||
<div class="avatar-fallback" aria-label="Kein Profilbild">{{ profile.display_name[:1] }}</div>
|
||||
<div class="avatar-fallback" aria-label="{{ _('Kein Profilbild') }}">{{ profile.display_name[:1] }}</div>
|
||||
{% endif %}
|
||||
<h2 class="profile-name">{{ profile.display_name }}</h2>
|
||||
<p class="profile-handle">@{{ profile.username }}</p>
|
||||
{% if can_view_details %}<p class="profile-since">Dabei seit {{ profile.created_at }}</p>{% endif %}
|
||||
{% if can_view_details %}<p class="profile-since">{{ _('Dabei seit') }} {{ profile.created_at }}</p>{% endif %}
|
||||
{% if can_view_details and profile.instagram_url %}
|
||||
<a class="instagram-link" href="{{ profile.instagram_url }}" target="_blank" rel="noopener noreferrer">◎ Instagram · @{{ profile.instagram_handle }}</a>
|
||||
{% endif %}
|
||||
<div class="stat"><strong>{{ attended_count }}</strong>besuchte Konzerte</div>
|
||||
<div class="stat"><strong>{{ attended_count }}</strong>{{ _('besuchte Konzerte') }}</div>
|
||||
{% if not is_own_profile %}
|
||||
<div class="friend-actions">
|
||||
{% if not block_status.blocked_by_viewer and not block_status.blocked_viewer %}
|
||||
{% if (user.is_admin or profile.is_admin) and (not friendship or friendship.status != 'accepted') %}
|
||||
<a class="button" href="/messages/{{ profile.username }}">Nachricht senden</a>
|
||||
<a class="button" href="/messages/{{ profile.username }}">{{ _('Nachricht senden') }}</a>
|
||||
{% endif %}
|
||||
{% if not friendship %}
|
||||
<form method="post" action="/users/{{ profile.username }}/friend-request"><button class="button" type="submit">Freundschaft anfragen</button></form>
|
||||
<form method="post" action="/users/{{ profile.username }}/friend-request"><button class="button" type="submit">{{ _('Freundschaft anfragen') }}</button></form>
|
||||
{% elif friendship.status == 'accepted' %}
|
||||
<a class="button" href="/messages/{{ profile.username }}">Nachricht senden</a>
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">Freundschaft beenden</button></form>
|
||||
<a class="button" href="/messages/{{ profile.username }}">{{ _('Nachricht senden') }}</a>
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">{{ _('Freundschaft beenden') }}</button></form>
|
||||
{% elif friendship.addressee_id == user.id %}
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/accept"><button class="button" type="submit">Annehmen</button></form>
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/decline"><button class="button button-secondary" type="submit">Ablehnen</button></form>
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/accept"><button class="button" type="submit">{{ _('Annehmen') }}</button></form>
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/decline"><button class="button button-secondary" type="submit">{{ _('Ablehnen') }}</button></form>
|
||||
{% else %}
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">Anfrage zurückziehen</button></form>
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">{{ _('Anfrage zurückziehen') }}</button></form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if block_status.blocked_by_viewer %}
|
||||
<form method="post" action="/users/{{ profile.username }}/unblock"><button class="button button-secondary" type="submit">Blockierung aufheben</button></form>
|
||||
<form method="post" action="/users/{{ profile.username }}/unblock"><button class="button button-secondary" type="submit">{{ _('Blockierung aufheben') }}</button></form>
|
||||
{% else %}
|
||||
<form method="post" action="/users/{{ profile.username }}/block" onsubmit="return confirm('Diesen Nutzer wirklich blockieren?');"><button class="button button-secondary" type="submit">Nutzer blockieren</button></form>
|
||||
<form method="post" action="/users/{{ profile.username }}/block" onsubmit="return confirm({{ _('Diesen Nutzer wirklich blockieren?') | tojson | forceescape }});"><button class="button button-secondary" type="submit">{{ _('Nutzer blockieren') }}</button></form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if block_status.blocked_viewer and not block_status.blocked_by_viewer %}<p class="private-note">Interaktionen mit diesem Nutzer sind nicht verfügbar.</p>{% endif %}
|
||||
{% if not can_view_details %}<p class="private-note">Weitere Profildetails sind nur für Freunde sichtbar.</p>{% endif %}
|
||||
{% if block_status.blocked_viewer and not block_status.blocked_by_viewer %}<p class="private-note">{{ _('Interaktionen mit diesem Nutzer sind nicht verfügbar.') }}</p>{% endif %}
|
||||
{% if not can_view_details %}<p class="private-note">{{ _('Weitere Profildetails sind nur für Freunde sichtbar.') }}</p>{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
<section class="badges-card">
|
||||
<h2>🥋 Virtuelle Kutte</h2>
|
||||
<p>Deine freigeschalteten Patches. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.</p>
|
||||
<h2>{{ _('🥋 Virtuelle Kutte') }}</h2>
|
||||
<p>{{ _('Deine freigeschalteten Patches. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.') }}</p>
|
||||
<div class="kutte">
|
||||
<div class="patch-grid">
|
||||
{% for badge in badges %}
|
||||
{% if badge.earned %}
|
||||
<button class="patch-button" type="button" onclick="document.getElementById('patch-{{ badge.code }}').showModal()" aria-label="Details zu {{ badge.name }} anzeigen">
|
||||
<span class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} – {{ badge.description }}">
|
||||
<button class="patch-button" type="button" onclick="document.getElementById('patch-{{ badge.code }}').showModal()" aria-label="{{ _('Details zu') }} {{ badge.name | t }} {{ _('anzeigen') }}">
|
||||
<span class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name | t }} – {{ badge.description | t }}">
|
||||
{% if badge.image_path %}
|
||||
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">
|
||||
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name | t }}">
|
||||
{% else %}
|
||||
<span class="patch-icon">{{ badge.icon }}</span>
|
||||
{% endif %}
|
||||
</span></button>
|
||||
<dialog class="patch-dialog" id="patch-{{ badge.code }}">
|
||||
<form method="dialog"><button class="button button-secondary patch-dialog-close" aria-label="Schließen">×</button></form>
|
||||
{% if badge.image_path %}<img class="patch-dialog-preview" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">{% else %}<div class="patch-icon">{{ badge.icon }}</div>{% endif %}
|
||||
<h3>{{ badge.name }}</h3><p>{{ badge.description }}</p>
|
||||
<p><strong>Erhalten am:</strong> {{ badge.awarded_at }}</p>
|
||||
{% if badge.trigger_concert %}<p><strong>Ausgelöst durch:</strong> <a href="/concerts/{{ badge.trigger_concert.id }}">{{ badge.trigger_concert.artist }} · {{ badge.trigger_concert.date }}</a></p>{% else %}<p><strong>Grund:</strong> {{ badge.description }}</p>{% endif %}
|
||||
<form method="dialog"><button class="button button-secondary patch-dialog-close" aria-label="{{ _('Schließen') }}">×</button></form>
|
||||
{% if badge.image_path %}<img class="patch-dialog-preview" src="{{ badge.image_path }}" alt="Patch {{ badge.name | t }}">{% else %}<div class="patch-icon">{{ badge.icon }}</div>{% endif %}
|
||||
<h3>{{ badge.name | t }}</h3><p>{{ badge.description | t }}</p>
|
||||
<p><strong>{{ _('Erhalten am:') }}</strong> {{ badge.awarded_at }}</p>
|
||||
{% if badge.trigger_concert %}<p><strong>{{ _('Ausgelöst durch:') }}</strong> <a href="/concerts/{{ badge.trigger_concert.id }}">{{ badge.trigger_concert.artist }} · {{ badge.trigger_concert.date }}</a></p>{% else %}<p><strong>{{ _('Grund:') }}</strong> {{ badge.description | t }}</p>{% endif %}
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not profile.earned_codes %}
|
||||
<p>Noch keine Patches – dein erstes Konzert wartet!</p>
|
||||
<p>{{ _('Noch keine Patches – dein erstes Konzert wartet!') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,10 +162,10 @@
|
||||
{% if is_own_profile %}
|
||||
<section class="connections">
|
||||
<details class="connections-details" {% if connections.incoming %}open{% endif %}>
|
||||
<summary>🤝 Freunde{% if connections.friends %} · {{ connections.friends|length }}{% endif %}</summary>
|
||||
<summary>{{ _('🤝 Freunde') }}{% if connections.friends %} · {{ connections.friends|length }}{% endif %}</summary>
|
||||
<div class="connections-content">
|
||||
{% if connections.incoming %}
|
||||
<h3>Offene Anfragen</h3>
|
||||
<h3>{{ _('Offene Anfragen') }}</h3>
|
||||
<div class="connection-list">
|
||||
{% for connection in connections.incoming %}
|
||||
<div class="connection-row">
|
||||
@@ -172,15 +174,15 @@
|
||||
<strong>{{ connection.display_name }} <small>@{{ connection.username }}</small></strong>
|
||||
</a>
|
||||
<div class="connection-actions">
|
||||
<form method="post" action="/friendships/{{ connection.id }}/accept"><button class="button" type="submit">Annehmen</button></form>
|
||||
<form method="post" action="/friendships/{{ connection.id }}/decline"><button class="button button-secondary" type="submit">Ablehnen</button></form>
|
||||
<form method="post" action="/friendships/{{ connection.id }}/accept"><button class="button" type="submit">{{ _('Annehmen') }}</button></form>
|
||||
<form method="post" action="/friendships/{{ connection.id }}/decline"><button class="button button-secondary" type="submit">{{ _('Ablehnen') }}</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if connections.friends %}
|
||||
<h3>Deine Freunde</h3>
|
||||
<h3>{{ _('Deine Freunde') }}</h3>
|
||||
<div class="connection-list">
|
||||
{% for connection in connections.friends %}
|
||||
<div class="connection-row">
|
||||
@@ -188,15 +190,15 @@
|
||||
{% if connection.avatar_path %}<img class="connection-avatar" src="{{ connection.avatar_path }}" alt="">{% else %}<span class="connection-avatar">{{ connection.display_name[:1] }}</span>{% endif %}
|
||||
<strong>{{ connection.display_name }} <small>@{{ connection.username }}</small></strong>
|
||||
</a>
|
||||
<div class="connection-actions"><form method="post" action="/friendships/{{ connection.id }}/remove"><button class="button button-secondary" type="submit">Entfernen</button></form></div>
|
||||
<div class="connection-actions"><form method="post" action="/friendships/{{ connection.id }}/remove"><button class="button button-secondary" type="submit">{{ _('Entfernen') }}</button></form></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if connections.outgoing %}<p class="friend-note">{{ connections.outgoing|length }} gesendete Anfrage(n) warten noch auf Antwort.</p>{% endif %}
|
||||
{% if not connections.incoming and not connections.friends and not connections.outgoing %}<p class="friend-note">Noch keine Verbindungen. Finde andere User über die Suche auf der Startseite.</p>{% endif %}
|
||||
{% if connections.outgoing %}<p class="friend-note">{{ connections.outgoing|length }} {{ _('gesendete Anfrage(n) warten noch auf Antwort.') }}</p>{% endif %}
|
||||
{% if not connections.incoming and not connections.friends and not connections.outgoing %}<p class="friend-note">{{ _('Noch keine Verbindungen. Finde andere User über die Suche auf der Startseite.') }}</p>{% endif %}
|
||||
<details class="blocked-users">
|
||||
<summary>Blockierte Nutzer{% if connections.blocked %} ({{ connections.blocked|length }}){% endif %}</summary>
|
||||
<summary>{{ _('Blockierte Nutzer') }}{% if connections.blocked %} ({{ connections.blocked|length }}){% endif %}</summary>
|
||||
{% if connections.blocked %}
|
||||
<div class="connection-list">
|
||||
{% for connection in connections.blocked %}
|
||||
@@ -205,12 +207,12 @@
|
||||
{% if connection.avatar_path %}<img class="connection-avatar" src="{{ connection.avatar_path }}" alt="">{% else %}<span class="connection-avatar">{{ connection.display_name[:1] }}</span>{% endif %}
|
||||
<strong>{{ connection.display_name }} <small>@{{ connection.username }}</small></strong>
|
||||
</a>
|
||||
<div class="connection-actions"><form method="post" action="/users/{{ connection.username }}/unblock"><button class="button button-secondary" type="submit">Freigeben</button></form></div>
|
||||
<div class="connection-actions"><form method="post" action="/users/{{ connection.username }}/unblock"><button class="button button-secondary" type="submit">{{ _('Freigeben') }}</button></form></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="friend-note">Keine blockierten Nutzer.</p>
|
||||
<p class="friend-note">{{ _('Keine blockierten Nutzer.') }}</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
</div>
|
||||
@@ -219,37 +221,37 @@
|
||||
{% endif %}
|
||||
{% if is_own_profile %}
|
||||
<section class="profile-edit">
|
||||
<h2>Profil bearbeiten</h2>
|
||||
{% if form_error %}<p class="profile-error" role="alert">{{ form_error }}</p>{% endif %}
|
||||
<h2>{{ _('Profil bearbeiten') }}</h2>
|
||||
{% if form_error %}<p class="profile-error" role="alert">{{ form_error | t }}</p>{% endif %}
|
||||
{% if form_success %}<p class="profile-success" role="status">{{ form_success }}</p>{% endif %}
|
||||
<form method="post" action="/profile" enctype="multipart/form-data" id="profile-form">
|
||||
<label for="display-name">Anzeigename</label>
|
||||
<label for="display-name">{{ _('Anzeigename') }}</label>
|
||||
<input id="display-name" name="display_name" type="text" value="{{ profile.display_name }}" maxlength="100">
|
||||
<label for="instagram-url">Instagram-Profil <small>(optional)</small></label>
|
||||
<input id="instagram-url" name="instagram_url" type="text" value="{{ instagram_input if instagram_input is not none else (profile.instagram_url or '') }}" placeholder="@name oder https://instagram.com/name" autocomplete="url">
|
||||
{% if profile.instagram_url %}<a class="instagram-current" href="{{ profile.instagram_url }}" target="_blank" rel="noopener noreferrer">Aktuell verknüpft: @{{ profile.instagram_handle }}</a>{% endif %}
|
||||
<label for="instagram-url">{{ _('Instagram-Profil') }} <small>(optional)</small></label>
|
||||
<input id="instagram-url" name="instagram_url" type="text" value="{{ instagram_input if instagram_input is not none else (profile.instagram_url or '') }}" placeholder="{{ _('@name oder https://instagram.com/name') }}" autocomplete="url">
|
||||
{% if profile.instagram_url %}<a class="instagram-current" href="{{ profile.instagram_url }}" target="_blank" rel="noopener noreferrer">{{ _('Aktuell verknüpft: @') }}{{ profile.instagram_handle }}</a>{% endif %}
|
||||
<fieldset>
|
||||
<legend>Wer darf alle Profildetails sehen?</legend>
|
||||
<legend>{{ _('Wer darf alle Profildetails sehen?') }}</legend>
|
||||
<div class="visibility-options">
|
||||
<label><input type="radio" name="profile_visibility" value="public" {% if profile.profile_visibility == 'public' %}checked{% endif %}> <span><strong>Alle User</strong><br><small>Jeder angemeldete User sieht auch Instagram und Beitrittsdatum.</small></span></label>
|
||||
<label><input type="radio" name="profile_visibility" value="friends" {% if profile.profile_visibility == 'friends' %}checked{% endif %}> <span><strong>Nur Freunde</strong><br><small>Username, Profilbild, Konzertanzahl und Kutte bleiben immer sichtbar.</small></span></label>
|
||||
<label><input type="radio" name="profile_visibility" value="nobody" {% if profile.profile_visibility == 'nobody' %}checked{% endif %}> <span><strong>Niemand</strong><br><small>Zusätzliche Details siehst nur du; Admins behalten administrativen Zugriff.</small></span></label>
|
||||
<label><input type="radio" name="profile_visibility" value="public" {% if profile.profile_visibility == 'public' %}checked{% endif %}> <span><strong>{{ _('Alle User') }}</strong><br><small>{{ _('Jeder angemeldete User sieht auch Instagram und Beitrittsdatum.') }}</small></span></label>
|
||||
<label><input type="radio" name="profile_visibility" value="friends" {% if profile.profile_visibility == 'friends' %}checked{% endif %}> <span><strong>{{ _('Nur Freunde') }}</strong><br><small>{{ _('Username, Profilbild, Konzertanzahl und Kutte bleiben immer sichtbar.') }}</small></span></label>
|
||||
<label><input type="radio" name="profile_visibility" value="nobody" {% if profile.profile_visibility == 'nobody' %}checked{% endif %}> <span><strong>{{ _('Niemand') }}</strong><br><small>{{ _('Zusätzliche Details siehst nur du; Admins behalten administrativen Zugriff.') }}</small></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<label for="avatar">Profilbild</label>
|
||||
<label for="avatar">{{ _('Profilbild') }}</label>
|
||||
<input id="avatar" name="avatar" type="file" accept="image/jpeg,image/png,image/webp">
|
||||
<span class="avatar-upload-status" id="avatar-upload-status">Große Bilder werden vor dem Upload automatisch optimiert.</span>
|
||||
<button class="button" type="submit">Profil speichern</button>
|
||||
<span class="avatar-upload-status" id="avatar-upload-status">{{ _('Große Bilder werden vor dem Upload automatisch optimiert.') }}</span>
|
||||
<button class="button" type="submit">{{ _('Profil speichern') }}</button>
|
||||
</form>
|
||||
<div class="account-actions">
|
||||
<details class="account-delete">
|
||||
<summary>Account löschen</summary>
|
||||
<p>Diese Aktion löscht dein Konto dauerhaft und kann nicht rückgängig gemacht werden.</p>
|
||||
<form method="post" action="/profile/delete" onsubmit="return confirm('Account wirklich dauerhaft löschen?');">
|
||||
<button type="submit" class="admin-danger">Account dauerhaft löschen</button>
|
||||
<summary>{{ _('Account löschen') }}</summary>
|
||||
<p>{{ _('Diese Aktion löscht dein Konto dauerhaft und kann nicht rückgängig gemacht werden.') }}</p>
|
||||
<form method="post" action="/profile/delete" onsubmit="return confirm({{ _('Account wirklich dauerhaft löschen?') | tojson | forceescape }});">
|
||||
<button type="submit" class="admin-danger">{{ _('Account dauerhaft löschen') }}</button>
|
||||
</form>
|
||||
</details>
|
||||
<p class="data-export"><a href="/profile/export">Meine gespeicherten Daten exportieren (JSON)</a></p>
|
||||
<p class="data-export"><a href="/profile/export">{{ _('Meine gespeicherten Daten exportieren (JSON)') }}</a></p>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -288,7 +290,7 @@ if (profileForm) {
|
||||
profileForm.requestSubmit();
|
||||
} catch (error) {
|
||||
console.error("Avatar optimization failed:", error);
|
||||
status.textContent = "Das Bild konnte nicht verarbeitet werden. Bitte ein kleineres JPG, PNG oder WebP wählen.";
|
||||
status.textContent = {{ _('Das Bild konnte nicht verarbeitet werden. Bitte ein kleineres JPG, PNG oder WebP wählen.') | tojson }};
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>MetalCircle - Registrierung</title>
|
||||
<title>{{ _('MetalCircle - Registrierung') }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||||
|
||||
<style>
|
||||
@@ -28,16 +28,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% include '_language_switch.html' %}
|
||||
|
||||
<p><img src="/static/images/metalcircle-full.png" alt="MetalCircle" style="display:block;max-width:100%;height:auto;margin:0 auto 24px"></p>
|
||||
|
||||
<h2>Einladung angenommen</h2>
|
||||
<h2>{{ _('Einladung angenommen') }}</h2>
|
||||
|
||||
<p>
|
||||
Erstelle deinen Account für MetalCircle.
|
||||
{{ _('Erstelle deinen Account für MetalCircle.') }}
|
||||
</p>
|
||||
|
||||
<form method="post" action="/register">
|
||||
@@ -48,7 +50,7 @@
|
||||
value="{{ token }}"
|
||||
>
|
||||
|
||||
<label>Benutzername</label>
|
||||
<label>{{ _('Benutzername') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
@@ -56,14 +58,14 @@
|
||||
maxlength="50"
|
||||
>
|
||||
|
||||
<label>Anzeigename</label>
|
||||
<label>{{ _('Anzeigename') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="display_name"
|
||||
maxlength="100"
|
||||
>
|
||||
|
||||
<label>E-Mail</label>
|
||||
<label>{{ _('E-Mail') }}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
@@ -71,7 +73,7 @@
|
||||
maxlength="255"
|
||||
>
|
||||
|
||||
<label>Passwort</label>
|
||||
<label>{{ _('Passwort') }}</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
@@ -81,7 +83,7 @@
|
||||
>
|
||||
|
||||
<button type="submit">
|
||||
Account erstellen
|
||||
{{ _('Account erstellen') }}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Run with: python -m unittest discover -s tests (from app/). No database needed."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import main
|
||||
from i18n import ENGLISH, current_language, gettext, safe_return_path, format_time, format_datetime
|
||||
|
||||
|
||||
class TranslationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.token = current_language.set('de')
|
||||
|
||||
def tearDown(self):
|
||||
current_language.reset(self.token)
|
||||
|
||||
def test_time_formats_include_midnight_and_noon(self):
|
||||
for hour, minute, english, german in (
|
||||
(0, 0, '12:00 AM', '00:00'),
|
||||
(0, 5, '12:05 AM', '00:05'),
|
||||
(9, 7, '9:07 AM', '09:07'),
|
||||
(12, 0, '12:00 PM', '12:00'),
|
||||
(20, 30, '8:30 PM', '20:30'),
|
||||
(23, 59, '11:59 PM', '23:59'),
|
||||
):
|
||||
value = datetime(2026, 9, 14, hour, minute)
|
||||
for language, expected in [('en', english), ('de', german)]:
|
||||
with self.subTest(language=language, hour=hour, minute=minute):
|
||||
current_language.set(language)
|
||||
self.assertEqual(format_time(value), expected)
|
||||
self.assertEqual(format_datetime(value), f'14.09.2026 {expected}')
|
||||
|
||||
def test_event_card_uses_selected_time_format(self):
|
||||
start = datetime(2026, 9, 14, 20, 30)
|
||||
row = (1, 'Band', start, None, 'Venue', 'Hagen', 'Deutschland', 'concert', None, 'public', False)
|
||||
for language, expected in [('en', '8:30 PM'), ('de', '20:30')]:
|
||||
current_language.set(language)
|
||||
card = main.serialize_concert_card(row)
|
||||
self.assertEqual(card['time'], expected)
|
||||
self.assertEqual(card['_start_datetime'], start)
|
||||
|
||||
def test_templates_compile_and_catalog_covers_literal_translations(self):
|
||||
for path in Path(main.BASE_DIR, 'templates').glob('*.html'):
|
||||
with self.subTest(template=path.name):
|
||||
main.templates.get_template(path.name)
|
||||
tree = main.templates.parse(path.read_text())
|
||||
from jinja2 import nodes
|
||||
for node in tree.find_all(nodes.Call):
|
||||
if isinstance(node.node, nodes.Name) and node.node.name == '_' and node.args and isinstance(node.args[0], nodes.Const):
|
||||
self.assertIn(node.args[0].value, ENGLISH)
|
||||
|
||||
def test_language_persists_and_can_be_changed_before_login(self):
|
||||
with patch.object(main, 'COOKIE_SECURE', False):
|
||||
client = TestClient(main.app)
|
||||
response = client.get('/login')
|
||||
self.assertIn('<html lang="de">', response.text)
|
||||
self.assertIn('Anmelden', response.text)
|
||||
response = client.get('/language/en?next=/login')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('<html lang="en">', response.text)
|
||||
self.assertIn('Log in', response.text)
|
||||
self.assertNotIn('Anmelden', response.text)
|
||||
self.assertIn('HttpOnly', response.history[0].headers['set-cookie'])
|
||||
self.assertEqual(response.headers['content-language'], 'en')
|
||||
self.assertIn('Cookie', response.headers['vary'])
|
||||
for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]:
|
||||
page = client.get(path)
|
||||
self.assertIn(title, page.text)
|
||||
self.assertIn('aria-label="English" aria-current="true"', page.text)
|
||||
response = client.get('/language/de?next=/login')
|
||||
self.assertIn('Anmelden', response.text)
|
||||
self.assertIn('<html lang="de">', response.text)
|
||||
|
||||
def test_language_validation_and_safe_return(self):
|
||||
with patch.object(main, 'COOKIE_SECURE', False):
|
||||
client = TestClient(main.app)
|
||||
self.assertEqual(client.get('/language/fr').status_code, 400)
|
||||
for target in ['https://evil.example', '//evil.example', '/\\evil.example', '/\nevil', '/language/en']:
|
||||
self.assertEqual(safe_return_path(target), '/')
|
||||
target = '/events/past?q=Iron+Maiden&category=concert'
|
||||
response = client.get('/language/en', params={'next': target}, follow_redirects=False)
|
||||
self.assertEqual(response.headers['location'], target)
|
||||
|
||||
def test_translations_are_request_local(self):
|
||||
def read(language):
|
||||
with patch.object(main, 'COOKIE_SECURE', False):
|
||||
client = TestClient(main.app)
|
||||
client.cookies.set('metalcircle_language', language)
|
||||
return client.get('/login').text
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
languages = ['de', 'en'] * 8
|
||||
for language, page in zip(languages, pool.map(read, languages)):
|
||||
self.assertIn(f'<html lang="{language}">', page)
|
||||
self.assertIn('Log in' if language == 'en' else 'Anmelden', page)
|
||||
self.assertEqual(gettext('Anmelden'), 'Anmelden')
|
||||
|
||||
def test_user_content_is_escaped_and_never_translated(self):
|
||||
current_language.set('en')
|
||||
page = main.templates.get_template('_user_menu.html').render(user={
|
||||
'display_name': '<script>Nachrichten</script>', 'is_admin': True, 'notification_count': 2,
|
||||
})
|
||||
self.assertIn('<script>Nachrichten</script>', page)
|
||||
self.assertIn('My profile', page)
|
||||
self.assertIn('new message(s) or request(s)', page)
|
||||
|
||||
def test_every_page_renders_with_populated_content_in_both_languages(self):
|
||||
from jinja2 import ChainableUndefined
|
||||
import os
|
||||
env = main.templates.overlay(undefined=ChainableUndefined)
|
||||
member = dict(id=1, username='sample', display_name='Nachrichten <test>', email='sample@example.invalid', is_admin=True, notification_count=2, created_at='14.09.2026')
|
||||
event = dict(id=1, concert_id=1, artist='Nachrichten <test>', title='Nachrichten <test>', venue='Venue', city='Hagen', country='Deutschland', date='14.09.2026', time='20:00', event_type='concert', event_type_label='Konzert', visibility='friends', is_past=True, is_verified=True, concert_count=2, name='Venue', rating=4, favorite_song='Nachrichten <test>', notes='Nachrichten <test>', photos=[dict(id=1, path='/photo.jpg')], bands=[])
|
||||
badge = dict(code='founder', name='Gründer', description='Von Anfang an dabei und MetalCircle aufgebaut', earned=True, icon='⚔️', image_path='/patch.jpg')
|
||||
event['badges'] = [badge]
|
||||
event['linked_events'] = []
|
||||
profile = dict(member, attended_concerts=1)
|
||||
context = dict(user=member, users=[member], managed_user=member, concert=event, profile=profile, filters={}, stats={}, connections=dict(friends=[member], incoming=[member], outgoing=[member], blocked=[member]), badges=[badge], patch_groups=[dict(label='⚔️ Spezial-Patches', patches=[badge])], venues=[event, dict(event, id=2)], entries=[event], upcoming_concerts=[event], past_concerts=[event], events=[event], followed_bands=[dict(name='Nachrichten <test>', key='test')], followed_venues=[event], linkable_events=[event], invitable_users=[member], invited_user_ids=[1], attending_users=[member], maybe_users=[member], ticket_seekers=[member], ticket_offers=[member], attending_count=2, maybe_count=1, ticket_seeker_count=1, ticket_offer_count=1, comments=[dict(member, body='Nachrichten <test>')], is_own_profile=True, can_view_details=True, can_edit_title=True, can_edit_details=True, can_manage_access=True, can_delete=True, can_edit=True, can_manage=True, user_results=[member], friend_requests=[member], event_invitations=[dict(event, invited_by='Nachrichten <test>')], conversations=[dict(member, unread_count=1, last_message='Nachrichten <test>')], messages=[dict(body='Nachrichten <test>')])
|
||||
for language in ('de', 'en'):
|
||||
current_language.set(language)
|
||||
for path in Path(main.BASE_DIR, 'templates').glob('*.html'):
|
||||
with self.subTest(language=language, template=path.name):
|
||||
page = env.get_template(path.name).render(**context)
|
||||
if not path.name.startswith('_'):
|
||||
self.assertIn(f'<html lang="{language}">', page)
|
||||
self.assertIn('class="language-switch"', page)
|
||||
if os.environ.get('I18N_RENDER_DIR'):
|
||||
target = Path(os.environ['I18N_RENDER_DIR'], language)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / path.name).write_text(page)
|
||||
|
||||
def test_server_validation_is_localized(self):
|
||||
current_language.set('en')
|
||||
with self.assertRaisesRegex(ValueError, 'Instagram') as error:
|
||||
main.normalize_instagram_url('https://evil.example/test')
|
||||
self.assertIn('Please enter', str(error.exception))
|
||||
response = main.change_language('invalid')
|
||||
self.assertEqual(response.body, b'Invalid language.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user