From 72567acd28fa87a3a39f7520def7bb361802ef0f Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:53:37 +0300 Subject: [PATCH] feat(web): polish catalog motion, empty states, and mobile now-playing Co-authored-by: Cursor --- apps/web/src/App.test.tsx | 2 +- apps/web/src/entities/catalog.module.css | 59 ++++++- apps/web/src/entities/catalog.tsx | 40 ++++- apps/web/src/pages/AlbumPage.tsx | 45 +++-- apps/web/src/pages/AppDownloadPage.tsx | 6 +- apps/web/src/pages/ArtistPage.tsx | 61 ++++--- apps/web/src/pages/AuthLayout.tsx | 7 +- apps/web/src/pages/CollectionPage.tsx | 21 ++- apps/web/src/pages/HomePage.tsx | 15 +- apps/web/src/pages/PlaylistsPage.tsx | 83 +++++++-- apps/web/src/pages/SearchPage.tsx | 9 +- apps/web/src/pages/TrackPage.tsx | 66 ++++++-- apps/web/src/pages/admin/AuditPage.tsx | 6 +- apps/web/src/pages/admin/SourcesPage.tsx | 8 +- apps/web/src/pages/admin/UploadQueuePage.tsx | 6 +- apps/web/src/pages/admin/UsersPage.tsx | 6 +- apps/web/src/pages/auth.module.css | 9 + apps/web/src/pages/catalog.module.css | 43 ++++- apps/web/src/pages/home.module.css | 4 + apps/web/src/pages/playlists.module.css | 15 +- apps/web/src/pages/uploads/MyUploadsPage.tsx | 8 +- apps/web/src/shared/ui/ui.module.css | 116 ++++++++++++- apps/web/src/shared/ui/ui.tsx | 58 ++++++- apps/web/src/widgets/AppShell.module.css | 21 +++ apps/web/src/widgets/AppShell.tsx | 26 ++- apps/web/src/widgets/PlayerBar.module.css | 117 ++++++++++++- apps/web/src/widgets/PlayerBar.tsx | 169 ++++++++++++++++++- apps/web/src/widgets/SearchBox.tsx | 21 ++- apps/web/src/widgets/wave/WaveButton.tsx | 9 + packages/i18n/locales/en/common.json | 7 +- packages/i18n/locales/ru/common.json | 7 +- 31 files changed, 939 insertions(+), 131 deletions(-) diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 57b1495..46e2d65 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -291,7 +291,7 @@ describe('player', () => { await user.click(screen.getByRole('button', { name: 'Войти' })); await screen.findByRole('heading', { name: 'Закрой за мной дверь' }); expect(router.state.location.pathname).toBe(path); - await user.click(screen.getByRole('listitem')); + await user.click(screen.getByRole('button', { name: 'Слушать' })); const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' })); expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована'); diff --git a/apps/web/src/entities/catalog.module.css b/apps/web/src/entities/catalog.module.css index 33c6961..c517d3c 100644 --- a/apps/web/src/entities/catalog.module.css +++ b/apps/web/src/entities/catalog.module.css @@ -8,12 +8,18 @@ color: rgba(255, 255, 255, 0.85); } -.cover img { +.coverImg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; + opacity: 0; + transition: opacity 0.35s ease; +} + +.coverImgOn { + opacity: 1; } .round { @@ -59,10 +65,52 @@ width: 128px; } +.cardCoverWrap { + position: relative; + margin-bottom: 4px; + overflow: hidden; + border-radius: 12px; +} + .cardCover { aspect-ratio: 1; border-radius: 12px; - margin-bottom: 4px; + transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.card:hover .cardCover, +.card:focus-within .cardCover { + transform: scale(1.04); +} + +.cardPlay { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 1; + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: #fff; + color: #05070d; + opacity: 0; + transform: translateY(6px); + transition: + opacity 0.18s, + transform 0.18s; +} + +.card:hover .cardPlay, +.card:focus-within .cardPlay, +.cardPlay:focus-visible { + opacity: 1; + transform: none; +} + +.cardPlay:active { + transform: scale(0.94); } .cardTitle { @@ -104,6 +152,12 @@ min-height: 56px; padding: 6px 12px; border-radius: 10px; + animation: pipuIn 0.2s ease-out both; + animation-delay: var(--stagger, 0ms); +} + +.trackStatic { + animation: none; } .track:has(.trackAlbum) { @@ -272,6 +326,7 @@ } @media (max-width: 767px) { + .cardPlay { opacity: 1; transform: none; } .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; } .track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; } diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx index b608902..1202600 100644 --- a/apps/web/src/entities/catalog.tsx +++ b/apps/web/src/entities/catalog.tsx @@ -1,10 +1,12 @@ import type { components } from '@pipu/api-client'; +import { unwrap } from '@pipu/core'; import { Link } from '@tanstack/react-router'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { Fragment, useRef, type ReactNode } from 'react'; +import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react'; import { AddToPlaylist } from './playlists'; import { useTranslation } from 'react-i18next'; +import { useSession } from '../app/session'; import { formatDuration } from '../shared/lib/format'; import { usePlayer, usePlayerState } from '../player/context'; import type { PlayContext } from '../player/player'; @@ -59,7 +61,7 @@ export function Cover({ aria-hidden > {album.cover_url ? ( - + ) : ( )} @@ -83,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) { ); } +function CoverImg({ src }: { src: string }) { + const [ready, setReady] = useState(false); + return ( + setReady(true)} + /> + ); +} + export function AlbumCard({ album }: { album: AlbumSummary }) { + const { t } = useTranslation(); + const session = useSession(); + const player = usePlayer(); + const play = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + void (async () => { + const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } })); + if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title }); + })(); + }; return ( - + + + + {album.title} {[album.artist.name, album.year].filter(Boolean).join(' · ')} @@ -155,7 +186,8 @@ export function TrackList({ role="listitem" tabIndex={tr.available ? 0 : -1} aria-current={isCurrent || undefined} - className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)} + className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)} + style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties} title={tr.available ? undefined : t('catalog.unavailable')} onClick={(e) => { if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play(); diff --git a/apps/web/src/pages/AlbumPage.tsx b/apps/web/src/pages/AlbumPage.tsx index aebd1a7..cfc2836 100644 --- a/apps/web/src/pages/AlbumPage.tsx +++ b/apps/web/src/pages/AlbumPage.tsx @@ -4,12 +4,12 @@ import { Link } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; -import { Cover, TrackList } from '../entities/catalog'; +import { Cover, TrackList, coverSrc } from '../entities/catalog'; import { AddToPlaylist } from '../entities/playlists'; import { usePlayer } from '../player/context'; import { formatDuration } from '../shared/lib/format'; import { playIcon, shuffleIcon } from '../shared/ui/icons'; -import { Button } from '../shared/ui/ui'; +import { Button, PageSkeleton } from '../shared/ui/ui'; import { NotFound } from './NotFound'; import s from './page.module.css'; import c from './catalog.module.css'; @@ -23,30 +23,41 @@ export function AlbumPage({ id }: { id: string }) { queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })), }); + if (album.isPending) { + return ( +
+ +
+ ); + } if (album.isError) return ; const a = album.data; - const colors = a?.cover_colors.length === 3 ? a.cover_colors : null; - const ctx = { type: 'album' as const, id, label: a?.title }; + if (!a) return null; + const colors = a.cover_colors.length === 3 ? a.cover_colors : null; + const ctx = { type: 'album' as const, id, label: a.title }; return (
+ {a.cover_url && ( + + + + )} {colors && } - +
{t('catalog.album')} -

{a?.title ?? '…'}

- {a && ( - - - {a.artist.name} - - {a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)} - - )} - {a && a.tracks.some((tr) => tr.available) && ( +

{a.title}

+ + + {a.artist.name} + + {a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)} + + {a.tracks.some((tr) => tr.available) && (
- tr.id)} /> + tr.id)} /> @@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) { )}
- {a && } +
); } diff --git a/apps/web/src/pages/AppDownloadPage.tsx b/apps/web/src/pages/AppDownloadPage.tsx index 3fa7310..c598e0e 100644 --- a/apps/web/src/pages/AppDownloadPage.tsx +++ b/apps/web/src/pages/AppDownloadPage.tsx @@ -40,7 +40,11 @@ export function AppDownloadPage() { {detected ? t(`app.platform.${detected}`) : t('app.badge')}

{t('app.title')}

{t('app.subtitle')}

- {latest.isPending ? null : data ? ( + {latest.isPending ? ( +

{t('common:loading')}

+ ) : latest.isError ? ( +
{t('app.unavailable')}
+ ) : data ? ( <> {primary ? ( <> diff --git a/apps/web/src/pages/ArtistPage.tsx b/apps/web/src/pages/ArtistPage.tsx index 752b7fa..2878f1a 100644 --- a/apps/web/src/pages/ArtistPage.tsx +++ b/apps/web/src/pages/ArtistPage.tsx @@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; -import { AlbumGrid, Cover, TrackList } from '../entities/catalog'; +import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog'; import { usePlayer } from '../player/context'; import { playIcon, waveIcon } from '../shared/ui/icons'; -import { Button } from '../shared/ui/ui'; +import { Button, PageSkeleton } from '../shared/ui/ui'; import { NotFound } from './NotFound'; import s from './page.module.css'; import c from './catalog.module.css'; @@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) { queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items, }); + if (artist.isPending) { + return ( +
+ +
+ ); + } if (artist.isError) return ; const a = artist.data; + if (!a) return null; const tracks = top.data ?? []; - const ctx = { type: 'artist' as const, id, label: a?.name }; + const ctx = { type: 'artist' as const, id, label: a.name }; const own = albums.data?.filter((al) => al.artist.id === id) ?? []; const appears = albums.data?.filter((al) => al.artist.id !== id) ?? []; return (
- + {a.image_url && ( + + + + )} +
{t('catalog.artist')} -

{a?.name ?? '…'}

- {a && ( - - {t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })} - - )} - {a && ( -
- + {tracks.length > 0 && ( + - {tracks.length > 0 && ( - - )} -
- )} + )} +
{tracks.length > 0 && ( diff --git a/apps/web/src/pages/AuthLayout.tsx b/apps/web/src/pages/AuthLayout.tsx index 3707ac4..94a0825 100644 --- a/apps/web/src/pages/AuthLayout.tsx +++ b/apps/web/src/pages/AuthLayout.tsx @@ -2,16 +2,21 @@ import type { ReactNode } from 'react'; import { Logo } from '../shared/ui/ui'; import { LanguageSwitch } from '../widgets/LanguageSwitch'; +import { WaveButton } from '../widgets/wave/WaveButton'; import s from './auth.module.css'; -/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */ +/** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */ export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) { + const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'; return (
+
+ +
diff --git a/apps/web/src/pages/CollectionPage.tsx b/apps/web/src/pages/CollectionPage.tsx index 738b1fb..78cbbfe 100644 --- a/apps/web/src/pages/CollectionPage.tsx +++ b/apps/web/src/pages/CollectionPage.tsx @@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection'; import { usePlayer } from '../player/context'; import { useErrorText } from '../shared/lib/errors'; import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons'; -import { Button, Dialog, FormError, Icon } from '../shared/ui/ui'; +import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui'; import c from './catalog.module.css'; import s from './page.module.css'; @@ -109,7 +109,12 @@ function History() {
)} {history.error || clear.error ? errorText(history.error ?? clear.error) : null} - {history.isSuccess && items.length === 0 &&
{t('collection.empty_history')}
} + {history.isPending && } + {history.isSuccess && items.length === 0 && ( + {t('catalog.back_home')}}> + {t('collection.empty_history')} + + )} {groups.map((g) => (

{g.title}

@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) { )} {list.error ? errorText(list.error) : null} + {list.isPending && } {list.isSuccess && tracks.length === 0 && ( -
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
+ + {t('playlists.find_tracks')} + + } + > + {t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')} + )} {tracks.length > 0 && ( unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: Infinity, }); + if (onboarding.isPending) { + return ( +
+ +
+ ); + } if (onboarding.data?.required) { return ( {t('wave.empty')}

} {error ? errorText(error) : null} + {options.isPending && (showActivities || showMoods) && ( +
+ + +
+ )} {options.data && (showActivities || showMoods) && (
{showActivities && ( diff --git a/apps/web/src/pages/PlaylistsPage.tsx b/apps/web/src/pages/PlaylistsPage.tsx index eb5f79f..8df7102 100644 --- a/apps/web/src/pages/PlaylistsPage.tsx +++ b/apps/web/src/pages/PlaylistsPage.tsx @@ -1,16 +1,17 @@ import { unwrap } from '@pipu/core'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Link, useNavigate } from '@tanstack/react-router'; -import { useState } from 'react'; +import { useState, type MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; import { Cover, TrackList } from '../entities/catalog'; import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists'; +import type { components } from '@pipu/api-client'; import { usePlayer } from '../player/context'; import { useErrorText } from '../shared/lib/errors'; import { formatDuration } from '../shared/lib/format'; -import { Button, Dialog, Field, FormError } from '../shared/ui/ui'; -import { playIcon, shuffleIcon } from '../shared/ui/icons'; +import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui'; +import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons'; import c from './catalog.module.css'; import s from './page.module.css'; import p from './playlists.module.css'; @@ -27,13 +28,16 @@ export function PlaylistsPage() {

{t('playlists.title')}

{list.error ? errorText(list.error) : null} - {list.isPending &&

{t('playlists.loading')}

} - {list.isSuccess && !items.length &&

{t(search ? 'playlists.no_results' : 'playlists.empty')}

} -
{items.map(item => - - {item.name}{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)} - {item.description && {item.description}} - )}
+ {list.isPending && } + {list.isSuccess && !items.length && ( + setCreate(true)}>{t('playlists.create')} : undefined} + > + {t(search ? 'playlists.no_results' : 'playlists.empty')} + + )} +
{items.map(item => )}
{create && setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
; } @@ -45,7 +49,7 @@ export function PlaylistPage({ id }: { id: string }) { return
← {t('playlists.title')} {query.error ? errorText(query.error) : null} - {query.isPending &&

{t('playlists.loading')}

} + {query.isPending && } {query.data && }
; } @@ -93,7 +97,11 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) { {save.error ? errorText(save.error) : null} - {!playlist.tracks.length &&

{t('playlists.empty_tracks')}

{t('playlists.find_tracks')}
} + {!playlist.tracks.length && ( + {t('playlists.find_tracks')}}> + {t('playlists.empty_tracks')} + + )} String(i + 1)} actions={manage ? (track, i) => @@ -101,9 +109,52 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) { : undefined} /> {edit && setEdit(false)} onSaved={() => setEdit(false)} />} - {remove && { if (!deletion.isPending) setRemove(false); }} footer={<> - - - }>

{t('playlists.delete_confirm', { name: playlist.name })}

{deletion.error ? errorText(deletion.error) : null}
} + {remove && ( + { + if (!deletion.isPending) setRemove(false); + }} + footer={ + <> + + + + } + > +

{t('playlists.delete_confirm', { name: playlist.name })}

+ {deletion.error ? errorText(deletion.error) : null} +
+ )} ; } + +function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) { + const { t } = useTranslation(); + const session = useSession(); + const player = usePlayer(); + const play = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + void (async () => { + const full = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id: item.id } } })); + const tracks = full.tracks.filter((x) => x.available); + if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name }); + })(); + }; + return ( + + + + {item.track_count > 0 && ( + + )} + + {item.name} + {t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)} + {item.description && {item.description}} + + ); +} diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx index d7018c4..4fa1baa 100644 --- a/apps/web/src/pages/SearchPage.tsx +++ b/apps/web/src/pages/SearchPage.tsx @@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog'; import { useErrorText } from '../shared/lib/errors'; -import { Button, FormError } from '../shared/ui/ui'; +import { searchIcon } from '../shared/ui/icons'; +import { Button, EmptyState, FormError, PageSkeleton } from '../shared/ui/ui'; import s from './page.module.css'; import c from './catalog.module.css'; @@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) { {results.error ? errorText(results.error) : null} - {empty &&
{t('search.nothing')}
} + {query === '' && {t('search.type_query')}} + {query !== '' && results.isPending && } + {empty && ( + {t('search.nothing')} + )} {artists.length > 0 && (
diff --git a/apps/web/src/pages/TrackPage.tsx b/apps/web/src/pages/TrackPage.tsx index f54dc93..1218fd1 100644 --- a/apps/web/src/pages/TrackPage.tsx +++ b/apps/web/src/pages/TrackPage.tsx @@ -4,8 +4,12 @@ import { Link } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; -import { ArtistLinks, Cover, TrackList } from '../entities/catalog'; +import { ArtistLinks, Cover, coverSrc } from '../entities/catalog'; +import { AddToPlaylist } from '../entities/playlists'; +import { usePlayer, usePlayerState } from '../player/context'; import { formatDuration } from '../shared/lib/format'; +import { pauseIcon, playIcon } from '../shared/ui/icons'; +import { Button, PageSkeleton } from '../shared/ui/ui'; import { NotFound } from './NotFound'; import s from './page.module.css'; import c from './catalog.module.css'; @@ -14,27 +18,63 @@ import { TrackGenius } from './TrackGenius'; export function TrackPage({ id }: { id: string }) { const { t } = useTranslation(); const session = useSession(); + const player = usePlayer(); + const st = usePlayerState(); const track = useQuery({ queryKey: ['track', id], queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })), }); + if (track.isPending) { + return ( +
+ +
+ ); + } if (track.isError) return ; const tr = track.data; + if (!tr) return null; + const ctx = { type: 'queue' as const }; + const current = st.track?.id === tr.id; + const playing = current && st.playing; return (
-
- {tr && } -
- {t('player.track_page')} -

{tr?.title ?? '…'}

- {tr && <> - · {formatDuration(tr.duration_ms)} - {tr.album.title} - } +
+
+ {tr.album.cover_url && ( + + + + )} + +
+ {t('player.track_page')} +

{tr.title}

+ + · {formatDuration(tr.duration_ms)} + + + {tr.album.title} + +
+ {tr.available && ( + + )} + +
+
+
+
+
-
- {tr && } - {tr && } +
); } diff --git a/apps/web/src/pages/admin/AuditPage.tsx b/apps/web/src/pages/admin/AuditPage.tsx index d594122..8f6ced1 100644 --- a/apps/web/src/pages/admin/AuditPage.tsx +++ b/apps/web/src/pages/admin/AuditPage.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; import { useErrorText } from '../../shared/lib/errors'; import { formatDateTime } from '../../shared/lib/format'; -import { Button, FormError } from '../../shared/ui/ui'; +import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui'; import s from '../page.module.css'; type Entry = components['schemas']['AuditEntry']; @@ -67,8 +67,8 @@ export function AuditPage() { ))} - {list.isSuccess && entries.length === 0 &&
{t('audit.empty')}
} - {list.isPending &&
{t('common:loading')}
} + {list.isSuccess && entries.length === 0 && {t('audit.empty')}} + {list.isPending && } {list.hasNextPage && ( }> + {t('sources.empty')} + + )}
{sources.data?.map((src) => ( setEditing(src)} /> diff --git a/apps/web/src/pages/admin/UploadQueuePage.tsx b/apps/web/src/pages/admin/UploadQueuePage.tsx index 8b66963..fdea907 100644 --- a/apps/web/src/pages/admin/UploadQueuePage.tsx +++ b/apps/web/src/pages/admin/UploadQueuePage.tsx @@ -8,8 +8,8 @@ import { useSession } from '../../app/session'; import { needsFormatAttention } from '../../features/uploads/files'; import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format'; -import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons'; -import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui'; +import { checkIcon, closeIcon, editIcon, inboxIcon, refreshIcon } from '../../shared/ui/icons'; +import { Button, cx, Dialog, EmptyState, Field, FormError, Icon } from '../../shared/ui/ui'; import s from '../page.module.css'; import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common'; import u from '../uploads/uploads.module.css'; @@ -123,7 +123,7 @@ export function UploadQueuePage() { {failed.length > 0 && ( {t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })} )} - {list.isSuccess && items.length === 0 &&
{t('uploads.queue_empty')}
} + {list.isSuccess && items.length === 0 && {t('uploads.queue_empty')}} {batches.map((files) => ( - {list.isSuccess && users.length === 0 &&
{t('users.empty')}
} - {list.isPending &&
{t('common:loading')}
} + {list.isSuccess && users.length === 0 && {t('users.empty')}} + {list.isPending && }
{list.hasNextPage && (