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 {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Настройки',
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user