Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1b2b40ffd | |||
| 8a70f478c3 | |||
| 9c344b98c4 | |||
| 42080b37ea | |||
| a37c19fd45 | |||
| facc215450 | |||
| 98e9344261 | |||
| 1228118027 | |||
| 538cfb9c5b | |||
| 2ad3b128d6 |
@@ -1,2 +1,6 @@
|
||||
# Default backend URL (overridable at runtime in the UI)
|
||||
PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
|
||||
|
||||
# Show the public sign-up UI on the connect screen. Set to false to hide it.
|
||||
# The backend's ALLOW_REGISTRATION is the real authority; this only gates the UI.
|
||||
PUBLIC_ENABLE_REGISTRATION=true
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
# Write the SPA's runtime operator config at container start.
|
||||
#
|
||||
# The nginx base image runs every /docker-entrypoint.d/*.sh before launching
|
||||
# nginx, so this overwrites the build-time public/config.js stub with the value
|
||||
# of $PUBLIC_API_BASE_URL. That lets one prebuilt image target any backend
|
||||
# origin without rebuilding. Resolution + precedence live in src/config/env.ts.
|
||||
# nginx, so this overwrites the build-time public/config.js stub with the
|
||||
# operator's runtime config ($PUBLIC_API_BASE_URL, $PUBLIC_ENABLE_REGISTRATION).
|
||||
# That lets one prebuilt image target any backend origin and toggle sign-up
|
||||
# without rebuilding. Resolution + precedence live in src/config/env.ts.
|
||||
set -eu
|
||||
|
||||
: "${PUBLIC_API_BASE_URL:=/api/v1}"
|
||||
: "${PUBLIC_ENABLE_REGISTRATION:=true}"
|
||||
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
|
||||
|
||||
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s"};\n' "$PUBLIC_API_BASE_URL" \
|
||||
# Anything but "false"/"0" enables the sign-up UI (mirrors parseFlag in env.ts).
|
||||
if [ "$PUBLIC_ENABLE_REGISTRATION" = "false" ] || [ "$PUBLIC_ENABLE_REGISTRATION" = "0" ]; then
|
||||
ENABLE_REGISTRATION=false
|
||||
else
|
||||
ENABLE_REGISTRATION=true
|
||||
fi
|
||||
|
||||
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s","enableRegistration":%s};\n' \
|
||||
"$PUBLIC_API_BASE_URL" "$ENABLE_REGISTRATION" \
|
||||
>"$ROOT/config.js"
|
||||
|
||||
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL to $ROOT/config.js"
|
||||
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL enableRegistration=$ENABLE_REGISTRATION to $ROOT/config.js"
|
||||
|
||||
Generated
+4
-4
@@ -8,7 +8,7 @@
|
||||
"name": "mcma-webui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@olly/modern-sk": "0.1.4-3",
|
||||
"@olly/modern-sk": "^0.1.5",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"i18next": "^26.3.1",
|
||||
@@ -693,9 +693,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@olly/modern-sk": {
|
||||
"version": "0.1.4-3",
|
||||
"resolved": "https://git.ollyhearn.ru/api/packages/olly/npm/%40olly%2Fmodern-sk/-/0.1.4-3/modern-sk-0.1.4-3.tgz",
|
||||
"integrity": "sha512-h+d+Jd3DBr7d51V78p1Eb5rVrpN4PAskwQFnh2X4Dk7Q8oajiMVJuhZU1amx97bKHFDHgcOfhwc4cS8P4tFCmQ==",
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://git.ollyhearn.ru/api/packages/olly/npm/%40olly%2Fmodern-sk/-/0.1.5/modern-sk-0.1.5.tgz",
|
||||
"integrity": "sha512-rhKp4U2IovSZkgdfg4oZqyhF0GgB8oR5TPlPXg0iYQEuEtff5zAgRXS+uY3dOPg2tStG3ysHUJaohD9YS2ADiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"test:watch": "rstest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@olly/modern-sk": "0.1.4-3",
|
||||
"@olly/modern-sk": "^0.1.5",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"i18next": "^26.3.1",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { api } from '../index';
|
||||
import { toUser, type RawUser } from '../mappers';
|
||||
import type { AuthTokens, LoginRequest, LoginResponse, User } from '../types';
|
||||
import type {
|
||||
AuthTokens,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
User,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Auth seam over the backend's wire format: tokens-only login + a separate
|
||||
@@ -48,6 +54,29 @@ export const authApi = api.injectEndpoints({
|
||||
return { data: { user, tokens } };
|
||||
},
|
||||
}),
|
||||
// Sign-up mirrors login: POST /auth/register returns a token pair (the
|
||||
// backend logs the new account straight in), then GET /auth/me resolves the
|
||||
// user — so the UI gets the same unified { user, tokens } as login.
|
||||
register: build.mutation<LoginResponse, RegisterRequest>({
|
||||
async queryFn(body, _api, _extra, baseQuery) {
|
||||
const tokenRes = await baseQuery({
|
||||
url: '/auth/register',
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
if (tokenRes.error) return { error: tokenRes.error };
|
||||
const tokens = toTokens(tokenRes.data as RawTokenResponse);
|
||||
|
||||
const meRes = await baseQuery({
|
||||
url: '/auth/me',
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
if (meRes.error) return { error: meRes.error };
|
||||
const user = toUser(meRes.data as RawUser);
|
||||
|
||||
return { data: { user, tokens } };
|
||||
},
|
||||
}),
|
||||
logout: build.mutation<void, { refreshToken: string }>({
|
||||
query: ({ refreshToken }) => ({
|
||||
url: '/auth/logout',
|
||||
@@ -74,6 +103,7 @@ export const authApi = api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useLoginMutation,
|
||||
useRegisterMutation,
|
||||
useLogoutMutation,
|
||||
useRefreshTokenMutation,
|
||||
useMeQuery,
|
||||
|
||||
@@ -2,10 +2,12 @@ import { api } from '../index';
|
||||
import {
|
||||
toAlbum,
|
||||
toArtist,
|
||||
toMetadataMatch,
|
||||
toPage,
|
||||
toTrack,
|
||||
type RawAlbum,
|
||||
type RawArtist,
|
||||
type RawMetadataMatch,
|
||||
type RawPaged,
|
||||
type RawTrack,
|
||||
} from '../mappers';
|
||||
@@ -13,6 +15,8 @@ import type {
|
||||
Track,
|
||||
Album,
|
||||
Artist,
|
||||
MetadataEdit,
|
||||
MetadataMatch,
|
||||
PaginatedResponse,
|
||||
LibraryFilters,
|
||||
} from '../types';
|
||||
@@ -161,6 +165,41 @@ export const libraryApi = api.injectEndpoints({
|
||||
}),
|
||||
providesTags: ['Track', 'Album', 'Artist'],
|
||||
}),
|
||||
getMetadataMatches: build.query<MetadataMatch[], string>({
|
||||
query: (trackId) => `/tracks/${trackId}/metadata/matches`,
|
||||
transformResponse: (raw: { items: RawMetadataMatch[] }) =>
|
||||
raw.items.map(toMetadataMatch),
|
||||
}),
|
||||
applyMetadata: build.mutation<
|
||||
Track,
|
||||
{ trackId: string; edit: MetadataEdit }
|
||||
>({
|
||||
query: ({ trackId, edit }) => ({
|
||||
url: `/tracks/${trackId}/metadata`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
title: edit.title,
|
||||
artist_name: edit.artistName,
|
||||
album_title: edit.albumTitle,
|
||||
year: edit.year,
|
||||
genre: edit.genre,
|
||||
track_number: edit.trackNumber,
|
||||
},
|
||||
}),
|
||||
transformResponse: (raw: RawTrack) => toTrack(raw),
|
||||
invalidatesTags: (_r, _e, { trackId }) => [
|
||||
{ type: 'Track', id: trackId },
|
||||
'Album',
|
||||
'Artist',
|
||||
],
|
||||
}),
|
||||
enrichTrack: build.mutation<{ track_id: string; job_id: string }, string>({
|
||||
query: (trackId) => ({
|
||||
url: `/tracks/${trackId}/metadata/enrich`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -176,4 +215,7 @@ export const {
|
||||
useGetArtistAlbumsQuery,
|
||||
useGetArtistTracksQuery,
|
||||
useSearchLibraryQuery,
|
||||
useLazyGetMetadataMatchesQuery,
|
||||
useApplyMetadataMutation,
|
||||
useEnrichTrackMutation,
|
||||
} = libraryApi;
|
||||
|
||||
@@ -17,3 +17,18 @@ export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
||||
const base = getApiBaseUrl();
|
||||
return `${base}${artUrl}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover image URL for a track, served by `GET /tracks/{id}/cover`. Like the
|
||||
* audio stream, an `<img>` can't send an `Authorization` header, so the access
|
||||
* token rides as `?token=`. Returns undefined when the track has no cover.
|
||||
*/
|
||||
export function getTrackCoverUrl(
|
||||
trackId: string,
|
||||
token: string,
|
||||
hasCover: boolean,
|
||||
): string | undefined {
|
||||
if (!hasCover) return undefined;
|
||||
const base = getApiBaseUrl();
|
||||
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
+57
-2
@@ -14,12 +14,28 @@
|
||||
import type {
|
||||
Album,
|
||||
Artist,
|
||||
MetadataMatch,
|
||||
MetadataStatus,
|
||||
PaginatedResponse,
|
||||
Playlist,
|
||||
Track,
|
||||
User,
|
||||
} from './types';
|
||||
|
||||
const METADATA_STATUSES: readonly MetadataStatus[] = [
|
||||
'pending',
|
||||
'enriched',
|
||||
'failed',
|
||||
'manual',
|
||||
];
|
||||
|
||||
/** Map the backend's free-form status string onto the UI union, defaulting any
|
||||
* unknown value to `pending` (a safe "not yet identified" state). */
|
||||
const toMetadataStatus = (raw: string): MetadataStatus =>
|
||||
(METADATA_STATUSES as readonly string[]).includes(raw)
|
||||
? (raw as MetadataStatus)
|
||||
: 'pending';
|
||||
|
||||
// ---- raw wire shapes (snake_case, exactly as the backend emits) ----
|
||||
|
||||
export interface RawPaged<T> {
|
||||
@@ -48,11 +64,29 @@ export interface RawTrack {
|
||||
duration_seconds: number | null;
|
||||
file_format: string;
|
||||
file_size: number;
|
||||
genre: string | null;
|
||||
year: number | null;
|
||||
track_number: number | null;
|
||||
metadata_status: string;
|
||||
metadata_error: string | null;
|
||||
enriched_at: string | null;
|
||||
has_cover: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** One AcoustID candidate, as returned by `GET /tracks/{id}/metadata/matches`. */
|
||||
export interface RawMetadataMatch {
|
||||
acoustid: string;
|
||||
score: number;
|
||||
recording_mbid: string | null;
|
||||
release_group_mbid: string | null;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
year: number | null;
|
||||
}
|
||||
|
||||
export interface RawAlbum {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -98,16 +132,37 @@ export const toTrack = (r: RawTrack): Track => ({
|
||||
artistName: r.artist_name,
|
||||
albumId: r.album_id ?? '',
|
||||
albumTitle: r.album_title ?? '',
|
||||
// Cover endpoints aren't wired on the backend yet — leave art undefined so the
|
||||
// UI renders generated tile art instead of a broken image.
|
||||
// `has_cover` says a cover exists; the actual URL (which needs a `?token=`) is
|
||||
// built in the component from the track id — see `getTrackCoverUrl`. Keep
|
||||
// `albumArtUrl` undefined so callers fall back to generated tile art.
|
||||
albumArtUrl: undefined,
|
||||
hasCover: r.has_cover,
|
||||
durationMs: (r.duration_seconds ?? 0) * 1000,
|
||||
// The lean TrackOut carries no availability/like state: a track returned by
|
||||
// the library is on the server, and per-track like state comes from /likes.
|
||||
availability: 'server',
|
||||
metadataStatus: toMetadataStatus(r.metadata_status),
|
||||
metadataError: r.metadata_error ?? undefined,
|
||||
genre: r.genre ?? undefined,
|
||||
year: r.year ?? undefined,
|
||||
trackNumber: r.track_number ?? undefined,
|
||||
liked: false,
|
||||
format: r.file_format,
|
||||
fileSize: r.file_size,
|
||||
source: r.source,
|
||||
createdAt: r.created_at,
|
||||
enrichedAt: r.enriched_at ?? undefined,
|
||||
});
|
||||
|
||||
export const toMetadataMatch = (r: RawMetadataMatch): MetadataMatch => ({
|
||||
acoustid: r.acoustid,
|
||||
score: r.score,
|
||||
recordingMbid: r.recording_mbid ?? undefined,
|
||||
releaseGroupMbid: r.release_group_mbid ?? undefined,
|
||||
title: r.title ?? undefined,
|
||||
artist: r.artist ?? undefined,
|
||||
album: r.album ?? undefined,
|
||||
year: r.year ?? undefined,
|
||||
});
|
||||
|
||||
export const toAlbum = (r: RawAlbum): Album => ({
|
||||
|
||||
@@ -15,6 +15,11 @@ export const REHYDRATE_API = 'api/rehydrate';
|
||||
export interface RehydrateApiPayload {
|
||||
queries: Record<string, unknown>;
|
||||
mutations: Record<string, unknown>;
|
||||
// RTKQ's invalidation slice reads `provided.tags`/`provided.keys` during
|
||||
// rehydration (it does `Object.entries(provided.tags ?? {})`), so `provided`
|
||||
// must be an object — a bare `{ queries, mutations }` makes it crash on
|
||||
// `provided.tags` of undefined. Always present; empty objects are valid.
|
||||
provided: { tags: Record<string, unknown>; keys: Record<string, unknown> };
|
||||
}
|
||||
|
||||
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
|
||||
|
||||
/**
|
||||
* Metadata-enrichment state, distinct from file `availability`. `pending` = the
|
||||
* worker hasn't finished (or hasn't started); `enriched` = identity found;
|
||||
* `failed` = no match / a worker error (see `metadataError`); `manual` = user-
|
||||
* edited and never auto-overwritten.
|
||||
*/
|
||||
export type MetadataStatus = 'pending' | 'enriched' | 'failed' | 'manual';
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -8,16 +16,26 @@ export interface Track {
|
||||
albumId: string;
|
||||
albumTitle: string;
|
||||
albumArtUrl?: string;
|
||||
hasCover: boolean;
|
||||
durationMs: number;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
availability: TrackAvailability;
|
||||
metadataStatus: MetadataStatus;
|
||||
/** Human-readable reason the last enrichment run set `failed`; else undefined. */
|
||||
metadataError?: string;
|
||||
fileSize?: number;
|
||||
format?: string;
|
||||
bitrate?: number;
|
||||
liked: boolean;
|
||||
/** Where the track entered the library (e.g. `upload`, `local_folder`). */
|
||||
source?: string;
|
||||
/** ISO timestamp the track was added to the library. */
|
||||
createdAt?: string;
|
||||
/** ISO timestamp the last successful enrichment ran; undefined if never. */
|
||||
enrichedAt?: string;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
@@ -113,6 +131,11 @@ export interface LoginResponse {
|
||||
tokens: AuthTokens;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
@@ -138,3 +161,26 @@ export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** One AcoustID candidate from `GET /tracks/{id}/metadata/matches` (§A7). */
|
||||
export interface MetadataMatch {
|
||||
acoustid: string;
|
||||
/** Confidence 0..1. */
|
||||
score: number;
|
||||
recordingMbid?: string;
|
||||
releaseGroupMbid?: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
/** Manual edits / an accepted match, sent to `PUT /tracks/{id}/metadata`. */
|
||||
export interface MetadataEdit {
|
||||
title?: string;
|
||||
artistName?: string;
|
||||
albumTitle?: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
trackNumber?: number;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
GearSix,
|
||||
HardDrives,
|
||||
Heart,
|
||||
Info,
|
||||
MagnifyingGlass,
|
||||
Pause,
|
||||
Play,
|
||||
@@ -69,6 +70,7 @@ const ICONS = {
|
||||
'skip-forward': SkipForward,
|
||||
repeat: Repeat,
|
||||
heart: Heart,
|
||||
info: Info,
|
||||
'thumbs-down': ThumbsDown,
|
||||
'speaker-high': SpeakerHigh,
|
||||
'speaker-x': SpeakerSimpleX,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Suspense } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||
import { QueuePanel } from '../player/QueuePanel';
|
||||
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||
|
||||
export function AppShell() {
|
||||
@@ -31,6 +32,7 @@ export function AppShell() {
|
||||
</div>
|
||||
</main>
|
||||
<QueuePanel />
|
||||
<TrackInfoDrawer />
|
||||
</div>
|
||||
<PersistentPlayer />
|
||||
</div>
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
setVolume,
|
||||
toggleShuffle,
|
||||
setRepeat,
|
||||
toggleNowPlaying,
|
||||
toggleQueue,
|
||||
} from '../../store/slices/player';
|
||||
import { openTrackInfo } from '../../store/slices/ui';
|
||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||
import { useStreamCached } from '../../hooks/useStreamCached';
|
||||
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||
|
||||
export function PersistentPlayer() {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,7 +25,11 @@ export function PersistentPlayer() {
|
||||
const { seek, playNext, playPrev } = useAudioPlayer();
|
||||
const player = useAppSelector((s) => s.player);
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const token = useAppSelector((s) => s.auth.accessToken);
|
||||
const currentEntry = queue.entries[queue.currentIndex];
|
||||
// Read through to the live Track cache so enrichment updates reach the player,
|
||||
// not just the play-time snapshot frozen in the queue slice.
|
||||
const current = useResolvedQueueEntry(currentEntry);
|
||||
// Source indicator: cached → playing locally, otherwise streaming.
|
||||
const cached = useStreamCached(currentEntry?.trackId);
|
||||
|
||||
@@ -32,21 +37,28 @@ export function PersistentPlayer() {
|
||||
return <div className="player empty">{t('player.nothingPlaying')}</div>;
|
||||
}
|
||||
|
||||
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
|
||||
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
|
||||
const artUrl =
|
||||
getCoverUrl(currentEntry?.albumArtUrl) ??
|
||||
(token && current?.hasCover
|
||||
? getTrackCoverUrl(current.trackId, token, true)
|
||||
: undefined);
|
||||
const seedLabel = current?.albumTitle ?? current?.title ?? '';
|
||||
const onStream = !cached;
|
||||
|
||||
return (
|
||||
<div className="player">
|
||||
<div
|
||||
className="pl-now"
|
||||
onClick={() => dispatch(toggleNowPlaying())}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
currentEntry && dispatch(openTrackInfo(currentEntry.trackId))
|
||||
}
|
||||
style={{ cursor: currentEntry ? 'pointer' : 'default' }}
|
||||
title={currentEntry ? t('trackInfo.open') : undefined}
|
||||
>
|
||||
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
|
||||
<div className="pl-now-tt">
|
||||
<div className="t">{currentEntry?.title ?? '—'}</div>
|
||||
<div className="a">{currentEntry?.artistName ?? ''}</div>
|
||||
<div className="t">{current?.title ?? '—'}</div>
|
||||
<div className="a">{current?.artistName ?? ''}</div>
|
||||
<div
|
||||
className="pl-srcbadge"
|
||||
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
goToIndex,
|
||||
removeFromQueue,
|
||||
clearQueue,
|
||||
type QueueEntry,
|
||||
} from '../../store/slices/queue';
|
||||
import { toggleQueue } from '../../store/slices/player';
|
||||
import { openTrackInfo } from '../../store/slices/ui';
|
||||
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||
|
||||
export function QueuePanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,8 +19,9 @@ export function QueuePanel() {
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
||||
|
||||
const now =
|
||||
const nowEntry =
|
||||
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
|
||||
const now = useResolvedQueueEntry(nowEntry);
|
||||
const upNext = queue.entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ index }) => index > queue.currentIndex);
|
||||
@@ -82,14 +86,27 @@ export function QueuePanel() {
|
||||
<div className="t">{now.title}</div>
|
||||
<div className="r">{now.artistName}</div>
|
||||
</div>
|
||||
<Icon name="cloud" style={{ color: 'var(--fg-3)' }} />
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(openTrackInfo(now.trackId))}
|
||||
title={t('trackInfo.open')}
|
||||
>
|
||||
<Icon name="info" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isRadio && (
|
||||
<div className="qd-radio">
|
||||
<div className="row">
|
||||
<Icon name="radio" />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg-1)' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-1)',
|
||||
}}
|
||||
>
|
||||
{t('queue.radioActive')}
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
@@ -120,33 +137,12 @@ export function QueuePanel() {
|
||||
<div className="qd-empty">{t('queue.nothingNext')}</div>
|
||||
) : (
|
||||
upNext.map(({ entry, index }) => (
|
||||
<div
|
||||
<QueueRow
|
||||
key={`${entry.trackId}-${index}`}
|
||||
className="qrow"
|
||||
onDoubleClick={() => dispatch(goToIndex(index))}
|
||||
title={t('queue.doubleClickPlay')}
|
||||
>
|
||||
<span className="grip">
|
||||
<Icon name="dots-six-vertical" />
|
||||
</span>
|
||||
<ArtTile
|
||||
seed={entry.albumTitle}
|
||||
size={36}
|
||||
label={entry.albumTitle}
|
||||
/>
|
||||
<div className="qt">
|
||||
<div className="t">{entry.title}</div>
|
||||
<div className="r">{entry.artistName}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(removeFromQueue(index))}
|
||||
title={t('queue.removeFromQueue')}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
entry={entry}
|
||||
onPlay={() => dispatch(goToIndex(index))}
|
||||
onRemove={() => dispatch(removeFromQueue(index))}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -162,3 +158,44 @@ export function QueuePanel() {
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** An "up next" row, resolving its display fields against the live Track cache
|
||||
* (same read-through as the now-playing entry) so enrichment updates show. */
|
||||
function QueueRow({
|
||||
entry,
|
||||
onPlay,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: QueueEntry;
|
||||
onPlay: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolved = useResolvedQueueEntry(entry);
|
||||
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="qrow"
|
||||
onDoubleClick={onPlay}
|
||||
title={t('queue.doubleClickPlay')}
|
||||
>
|
||||
<span className="grip">
|
||||
<Icon name="dots-six-vertical" />
|
||||
</span>
|
||||
<ArtTile seed={albumTitle} size={36} label={albumTitle} />
|
||||
<div className="qt">
|
||||
<div className="t">{resolved?.title ?? entry.title}</div>
|
||||
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={onRemove}
|
||||
title={t('queue.removeFromQueue')}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MetadataStatus } from '../../api/types';
|
||||
|
||||
interface Props {
|
||||
status: MetadataStatus;
|
||||
/** Reason shown in the tooltip for a `failed` status. */
|
||||
error?: string;
|
||||
/** When true, render nothing for the normal `enriched` state (keeps dense
|
||||
* track lists quiet; the upload screen sets this false to confirm success). */
|
||||
hideWhenEnriched?: boolean;
|
||||
}
|
||||
|
||||
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
|
||||
|
||||
const VARIANT: Record<MetadataStatus, Variant> = {
|
||||
pending: 'neutral',
|
||||
enriched: 'lime',
|
||||
failed: 'ember',
|
||||
manual: 'outline',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows a track's metadata-enrichment state (distinct from file availability).
|
||||
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
|
||||
*/
|
||||
export function MetadataStatusBadge({
|
||||
status,
|
||||
error,
|
||||
hideWhenEnriched = true,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'enriched' && hideWhenEnriched) return null;
|
||||
|
||||
const label = t(`metadata.status.${status}`);
|
||||
const tooltip =
|
||||
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip}>
|
||||
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
|
||||
{status === 'pending' ? <Spinner size="sm" /> : null}
|
||||
{label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
||||
import { play } from '../../store/slices/player';
|
||||
import { openTrackInfo } from '../../store/slices/ui';
|
||||
import type { Track } from '../../api/types';
|
||||
|
||||
interface Props {
|
||||
@@ -42,21 +43,45 @@ export function TrackContextMenu({
|
||||
return (
|
||||
<Menu>
|
||||
<MenuTrigger asChild>
|
||||
<IconButton variant="ghost" size="sm" aria-label={t('track.menu.options')}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('track.menu.options')}
|
||||
>
|
||||
⋯
|
||||
</IconButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem onSelect={() => { dispatch(play(track.id)); }}>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
dispatch(play(track.id));
|
||||
}}
|
||||
>
|
||||
{t('track.menu.playNow')}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
dispatch(addNextInQueue(entry));
|
||||
}}
|
||||
>
|
||||
{t('track.menu.playNext')}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
dispatch(addToQueue(entry));
|
||||
}}
|
||||
>
|
||||
{t('track.menu.addToQueue')}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
dispatch(openTrackInfo(track.id));
|
||||
}}
|
||||
>
|
||||
{t('track.menu.info')}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{onAddToPlaylist && (
|
||||
<MenuItem onSelect={() => onAddToPlaylist(track)}>
|
||||
{t('track.menu.addToPlaylist')}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge, Button } from '@olly/modern-sk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { Icon } from '../common/Icon';
|
||||
import { ArtTile } from '../common/ArtTile';
|
||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||
import { ErrorState } from '../common/ErrorState';
|
||||
import { EmptyState } from '../common/EmptyState';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { closeTrackInfo } from '../../store/slices/ui';
|
||||
import { play } from '../../store/slices/player';
|
||||
import { addToQueue } from '../../store/slices/queue';
|
||||
import {
|
||||
useGetTrackQuery,
|
||||
useGetAlbumQuery,
|
||||
} from '../../api/endpoints/library';
|
||||
import { getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||
import {
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
formatDateTime,
|
||||
} from '../../lib/format';
|
||||
import type { Track } from '../../api/types';
|
||||
|
||||
/**
|
||||
* Right-side "Get Info"-style drawer for a single track. Rendered after the
|
||||
* QueuePanel in AppShell so it sits to the *right* of the queue when both are
|
||||
* open. Open state lives in `ui.trackInfoId`; it reads the live Track (and its
|
||||
* album) from the RTKQ cache so enrichment updates stay in sync.
|
||||
*/
|
||||
export function TrackInfoDrawer() {
|
||||
const trackId = useAppSelector((s) => s.ui.trackInfoId);
|
||||
const isOpen = trackId !== null;
|
||||
|
||||
return (
|
||||
<aside className={`tid${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
||||
<div className="tid-inner">
|
||||
{trackId ? <TrackInfoContent trackId={trackId} /> : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackInfoContent({ trackId }: { trackId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const token = useAppSelector((s) => s.auth.accessToken);
|
||||
|
||||
const {
|
||||
data: track,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useGetTrackQuery(trackId);
|
||||
// Album record fills in fields the lean TrackOut omits (year especially).
|
||||
const { data: album } = useGetAlbumQuery(track?.albumId ?? skipToken);
|
||||
|
||||
const close = () => dispatch(closeTrackInfo());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tid-head">
|
||||
<h3>{t('trackInfo.title')}</h3>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={close}
|
||||
title={t('trackInfo.close')}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tid-scroll">
|
||||
{isLoading ? (
|
||||
<LoadingSkeleton rows={6} />
|
||||
) : isError ? (
|
||||
<ErrorState onRetry={refetch} />
|
||||
) : !track ? (
|
||||
<EmptyState title={t('trackInfo.notFound')} />
|
||||
) : (
|
||||
<TrackInfoBody
|
||||
track={track}
|
||||
albumYear={album?.year}
|
||||
albumTrackCount={album?.trackCount}
|
||||
coverUrl={
|
||||
token
|
||||
? getTrackCoverUrl(track.id, token, track.hasCover)
|
||||
: undefined
|
||||
}
|
||||
onPlay={() => dispatch(play(track.id))}
|
||||
onQueue={() =>
|
||||
dispatch(
|
||||
addToQueue({
|
||||
trackId: track.id,
|
||||
title: track.title,
|
||||
artistName: track.artistName,
|
||||
albumTitle: track.albumTitle,
|
||||
durationMs: track.durationMs,
|
||||
albumArtUrl: track.albumArtUrl,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onEdit={() => {
|
||||
navigate(`/tracks/${track.id}/metadata`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackInfoBody({
|
||||
track,
|
||||
albumYear,
|
||||
albumTrackCount,
|
||||
coverUrl,
|
||||
onPlay,
|
||||
onQueue,
|
||||
onEdit,
|
||||
}: {
|
||||
track: Track;
|
||||
albumYear?: number;
|
||||
albumTrackCount?: number;
|
||||
coverUrl?: string;
|
||||
onPlay: () => void;
|
||||
onQueue: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const seedLabel = track.albumTitle || track.title;
|
||||
const year = track.year ?? albumYear;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tid-cover">
|
||||
{coverUrl ? (
|
||||
<img src={coverUrl} alt={track.albumTitle} />
|
||||
) : (
|
||||
<ArtTile seed={seedLabel} size={256} label={seedLabel} radius={12} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="tid-title">{track.title}</h2>
|
||||
<Link className="tid-sub" to={`/artists/${track.artistId}`}>
|
||||
{track.artistName}
|
||||
</Link>
|
||||
{track.albumId && (
|
||||
<Link className="tid-sub tid-album" to={`/albums/${track.albumId}`}>
|
||||
<Icon name="vinyl-record" />
|
||||
{track.albumTitle}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="tid-actions">
|
||||
<Button variant="primary" size="sm" onClick={onPlay}>
|
||||
<Icon name="play" fill /> {t('trackInfo.play')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onQueue}>
|
||||
<Icon name="queue" /> {t('trackInfo.addToQueue')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||
{t('trackInfo.editMetadata')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<InfoSection title={t('trackInfo.sections.status')}>
|
||||
<div className="tid-status">
|
||||
<AvailabilityBadge availability={track.availability} />
|
||||
<MetadataStatusBadge
|
||||
status={track.metadataStatus}
|
||||
error={track.metadataError}
|
||||
hideWhenEnriched={false}
|
||||
/>
|
||||
{track.liked && (
|
||||
<Badge variant="lime" dot>
|
||||
{t('trackInfo.liked')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{track.metadataStatus === 'failed' && track.metadataError && (
|
||||
<p className="tid-error">{track.metadataError}</p>
|
||||
)}
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection title={t('trackInfo.sections.general')}>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.artist')}
|
||||
value={track.artistName}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.album')}
|
||||
value={track.albumTitle || undefined}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.trackNumber')}
|
||||
value={
|
||||
track.trackNumber !== undefined
|
||||
? albumTrackCount
|
||||
? t('trackInfo.trackOf', {
|
||||
n: track.trackNumber,
|
||||
total: albumTrackCount,
|
||||
})
|
||||
: String(track.trackNumber)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.disc')}
|
||||
value={
|
||||
track.discNumber !== undefined
|
||||
? String(track.discNumber)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.year')}
|
||||
value={year !== undefined ? String(year) : undefined}
|
||||
/>
|
||||
<InfoRow label={t('trackInfo.fields.genre')} value={track.genre} />
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.duration')}
|
||||
value={formatDuration(track.durationMs)}
|
||||
/>
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection title={t('trackInfo.sections.file')}>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.format')}
|
||||
value={track.format?.toUpperCase()}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.bitrate')}
|
||||
value={
|
||||
track.bitrate !== undefined
|
||||
? t('trackInfo.kbps', { n: track.bitrate })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.size')}
|
||||
value={
|
||||
track.fileSize !== undefined
|
||||
? formatFileSize(track.fileSize)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.source')}
|
||||
value={track.source}
|
||||
mono
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.added')}
|
||||
value={formatDateTime(track.createdAt)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.enriched')}
|
||||
value={formatDateTime(track.enrichedAt)}
|
||||
/>
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection title={t('trackInfo.sections.identifiers')}>
|
||||
<InfoRow label={t('trackInfo.fields.trackId')} value={track.id} mono />
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.albumId')}
|
||||
value={track.albumId || undefined}
|
||||
mono
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('trackInfo.fields.artistId')}
|
||||
value={track.artistId}
|
||||
mono
|
||||
/>
|
||||
</InfoSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="tid-section">
|
||||
<span className="msk-label tid-section-label">{title}</span>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A label/value row; renders nothing when the value is empty (Finder-style). */
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="tid-row">
|
||||
<span className="tid-row-k">{label}</span>
|
||||
<span className={`tid-row-v${mono ? ' mono' : ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Row } from '@olly/modern-sk';
|
||||
import { TrackContextMenu } from './TrackContextMenu';
|
||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { play } from '../../store/slices/player';
|
||||
import type { Track } from '../../api/types';
|
||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||
|
||||
interface Props {
|
||||
track: Track;
|
||||
@@ -27,8 +28,13 @@ export function TrackRow({
|
||||
const dispatch = useAppDispatch();
|
||||
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
||||
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||
const token = useAppSelector((s) => s.auth.accessToken);
|
||||
const isActive = currentTrackId === track.id;
|
||||
const artUrl = getCoverUrl(track.albumArtUrl);
|
||||
// Prefer an explicit album art URL; otherwise serve the track's own cover
|
||||
// (needs the token in the query string — `<img>` can't send a header).
|
||||
const artUrl =
|
||||
getCoverUrl(track.albumArtUrl) ??
|
||||
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
|
||||
|
||||
return (
|
||||
<Row
|
||||
@@ -95,7 +101,13 @@ export function TrackRow({
|
||||
{showAlbum && ` · ${track.albumTitle}`}
|
||||
</div>
|
||||
</div>
|
||||
<AvailabilityBadge availability={track.availability} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<MetadataStatusBadge
|
||||
status={track.metadataStatus}
|
||||
error={track.metadataError}
|
||||
/>
|
||||
<AvailabilityBadge availability={track.availability} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -20,3 +20,22 @@ function runtimeApiBaseUrl(): string | undefined {
|
||||
|
||||
export const DEFAULT_API_BASE_URL =
|
||||
runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
|
||||
|
||||
/**
|
||||
* Whether the public sign-up UI is shown. Same precedence as the base URL:
|
||||
* runtime operator config (injected into `window.__APP_CONFIG__` at container
|
||||
* start) wins over the build-time `PUBLIC_ENABLE_REGISTRATION` env, which
|
||||
* defaults to enabled. This only gates the *UI*; the backend independently
|
||||
* enforces `ALLOW_REGISTRATION` and is the real authority.
|
||||
*/
|
||||
function parseFlag(value: string | undefined): boolean | undefined {
|
||||
if (value == null || value === '') return undefined;
|
||||
return value !== 'false' && value !== '0';
|
||||
}
|
||||
|
||||
export const REGISTRATION_ENABLED: boolean =
|
||||
(typeof window !== 'undefined'
|
||||
? window.__APP_CONFIG__?.enableRegistration
|
||||
: undefined) ??
|
||||
parseFlag(import.meta.env.PUBLIC_ENABLE_REGISTRATION) ??
|
||||
true;
|
||||
|
||||
+11
-1
@@ -29,8 +29,13 @@ const ACTIVE_KEY = 'mcma:activeInstance';
|
||||
const LEGACY_URL_KEY = 'mcma_api_base_url';
|
||||
const LEGACY_AUTH_KEY = 'mcma_auth';
|
||||
|
||||
// The UI always talks to the `/api/v1` contract, so users only enter the
|
||||
// origin (and optional reverse-proxy prefix). We append the contract path
|
||||
// here, the single choke point for both the base URL and the instance id, so
|
||||
// `domain.com`, `domain.com/`, and `domain.com/api/v1` all converge.
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
const trimmed = url.trim().replace(/\/+$/, '');
|
||||
return /\/api\/v1$/.test(trimmed) ? trimmed : `${trimmed}/api/v1`;
|
||||
}
|
||||
|
||||
/** Stable, readable id from a base URL — also serves as the storage namespace. */
|
||||
@@ -93,6 +98,11 @@ export function upsertInstance(url: string, name?: string): Instance {
|
||||
return inst;
|
||||
}
|
||||
|
||||
/** Clear a backend's stored session without forgetting the instance itself. */
|
||||
export function clearInstanceAuth(id: string): void {
|
||||
localStorage.removeItem(scopedKey('auth', id));
|
||||
}
|
||||
|
||||
/** Remove a backend and wipe every scoped key it owns. */
|
||||
export function removeInstance(id: string): void {
|
||||
writeRegistry(readRegistry().filter((i) => i.id !== id));
|
||||
|
||||
Vendored
+2
@@ -1,6 +1,7 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_API_BASE_URL?: string;
|
||||
readonly PUBLIC_ENABLE_REGISTRATION?: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
@@ -11,5 +12,6 @@ interface ImportMeta {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: {
|
||||
apiBaseUrl?: string;
|
||||
enableRegistration?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,19 +2,50 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
|
||||
import {
|
||||
Card,
|
||||
TextField,
|
||||
Button,
|
||||
Callout,
|
||||
Badge,
|
||||
Dialog,
|
||||
IconButton,
|
||||
} from '@olly/modern-sk';
|
||||
import { Icon } from '../../components/common/Icon';
|
||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
import { setTokens, setUser } from '../../store/slices/auth';
|
||||
import { setApiBaseUrl } from '../../config/runtime-config';
|
||||
import { useLoginMutation } from '../../api/endpoints/auth';
|
||||
import {
|
||||
useLoginMutation,
|
||||
useRegisterMutation,
|
||||
} from '../../api/endpoints/auth';
|
||||
import { REGISTRATION_ENABLED } from '../../config/env';
|
||||
import {
|
||||
listInstances,
|
||||
getActiveInstanceId,
|
||||
setActiveInstanceId,
|
||||
removeInstance,
|
||||
clearInstanceAuth,
|
||||
upsertInstance,
|
||||
type Instance,
|
||||
} from '../../config/instances';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
|
||||
const HEALTH_VARIANTS = {
|
||||
connected: 'lime',
|
||||
connecting: 'neutral',
|
||||
disconnected: 'ember',
|
||||
error: 'ember',
|
||||
} as const;
|
||||
|
||||
const HEALTH_KEY = {
|
||||
connected: 'conn.connected',
|
||||
connecting: 'conn.connecting',
|
||||
disconnected: 'conn.disconnected',
|
||||
error: 'conn.error',
|
||||
} as const;
|
||||
|
||||
/** Map an RTKQ login failure to a user-facing i18n key. */
|
||||
function resolveLoginError(err: unknown): string {
|
||||
const e = err as FetchBaseQueryError | undefined;
|
||||
@@ -25,6 +56,137 @@ function resolveLoginError(err: unknown): string {
|
||||
return 'connect.errors.generic';
|
||||
}
|
||||
|
||||
/** Map an RTKQ register failure to a user-facing i18n key. */
|
||||
function resolveRegisterError(err: unknown): string {
|
||||
const e = err as FetchBaseQueryError | undefined;
|
||||
if (e && 'status' in e) {
|
||||
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
|
||||
if (e.status === 409) return 'connect.errors.usernameTaken';
|
||||
if (e.status === 422) return 'connect.errors.passwordTooShort';
|
||||
if (e.status === 403) return 'connect.errors.registrationDisabled';
|
||||
}
|
||||
return 'connect.errors.registerFailed';
|
||||
}
|
||||
|
||||
function InstanceRow({
|
||||
inst,
|
||||
selected,
|
||||
onSelect,
|
||||
onLogout,
|
||||
onRemove,
|
||||
}: {
|
||||
inst: Instance;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onLogout: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const status = useConnectionStatus(inst.baseUrl);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
padding: '0.375rem 0',
|
||||
}}
|
||||
>
|
||||
<Badge variant={HEALTH_VARIANTS[status]} dot>
|
||||
{t(HEALTH_KEY[status])}
|
||||
</Badge>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-1)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{inst.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{inst.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
{selected ? (
|
||||
<Badge variant="outline">{t('connect.domains.selected')}</Badge>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={onSelect}>
|
||||
{t('connect.domains.use')}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t('connect.removeDialog.title')}
|
||||
description={t('connect.removeDialog.description', {
|
||||
name: inst.name,
|
||||
})}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
title={t('connect.domains.forgetTitle')}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
}
|
||||
footer={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
{t('connect.removeDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
onLogout();
|
||||
}}
|
||||
>
|
||||
{t('connect.removeDialog.logout')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ember"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t('connect.removeDialog.removeAndLogout')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectPage() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -32,40 +194,79 @@ export function ConnectPage() {
|
||||
|
||||
const [rev, setRev] = useState(0);
|
||||
const instances = listInstances();
|
||||
const activeId = getActiveInstanceId();
|
||||
|
||||
const [apiUrl, setApiUrl] = useState('https://');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(() =>
|
||||
getActiveInstanceId(),
|
||||
);
|
||||
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
|
||||
const [instanceAddShown, setInstanceAddShown] = useState(false);
|
||||
|
||||
const [addUrl, setAddUrl] = useState('');
|
||||
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [login, { isLoading }] = useLoginMutation();
|
||||
const [login, { isLoading: isLoggingIn }] = useLoginMutation();
|
||||
const [register, { isLoading: isRegistering }] = useRegisterMutation();
|
||||
const isLoading = isLoggingIn || isRegistering;
|
||||
|
||||
const switchTo = (id: string) => {
|
||||
const switchMode = (next: Mode) => {
|
||||
setMode(next);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Switching the active instance and reloading lets the app pick the saved
|
||||
// session for that instance back up (if any); if it has none, ProtectedRoute
|
||||
// bounces back here and `selectedId` defaults to it, surfacing the login card.
|
||||
const selectInstance = (id: string) => {
|
||||
setActiveInstanceId(id);
|
||||
window.location.assign('/');
|
||||
};
|
||||
|
||||
const forget = (id: string) => {
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const url = addUrl.trim();
|
||||
if (!url || url === 'https://') return;
|
||||
const inst = upsertInstance(url);
|
||||
setActiveInstanceId(inst.id);
|
||||
setAddUrl('https://');
|
||||
setSelectedId(inst.id);
|
||||
setRev((r) => r + 1);
|
||||
};
|
||||
|
||||
const handleLogout = (id: string) => {
|
||||
clearInstanceAuth(id);
|
||||
setRev((r) => r + 1);
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
removeInstance(id);
|
||||
if (selectedId === id) setSelectedId(getActiveInstanceId());
|
||||
setRev((r) => r + 1);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedInstance) return;
|
||||
setError(null);
|
||||
// Point the API layer at this backend *before* logging in — baseQuery reads
|
||||
// the active instance's URL at request time. Auth tokens then persist under
|
||||
// that instance's namespace, never bleeding across servers.
|
||||
setApiBaseUrl(apiUrl);
|
||||
|
||||
try {
|
||||
const { user, tokens } = await login({ username, password }).unwrap();
|
||||
const action =
|
||||
mode === 'register'
|
||||
? register({ username, password })
|
||||
: login({ username, password });
|
||||
const { user, tokens } = await action.unwrap();
|
||||
dispatch(setTokens(tokens));
|
||||
dispatch(setUser(user));
|
||||
void navigate('/');
|
||||
} catch (err) {
|
||||
setError(resolveLoginError(err));
|
||||
setError(
|
||||
mode === 'register'
|
||||
? resolveRegisterError(err)
|
||||
: resolveLoginError(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,144 +312,172 @@ export function ConnectPage() {
|
||||
<Icon name="vinyl-record" fill /> MCMA
|
||||
</h1>
|
||||
|
||||
{instances.length > 0 && (
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
|
||||
{t('connect.savedInstances')}
|
||||
</span>
|
||||
{instances.map((inst) => (
|
||||
<div
|
||||
key={inst.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
padding: '0.375rem 0',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
|
||||
boxShadow:
|
||||
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-1)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{inst.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{inst.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
{inst.id === activeId ? (
|
||||
<Badge variant="lime">{t('connect.active')}</Badge>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => switchTo(inst.id)}
|
||||
>
|
||||
{t('connect.use')}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => forget(inst.id)}
|
||||
title={t('connect.forgetTitle')}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
padding: '1.5rem',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span className="msk-label">{t('connect.form.title')}</span>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('connect.form.serverUrl')}</label>
|
||||
<TextField
|
||||
value={apiUrl}
|
||||
onChange={(e) => setApiUrl(e.target.value)}
|
||||
placeholder="https://your-server.example.com/api/v1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('connect.form.username')}</label>
|
||||
<TextField
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('connect.form.password')}</label>
|
||||
<TextField
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <Callout variant="danger">{t(error)}</Callout>}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
{instances.length > 0 && (
|
||||
<>
|
||||
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
|
||||
{t('connect.domains.title')}
|
||||
</span>
|
||||
{instances.map((inst) => (
|
||||
<InstanceRow
|
||||
key={inst.id}
|
||||
inst={inst}
|
||||
selected={inst.id === selectedId}
|
||||
onSelect={() => selectInstance(inst.id)}
|
||||
onLogout={() => handleLogout(inst.id)}
|
||||
onRemove={() => handleRemove(inst.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
marginTop: instances.length > 0 ? '0.5rem' : 0,
|
||||
}}
|
||||
>
|
||||
{isLoading ? t('connect.form.submitting') : t('connect.form.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
{instanceAddShown ? (
|
||||
<>
|
||||
<TextField
|
||||
value={addUrl}
|
||||
onChange={(e) => setAddUrl(e.target.value)}
|
||||
placeholder={t('connect.domains.addPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton type="submit" variant="primary">
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setInstanceAddShown(true)}
|
||||
style={{ width: '100%' }}
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon name="plus" /> {t('connect.domains.addButton')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selectedInstance && (
|
||||
<Card>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
padding: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<span className="msk-label">
|
||||
{mode === 'register'
|
||||
? t('connect.login.registerTitle', {
|
||||
name: selectedInstance.name,
|
||||
})
|
||||
: t('connect.login.title', { name: selectedInstance.name })}
|
||||
</span>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('connect.login.username')}</label>
|
||||
<TextField
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('connect.login.password')}</label>
|
||||
<TextField
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="password"
|
||||
autoComplete={
|
||||
mode === 'register' ? 'new-password' : 'current-password'
|
||||
}
|
||||
required
|
||||
/>
|
||||
{mode === 'register' && (
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
marginTop: '0.375rem',
|
||||
}}
|
||||
>
|
||||
{t('connect.login.passwordHint')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && <Callout variant="danger">{t(error)}</Callout>}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
>
|
||||
{isLoading
|
||||
? mode === 'register'
|
||||
? t('connect.login.registering')
|
||||
: t('connect.login.submitting')
|
||||
: mode === 'register'
|
||||
? t('connect.login.registerSubmit')
|
||||
: t('connect.login.submit')}
|
||||
</Button>
|
||||
|
||||
{REGISTRATION_ENABLED && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{mode === 'register' ? (
|
||||
<>
|
||||
{t('connect.login.haveAccount')}{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => switchMode('login')}
|
||||
>
|
||||
{t('connect.login.signInLink')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('connect.login.noAccount')}{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => switchMode('register')}
|
||||
>
|
||||
{t('connect.login.registerLink')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Button, Callout, Card, IconButton, ScrollArea, Spinner, TextField } from '@olly/modern-sk';
|
||||
import {
|
||||
useApplyMetadataMutation,
|
||||
useEnrichTrackMutation,
|
||||
useGetTrackQuery,
|
||||
useLazyGetMetadataMatchesQuery,
|
||||
} from '../../api/endpoints/library';
|
||||
import type { MetadataMatch } from '../../api/types';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
|
||||
interface Props {
|
||||
@@ -6,13 +18,500 @@ interface Props {
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
/** Editable fields, kept as strings while in the form — parsed on save. */
|
||||
interface FormState {
|
||||
title: string;
|
||||
artistName: string;
|
||||
albumTitle: string;
|
||||
year: string;
|
||||
genre: string;
|
||||
trackNumber: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
title: '',
|
||||
artistName: '',
|
||||
albumTitle: '',
|
||||
year: '',
|
||||
genre: '',
|
||||
trackNumber: '',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
marginBottom: '0.375rem',
|
||||
color: 'var(--color-text-2)',
|
||||
};
|
||||
|
||||
function fieldStyle(): React.CSSProperties {
|
||||
return { width: '100%' };
|
||||
}
|
||||
|
||||
/**
|
||||
* `/tracks/:trackId/metadata` (single) and `/metadata/batch` (bulk) — A7
|
||||
* metadata editor with auto-enrichment / diff view. Scaffold only.
|
||||
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
||||
* match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
|
||||
*/
|
||||
export function MetadataEditorPage({ batch = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (batch) {
|
||||
return <Placeholder title={t('pages.metadataBatch')} />;
|
||||
}
|
||||
|
||||
return <SingleTrackEditor />;
|
||||
}
|
||||
|
||||
function SingleTrackEditor() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { trackId } = useParams<{ trackId: string }>();
|
||||
|
||||
const trackQuery = useGetTrackQuery(trackId ?? '', { skip: !trackId });
|
||||
const [findMatches, matchesResult] = useLazyGetMetadataMatchesQuery();
|
||||
const [applyMetadata, applyResult] = useApplyMetadataMutation();
|
||||
const [enrichTrack, enrichResult] = useEnrichTrackMutation();
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(null);
|
||||
|
||||
// Seed the form from the loaded track exactly once — afterwards it's the
|
||||
// user's edit buffer and shouldn't be clobbered by refetches.
|
||||
useEffect(() => {
|
||||
if (initialized || !trackQuery.data) return;
|
||||
const track = trackQuery.data;
|
||||
setForm({
|
||||
title: track.title,
|
||||
artistName: track.artistName,
|
||||
albumTitle: track.albumTitle,
|
||||
year: track.year != null ? String(track.year) : '',
|
||||
genre: track.genre ?? '',
|
||||
trackNumber: track.trackNumber != null ? String(track.trackNumber) : '',
|
||||
});
|
||||
setInitialized(true);
|
||||
}, [initialized, trackQuery.data]);
|
||||
|
||||
if (!trackId) {
|
||||
return <ErrorState message={t('metadataEditor.error')} />;
|
||||
}
|
||||
|
||||
if (trackQuery.isLoading || !initialized) {
|
||||
return (
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<LoadingSkeleton rows={6} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (trackQuery.isError || !trackQuery.data) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={t('metadataEditor.error')}
|
||||
onRetry={() => trackQuery.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const track = trackQuery.data;
|
||||
|
||||
const updateField = (key: keyof FormState) => (value: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const applyMatch = (match: MetadataMatch) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
title: match.title ?? prev.title,
|
||||
artistName: match.artist ?? prev.artistName,
|
||||
albumTitle: match.album ?? prev.albumTitle,
|
||||
year: match.year != null ? String(match.year) : prev.year,
|
||||
}));
|
||||
setSelectedMatch(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await applyMetadata({
|
||||
trackId,
|
||||
edit: {
|
||||
title: form.title.trim() || undefined,
|
||||
artistName: form.artistName.trim() || undefined,
|
||||
albumTitle: form.albumTitle.trim() || undefined,
|
||||
year: form.year.trim() ? Number(form.year) : undefined,
|
||||
genre: form.genre.trim() || undefined,
|
||||
trackNumber: form.trackNumber.trim() ? Number(form.trackNumber) : undefined,
|
||||
},
|
||||
}).unwrap();
|
||||
};
|
||||
|
||||
return (
|
||||
<Placeholder title={batch ? t('pages.metadataBatch') : t('pages.metadata')} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label={t('common.back')}
|
||||
>
|
||||
←
|
||||
</IconButton>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||
{t('pages.metadata')}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.125rem 0 0',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{track.artistName} · {track.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
maxWidth: 640,
|
||||
}}
|
||||
>
|
||||
{applyResult.isSuccess && (
|
||||
<Callout variant="success">{t('metadataEditor.saved')}</Callout>
|
||||
)}
|
||||
{applyResult.isError && (
|
||||
<Callout variant="danger">{t('metadataEditor.saveError')}</Callout>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('metadataEditor.fields.title')}</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
value={form.title}
|
||||
onChange={(e) => updateField('title')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('metadataEditor.fields.artist')}</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
value={form.artistName}
|
||||
onChange={(e) => updateField('artistName')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('metadataEditor.fields.album')}</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
value={form.albumTitle}
|
||||
onChange={(e) => updateField('albumTitle')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>{t('metadataEditor.fields.year')}</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
type="number"
|
||||
value={form.year}
|
||||
onChange={(e) => updateField('year')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>{t('metadataEditor.fields.trackNumber')}</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
type="number"
|
||||
value={form.trackNumber}
|
||||
onChange={(e) => updateField('trackNumber')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('metadataEditor.fields.genre')}</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
value={form.genre}
|
||||
onChange={(e) => updateField('genre')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={applyResult.isLoading}
|
||||
>
|
||||
{applyResult.isLoading ? <Spinner size="sm" /> : t('metadataEditor.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||
{t('metadataEditor.autoEnrich.title')}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('metadataEditor.autoEnrich.hint')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void enrichTrack(trackId)}
|
||||
disabled={enrichResult.isLoading}
|
||||
>
|
||||
{enrichResult.isLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
t('metadataEditor.autoEnrich.reEnrich')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void findMatches(trackId)}
|
||||
disabled={matchesResult.isFetching}
|
||||
>
|
||||
{matchesResult.isFetching ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
t('metadataEditor.autoEnrich.findMatches')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enrichResult.isSuccess && (
|
||||
<Callout variant="info">{t('metadataEditor.autoEnrich.enqueued')}</Callout>
|
||||
)}
|
||||
|
||||
{matchesResult.isError && (
|
||||
<Callout variant="danger">{t('metadataEditor.autoEnrich.error')}</Callout>
|
||||
)}
|
||||
|
||||
{matchesResult.isSuccess && matchesResult.data && (
|
||||
matchesResult.data.length === 0 ? (
|
||||
<Callout variant="warning">{t('metadataEditor.autoEnrich.noMatches')}</Callout>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{matchesResult.data.map((match) => (
|
||||
<MatchRow
|
||||
key={match.acoustid}
|
||||
match={match}
|
||||
onUse={() => setSelectedMatch(match)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{selectedMatch && (
|
||||
<DiffView
|
||||
current={form}
|
||||
proposed={selectedMatch}
|
||||
onApply={() => applyMatch(selectedMatch)}
|
||||
onCancel={() => setSelectedMatch(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({ match, onUse }: { match: MetadataMatch; onUse: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(match.score * 100);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--color-surface-1)',
|
||||
}}
|
||||
>
|
||||
<Badge variant={pct >= 80 ? 'lime' : pct >= 50 ? 'outline' : 'neutral'}>
|
||||
{pct}%
|
||||
</Badge>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{match.title ?? t('metadataEditor.matches.unknownTitle')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{[match.artist, match.album, match.year]
|
||||
.filter((v) => v !== undefined && v !== null && v !== '')
|
||||
.join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onUse}>
|
||||
{t('metadataEditor.matches.use')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffRowDef {
|
||||
key: 'title' | 'artistName' | 'albumTitle' | 'year';
|
||||
label: string;
|
||||
current: string;
|
||||
proposed?: string;
|
||||
}
|
||||
|
||||
function DiffView({
|
||||
current,
|
||||
proposed,
|
||||
onApply,
|
||||
onCancel,
|
||||
}: {
|
||||
current: FormState;
|
||||
proposed: MetadataMatch;
|
||||
onApply: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const rows: DiffRowDef[] = [
|
||||
{
|
||||
key: 'title',
|
||||
label: t('metadataEditor.fields.title'),
|
||||
current: current.title,
|
||||
proposed: proposed.title,
|
||||
},
|
||||
{
|
||||
key: 'artistName',
|
||||
label: t('metadataEditor.fields.artist'),
|
||||
current: current.artistName,
|
||||
proposed: proposed.artist,
|
||||
},
|
||||
{
|
||||
key: 'albumTitle',
|
||||
label: t('metadataEditor.fields.album'),
|
||||
current: current.albumTitle,
|
||||
proposed: proposed.album,
|
||||
},
|
||||
{
|
||||
key: 'year',
|
||||
label: t('metadataEditor.fields.year'),
|
||||
current: current.year,
|
||||
proposed: proposed.year != null ? String(proposed.year) : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const changed = rows.filter(
|
||||
(row) => row.proposed !== undefined && row.proposed !== row.current,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '0.875rem 1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.625rem',
|
||||
background: 'var(--color-surface-1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||
{t('metadataEditor.diff.title')}
|
||||
</div>
|
||||
{changed.length === 0 ? (
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('metadataEditor.diff.noChanges')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{changed.map((row) => (
|
||||
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
|
||||
<span style={{ color: 'var(--color-text-3)' }}>{row.label}: </span>
|
||||
<span style={{ textDecoration: 'line-through', color: 'var(--color-text-3)' }}>
|
||||
{row.current || '—'}
|
||||
</span>
|
||||
{' → '}
|
||||
<span style={{ color: 'var(--color-accent)', fontWeight: 600 }}>
|
||||
{row.proposed || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('metadataEditor.diff.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={onApply} disabled={changed.length === 0}>
|
||||
{t('metadataEditor.diff.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
buildUploadFormData,
|
||||
useUploadTrackMutation,
|
||||
} from '../../api/endpoints/upload';
|
||||
import { useGetTrackQuery } from '../../api/endpoints/library';
|
||||
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
||||
|
||||
/** Pure client-side state — this is a transient upload queue, never server data. */
|
||||
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
||||
@@ -273,11 +275,7 @@ function UploadRow({
|
||||
{item.error}
|
||||
</div>
|
||||
)}
|
||||
{done && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||
{t('upload.unknownArtist')}
|
||||
</div>
|
||||
)}
|
||||
{done && item.trackId && <EnrichmentStatus trackId={item.trackId} />}
|
||||
</div>
|
||||
|
||||
<StatusBadge status={item.status} />
|
||||
@@ -301,6 +299,59 @@ function UploadRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a just-uploaded track until enrichment settles, then shows the outcome.
|
||||
* Metadata enrichment runs asynchronously in a worker after the upload response
|
||||
* returns, so without polling the row would never reflect the resolved title/
|
||||
* artist or a failure reason. Polling stops (interval → 0) once the status
|
||||
* leaves `pending`.
|
||||
*/
|
||||
function EnrichmentStatus({ trackId }: { trackId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [pollMs, setPollMs] = useState(2500);
|
||||
const { data } = useGetTrackQuery(trackId, { pollingInterval: pollMs });
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data.metadataStatus !== 'pending') setPollMs(0);
|
||||
}, [data]);
|
||||
|
||||
const status = data?.metadataStatus ?? 'pending';
|
||||
const resolved =
|
||||
data && data.metadataStatus === 'enriched'
|
||||
? `${data.artistName} · ${data.title}`
|
||||
: t(`metadata.statusHint.${status}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<MetadataStatusBadge
|
||||
status={status}
|
||||
error={data?.metadataError}
|
||||
hideWhenEnriched={false}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{status === 'failed' && data?.metadataError
|
||||
? data.metadataError
|
||||
: resolved}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ItemStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'uploading') {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { getApiBaseUrl } from '../config/runtime-config';
|
||||
|
||||
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
|
||||
export function useConnectionStatus() {
|
||||
/** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
|
||||
export function useConnectionStatus(baseUrl?: string) {
|
||||
const url = baseUrl ?? getApiBaseUrl();
|
||||
const [status, setStatus] = useState<ConnectionStatus>('connecting');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -13,7 +15,7 @@ export function useConnectionStatus() {
|
||||
if (cancelled) return;
|
||||
setStatus('connecting');
|
||||
try {
|
||||
const res = await fetch(`${getApiBaseUrl()}/health`, {
|
||||
const res = await fetch(`${url}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
|
||||
@@ -30,7 +32,7 @@ export function useConnectionStatus() {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [url]);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useGetTrackQuery } from '../api/endpoints/library';
|
||||
import type { QueueEntry } from '../store/slices/queue';
|
||||
|
||||
export interface ResolvedQueueEntry {
|
||||
trackId: string;
|
||||
title: string;
|
||||
artistName: string;
|
||||
albumTitle: string;
|
||||
durationMs: number;
|
||||
hasCover: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a queue entry's play-time snapshot with the live `Track` cache.
|
||||
*
|
||||
* The queue slice stores denormalized display fields (title/artist/…) captured
|
||||
* when a track was queued, so they go stale after metadata enrichment updates
|
||||
* the track. This reads through to the RTKQ `Track` cache — invalidated by the
|
||||
* same tags that refresh the library — and prefers its fresh values, falling
|
||||
* back to the snapshot for instant render and offline. Returns undefined when
|
||||
* there is no current entry.
|
||||
*/
|
||||
export function useResolvedQueueEntry(
|
||||
entry: QueueEntry | undefined,
|
||||
): ResolvedQueueEntry | undefined {
|
||||
const { data } = useGetTrackQuery(entry?.trackId ?? skipToken);
|
||||
if (!entry) return undefined;
|
||||
return {
|
||||
trackId: entry.trackId,
|
||||
title: data?.title ?? entry.title,
|
||||
artistName: data?.artistName ?? entry.artistName,
|
||||
albumTitle: data?.albumTitle ?? entry.albumTitle,
|
||||
durationMs: data?.durationMs ?? entry.durationMs,
|
||||
hasCover: data?.hasCover ?? false,
|
||||
};
|
||||
}
|
||||
+121
-11
@@ -25,22 +25,46 @@ const en = {
|
||||
signOut: 'Sign out',
|
||||
},
|
||||
connect: {
|
||||
savedInstances: 'Saved instances',
|
||||
active: 'active',
|
||||
use: 'Use',
|
||||
forgetTitle: 'Forget this instance',
|
||||
form: {
|
||||
title: 'Connect to a backend',
|
||||
serverUrl: 'Server URL',
|
||||
domains: {
|
||||
title: 'Saved instances',
|
||||
addPlaceholder: 'https://your-server.example.com',
|
||||
addButton: 'Add instance',
|
||||
selected: 'Selected',
|
||||
use: 'Use',
|
||||
forgetTitle: 'Remove this instance',
|
||||
},
|
||||
removeDialog: {
|
||||
title: 'Remove cached data?',
|
||||
description:
|
||||
'This removes "{{name}}" from your saved instances and clears its cached data on this device.',
|
||||
cancel: 'Cancel',
|
||||
logout: 'Just log out',
|
||||
removeAndLogout: 'Remove data & log out',
|
||||
},
|
||||
login: {
|
||||
title: 'Log in to {{name}}',
|
||||
registerTitle: 'Sign up for {{name}}',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
submit: 'Connect',
|
||||
submitting: 'Connecting…',
|
||||
passwordHint: 'At least 8 characters.',
|
||||
submit: 'Log in',
|
||||
submitting: 'Logging in…',
|
||||
registerSubmit: 'Sign up',
|
||||
registering: 'Signing up…',
|
||||
noAccount: "Don't have an account?",
|
||||
registerLink: 'Sign up',
|
||||
haveAccount: 'Already have an account?',
|
||||
signInLink: 'Log in',
|
||||
},
|
||||
errors: {
|
||||
unreachable: "Can't reach this server. Check the URL and that it's online.",
|
||||
unreachable:
|
||||
"Can't reach this server. Check the URL and that it's online.",
|
||||
badCredentials: 'Incorrect username or password.',
|
||||
generic: 'Sign-in failed. Please try again.',
|
||||
usernameTaken: 'That username is already taken.',
|
||||
passwordTooShort: 'Password must be at least 8 characters.',
|
||||
registrationDisabled: 'Registration is disabled on this server.',
|
||||
registerFailed: 'Could not create the account. Please try again.',
|
||||
},
|
||||
},
|
||||
library: {
|
||||
@@ -132,12 +156,49 @@ const en = {
|
||||
playNow: 'Play now',
|
||||
playNext: 'Play next',
|
||||
addToQueue: 'Add to queue',
|
||||
info: 'Track info',
|
||||
addToPlaylist: 'Add to playlist…',
|
||||
editMetadata: 'Edit metadata',
|
||||
download: 'Download',
|
||||
delete: 'Delete',
|
||||
},
|
||||
},
|
||||
trackInfo: {
|
||||
title: 'Track info',
|
||||
open: 'View track info',
|
||||
close: 'Close',
|
||||
notFound: 'Track not found',
|
||||
play: 'Play',
|
||||
addToQueue: 'Queue',
|
||||
editMetadata: 'Edit metadata',
|
||||
liked: 'Liked',
|
||||
trackOf: 'No. {{n}} of {{total}}',
|
||||
kbps: '{{n}} kbps',
|
||||
sections: {
|
||||
status: 'Status',
|
||||
general: 'General',
|
||||
file: 'File',
|
||||
identifiers: 'Identifiers',
|
||||
},
|
||||
fields: {
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
trackNumber: 'Track',
|
||||
disc: 'Disc',
|
||||
year: 'Year',
|
||||
genre: 'Genre',
|
||||
duration: 'Duration',
|
||||
format: 'Format',
|
||||
bitrate: 'Bitrate',
|
||||
size: 'Size',
|
||||
source: 'Source',
|
||||
added: 'Added',
|
||||
enriched: 'Enriched',
|
||||
trackId: 'Track ID',
|
||||
albumId: 'Album ID',
|
||||
artistId: 'Artist ID',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
error: 'Something went wrong',
|
||||
retry: 'Retry',
|
||||
@@ -206,11 +267,60 @@ const en = {
|
||||
error: 'Failed',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
status: {
|
||||
pending: 'Enriching…',
|
||||
enriched: 'Enriched',
|
||||
failed: 'No match',
|
||||
manual: 'Manual',
|
||||
},
|
||||
statusHint: {
|
||||
pending: 'Identifying metadata…',
|
||||
enriched: 'Metadata identified',
|
||||
failed: 'Metadata could not be identified',
|
||||
manual: 'Edited manually — not auto-updated',
|
||||
},
|
||||
},
|
||||
metadataEditor: {
|
||||
error: 'Failed to load track',
|
||||
saved: 'Metadata saved.',
|
||||
saveError: 'Failed to save metadata.',
|
||||
save: 'Save',
|
||||
fields: {
|
||||
title: 'Title',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
year: 'Year',
|
||||
genre: 'Genre',
|
||||
trackNumber: 'Track number',
|
||||
},
|
||||
autoEnrich: {
|
||||
title: 'AcoustID lookup',
|
||||
hint: 'Identify this track by audio fingerprint.',
|
||||
findMatches: 'Find matches',
|
||||
reEnrich: 'Re-run enrichment',
|
||||
enqueued: 'Enrichment queued — refresh in a moment.',
|
||||
error: 'Could not look up matches.',
|
||||
noMatches: 'No matches found.',
|
||||
},
|
||||
matches: {
|
||||
use: 'Use',
|
||||
unknownTitle: 'Unknown title',
|
||||
},
|
||||
diff: {
|
||||
title: 'Apply this match?',
|
||||
noChanges: 'No changes from current values.',
|
||||
cancel: 'Cancel',
|
||||
apply: 'Apply',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
|
||||
type DeepString<T> = {
|
||||
[K in keyof T]: T[K] extends Record<string, unknown> ? DeepString<T[K]> : string;
|
||||
[K in keyof T]: T[K] extends Record<string, unknown>
|
||||
? DeepString<T[K]>
|
||||
: string;
|
||||
};
|
||||
export type Translations = DeepString<typeof en>;
|
||||
|
||||
+116
-9
@@ -27,23 +27,46 @@ const ru: Translations = {
|
||||
signOut: 'Выйти',
|
||||
},
|
||||
connect: {
|
||||
savedInstances: 'Сохранённые серверы',
|
||||
active: 'активный',
|
||||
use: 'Выбрать',
|
||||
forgetTitle: 'Забыть этот сервер',
|
||||
form: {
|
||||
title: 'Подключиться к серверу',
|
||||
serverUrl: 'URL сервера',
|
||||
domains: {
|
||||
title: 'Сохранённые серверы',
|
||||
addPlaceholder: 'https://your-server.example.com',
|
||||
addButton: 'Добавить сервер',
|
||||
selected: 'Выбран',
|
||||
use: 'Выбрать',
|
||||
forgetTitle: 'Удалить этот сервер',
|
||||
},
|
||||
removeDialog: {
|
||||
title: 'Удалить локальные данные?',
|
||||
description:
|
||||
'Сервер «{{name}}» будет удалён из сохранённых, а его данные на этом устройстве — очищены.',
|
||||
cancel: 'Отмена',
|
||||
logout: 'Просто выйти',
|
||||
removeAndLogout: 'Удалить данные и выйти',
|
||||
},
|
||||
login: {
|
||||
title: 'Вход в {{name}}',
|
||||
registerTitle: 'Регистрация на {{name}}',
|
||||
username: 'Имя пользователя',
|
||||
password: 'Пароль',
|
||||
submit: 'Подключиться',
|
||||
submitting: 'Подключение…',
|
||||
passwordHint: 'Не менее 8 символов.',
|
||||
submit: 'Войти',
|
||||
submitting: 'Вход…',
|
||||
registerSubmit: 'Зарегистрироваться',
|
||||
registering: 'Регистрация…',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
registerLink: 'Зарегистрироваться',
|
||||
haveAccount: 'Уже есть аккаунт?',
|
||||
signInLink: 'Войти',
|
||||
},
|
||||
errors: {
|
||||
unreachable:
|
||||
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
|
||||
badCredentials: 'Неверное имя пользователя или пароль.',
|
||||
generic: 'Не удалось войти. Попробуйте ещё раз.',
|
||||
usernameTaken: 'Это имя пользователя уже занято.',
|
||||
passwordTooShort: 'Пароль должен содержать не менее 8 символов.',
|
||||
registrationDisabled: 'Регистрация на этом сервере отключена.',
|
||||
registerFailed: 'Не удалось создать аккаунт. Попробуйте ещё раз.',
|
||||
},
|
||||
},
|
||||
library: {
|
||||
@@ -135,12 +158,49 @@ const ru: Translations = {
|
||||
playNow: 'Играть сейчас',
|
||||
playNext: 'Следующим',
|
||||
addToQueue: 'Добавить в очередь',
|
||||
info: 'Информация о треке',
|
||||
addToPlaylist: 'Добавить в плейлист…',
|
||||
editMetadata: 'Редактировать метаданные',
|
||||
download: 'Скачать',
|
||||
delete: 'Удалить',
|
||||
},
|
||||
},
|
||||
trackInfo: {
|
||||
title: 'О треке',
|
||||
open: 'Информация о треке',
|
||||
close: 'Закрыть',
|
||||
notFound: 'Трек не найден',
|
||||
play: 'Играть',
|
||||
addToQueue: 'В очередь',
|
||||
editMetadata: 'Метаданные',
|
||||
liked: 'В избранном',
|
||||
trackOf: '№ {{n}} из {{total}}',
|
||||
kbps: '{{n}} кбит/с',
|
||||
sections: {
|
||||
status: 'Статус',
|
||||
general: 'Основное',
|
||||
file: 'Файл',
|
||||
identifiers: 'Идентификаторы',
|
||||
},
|
||||
fields: {
|
||||
artist: 'Исполнитель',
|
||||
album: 'Альбом',
|
||||
trackNumber: 'Трек',
|
||||
disc: 'Диск',
|
||||
year: 'Год',
|
||||
genre: 'Жанр',
|
||||
duration: 'Длительность',
|
||||
format: 'Формат',
|
||||
bitrate: 'Битрейт',
|
||||
size: 'Размер',
|
||||
source: 'Источник',
|
||||
added: 'Добавлен',
|
||||
enriched: 'Обогащён',
|
||||
trackId: 'ID трека',
|
||||
albumId: 'ID альбома',
|
||||
artistId: 'ID исполнителя',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
error: 'Что-то пошло не так',
|
||||
retry: 'Повторить',
|
||||
@@ -209,6 +269,53 @@ const ru: Translations = {
|
||||
error: 'Ошибка',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
status: {
|
||||
pending: 'Обработка…',
|
||||
enriched: 'Готово',
|
||||
failed: 'Нет совпадения',
|
||||
manual: 'Вручную',
|
||||
},
|
||||
statusHint: {
|
||||
pending: 'Определяем метаданные…',
|
||||
enriched: 'Метаданные определены',
|
||||
failed: 'Не удалось определить метаданные',
|
||||
manual: 'Изменено вручную — не обновляется автоматически',
|
||||
},
|
||||
},
|
||||
metadataEditor: {
|
||||
error: 'Не удалось загрузить трек',
|
||||
saved: 'Метаданные сохранены.',
|
||||
saveError: 'Не удалось сохранить метаданные.',
|
||||
save: 'Сохранить',
|
||||
fields: {
|
||||
title: 'Название',
|
||||
artist: 'Исполнитель',
|
||||
album: 'Альбом',
|
||||
year: 'Год',
|
||||
genre: 'Жанр',
|
||||
trackNumber: 'Номер трека',
|
||||
},
|
||||
autoEnrich: {
|
||||
title: 'Поиск по AcoustID',
|
||||
hint: 'Определить трек по аудио-отпечатку.',
|
||||
findMatches: 'Найти совпадения',
|
||||
reEnrich: 'Повторить обогащение',
|
||||
enqueued: 'Обогащение запущено — обновите через момент.',
|
||||
error: 'Не удалось найти совпадения.',
|
||||
noMatches: 'Совпадений не найдено.',
|
||||
},
|
||||
matches: {
|
||||
use: 'Использовать',
|
||||
unknownTitle: 'Неизвестное название',
|
||||
},
|
||||
diff: {
|
||||
title: 'Применить это совпадение?',
|
||||
noChanges: 'Нет изменений относительно текущих значений.',
|
||||
cancel: 'Отмена',
|
||||
apply: 'Применить',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -16,6 +16,16 @@ export function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | undefined): string | undefined {
|
||||
if (!iso) return undefined;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||
|
||||
@@ -22,13 +22,17 @@ type QueryEntry = ApiState['queries'][string];
|
||||
* carry no usable data and subscriptions are rebuilt by components on mount.
|
||||
* Mutation results are never restored.
|
||||
*/
|
||||
const EMPTY_PROVIDED = { tags: {}, keys: {} };
|
||||
|
||||
function snapshot(apiState: ApiState): RehydrateApiPayload {
|
||||
const queries: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(apiState.queries)) {
|
||||
const q = entry as QueryEntry | undefined;
|
||||
if (q && q.status === 'fulfilled') queries[key] = q;
|
||||
}
|
||||
return { queries, mutations: {} };
|
||||
// Carry `provided` along so RTKQ can re-register invalidation tags for the
|
||||
// restored entries; it is also required structurally (see RehydrateApiPayload).
|
||||
return { queries, mutations: {}, provided: apiState.provided ?? EMPTY_PROVIDED };
|
||||
}
|
||||
|
||||
function load(): RehydrateApiPayload | null {
|
||||
@@ -37,7 +41,13 @@ function load(): RehydrateApiPayload | null {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
|
||||
if (!parsed.queries) return null;
|
||||
return { queries: parsed.queries, mutations: {} };
|
||||
// `provided` may be absent in snapshots written before this field existed —
|
||||
// default it so the invalidation slice doesn't crash on `provided.tags`.
|
||||
return {
|
||||
queries: parsed.queries,
|
||||
mutations: {},
|
||||
provided: parsed.provided ?? EMPTY_PROVIDED,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface PlayerState {
|
||||
muted: boolean;
|
||||
repeat: RepeatMode;
|
||||
shuffle: boolean;
|
||||
isNowPlayingOpen: boolean;
|
||||
isQueueOpen: boolean;
|
||||
}
|
||||
|
||||
@@ -24,7 +23,6 @@ export const playerInitialState: PlayerState = {
|
||||
muted: false,
|
||||
repeat: 'none',
|
||||
shuffle: false,
|
||||
isNowPlayingOpen: false,
|
||||
isQueueOpen: false,
|
||||
};
|
||||
|
||||
@@ -66,9 +64,6 @@ export const playerSlice = createSlice({
|
||||
toggleShuffle(state) {
|
||||
state.shuffle = !state.shuffle;
|
||||
},
|
||||
toggleNowPlaying(state) {
|
||||
state.isNowPlayingOpen = !state.isNowPlayingOpen;
|
||||
},
|
||||
toggleQueue(state) {
|
||||
state.isQueueOpen = !state.isQueueOpen;
|
||||
},
|
||||
@@ -86,7 +81,6 @@ export const {
|
||||
toggleMute,
|
||||
setRepeat,
|
||||
toggleShuffle,
|
||||
toggleNowPlaying,
|
||||
toggleQueue,
|
||||
} = playerSlice.actions;
|
||||
export default playerSlice.reducer;
|
||||
|
||||
@@ -4,12 +4,15 @@ interface UiState {
|
||||
sidebarCollapsed: boolean;
|
||||
activeModal: string | null;
|
||||
activeTrackContextMenuId: string | null;
|
||||
/** Track whose info drawer is open (rightmost drawer); null = closed. */
|
||||
trackInfoId: string | null;
|
||||
}
|
||||
|
||||
const initialState: UiState = {
|
||||
sidebarCollapsed: false,
|
||||
activeModal: null,
|
||||
activeTrackContextMenuId: null,
|
||||
trackInfoId: null,
|
||||
};
|
||||
|
||||
export const uiSlice = createSlice({
|
||||
@@ -31,6 +34,12 @@ export const uiSlice = createSlice({
|
||||
setActiveContextMenu(state, action: PayloadAction<string | null>) {
|
||||
state.activeTrackContextMenuId = action.payload;
|
||||
},
|
||||
openTrackInfo(state, action: PayloadAction<string>) {
|
||||
state.trackInfoId = action.payload;
|
||||
},
|
||||
closeTrackInfo(state) {
|
||||
state.trackInfoId = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,5 +49,7 @@ export const {
|
||||
openModal,
|
||||
closeModal,
|
||||
setActiveContextMenu,
|
||||
openTrackInfo,
|
||||
closeTrackInfo,
|
||||
} = uiSlice.actions;
|
||||
export default uiSlice.reducer;
|
||||
|
||||
@@ -724,6 +724,164 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TRACK INFO DRAWER (rightmost — sits right of the queue drawer)
|
||||
============================================================ */
|
||||
/* Same width-collapse pattern as .qd. Rendered after QueuePanel in AppShell so
|
||||
when both are open this is the rightmost panel. */
|
||||
.tid {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--hair);
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.24));
|
||||
transition:
|
||||
width 0.24s var(--ease-out),
|
||||
border-left-color 0.24s var(--ease-out);
|
||||
}
|
||||
.tid.closed {
|
||||
width: 0;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
.tid-inner {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.tid-head {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
}
|
||||
.tid-head h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.tid-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
}
|
||||
.tid-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--steel-900);
|
||||
box-shadow: var(--shadow-raised, 0 8px 24px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
.tid-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.tid-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: var(--fg-1);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tid-sub {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tid-sub:hover {
|
||||
color: var(--lime);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tid-album {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.tid-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
.tid-section {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
.tid-section-label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tid-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.tid-error {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ember, #e9572b);
|
||||
}
|
||||
.tid-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 5px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tid-row-k {
|
||||
flex-shrink: 0;
|
||||
width: 96px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.tid-row-v {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: var(--fg-1);
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
.tid-row-v.mono {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
/* On narrower viewports the drawer overlays the content instead of pushing it,
|
||||
so the queue + info drawers don't squeeze the main screen. */
|
||||
@media (max-width: 1180px) {
|
||||
.app-body {
|
||||
position: relative;
|
||||
}
|
||||
.tid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 360px;
|
||||
z-index: 30;
|
||||
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.5);
|
||||
transition: transform 0.24s var(--ease-out);
|
||||
}
|
||||
.tid.closed {
|
||||
width: 360px;
|
||||
transform: translateX(100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PAGE HEADER + SECONDARY NAV (Settings, Admin)
|
||||
============================================================ */
|
||||
|
||||
@@ -49,6 +49,23 @@ test('rehydrateApiCache replays a stored cache as a rehydrate action', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('rehydrate payload always carries `provided` (regression: RTKQ reads provided.tags)', () => {
|
||||
// A snapshot persisted before `provided` existed must not crash RTKQ's
|
||||
// invalidation slice, which does `Object.entries(provided.tags ?? {})`.
|
||||
instanceStorage.set(
|
||||
'rtkq',
|
||||
JSON.stringify({
|
||||
queries: { 'getLibrary(undefined)': { status: 'fulfilled', data: [] } },
|
||||
mutations: {},
|
||||
}),
|
||||
);
|
||||
const dispatched: Array<{ payload: { provided?: unknown } }> = [];
|
||||
rehydrateApiCache((a) =>
|
||||
dispatched.push(a as { payload: { provided?: unknown } }),
|
||||
);
|
||||
expect(dispatched[0].payload.provided).toEqual({ tags: {}, keys: {} });
|
||||
});
|
||||
|
||||
test('startApiPersistence saves only fulfilled queries after throttle', () => {
|
||||
rstest.useFakeTimers();
|
||||
let state = apiStateWith({});
|
||||
|
||||
Reference in New Issue
Block a user