Files
pingu-concerts/app/templates/new_concert.html
T
kai 4035176370 - Freitext wird jetzt direkt als venue_name übertragen und somit auch ohne Suchtreffer gespeichert.
- Die Suche ist international und kombiniert lokale Ergebnisse mit Nominatim.
  - Externe IDs werden korrekt als nominatim:<Typ>:<ID> gespeichert und dedupliziert.
  - Straße, PLZ, Stadt und Land können beim Anlegen manuell ergänzt werden.
  - Im Adminbereich können Orte korrigiert, bestätigt, mit Aliasen versehen und zusammengeführt werden.
2026-08-26 10:02:52 +02:00

833 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>Konzert 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>
</div>
</header>
<main>
<div class="page-title">
<h1>🎤 Konzert hinzufügen</h1>
<p>
Trag ein, worauf wir uns freuen.
</p>
</div>
<section class="empty">
<form
method="post"
action="/concerts"
enctype="multipart/form-data"
>
<!-- ================================================= -->
<!-- Künstler -->
<!-- ================================================= -->
<p>
<label>
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>
<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
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>
<!-- ================================================= -->
<!-- Beschreibung -->
<!-- ================================================= -->
<p>
<label>
Beschreibung<br>
<textarea
name="description"
rows="5"
placeholder="Optional: Infos zum Konzert, 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">
🎨 Konzert-Flyer
</label>
<input
type="file"
id="flyer"
name="flyer"
accept="image/jpeg,image/png,image/webp"
>
<div class="flyer-help">
JPG, PNG oder WEBP · maximal 10 MB
</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"
>
💾 Konzert speichern
</button>
<a
href="/"
class="button button-secondary"
>
Abbrechen
</a>
</div>
</form>
</section>
</main>
<script>
/* ============================================================
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
);
}
);
/* ============================================================
HTML ESCAPING
============================================================ */
function escapeHtml(
value
) {
const div =
document.createElement(
"div"
);
div.textContent =
value ?? "";
return div.innerHTML;
}
</script>
</body>
</html>