Compare commits

..

11 Commits

Author SHA1 Message Date
Senko-san d1b2b40ffd feat(metadata): implement single-track metadata editor page (§A7)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Replace the placeholder with a controlled form for title/artist/album/
year/genre/track number, an AcoustID "find matches" action showing
ranked candidates with confidence, a diff/apply picker, a re-enrich
button, and save via PUT /metadata. Adds matches/apply API endpoints,
mappers, types, and en/ru i18n strings. Batch editor remains a
placeholder (deferred).
2026-06-13 14:36:17 +03:00
Senko-san 8a70f478c3 feat: track info drawer (Get Info-style)
Add a right-side track info drawer that sits to the right of the queue
panel when both are open. Shows a large cover, title/artist/album links,
a Play/Queue/Edit actions row, and Status/General/File/Identifiers
sections (empty rows omitted). Opens from the track context menu, the
player now-playing tile, and the queue now-playing card.

- ui slice: trackInfoId + open/closeTrackInfo
- TrackInfoDrawer rendered after QueuePanel in AppShell; overlays content
  on narrow viewports
- map source/createdAt/enrichedAt from the wire (were unmapped)
- formatDateTime helper, info icon, i18n (en/ru)
- drop orphaned toggleNowPlaying/isNowPlayingOpen from player slice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:02:38 +03:00
Senko-san 9c344b98c4 fix(player): show live track metadata, not the stale queue snapshot
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The queue slice stores denormalized display fields captured at play-time, so the
player and queue panel kept showing pre-enrichment title/artist after a track's
metadata was updated — the library (RTKQ cache) and the player disagreed.

Add useResolvedQueueEntry: read through to the RTKQ Track cache and prefer its
fresh values, keeping the snapshot only as instant/offline fallback. Wire it into
PersistentPlayer (now-playing + cover) and QueuePanel (now-playing + up-next
rows), so enrichment updates reach the player through the same Track tags that
refresh the library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:37:34 +03:00
Senko-san 42080b37ea chore: login page upd
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
2026-06-13 13:30:17 +03:00
Senko-san a37c19fd45 feat(library): surface metadata enrichment status, errors and covers
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The mapper dropped metadata_status and hardcoded availability, so enrichment
state was invisible and a just-uploaded track never appeared to change. Map
metadata_status/metadata_error/has_cover onto Track; add MetadataStatusBadge
(pending spinner / enriched / failed-with-reason / manual) shown in TrackRow,
and serve token-bearing track covers via getTrackCoverUrl.

UploadPage now polls each uploaded track (stops once enrichment settles) so the
resolved title/artist — or a failure reason — appears live. i18n in en + ru.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:29:22 +03:00
Senko-san facc215450 chore: update/make more clear connect flow 2026-06-13 12:35:20 +03:00
olly 98e9344261 chore: bump modern-sk ver.
Docker Build & Publish / build (push) Successful in 34s
Docker Build & Publish / push (push) Failing after 3m3s
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-10 20:09:27 +03:00
Senko-san 1228118027 fix(offline): include provided in RTKQ rehydration payload
Docker Build & Publish / build (push) Failing after 1m42s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
RTK Query 2.12's invalidation slice reads `provided.tags` during cache
rehydration (`Object.entries(provided.tags ?? {})`). Our persisted
snapshot only carried `{ queries, mutations }`, so `provided` was
undefined and `.tags` threw on every startup with a cached snapshot —
crashing the app inside the rehydrate reducer / immer produce.

Snapshot now carries the real `provided` (so invalidation tags
rehydrate), and `load()` defaults it to `{ tags: {}, keys: {} }` so
snapshots persisted before this field existed recover without a manual
localStorage clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:29:42 +03:00
Senko-san 538cfb9c5b feat(auth): registration mode on ConnectPage (PUBLIC_ENABLE_REGISTRATION)
Docker Build & Publish / build (push) Failing after 1m42s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
Add a login/register toggle to ConnectPage backed by a new
useRegisterMutation (register -> /auth/me, mirroring login). The toggle
is shown only when REGISTRATION_ENABLED, resolved with the same
precedence as the API base URL: runtime window.__APP_CONFIG__ >
PUBLIC_ENABLE_REGISTRATION env > default true. The prod runtime-config
script injects the runtime flag. The backend's ALLOW_REGISTRATION stays
the real authority; this only gates the UI. EN/RU strings added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:07:07 +03:00
olly 2ad3b128d6 fix: backend url normalization
Docker Build & Publish / build (push) Failing after 3m9s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-10 13:49:38 +03:00
Senko-san 55aa8933af fix(theme): kill flash of white on dark-themed load
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
The app painted white until <ThemeProvider> mounted and set data-theme,
then snapped to the dark theme. Two fixes:

- Inline head script (rsbuild html.tags) sets data-theme before first
  paint, mirroring modern-sk's exact logic (localStorage 'modern-sk-theme'
  || 'dark') so there's no second flip when the provider mounts. Inline =
  zero round-trips.
- body now paints var(--color-bg) so the themed background shows before
  React mounts #root and layers the felt grain on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:18:29 +03:00
36 changed files with 2188 additions and 242 deletions
+4
View File
@@ -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
+15 -5
View File
@@ -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"
+4 -4
View File
@@ -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
View File
@@ -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",
+12
View File
@@ -36,6 +36,18 @@ export default defineConfig({
// "Install app". The service worker (audio offline cache) is registered
// from src/index.tsx, not here.
tags: [
// Theme bootstrap — runs inline before first paint to kill the flash of
// white on a dark-themed load. Mirrors modern-sk's own logic exactly
// (localStorage 'modern-sk-theme' || 'dark' → data-theme on <html>), so
// there's no second flip when <ThemeProvider> mounts. Inline (not an
// external file) so it costs zero round-trips.
{
tag: 'script',
children:
"(function(){try{var t=localStorage.getItem('modern-sk-theme')||'dark';document.documentElement.setAttribute('data-theme',t);}catch(e){}})();",
head: true,
append: false,
},
// Runtime operator config. A classic (non-deferred) head script, so it
// runs before the deferred app bundle and window.__APP_CONFIG__ is set by
// the time src/config/env.ts reads it. Served from public/ in dev and
+31 -1
View File
@@ -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,
+42
View File
@@ -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;
+15
View File
@@ -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
View File
@@ -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 => ({
+5
View File
@@ -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);
+46
View File
@@ -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;
}
+2
View File
@@ -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,
+2
View File
@@ -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>
+20 -8
View File
@@ -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)' }}
+66 -29
View File
@@ -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>
);
}
+29 -4
View File
@@ -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')}
+319
View File
@@ -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>
);
}
+15 -3
View File
@@ -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={{
+19
View File
@@ -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
View File
@@ -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));
+2
View File
@@ -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;
};
}
+373 -144
View File
@@ -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>
);
}
+57 -6
View File
@@ -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') {
+5 -3
View File
@@ -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;
}
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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`;
+12 -2
View File
@@ -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;
}
-6
View File
@@ -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;
+11
View File
@@ -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;
+5
View File
@@ -28,6 +28,11 @@ body {
margin: 0;
font-family: var(--font-sans);
color: var(--fg-1);
/* Paint the themed background immediately. The inline theme script in
index.html (see rsbuild.config.ts) sets [data-theme] before first paint, so
--color-bg resolves to the right value here before React mounts #root and
layers the .modern-sk-felt grain on top — no flash of white. */
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
+158
View File
@@ -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)
============================================================ */
+17
View File
@@ -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({});