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 { api } from '../index';
|
||||||
|
import { toStorageStats, type RawStorageStats } from '../mappers';
|
||||||
import type { StorageStats } from '../types';
|
import type { StorageStats } from '../types';
|
||||||
|
|
||||||
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body /
|
// `GET /storage` returns library + disk statistics (§A6). The maintenance
|
||||||
// no schema), and the real paths differ from these placeholders (`GET /storage`,
|
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
||||||
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
|
||||||
// `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.
|
|
||||||
|
|
||||||
export const storageApi = api.injectEndpoints({
|
export const storageApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getStorageStats: build.query<StorageStats, void>({
|
getStorageStats: build.query<StorageStats, void>({
|
||||||
query: () => '/storage/stats',
|
query: () => '/storage',
|
||||||
|
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
||||||
providesTags: ['Storage'],
|
providesTags: ['Storage'],
|
||||||
}),
|
}),
|
||||||
scanStorage: build.mutation<{ jobId: string }, void>({
|
scanStorage: build.mutation<{ jobId: string }, void>({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
MetadataStatus,
|
MetadataStatus,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
StorageStats,
|
||||||
Track,
|
Track,
|
||||||
User,
|
User,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -200,6 +201,47 @@ export const toPlaylist = (r: RawPlaylist): Playlist => ({
|
|||||||
updatedAt: r.created_at,
|
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
|
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
||||||
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
||||||
|
|||||||
+33
-5
@@ -96,12 +96,40 @@ export interface UploadResponse {
|
|||||||
already_exists: boolean;
|
already_exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageStats {
|
export interface StorageFormatBreakdown {
|
||||||
totalBytes: number;
|
fileFormat: string;
|
||||||
usedBytes: number;
|
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
albumCount: number;
|
totalSize: number;
|
||||||
artistCount: 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 {
|
export interface User {
|
||||||
|
|||||||
@@ -1,13 +1,512 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
|
||||||
|
{
|
||||||
|
enriched: 'lime',
|
||||||
|
manual: 'outline',
|
||||||
|
pending: 'neutral',
|
||||||
|
failed: 'ember',
|
||||||
|
};
|
||||||
|
|
||||||
export function StoragePage() {
|
export function StoragePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
<Window title={t('pages.storage')}>
|
<Window title={t('pages.storage')}>
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
<p style={{ color: 'var(--color-text-2)', marginTop: 0 }}>
|
||||||
|
{t('storage.subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading && <LoadingSkeleton rows={6} height={72} />}
|
||||||
|
{isError && (
|
||||||
|
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
||||||
|
)}
|
||||||
|
{data && data.totalTracks === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="hard-drives" />}
|
||||||
|
title={t('storage.emptyTitle')}
|
||||||
|
description={t('storage.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data && data.totalTracks > 0 && <StorageDashboard stats={data} />}
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StorageDashboard({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
{stats.disk && (
|
||||||
|
<DiskGauge disk={stats.disk} libraryBytes={stats.totalSize} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatTile
|
||||||
|
icon="vinyl-record"
|
||||||
|
label={t('storage.tracks')}
|
||||||
|
value={formatCount(stats.totalTracks)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="vinyl-record"
|
||||||
|
label={t('storage.artists')}
|
||||||
|
value={formatCount(stats.totalArtists)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="vinyl-record"
|
||||||
|
label={t('storage.albums')}
|
||||||
|
value={formatCount(stats.totalAlbums)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="play"
|
||||||
|
label={t('storage.playtime')}
|
||||||
|
value={formatLongDuration(stats.totalDurationSeconds)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="hard-drives"
|
||||||
|
label={t('storage.footprint')}
|
||||||
|
value={formatFileSize(stats.totalSize)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="hard-drives"
|
||||||
|
label={t('storage.avgTrackSize')}
|
||||||
|
value={formatFileSize(avgSize)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.byFormat.length > 0 && <FormatBars stats={stats} />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MetadataHealth stats={stats} />
|
||||||
|
<Sources stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.topGenres.length > 0 && <TopGenres stats={stats} />}
|
||||||
|
|
||||||
|
<FunFacts stats={stats} avgSize={avgSize} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiskGauge({
|
||||||
|
disk,
|
||||||
|
libraryBytes,
|
||||||
|
}: {
|
||||||
|
disk: NonNullable<StorageStats['disk']>;
|
||||||
|
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 (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="hard-drives">{t('storage.disk')}</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct(libShare)}%`,
|
||||||
|
background: 'var(--color-accent)',
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct(otherUsed)}%`,
|
||||||
|
background: 'var(--color-text-3)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.6rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Dot color="var(--color-accent)" /> {formatFileSize(libraryBytes)}{' '}
|
||||||
|
{t('storage.footprint').toLowerCase()}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('storage.diskUsage', {
|
||||||
|
used: formatFileSize(disk.used),
|
||||||
|
total: formatFileSize(disk.total),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>{t('storage.diskFree', { free: formatFileSize(disk.free) })}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.5rem 0 0',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.diskLibraryShare', { percent: libPercentOfDisk })}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormatBars({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const max = Math.max(...stats.byFormat.map((f) => f.totalSize), 1);
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="vinyl-record">{t('storage.formats')}</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.7rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats.byFormat.map((f) => (
|
||||||
|
<div key={f.fileFormat}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.fileFormat}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--color-text-2)' }}>
|
||||||
|
{formatCount(f.trackCount)} · {formatFileSize(f.totalSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(f.totalSize / max) * 100}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--color-accent)',
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataHealth({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entries = Object.entries(stats.byMetadataStatus).filter(
|
||||||
|
([, n]) => n > 0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="check-circle">
|
||||||
|
{t('storage.metadataHealth')}
|
||||||
|
</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map(([status, count]) => (
|
||||||
|
<Badge key={status} variant={STATUS_VARIANT[status] ?? 'neutral'}>
|
||||||
|
{t(`storage.status.${status}`, { defaultValue: status })} ·{' '}
|
||||||
|
{formatCount(count)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sources({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entries = Object.entries(stats.bySource).sort((a, b) => b[1] - a[1]);
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="arrow-circle-down">
|
||||||
|
{t('storage.sources')}
|
||||||
|
</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.4rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map(([source, count]) => (
|
||||||
|
<div
|
||||||
|
key={source}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--color-text-1)' }}>{source}</span>
|
||||||
|
<span style={{ color: 'var(--color-text-2)' }}>
|
||||||
|
{formatCount(count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopGenres({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const max = Math.max(...stats.topGenres.map((g) => g.trackCount), 1);
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="vinyl-record">{t('storage.topGenres')}</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats.topGenres.map((g) => {
|
||||||
|
// Scale chip emphasis by popularity for a tag-cloud feel.
|
||||||
|
const weight = 0.55 + (g.trackCount / max) * 0.45;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={g.genre}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.7rem',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: `color-mix(in srgb, var(--color-accent) ${Math.round(
|
||||||
|
weight * 18,
|
||||||
|
)}%, transparent)`,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.genre}{' '}
|
||||||
|
<span style={{ color: 'var(--color-text-3)' }}>
|
||||||
|
{formatCount(g.trackCount)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="info">{t('storage.funFacts')}</SectionTitle>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
paddingLeft: '1.1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.4rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{facts.map((f) => (
|
||||||
|
<li key={f}>{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- small shared bits --------------------------------------------------------
|
||||||
|
|
||||||
|
function StatTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: IconName;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.35rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={icon} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-2)' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: IconName;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--color-accent)' }}>
|
||||||
|
<Icon name={icon} />
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: color,
|
||||||
|
marginRight: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -210,6 +210,42 @@ const en = {
|
|||||||
comingSoon: 'Coming soon',
|
comingSoon: 'Coming soon',
|
||||||
back: 'Back',
|
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: {
|
pages: {
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
|
|||||||
@@ -212,6 +212,42 @@ const ru: Translations = {
|
|||||||
comingSoon: 'Скоро',
|
comingSoon: 'Скоро',
|
||||||
back: 'Назад',
|
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: {
|
pages: {
|
||||||
admin: 'Администрирование',
|
admin: 'Администрирование',
|
||||||
settings: 'Настройки',
|
settings: 'Настройки',
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export function formatDateTime(iso: string | undefined): string | undefined {
|
|||||||
}).format(d);
|
}).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 {
|
export function formatCount(n: number): string {
|
||||||
if (n < 1000) return String(n);
|
if (n < 1000) return String(n);
|
||||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
|||||||
Reference in New Issue
Block a user