feat: artist concerts via Bandsintown — button + modal with ticket links
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6bbebcc602
commit
914297651e
6 files changed
+86
-3
No files matched your search
+11
@@ -936,6 +936,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EVENTS MODAL -->
|
||||
<div class="modal-overlay" id="events-modal" onclick="if(event.target===this)closeEventsModal()">
|
||||
<div class="modal events-modal">
|
||||
<div class="events-modal-header">
|
||||
<div id="events-modal-title" class="events-modal-title"></div>
|
||||
<button class="events-modal-close" onclick="closeEventsModal()">✕</button>
|
||||
</div>
|
||||
<div id="events-modal-list" class="events-modal-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SONG INFO MODAL -->
|
||||
<div class="info-modal" id="info-modal">
|
||||
<div class="inner">
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
navigate, _navGoBack, _navLoad, onHeaderSearch, closeHeaderSearch,
|
||||
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
|
||||
searchSimilarArtist, searchAndPreview, loadItunesAlbum,
|
||||
searchSimilarArtist, searchAndPreview, loadItunesAlbum, closeEventsModal,
|
||||
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
||||
} from './views/views.js';
|
||||
@@ -300,7 +300,7 @@ Object.assign(window, {
|
||||
// Navigation
|
||||
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
|
||||
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
|
||||
loadItunesAlbum,
|
||||
loadItunesAlbum, closeEventsModal,
|
||||
// Player
|
||||
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
|
||||
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
|
||||
|
||||
+11
-1
@@ -7,6 +7,7 @@ import { showCtxMenu, openDlModal } from '../ui/ui.js';
|
||||
// Circular — safe (called inside event handlers / function bodies)
|
||||
import { navigate, _navGoBack } from './navigation.js';
|
||||
import { getArtistImage } from './home.js';
|
||||
import { fetchEvents, openEventsModal } from './events.js';
|
||||
|
||||
// ─── QUICK PLAY HELPERS ───
|
||||
export async function quickPlayAlbum(a) {
|
||||
@@ -85,7 +86,7 @@ export async function loadArtistDetail(artist) {
|
||||
const hasND = !!artist.id;
|
||||
const knownItunes = artist.itunesArtistId || null;
|
||||
|
||||
const [imgRes, albumRes, infoRes, topRes, iArtistRes] = await Promise.allSettled([
|
||||
const [imgRes, albumRes, infoRes, topRes, iArtistRes, eventsRes] = await Promise.allSettled([
|
||||
hasND ? getArtistImage(artist.id) : Promise.resolve(null),
|
||||
hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null),
|
||||
hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null),
|
||||
@@ -93,6 +94,7 @@ export async function loadArtistDetail(artist) {
|
||||
knownItunes
|
||||
? Promise.resolve([{ artistId: knownItunes }])
|
||||
: itunesSearch(artist.name, 'musicArtist', 5),
|
||||
fetchEvents(artist.name),
|
||||
]);
|
||||
|
||||
const _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean));
|
||||
@@ -226,6 +228,14 @@ export async function loadArtistDetail(artist) {
|
||||
actionBar.appendChild(playTopBtn);
|
||||
actionBar.appendChild(shuffleBtn);
|
||||
}
|
||||
const events = eventsRes.status === 'fulfilled' ? (eventsRes.value || []) : [];
|
||||
if(events.length) {
|
||||
const evBtn = document.createElement('button');
|
||||
evBtn.className = 'artist-action-btn';
|
||||
evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte <span style="font-size:11px;opacity:.7">(${events.length})</span>`;
|
||||
evBtn.onclick = () => openEventsModal(events, artist.name);
|
||||
actionBar.appendChild(evBtn);
|
||||
}
|
||||
}
|
||||
|
||||
if(albEl) { albEl.innerHTML = ''; _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist); }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
export async function fetchEvents(artistName) {
|
||||
try {
|
||||
const r = await fetch(`https://rest.bandsintown.com/artists/${encodeURIComponent(artistName)}/events?app_id=novasonic`);
|
||||
if(!r.ok) return [];
|
||||
const data = await r.json();
|
||||
if(!Array.isArray(data)) return [];
|
||||
return data.filter(e => new Date(e.datetime) > new Date());
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function openEventsModal(events, artistName) {
|
||||
const modal = document.getElementById('events-modal');
|
||||
const title = document.getElementById('events-modal-title');
|
||||
const list = document.getElementById('events-modal-list');
|
||||
if(!modal || !list) return;
|
||||
|
||||
title.textContent = `Konzerte · ${artistName}`;
|
||||
list.innerHTML = events.map(e => {
|
||||
const d = new Date(e.datetime);
|
||||
const date = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
const time = d.getHours() === 0 && d.getMinutes() === 0 ? '' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const venue = e.venue?.name || '';
|
||||
const city = [e.venue?.city, e.venue?.country].filter(Boolean).join(', ');
|
||||
const offer = e.offers?.find(o => o.url) || null;
|
||||
const soldOut = offer?.status === 'unavailable';
|
||||
return `<div class="event-row">
|
||||
<div class="event-date">${esc(date)}${time ? `<span class="event-time">${esc(time)}</span>` : ''}</div>
|
||||
<div class="event-venue">
|
||||
<div class="event-venue-name">${esc(venue)}</div>
|
||||
<div class="event-venue-city">${esc(city)}</div>
|
||||
</div>
|
||||
${offer ? `<a class="event-book-btn${soldOut ? ' sold-out' : ''}" href="${esc(offer.url)}" target="_blank" rel="noopener">${soldOut ? 'Ausverkauft' : 'Buchen →'}</a>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
modal.classList.add('open');
|
||||
}
|
||||
|
||||
export function closeEventsModal() {
|
||||
document.getElementById('events-modal')?.classList.remove('open');
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './detail.js';
|
||||
export * from './playlists.js';
|
||||
export * from './search.js';
|
||||
export * from './discover.js';
|
||||
export * from './events.js';
|
||||
+18
@@ -841,6 +841,24 @@ button:focus{outline:none}
|
||||
.artist-hero-name{font-size:44px;letter-spacing:-2px}
|
||||
}
|
||||
|
||||
/* ── EVENTS MODAL ── */
|
||||
.events-modal{padding:0;overflow:hidden;width:min(520px,calc(100vw - 32px))}
|
||||
.events-modal-header{display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;border-bottom:1px solid var(--border)}
|
||||
.events-modal-title{font-size:16px;font-weight:700}
|
||||
.events-modal-close{background:none;border:none;color:var(--text2);font-size:18px;cursor:pointer;padding:4px 8px;line-height:1}
|
||||
.events-modal-close:hover{color:var(--text)}
|
||||
.events-modal-list{overflow-y:auto;max-height:60vh;padding:8px 0}
|
||||
.event-row{display:flex;align-items:center;gap:12px;padding:12px 24px;border-bottom:1px solid var(--border)}
|
||||
.event-row:last-child{border-bottom:none}
|
||||
.event-date{font-size:13px;font-weight:700;color:var(--text);min-width:110px;flex-shrink:0;line-height:1.4}
|
||||
.event-time{display:block;font-size:11px;font-weight:400;color:var(--text2);margin-top:2px}
|
||||
.event-venue{flex:1;min-width:0}
|
||||
.event-venue-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.event-venue-city{font-size:11px;color:var(--text2);margin-top:2px}
|
||||
.event-book-btn{flex-shrink:0;padding:7px 14px;border-radius:20px;background:var(--accent);color:#fff;font-family:var(--font);font-size:12px;font-weight:700;text-decoration:none;transition:opacity .15s}
|
||||
.event-book-btn:hover{opacity:.85}
|
||||
.event-book-btn.sold-out{background:var(--surface2);color:var(--text3)}
|
||||
|
||||
/* ── MISC ── */
|
||||
.spinner{width:28px;height:28px;border:3px solid var(--surface2);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;margin:50px auto;display:block}
|
||||
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:70px 20px;color:var(--text3);text-align:center}
|
||||
|
||||
Reference in new issue
Block a user