- 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.
This commit is contained in:
kai
2026-08-26 10:02:52 +02:00
parent caddac9eab
commit 4035176370
7 changed files with 490 additions and 79 deletions
+49
View File
@@ -14,7 +14,16 @@
.role-form, .delete-form { margin: 0; display: flex; align-items: center; gap: 8px; }
.role-form select { padding: 9px; background: #0b0f19; color: var(--text); border: 1px solid var(--border); border-radius: 8px; }
.delete-button { padding: 9px 12px; color: #fecaca; border: 1px solid #7f1d1d; background: #450a0a; border-radius: 8px; cursor: pointer; }
.venue-list { display: grid; gap: 14px; }
.venue-card { padding: 16px; background: #0f1420; border: 1px solid var(--border); border-radius: 10px; }
.venue-fields { display: grid; grid-template-columns: 2fr 2fr .8fr 1.4fr 1.2fr; gap: 8px; }
.venue-fields input, .merge-form select { min-width: 0; width: 100%; }
.venue-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 10px; }
.venue-actions label { display: flex; align-items: center; gap: 6px; }
.venue-aliases { margin-top: 8px; }
.merge-form { display: grid; grid-template-columns: minmax(180px, 1fr) auto; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
@media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } .role-form, .delete-form { width: 100%; } }
@media (max-width: 900px) { .venue-fields { grid-template-columns: 1fr 1fr; } }
</style>
</head>
<body>
@@ -64,6 +73,46 @@
{% endfor %}
</div>
</section>
<section class="admin-section" id="venues">
<h2>Veranstaltungsorte</h2>
<p>Namen und Adressen korrigieren, Such-Aliase ergänzen oder Dubletten zusammenführen.</p>
<div class="venue-list">
{% for venue in venues %}
<div class="venue-card">
<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">
</div>
<input class="venue-aliases" name="aliases" value="{{ venue.aliases }}" placeholder="Aliase, durch Komma getrennt" aria-label="Aliase">
<div class="venue-actions">
<label><input type="checkbox" name="is_verified" {% if venue.is_verified %}checked{% endif %}> bestätigt</label>
<span class="user-meta">Quelle: {{ venue.source }} · {{ venue.concert_count }} Konzert(e) · ID {{ venue.id }}</span>
<button class="button" type="submit">Ort speichern</button>
</div>
</form>
{% if venues|length > 1 %}
<form class="merge-form" method="post" action="/admin/venues/{{ venue.id }}/merge" onsubmit="return confirm('Dieser Ort wird gelöscht und alle Konzerte werden dem Zielort zugeordnet. Fortfahren?');">
<select name="target_venue_id" required aria-label="Zielort">
<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="delete-button" type="submit">Zusammenführen</button>
</form>
{% endif %}
</div>
{% else %}
<p>Noch keine Veranstaltungsorte vorhanden.</p>
{% endfor %}
</div>
</section>
</main>
</body>
</html>
+5 -1
View File
@@ -41,7 +41,11 @@
<input type="text" name="city" value="{{ concert.venue.city or '' }}" placeholder="Ort">
</label>
</p>
<input type="hidden" name="country" value="{{ concert.venue.country or 'Deutschland' }}">
<p>
<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>
+42 -61
View File
@@ -138,8 +138,10 @@
<input
type="text"
id="venue-search"
name="venue_name"
autocomplete="off"
placeholder="z. B. Turbinenhalle"
required
>
<div
@@ -153,6 +155,16 @@
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"
@@ -160,36 +172,6 @@
id="venue-id"
>
<input
type="hidden"
name="venue_name"
id="venue-name"
>
<input
type="hidden"
name="city"
id="venue-city"
>
<input
type="hidden"
name="street"
id="venue-street"
>
<input
type="hidden"
name="postal_code"
id="venue-postal-code"
>
<input
type="hidden"
name="country"
id="venue-country"
>
<input
type="hidden"
name="latitude"
@@ -396,7 +378,6 @@ function clearVenueSelection() {
[
"venue-id",
"venue-name",
"venue-city",
"venue-street",
"venue-postal-code",
@@ -433,7 +414,6 @@ venueSearch.addEventListener(
clearVenueSelection();
if (query.length < 2) {
return;
@@ -482,7 +462,7 @@ async function searchVenues(
if (!response.ok) {
appendManualVenueResult(query, true);
return;
}
@@ -496,28 +476,6 @@ async function searchVenues(
"";
if (!venues.length) {
const empty =
document.createElement(
"div"
);
empty.className =
"venue-result";
empty.textContent =
"Keine Veranstaltungsorte gefunden";
venueResults.appendChild(
empty
);
return;
}
venues.forEach(
venue => {
@@ -583,6 +541,8 @@ async function searchVenues(
}
);
appendManualVenueResult(query, venues.length === 0);
} catch (error) {
@@ -595,11 +555,38 @@ async function searchVenues(
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
============================================================ */
@@ -614,12 +601,6 @@ function selectVenue(
venue.id ?? "";
document.getElementById(
"venue-name"
).value =
venue.name ?? "";
document.getElementById(
"venue-city"
).value =