950 lines
22 KiB
HTML
950 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
|
||
<head>
|
||
|
||
<meta charset="UTF-8">
|
||
|
||
<meta
|
||
name="viewport"
|
||
content="width=device-width, initial-scale=1.0"
|
||
>
|
||
|
||
<title>Veranstaltung hinzufügen · Pingu Concerts</title>
|
||
|
||
<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;
|
||
}
|
||
|
||
</style>
|
||
|
||
</head>
|
||
|
||
|
||
<body>
|
||
|
||
<header>
|
||
|
||
<div class="header-inner">
|
||
|
||
<a href="/" class="logo">
|
||
🎸 Pingu <span>Concerts</span>
|
||
</a>
|
||
|
||
{% include '_user_menu.html' %}
|
||
|
||
</div>
|
||
|
||
</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 }} · {{ 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"
|
||
required
|
||
placeholder="z. B. Iron Maiden"
|
||
>
|
||
|
||
</label>
|
||
|
||
</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"
|
||
required
|
||
>
|
||
|
||
</label>
|
||
|
||
</p>
|
||
|
||
<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 Pingu Concerts 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(query)}“ verwenden</strong>
|
||
<span>${isOnlyResult ? "Keine passenden Treffer – Eingabe trotzdem speichern" : "Als eigenen Veranstaltungsort anlegen"}</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."
|
||
);
|
||
|
||
|
||
flyerInput.value =
|
||
"";
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
file.size >
|
||
10 * 1024 * 1024
|
||
) {
|
||
|
||
alert(
|
||
"Der Flyer darf maximal 10 MB groß sein."
|
||
);
|
||
|
||
|
||
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;
|
||
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.flyerOptimized === "true") return;
|
||
const file = flyerInput.files[0];
|
||
if (!file) return;
|
||
event.preventDefault();
|
||
try {
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(await optimizeFlyerUpload(file));
|
||
flyerInput.files = transfer.files;
|
||
concertForm.dataset.flyerOptimized = "true";
|
||
concertForm.requestSubmit();
|
||
} catch (error) {
|
||
console.error("Flyer optimization failed:", error);
|
||
concertForm.dataset.flyerOptimized = "true";
|
||
concertForm.requestSubmit();
|
||
}
|
||
});
|
||
|
||
|
||
/* ============================================================
|
||
HTML ESCAPING
|
||
============================================================ */
|
||
|
||
function escapeHtml(
|
||
value
|
||
) {
|
||
|
||
const div =
|
||
document.createElement(
|
||
"div"
|
||
);
|
||
|
||
|
||
div.textContent =
|
||
value ?? "";
|
||
|
||
|
||
return div.innerHTML;
|
||
|
||
}
|
||
|
||
</script>
|
||
|
||
</body>
|
||
|
||
</html>
|