feat(upload): persistent "Recently uploaded" list (§A8)
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

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:
Senko-san
2026-06-14 01:36:07 +03:00
parent 45a624b642
commit 4aa071eeeb
5 changed files with 57 additions and 1 deletions
+1
View File
@@ -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),
+2
View File
@@ -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;
+46 -1
View File
@@ -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>
+4
View File
@@ -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',
+4
View File
@@ -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 · метаданные в ожидании',