b2af866cf0
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.
1044 lines
27 KiB
HTML
1044 lines
27 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="{{ language() }}">
|
||
|
||
<head>
|
||
|
||
<meta charset="UTF-8">
|
||
|
||
<meta
|
||
name="viewport"
|
||
content="width=device-width, initial-scale=1.0"
|
||
>
|
||
|
||
<title>{{ _('Veranstaltung hinzufügen · MetalCircle') }}</title>
|
||
<link rel="icon" type="image/png" href="/static/images/metalcircle-circle.png">
|
||
|
||
<link
|
||
rel="stylesheet"
|
||
href="/static/css/style.css"
|
||
>
|
||
|
||
<style>
|
||
|
||
.flyer-upload {
|
||
margin-top: 25px;
|
||
}
|
||
|
||
.flyer-upload label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.flyer-help {
|
||
color: #8b949e;
|
||
font-size: 0.9rem;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.flyer-preview-container {
|
||
margin-top: 15px;
|
||
display: none;
|
||
}
|
||
|
||
.flyer-preview {
|
||
max-width: 300px;
|
||
max-height: 400px;
|
||
display: block;
|
||
border-radius: 10px;
|
||
border: 1px solid #30363d;
|
||
}
|
||
|
||
.flyer-preview-name {
|
||
margin-top: 8px;
|
||
color: #8b949e;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.selected-venue {
|
||
margin-top: 10px;
|
||
padding: 12px;
|
||
background: #0d1117;
|
||
border: 1px solid #30363d;
|
||
border-radius: 8px;
|
||
color: #b8c1cc;
|
||
display: none;
|
||
}
|
||
|
||
.duplicate-warning { display:none; margin:10px 0; padding:12px 14px; color:#fde68a; background:#422006; border:1px solid #d97706; border-radius:9px; }
|
||
.duplicate-warning strong { display:block; margin-bottom:6px; }
|
||
.duplicate-warning ul { margin:6px 0 0; padding-left:20px; }
|
||
.duplicate-warning a { color:#fef3c7; text-decoration:underline; }
|
||
|
||
</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>{{ _('🎤 Veranstaltung hinzufügen') }}</h1>
|
||
|
||
<p>
|
||
{{ _('Trag ein, worauf wir uns freuen.') }}
|
||
</p>
|
||
|
||
</div>
|
||
|
||
|
||
<section class="empty">
|
||
|
||
<form
|
||
method="post"
|
||
action="/concerts"
|
||
enctype="multipart/form-data"
|
||
>
|
||
|
||
<p>
|
||
<label for="event-type">{{ _('Kategorie') }}</label><br>
|
||
<select name="event_type" id="event-type" required>
|
||
<option value="concert">{{ _('Konzert') }}</option>
|
||
<option value="festival">Festival</option>
|
||
<option value="other">{{ _('Sonstiges') }}</option>
|
||
</select>
|
||
</p>
|
||
|
||
<p id="parent-event-field" hidden>
|
||
<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>
|
||
{% for event in linkable_events %}
|
||
<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>
|
||
</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>
|
||
</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>
|
||
{% 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 %}
|
||
</fieldset>
|
||
|
||
<!-- ================================================= -->
|
||
<!-- Künstler -->
|
||
<!-- ================================================= -->
|
||
|
||
<p>
|
||
|
||
<label>
|
||
{{ _('Titel / Künstler / Band') }}<br>
|
||
|
||
<input
|
||
type="text"
|
||
name="artist"
|
||
id="artist"
|
||
required
|
||
minlength="2"
|
||
maxlength="255"
|
||
placeholder="{{ _('z. B. Iron Maiden') }}"
|
||
>
|
||
|
||
</label>
|
||
|
||
</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\nSupportband') }}"></textarea>
|
||
</label>
|
||
<small>{{ _('Der Veranstaltungstitel bleibt frei. Diese Namen werden einzeln für „Band folgen“ verwendet.') }}</small>
|
||
</p>
|
||
|
||
<!-- ================================================= -->
|
||
<!-- Veranstaltungsort -->
|
||
<!-- ================================================= -->
|
||
|
||
<div class="venue-search">
|
||
|
||
<label for="venue-search">
|
||
{{ _('Veranstaltungsort') }}
|
||
</label>
|
||
|
||
<div class="venue-search-control">
|
||
<input
|
||
type="text"
|
||
id="venue-search"
|
||
name="venue_name"
|
||
autocomplete="off"
|
||
placeholder="{{ _('z. B. Turbinenhalle') }}"
|
||
required
|
||
>
|
||
|
||
<div
|
||
id="venue-results"
|
||
class="venue-results"
|
||
></div>
|
||
</div>
|
||
|
||
|
||
<div
|
||
id="selected-venue"
|
||
class="selected-venue"
|
||
></div>
|
||
|
||
<div class="venue-manual-details">
|
||
<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') }}">
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<input
|
||
type="hidden"
|
||
name="venue_id"
|
||
id="venue-id"
|
||
>
|
||
|
||
<input
|
||
type="hidden"
|
||
name="latitude"
|
||
id="venue-latitude"
|
||
>
|
||
|
||
<input
|
||
type="hidden"
|
||
name="longitude"
|
||
id="venue-longitude"
|
||
>
|
||
|
||
</div>
|
||
|
||
|
||
<!-- ================================================= -->
|
||
<!-- Datum -->
|
||
<!-- ================================================= -->
|
||
|
||
<p>
|
||
|
||
<label>
|
||
{{ _('Datum und Uhrzeit') }}<br>
|
||
|
||
<input
|
||
type="datetime-local"
|
||
name="start_datetime"
|
||
id="start-datetime"
|
||
required
|
||
>
|
||
|
||
</label>
|
||
|
||
</p>
|
||
|
||
<div id="duplicate-warning" class="duplicate-warning" role="status" aria-live="polite"></div>
|
||
<input type="hidden" name="duplicate_confirmed" id="duplicate-confirmed" value="false">
|
||
|
||
<p id="festival-end-field" hidden>
|
||
<label>
|
||
{{ _('Enddatum und Uhrzeit') }}<br>
|
||
<input type="datetime-local" name="end_datetime" id="festival-end-datetime">
|
||
</label>
|
||
</p>
|
||
|
||
|
||
<!-- ================================================= -->
|
||
<!-- Beschreibung -->
|
||
<!-- ================================================= -->
|
||
|
||
<p>
|
||
|
||
<label>
|
||
{{ _('Beschreibung') }}<br>
|
||
|
||
<textarea
|
||
name="description"
|
||
rows="5"
|
||
placeholder="{{ _('Optional: Infos zur Veranstaltung, Tour, Supportbands usw.') }}"
|
||
></textarea>
|
||
|
||
</label>
|
||
|
||
</p>
|
||
|
||
|
||
<!-- ================================================= -->
|
||
<!-- Ticket-Link -->
|
||
<!-- ================================================= -->
|
||
|
||
<p>
|
||
|
||
<label>
|
||
{{ _('Ticket-Link') }}<br>
|
||
|
||
<input
|
||
type="url"
|
||
name="ticket_url"
|
||
placeholder="https://..."
|
||
>
|
||
|
||
</label>
|
||
|
||
</p>
|
||
|
||
|
||
<!-- ================================================= -->
|
||
<!-- Ticketpreis -->
|
||
<!-- ================================================= -->
|
||
|
||
<p>
|
||
|
||
<label>
|
||
{{ _('Ticketpreis') }}<br>
|
||
|
||
<input
|
||
type="number"
|
||
name="ticket_price"
|
||
step="0.01"
|
||
min="0"
|
||
placeholder="{{ _('z. B. 59.90') }}"
|
||
>
|
||
|
||
</label>
|
||
|
||
</p>
|
||
|
||
|
||
<!-- ================================================= -->
|
||
<!-- Flyer -->
|
||
<!-- ================================================= -->
|
||
|
||
<div class="flyer-upload">
|
||
|
||
<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>
|
||
|
||
<label for="flyer">
|
||
|
||
{{ _('🎨 Veranstaltungs-Flyer') }}
|
||
|
||
</label>
|
||
|
||
|
||
<input
|
||
type="file"
|
||
id="flyer"
|
||
name="flyer"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
>
|
||
|
||
|
||
<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.') }}
|
||
|
||
</div>
|
||
|
||
|
||
<div
|
||
id="flyer-preview-container"
|
||
class="flyer-preview-container"
|
||
>
|
||
|
||
<img
|
||
id="flyer-preview"
|
||
class="flyer-preview"
|
||
alt="{{ _('Flyer Vorschau') }}"
|
||
>
|
||
|
||
<div
|
||
id="flyer-preview-name"
|
||
class="flyer-preview-name"
|
||
></div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
|
||
<!-- ================================================= -->
|
||
<!-- Buttons -->
|
||
<!-- ================================================= -->
|
||
|
||
<div style="margin-top: 30px;">
|
||
|
||
<button
|
||
type="submit"
|
||
class="button"
|
||
>
|
||
{{ _('💾 Veranstaltung speichern') }}
|
||
</button>
|
||
|
||
|
||
<a
|
||
href="/"
|
||
class="button button-secondary"
|
||
>
|
||
{{ _('Abbrechen') }}
|
||
</a>
|
||
|
||
</div>
|
||
|
||
</form>
|
||
|
||
</section>
|
||
|
||
</main>
|
||
|
||
|
||
<script>
|
||
|
||
const eventType = document.getElementById("event-type");
|
||
const parentEventField = document.getElementById("parent-event-field");
|
||
const parentEventId = document.getElementById("parent-event-id");
|
||
const festivalEndField = document.getElementById("festival-end-field");
|
||
const festivalEndDatetime = document.getElementById("festival-end-datetime");
|
||
const eventVisibilityField = document.getElementById("event-visibility-field");
|
||
const eventInvitationsField = document.getElementById("event-invitations-field");
|
||
const visibilityInputs = [...document.querySelectorAll('input[name="visibility"]')];
|
||
|
||
function updateEventFields() {
|
||
const isFestival = eventType.value === "festival";
|
||
const isOther = eventType.value === "other";
|
||
festivalEndField.hidden = !isFestival;
|
||
festivalEndDatetime.required = isFestival;
|
||
parentEventField.hidden = !isOther;
|
||
eventVisibilityField.hidden = !isOther;
|
||
const isPrivate = isOther && document.querySelector('input[name="visibility"]:checked').value === "private";
|
||
eventInvitationsField.hidden = !isPrivate;
|
||
if (!isFestival) festivalEndDatetime.value = "";
|
||
if (!isOther) parentEventId.value = "";
|
||
}
|
||
|
||
eventType.addEventListener("change", updateEventFields);
|
||
visibilityInputs.forEach(input => input.addEventListener("change", updateEventFields));
|
||
updateEventFields();
|
||
|
||
/* ============================================================
|
||
VENUE SEARCH
|
||
============================================================ */
|
||
|
||
const venueSearch =
|
||
document.getElementById(
|
||
"venue-search"
|
||
);
|
||
|
||
const venueResults =
|
||
document.getElementById(
|
||
"venue-results"
|
||
);
|
||
|
||
const selectedVenue =
|
||
document.getElementById(
|
||
"selected-venue"
|
||
);
|
||
|
||
|
||
let searchTimeout = null;
|
||
let venueRequestController = null;
|
||
|
||
|
||
function clearVenueSelection() {
|
||
|
||
[
|
||
"venue-id",
|
||
"venue-city",
|
||
"venue-street",
|
||
"venue-postal-code",
|
||
"venue-country",
|
||
"venue-latitude",
|
||
"venue-longitude"
|
||
].forEach(
|
||
id => {
|
||
document.getElementById(id).value = "";
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
venueSearch.addEventListener(
|
||
"input",
|
||
() => {
|
||
|
||
clearTimeout(
|
||
searchTimeout
|
||
);
|
||
|
||
|
||
const query =
|
||
venueSearch.value.trim();
|
||
|
||
|
||
venueResults.innerHTML = "";
|
||
|
||
|
||
selectedVenue.style.display =
|
||
"none";
|
||
|
||
clearVenueSelection();
|
||
|
||
if (query.length < 2) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
searchTimeout = setTimeout(
|
||
() => {
|
||
|
||
searchVenues(
|
||
query
|
||
);
|
||
|
||
},
|
||
450
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* ============================================================
|
||
VENUE SEARCH REQUEST
|
||
============================================================ */
|
||
|
||
async function searchVenues(
|
||
query
|
||
) {
|
||
|
||
try {
|
||
|
||
if (venueRequestController) {
|
||
venueRequestController.abort();
|
||
}
|
||
|
||
venueRequestController = new AbortController();
|
||
|
||
const response =
|
||
await fetch(
|
||
`/api/venues/search?q=${encodeURIComponent(query)}`,
|
||
{
|
||
signal: venueRequestController.signal
|
||
}
|
||
);
|
||
|
||
|
||
if (!response.ok) {
|
||
appendManualVenueResult(query, true);
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const venues =
|
||
await response.json();
|
||
|
||
|
||
venueResults.innerHTML =
|
||
"";
|
||
|
||
|
||
venues.forEach(
|
||
venue => {
|
||
|
||
const result =
|
||
document.createElement(
|
||
"button"
|
||
);
|
||
|
||
|
||
result.type =
|
||
"button";
|
||
|
||
result.className =
|
||
"venue-result";
|
||
|
||
|
||
const address = [
|
||
|
||
venue.street,
|
||
|
||
venue.postal_code,
|
||
|
||
venue.city
|
||
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
|
||
result.innerHTML = `
|
||
|
||
<strong>
|
||
📍 ${escapeHtml(
|
||
venue.name
|
||
)}
|
||
</strong>
|
||
|
||
<span>
|
||
${escapeHtml(
|
||
address
|
||
)}
|
||
</span>
|
||
|
||
`;
|
||
|
||
|
||
result.addEventListener(
|
||
"click",
|
||
() => {
|
||
|
||
selectVenue(
|
||
venue
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
venueResults.appendChild(
|
||
result
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
appendManualVenueResult(query, venues.length === 0);
|
||
|
||
|
||
} catch (error) {
|
||
|
||
if (error.name === "AbortError") {
|
||
return;
|
||
}
|
||
|
||
console.error(
|
||
"Venue search failed:",
|
||
error
|
||
);
|
||
|
||
venueResults.innerHTML = "";
|
||
appendManualVenueResult(query, true);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
function appendManualVenueResult(query, isOnlyResult) {
|
||
const result = document.createElement("button");
|
||
result.type = "button";
|
||
result.className = "venue-result venue-result-manual";
|
||
result.innerHTML = `
|
||
<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({
|
||
id: "",
|
||
name: query,
|
||
street: document.getElementById("venue-street").value,
|
||
postal_code: document.getElementById("venue-postal-code").value,
|
||
city: document.getElementById("venue-city").value,
|
||
country: document.getElementById("venue-country").value,
|
||
latitude: "",
|
||
longitude: ""
|
||
});
|
||
});
|
||
venueResults.appendChild(result);
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
VENUE SELECT
|
||
============================================================ */
|
||
|
||
function selectVenue(
|
||
venue
|
||
) {
|
||
|
||
document.getElementById(
|
||
"venue-id"
|
||
).value =
|
||
venue.id ?? "";
|
||
|
||
|
||
document.getElementById(
|
||
"venue-city"
|
||
).value =
|
||
venue.city ?? "";
|
||
|
||
|
||
document.getElementById(
|
||
"venue-street"
|
||
).value =
|
||
venue.street ?? "";
|
||
|
||
|
||
document.getElementById(
|
||
"venue-postal-code"
|
||
).value =
|
||
venue.postal_code ?? "";
|
||
|
||
|
||
document.getElementById(
|
||
"venue-country"
|
||
).value =
|
||
venue.country ?? "";
|
||
|
||
|
||
document.getElementById(
|
||
"venue-latitude"
|
||
).value =
|
||
venue.latitude ?? "";
|
||
|
||
|
||
document.getElementById(
|
||
"venue-longitude"
|
||
).value =
|
||
venue.longitude ?? "";
|
||
|
||
|
||
venueSearch.value =
|
||
venue.name;
|
||
|
||
|
||
venueResults.innerHTML =
|
||
"";
|
||
|
||
|
||
const address = [
|
||
|
||
venue.street,
|
||
|
||
venue.postal_code,
|
||
|
||
venue.city
|
||
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
|
||
selectedVenue.innerHTML = `
|
||
|
||
<strong>
|
||
📍 ${escapeHtml(
|
||
venue.name
|
||
)}
|
||
</strong>
|
||
|
||
${
|
||
address
|
||
? `<br>${escapeHtml(address)}`
|
||
: ""
|
||
}
|
||
|
||
`;
|
||
|
||
|
||
selectedVenue.style.display =
|
||
"block";
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
FLYER PREVIEW
|
||
============================================================ */
|
||
|
||
const flyerInput =
|
||
document.getElementById(
|
||
"flyer"
|
||
);
|
||
|
||
|
||
const flyerPreviewContainer =
|
||
document.getElementById(
|
||
"flyer-preview-container"
|
||
);
|
||
|
||
|
||
const flyerPreview =
|
||
document.getElementById(
|
||
"flyer-preview"
|
||
);
|
||
|
||
|
||
const flyerPreviewName =
|
||
document.getElementById(
|
||
"flyer-preview-name"
|
||
);
|
||
|
||
|
||
flyerInput.addEventListener(
|
||
"change",
|
||
() => {
|
||
|
||
const file =
|
||
flyerInput.files[0];
|
||
|
||
|
||
if (!file) {
|
||
|
||
flyerPreviewContainer.style.display =
|
||
"none";
|
||
|
||
flyerPreview.src =
|
||
"";
|
||
|
||
flyerPreviewName.textContent =
|
||
"";
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
![
|
||
"image/jpeg",
|
||
"image/png",
|
||
"image/webp"
|
||
].includes(
|
||
file.type
|
||
)
|
||
) {
|
||
|
||
alert(
|
||
{{ _('Bitte JPG, PNG oder WEBP auswählen.') | tojson }}
|
||
);
|
||
|
||
|
||
flyerInput.value =
|
||
"";
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
file.size >
|
||
10 * 1024 * 1024
|
||
) {
|
||
|
||
alert(
|
||
{{ _('Der Flyer darf maximal 10 MB groß sein.') | tojson }}
|
||
);
|
||
|
||
|
||
flyerInput.value =
|
||
"";
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const reader =
|
||
new FileReader();
|
||
|
||
|
||
reader.onload =
|
||
event => {
|
||
|
||
flyerPreview.src =
|
||
event.target.result;
|
||
|
||
|
||
flyerPreviewName.textContent =
|
||
file.name;
|
||
|
||
|
||
flyerPreviewContainer.style.display =
|
||
"block";
|
||
|
||
};
|
||
|
||
|
||
reader.readAsDataURL(
|
||
file
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
// Große Flyer vor dem Absenden verkleinern. Dadurch bleibt der Multipart-
|
||
// Request unter typischen Proxy-Limits und der Server muss keine riesigen
|
||
// Originaldateien verarbeiten.
|
||
const concertForm = flyerInput.form;
|
||
const artistInput = document.getElementById("artist");
|
||
const bandNamesInput = document.getElementById("band-names");
|
||
const startDatetimeInput = document.getElementById("start-datetime");
|
||
const duplicateWarning = document.getElementById("duplicate-warning");
|
||
const duplicateConfirmed = document.getElementById("duplicate-confirmed");
|
||
let duplicateTimeout = null;
|
||
let duplicateRequestController = null;
|
||
|
||
function showDuplicateMatches(matches) {
|
||
duplicateWarning.replaceChildren();
|
||
if (!matches.length) {
|
||
duplicateWarning.style.display = "none";
|
||
return;
|
||
}
|
||
const heading = document.createElement("strong");
|
||
heading.textContent = {{ _('⚠️ Möglicherweise existiert diese Veranstaltung bereits:') | tojson }};
|
||
const list = document.createElement("ul");
|
||
matches.forEach(match => {
|
||
const item = document.createElement("li");
|
||
const link = document.createElement("a");
|
||
link.href = `/concerts/${match.id}`;
|
||
link.target = "_blank";
|
||
link.rel = "noopener";
|
||
link.textContent = `${match.artist} · ${match.date} ${match.time}${match.venue ? ` · ${match.venue}` : ""}`;
|
||
item.appendChild(link);
|
||
list.appendChild(item);
|
||
});
|
||
duplicateWarning.append(heading, list);
|
||
duplicateWarning.style.display = "block";
|
||
}
|
||
|
||
async function checkForDuplicates(showWarning = true) {
|
||
const artist = artistInput.value.trim();
|
||
const startDate = startDatetimeInput.value;
|
||
if (artist.length < 2 || !startDate) {
|
||
if (showWarning) showDuplicateMatches([]);
|
||
return [];
|
||
}
|
||
if (duplicateRequestController) duplicateRequestController.abort();
|
||
duplicateRequestController = new AbortController();
|
||
try {
|
||
const params = new URLSearchParams({artist, start_date: startDate, band_names: bandNamesInput.value});
|
||
const response = await fetch(`/api/concerts/duplicates?${params}`, {
|
||
signal: duplicateRequestController.signal
|
||
});
|
||
if (!response.ok) return [];
|
||
const matches = (await response.json()).matches || [];
|
||
if (showWarning) showDuplicateMatches(matches);
|
||
return matches;
|
||
} catch (error) {
|
||
if (error.name !== "AbortError") console.error("Duplicate check failed:", error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
[artistInput, bandNamesInput, startDatetimeInput].forEach(input => input.addEventListener("input", () => {
|
||
duplicateConfirmed.value = "false";
|
||
concertForm.dataset.readyToSubmit = "false";
|
||
clearTimeout(duplicateTimeout);
|
||
duplicateTimeout = setTimeout(() => checkForDuplicates(true), 450);
|
||
}));
|
||
|
||
async function optimizeFlyerUpload(file) {
|
||
const bitmap = await createImageBitmap(file);
|
||
const maxSide = 1800;
|
||
const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||
const context = canvas.getContext("2d");
|
||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||
bitmap.close();
|
||
let quality = 0.86;
|
||
let blob;
|
||
do {
|
||
blob = await new Promise(resolve => canvas.toBlob(resolve, "image/jpeg", quality));
|
||
quality -= 0.08;
|
||
} while (blob && blob.size > 900 * 1024 && quality >= 0.5);
|
||
if (!blob) throw new Error("Bild konnte nicht verarbeitet werden");
|
||
return new File([blob], "flyer.jpg", {type: "image/jpeg"});
|
||
}
|
||
|
||
concertForm.addEventListener("submit", async event => {
|
||
if (concertForm.dataset.readyToSubmit === "true") return;
|
||
event.preventDefault();
|
||
|
||
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?') | tojson }}
|
||
);
|
||
if (!proceed) return;
|
||
duplicateConfirmed.value = "true";
|
||
}
|
||
|
||
const file = flyerInput.files[0];
|
||
if (file && concertForm.dataset.flyerOptimized !== "true") {
|
||
try {
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(await optimizeFlyerUpload(file));
|
||
flyerInput.files = transfer.files;
|
||
} catch (error) {
|
||
console.error("Flyer optimization failed:", error);
|
||
}
|
||
concertForm.dataset.flyerOptimized = "true";
|
||
}
|
||
concertForm.dataset.readyToSubmit = "true";
|
||
concertForm.requestSubmit();
|
||
});
|
||
|
||
|
||
/* ============================================================
|
||
HTML ESCAPING
|
||
============================================================ */
|
||
|
||
function escapeHtml(
|
||
value
|
||
) {
|
||
|
||
const div =
|
||
document.createElement(
|
||
"div"
|
||
);
|
||
|
||
|
||
div.textContent =
|
||
value ?? "";
|
||
|
||
|
||
return div.innerHTML;
|
||
|
||
}
|
||
|
||
</script>
|
||
|
||
</body>
|
||
|
||
</html>
|