feat(upload): persistent "Recently uploaded" list (§A8)
The transient client-side upload queue vanished on refresh, so a just- uploaded track seemed to disappear. Add a server-backed "Recently uploaded" section (source=upload, newest first) that survives refresh and auto-refreshes after each upload (the upload mutation already invalidates the `Track` tag this query provides). - api: `source` filter on `LibraryFilters` → `GET /tracks?source=` - i18n: `upload.recent.*` (en + ru); loading/error/empty states Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ function trackParams(f: LibraryFilters) {
|
|||||||
q: f.search,
|
q: f.search,
|
||||||
artist_id: f.artistId,
|
artist_id: f.artistId,
|
||||||
album_id: f.albumId,
|
album_id: f.albumId,
|
||||||
|
source: f.source,
|
||||||
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
||||||
order: f.sortOrder,
|
order: f.sortOrder,
|
||||||
...paging(f.page, f.pageSize),
|
...paging(f.page, f.pageSize),
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ export interface LibraryFilters {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
|
/** Filter by ingest origin, e.g. `upload`, `youtube`, `local`. */
|
||||||
|
source?: string;
|
||||||
liked?: boolean;
|
liked?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|||||||
@@ -6,8 +6,23 @@ import {
|
|||||||
buildUploadFormData,
|
buildUploadFormData,
|
||||||
useUploadTrackMutation,
|
useUploadTrackMutation,
|
||||||
} from '../../api/endpoints/upload';
|
} from '../../api/endpoints/upload';
|
||||||
import { useGetTrackQuery } from '../../api/endpoints/library';
|
import {
|
||||||
|
useGetTrackQuery,
|
||||||
|
useGetTracksQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
|
||||||
|
/** A8 "Recently uploaded": server-backed list (source=upload, newest first) so
|
||||||
|
* it survives a page refresh — unlike the transient client-side queue above. */
|
||||||
|
const RECENT_UPLOADS = {
|
||||||
|
source: 'upload',
|
||||||
|
sortBy: 'dateAdded',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
pageSize: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Pure client-side state — this is a transient upload queue, never server data. */
|
/** Pure client-side state — this is a transient upload queue, never server data. */
|
||||||
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
||||||
@@ -40,6 +55,10 @@ export function UploadPage() {
|
|||||||
const [items, setItems] = useState<QueueItem[]>([]);
|
const [items, setItems] = useState<QueueItem[]>([]);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
// Persisted view of past uploads. Auto-refreshes after each upload because the
|
||||||
|
// upload mutation invalidates the `Track` tag this query provides.
|
||||||
|
const recentQuery = useGetTracksQuery(RECENT_UPLOADS);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const idCounter = useRef(0);
|
const idCounter = useRef(0);
|
||||||
const activeCount = useRef(0);
|
const activeCount = useRef(0);
|
||||||
@@ -228,6 +247,32 @@ export function UploadPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||||
|
{t('upload.recent.title')}
|
||||||
|
</span>
|
||||||
|
{recentQuery.isLoading && <LoadingSkeleton rows={4} />}
|
||||||
|
{recentQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => recentQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{recentQuery.data && recentQuery.data.items.length === 0 && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('upload.recent.empty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recentQuery.data?.items.map((track, i) => (
|
||||||
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ const en = {
|
|||||||
clearCompleted: 'Clear completed',
|
clearCompleted: 'Clear completed',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
editMetadata: 'Edit metadata',
|
editMetadata: 'Edit metadata',
|
||||||
|
recent: {
|
||||||
|
title: 'Recently uploaded',
|
||||||
|
empty: 'Nothing uploaded yet.',
|
||||||
|
},
|
||||||
metadataPending:
|
metadataPending:
|
||||||
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
||||||
unknownArtist: 'Unknown Artist · metadata pending',
|
unknownArtist: 'Unknown Artist · metadata pending',
|
||||||
|
|||||||
@@ -312,6 +312,10 @@ const ru: Translations = {
|
|||||||
clearCompleted: 'Убрать завершённые',
|
clearCompleted: 'Убрать завершённые',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
editMetadata: 'Изменить метаданные',
|
editMetadata: 'Изменить метаданные',
|
||||||
|
recent: {
|
||||||
|
title: 'Недавно загруженные',
|
||||||
|
empty: 'Пока ничего не загружено.',
|
||||||
|
},
|
||||||
metadataPending:
|
metadataPending:
|
||||||
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
||||||
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
||||||
|
|||||||
Reference in New Issue
Block a user