feat(storage): functional Storage dashboard (§A6)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<StorageStats, void>({
|
||||
query: () => '/storage/stats',
|
||||
query: () => '/storage',
|
||||
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
||||
providesTags: ['Storage'],
|
||||
}),
|
||||
scanStorage: build.mutation<{ jobId: string }, void>({
|
||||
|
||||
@@ -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<string, number>;
|
||||
by_source: Record<string, number>;
|
||||
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.
|
||||
|
||||
+33
-5
@@ -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<string, number>;
|
||||
bySource: Record<string, number>;
|
||||
topGenres: StorageGenreCount[];
|
||||
disk?: StorageDiskUsage;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
Reference in New Issue
Block a user