Compare commits

..

2 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
16 changed files with 1304 additions and 19 deletions
+42
View File
@@ -2,10 +2,12 @@ import { api } from '../index';
import { import {
toAlbum, toAlbum,
toArtist, toArtist,
toMetadataMatch,
toPage, toPage,
toTrack, toTrack,
type RawAlbum, type RawAlbum,
type RawArtist, type RawArtist,
type RawMetadataMatch,
type RawPaged, type RawPaged,
type RawTrack, type RawTrack,
} from '../mappers'; } from '../mappers';
@@ -13,6 +15,8 @@ import type {
Track, Track,
Album, Album,
Artist, Artist,
MetadataEdit,
MetadataMatch,
PaginatedResponse, PaginatedResponse,
LibraryFilters, LibraryFilters,
} from '../types'; } from '../types';
@@ -161,6 +165,41 @@ export const libraryApi = api.injectEndpoints({
}), }),
providesTags: ['Track', 'Album', 'Artist'], 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, overrideExisting: false,
}); });
@@ -176,4 +215,7 @@ export const {
useGetArtistAlbumsQuery, useGetArtistAlbumsQuery,
useGetArtistTracksQuery, useGetArtistTracksQuery,
useSearchLibraryQuery, useSearchLibraryQuery,
useLazyGetMetadataMatchesQuery,
useApplyMetadataMutation,
useEnrichTrackMutation,
} = libraryApi; } = libraryApi;
+33
View File
@@ -14,6 +14,7 @@
import type { import type {
Album, Album,
Artist, Artist,
MetadataMatch,
MetadataStatus, MetadataStatus,
PaginatedResponse, PaginatedResponse,
Playlist, Playlist,
@@ -63,6 +64,9 @@ export interface RawTrack {
duration_seconds: number | null; duration_seconds: number | null;
file_format: string; file_format: string;
file_size: number; file_size: number;
genre: string | null;
year: number | null;
track_number: number | null;
metadata_status: string; metadata_status: string;
metadata_error: string | null; metadata_error: string | null;
enriched_at: string | null; enriched_at: string | null;
@@ -71,6 +75,18 @@ export interface RawTrack {
created_at: 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 { export interface RawAlbum {
id: string; id: string;
title: string; title: string;
@@ -127,9 +143,26 @@ export const toTrack = (r: RawTrack): Track => ({
availability: 'server', availability: 'server',
metadataStatus: toMetadataStatus(r.metadata_status), metadataStatus: toMetadataStatus(r.metadata_status),
metadataError: r.metadata_error ?? undefined, metadataError: r.metadata_error ?? undefined,
genre: r.genre ?? undefined,
year: r.year ?? undefined,
trackNumber: r.track_number ?? undefined,
liked: false, liked: false,
format: r.file_format, format: r.file_format,
fileSize: r.file_size, 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 => ({ export const toAlbum = (r: RawAlbum): Album => ({
+29
View File
@@ -30,6 +30,12 @@ export interface Track {
format?: string; format?: string;
bitrate?: number; bitrate?: number;
liked: boolean; 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 { export interface Album {
@@ -155,3 +161,26 @@ export interface ApiError {
message: string; message: string;
code?: 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, GearSix,
HardDrives, HardDrives,
Heart, Heart,
Info,
MagnifyingGlass, MagnifyingGlass,
Pause, Pause,
Play, Play,
@@ -69,6 +70,7 @@ const ICONS = {
'skip-forward': SkipForward, 'skip-forward': SkipForward,
repeat: Repeat, repeat: Repeat,
heart: Heart, heart: Heart,
info: Info,
'thumbs-down': ThumbsDown, 'thumbs-down': ThumbsDown,
'speaker-high': SpeakerHigh, 'speaker-high': SpeakerHigh,
'speaker-x': SpeakerSimpleX, 'speaker-x': SpeakerSimpleX,
+2
View File
@@ -3,6 +3,7 @@ import { Suspense } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { PersistentPlayer } from '../player/PersistentPlayer'; import { PersistentPlayer } from '../player/PersistentPlayer';
import { QueuePanel } from '../player/QueuePanel'; import { QueuePanel } from '../player/QueuePanel';
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
import { LoadingSkeleton } from '../common/LoadingSkeleton'; import { LoadingSkeleton } from '../common/LoadingSkeleton';
export function AppShell() { export function AppShell() {
@@ -31,6 +32,7 @@ export function AppShell() {
</div> </div>
</main> </main>
<QueuePanel /> <QueuePanel />
<TrackInfoDrawer />
</div> </div>
<PersistentPlayer /> <PersistentPlayer />
</div> </div>
+6 -3
View File
@@ -10,9 +10,9 @@ import {
setVolume, setVolume,
toggleShuffle, toggleShuffle,
setRepeat, setRepeat,
toggleNowPlaying,
toggleQueue, toggleQueue,
} from '../../store/slices/player'; } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import { useAudioPlayer } from '../../hooks/useAudioPlayer'; import { useAudioPlayer } from '../../hooks/useAudioPlayer';
import { useStreamCached } from '../../hooks/useStreamCached'; import { useStreamCached } from '../../hooks/useStreamCached';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry'; import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
@@ -49,8 +49,11 @@ export function PersistentPlayer() {
<div className="player"> <div className="player">
<div <div
className="pl-now" className="pl-now"
onClick={() => dispatch(toggleNowPlaying())} onClick={() =>
style={{ cursor: 'pointer' }} 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} /> <ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
<div className="pl-now-tt"> <div className="pl-now-tt">
+21 -3
View File
@@ -10,6 +10,7 @@ import {
type QueueEntry, type QueueEntry,
} from '../../store/slices/queue'; } from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player'; import { toggleQueue } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry'; import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
export function QueuePanel() { export function QueuePanel() {
@@ -85,14 +86,27 @@ export function QueuePanel() {
<div className="t">{now.title}</div> <div className="t">{now.title}</div>
<div className="r">{now.artistName}</div> <div className="r">{now.artistName}</div>
</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> </div>
{isRadio && ( {isRadio && (
<div className="qd-radio"> <div className="qd-radio">
<div className="row"> <div className="row">
<Icon name="radio" /> <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')} {t('queue.radioActive')}
</span> </span>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
@@ -161,7 +175,11 @@ function QueueRow({
const albumTitle = resolved?.albumTitle ?? entry.albumTitle; const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
return ( return (
<div className="qrow" onDoubleClick={onPlay} title={t('queue.doubleClickPlay')}> <div
className="qrow"
onDoubleClick={onPlay}
title={t('queue.doubleClickPlay')}
>
<span className="grip"> <span className="grip">
<Icon name="dots-six-vertical" /> <Icon name="dots-six-vertical" />
</span> </span>
+29 -4
View File
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue'; import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player'; import { play } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import type { Track } from '../../api/types'; import type { Track } from '../../api/types';
interface Props { interface Props {
@@ -42,21 +43,45 @@ export function TrackContextMenu({
return ( return (
<Menu> <Menu>
<MenuTrigger asChild> <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> </IconButton>
</MenuTrigger> </MenuTrigger>
<MenuContent> <MenuContent>
<MenuItem onSelect={() => { dispatch(play(track.id)); }}> <MenuItem
onSelect={() => {
dispatch(play(track.id));
}}
>
{t('track.menu.playNow')} {t('track.menu.playNow')}
</MenuItem> </MenuItem>
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}> <MenuItem
onSelect={() => {
dispatch(addNextInQueue(entry));
}}
>
{t('track.menu.playNext')} {t('track.menu.playNext')}
</MenuItem> </MenuItem>
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}> <MenuItem
onSelect={() => {
dispatch(addToQueue(entry));
}}
>
{t('track.menu.addToQueue')} {t('track.menu.addToQueue')}
</MenuItem> </MenuItem>
<MenuSeparator /> <MenuSeparator />
<MenuItem
onSelect={() => {
dispatch(openTrackInfo(track.id));
}}
>
{t('track.menu.info')}
</MenuItem>
<MenuSeparator />
{onAddToPlaylist && ( {onAddToPlaylist && (
<MenuItem onSelect={() => onAddToPlaylist(track)}> <MenuItem onSelect={() => onAddToPlaylist(track)}>
{t('track.menu.addToPlaylist')} {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>
);
}
@@ -1,4 +1,16 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; 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'; import { Placeholder } from '../../components/common/Placeholder';
interface Props { interface Props {
@@ -6,13 +18,500 @@ interface Props {
batch?: boolean; 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 * `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
* metadata editor with auto-enrichment / diff view. Scaffold only. * match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
*/ */
export function MetadataEditorPage({ batch = false }: Props) { export function MetadataEditorPage({ batch = false }: Props) {
const { t } = useTranslation(); 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 ( 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>
); );
} }
+70
View File
@@ -156,12 +156,49 @@ const en = {
playNow: 'Play now', playNow: 'Play now',
playNext: 'Play next', playNext: 'Play next',
addToQueue: 'Add to queue', addToQueue: 'Add to queue',
info: 'Track info',
addToPlaylist: 'Add to playlist…', addToPlaylist: 'Add to playlist…',
editMetadata: 'Edit metadata', editMetadata: 'Edit metadata',
download: 'Download', download: 'Download',
delete: 'Delete', 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: { common: {
error: 'Something went wrong', error: 'Something went wrong',
retry: 'Retry', retry: 'Retry',
@@ -244,6 +281,39 @@ const en = {
manual: 'Edited manually — not auto-updated', 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; } as const;
export default en; export default en;
+70
View File
@@ -158,12 +158,49 @@ const ru: Translations = {
playNow: 'Играть сейчас', playNow: 'Играть сейчас',
playNext: 'Следующим', playNext: 'Следующим',
addToQueue: 'Добавить в очередь', addToQueue: 'Добавить в очередь',
info: 'Информация о треке',
addToPlaylist: 'Добавить в плейлист…', addToPlaylist: 'Добавить в плейлист…',
editMetadata: 'Редактировать метаданные', editMetadata: 'Редактировать метаданные',
download: 'Скачать', download: 'Скачать',
delete: 'Удалить', 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: { common: {
error: 'Что-то пошло не так', error: 'Что-то пошло не так',
retry: 'Повторить', retry: 'Повторить',
@@ -246,6 +283,39 @@ const ru: Translations = {
manual: 'Изменено вручную — не обновляется автоматически', 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; export default ru;
+10
View File
@@ -16,6 +16,16 @@ export function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; 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 { export function formatCount(n: number): string {
if (n < 1000) return String(n); if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`; if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
-6
View File
@@ -11,7 +11,6 @@ export interface PlayerState {
muted: boolean; muted: boolean;
repeat: RepeatMode; repeat: RepeatMode;
shuffle: boolean; shuffle: boolean;
isNowPlayingOpen: boolean;
isQueueOpen: boolean; isQueueOpen: boolean;
} }
@@ -24,7 +23,6 @@ export const playerInitialState: PlayerState = {
muted: false, muted: false,
repeat: 'none', repeat: 'none',
shuffle: false, shuffle: false,
isNowPlayingOpen: false,
isQueueOpen: false, isQueueOpen: false,
}; };
@@ -66,9 +64,6 @@ export const playerSlice = createSlice({
toggleShuffle(state) { toggleShuffle(state) {
state.shuffle = !state.shuffle; state.shuffle = !state.shuffle;
}, },
toggleNowPlaying(state) {
state.isNowPlayingOpen = !state.isNowPlayingOpen;
},
toggleQueue(state) { toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen; state.isQueueOpen = !state.isQueueOpen;
}, },
@@ -86,7 +81,6 @@ export const {
toggleMute, toggleMute,
setRepeat, setRepeat,
toggleShuffle, toggleShuffle,
toggleNowPlaying,
toggleQueue, toggleQueue,
} = playerSlice.actions; } = playerSlice.actions;
export default playerSlice.reducer; export default playerSlice.reducer;
+11
View File
@@ -4,12 +4,15 @@ interface UiState {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
activeModal: string | null; activeModal: string | null;
activeTrackContextMenuId: string | null; activeTrackContextMenuId: string | null;
/** Track whose info drawer is open (rightmost drawer); null = closed. */
trackInfoId: string | null;
} }
const initialState: UiState = { const initialState: UiState = {
sidebarCollapsed: false, sidebarCollapsed: false,
activeModal: null, activeModal: null,
activeTrackContextMenuId: null, activeTrackContextMenuId: null,
trackInfoId: null,
}; };
export const uiSlice = createSlice({ export const uiSlice = createSlice({
@@ -31,6 +34,12 @@ export const uiSlice = createSlice({
setActiveContextMenu(state, action: PayloadAction<string | null>) { setActiveContextMenu(state, action: PayloadAction<string | null>) {
state.activeTrackContextMenuId = action.payload; 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, openModal,
closeModal, closeModal,
setActiveContextMenu, setActiveContextMenu,
openTrackInfo,
closeTrackInfo,
} = uiSlice.actions; } = uiSlice.actions;
export default uiSlice.reducer; export default uiSlice.reducer;
+158
View File
@@ -724,6 +724,164 @@
font-size: 12px; 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) PAGE HEADER + SECONDARY NAV (Settings, Admin)
============================================================ */ ============================================================ */