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 type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useGetStorageStatsQuery } from '../../api/endpoints/storage';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { Icon, type IconName } from '../../components/common/Icon';
|
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 {
|
import {
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
formatCount,
|
formatCount,
|
||||||
@@ -26,6 +35,13 @@ const STATUS_VARIANT: Record<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
|
|||||||
export function StoragePage() {
|
export function StoragePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
@@ -34,9 +50,37 @@ export function StoragePage() {
|
|||||||
{t('storage.subtitle')}
|
{t('storage.subtitle')}
|
||||||
</p>
|
</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} />}
|
{isLoading && <LoadingSkeleton rows={6} height={72} />}
|
||||||
{isError && (
|
{isError && offline && (
|
||||||
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
<Callout variant="info">
|
||||||
|
{t('storage.serverUnreachable')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
{isError && !offline && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('common.error')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{data && data.totalTracks === 0 && (
|
{data && data.totalTracks === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -45,12 +89,131 @@ export function StoragePage() {
|
|||||||
description={t('storage.emptyDesc')}
|
description={t('storage.emptyDesc')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{data && data.totalTracks > 0 && <StorageDashboard stats={data} />}
|
{data && data.totalTracks > 0 && (
|
||||||
|
<StorageDashboard stats={data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</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 }) {
|
function StorageDashboard({ stats }: { stats: StorageStats }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
|
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: {
|
storage: {
|
||||||
subtitle: 'Everything this instance has tucked away',
|
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',
|
emptyTitle: 'Nothing stored yet',
|
||||||
emptyDesc:
|
emptyDesc:
|
||||||
'Download or upload some music and your library stats will appear here.',
|
'Download or upload some music and your library stats will appear here.',
|
||||||
|
|||||||
@@ -244,6 +244,17 @@ const ru: Translations = {
|
|||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Всё, что хранит этот инстанс',
|
subtitle: 'Всё, что хранит этот инстанс',
|
||||||
|
device: 'На этом устройстве',
|
||||||
|
server: 'На сервере',
|
||||||
|
audioCache: 'Кэш аудио',
|
||||||
|
audioCacheUsage: 'Занято {{used}} из {{max}}',
|
||||||
|
cachedTracks: '{{n}} треков сохранено офлайн',
|
||||||
|
audioCacheUnavailable:
|
||||||
|
'Офлайн-кэш аудио недоступен (service worker не активен).',
|
||||||
|
offlineLibrary: 'Офлайн-библиотека',
|
||||||
|
offlineLibraryMeta:
|
||||||
|
'{{tracks}} треков · {{albums}} альбомов · {{artists}} исполнителей доступно офлайн',
|
||||||
|
serverUnreachable: 'Сервер недоступен — показано только это устройство.',
|
||||||
emptyTitle: 'Пока ничего не сохранено',
|
emptyTitle: 'Пока ничего не сохранено',
|
||||||
emptyDesc:
|
emptyDesc:
|
||||||
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
|
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
|
||||||
|
|||||||
Reference in New Issue
Block a user