Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a37c19fd45 | |||
| facc215450 |
@@ -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)}`;
|
||||
}
|
||||
|
||||
+24
-2
@@ -14,12 +14,27 @@
|
||||
import type {
|
||||
Album,
|
||||
Artist,
|
||||
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> {
|
||||
@@ -49,6 +64,9 @@ export interface RawTrack {
|
||||
file_format: string;
|
||||
file_size: number;
|
||||
metadata_status: string;
|
||||
metadata_error: string | null;
|
||||
enriched_at: string | null;
|
||||
has_cover: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -98,13 +116,17 @@ 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,
|
||||
liked: false,
|
||||
format: r.file_format,
|
||||
fileSize: r.file_size,
|
||||
|
||||
@@ -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,12 +16,16 @@ 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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -98,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,11 +2,11 @@ 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 } 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,
|
||||
useRegisterMutation,
|
||||
@@ -17,10 +17,27 @@ import {
|
||||
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;
|
||||
@@ -43,6 +60,121 @@ function resolveRegisterError(err: unknown): string {
|
||||
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();
|
||||
@@ -50,10 +182,15 @@ export function ConnectPage() {
|
||||
|
||||
const [rev, setRev] = useState(0);
|
||||
const instances = listInstances();
|
||||
const activeId = getActiveInstanceId();
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(() =>
|
||||
getActiveInstanceId(),
|
||||
);
|
||||
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
const [addUrl, setAddUrl] = useState('https://');
|
||||
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [apiUrl, setApiUrl] = useState('https://');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -67,23 +204,40 @@ export function ConnectPage() {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const switchTo = (id: string) => {
|
||||
// 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 action =
|
||||
@@ -145,204 +299,160 @@ 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">
|
||||
{mode === 'register'
|
||||
? t('connect.form.registerTitle')
|
||||
: 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"
|
||||
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={
|
||||
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.form.passwordHint')}
|
||||
{instances.length > 0 && (
|
||||
<>
|
||||
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
|
||||
{t('connect.domains.title')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && <Callout variant="danger">{t(error)}</Callout>}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
{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
|
||||
? mode === 'register'
|
||||
? t('connect.form.registering')
|
||||
: t('connect.form.submitting')
|
||||
: mode === 'register'
|
||||
? t('connect.form.registerSubmit')
|
||||
: t('connect.form.submit')}
|
||||
</Button>
|
||||
<TextField
|
||||
value={addUrl}
|
||||
onChange={(e) => setAddUrl(e.target.value)}
|
||||
placeholder={t('connect.domains.addPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="submit" variant="primary">
|
||||
<Icon name="plus" /> {t('connect.domains.addButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{REGISTRATION_ENABLED && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{mode === 'register' ? (
|
||||
<>
|
||||
{t('connect.form.haveAccount')}{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => switchMode('login')}
|
||||
>
|
||||
{t('connect.form.signInLink')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('connect.form.noAccount')}{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => switchMode('register')}
|
||||
>
|
||||
{t('connect.form.registerLink')}
|
||||
</Button>
|
||||
</>
|
||||
{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>
|
||||
)}
|
||||
</form>
|
||||
</Card>
|
||||
{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,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;
|
||||
}
|
||||
|
||||
+38
-13
@@ -25,25 +25,36 @@ const en = {
|
||||
signOut: 'Sign out',
|
||||
},
|
||||
connect: {
|
||||
savedInstances: 'Saved instances',
|
||||
active: 'active',
|
||||
use: 'Use',
|
||||
forgetTitle: 'Forget this instance',
|
||||
form: {
|
||||
title: 'Connect to a backend',
|
||||
registerTitle: 'Create an account',
|
||||
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',
|
||||
passwordHint: 'At least 8 characters.',
|
||||
submit: 'Connect',
|
||||
submitting: 'Connecting…',
|
||||
registerSubmit: 'Create account',
|
||||
registering: 'Creating account…',
|
||||
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: 'Sign in',
|
||||
signInLink: 'Log in',
|
||||
},
|
||||
errors: {
|
||||
unreachable:
|
||||
@@ -219,6 +230,20 @@ 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',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
|
||||
+37
-12
@@ -27,21 +27,32 @@ const ru: Translations = {
|
||||
signOut: 'Выйти',
|
||||
},
|
||||
connect: {
|
||||
savedInstances: 'Сохранённые серверы',
|
||||
active: 'активный',
|
||||
use: 'Выбрать',
|
||||
forgetTitle: 'Забыть этот сервер',
|
||||
form: {
|
||||
title: 'Подключиться к серверу',
|
||||
registerTitle: 'Создать аккаунт',
|
||||
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: 'Пароль',
|
||||
passwordHint: 'Не менее 8 символов.',
|
||||
submit: 'Подключиться',
|
||||
submitting: 'Подключение…',
|
||||
registerSubmit: 'Создать аккаунт',
|
||||
registering: 'Создание аккаунта…',
|
||||
submit: 'Войти',
|
||||
submitting: 'Вход…',
|
||||
registerSubmit: 'Зарегистрироваться',
|
||||
registering: 'Регистрация…',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
registerLink: 'Зарегистрироваться',
|
||||
haveAccount: 'Уже есть аккаунт?',
|
||||
@@ -221,6 +232,20 @@ const ru: Translations = {
|
||||
error: 'Ошибка',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
status: {
|
||||
pending: 'Обработка…',
|
||||
enriched: 'Готово',
|
||||
failed: 'Нет совпадения',
|
||||
manual: 'Вручную',
|
||||
},
|
||||
statusHint: {
|
||||
pending: 'Определяем метаданные…',
|
||||
enriched: 'Метаданные определены',
|
||||
failed: 'Не удалось определить метаданные',
|
||||
manual: 'Изменено вручную — не обновляется автоматически',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
Reference in New Issue
Block a user