refactor(webui): remove legacy stats page assets
- delete the old stats page HTML, JS, and CSS now that the React route owns the experience - preserve helper/tour selectors by exposing the legacy stats ids from the React page - move shared track playback fallback into library code
This commit is contained in:
parent
b24152c74b
commit
5b82e6c1ba
8 changed files with 86 additions and 1966 deletions
141
webui/index.html
141
webui/index.html
|
|
@ -6184,147 +6184,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Page -->
|
|
||||||
<div class="page" id="stats-page">
|
|
||||||
<div class="stats-container">
|
|
||||||
<div class="stats-header">
|
|
||||||
<div class="stats-header-title">
|
|
||||||
<img src="/static/trans2.png" alt="Stats" class="page-header-icon">
|
|
||||||
<h1 class="header-title">Listening Stats</h1>
|
|
||||||
</div>
|
|
||||||
<div class="stats-header-controls">
|
|
||||||
<div class="stats-time-range" id="stats-time-range">
|
|
||||||
<button class="stats-range-btn active" data-range="7d">7 Days</button>
|
|
||||||
<button class="stats-range-btn" data-range="30d">30 Days</button>
|
|
||||||
<button class="stats-range-btn" data-range="12m">12 Months</button>
|
|
||||||
<button class="stats-range-btn" data-range="all">All Time</button>
|
|
||||||
</div>
|
|
||||||
<div class="stats-sync-controls">
|
|
||||||
<span class="stats-last-synced" id="stats-last-synced"></span>
|
|
||||||
<button class="stats-sync-btn" id="stats-sync-btn" onclick="triggerStatsSync()" title="Sync now"><span class="stats-sync-icon">↻</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overview Cards -->
|
|
||||||
<div class="stats-overview" id="stats-overview">
|
|
||||||
<div class="stats-card">
|
|
||||||
<div class="stats-card-value" id="stats-total-plays">0</div>
|
|
||||||
<div class="stats-card-label">Total Plays</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-card">
|
|
||||||
<div class="stats-card-value" id="stats-listening-time">0h</div>
|
|
||||||
<div class="stats-card-label">Listening Time</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-card">
|
|
||||||
<div class="stats-card-value" id="stats-unique-artists">0</div>
|
|
||||||
<div class="stats-card-label">Artists</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-card">
|
|
||||||
<div class="stats-card-value" id="stats-unique-albums">0</div>
|
|
||||||
<div class="stats-card-label">Albums</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-card">
|
|
||||||
<div class="stats-card-value" id="stats-unique-tracks">0</div>
|
|
||||||
<div class="stats-card-label">Tracks</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Grid: Charts + Rankings -->
|
|
||||||
<div class="stats-main-grid">
|
|
||||||
<div class="stats-left-col">
|
|
||||||
<div class="stats-section-card">
|
|
||||||
<div class="stats-section-title">Listening Activity</div>
|
|
||||||
<div style="position:relative;height:220px;">
|
|
||||||
<canvas id="stats-timeline-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-section-card">
|
|
||||||
<div class="stats-section-title">Genre Breakdown</div>
|
|
||||||
<div class="stats-genre-chart-container">
|
|
||||||
<canvas id="stats-genre-chart"></canvas>
|
|
||||||
<div class="stats-genre-legend" id="stats-genre-legend"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-section-card">
|
|
||||||
<div class="stats-section-title">Recently Played</div>
|
|
||||||
<div class="stats-recent-list" id="stats-recent-plays"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-right-col">
|
|
||||||
<div class="stats-section-card">
|
|
||||||
<div class="stats-section-title">Top Artists</div>
|
|
||||||
<div class="stats-top-artists-visual" id="stats-top-artists-visual"></div>
|
|
||||||
<div class="stats-ranked-list" id="stats-top-artists"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-section-card">
|
|
||||||
<div class="stats-section-title">Top Albums</div>
|
|
||||||
<div class="stats-ranked-list" id="stats-top-albums"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-section-card">
|
|
||||||
<div class="stats-section-title">Top Tracks</div>
|
|
||||||
<div class="stats-ranked-list" id="stats-top-tracks"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Library Health -->
|
|
||||||
<div class="stats-section-card stats-full-width">
|
|
||||||
<div class="stats-section-title">Library Health</div>
|
|
||||||
<div class="stats-health-grid" id="stats-library-health">
|
|
||||||
<div class="stats-health-item">
|
|
||||||
<div class="stats-health-label">Format Breakdown</div>
|
|
||||||
<div class="stats-format-bar" id="stats-format-bar"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-health-item">
|
|
||||||
<div class="stats-health-value" id="stats-unplayed">0</div>
|
|
||||||
<div class="stats-health-label">Unplayed Tracks</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-health-item">
|
|
||||||
<div class="stats-health-value" id="stats-total-duration">0h</div>
|
|
||||||
<div class="stats-health-label">Total Duration</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-health-item">
|
|
||||||
<div class="stats-health-value" id="stats-total-tracks-count">0</div>
|
|
||||||
<div class="stats-health-label">Total Tracks</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-enrichment" id="stats-enrichment-coverage"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Library Disk Usage -->
|
|
||||||
<div class="stats-section-card stats-full-width">
|
|
||||||
<div class="stats-section-title">Library Disk Usage</div>
|
|
||||||
<div class="stats-disk-usage-wrap" id="stats-disk-usage-wrap">
|
|
||||||
<div class="stats-disk-total-row">
|
|
||||||
<div class="stats-disk-total-value" id="stats-disk-total-value">—</div>
|
|
||||||
<div class="stats-disk-total-meta" id="stats-disk-total-meta">Run a Deep Scan to populate</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-disk-formats" id="stats-disk-formats"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Database Storage -->
|
|
||||||
<div class="stats-section-card stats-full-width">
|
|
||||||
<div class="stats-section-title">Database Storage</div>
|
|
||||||
<div class="stats-db-storage-wrap">
|
|
||||||
<div class="stats-db-chart-container">
|
|
||||||
<canvas id="stats-db-storage-chart"></canvas>
|
|
||||||
<div class="stats-db-total" id="stats-db-total"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-db-legend" id="stats-db-legend"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div class="stats-empty hidden" id="stats-empty">
|
|
||||||
<div class="stats-empty-icon">📊</div>
|
|
||||||
<h3>No Listening Data Yet</h3>
|
|
||||||
<p>Enable "Listening Stats" in Settings to start tracking your listening activity from your media server.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Import Page -->
|
<!-- Import Page -->
|
||||||
<div class="page" id="import-page">
|
<div class="page" id="import-page">
|
||||||
<div class="import-page-container">
|
<div class="import-page-container">
|
||||||
|
|
|
||||||
|
|
@ -134,14 +134,19 @@ export function StatsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.statsContainer} data-testid="stats-page">
|
<div id="stats-container" className={styles.statsContainer} data-testid="stats-page">
|
||||||
<header className={styles.statsHeader}>
|
<header className={styles.statsHeader}>
|
||||||
<div className={styles.statsHeaderTitle}>
|
<div className={styles.statsHeaderTitle}>
|
||||||
<img src="/static/trans2.png" alt="Stats" className={styles.headerIcon} />
|
<img src="/static/trans2.png" alt="Stats" className={styles.headerIcon} />
|
||||||
<h1 className={styles.headerTitle}>Listening Stats</h1>
|
<h1 className={styles.headerTitle}>Listening Stats</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statsHeaderControls}>
|
<div className={styles.statsHeaderControls}>
|
||||||
<div className={styles.statsTimeRange} role="tablist" aria-label="Listening stats range">
|
<div
|
||||||
|
id="stats-time-range"
|
||||||
|
className={styles.statsTimeRange}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Listening stats range"
|
||||||
|
>
|
||||||
{(['7d', '30d', '12m', 'all'] as const).map((option) => (
|
{(['7d', '30d', '12m', 'all'] as const).map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
|
|
@ -160,6 +165,7 @@ export function StatsPage() {
|
||||||
{lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'}
|
{lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
id="stats-sync-btn"
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.statsSyncButton} ${syncing ? styles.statsSyncButtonSyncing : ''}`}
|
className={`${styles.statsSyncButton} ${syncing ? styles.statsSyncButtonSyncing : ''}`}
|
||||||
onClick={() => syncMutation.mutate()}
|
onClick={() => syncMutation.mutate()}
|
||||||
|
|
@ -183,13 +189,13 @@ export function StatsPage() {
|
||||||
<div className={styles.statsMainGrid}>
|
<div className={styles.statsMainGrid}>
|
||||||
<div className={styles.statsLeftCol}>
|
<div className={styles.statsLeftCol}>
|
||||||
<StatsSectionCard title="Listening Activity">
|
<StatsSectionCard title="Listening Activity">
|
||||||
<div className={styles.chartContainer}>
|
<div id="stats-timeline-chart" className={styles.chartContainer}>
|
||||||
<StatsActivityChart timeline={cachedStats?.timeline ?? []} />
|
<StatsActivityChart timeline={cachedStats?.timeline ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
<StatsSectionCard title="Genre Breakdown">
|
<StatsSectionCard title="Genre Breakdown">
|
||||||
<div className={styles.statsGenreChartContainer}>
|
<div className={styles.statsGenreChartContainer}>
|
||||||
<div className={styles.statsGenreChartWrap}>
|
<div id="stats-genre-chart" className={styles.statsGenreChartWrap}>
|
||||||
<StatsGenreChart genres={cachedStats?.genres ?? []} />
|
<StatsGenreChart genres={cachedStats?.genres ?? []} />
|
||||||
</div>
|
</div>
|
||||||
<StatsGenreLegend genres={cachedStats?.genres ?? []} />
|
<StatsGenreLegend genres={cachedStats?.genres ?? []} />
|
||||||
|
|
@ -265,7 +271,7 @@ function OverviewCards({
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.statsOverview}>
|
<div id="stats-overview" className={styles.statsOverview}>
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<div key={card.label} className={styles.statsCard}>
|
<div key={card.label} className={styles.statsCard}>
|
||||||
<div className={styles.statsCardValue}>{card.value}</div>
|
<div className={styles.statsCardValue}>{card.value}</div>
|
||||||
|
|
@ -440,7 +446,7 @@ function StatsRankedArtists({
|
||||||
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.statsRankedList}>
|
<div id="stats-top-artists" className={styles.statsRankedList}>
|
||||||
{artists.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
{artists.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
||||||
{artists.map((artist, index) => (
|
{artists.map((artist, index) => (
|
||||||
<div key={`${artist.name}-${artist.id ?? index}`} className={styles.statsRankedItem}>
|
<div key={`${artist.name}-${artist.id ?? index}`} className={styles.statsRankedItem}>
|
||||||
|
|
@ -490,7 +496,7 @@ function StatsRankedAlbums({
|
||||||
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.statsRankedList}>
|
<div id="stats-top-albums" className={styles.statsRankedList}>
|
||||||
{albums.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
{albums.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
||||||
{albums.map((album, index) => (
|
{albums.map((album, index) => (
|
||||||
<div key={`${album.name}-${index}`} className={styles.statsRankedItem}>
|
<div key={`${album.name}-${index}`} className={styles.statsRankedItem}>
|
||||||
|
|
@ -537,7 +543,7 @@ function StatsRankedTracks({
|
||||||
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
|
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.statsRankedList}>
|
<div id="stats-top-tracks" className={styles.statsRankedList}>
|
||||||
{tracks.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
{tracks.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<div key={`${track.name}-${index}`} className={styles.statsRankedItem}>
|
<div key={`${track.name}-${index}`} className={styles.statsRankedItem}>
|
||||||
|
|
@ -597,7 +603,7 @@ function StatsRecentPlays({
|
||||||
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
|
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.statsRecentList}>
|
<div id="stats-recent-plays" className={styles.statsRecentList}>
|
||||||
{tracks.length === 0 ? <EmptyListState message="No recent plays" /> : null}
|
{tracks.length === 0 ? <EmptyListState message="No recent plays" /> : null}
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<div key={`${track.title}-${track.played_at ?? index}`} className={styles.statsRecentItem}>
|
<div key={`${track.title}-${track.played_at ?? index}`} className={styles.statsRecentItem}>
|
||||||
|
|
@ -639,7 +645,7 @@ function StatsLibraryHealth({ health }: { health: StatsHealth }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div id="stats-library-health">
|
||||||
<div className={styles.statsHealthGrid}>
|
<div className={styles.statsHealthGrid}>
|
||||||
<div className={`${styles.statsHealthItem} ${styles.statsHealthItemWide}`}>
|
<div className={`${styles.statsHealthItem} ${styles.statsHealthItemWide}`}>
|
||||||
<div className={styles.statsHealthLabel}>Format Breakdown</div>
|
<div className={styles.statsHealthLabel}>Format Breakdown</div>
|
||||||
|
|
@ -679,7 +685,7 @@ function StatsLibraryHealth({ health }: { health: StatsHealth }) {
|
||||||
<div className={styles.statsHealthLabel}>Total Tracks</div>
|
<div className={styles.statsHealthLabel}>Total Tracks</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statsEnrichment}>
|
<div id="stats-enrichment-coverage" className={styles.statsEnrichment}>
|
||||||
{STATS_ENRICHMENT_SERVICES.map((service) => {
|
{STATS_ENRICHMENT_SERVICES.map((service) => {
|
||||||
const percent = health.enrichment_coverage?.[service.key] || 0;
|
const percent = health.enrichment_coverage?.[service.key] || 0;
|
||||||
return (
|
return (
|
||||||
|
|
@ -696,7 +702,7 @@ function StatsLibraryHealth({ health }: { health: StatsHealth }) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -769,7 +775,7 @@ function StatsDbStorage({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.statsDbStorageWrap}>
|
<div className={styles.statsDbStorageWrap}>
|
||||||
<div className={styles.statsDbChartContainer}>
|
<div id="stats-db-storage-chart" className={styles.statsDbChartContainer}>
|
||||||
<ResponsiveContainer width={180} height={180}>
|
<ResponsiveContainer width={180} height={180}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
|
|
|
||||||
|
|
@ -1632,7 +1632,7 @@ const HELPER_CONTENT = {
|
||||||
|
|
||||||
// ─── STATS PAGE ──────────────────────────────────────────────────
|
// ─── STATS PAGE ──────────────────────────────────────────────────
|
||||||
|
|
||||||
'.stats-container': {
|
'#stats-container': {
|
||||||
title: 'Listening Stats',
|
title: 'Listening Stats',
|
||||||
description: 'Analytics dashboard showing your listening activity, top artists/albums/tracks, genre breakdown, library health, and storage usage. Data syncs from your media server.',
|
description: 'Analytics dashboard showing your listening activity, top artists/albums/tracks, genre breakdown, library health, and storage usage. Data syncs from your media server.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2420,9 +2420,6 @@ async function loadPageData(pageId) {
|
||||||
loadApiKeys();
|
loadApiKeys();
|
||||||
loadBlacklistCount();
|
loadBlacklistCount();
|
||||||
break;
|
break;
|
||||||
case 'stats':
|
|
||||||
initializeStatsPage();
|
|
||||||
break;
|
|
||||||
case 'import':
|
case 'import':
|
||||||
initializeImportPage();
|
initializeImportPage();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1512,6 +1512,70 @@ const _TOP_TRACKS_SOURCE_LABELS = {
|
||||||
lastfm: 'Popular on Last.fm',
|
lastfm: 'Popular on Last.fm',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function playTrackByMetadata(title, artist, album = '') {
|
||||||
|
// 1. Try the library first — fastest and best quality if owned.
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/stats/resolve-track', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, artist }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success && data.track) {
|
||||||
|
const track = data.track;
|
||||||
|
playLibraryTrack(
|
||||||
|
{
|
||||||
|
id: track.id,
|
||||||
|
title: track.title,
|
||||||
|
file_path: track.file_path,
|
||||||
|
bitrate: track.bitrate,
|
||||||
|
artist_id: track.artist_id,
|
||||||
|
album_id: track.album_id,
|
||||||
|
_stats_image: track.image_url || null,
|
||||||
|
},
|
||||||
|
track.album_title || album || '',
|
||||||
|
track.artist_name || artist || '',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('Library resolve failed, will try streaming fallback:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Library miss — fall back to streaming via the enhanced-search streamer.
|
||||||
|
if (typeof showLoadingOverlay === 'function') {
|
||||||
|
showLoadingOverlay(`Searching for ${title}...`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const streamResp = await fetch('/api/enhanced-search/stream-track', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
track_name: title,
|
||||||
|
artist_name: artist,
|
||||||
|
album_name: album,
|
||||||
|
duration_ms: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const streamData = await streamResp.json();
|
||||||
|
if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay();
|
||||||
|
|
||||||
|
if (streamData.success && streamData.result) {
|
||||||
|
if (typeof startStream === 'function') {
|
||||||
|
await startStream(streamData.result);
|
||||||
|
} else {
|
||||||
|
showToast('Streaming not available', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(streamData.error || 'Track not found in library or any source', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay();
|
||||||
|
showToast('Failed to play track', 'error');
|
||||||
|
console.error('Stream fallback failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function _loadArtistTopTracks(artistName) {
|
async function _loadArtistTopTracks(artistName) {
|
||||||
const sidebar = document.getElementById('artist-hero-sidebar');
|
const sidebar = document.getElementById('artist-hero-sidebar');
|
||||||
const container = document.getElementById('hero-top-tracks');
|
const container = document.getElementById('hero-top-tracks');
|
||||||
|
|
@ -1576,7 +1640,7 @@ async function _loadArtistTopTracks(artistName) {
|
||||||
const playBtn = e.target.closest('.hero-top-track-play');
|
const playBtn = e.target.closest('.hero-top-track-play');
|
||||||
if (playBtn) {
|
if (playBtn) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
playStatsTrack(playBtn.dataset.track, playBtn.dataset.artist, '');
|
playTrackByMetadata(playBtn.dataset.track, playBtn.dataset.artist, '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dlBtn = e.target.closest('.hero-top-track-download');
|
const dlBtn = e.target.closest('.hero-top-track-download');
|
||||||
|
|
@ -1632,7 +1696,7 @@ async function _loadArtistTopTracks(artistName) {
|
||||||
const btn = e.target.closest('.hero-top-track-play');
|
const btn = e.target.closest('.hero-top-track-play');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
playStatsTrack(btn.dataset.track, btn.dataset.artist, '');
|
playTrackByMetadata(btn.dataset.track, btn.dataset.artist, '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
sidebar.style.display = '';
|
sidebar.style.display = '';
|
||||||
|
|
|
||||||
|
|
@ -2750,323 +2750,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================================
|
|
||||||
STATS PAGE — Mobile
|
|
||||||
====================================== */
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.stats-container {
|
|
||||||
padding: 12px !important;
|
|
||||||
margin: 8px !important;
|
|
||||||
gap: 14px;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 12px !important;
|
|
||||||
margin: -12px -12px 0 -12px !important;
|
|
||||||
gap: 10px;
|
|
||||||
border-top-left-radius: 16px;
|
|
||||||
border-top-right-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header-title {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header-title .page-header-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header-title h1 {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header-controls {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start !important;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-time-range {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-range-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-sync-controls {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-last-synced {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-sync-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overview cards — 2 columns */
|
|
||||||
.stats-overview {
|
|
||||||
grid-template-columns: repeat(2, 1fr) !important;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card {
|
|
||||||
padding: 12px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card-value {
|
|
||||||
font-size: 18px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card-label {
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main grid — single column */
|
|
||||||
.stats-main-grid {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-left-col,
|
|
||||||
.stats-right-col {
|
|
||||||
width: 100% !important;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-section-card {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-section-title {
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Timeline chart — shrink height */
|
|
||||||
.stats-section-card > div[style*="height:220px"],
|
|
||||||
.stats-section-card > div[style*="height: 220px"] {
|
|
||||||
height: 160px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genre chart — stack vertically, shrink canvas */
|
|
||||||
.stats-genre-chart-container {
|
|
||||||
flex-direction: column !important;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-genre-chart-container canvas {
|
|
||||||
width: 130px !important;
|
|
||||||
height: 130px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-genre-legend {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-genre-legend-item {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Top artists bubbles */
|
|
||||||
.stats-artist-bubbles {
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-artist-bubble {
|
|
||||||
max-width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-img {
|
|
||||||
width: 44px !important;
|
|
||||||
height: 44px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-name {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-count {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-bar-container {
|
|
||||||
height: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ranked lists */
|
|
||||||
.stats-ranked-item {
|
|
||||||
padding: 8px 8px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-num {
|
|
||||||
font-size: 11px;
|
|
||||||
min-width: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-img {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-info {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-name {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-meta {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-count {
|
|
||||||
font-size: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-play-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 8px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-play-btn-sm {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
font-size: 7px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Recent plays */
|
|
||||||
.stats-recent-item {
|
|
||||||
padding: 6px 8px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-recent-title {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-recent-artist {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-recent-time {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Library health */
|
|
||||||
.stats-full-width {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-health-grid {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-health-label {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-health-value {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-format-bar {
|
|
||||||
min-height: 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-format-segment {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enrichment coverage bars */
|
|
||||||
.stats-enrichment {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-enrich-item {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-enrich-name {
|
|
||||||
font-size: 10px;
|
|
||||||
min-width: 55px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-enrich-pct {
|
|
||||||
font-size: 10px;
|
|
||||||
min-width: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* DB storage chart */
|
|
||||||
.stats-db-storage-wrap {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-chart-container {
|
|
||||||
width: 140px;
|
|
||||||
height: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-chart-container canvas {
|
|
||||||
width: 140px !important;
|
|
||||||
height: 140px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-total-value {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-legend {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.stats-empty-icon {
|
|
||||||
font-size: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-empty h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-empty p {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================
|
/* ======================================
|
||||||
ARTIST ENRICHMENT RINGS — Mobile
|
ARTIST ENRICHMENT RINGS — Mobile
|
||||||
====================================== */
|
====================================== */
|
||||||
|
|
|
||||||
|
|
@ -21,614 +21,6 @@ const importPageState = {
|
||||||
_albumLookup: {}, // { albumId: { id, name, artist, source } }
|
_albumLookup: {}, // { albumId: { id, name, artist, source } }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===============================
|
|
||||||
// STATS PAGE
|
|
||||||
// ===============================
|
|
||||||
|
|
||||||
let _statsRange = '7d';
|
|
||||||
let _statsTimelineChart = null;
|
|
||||||
let _statsGenreChart = null;
|
|
||||||
let _statsDbStorageChart = null;
|
|
||||||
let _statsInitialized = false;
|
|
||||||
|
|
||||||
function initializeStatsPage() {
|
|
||||||
if (_statsInitialized) {
|
|
||||||
loadStatsData();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_statsInitialized = true;
|
|
||||||
|
|
||||||
// Time range buttons
|
|
||||||
const rangeContainer = document.getElementById('stats-time-range');
|
|
||||||
if (rangeContainer) {
|
|
||||||
rangeContainer.addEventListener('click', (e) => {
|
|
||||||
const btn = e.target.closest('.stats-range-btn');
|
|
||||||
if (!btn) return;
|
|
||||||
_statsRange = btn.dataset.range;
|
|
||||||
rangeContainer.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
loadStatsData();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadStatsData();
|
|
||||||
_updateStatsLastSynced();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerStatsSync() {
|
|
||||||
const btn = document.getElementById('stats-sync-btn');
|
|
||||||
if (btn) btn.classList.add('syncing');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/listening-stats/sync', { method: 'POST' });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
showToast('Syncing listening data...', 'info');
|
|
||||||
// Wait a few seconds for the sync to complete, then reload
|
|
||||||
setTimeout(async () => {
|
|
||||||
await loadStatsData();
|
|
||||||
_updateStatsLastSynced();
|
|
||||||
if (btn) btn.classList.remove('syncing');
|
|
||||||
showToast('Listening stats updated', 'success');
|
|
||||||
}, 5000);
|
|
||||||
} else {
|
|
||||||
showToast(data.error || 'Sync failed', 'error');
|
|
||||||
if (btn) btn.classList.remove('syncing');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Sync failed', 'error');
|
|
||||||
if (btn) btn.classList.remove('syncing');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _updateStatsLastSynced() {
|
|
||||||
const el = document.getElementById('stats-last-synced');
|
|
||||||
if (!el) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/listening-stats/status');
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.stats && data.stats.last_poll) {
|
|
||||||
el.textContent = `Last synced: ${data.stats.last_poll}`;
|
|
||||||
} else {
|
|
||||||
el.textContent = 'Not synced yet';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
el.textContent = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStatsData() {
|
|
||||||
// Show loading state
|
|
||||||
document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '0.3');
|
|
||||||
|
|
||||||
// Single cached endpoint — instant response
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/stats/cached?range=${_statsRange}`);
|
|
||||||
data = await resp.json();
|
|
||||||
} catch {
|
|
||||||
data = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
// Cache not available — show empty state, user should hit Sync
|
|
||||||
data = {
|
|
||||||
overview: {}, top_artists: [], top_albums: [], top_tracks: [],
|
|
||||||
timeline: [], genres: [], recent: [], health: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const overview = data.overview || {};
|
|
||||||
const emptyEl = document.getElementById('stats-empty');
|
|
||||||
const hasData = (overview.total_plays || 0) > 0;
|
|
||||||
|
|
||||||
if (emptyEl) {
|
|
||||||
emptyEl.classList.toggle('hidden', hasData);
|
|
||||||
}
|
|
||||||
// Hide main content sections when no data
|
|
||||||
const mainSections = document.querySelectorAll('.stats-overview, .stats-main-grid, .stats-full-width');
|
|
||||||
mainSections.forEach(el => el.style.display = hasData ? '' : 'none');
|
|
||||||
|
|
||||||
// Overview cards
|
|
||||||
const _fmt = (n) => {
|
|
||||||
if (!n) return '0';
|
|
||||||
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
||||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
||||||
return n.toLocaleString();
|
|
||||||
};
|
|
||||||
const _fmtTime = (ms) => {
|
|
||||||
if (!ms) return '0h';
|
|
||||||
const hours = Math.floor(ms / 3600000);
|
|
||||||
const mins = Math.floor((ms % 3600000) / 60000);
|
|
||||||
if (hours > 0) return `${hours}h ${mins}m`;
|
|
||||||
return `${mins}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore opacity
|
|
||||||
document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '1');
|
|
||||||
|
|
||||||
_setText('stats-total-plays', _fmt(overview.total_plays));
|
|
||||||
_setText('stats-listening-time', _fmtTime(overview.total_time_ms));
|
|
||||||
_setText('stats-unique-artists', _fmt(overview.unique_artists));
|
|
||||||
_setText('stats-unique-albums', _fmt(overview.unique_albums));
|
|
||||||
_setText('stats-unique-tracks', _fmt(overview.unique_tracks));
|
|
||||||
|
|
||||||
// Top Artists — visual bubbles
|
|
||||||
_renderTopArtistsVisual(data.top_artists || []);
|
|
||||||
|
|
||||||
// Top Artists — ranked list
|
|
||||||
_renderRankedList('stats-top-artists', data.top_artists || [], (item, i) => `
|
|
||||||
<div class="stats-ranked-item">
|
|
||||||
<span class="stats-ranked-num">${i + 1}</span>
|
|
||||||
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
|
|
||||||
<div class="stats-ranked-info">
|
|
||||||
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.id)}">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
|
|
||||||
<div class="stats-ranked-meta">${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}</div>
|
|
||||||
</div>
|
|
||||||
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Top Albums
|
|
||||||
_renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => `
|
|
||||||
<div class="stats-ranked-item">
|
|
||||||
<span class="stats-ranked-num">${i + 1}</span>
|
|
||||||
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
|
|
||||||
<div class="stats-ranked-info">
|
|
||||||
<div class="stats-ranked-name">${_esc(item.name)}</div>
|
|
||||||
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.artist_id)}">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
|
|
||||||
</div>
|
|
||||||
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Top Tracks
|
|
||||||
_renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => `
|
|
||||||
<div class="stats-ranked-item">
|
|
||||||
<span class="stats-ranked-num">${i + 1}</span>
|
|
||||||
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
|
|
||||||
<div class="stats-ranked-info">
|
|
||||||
<div class="stats-ranked-name">${_esc(item.name)}</div>
|
|
||||||
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.artist_id)}">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
|
|
||||||
</div>
|
|
||||||
<button class="stats-play-btn" onclick="event.stopPropagation();playStatsTrack('${_esc(item.name).replace(/'/g, "\\'")}','${_esc(item.artist || '').replace(/'/g, "\\'")}','${_esc(item.album || '').replace(/'/g, "\\'")}')" title="Play">▶</button>
|
|
||||||
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Timeline chart
|
|
||||||
_renderTimelineChart(data.timeline || []);
|
|
||||||
|
|
||||||
// Genre chart
|
|
||||||
_renderGenreChart(data.genres || []);
|
|
||||||
|
|
||||||
// Library health
|
|
||||||
_renderLibraryHealth(data.health || {});
|
|
||||||
|
|
||||||
// DB storage chart (separate fetch — not part of cached stats)
|
|
||||||
_loadDbStorageChart();
|
|
||||||
// Library disk usage (separate fetch — populated by deep scan)
|
|
||||||
_loadLibraryDiskUsage();
|
|
||||||
|
|
||||||
// Recent plays
|
|
||||||
_renderRecentPlays(data.recent || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderTopArtistsVisual(artists) {
|
|
||||||
const el = document.getElementById('stats-top-artists-visual');
|
|
||||||
if (!el || !artists.length) { if (el) el.innerHTML = ''; return; }
|
|
||||||
|
|
||||||
const top5 = artists.slice(0, 5);
|
|
||||||
const maxPlays = top5[0]?.play_count || 1;
|
|
||||||
const _fmt = (n) => {
|
|
||||||
if (!n) return '0';
|
|
||||||
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
||||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
||||||
return n.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
el.innerHTML = `<div class="stats-artist-bubbles">
|
|
||||||
${top5.map((a, i) => {
|
|
||||||
const pct = Math.round((a.play_count / maxPlays) * 100);
|
|
||||||
const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44
|
|
||||||
return `<a class="stats-artist-bubble" href="${a.id ? buildArtistDetailPath(a.id, a.source || null) : '#'}" style="cursor:${a.id ? 'pointer' : 'default'};text-decoration:none;color:inherit;">
|
|
||||||
<div class="stats-bubble-img" style="width:${size}px;height:${size}px;${a.image_url ? `background-image:url('${a.image_url}')` : ''}">
|
|
||||||
${!a.image_url ? `<span>${(a.name || '?')[0]}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="stats-bubble-bar-container">
|
|
||||||
<div class="stats-bubble-bar" style="width:${pct}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-bubble-name">${_esc(a.name)}</div>
|
|
||||||
<div class="stats-bubble-count">${_fmt(a.play_count)}</div>
|
|
||||||
</a>`;
|
|
||||||
}).join('')}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setText(id, text) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderRankedList(containerId, items, template) {
|
|
||||||
const el = document.getElementById(containerId);
|
|
||||||
if (!el) return;
|
|
||||||
el.innerHTML = items.length
|
|
||||||
? items.map((item, i) => template(item, i)).join('')
|
|
||||||
: '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No data yet</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderTimelineChart(data) {
|
|
||||||
const canvas = document.getElementById('stats-timeline-chart');
|
|
||||||
if (!canvas || typeof Chart === 'undefined') return;
|
|
||||||
|
|
||||||
if (_statsTimelineChart) _statsTimelineChart.destroy();
|
|
||||||
|
|
||||||
_statsTimelineChart = new Chart(canvas, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: data.map(d => d.date),
|
|
||||||
datasets: [{
|
|
||||||
label: 'Plays',
|
|
||||||
data: data.map(d => d.plays),
|
|
||||||
backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`,
|
|
||||||
borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 4,
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
scales: {
|
|
||||||
x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } },
|
|
||||||
y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderGenreChart(data) {
|
|
||||||
const canvas = document.getElementById('stats-genre-chart');
|
|
||||||
const legend = document.getElementById('stats-genre-legend');
|
|
||||||
if (!canvas || typeof Chart === 'undefined') return;
|
|
||||||
|
|
||||||
if (_statsGenreChart) _statsGenreChart.destroy();
|
|
||||||
|
|
||||||
const colors = [
|
|
||||||
'#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7',
|
|
||||||
'#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4',
|
|
||||||
'#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b',
|
|
||||||
];
|
|
||||||
|
|
||||||
const top = data.slice(0, 10);
|
|
||||||
|
|
||||||
_statsGenreChart = new Chart(canvas, {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: top.map(g => g.genre),
|
|
||||||
datasets: [{
|
|
||||||
data: top.map(g => g.play_count),
|
|
||||||
backgroundColor: colors.slice(0, top.length),
|
|
||||||
borderWidth: 0,
|
|
||||||
hoverOffset: 6,
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: true,
|
|
||||||
cutout: '65%',
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (legend) {
|
|
||||||
legend.innerHTML = top.map((g, i) => `
|
|
||||||
<div class="stats-genre-legend-item">
|
|
||||||
<span class="stats-genre-dot" style="background:${colors[i]}"></span>
|
|
||||||
<span>${g.genre}</span>
|
|
||||||
<span class="stats-genre-pct">${g.percentage}%</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderLibraryHealth(data) {
|
|
||||||
if (!data || !data.total_tracks) return;
|
|
||||||
|
|
||||||
const _fmt = (n) => {
|
|
||||||
if (!n) return '0';
|
|
||||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
||||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
||||||
return n.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
_setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`);
|
|
||||||
_setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h');
|
|
||||||
_setText('stats-total-tracks-count', _fmt(data.total_tracks));
|
|
||||||
|
|
||||||
// Format bar
|
|
||||||
const bar = document.getElementById('stats-format-bar');
|
|
||||||
if (bar && data.format_breakdown) {
|
|
||||||
const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1;
|
|
||||||
const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' };
|
|
||||||
|
|
||||||
bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => {
|
|
||||||
const pct = (count / total * 100).toFixed(1);
|
|
||||||
return `<div class="stats-format-segment" style="flex:${count};background:${fmtColors[fmt] || '#555'}" title="${fmt}: ${count} tracks (${pct}%)">${pct > 8 ? fmt : ''}</div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrichment coverage
|
|
||||||
const enrichEl = document.getElementById('stats-enrichment-coverage');
|
|
||||||
if (enrichEl && data.enrichment_coverage) {
|
|
||||||
const ec = data.enrichment_coverage;
|
|
||||||
const services = [
|
|
||||||
{ name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' },
|
|
||||||
{ name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' },
|
|
||||||
{ name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' },
|
|
||||||
{ name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' },
|
|
||||||
{ name: 'iTunes', pct: ec.itunes || 0, color: '#fc3c44' },
|
|
||||||
{ name: 'AudioDB', pct: ec.audiodb || 0, color: '#1a9fff' },
|
|
||||||
{ name: 'Genius', pct: ec.genius || 0, color: '#ffff64' },
|
|
||||||
{ name: 'Tidal', pct: ec.tidal || 0, color: '#00ffff' },
|
|
||||||
{ name: 'Qobuz', pct: ec.qobuz || 0, color: '#4285f4' },
|
|
||||||
];
|
|
||||||
enrichEl.innerHTML = services.map(s => `
|
|
||||||
<div class="stats-enrich-item">
|
|
||||||
<span class="stats-enrich-name">${s.name}</span>
|
|
||||||
<div class="stats-enrich-bar"><div class="stats-enrich-fill" style="width:${s.pct}%;background:${s.color}"></div></div>
|
|
||||||
<span class="stats-enrich-pct">${s.pct}%</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadDbStorageChart() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/stats/db-storage');
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.success || !data.tables || !data.tables.length) return;
|
|
||||||
_renderDbStorageChart(data.tables, data.total_file_size, data.method);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug('DB storage chart load failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadLibraryDiskUsage() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/stats/library-disk-usage');
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.success) return;
|
|
||||||
_renderLibraryDiskUsage(data);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug('Library disk usage load failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _formatBytes(n) {
|
|
||||||
if (!n || n <= 0) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
let i = 0;
|
|
||||||
let v = n;
|
|
||||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
||||||
return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderLibraryDiskUsage(data) {
|
|
||||||
const totalEl = document.getElementById('stats-disk-total-value');
|
|
||||||
const metaEl = document.getElementById('stats-disk-total-meta');
|
|
||||||
const formatsEl = document.getElementById('stats-disk-formats');
|
|
||||||
if (!totalEl || !metaEl || !formatsEl) return;
|
|
||||||
|
|
||||||
if (!data.has_data || !data.total_bytes) {
|
|
||||||
totalEl.textContent = '—';
|
|
||||||
metaEl.textContent = data.tracks_without_size > 0
|
|
||||||
? `Run a Deep Scan to populate (${data.tracks_without_size.toLocaleString()} tracks pending)`
|
|
||||||
: 'No tracks in library yet';
|
|
||||||
formatsEl.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalEl.textContent = _formatBytes(data.total_bytes);
|
|
||||||
|
|
||||||
const withSize = data.tracks_with_size || 0;
|
|
||||||
const withoutSize = data.tracks_without_size || 0;
|
|
||||||
const trackBits = `${withSize.toLocaleString()} tracks measured`;
|
|
||||||
const pendingBits = withoutSize > 0
|
|
||||||
? ` (+${withoutSize.toLocaleString()} pending next Deep Scan)`
|
|
||||||
: '';
|
|
||||||
metaEl.textContent = trackBits + pendingBits;
|
|
||||||
|
|
||||||
// Per-format bars sorted by size descending. Skip if no breakdown.
|
|
||||||
const formats = Object.entries(data.by_format || {}).sort((a, b) => b[1] - a[1]);
|
|
||||||
if (!formats.length) { formatsEl.innerHTML = ''; return; }
|
|
||||||
|
|
||||||
const max = formats[0][1] || 1;
|
|
||||||
formatsEl.innerHTML = formats.map(([ext, bytes]) => {
|
|
||||||
const pct = Math.max(2, Math.round((bytes / max) * 100));
|
|
||||||
return `
|
|
||||||
<div class="stats-disk-format-row">
|
|
||||||
<span class="stats-disk-format-name">${ext.toUpperCase()}</span>
|
|
||||||
<div class="stats-disk-format-bar">
|
|
||||||
<div class="stats-disk-format-fill" style="width:${pct}%"></div>
|
|
||||||
</div>
|
|
||||||
<span class="stats-disk-format-size">${_formatBytes(bytes)}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderDbStorageChart(tables, totalFileSize, method) {
|
|
||||||
const canvas = document.getElementById('stats-db-storage-chart');
|
|
||||||
if (!canvas || typeof Chart === 'undefined') return;
|
|
||||||
|
|
||||||
if (_statsDbStorageChart) _statsDbStorageChart.destroy();
|
|
||||||
|
|
||||||
// Top 8 tables, group rest as "Other"
|
|
||||||
const top = tables.slice(0, 8);
|
|
||||||
const rest = tables.slice(8);
|
|
||||||
const restSize = rest.reduce((s, t) => s + t.size, 0);
|
|
||||||
if (restSize > 0) top.push({ name: 'Other', size: restSize });
|
|
||||||
|
|
||||||
const colors = ['#3b82f6', '#f97316', '#a855f7', '#14b8a6', '#eab308', '#ec4899', '#6366f1', '#22c55e', '#555'];
|
|
||||||
|
|
||||||
_statsDbStorageChart = new Chart(canvas, {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: top.map(t => t.name),
|
|
||||||
datasets: [{
|
|
||||||
data: top.map(t => t.size),
|
|
||||||
backgroundColor: colors.slice(0, top.length),
|
|
||||||
borderWidth: 0,
|
|
||||||
hoverOffset: 4,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: false,
|
|
||||||
cutout: '65%',
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (ctx) => {
|
|
||||||
const val = ctx.parsed;
|
|
||||||
if (method === 'dbstat') {
|
|
||||||
if (val > 1048576) return ` ${(val / 1048576).toFixed(1)} MB`;
|
|
||||||
return ` ${(val / 1024).toFixed(0)} KB`;
|
|
||||||
}
|
|
||||||
return ` ${val.toLocaleString()} rows`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Center label — total file size
|
|
||||||
const totalEl = document.getElementById('stats-db-total');
|
|
||||||
if (totalEl) {
|
|
||||||
let sizeStr;
|
|
||||||
if (totalFileSize > 1073741824) sizeStr = (totalFileSize / 1073741824).toFixed(2) + ' GB';
|
|
||||||
else if (totalFileSize > 1048576) sizeStr = (totalFileSize / 1048576).toFixed(1) + ' MB';
|
|
||||||
else sizeStr = (totalFileSize / 1024).toFixed(0) + ' KB';
|
|
||||||
totalEl.innerHTML = `<div class="stats-db-total-value">${sizeStr}</div><div class="stats-db-total-label">Total Size</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legend
|
|
||||||
const legendEl = document.getElementById('stats-db-legend');
|
|
||||||
if (legendEl) {
|
|
||||||
legendEl.innerHTML = top.map((t, i) => {
|
|
||||||
let sizeLabel;
|
|
||||||
if (method === 'dbstat') {
|
|
||||||
if (t.size > 1048576) sizeLabel = (t.size / 1048576).toFixed(1) + ' MB';
|
|
||||||
else sizeLabel = (t.size / 1024).toFixed(0) + ' KB';
|
|
||||||
} else {
|
|
||||||
sizeLabel = t.size.toLocaleString() + ' rows';
|
|
||||||
}
|
|
||||||
return `<div class="stats-db-legend-item">
|
|
||||||
<span class="stats-db-legend-dot" style="background:${colors[i]}"></span>
|
|
||||||
<span class="stats-db-legend-name">${t.name}</span>
|
|
||||||
<span class="stats-db-legend-size">${sizeLabel}</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playStatsTrack(title, artist, album) {
|
|
||||||
// 1. Try the library first — fastest and best quality if owned.
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/stats/resolve-track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title, artist }),
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success && data.track) {
|
|
||||||
const t = data.track;
|
|
||||||
playLibraryTrack({
|
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
file_path: t.file_path,
|
|
||||||
bitrate: t.bitrate,
|
|
||||||
artist_id: t.artist_id,
|
|
||||||
album_id: t.album_id,
|
|
||||||
_stats_image: t.image_url || null,
|
|
||||||
}, t.album_title || album || '', t.artist_name || artist || '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug('Library resolve failed, will try streaming fallback:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Library miss — fall back to streaming via the enhanced-search streamer
|
|
||||||
// (Soulseek → YouTube → other configured sources, same pipeline used by
|
|
||||||
// the search results' play button).
|
|
||||||
if (typeof showLoadingOverlay === 'function') {
|
|
||||||
showLoadingOverlay(`Searching for ${title}...`);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const streamResp = await fetch('/api/enhanced-search/stream-track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
track_name: title,
|
|
||||||
artist_name: artist,
|
|
||||||
album_name: album || '',
|
|
||||||
duration_ms: 0,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const streamData = await streamResp.json();
|
|
||||||
if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay();
|
|
||||||
|
|
||||||
if (streamData.success && streamData.result) {
|
|
||||||
if (typeof startStream === 'function') {
|
|
||||||
await startStream(streamData.result);
|
|
||||||
} else {
|
|
||||||
showToast('Streaming not available', 'error');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showToast(streamData.error || 'Track not found in library or any source', 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay();
|
|
||||||
showToast('Failed to play track', 'error');
|
|
||||||
console.error('Stream fallback failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderRecentPlays(tracks) {
|
|
||||||
const el = document.getElementById('stats-recent-plays');
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
if (!tracks.length) {
|
|
||||||
el.innerHTML = '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No recent plays</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _ago = (dateStr) => {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
|
||||||
const mins = Math.floor(diff / 60000);
|
|
||||||
if (mins < 60) return `${mins}m ago`;
|
|
||||||
const hours = Math.floor(mins / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
if (days < 30) return `${days}d ago`;
|
|
||||||
return `${Math.floor(days / 30)}mo ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
el.innerHTML = tracks.map(t => `
|
|
||||||
<div class="stats-recent-item">
|
|
||||||
<button class="stats-play-btn stats-play-btn-sm" onclick="event.stopPropagation();playStatsTrack('${_esc(t.title).replace(/'/g, "\\'")}','${_esc(t.artist || '').replace(/'/g, "\\'")}','${_esc(t.album || '').replace(/'/g, "\\'")}')" title="Play">▶</button>
|
|
||||||
<span class="stats-recent-title">${_esc(t.title)}</span>
|
|
||||||
<span class="stats-recent-artist">${_esc(t.artist || '')}</span>
|
|
||||||
<span class="stats-recent-time">${_ago(t.played_at)}</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Initialization ---
|
// --- Initialization ---
|
||||||
|
|
||||||
function initializeImportPage() {
|
function initializeImportPage() {
|
||||||
|
|
|
||||||
|
|
@ -40072,887 +40072,6 @@ div.artist-hero-badge {
|
||||||
IMPORT PAGE (full page, replaces modal)
|
IMPORT PAGE (full page, replaces modal)
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
STATS PAGE
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
/* Stats page uses dashboard-container pattern for consistency */
|
|
||||||
.stats-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 28px 24px 30px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: linear-gradient(135deg,
|
|
||||||
rgba(20, 20, 20, 0.55) 0%,
|
|
||||||
rgba(12, 12, 12, 0.62) 100%);
|
|
||||||
border-radius: 24px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
margin: 20px;
|
|
||||||
box-shadow:
|
|
||||||
0 8px 32px rgba(0, 0, 0, 0.3),
|
|
||||||
0 4px 16px rgba(0, 0, 0, 0.2),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header uses same pattern as .dashboard-header */
|
|
||||||
.stats-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 20px 24px;
|
|
||||||
margin: -28px -24px 0 -24px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
background: linear-gradient(180deg,
|
|
||||||
rgba(var(--accent-rgb), 0.10) 0%,
|
|
||||||
rgba(var(--accent-rgb), 0.04) 40%,
|
|
||||||
transparent 100%);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-top-left-radius: 24px;
|
|
||||||
border-top-right-radius: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 50%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
|
|
||||||
animation: stats-header-sweep 12s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stats-header-sweep {
|
|
||||||
0%, 100% { left: -100%; }
|
|
||||||
50% { left: 150%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header-title h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'SF Pro Display', -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time range pills */
|
|
||||||
.stats-time-range {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-range-btn {
|
|
||||||
padding: 7px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-size: 0.82em;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-range-btn:hover {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-range-btn.active {
|
|
||||||
background: rgb(var(--accent-rgb));
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-sync-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-last-synced {
|
|
||||||
font-size: 0.72em;
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-sync-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-family: inherit;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-sync-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: #fff;
|
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-sync-btn.syncing {
|
|
||||||
pointer-events: none;
|
|
||||||
color: transparent;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-sync-btn.syncing::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid rgba(var(--accent-rgb), 0.2);
|
|
||||||
border-top-color: rgba(var(--accent-rgb), 0.8);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: stats-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stats-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overview cards */
|
|
||||||
.stats-overview {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card {
|
|
||||||
background: linear-gradient(135deg, rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 20%;
|
|
||||||
right: 20%;
|
|
||||||
height: 2px;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
border-color: rgba(var(--accent-rgb), 0.2);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 20px rgba(var(--accent-rgb), 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card:hover::before {
|
|
||||||
left: 10%;
|
|
||||||
right: 10%;
|
|
||||||
height: 3px;
|
|
||||||
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card-value {
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-family: 'SF Pro Display', -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card-label {
|
|
||||||
font-size: 0.78em;
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main grid */
|
|
||||||
.stats-main-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 360px;
|
|
||||||
gap: 20px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-left-col, .stats-right-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-two-col {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section cards */
|
|
||||||
.stats-section-card {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 20px;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-section-card:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-full-width {
|
|
||||||
/* No extra margin — container handles it */
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-section-title {
|
|
||||||
font-size: 0.78em;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genre chart */
|
|
||||||
.stats-genre-chart-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-genre-chart-container canvas {
|
|
||||||
width: 180px !important;
|
|
||||||
height: 180px !important;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-genre-legend {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-genre-legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.82em;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-genre-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-genre-pct {
|
|
||||||
margin-left: auto;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Top artists visual bubbles */
|
|
||||||
.stats-top-artists-visual {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-bottom: 14px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-artist-bubbles {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-artist-bubble {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-artist-bubble:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-img {
|
|
||||||
border-radius: 50%;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-color: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 2px solid rgba(var(--accent-rgb), 0.2);
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-artist-bubble:hover .stats-bubble-img {
|
|
||||||
border-color: rgba(var(--accent-rgb), 0.5);
|
|
||||||
box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-img span {
|
|
||||||
font-size: 1.2em;
|
|
||||||
font-weight: 700;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-bar-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 3px;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4));
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-name {
|
|
||||||
font-size: 0.7em;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bubble-count {
|
|
||||||
font-size: 0.65em;
|
|
||||||
color: rgba(var(--accent-rgb), 0.7);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ranked lists */
|
|
||||||
.stats-ranked-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
max-height: 280px;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-item:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-num {
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: rgba(255, 255, 255, 0.25);
|
|
||||||
font-weight: 700;
|
|
||||||
width: 18px;
|
|
||||||
text-align: right;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-img {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 6px;
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-name {
|
|
||||||
font-size: 0.88em;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-meta {
|
|
||||||
font-size: 0.72em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-count {
|
|
||||||
font-size: 0.78em;
|
|
||||||
color: rgba(var(--accent-rgb), 0.8);
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-artist-link {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-artist-link:hover {
|
|
||||||
color: rgb(var(--accent-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Play buttons */
|
|
||||||
.stats-play-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: rgba(var(--accent-rgb), 0.15);
|
|
||||||
color: rgb(var(--accent-rgb));
|
|
||||||
font-size: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-ranked-item:hover .stats-play-btn,
|
|
||||||
.stats-recent-item:hover .stats-play-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-play-btn:hover {
|
|
||||||
background: rgb(var(--accent-rgb));
|
|
||||||
color: #fff;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-play-btn-sm {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
font-size: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Library health */
|
|
||||||
.stats-health-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-health-item {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-health-item:first-child {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-health-value {
|
|
||||||
font-size: 1.6em;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-health-label {
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Format breakdown bar */
|
|
||||||
.stats-format-bar {
|
|
||||||
display: flex;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-format-segment {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.68em;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 30px;
|
|
||||||
transition: flex 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Recent plays */
|
|
||||||
.stats-recent-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-recent-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-recent-item:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-recent-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-recent-artist {
|
|
||||||
font-size: 0.78em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-recent-time {
|
|
||||||
font-size: 0.72em;
|
|
||||||
color: rgba(255, 255, 255, 0.25);
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 65px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enrichment coverage */
|
|
||||||
.stats-enrichment {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-enrich-item {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-enrich-name {
|
|
||||||
font-size: 0.72em;
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
min-width: 70px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-enrich-bar {
|
|
||||||
flex: 1;
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-enrich-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-enrich-pct {
|
|
||||||
font-size: 0.72em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
min-width: 30px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Library Disk Usage */
|
|
||||||
.stats-disk-usage-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-total-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-total-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: rgb(var(--accent-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-total-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-formats {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-format-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 60px 1fr 80px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-format-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-format-bar {
|
|
||||||
height: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-format-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg,
|
|
||||||
rgb(var(--accent-rgb)) 0%,
|
|
||||||
rgba(var(--accent-rgb), 0.6) 100%);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-disk-format-size {
|
|
||||||
text-align: right;
|
|
||||||
color: rgba(255, 255, 255, 0.55);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Database Storage Chart */
|
|
||||||
.stats-db-storage-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-chart-container {
|
|
||||||
position: relative;
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-chart-container canvas {
|
|
||||||
width: 180px !important;
|
|
||||||
height: 180px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-total {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-total-value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-total-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-legend {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-legend-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-legend-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-db-legend-size {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats empty state */
|
|
||||||
.stats-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 80px 20px;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-empty-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-empty h3 {
|
|
||||||
font-size: 1.2em;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-empty p {
|
|
||||||
font-size: 0.88em;
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.stats-container {
|
|
||||||
margin: 10px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.stats-overview {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
.stats-main-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.stats-health-grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
.stats-genre-chart-container {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.stats-two-col {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.stats-genre-chart-container canvas {
|
|
||||||
width: 150px !important;
|
|
||||||
height: 150px !important;
|
|
||||||
}
|
|
||||||
.stats-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.stats-header-controls {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.stats-card-value {
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
IMPORT PAGE
|
IMPORT PAGE
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue