From 808c52484ce73acb9f29861c037d7526f1b83d29 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sun, 14 Jun 2026 01:20:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(storage):=20functional=20Storage=20dashboa?= =?UTF-8?q?rd=20(=C2=A7A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "coming soon" stub with a real dashboard wired to `GET /storage`. modern-sk visuals: a layered disk-capacity gauge (library share vs other-used vs free), stat tiles (tracks/artists/albums/playtime/ footprint/avg size), per-format size bars, metadata-health badges, source breakdown, a popularity-weighted top-genres cloud, and playful fun facts. - types: full `StorageStats` shape + `toStorageStats` snake→camel mapper - endpoint: re-point `getStorageStats` to `GET /storage` with transform - lib: `formatLongDuration` for big playtime spans - i18n: `storage.*` keys (en + ru) - three list states (loading / error / empty) per the UI invariant Co-Authored-By: Claude Opus 4.8 --- src/api/endpoints/storage.ts | 13 +- src/api/mappers.ts | 42 +++ src/api/types.ts | 38 +- src/features/storage/StoragePage.tsx | 505 ++++++++++++++++++++++++++- src/i18n/locales/en.ts | 36 ++ src/i18n/locales/ru.ts | 36 ++ src/lib/format.ts | 14 + 7 files changed, 669 insertions(+), 15 deletions(-) diff --git a/src/api/endpoints/storage.ts b/src/api/endpoints/storage.ts index 5ae1bee..4b1da32 100644 --- a/src/api/endpoints/storage.ts +++ b/src/api/endpoints/storage.ts @@ -1,17 +1,16 @@ import { api } from '../index'; +import { toStorageStats, type RawStorageStats } from '../mappers'; import type { StorageStats } from '../types'; -// NOTE: the backend `/storage` routes are still unimplemented stubs (no body / -// no schema), and the real paths differ from these placeholders (`GET /storage`, -// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`, -// `POST /storage/cleanup`). Re-point paths and add snake→camel mappers (see -// `mappers.ts`) once the backend defines the storage response shapes; until then -// these are provisional and unused by the UI. +// `GET /storage` returns library + disk statistics (§A6). The maintenance +// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`, +// `POST /storage/cleanup`) are still backend stubs and unused by the UI. export const storageApi = api.injectEndpoints({ endpoints: (build) => ({ getStorageStats: build.query({ - query: () => '/storage/stats', + query: () => '/storage', + transformResponse: (raw: RawStorageStats) => toStorageStats(raw), providesTags: ['Storage'], }), scanStorage: build.mutation<{ jobId: string }, void>({ diff --git a/src/api/mappers.ts b/src/api/mappers.ts index 23d3580..a4e2b63 100644 --- a/src/api/mappers.ts +++ b/src/api/mappers.ts @@ -18,6 +18,7 @@ import type { MetadataStatus, PaginatedResponse, Playlist, + StorageStats, Track, User, } from './types'; @@ -200,6 +201,47 @@ export const toPlaylist = (r: RawPlaylist): Playlist => ({ updatedAt: r.created_at, }); +interface RawStorageStats { + total_tracks: number; + total_artists: number; + total_albums: number; + total_size: number; + total_duration_seconds: number; + largest_track_size: number; + earliest_added: string | null; + latest_added: string | null; + by_format: { file_format: string; track_count: number; total_size: number }[]; + by_metadata_status: Record; + by_source: Record; + top_genres: { genre: string; track_count: number }[]; + disk: { total: number; used: number; free: number } | null; +} + +export type { RawStorageStats }; + +export const toStorageStats = (r: RawStorageStats): StorageStats => ({ + totalTracks: r.total_tracks, + totalArtists: r.total_artists, + totalAlbums: r.total_albums, + totalSize: r.total_size, + totalDurationSeconds: r.total_duration_seconds, + largestTrackSize: r.largest_track_size, + earliestAdded: r.earliest_added ?? undefined, + latestAdded: r.latest_added ?? undefined, + byFormat: r.by_format.map((f) => ({ + fileFormat: f.file_format, + trackCount: f.track_count, + totalSize: f.total_size, + })), + byMetadataStatus: r.by_metadata_status, + bySource: r.by_source, + topGenres: r.top_genres.map((g) => ({ + genre: g.genre, + trackCount: g.track_count, + })), + disk: r.disk ?? undefined, +}); + /** * Translate the backend's `{items,total,limit,offset}` envelope into the UI's * `{items,total,page,pageSize,hasMore}`, mapping each element. diff --git a/src/api/types.ts b/src/api/types.ts index f6e2c4e..019b1b2 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -96,12 +96,40 @@ export interface UploadResponse { already_exists: boolean; } -export interface StorageStats { - totalBytes: number; - usedBytes: number; +export interface StorageFormatBreakdown { + fileFormat: string; trackCount: number; - albumCount: number; - artistCount: number; + totalSize: number; +} + +export interface StorageGenreCount { + genre: string; + trackCount: number; +} + +/** Capacity of the volume backing the media store. Absent for object-store + * backends (S3), which have no fixed disk to report. */ +export interface StorageDiskUsage { + total: number; + used: number; + free: number; +} + +export interface StorageStats { + totalTracks: number; + totalArtists: number; + totalAlbums: number; + /** Sum of every track's recorded file size (the library's footprint). */ + totalSize: number; + totalDurationSeconds: number; + largestTrackSize: number; + earliestAdded?: string; + latestAdded?: string; + byFormat: StorageFormatBreakdown[]; + byMetadataStatus: Record; + bySource: Record; + topGenres: StorageGenreCount[]; + disk?: StorageDiskUsage; } export interface User { diff --git a/src/features/storage/StoragePage.tsx b/src/features/storage/StoragePage.tsx index 1d37aaf..6ef4c16 100644 --- a/src/features/storage/StoragePage.tsx +++ b/src/features/storage/StoragePage.tsx @@ -1,13 +1,512 @@ +import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Window } from '@olly/modern-sk'; +import { Window, Card, Badge } from '@olly/modern-sk'; +import { useGetStorageStatsQuery } from '../../api/endpoints/storage'; +import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; +import { EmptyState } from '../../components/common/EmptyState'; +import { ErrorState } from '../../components/common/ErrorState'; +import { Icon, type IconName } from '../../components/common/Icon'; +import { + formatFileSize, + formatCount, + formatLongDuration, + formatDateTime, +} from '../../lib/format'; +import type { StorageStats } from '../../api/types'; + +// modern-sk Badge variants we map metadata-health buckets onto. +const STATUS_VARIANT: Record = + { + enriched: 'lime', + manual: 'outline', + pending: 'neutral', + failed: 'ember', + }; export function StoragePage() { const { t } = useTranslation(); + const { data, isLoading, isError, refetch } = useGetStorageStatsQuery(); + return ( -
+
-

{t('common.comingSoon')}

+

+ {t('storage.subtitle')} +

+ + {isLoading && } + {isError && ( + refetch()} /> + )} + {data && data.totalTracks === 0 && ( + } + title={t('storage.emptyTitle')} + description={t('storage.emptyDesc')} + /> + )} + {data && data.totalTracks > 0 && }
); } + +function StorageDashboard({ stats }: { stats: StorageStats }) { + const { t } = useTranslation(); + const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1)); + + return ( +
+ {stats.disk && ( + + )} + +
+ + + + + + +
+ + {stats.byFormat.length > 0 && } + +
+ + +
+ + {stats.topGenres.length > 0 && } + + +
+ ); +} + +function DiskGauge({ + disk, + libraryBytes, +}: { + disk: NonNullable; + libraryBytes: number; +}) { + const { t } = useTranslation(); + const pct = (n: number) => (disk.total > 0 ? (n / disk.total) * 100 : 0); + // The library is a slice of "used"; the rest of used is everything else on + // the volume. Clamp so a slightly-stale library total never overflows. + const libShare = Math.min(libraryBytes, disk.used); + const otherUsed = Math.max(disk.used - libShare, 0); + const libPercentOfDisk = pct(libraryBytes).toFixed(1); + + return ( + + {t('storage.disk')} +
+
+
+
+
+ + {formatFileSize(libraryBytes)}{' '} + {t('storage.footprint').toLowerCase()} + + + {t('storage.diskUsage', { + used: formatFileSize(disk.used), + total: formatFileSize(disk.total), + })} + + {t('storage.diskFree', { free: formatFileSize(disk.free) })} +
+

+ {t('storage.diskLibraryShare', { percent: libPercentOfDisk })} +

+ + ); +} + +function FormatBars({ stats }: { stats: StorageStats }) { + const { t } = useTranslation(); + const max = Math.max(...stats.byFormat.map((f) => f.totalSize), 1); + return ( + + {t('storage.formats')} +
+ {stats.byFormat.map((f) => ( +
+
+ + {f.fileFormat} + + + {formatCount(f.trackCount)} · {formatFileSize(f.totalSize)} + +
+
+
+
+
+ ))} +
+ + ); +} + +function MetadataHealth({ stats }: { stats: StorageStats }) { + const { t } = useTranslation(); + const entries = Object.entries(stats.byMetadataStatus).filter( + ([, n]) => n > 0, + ); + return ( + + + {t('storage.metadataHealth')} + +
+ {entries.map(([status, count]) => ( + + {t(`storage.status.${status}`, { defaultValue: status })} ·{' '} + {formatCount(count)} + + ))} +
+
+ ); +} + +function Sources({ stats }: { stats: StorageStats }) { + const { t } = useTranslation(); + const entries = Object.entries(stats.bySource).sort((a, b) => b[1] - a[1]); + return ( + + + {t('storage.sources')} + +
+ {entries.map(([source, count]) => ( +
+ {source} + + {formatCount(count)} + +
+ ))} +
+
+ ); +} + +function TopGenres({ stats }: { stats: StorageStats }) { + const { t } = useTranslation(); + const max = Math.max(...stats.topGenres.map((g) => g.trackCount), 1); + return ( + + {t('storage.topGenres')} +
+ {stats.topGenres.map((g) => { + // Scale chip emphasis by popularity for a tag-cloud feel. + const weight = 0.55 + (g.trackCount / max) * 0.45; + return ( + + {g.genre}{' '} + + {formatCount(g.trackCount)} + + + ); + })} +
+
+ ); +} + +function FunFacts({ + stats, + avgSize, +}: { + stats: StorageStats; + avgSize: number; +}) { + const { t } = useTranslation(); + const facts: string[] = []; + if (stats.totalDurationSeconds > 0) + facts.push( + t('storage.factPlaytime', { + duration: formatLongDuration(stats.totalDurationSeconds), + }), + ); + facts.push( + t('storage.factFootprint', { + size: formatFileSize(stats.totalSize), + tracks: formatCount(stats.totalTracks), + }), + ); + if (stats.topGenres[0]) + facts.push( + t('storage.factGenre', { + genre: stats.topGenres[0].genre, + count: stats.topGenres[0].trackCount, + }), + ); + facts.push(t('storage.factAvg', { size: formatFileSize(avgSize) })); + const since = formatDateTime(stats.earliestAdded); + if (since) facts.push(t('storage.factSince', { date: since })); + + return ( + + {t('storage.funFacts')} +
    + {facts.map((f) => ( +
  • {f}
  • + ))} +
+
+ ); +} + +// -- small shared bits -------------------------------------------------------- + +function StatTile({ + icon, + label, + value, +}: { + icon: IconName; + label: string; + value: string; +}) { + return ( + + + + + + {value} + + + {label} + + + ); +} + +function SectionTitle({ + icon, + children, +}: { + icon: IconName; + children: ReactNode; +}) { + return ( +

+ + + + {children} +

+ ); +} + +function Dot({ color }: { color: string }) { + return ( + + ); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2868e33..a67abcf 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -210,6 +210,42 @@ const en = { comingSoon: 'Coming soon', back: 'Back', }, + storage: { + subtitle: 'Everything this instance has tucked away', + emptyTitle: 'Nothing stored yet', + emptyDesc: + 'Download or upload some music and your library stats will appear here.', + disk: 'Disk', + diskUsage: '{{used}} of {{total}} used', + diskFree: '{{free}} free', + diskLibraryShare: 'This library is {{percent}}% of the whole disk', + diskUnknown: 'Object storage — no fixed disk to report', + footprint: 'Library footprint', + tracks: 'Tracks', + artists: 'Artists', + albums: 'Albums', + playtime: 'Total playtime', + avgTrackSize: 'Avg. track size', + largestTrack: 'Largest track', + formats: 'Formats', + sources: 'Where it came from', + metadataHealth: 'Metadata health', + topGenres: 'Top genres', + noGenres: 'No genres tagged yet', + funFacts: 'Fun facts', + factPlaytime: + 'Hit play and walk away — this library runs for {{duration}} non-stop.', + factFootprint: '{{size}} of music across {{tracks}} tracks.', + factGenre: 'Your most-tagged genre is {{genre}} ({{count}} tracks).', + factAvg: 'The average track weighs in at {{size}}.', + factSince: 'Collecting since {{date}}.', + status: { + enriched: 'Enriched', + manual: 'Manual', + pending: 'Pending', + failed: 'Failed', + }, + }, pages: { admin: 'Admin', settings: 'Settings', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 66d445a..0d0a542 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -212,6 +212,42 @@ const ru: Translations = { comingSoon: 'Скоро', back: 'Назад', }, + storage: { + subtitle: 'Всё, что хранит этот инстанс', + emptyTitle: 'Пока ничего не сохранено', + emptyDesc: + 'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.', + disk: 'Диск', + diskUsage: 'Занято {{used}} из {{total}}', + diskFree: 'Свободно {{free}}', + diskLibraryShare: 'Библиотека занимает {{percent}}% всего диска', + diskUnknown: 'Объектное хранилище — фиксированного диска нет', + footprint: 'Объём библиотеки', + tracks: 'Треки', + artists: 'Исполнители', + albums: 'Альбомы', + playtime: 'Общая длительность', + avgTrackSize: 'Средний размер трека', + largestTrack: 'Самый большой трек', + formats: 'Форматы', + sources: 'Откуда взято', + metadataHealth: 'Состояние метаданных', + topGenres: 'Топ жанров', + noGenres: 'Жанры пока не указаны', + funFacts: 'Интересные факты', + factPlaytime: + 'Нажмите play и уходите — библиотека играет {{duration}} без остановки.', + factFootprint: '{{size}} музыки в {{tracks}} треках.', + factGenre: 'Чаще всего встречается жанр {{genre}} ({{count}} треков).', + factAvg: 'Средний трек весит {{size}}.', + factSince: 'Коллекция собирается с {{date}}.', + status: { + enriched: 'Обогащено', + manual: 'Вручную', + pending: 'В ожидании', + failed: 'Ошибка', + }, + }, pages: { admin: 'Администрирование', settings: 'Настройки', diff --git a/src/lib/format.ts b/src/lib/format.ts index 5a2995b..6043fa4 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -26,6 +26,20 @@ export function formatDateTime(iso: string | undefined): string | undefined { }).format(d); } +/** Human "X days Y hours" style for big spans (e.g. total library playtime). + * Shows the two most-significant non-zero units; falls back to "0m". */ +export function formatLongDuration(seconds: number): string { + if (seconds <= 0) return '0m'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (mins && parts.length < 2) parts.push(`${mins}m`); + return parts.slice(0, 2).join(' ') || '0m'; +} + export function formatCount(n: number): string { if (n < 1000) return String(n); if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;