feat(storage): functional Storage dashboard (§A6)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled

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:
Senko-san
2026-06-14 01:20:01 +03:00
parent 44c8d1870f
commit 808c52484c
7 changed files with 669 additions and 15 deletions
+502 -3
View File
@@ -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<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
{
enriched: 'lime',
manual: 'outline',
pending: 'neutral',
failed: 'ember',
};
export function StoragePage() {
const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
return (
<div style={{ padding: '1.5rem' }}>
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
<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>
</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,
}}
/>
);
}