feat(web+android+backend): Konzerte gefolgter Kuenstler als zweiter Reiter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1366ec3832
commit
17a677a5a5
13 files changed
+682
-14
No files matched your search
+26
-1
@@ -42,6 +42,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
||||
| Besitzer geteilter Playlists | ✅ | ✅ |
|
||||
| Künstler folgen / Album merken | ✅ | ✅ |
|
||||
| Neu von deinen Künstlern | ✅ | ✅ |
|
||||
| Konzerte deiner Künstler | ✅ | ✅ |
|
||||
| Spotify / YouTube importer | ✅ | ✅ |
|
||||
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
||||
| Wiedergabe fortsetzen | ✅ | ✅ |
|
||||
@@ -80,9 +81,33 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
||||
|
||||
## [3.1] — 2026-09-22
|
||||
|
||||
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer.
|
||||
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer, 1.24 die Konzerte.
|
||||
|
||||
### Added
|
||||
- **Konzerte deiner Künstler (Web + Android)** — die Zeitleiste hat einen zweiten Reiter: neben
|
||||
*Neu erschienen* steht jetzt *Konzerte* und zeigt, wann die Künstler spielen, denen du folgst.
|
||||
Neuer Endpunkt `POST /api/artist-concerts`, Gegenstück zu `/api/artist-releases`.
|
||||
- **Zwei Reiter statt zweier Knöpfe** in den Favoriten. Beides ist dasselbe Thema; zwei Knöpfe
|
||||
drängeln sich, und man müsste sich merken, welcher was zeigt.
|
||||
- **Bewusst nicht in eine gemeinsame Liste gemischt.** Veröffentlichungen liegen hinter dir,
|
||||
Konzerte vor dir. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie, ob man
|
||||
vorwärts oder rückwärts liest. Deshalb läuft die Konzertliste **nach vorn**: das nächste
|
||||
Konzert zuerst. Das Zeitfenster (90/30/365) gilt je Reiter getrennt — bei den Konzerten zeigt
|
||||
es nach vorn, Vorgabe ein Jahr.
|
||||
- **Gegenprobe gegen die Stichwortsuche.** Ticketmaster wird über den Namen abgefragt
|
||||
(`keyword=`), nicht über `attractionId` — dieselbe Unschärfe wie bei der Deezer-Zuordnung.
|
||||
Jeder Treffer wird deshalb gegengeprüft: der Künstlername muss im Veranstaltungs- **oder**
|
||||
Attraktionsnamen vorkommen. Ohne das liefert „MORE" oder „TPR" fremde Veranstaltungen.
|
||||
- **Ortszeit, nicht UTC.** Ein Konzert um 20:00 stünde sonst als 08:00 da. Die Zeit wird als
|
||||
zonenlose Ortszeit durchgereicht und unverändert angezeigt.
|
||||
- **Kein Ortsfilter** — die Suche läuft weltweit. Eine Einschränkung per `.env` ist im Backlog
|
||||
notiert; fürs Erste reicht, was die API liefert.
|
||||
- Der Cache liegt im Backend (6 h, höchstens vier Anfragen gleichzeitig), nicht im Client: bei
|
||||
50 gefolgten Künstlern wären das sonst 50 Abfragen pro Gerät und App-Start. Bei einem Fehler
|
||||
kommt der abgelaufene Stand zurück statt einer leeren Liste.
|
||||
- **Neun Prüfungen** in `backend/test/konzerte.test.js` — mit eingeschobener
|
||||
Ticketmaster-Antwort, also ohne Schlüssel und ohne Netz: Ortszeit, Gegenprobe, Fenster,
|
||||
Sortierung nach vorn, Cache-Treffer, abgesagte Termine.
|
||||
- **Zwei neue Designs: Violet und Crimson (Web + Android)** — ein helles Lila auf tiefem
|
||||
Violettschwarz und ein gedämpftes Rot auf Weinrot-Schwarz. Beide mit demselben Variablensatz wie
|
||||
die vorhandenen, geprüft: kein Design vermisst eine Variable.
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 26
|
||||
versionName = "1.23"
|
||||
versionCode = 27
|
||||
versionName = "1.24"
|
||||
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistReleasesRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteResponse
|
||||
import com.novasonic.app.api.model.ArtistReleasesResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
@@ -36,4 +38,13 @@ object ReleasesClient {
|
||||
setBody(ArtistReleasesRequest(artists, days))
|
||||
}.body()
|
||||
}
|
||||
|
||||
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
|
||||
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistKonzerteResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistKonzerteRequest(artists, days))
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,31 @@ data class ArtistRef(val id: String, val name: String)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
|
||||
|
||||
/** Ein anstehendes Konzert eines gefolgten Künstlers. */
|
||||
@Serializable
|
||||
data class ArtistKonzert(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val eventId: String = "",
|
||||
val titel: String = "",
|
||||
/** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */
|
||||
val datum: String = "",
|
||||
val ort: String = "",
|
||||
val stadt: String = "",
|
||||
val land: String = "",
|
||||
val url: String = "",
|
||||
val abgesagt: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteResponse(
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
/** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */
|
||||
val ohneTermine: List<String> = emptyList(),
|
||||
/** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */
|
||||
val keinSchluessel: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteRequest(val artists: List<ArtistRef>, val days: Int)
|
||||
@@ -30,8 +30,13 @@ import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistNewsViewModel
|
||||
import com.novasonic.app.viewmodel.NewsReiter
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
/**
|
||||
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
|
||||
@@ -55,19 +60,48 @@ fun ArtistNewsScreen(
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text("Neu von deinen Künstlern", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
val konzerte = state.reiter == NewsReiter.KONZERTE
|
||||
Text(
|
||||
if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe
|
||||
// Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen
|
||||
// hinter, Konzerte vor dem heutigen Tag.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||
) {
|
||||
listOf(
|
||||
NewsReiter.RELEASES to "Neu erschienen",
|
||||
NewsReiter.KONZERTE to "Konzerte",
|
||||
).forEach { (r, label) ->
|
||||
FilterChip(
|
||||
selected = state.reiter == r,
|
||||
onClick = { vm.zeigeReiter(r) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 10.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
|
||||
) {
|
||||
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
|
||||
// Bei den Konzerten zeigt dasselbe Fenster nach VORN, dort sind 1 Jahr die Vorgabe.
|
||||
val aktuell = if (state.reiter == NewsReiter.KONZERTE) state.tageKonzerte else state.tage
|
||||
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
|
||||
FilterChip(
|
||||
selected = state.tage == tage,
|
||||
onClick = { vm.laden(tage) },
|
||||
selected = aktuell == tage,
|
||||
onClick = { vm.setzeTage(tage) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
@@ -87,6 +121,30 @@ fun ArtistNewsScreen(
|
||||
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
|
||||
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
|
||||
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
|
||||
|
||||
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
|
||||
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
|
||||
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
|
||||
"Keine Termine in den nächsten ${state.tageKonzerte} Tagen",
|
||||
"Gesucht wird weltweit über den Künstlernamen.")
|
||||
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
var letzterMonat = ""
|
||||
state.konzerte.forEach { k ->
|
||||
val monat = monatVon(k.datum)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "km_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "k_${k.eventId}_${k.artistId}") {
|
||||
KonzertZeile(k) { onArtistClick(k.artistId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.releases.isEmpty() -> Hinweis("🌙",
|
||||
"Nichts Neues in den letzten ${state.tage} Tagen", "")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
@@ -215,3 +273,76 @@ private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
|
||||
|
||||
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
|
||||
// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00".
|
||||
private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN)
|
||||
private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN)
|
||||
private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN)
|
||||
private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN)
|
||||
|
||||
private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull()
|
||||
|
||||
/**
|
||||
* Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild,
|
||||
* aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft.
|
||||
*/
|
||||
@Composable
|
||||
private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) {
|
||||
val uri = LocalUriHandler.current
|
||||
val d = zeitVon(k.datum)
|
||||
val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp))
|
||||
.alpha(if (k.abgesagt) 0.45f else 1f),
|
||||
) {
|
||||
Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold, fontSize = 17.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit,
|
||||
fontSize = 10.sp, color = NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
k.titel.ifBlank { k.artistName },
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" })
|
||||
if (rest.isNotEmpty()) {
|
||||
Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (k.abgesagt) {
|
||||
Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
} else if (k.url.isNotBlank()) {
|
||||
TextButton(onClick = { uri.openUri(k.url) }) {
|
||||
Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ReleasesClient
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.app
|
||||
@@ -18,8 +19,16 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */
|
||||
enum class NewsReiter { RELEASES, KONZERTE }
|
||||
|
||||
data class ArtistNewsUiState(
|
||||
val reiter: NewsReiter = NewsReiter.RELEASES,
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
/** Zeitfenster der Konzerte — nach VORN, deshalb ein eigener Wert. */
|
||||
val tageKonzerte: Int = 365,
|
||||
val keinSchluessel: Boolean = false,
|
||||
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
|
||||
val vorhanden: Set<String> = emptySet(),
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
@@ -44,9 +53,45 @@ class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
init { laden(90) }
|
||||
|
||||
/** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */
|
||||
fun zeigeReiter(r: NewsReiter) {
|
||||
_state.value = _state.value.copy(reiter = r)
|
||||
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
|
||||
}
|
||||
|
||||
/** Zeitfenster setzen — es gilt je Reiter, weil es in verschiedene Richtungen zeigt. */
|
||||
fun setzeTage(tage: Int) {
|
||||
if (_state.value.reiter == NewsReiter.KONZERTE) ladeKonzerte(tage) else laden(tage)
|
||||
}
|
||||
|
||||
fun ladeKonzerte(tage: Int = _state.value.tageKonzerte) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, tageKonzerte = tage, error = null,
|
||||
keineGefolgten = false, keinBackend = false, keinSchluessel = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler, tage)
|
||||
_state.value = _state.value.copy(
|
||||
konzerte = antwort.konzerte,
|
||||
keinSchluessel = antwort.keinSchluessel,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
|
||||
keineGefolgten = false, keinBackend = false)
|
||||
reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// ─── KONZERTE GEFOLGTER KÜNSTLER ──────────────────────────────────────────────
|
||||
// Liefert zu einer Liste von Navidrome-Künstlern die anstehenden Termine, damit die App den
|
||||
// Reiter "Konzerte" in der Zeitleiste füllen kann. Gegenstück zu releases.js.
|
||||
//
|
||||
// Warum serverseitig: bei 50 gefolgten Künstlern wären das 50 Ticketmaster-Aufrufe — pro Gerät und
|
||||
// pro App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
|
||||
//
|
||||
// Bewusste Einschränkung (25.9.2026): gesucht wird über den NAMEN (`keyword=`), nicht über
|
||||
// Ticketmasters `attractionId`. Das ist dieselbe Unschärfe wie bei der Deezer-Zuordnung — ein
|
||||
// kurzer Künstlername trifft als Stichwort auch Fremdes. Deshalb wird jeder Treffer gegengeprüft:
|
||||
// der Name muss im Veranstaltungs- oder Attraktionsnamen vorkommen. Wer es genauer braucht, löst
|
||||
// später die attractionId einmal pro Künstler auf (siehe Backlog im CHANGELOG).
|
||||
const https = require('https');
|
||||
|
||||
const _cache = new Map(); // nameLower → { ts, termine }
|
||||
const TTL = 6 * 60 * 60 * 1000; // 6 h — Termine ändern sich nicht minütlich
|
||||
const GLEICHZEITIG = 4; // höflich gegenüber Ticketmaster, trotzdem zügig
|
||||
|
||||
const norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
||||
|
||||
function holen(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { headers: { Accept: 'application/json' } }, up => {
|
||||
let body = '';
|
||||
up.on('data', d => body += d);
|
||||
up.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (up.statusCode !== 200 || data.fault || data.errors) {
|
||||
return reject(new Error(`Ticketmaster ${up.statusCode}`));
|
||||
}
|
||||
resolve(data);
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Anstehende Termine eines Künstlers, gecacht. Bei einem Fehler kommt der alte Stand zurück. */
|
||||
async function termineFuer(name, apiKey) {
|
||||
const key = norm(name);
|
||||
if (!key) return [];
|
||||
const c = _cache.get(key);
|
||||
if (c && Date.now() - c.ts < TTL) return c.termine;
|
||||
|
||||
const ab = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const url = 'https://app.ticketmaster.com/discovery/v2/events.json' +
|
||||
`?keyword=${encodeURIComponent(name)}&classificationName=music` +
|
||||
`&startDateTime=${ab}&size=20&sort=date,asc&apikey=${apiKey}`;
|
||||
|
||||
try {
|
||||
const data = await holen(url);
|
||||
const roh = data?._embedded?.events || [];
|
||||
const termine = roh.map(e => {
|
||||
const ort = e._embedded?.venues?.[0] || {};
|
||||
const start = e.dates?.start || {};
|
||||
// Die ORTSZEIT nehmen, nicht die UTC-Zeit: ein Konzert um 20:00 sonst als 08:00Z angezeigt.
|
||||
// Als naive lokale Zeichenkette ohne Zone weitergereicht, die Clients zeigen sie unverändert.
|
||||
const zeit = (start.localDate
|
||||
? `${start.localDate}T${start.localTime || '00:00:00'}`
|
||||
: start.dateTime) || null;
|
||||
const aus = e.dates?.status?.code === 'offsale' || e.dates?.status?.code === 'cancelled';
|
||||
const attraktionen = (e._embedded?.attractions || []).map(a => a.name || '');
|
||||
return {
|
||||
eventId: e.id || '',
|
||||
titel: e.name || '',
|
||||
datum: zeit,
|
||||
ort: ort.name || '',
|
||||
stadt: ort.city?.name || '',
|
||||
land: ort.country?.name || '',
|
||||
url: e.url || '',
|
||||
abgesagt: aus,
|
||||
_namen: [e.name || '', ...attraktionen],
|
||||
};
|
||||
}).filter(t => t.datum);
|
||||
|
||||
// Gegenprobe zur Stichwortsuche: der Künstlername muss irgendwo im Veranstaltungs- oder
|
||||
// Attraktionsnamen stehen. Ohne das liefert "MORE" oder "TPR" fremde Veranstaltungen.
|
||||
const gesucht = key;
|
||||
const gefiltert = termine.filter(t => t._namen.some(n => norm(n).includes(gesucht)));
|
||||
const sauber = gefiltert.map(({ _namen, ...rest }) => rest);
|
||||
|
||||
_cache.set(key, { ts: Date.now(), termine: sauber });
|
||||
return sauber;
|
||||
} catch (e) {
|
||||
// Bei einem Fehler den abgelaufenen Stand ausliefern statt einer leeren Liste — aber den
|
||||
// Zeitstempel NICHT auffrischen, damit er beim nächsten Mal wieder frisch geholt wird.
|
||||
if (c) return c.termine;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function inHaeppchen(items, groesse, fn) {
|
||||
const raus = [];
|
||||
for (let i = 0; i < items.length; i += groesse) {
|
||||
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{id,name}>} kuenstler Navidrome-Künstler
|
||||
* @param {string} apiKey Ticketmaster-Schlüssel
|
||||
* @param {number} tage Zeitfenster nach vorn
|
||||
* @returns {{konzerte:Array, ohneTermine:Array<string>}}
|
||||
*/
|
||||
async function kommendeKonzerte(kuenstler, apiKey, tage = 365) {
|
||||
const grenze = Date.now() + tage * 24 * 60 * 60 * 1000;
|
||||
const ohneTermine = [];
|
||||
|
||||
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
|
||||
let termine = [];
|
||||
try { termine = await termineFuer(k.name, apiKey); } catch { return []; }
|
||||
if (!termine.length) { ohneTermine.push(k.name); return []; }
|
||||
return termine
|
||||
.filter(t => new Date(t.datum).getTime() <= grenze)
|
||||
.map(t => ({ artistId: k.id, artistName: k.name, ...t }));
|
||||
});
|
||||
|
||||
// Nach vorn sortiert: das naechste Konzert zuerst — anders als bei den Veroeffentlichungen,
|
||||
// die rueckwaerts laufen.
|
||||
const konzerte = proKuenstler.flat()
|
||||
.sort((a, b) => new Date(a.datum) - new Date(b.datum));
|
||||
return { konzerte, ohneTermine };
|
||||
}
|
||||
|
||||
module.exports = { kommendeKonzerte, termineFuer, _cache };
|
||||
@@ -256,6 +256,32 @@ app.post('/api/artist-releases', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/artist-concerts { artists: [{id, name}], days }
|
||||
// Gegenstueck zu /api/artist-releases: anstehende Termine aller gefolgten Kuenstler.
|
||||
// Ohne Ortsfilter — Ticketmaster sucht weltweit. Das ist bewusst so (siehe CHANGELOG-Backlog):
|
||||
// eine Einschraenkung per .env kommt spaeter, fuers Erste reicht, was die API liefert.
|
||||
app.post('/api/artist-concerts', async (req, res) => {
|
||||
const key = CONFIG.ticketmasterKey;
|
||||
if (!key) return res.json({ konzerte: [], ohneTermine: [], keinSchluessel: true });
|
||||
|
||||
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
|
||||
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 365, 1), 365);
|
||||
if (!artists.length) return res.json({ konzerte: [], ohneTermine: [] });
|
||||
// Obergrenze, damit ein kaputter Client nicht hunderte Ticketmaster-Aufrufe ausloest.
|
||||
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
|
||||
|
||||
const sauber = artists.filter(a => a && a.id && a.name)
|
||||
.map(a => ({ id: String(a.id), name: String(a.name) }));
|
||||
|
||||
try {
|
||||
const { kommendeKonzerte } = require('./lib/konzerte');
|
||||
res.json(await kommendeKonzerte(sauber, key, days));
|
||||
} catch (e) {
|
||||
console.error('[concerts]', e.message);
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
|
||||
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
|
||||
const id = decodeURIComponent(req.params.ndArtistId);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const { test, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const https = require('https');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const { kommendeKonzerte, _cache } = require('../lib/konzerte');
|
||||
|
||||
// ─── Ticketmaster-Antwort einschieben ────────────────────────────────────────
|
||||
// Kein Schluessel, kein Netz: https.get wird ersetzt und liefert eine feste Antwort. Geprueft
|
||||
// wird damit genau das, was uns gehoert — Ortszeit, Gegenprobe des Namens, Fenster, Sortierung.
|
||||
let _antworten = {};
|
||||
let _aufrufe = [];
|
||||
const echtesGet = https.get;
|
||||
|
||||
function stub() {
|
||||
https.get = (url, _opts, cb) => {
|
||||
_aufrufe.push(url);
|
||||
const name = decodeURIComponent((url.match(/keyword=([^&]*)/) || [])[1] || '');
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = 200;
|
||||
process.nextTick(() => {
|
||||
res.emit('data', JSON.stringify(_antworten[name] ?? { _embedded: { events: [] } }));
|
||||
res.emit('end');
|
||||
});
|
||||
cb(res);
|
||||
return new EventEmitter(); // das Request-Objekt, auf dem .on('error') gesetzt wird
|
||||
};
|
||||
}
|
||||
stub();
|
||||
process.on('exit', () => { https.get = echtesGet; });
|
||||
|
||||
const inTagen = n => {
|
||||
const d = new Date(Date.now() + n * 864e5);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
beforeEach(() => { _cache.clear(); _aufrufe = []; _antworten = {}; });
|
||||
|
||||
const ereignis = (name, tage, extra = {}) => ({
|
||||
id: 'e' + tage,
|
||||
name,
|
||||
dates: { start: { localDate: inTagen(tage), localTime: '20:00:00', dateTime: `${inTagen(tage)}T18:00:00Z` }, ...extra.dates },
|
||||
_embedded: { venues: [{ name: 'Halle', city: { name: 'Köln' }, country: { name: 'Germany' } }] },
|
||||
url: 'https://tm.example/' + tage,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('Ortszeit schlaegt UTC — 20:00 bleibt 20:00', async () => {
|
||||
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 1);
|
||||
assert.ok(konzerte[0].datum.endsWith('T20:00:00'), `unerwartet: ${konzerte[0].datum}`);
|
||||
});
|
||||
|
||||
test('Gegenprobe: fremde Veranstaltung mit demselben Stichwort faellt raus', async () => {
|
||||
_antworten['MORE'] = { _embedded: { events: [
|
||||
ereignis('MORE Tour 2026', 10),
|
||||
ereignis('Sing Something MORE Festival', 20), // enthaelt das Wort → bleibt
|
||||
ereignis('Ganz was anderes', 30), // enthaelt es nicht → faellt raus
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'MORE' }], 'K', 365);
|
||||
assert.deepEqual(konzerte.map(k => k.titel), ['MORE Tour 2026', 'Sing Something MORE Festival']);
|
||||
});
|
||||
|
||||
test('Name im Attraktionsnamen zaehlt auch', async () => {
|
||||
_antworten['K/DA'] = { _embedded: { events: [
|
||||
{ ...ereignis('League Music Night', 15), _embedded: {
|
||||
venues: [{ name: 'Arena', city: { name: 'Berlin' }, country: { name: 'Germany' } }],
|
||||
attractions: [{ name: 'K/DA' }],
|
||||
} },
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'K/DA' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 1, 'ueber die Attraktion erkannt');
|
||||
});
|
||||
|
||||
test('Termine hinter dem Fenster fallen weg', async () => {
|
||||
_antworten['Coldplay'] = { _embedded: { events: [ereignis('Coldplay Live', 20), ereignis('Coldplay Later', 200)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Coldplay' }], 'K', 30);
|
||||
assert.equal(konzerte.length, 1);
|
||||
assert.equal(konzerte[0].titel, 'Coldplay Live');
|
||||
});
|
||||
|
||||
test('Sortierung laeuft NACH VORN — naechstes Konzert zuerst', async () => {
|
||||
_antworten['A'] = { _embedded: { events: [ereignis('A spaet', 90)] } };
|
||||
_antworten['B'] = { _embedded: { events: [ereignis('B frueh', 5)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'A' }, { id: '2', name: 'B' }], 'K', 365);
|
||||
assert.deepEqual(konzerte.map(k => k.titel), ['B frueh', 'A spaet']);
|
||||
});
|
||||
|
||||
test('Kuenstler ohne Termine werden gemeldet, nicht verschwiegen', async () => {
|
||||
_antworten['Leer'] = { _embedded: { events: [] } };
|
||||
const { konzerte, ohneTermine } = await kommendeKonzerte([{ id: '1', name: 'Leer' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 0);
|
||||
assert.deepEqual(ohneTermine, ['Leer']);
|
||||
});
|
||||
|
||||
test('Zweiter Aufruf kommt aus dem Cache — kein weiterer Abruf', async () => {
|
||||
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
|
||||
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
const nachErstem = _aufrufe.length;
|
||||
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
assert.equal(_aufrufe.length, nachErstem, 'zweiter Aufruf darf nicht erneut abrufen');
|
||||
});
|
||||
|
||||
test('Abgesagt wird durchgereicht statt stillschweigend gezeigt', async () => {
|
||||
_antworten['X'] = { _embedded: { events: [
|
||||
{ ...ereignis('X Tour', 40), dates: { start: { localDate: inTagen(40), localTime: '20:00:00' }, status: { code: 'cancelled' } } },
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'X' }], 'K', 365);
|
||||
assert.equal(konzerte[0].abgesagt, true);
|
||||
});
|
||||
|
||||
test('Ein Kuenstlername ohne Buchstaben loest keinen Abruf aus', async () => {
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: '???' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 0);
|
||||
assert.equal(_aufrufe.length, 0);
|
||||
});
|
||||
+10
-3
@@ -427,10 +427,17 @@
|
||||
<div class="page-header">
|
||||
<div class="page-title">Neu von <em>deinen Künstlern</em></div>
|
||||
</div>
|
||||
<!-- Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind
|
||||
dasselbe Thema. Nicht in EINE Liste gemischt — Veroeffentlichungen liegen hinter,
|
||||
Konzerte vor dem heutigen Tag, chronologisch gemischt waere das nicht zu lesen. -->
|
||||
<div id="artist-news-tabs" class="sort-chips" style="margin-bottom:6px">
|
||||
<button class="sort-chip active" data-tab="releases" onclick="showArtistNewsTab('releases')">Neu erschienen</button>
|
||||
<button class="sort-chip" data-tab="konzerte" onclick="showArtistNewsTab('konzerte')">Konzerte</button>
|
||||
</div>
|
||||
<div id="artist-news-sort" class="sort-chips">
|
||||
<button class="sort-chip active" data-days="90" onclick="loadArtistNews(90)">90 Tage</button>
|
||||
<button class="sort-chip" data-days="30" onclick="loadArtistNews(30)">30 Tage</button>
|
||||
<button class="sort-chip" data-days="365" onclick="loadArtistNews(365)">1 Jahr</button>
|
||||
<button class="sort-chip active" data-days="90" onclick="setArtistNewsDays(90)">90 Tage</button>
|
||||
<button class="sort-chip" data-days="30" onclick="setArtistNewsDays(30)">30 Tage</button>
|
||||
<button class="sort-chip" data-days="365" onclick="setArtistNewsDays(365)">1 Jahr</button>
|
||||
</div>
|
||||
<div id="artist-news-list"></div>
|
||||
</div>
|
||||
|
||||
@@ -352,6 +352,8 @@ Object.assign(window, {
|
||||
loadDeezerAlbum, closeEventsModal,
|
||||
// Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach.
|
||||
loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)),
|
||||
showArtistNewsTab: (reiter) => import('./views/artistNews.js').then(m => m.showArtistNewsTab(reiter)),
|
||||
setArtistNewsDays: (tage) => import('./views/artistNews.js').then(m => m.setArtistNewsDays(tage)),
|
||||
// Player
|
||||
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
|
||||
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
|
||||
|
||||
+140
-2
@@ -11,16 +11,44 @@ import { navigate, _navGoBack } from './navigation.js';
|
||||
// Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen.
|
||||
|
||||
let _tage = 90;
|
||||
let _tageKonzerte = 365; // Konzerte schaut man in die Weite an, nicht auf 90 Tage
|
||||
let _reiter = 'releases';
|
||||
let _laeuft = false;
|
||||
|
||||
/** Reiter wechseln: "releases" oder "konzerte". */
|
||||
export function showArtistNewsTab(reiter) {
|
||||
_reiter = reiter === 'konzerte' ? 'konzerte' : 'releases';
|
||||
document.querySelectorAll('#artist-news-tabs .sort-chip').forEach(c =>
|
||||
c.classList.toggle('active', c.dataset.tab === _reiter));
|
||||
const titel = document.querySelector('#page-artist-news .page-title');
|
||||
if (titel) {
|
||||
titel.innerHTML = _reiter === 'konzerte'
|
||||
? 'Konzerte <em>deiner Künstler</em>'
|
||||
: 'Neu von <em>deinen Künstlern</em>';
|
||||
}
|
||||
if (_reiter === 'konzerte') loadKonzerte();
|
||||
else loadArtistNews();
|
||||
}
|
||||
|
||||
/** Zeitfenster setzen — es gilt je Reiter, weil es in verschiedene Richtungen zeigt. */
|
||||
export function setArtistNewsDays(tage) {
|
||||
if (_reiter === 'konzerte') { _tageKonzerte = tage; loadKonzerte(); }
|
||||
else { _tage = tage; loadArtistNews(); }
|
||||
}
|
||||
|
||||
function _markiereTage(tage) {
|
||||
document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c =>
|
||||
c.classList.toggle('active', Number(c.dataset.days) === tage));
|
||||
}
|
||||
|
||||
export async function loadArtistNews(tage = _tage) {
|
||||
_tage = tage;
|
||||
_reiter = 'releases';
|
||||
// Die Seite haengt an den Favoriten (Knopf "✨ Neu") und nicht mehr an der Seitenleiste,
|
||||
// deshalb braucht sie einen Weg zurueck wie jede andere Unterseite.
|
||||
const zurueck = document.getElementById('artist-news-back-btn');
|
||||
if (zurueck) zurueck.onclick = () => _navGoBack();
|
||||
document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c =>
|
||||
c.classList.toggle('active', Number(c.dataset.days) === tage));
|
||||
_markiereTage(tage);
|
||||
|
||||
const el = document.getElementById('artist-news-list');
|
||||
if (!el || _laeuft) return;
|
||||
@@ -163,3 +191,113 @@ function _zeile(rel, schonDa) {
|
||||
row.appendChild(aktion);
|
||||
return row;
|
||||
}
|
||||
|
||||
// ─── KONZERTE DEINER KÜNSTLER ───────────────────────────────────────────────
|
||||
// Gegenstück zur Release-Zeitleiste. Die Liste läuft NACH VORN: das nächste Konzert zuerst.
|
||||
// Die Termine kommen über das Backend von Ticketmaster, gecacht für alle Geräte gemeinsam.
|
||||
|
||||
export async function loadKonzerte(tage = _tageKonzerte) {
|
||||
_tageKonzerte = tage;
|
||||
_reiter = 'konzerte';
|
||||
const zurueck = document.getElementById('artist-news-back-btn');
|
||||
if (zurueck) zurueck.onclick = () => _navGoBack();
|
||||
_markiereTage(tage);
|
||||
|
||||
const el = document.getElementById('artist-news-list');
|
||||
if (!el || _laeuft) return;
|
||||
|
||||
if (!S.cfg?.backendUrl) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Konzerttermine kommen über das Backend von Ticketmaster.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
_laeuft = true;
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const r = await api('getStarred2');
|
||||
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
|
||||
if (!kuenstler.length) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${S.cfg.backendUrl}/api/artist-concerts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artists: kuenstler, days: tage }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { konzerte = [], keinSchluessel } = await res.json();
|
||||
|
||||
el.innerHTML = '';
|
||||
if (keinSchluessel) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">🎟</div><p>Kein Ticketmaster-Schlüssel hinterlegt</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Im Backend <code>TICKETMASTER_API_KEY</code> setzen.</p></div>';
|
||||
return;
|
||||
}
|
||||
if (!konzerte.length) {
|
||||
const leer = document.createElement('div');
|
||||
leer.className = 'empty-state';
|
||||
leer.innerHTML = `<div class="icon">🎪</div><p>Keine Termine in den nächsten ${tage} Tagen</p>`
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Gesucht wird weltweit über den Künstlernamen.</p>';
|
||||
el.appendChild(leer);
|
||||
return;
|
||||
}
|
||||
_renderKonzerte(el, konzerte);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
|
||||
} finally {
|
||||
_laeuft = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderKonzerte(el, konzerte) {
|
||||
let letzterMonat = '';
|
||||
konzerte.forEach(k => {
|
||||
const d = new Date(k.datum);
|
||||
const monat = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
if (monat !== letzterMonat) {
|
||||
letzterMonat = monat;
|
||||
const h = document.createElement('div');
|
||||
h.className = 'search-section-title';
|
||||
h.style.margin = '18px 0 8px';
|
||||
h.textContent = monat;
|
||||
el.appendChild(h);
|
||||
}
|
||||
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'artist-news-row konzert-row' + (k.abgesagt ? ' konzert-abgesagt' : '');
|
||||
const ort = [k.ort, k.stadt, k.land].filter(Boolean).join(' · ');
|
||||
const uhr = d.getHours() || d.getMinutes()
|
||||
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
zeile.innerHTML =
|
||||
`<div class="konzert-datum"><span class="kd-tag">${d.getDate()}</span>`
|
||||
+ `<span class="kd-monat">${d.toLocaleDateString('de-DE', { month: 'short' })}</span></div>`
|
||||
+ `<div class="artist-news-info">`
|
||||
+ `<div class="artist-news-title">${esc(k.titel || k.artistName)}</div>`
|
||||
+ `<div class="artist-news-sub"><span class="artist-news-artist">${esc(k.artistName)}</span>`
|
||||
+ `${ort ? ' · ' + esc(ort) : ''}${uhr ? ' · ' + uhr + ' Uhr' : ''}</div></div>`;
|
||||
|
||||
// Künstlername führt zur Künstlerseite, wie in der Release-Liste.
|
||||
zeile.querySelector('.artist-news-artist').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigate('artist-detail', { id: k.artistId, name: k.artistName });
|
||||
};
|
||||
|
||||
const rechts = document.createElement('div');
|
||||
if (k.abgesagt) {
|
||||
rechts.className = 'artist-news-have';
|
||||
rechts.textContent = 'abgesagt';
|
||||
} else if (k.url) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'btn-outline konzert-ticket';
|
||||
a.href = k.url; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
a.textContent = 'Tickets';
|
||||
rechts.appendChild(a);
|
||||
}
|
||||
zeile.appendChild(rechts);
|
||||
el.appendChild(zeile);
|
||||
});
|
||||
}
|
||||
+10
@@ -1254,3 +1254,13 @@ button:focus{outline:none}
|
||||
.q-dragging .q-grip{cursor:grabbing}
|
||||
.q-dragging-aktiv [data-qidx]:not(.q-dragging){transition:transform .16s ease}
|
||||
.q-dragging-aktiv{user-select:none}
|
||||
|
||||
/* ── Konzerte deiner Künstler ── */
|
||||
/* Kalenderblatt statt Cover: ein Konzert hat kein Bild, aber ein Datum — und danach sucht das
|
||||
Auge in einer Liste, die nach vorn läuft. */
|
||||
.konzert-datum{flex-shrink:0;width:46px;height:46px;border-radius:10px;background:var(--surface);border:1px solid var(--border2);display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1}
|
||||
.kd-tag{font-size:17px;font-weight:700;color:var(--text)}
|
||||
.kd-monat{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--text2);margin-top:2px}
|
||||
.konzert-ticket{font-size:12px;padding:6px 12px;white-space:nowrap;text-decoration:none;display:inline-block}
|
||||
.konzert-abgesagt .konzert-datum{opacity:.45}
|
||||
.konzert-abgesagt .artist-news-title{text-decoration:line-through;color:var(--text2)}
|
||||
Reference in new issue
Block a user