feat(storage): show device-local storage alongside the server
The Storage dashboard only showed the remote server library. Split it into
two sections so both storages live there:
- "On this device": the Tier-3 service-worker audio cache (downloaded
audio — usage gauge vs max, cached-track count) plus the offline library
metadata (tracks/albums/artists browsable without the server, from the
selectLocal* selectors). Always rendered, even with no backend.
- "On the server": the existing remote dashboard, now offline-aware — a
quiet "server unreachable" notice instead of a blocking error when off.
- hook: useAudioCacheStats (reads getAudioCacheStats from the SW)
- i18n: storage.{device,server,audioCache,...} (en + ru)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Window, Card, Badge } from '@olly/modern-sk';
|
||||
import { Window, Card, Badge, Callout } 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 { useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||
import { useAudioCacheStats } from '../../hooks/useAudioCacheStats';
|
||||
import {
|
||||
selectLocalTracks,
|
||||
selectLocalAlbums,
|
||||
selectLocalArtists,
|
||||
} from '../../store/selectors/localLibrary';
|
||||
import type { AudioCacheStats } from '../../lib/sw';
|
||||
import {
|
||||
formatFileSize,
|
||||
formatCount,
|
||||
@@ -26,6 +35,13 @@ const STATUS_VARIANT: Record<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
|
||||
export function StoragePage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
||||
const offline = useIsOffline();
|
||||
|
||||
// Tier-3 audio cache + the locally-cached library metadata = "this device".
|
||||
const audio = useAudioCacheStats();
|
||||
const localTracks = useAppSelector(selectLocalTracks);
|
||||
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||
const localArtists = useAppSelector(selectLocalArtists);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||
@@ -34,9 +50,37 @@ export function StoragePage() {
|
||||
{t('storage.subtitle')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
{/* ── On this device (local + cached) ───────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle icon="hard-drives">
|
||||
{t('storage.device')}
|
||||
</SectionTitle>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<LocalStoragePanel
|
||||
audio={audio}
|
||||
trackCount={localTracks.length}
|
||||
albumCount={localAlbums.length}
|
||||
artistCount={localArtists.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── On the server (remote) ────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle icon="cloud">{t('storage.server')}</SectionTitle>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
{isLoading && <LoadingSkeleton rows={6} height={72} />}
|
||||
{isError && (
|
||||
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
||||
{isError && offline && (
|
||||
<Callout variant="info">
|
||||
{t('storage.serverUnreachable')}
|
||||
</Callout>
|
||||
)}
|
||||
{isError && !offline && (
|
||||
<ErrorState
|
||||
message={t('common.error')}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
{data && data.totalTracks === 0 && (
|
||||
<EmptyState
|
||||
@@ -45,12 +89,131 @@ export function StoragePage() {
|
||||
description={t('storage.emptyDesc')}
|
||||
/>
|
||||
)}
|
||||
{data && data.totalTracks > 0 && <StorageDashboard stats={data} />}
|
||||
{data && data.totalTracks > 0 && (
|
||||
<StorageDashboard stats={data} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "On this device": the SW audio cache (downloaded audio) + the offline
|
||||
* library metadata we can browse without the server. */
|
||||
function LocalStoragePanel({
|
||||
audio,
|
||||
trackCount,
|
||||
albumCount,
|
||||
artistCount,
|
||||
}: {
|
||||
audio: AudioCacheStats | null;
|
||||
trackCount: number;
|
||||
albumCount: number;
|
||||
artistCount: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<Card style={{ padding: '1.25rem' }}>
|
||||
<SectionTitle icon="arrow-circle-down">
|
||||
{t('storage.audioCache')}
|
||||
</SectionTitle>
|
||||
{audio ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--color-surface-3)',
|
||||
marginTop: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${audio.maxBytes > 0 ? Math.min((audio.bytes / audio.maxBytes) * 100, 100) : 0}%`,
|
||||
height: '100%',
|
||||
background: 'var(--color-accent)',
|
||||
transition: 'width 0.5s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.6rem',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
{t('storage.audioCacheUsage', {
|
||||
used: formatFileSize(audio.bytes),
|
||||
max: formatFileSize(audio.maxBytes),
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.25rem',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{t('storage.cachedTracks', { n: audio.count })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
style={{
|
||||
margin: '0.75rem 0 0',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{t('storage.audioCacheUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card style={{ padding: '1.25rem' }}>
|
||||
<SectionTitle icon="vinyl-record">
|
||||
{t('storage.offlineLibrary')}
|
||||
</SectionTitle>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.75rem 0 0',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-1)',
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{formatCount(trackCount)}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.35rem',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
{t('storage.offlineLibraryMeta', {
|
||||
tracks: formatCount(trackCount),
|
||||
albums: formatCount(albumCount),
|
||||
artists: formatCount(artistCount),
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageDashboard({ stats }: { stats: StorageStats }) {
|
||||
const { t } = useTranslation();
|
||||
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getAudioCacheStats, type AudioCacheStats } from '../lib/sw';
|
||||
|
||||
/**
|
||||
* Loads the service-worker audio offline cache stats (Tier 3 — the audio
|
||||
* actually stored on *this device*). Returns `null` until resolved, or when no
|
||||
* controlling service worker is present (insecure origin, first load, …).
|
||||
* `bump` forces a re-read after the cache is mutated (e.g. cleared).
|
||||
*/
|
||||
export function useAudioCacheStats(bump = 0): AudioCacheStats | null {
|
||||
const [stats, setStats] = useState<AudioCacheStats | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getAudioCacheStats().then((s) => {
|
||||
if (!cancelled) setStats(s);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bump]);
|
||||
return stats;
|
||||
}
|
||||
@@ -241,6 +241,17 @@ const en = {
|
||||
},
|
||||
storage: {
|
||||
subtitle: 'Everything this instance has tucked away',
|
||||
device: 'On this device',
|
||||
server: 'On the server',
|
||||
audioCache: 'Cached audio',
|
||||
audioCacheUsage: '{{used}} of {{max}} used',
|
||||
cachedTracks: '{{n}} tracks cached for offline',
|
||||
audioCacheUnavailable:
|
||||
'Offline audio cache unavailable (service worker not active).',
|
||||
offlineLibrary: 'Offline library',
|
||||
offlineLibraryMeta:
|
||||
'{{tracks}} tracks · {{albums}} albums · {{artists}} artists browsable offline',
|
||||
serverUnreachable: 'Server unreachable — showing this device only.',
|
||||
emptyTitle: 'Nothing stored yet',
|
||||
emptyDesc:
|
||||
'Download or upload some music and your library stats will appear here.',
|
||||
|
||||
@@ -244,6 +244,17 @@ const ru: Translations = {
|
||||
},
|
||||
storage: {
|
||||
subtitle: 'Всё, что хранит этот инстанс',
|
||||
device: 'На этом устройстве',
|
||||
server: 'На сервере',
|
||||
audioCache: 'Кэш аудио',
|
||||
audioCacheUsage: 'Занято {{used}} из {{max}}',
|
||||
cachedTracks: '{{n}} треков сохранено офлайн',
|
||||
audioCacheUnavailable:
|
||||
'Офлайн-кэш аудио недоступен (service worker не активен).',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
offlineLibraryMeta:
|
||||
'{{tracks}} треков · {{albums}} альбомов · {{artists}} исполнителей доступно офлайн',
|
||||
serverUnreachable: 'Сервер недоступен — показано только это устройство.',
|
||||
emptyTitle: 'Пока ничего не сохранено',
|
||||
emptyDesc:
|
||||
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
|
||||
|
||||
Reference in New Issue
Block a user