feat: Zeitleiste "Neu von deinen Künstlern" (Web) + Backend-Endpunkt
Eigener Punkt in der Seitenleiste: jüngste Veröffentlichungen aller gefolgten Künstler, nach Monat gruppiert, neueste zuerst, umschaltbar auf 30/90/365 Tage. Was schon in der Bibliothek liegt, ist markiert (Vergleich über Künstler + Albumname, akzent- und zeichenunabhängig); alles andere bekommt einen Laden-Knopf zum vertrauten Download-Dialog. Neuer Endpunkt POST /api/artist-releases (backend/lib/releases.js): die Deezer-Zuordnung passiert serverseitig, weil die Künstler-Pins dort ohnehin liegen. Gepinnte nutzen die feste ID, ungepinnte werden über den Namen gesucht und nach Fan-Zahl sortiert; ohne Treffer kommt der Name als ohneZuordnung zurück und die Oberfläche rät zum Pinnen. Cache im Backend statt im Client — bei 30 gefolgten Künstlern wären das sonst 30 Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für die Namenszuordnung, max. 4 Anfragen parallel, bei Deezer-Fehlern wird der abgelaufene Stand ausgeliefert statt einer leeren Liste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
19f241e493
commit
908488a337
8 files changed
+345
-2
No files matched your search
@@ -40,6 +40,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
||||
| Playlist cover upload | ✅ | ✅ |
|
||||
| Besitzer geteilter Playlists | ✅ | ✅ |
|
||||
| Künstler folgen / Album merken | ✅ | ✅ |
|
||||
| Neu von deinen Künstlern | ✅ | ❌ |
|
||||
| Spotify / YouTube importer | ✅ | ✅ |
|
||||
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
||||
| Wiedergabe fortsetzen | ✅ | ✅ |
|
||||
@@ -100,6 +101,24 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
||||
- Wer ohne Künstler-Pin folgt, bekommt einen Hinweis: ohne Pinnung läuft die Deezer-Zuordnung über
|
||||
den Namen, und bei gleichnamigen Künstlern kämen dauerhaft die Neuheiten des Falschen.
|
||||
|
||||
- **Zeitleiste „Neu von deinen Künstlern" (Web)** — eigener Punkt in der Seitenleiste. Zeigt die
|
||||
jüngsten Veröffentlichungen aller Künstler, denen du folgst: nach Monat gruppiert, neueste zuerst,
|
||||
pro Eintrag Cover, Künstler, Art (Album/Single/EP), Titelzahl und Datum. Umschaltbar auf 30 Tage,
|
||||
90 Tage oder ein Jahr.
|
||||
- **Was du schon hast, ist markiert.** Verglichen wird über Künstler + Albumname (akzent- und
|
||||
zeichenunabhängig) — Deezers Album-Id hat mit Navidromes nichts zu tun. Alles andere bekommt
|
||||
direkt einen Laden-Knopf, der den vertrauten Download-Dialog öffnet.
|
||||
- **Neuer Backend-Endpunkt `POST /api/artist-releases`** (`backend/lib/releases.js`). Die
|
||||
Deezer-Zuordnung passiert dort, weil die Künstler-Pins ohnehin serverseitig liegen: der Client
|
||||
schickt nur seine Navidrome-Künstler und muss über Deezer nichts wissen. Gepinnte Künstler nutzen
|
||||
die feste ID, ungepinnte werden über den Namen gesucht — sortiert nach Fan-Zahl, weil Deezers
|
||||
Suche nicht nach Bekanntheit ordnet. Künstler ohne Treffer kommen als `ohneZuordnung` zurück und
|
||||
die Oberfläche rät zum Pinnen, statt still die Neuheiten des Falschen anzuzeigen.
|
||||
- **Der Cache liegt im Backend, nicht im Client.** Bei 30 gefolgten Künstlern wären das sonst 30
|
||||
Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für die Namenszuordnung, höchstens
|
||||
vier Anfragen gleichzeitig; bei einem Deezer-Fehler wird der abgelaufene Stand ausgeliefert
|
||||
statt einer leeren Liste.
|
||||
|
||||
### Changed
|
||||
- **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei
|
||||
`getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ───────────────────────────────
|
||||
// Liefert zu einer Liste von Navidrome-Künstlern die jüngsten Deezer-Releases, damit die App eine
|
||||
// Zeitleiste "Neu von deinen Künstlern" zeigen kann.
|
||||
//
|
||||
// Warum serverseitig: bei 30 gefolgten Künstlern wären das 30 Deezer-Aufrufe — pro Gerät und pro
|
||||
// App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
|
||||
const { dzGet } = require('./deezer');
|
||||
|
||||
const _cache = new Map(); // deezerArtistId → { ts, alben }
|
||||
const _idCache = new Map(); // nameLower → { ts, id }
|
||||
const TTL = 6 * 60 * 60 * 1000; // 6 h — Alben erscheinen nicht minütlich
|
||||
const ID_TTL = 24 * 60 * 60 * 1000; // 24 h — die Zuordnung Name → Deezer ändert sich kaum
|
||||
const GLEICHZEITIG = 4; // höflich gegenüber Deezer, trotzdem zügig
|
||||
|
||||
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || '';
|
||||
// Künstler stilisieren ihren Namen mit führenden Zeichen (".diedlonely", "†…") — ohne das Abschneiden
|
||||
// findet Deezers Suche sie nicht. Gleiche Regel wie in deezer.js.
|
||||
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||
|
||||
/** Deezer-ID zu einem Künstler: erst die gepinnte, sonst über den Namen gesucht (und gemerkt). */
|
||||
async function deezerIdFuer(name, pinId) {
|
||||
if (pinId) return String(pinId);
|
||||
const key = stripLeadSym(name).toLowerCase();
|
||||
if (!key) return null;
|
||||
const c = _idCache.get(key);
|
||||
if (c && Date.now() - c.ts < ID_TTL) return c.id;
|
||||
try {
|
||||
const treffer = (await dzGet(`search/artist?q=${encodeURIComponent(stripLeadSym(name))}&limit=5`)).data || [];
|
||||
// Deezer sortiert nicht nach Bekanntheit — die Fan-Zahl trifft den gemeinten Künstler deutlich
|
||||
// öfter als der erste Treffer. Ein Pin schlägt das trotzdem, siehe oben.
|
||||
const best = treffer.slice().sort((a, b) => (b.nb_fan || 0) - (a.nb_fan || 0))[0];
|
||||
const id = best ? String(best.id) : null;
|
||||
_idCache.set(key, { ts: Date.now(), id });
|
||||
return id;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Albumliste eines Deezer-Künstlers, gecacht. */
|
||||
async function albenFuer(deezerId) {
|
||||
const c = _cache.get(deezerId);
|
||||
if (c && Date.now() - c.ts < TTL) return c.alben;
|
||||
try {
|
||||
const alben = (await dzGet(`artist/${deezerId}/albums?limit=50`)).data || [];
|
||||
_cache.set(deezerId, { ts: Date.now(), alben });
|
||||
return alben;
|
||||
} catch {
|
||||
// Bei einem Fehler lieber den alten Stand als gar nichts — abgelaufen, aber nicht falsch.
|
||||
return c ? c.alben : [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Führt eine Aufgabenliste mit begrenzter Gleichzeitigkeit aus. */
|
||||
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,deezerArtistId?}>} kuenstler Navidrome-Künstler (id + name)
|
||||
* @param {number} tage Zeitfenster
|
||||
* @returns {{releases:Array, ohneZuordnung:Array<string>}}
|
||||
*/
|
||||
async function neueReleases(kuenstler, tage = 90) {
|
||||
const grenze = Date.now() - tage * 24 * 60 * 60 * 1000;
|
||||
const ohneZuordnung = [];
|
||||
|
||||
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
|
||||
const dzId = await deezerIdFuer(k.name, k.deezerArtistId);
|
||||
if (!dzId) { ohneZuordnung.push(k.name); return []; }
|
||||
const alben = await albenFuer(dzId);
|
||||
return alben
|
||||
.filter(a => a.release_date && new Date(a.release_date).getTime() >= grenze)
|
||||
.map(a => ({
|
||||
artistId: k.id,
|
||||
artistName: k.name,
|
||||
deezerArtistId: dzId,
|
||||
albumId: String(a.id),
|
||||
title: a.title || '',
|
||||
cover: coverXl(a),
|
||||
releaseDate: a.release_date,
|
||||
recordType: a.record_type || 'album',
|
||||
trackCount: a.nb_tracks ?? null,
|
||||
explicit: !!a.explicit_lyrics,
|
||||
}));
|
||||
});
|
||||
|
||||
const releases = proKuenstler.flat()
|
||||
.sort((a, b) => new Date(b.releaseDate) - new Date(a.releaseDate));
|
||||
return { releases, ohneZuordnung };
|
||||
}
|
||||
|
||||
module.exports = { neueReleases, deezerIdFuer, albenFuer, _cache, _idCache };
|
||||
@@ -266,6 +266,36 @@ app.post('/api/artist-overrides', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ─────────────────────────────
|
||||
// POST /api/artist-releases { artists: [{id, name}], days }
|
||||
// Die Deezer-Zuordnung passiert HIER, weil die Pins ohnehin serverseitig liegen — der Client
|
||||
// schickt also nur seine Navidrome-Künstler und muss nichts über Deezer wissen.
|
||||
app.post('/api/artist-releases', async (req, res) => {
|
||||
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
|
||||
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 90, 1), 365);
|
||||
if (!artists.length) return res.json({ releases: [], ohneZuordnung: [] });
|
||||
// Obergrenze, damit ein kaputter Client nicht hunderte Deezer-Aufrufe auslöst.
|
||||
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
|
||||
|
||||
const pins = _loadArtistOverrides();
|
||||
const mitPin = artists
|
||||
.filter(a => a && a.id && a.name)
|
||||
.map(a => ({
|
||||
id: String(a.id),
|
||||
name: String(a.name),
|
||||
deezerArtistId: pins[String(a.id)]?.deezerArtistId || null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const { neueReleases } = require('./lib/releases');
|
||||
const ergebnis = await neueReleases(mitPin, days);
|
||||
res.json(ergebnis);
|
||||
} catch (e) {
|
||||
console.error('[releases]', 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);
|
||||
|
||||
+15
@@ -76,6 +76,9 @@
|
||||
<div class="nav-item" onclick="navigate('discover')" data-page="discover">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>Global Suche
|
||||
</div>
|
||||
<div class="nav-item" onclick="navigate('artist-news')" data-page="artist-news">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>Von deinen Künstlern
|
||||
</div>
|
||||
<div class="nav-item" onclick="navigate('new-releases')" data-page="new-releases">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>Neue Releases
|
||||
</div>
|
||||
@@ -406,6 +409,18 @@
|
||||
</div>
|
||||
|
||||
<!-- NEW RELEASES -->
|
||||
<div class="page" id="page-artist-news">
|
||||
<div class="page-header">
|
||||
<div class="page-title">Neu von <em>deinen Künstlern</em></div>
|
||||
</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>
|
||||
</div>
|
||||
<div id="artist-news-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="page-new-releases">
|
||||
<div class="page-header">
|
||||
<div class="page-title">Neue <em>Releases</em></div>
|
||||
|
||||
@@ -350,6 +350,8 @@ Object.assign(window, {
|
||||
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
|
||||
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
|
||||
loadDeezerAlbum, closeEventsModal,
|
||||
// Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach.
|
||||
loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)),
|
||||
// Player
|
||||
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
|
||||
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { S } from '../state.js';
|
||||
import { esc } from '../utils.js';
|
||||
import { api } from '../api.js';
|
||||
import { openDlModal } from '../ui/ui.js';
|
||||
// Zirkulär — sicher, weil nur in Ereignisbehandlern aufgerufen
|
||||
import { navigate } from './navigation.js';
|
||||
|
||||
// ─── NEU VON DEINEN KÜNSTLERN ───────────────────────────────────────────────
|
||||
// Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. "Folgen" ist
|
||||
// Navidromes Stern, die Liste kommt also aus getStarred2 — kein eigener Speicher.
|
||||
// Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen.
|
||||
|
||||
let _tage = 90;
|
||||
let _laeuft = false;
|
||||
|
||||
export async function loadArtistNews(tage = _tage) {
|
||||
_tage = tage;
|
||||
document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c =>
|
||||
c.classList.toggle('active', Number(c.dataset.days) === 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">Neue Veröffentlichungen kommen über das Backend von Deezer.</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-releases`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artists: kuenstler, days: tage }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { releases = [], ohneZuordnung = [] } = await res.json();
|
||||
|
||||
// Was hat man schon? Verglichen wird über Künstler + Albumname — Deezers Album-Id hat mit
|
||||
// Navidromes nichts zu tun.
|
||||
const vorhanden = new Set();
|
||||
(r.starred2?.album || []).forEach(a => vorhanden.add(_schluessel(a.artist, a.name)));
|
||||
await _ergaenzeVorhandene(kuenstler, vorhanden);
|
||||
|
||||
el.innerHTML = '';
|
||||
if (ohneZuordnung.length) el.appendChild(_hinweisOhneZuordnung(ohneZuordnung));
|
||||
if (!releases.length) {
|
||||
const leer = document.createElement('div');
|
||||
leer.className = 'empty-state';
|
||||
leer.innerHTML = `<div class="icon">🌙</div><p>Nichts Neues in den letzten ${tage} Tagen</p>`;
|
||||
el.appendChild(leer);
|
||||
return;
|
||||
}
|
||||
_renderZeitleiste(el, releases, vorhanden);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
|
||||
} finally {
|
||||
_laeuft = false;
|
||||
}
|
||||
}
|
||||
|
||||
const _norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
const _schluessel = (kuenstler, album) => `${_norm(kuenstler)}~~${_norm(album)}`;
|
||||
|
||||
/** Alben der gefolgten Künstler aus der eigenen Bibliothek nachtragen (parallel, begrenzt). */
|
||||
async function _ergaenzeVorhandene(kuenstler, vorhanden) {
|
||||
const haeppchen = 6;
|
||||
for (let i = 0; i < kuenstler.length; i += haeppchen) {
|
||||
await Promise.all(kuenstler.slice(i, i + haeppchen).map(async k => {
|
||||
try {
|
||||
const a = await api('getArtist', `&id=${encodeURIComponent(k.id)}`);
|
||||
(a.artist?.album || []).forEach(al => vorhanden.add(_schluessel(k.name, al.name)));
|
||||
} catch {}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function _hinweisOhneZuordnung(namen) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'artist-news-hint';
|
||||
d.innerHTML = `⚠ Für ${namen.length} Künstler wurde kein Deezer-Profil gefunden: `
|
||||
+ `<strong>${namen.slice(0, 4).map(esc).join(', ')}</strong>${namen.length > 4 ? ' …' : ''}. `
|
||||
+ `Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.`;
|
||||
return d;
|
||||
}
|
||||
|
||||
function _renderZeitleiste(el, releases, vorhanden) {
|
||||
let letzterMonat = '';
|
||||
releases.forEach(rel => {
|
||||
const monat = new Date(rel.releaseDate)
|
||||
.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
if (monat !== letzterMonat) {
|
||||
letzterMonat = monat;
|
||||
const h = document.createElement('div');
|
||||
h.className = 'search-section-title';
|
||||
h.style.cssText = 'margin:18px 0 8px';
|
||||
h.textContent = monat;
|
||||
el.appendChild(h);
|
||||
}
|
||||
el.appendChild(_zeile(rel, vorhanden.has(_schluessel(rel.artistName, rel.title))));
|
||||
});
|
||||
}
|
||||
|
||||
const _TYP = { album: 'Album', single: 'Single', ep: 'EP', compile: 'Compilation' };
|
||||
|
||||
function _zeile(rel, schonDa) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'artist-news-row';
|
||||
const datum = new Date(rel.releaseDate).toLocaleDateString('de-DE',
|
||||
{ day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const typ = _TYP[rel.recordType] || rel.recordType;
|
||||
row.innerHTML = `
|
||||
<div class="artist-news-art">${rel.cover ? `<img src="${esc(rel.cover)}" loading="lazy" alt="">` : '<div class="no-art">💿</div>'}</div>
|
||||
<div class="artist-news-info">
|
||||
<div class="artist-news-title">${esc(rel.title)}</div>
|
||||
<div class="artist-news-sub"><span class="artist-news-artist">${esc(rel.artistName)}</span> · ${typ}${rel.trackCount ? ` · ${rel.trackCount} Titel` : ''}</div>
|
||||
<div class="artist-news-date">${datum}</div>
|
||||
</div>`;
|
||||
row.querySelector('.artist-news-artist').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigate('artist-detail', { id: rel.artistId, name: rel.artistName });
|
||||
};
|
||||
|
||||
const aktion = document.createElement('div');
|
||||
aktion.className = 'artist-news-action';
|
||||
if (schonDa) {
|
||||
aktion.innerHTML = '<span class="artist-news-have" title="In deiner Bibliothek">✓ Hast du</span>';
|
||||
} else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-outline blue';
|
||||
btn.style.cssText = 'font-size:12px;padding:7px 12px';
|
||||
btn.textContent = '⬇ Laden';
|
||||
btn.onclick = () => openDlModal({
|
||||
artistName: rel.artistName,
|
||||
trackName: rel.title,
|
||||
collectionName: rel.title,
|
||||
artworkUrl100: rel.cover || '',
|
||||
isAlbum: true,
|
||||
collectionId: rel.albumId,
|
||||
deezerData: {
|
||||
collectionId: rel.albumId, collectionName: rel.title,
|
||||
artistName: rel.artistName, artworkUrl100: rel.cover || '',
|
||||
releaseDate: rel.releaseDate, trackCount: rel.trackCount,
|
||||
},
|
||||
});
|
||||
aktion.appendChild(btn);
|
||||
}
|
||||
row.appendChild(aktion);
|
||||
return row;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function navigate(page, data=null) {
|
||||
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
|
||||
const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active';
|
||||
const cls = ['discover','new-releases','recommendations','artist-news'].includes(page) ? 'active-blue' : 'active';
|
||||
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
|
||||
const pageEl = document.getElementById(`page-${page}`);
|
||||
if(pageEl) {
|
||||
@@ -109,7 +109,7 @@ export function navigate(page, data=null) {
|
||||
}
|
||||
}
|
||||
closeSidebar();
|
||||
if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover');
|
||||
if(['discover','new-releases','recommendations','random','artist-news'].includes(page)) switchNavMode('discover');
|
||||
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
|
||||
|
||||
if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state
|
||||
@@ -123,6 +123,7 @@ export function navigate(page, data=null) {
|
||||
if(page==='genre-detail' && data) loadGenreDetail(data);
|
||||
if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data);
|
||||
if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data);
|
||||
if(page==='artist-news') import('./artistNews.js').then(m => m.loadArtistNews());
|
||||
if(page==='new-releases') loadNewReleases();
|
||||
if(page==='playlists') loadPlaylists();
|
||||
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
|
||||
|
||||
+20
@@ -1178,3 +1178,23 @@ button:focus{outline:none}
|
||||
.cmp-bar{height:6px;border-radius:3px;background:var(--accent)}
|
||||
.cmp-track{display:flex;align-items:center;gap:10px;padding:6px 0;min-width:0}
|
||||
.cmp-track img{width:40px;height:40px;border-radius:6px;object-fit:cover;flex-shrink:0}
|
||||
|
||||
/* ─── NEU VON DEINEN KÜNSTLERN ─────────────────────────────────────────── */
|
||||
.artist-news-hint{background:rgba(232,160,32,.08);border:1px solid rgba(232,160,32,.28);border-radius:10px;padding:10px 14px;font-size:12px;color:var(--text2);line-height:1.5;margin-bottom:14px}
|
||||
.artist-news-hint strong{color:var(--text)}
|
||||
.artist-news-row{display:flex;align-items:center;gap:14px;padding:10px 12px;border-radius:12px;transition:background .15s}
|
||||
.artist-news-row:hover{background:var(--surface)}
|
||||
.artist-news-art{width:56px;height:56px;flex-shrink:0;border-radius:8px;overflow:hidden;background:var(--surface2);display:flex;align-items:center;justify-content:center}
|
||||
.artist-news-art img{width:100%;height:100%;object-fit:cover}
|
||||
.artist-news-info{flex:1;min-width:0}
|
||||
.artist-news-title{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.artist-news-sub{font-size:12px;color:var(--text2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.artist-news-artist{cursor:pointer;transition:color .15s}
|
||||
.artist-news-artist:hover{color:var(--accent);text-decoration:underline}
|
||||
.artist-news-date{font-size:11px;color:var(--text3);margin-top:3px}
|
||||
.artist-news-action{flex-shrink:0}
|
||||
.artist-news-have{font-size:12px;color:var(--text3);white-space:nowrap}
|
||||
@media(max-width:600px){
|
||||
.artist-news-art{width:46px;height:46px}
|
||||
.artist-news-row{gap:10px;padding:8px 6px}
|
||||
}
|
||||
Reference in new issue
Block a user