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,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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user