Compare commits

..

4 Commits

Author SHA1 Message Date
Senko-san 61dbb1abd2 feat(upload): wire A8 local track upload to backend
Implement the A8 upload screen against the existing /upload contract:
- UploadResponse type ({track_id, title, already_exists}) + mutation typed to it
- buildUploadFormData helper (single file under field `file`, per FastAPI)
- UploadPage: drag-and-drop + file picker, client-side queue with
  concurrency cap (3), per-file status badges, retry on error,
  already_exists -> "Already in library", deep-link to A7 metadata editor
- i18n upload.* section (en/ru) incl. "metadata pending" hint

Indeterminate spinner per file; percent progress is a follow-up
(needs an XHR baseQuery — fetchBaseQuery gives no upload progress).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:47:59 +03:00
Senko-san aed0572071 Scaffold global navigation aligned to routes plan
Build out the full web route map from music-selfhost-routes.md as
scaffolding (no functionality on new screens):

- Full route tree: /login, /albums/:id, /artists/:id, /playlists(+detail),
  /discover, /upload, metadata editor (single + batch), /storage/maintenance,
  /queue, nested /settings and /admin, and a 404.
- Sidebar rebuilt to the A1 spec with permission-gated Discover/Upload.
- ProtectedRoute gains requirePermission; Permission exported.
- AppShell wraps Outlet in a Suspense boundary for lazy routes.
- Reusable Placeholder + SubNav; Settings/Admin become nested layouts.
- Settings/Profile: wired language + theme selectors.
- Remove orphaned Home feature (web has no Home; / -> /library) and the
  now-unused house icon + nav.home keys.
- i18n keys (en + ru) and CSS for page-title/sub-nav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 17:05:21 +03:00
Senko-san e45bcef3a5 feat: i18n 2026-06-06 15:23:07 +03:00
Senko-san bbd59cc225 feat: move to npm package & small fixes 2026-06-06 14:07:17 +03:00
45 changed files with 1620 additions and 747 deletions
+1
View File
@@ -0,0 +1 @@
@olly:registry=https://git.ollyhearn.ru/api/packages/olly/npm/
+91 -53
View File
@@ -8,11 +8,13 @@
"name": "mcma-webui",
"version": "1.0.0",
"dependencies": {
"@olly/modern-sk": "0.1.4-3",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
"modern-sk": "file:./modern-sk-0.1.2.tgz",
"i18next": "^26.3.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
},
@@ -496,7 +498,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -691,6 +692,20 @@
"@emnapi/runtime": "^1.7.1"
}
},
"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==",
"license": "MIT",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"radix-ui": "^1.4.3"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
@@ -2464,9 +2479,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2481,9 +2493,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2498,9 +2507,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2515,9 +2521,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2818,9 +2821,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2838,9 +2838,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2858,9 +2855,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2878,9 +2872,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3501,6 +3492,43 @@
"node": ">=20.0.0"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
@@ -3707,9 +3735,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3731,9 +3756,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3755,9 +3777,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3779,9 +3798,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3877,20 +3893,6 @@
"node": ">=4"
}
},
"node_modules/modern-sk": {
"version": "0.1.2",
"resolved": "file:modern-sk-0.1.2.tgz",
"integrity": "sha512-tKSxbtUxT0CLkGc8DK+SABlVmKsMqqQr61uvAJ8EDcrutzm+VD230hTRVzk9hp2oSo6nXeeMig7KS8v0Lz5mWw==",
"license": "MIT",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"radix-ui": "^1.4.3"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4105,6 +4107,33 @@
"react": "^19.2.7"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -4371,7 +4400,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4471,6 +4500,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+3 -1
View File
@@ -13,11 +13,13 @@
"test:watch": "rstest --watch"
},
"dependencies": {
"@olly/modern-sk": "0.1.4-3",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
"modern-sk": "file:./modern-sk-0.1.2.tgz",
"i18next": "^26.3.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
},
+12 -2
View File
@@ -1,9 +1,19 @@
import { api } from '../index';
import type { Track } from '../types';
import type { UploadResponse } from '../types';
/**
* Build the multipart body for `/upload`. The backend expects exactly one file
* per request under the field name `file` (anything else → FastAPI 422).
*/
export function buildUploadFormData(file: File): FormData {
const fd = new FormData();
fd.append('file', file);
return fd;
}
export const uploadApi = api.injectEndpoints({
endpoints: (build) => ({
uploadTrack: build.mutation<Track, FormData>({
uploadTrack: build.mutation<UploadResponse, FormData>({
query: (formData) => ({
url: '/upload',
method: 'POST',
+6
View File
@@ -72,6 +72,12 @@ export interface DownloadJob {
updatedAt: string;
}
export interface UploadResponse {
track_id: string;
title: string;
already_exists: boolean;
}
export interface StorageStats {
totalBytes: number;
usedBytes: number;
+11 -8
View File
@@ -1,12 +1,13 @@
import { Badge, Tooltip } from 'modern-sk';
import { Badge, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { getApiBaseUrl } from '../../config/runtime-config';
const STATUS_LABELS = {
connected: 'Connected',
connecting: 'Connecting',
disconnected: 'Disconnected',
error: 'Connection error',
const STATUS_KEY = {
connected: 'conn.connected',
connecting: 'conn.connecting',
disconnected: 'conn.disconnected',
error: 'conn.error',
} as const;
const STATUS_VARIANTS = {
@@ -17,13 +18,15 @@ const STATUS_VARIANTS = {
} as const;
export function ConnectionStatus() {
const { t } = useTranslation();
const status = useConnectionStatus();
const baseUrl = getApiBaseUrl();
const label = t(STATUS_KEY[status]);
return (
<Tooltip content={`${STATUS_LABELS[status]} · ${baseUrl}`}>
<Tooltip content={`${label} · ${baseUrl}`}>
<Badge variant={STATUS_VARIANTS[status]} dot>
{STATUS_LABELS[status]}
{label}
</Badge>
</Tooltip>
);
+6 -7
View File
@@ -1,18 +1,17 @@
import { Callout, Button } from 'modern-sk';
import { Callout, Button } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
interface ErrorStateProps {
message?: string;
onRetry?: () => void;
}
export function ErrorState({
message = 'Something went wrong',
onRetry,
}: ErrorStateProps) {
export function ErrorState({ message, onRetry }: ErrorStateProps) {
const { t } = useTranslation();
return (
<div style={{ padding: '2rem' }}>
<Callout variant="danger">
{message}
{message ?? t('common.error')}
{onRetry && (
<Button
variant="ghost"
@@ -20,7 +19,7 @@ export function ErrorState({
onClick={onRetry}
style={{ marginLeft: '1rem' }}
>
Retry
{t('common.retry')}
</Button>
)}
</Callout>
-2
View File
@@ -19,7 +19,6 @@ import {
GearSix,
HardDrives,
Heart,
House,
MagnifyingGlass,
Pause,
Play,
@@ -48,7 +47,6 @@ import {
const ICONS = {
'vinyl-record': VinylRecord,
house: House,
'magnifying-glass': MagnifyingGlass,
'arrow-circle-down': ArrowCircleDown,
'upload-simple': UploadSimple,
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import { Window } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
interface Props {
/** Window title. Pass an already-translated string. */
title: string;
/** Optional sub-line under the title; defaults to the shared "coming soon" copy. */
note?: string;
/** Optional extra content rendered below the note (e.g. a sub-nav). */
children?: ReactNode;
}
/**
* Scaffolding placeholder for screens that exist in the navigation map
* (music-selfhost-routes.md) but have no functionality yet. Keeps every
* stub visually consistent so filling one in later is mechanical.
*/
export function Placeholder({ title, note, children }: Props) {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title={title}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{note ?? t('common.comingSoon')}
</p>
{children}
</Window>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { NavLink } from 'react-router';
export interface SubNavItem {
to: string;
label: string;
/** Match the path exactly (used for index/redirect targets). */
end?: boolean;
}
interface Props {
items: SubNavItem[];
}
function subNavClass({ isActive }: { isActive: boolean }) {
return isActive ? 'sub-nav-item active' : 'sub-nav-item';
}
/** Horizontal secondary navigation for screens with sub-sections (Settings, Admin). */
export function SubNav({ items }: Props) {
return (
<nav className="sub-nav">
{items.map((it) => (
<NavLink key={it.to} to={it.to} end={it.end} className={subNavClass}>
{it.label}
</NavLink>
))}
</nav>
);
}
+11 -1
View File
@@ -1,7 +1,9 @@
import { Outlet } from 'react-router';
import { Suspense } from 'react';
import { Sidebar } from './Sidebar';
import { PersistentPlayer } from '../player/PersistentPlayer';
import { QueuePanel } from '../player/QueuePanel';
import { LoadingSkeleton } from '../common/LoadingSkeleton';
export function AppShell() {
return (
@@ -17,7 +19,15 @@ export function AppShell() {
<Sidebar />
<main className="app-main">
<div className="app-screen">
<Outlet />
<Suspense
fallback={
<div style={{ padding: '2rem' }}>
<LoadingSkeleton />
</div>
}
>
<Outlet />
</Suspense>
</div>
</main>
<QueuePanel />
+44 -32
View File
@@ -1,7 +1,8 @@
import { NavLink, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions } from '../../hooks/usePermissions';
import { usePermissions, type Permission } from '../../hooks/usePermissions';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { logout } from '../../store/slices/auth';
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
@@ -9,24 +10,26 @@ import { getActiveInstance } from '../../config/instances';
interface NavDef {
to: string;
label: string;
labelKey: string;
icon: IconName;
end?: boolean;
/** Hide this item unless the user holds the permission. */
perm?: Permission;
}
const MAIN_NAV: NavDef[] = [
{ to: '/', label: 'Home', icon: 'house', end: true },
{ to: '/library', label: 'Library', icon: 'vinyl-record' },
{ to: '/search', label: 'Search & download', icon: 'magnifying-glass' },
{ to: '/downloads', label: 'Downloads', icon: 'arrow-circle-down' },
{ to: '/storage', label: 'Storage', icon: 'hard-drives' },
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
{ to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' },
{ to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' },
{ to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' },
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
];
const CONN_CLASS: Record<string, { cls: string; txt: string }> = {
connected: { cls: 'online', txt: 'Connected' },
connecting: { cls: 'syncing', txt: 'Connecting' },
disconnected: { cls: 'offline', txt: 'Offline' },
error: { cls: 'error', txt: 'Unreachable' },
const CONN_KEY: Record<string, { cls: string; txtKey: string }> = {
connected: { cls: 'online', txtKey: 'conn.connected' },
connecting: { cls: 'syncing', txtKey: 'conn.connecting' },
disconnected: { cls: 'offline', txtKey: 'conn.disconnected' },
error: { cls: 'error', txtKey: 'conn.error' },
};
function navClass({ isActive }: { isActive: boolean }) {
@@ -34,14 +37,15 @@ function navClass({ isActive }: { isActive: boolean }) {
}
export function Sidebar() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin } = usePermissions();
const { user, isAdmin, hasPermission } = usePermissions();
const status = useConnectionStatus();
const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance();
const conn = CONN_CLASS[status] ?? CONN_CLASS.connecting;
const conn = CONN_KEY[status] ?? CONN_KEY.connecting;
const online = status === 'connected';
const handleLogout = (e: React.MouseEvent) => {
@@ -59,20 +63,24 @@ export function Sidebar() {
</div>
<div className="sb-sec">
{MAIN_NAV.map(({ to, label, icon, end }) => (
<NavLink key={to} to={to} end={end} className={navClass}>
<Icon name={icon} />
<span>{label}</span>
</NavLink>
))}
{MAIN_NAV.filter((n) => !n.perm || hasPermission(n.perm)).map(
({ to, labelKey, icon, end }) => (
<NavLink key={to} to={to} end={end} className={navClass}>
<Icon name={icon} />
<span>{t(labelKey)}</span>
</NavLink>
),
)}
</div>
<div className="sb-sec">
<span className="msk-label">Playlists</span>
<NavLink to="/playlists" end className="msk-label sb-sec-link">
{t('nav.playlists')}
</NavLink>
{(playlists?.items ?? []).map((pl) => (
<NavLink
key={pl.id}
to={`/library/playlists/${pl.id}`}
to={`/playlists/${pl.id}`}
className={navClass}
>
<Icon name="playlist" />
@@ -82,30 +90,30 @@ export function Sidebar() {
<button
type="button"
className="pl-item"
onClick={() => void navigate('/library')}
onClick={() => void navigate('/playlists')}
>
<Icon name="plus" />
<span className="pl-name">New playlist</span>
<span className="pl-name">{t('nav.newPlaylist')}</span>
</button>
</div>
{isAdmin ? (
<div className="sb-sec">
<span className="msk-label">Administration</span>
<span className="msk-label">{t('nav.administration')}</span>
<NavLink to="/admin" className={navClass}>
<Icon name="shield-check" />
<span>Admin</span>
<span>{t('nav.admin')}</span>
</NavLink>
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>Settings</span>
<span>{t('nav.settings')}</span>
</NavLink>
</div>
) : (
<div className="sb-sec">
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>Settings</span>
<span>{t('nav.settings')}</span>
</NavLink>
</div>
)}
@@ -116,10 +124,10 @@ export function Sidebar() {
type="button"
className={`conn ${conn.cls}`}
onClick={() => void navigate('/connect')}
title="Connection — manage instances"
title={t('conn.manage')}
>
<span className="led" />
{conn.txt}
{t(conn.txtKey)}
</button>
{user && (
<button
@@ -133,10 +141,14 @@ export function Sidebar() {
<div className="user-meta">
<div className="nm">{user.username}</div>
<div className="rl">
{user.role} · {online ? 'online' : 'offline'}
{user.role} · {online ? t('user.online') : t('user.offline')}
</div>
</div>
<span className="uc-action" onClick={handleLogout} title="Sign out">
<span
className="uc-action"
onClick={handleLogout}
title={t('user.signOut')}
>
<Icon name="sign-out" />
</span>
</button>
+13 -15
View File
@@ -1,4 +1,5 @@
import { Slider } from 'modern-sk';
import { Slider } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -17,6 +18,7 @@ import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player);
@@ -24,17 +26,15 @@ export function PersistentPlayer() {
const currentEntry = queue.entries[queue.currentIndex];
if (!currentEntry && !player.currentTrackId) {
return <div className="player empty">Nothing playing</div>;
return <div className="player empty">{t('player.nothingPlaying')}</div>;
}
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
// Streaming is the web default; local playback is a mobile-client concern.
const onStream = true;
return (
<div className="player">
{/* now-playing identity */}
<div
className="pl-now"
onClick={() => dispatch(toggleNowPlaying())}
@@ -49,19 +49,18 @@ export function PersistentPlayer() {
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
{onStream ? t('player.streaming') : t('player.local')}
</div>
</div>
</div>
{/* transport + scrubber */}
<div className="pl-center">
<div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title="Shuffle"
title={t('player.shuffle')}
>
<Icon name="shuffle" />
</button>
@@ -69,7 +68,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playPrev}
title="Previous"
title={t('player.previous')}
>
<Icon name="skip-back" fill />
</button>
@@ -79,7 +78,7 @@ export function PersistentPlayer() {
onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume())
}
title={player.isPlaying ? 'Pause' : 'Play'}
title={player.isPlaying ? t('player.pause') : t('player.play')}
>
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button>
@@ -87,7 +86,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playNext}
title="Next"
title={t('player.next')}
>
<Icon name="skip-forward" fill />
</button>
@@ -105,7 +104,7 @@ export function PersistentPlayer() {
),
)
}
title={`Repeat: ${player.repeat}`}
title={t('player.repeat', { mode: player.repeat })}
>
<Icon name="repeat" />
</button>
@@ -121,7 +120,7 @@ export function PersistentPlayer() {
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
aria-label="Seek"
aria-label={t('player.play')}
/>
<span className="pl-time">
{formatDuration(player.duration * 1000)}
@@ -129,13 +128,12 @@ export function PersistentPlayer() {
</div>
</div>
{/* volume + queue */}
<div className="pl-right">
<button
type="button"
className="pl-tbtn"
onClick={() => dispatch(toggleMute())}
title={player.muted ? 'Unmute' : 'Mute'}
title={player.muted ? t('player.unmute') : t('player.mute')}
>
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
</button>
@@ -154,7 +152,7 @@ export function PersistentPlayer() {
type="button"
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
onClick={() => dispatch(toggleQueue())}
title="Play queue"
title={t('player.queue')}
>
<Icon name="queue" />
</button>
+20 -25
View File
@@ -1,4 +1,5 @@
import { Slider, Badge } from 'modern-sk';
import { Slider, Badge } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -10,6 +11,7 @@ import {
import { toggleQueue } from '../../store/slices/player';
export function QueuePanel() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
@@ -27,13 +29,13 @@ export function QueuePanel() {
<div className="qd-inner">
<div className="qd-head">
<div className="row">
<h3>Play queue</h3>
<h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(clearQueue())}
title="Clear queue"
title={t('queue.clear')}
>
<Icon name="trash" />
</button>
@@ -41,7 +43,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleQueue())}
title="Close"
title={t('queue.close')}
>
<Icon name="x" />
</button>
@@ -53,10 +55,10 @@ export function QueuePanel() {
/>
{isRadio ? (
<span style={{ color: 'var(--lime)' }}>
Radio · {sourceLabel}
{t('queue.radio', { source: sourceLabel })}
</span>
) : (
<span>From {sourceLabel}</span>
<span>{t('queue.from', { source: sourceLabel })}</span>
)}
</div>
</div>
@@ -68,7 +70,7 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
Now playing
{t('queue.nowPlaying')}
</span>
<div className="qd-now">
<ArtTile
@@ -87,21 +89,14 @@ export function QueuePanel() {
<div className="qd-radio">
<div className="row">
<Icon name="radio" />
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--fg-1)',
}}
>
Radio active
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg-1)' }}>
{t('queue.radioActive')}
</span>
<div style={{ flex: 1 }} />
<Badge variant="neutral"> mixing</Badge>
<Badge variant="neutral">{t('queue.mixing')}</Badge>
</div>
{/* exploration balance — stub under the future ML contract */}
<div className="expl">
<span className="lab">Familiar</span>
<span className="lab">{t('queue.familiar')}</span>
<Slider
className="expl-slider"
min={0}
@@ -110,7 +105,7 @@ export function QueuePanel() {
defaultValue={[42]}
aria-label="Exploration"
/>
<span className="lab">New</span>
<span className="lab">{t('queue.new')}</span>
</div>
</div>
)}
@@ -119,17 +114,17 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }}
>
Next up
{t('queue.nextUp')}
</span>
{upNext.length === 0 ? (
<div className="qd-empty">Nothing queued next</div>
<div className="qd-empty">{t('queue.nothingNext')}</div>
) : (
upNext.map(({ entry, index }) => (
<div
key={`${entry.trackId}-${index}`}
className="qrow"
onDoubleClick={() => dispatch(goToIndex(index))}
title="Double-click to play"
title={t('queue.doubleClickPlay')}
>
<span className="grip">
<Icon name="dots-six-vertical" />
@@ -147,7 +142,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
title="Remove from queue"
title={t('queue.removeFromQueue')}
>
<Icon name="x" />
</button>
@@ -156,11 +151,11 @@ export function QueuePanel() {
)}
{isRadio && (
<div className="qd-loadmore">Loading more from radio</div>
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
)}
</>
) : (
<div className="qd-empty">Queue is empty</div>
<div className="qd-empty">{t('queue.empty')}</div>
)}
</div>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
import { Badge, Tooltip } from 'modern-sk';
import { Badge, Tooltip } from '@olly/modern-sk';
import type { TrackAvailability } from '../../api/types';
interface Props {
+18 -24
View File
@@ -5,7 +5,8 @@ import {
MenuItem,
MenuSeparator,
IconButton,
} from 'modern-sk';
} from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player';
@@ -26,6 +27,7 @@ export function TrackContextMenu({
onDelete,
onDownload,
}: Props) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entry = {
@@ -40,49 +42,41 @@ export function TrackContextMenu({
return (
<Menu>
<MenuTrigger asChild>
<IconButton variant="ghost" size="sm" aria-label="Track options">
<IconButton variant="ghost" size="sm" aria-label={t('track.menu.options')}>
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem
onSelect={() => {
dispatch(play(track.id));
}}
>
Play now
<MenuItem onSelect={() => { dispatch(play(track.id)); }}>
{t('track.menu.playNow')}
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addNextInQueue(entry));
}}
>
Play next
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>
{t('track.menu.playNext')}
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addToQueue(entry));
}}
>
Add to queue
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>
{t('track.menu.addToQueue')}
</MenuItem>
<MenuSeparator />
{onAddToPlaylist && (
<MenuItem onSelect={() => onAddToPlaylist(track)}>
Add to playlist
{t('track.menu.addToPlaylist')}
</MenuItem>
)}
<MenuSeparator />
{onEditMetadata && (
<MenuItem onSelect={() => onEditMetadata(track)}>
Edit metadata
{t('track.menu.editMetadata')}
</MenuItem>
)}
{onDownload && (
<MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>
<MenuItem onSelect={() => onDownload(track)}>
{t('track.menu.download')}
</MenuItem>
)}
{onDelete && (
<MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>
<MenuItem onSelect={() => onDelete(track)}>
{t('track.menu.delete')}
</MenuItem>
)}
</MenuContent>
</Menu>
+1 -1
View File
@@ -1,4 +1,4 @@
import { Row } from 'modern-sk';
import { Row } from '@olly/modern-sk';
import { TrackContextMenu } from './TrackContextMenu';
import { AvailabilityBadge } from './AvailabilityBadge';
import { formatDuration } from '../../lib/format';
+27 -5
View File
@@ -1,10 +1,32 @@
import { Window } from 'modern-sk';
import { Outlet } from 'react-router';
import { useTranslation } from 'react-i18next';
import { SubNav, type SubNavItem } from '../../components/common/SubNav';
/**
* `/admin` — A9 admin shell (admin-gated). Secondary nav + nested `<Outlet/>`
* for users/sources/instance. `/admin` redirects to `/admin/users`.
*/
export function AdminPage() {
const { t } = useTranslation();
const items: SubNavItem[] = [
{ to: '/admin/users', label: t('admin.tabs.users') },
{ to: '/admin/sources', label: t('admin.tabs.sources') },
{ to: '/admin/instance', label: t('admin.tabs.instance') },
];
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Admin">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<h1 className="page-title">{t('pages.admin')}</h1>
<SubNav items={items} />
<Outlet />
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
function StubPanel({ title }: { title: string }) {
const { t } = useTranslation();
return (
<Window title={title}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
/** `/admin/users` — user list (add/remove). Scaffold. */
export function AdminUsers() {
const { t } = useTranslation();
return <StubPanel title={t('admin.tabs.users')} />;
}
/** `/admin/users/:userId` — per-user permissions / reset password / status. Scaffold. */
export function AdminUserDetail() {
const { t } = useTranslation();
return <StubPanel title={t('admin.userDetail')} />;
}
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
export function AdminSources() {
const { t } = useTranslation();
return <StubPanel title={t('admin.tabs.sources')} />;
}
/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */
export function AdminInstance() {
const { t } = useTranslation();
return <StubPanel title={t('admin.tabs.instance')} />;
}
+11 -11
View File
@@ -1,5 +1,6 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import {
useGetAlbumQuery,
useGetAlbumTracksQuery,
@@ -14,6 +15,7 @@ import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
export function AlbumDetailPage() {
const { t } = useTranslation();
const { albumId } = useParams<{ albumId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -32,7 +34,7 @@ export function AlbumDetailPage() {
if (albumQuery.isError) {
return (
<ErrorState
message="Failed to load album"
message={t('album.error')}
onRetry={() => albumQuery.refetch()}
/>
);
@@ -63,7 +65,6 @@ export function AlbumDetailPage() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* header */}
<div
style={{
padding: '1.25rem 1.5rem',
@@ -78,7 +79,7 @@ export function AlbumDetailPage() {
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
aria-label={t('common.back')}
>
</IconButton>
@@ -125,7 +126,7 @@ export function AlbumDetailPage() {
letterSpacing: '0.05em',
}}
>
Album
{t('album.type')}
</p>
<h1
style={{
@@ -146,7 +147,7 @@ export function AlbumDetailPage() {
{album?.artistName}
{album?.year && ` · ${album.year}`}
{album &&
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
` · ${album.trackCount} · ${formatDuration(album.totalDurationMs)}`}
</p>
</div>
</div>
@@ -155,16 +156,15 @@ export function AlbumDetailPage() {
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
{t('album.play')}
</Button>
</div>
{/* tracks */}
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
message={t('album.tracksError')}
onRetry={() => tracksQuery.refetch()}
/>
)}
@@ -173,8 +173,8 @@ export function AlbumDetailPage() {
tracks.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="This album has no tracks."
title={t('album.empty.title')}
description={t('album.empty.description')}
/>
)}
{tracks.map((track, i) => (
@@ -0,0 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/** `/artists/:artistId` — A3 artist detail (discography + similar). Scaffold only. */
export function ArtistDetailPage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.artist')} />;
}
+8
View File
@@ -0,0 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/** `/login` — sign in when the instance is already chosen (B1-for-web). Scaffold only. */
export function LoginPage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.login')} />;
}
+14 -18
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Card, TextField, Button, Callout, Badge } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setTokens, setUser } from '../../store/slices/auth';
@@ -14,10 +15,10 @@ import {
import type { User } from '../../api/types';
export function ConnectPage() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
// Re-read on each render trigger; instance ops below force a remount via state.
const [rev, setRev] = useState(0);
const instances = listInstances();
const activeId = getActiveInstanceId();
@@ -26,8 +27,6 @@ export function ConnectPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// Switching to a saved backend reloads the app so every slice re-initialises
// from that instance's namespaced storage (its own session, prefs, cache).
const switchTo = (id: string) => {
setActiveInstanceId(id);
window.location.assign('/');
@@ -38,11 +37,9 @@ export function ConnectPage() {
setRev((r) => r + 1);
};
// STUB: no backend yet. Register the instance, then fake a session so the rest
// of the app is reachable. Replace with the real useLoginMutation() flow later.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setApiBaseUrl(apiUrl); // upsert + activate this backend
setApiBaseUrl(apiUrl);
const fakeUser: User = {
id: 'dev-user',
@@ -114,7 +111,7 @@ export function ConnectPage() {
}}
>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
Saved instances
{t('connect.savedInstances')}
</span>
{instances.map((inst) => (
<div
@@ -165,21 +162,21 @@ export function ConnectPage() {
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">active</Badge>
<Badge variant="lime">{t('connect.active')}</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
Use
{t('connect.use')}
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title="Forget this instance"
title={t('connect.forgetTitle')}
>
<Icon name="trash" />
</button>
@@ -199,9 +196,9 @@ export function ConnectPage() {
padding: '1.5rem',
}}
>
<span className="msk-label">Connect to a backend</span>
<span className="msk-label">{t('connect.form.title')}</span>
<div>
<label style={labelStyle}>Server URL</label>
<label style={labelStyle}>{t('connect.form.serverUrl')}</label>
<TextField
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
@@ -210,7 +207,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={labelStyle}>Username</label>
<label style={labelStyle}>{t('connect.form.username')}</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -220,7 +217,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={labelStyle}>Password</label>
<label style={labelStyle}>{t('connect.form.password')}</label>
<TextField
type="password"
value={password}
@@ -231,15 +228,14 @@ export function ConnectPage() {
/>
</div>
<Callout variant="warning">
Stub mode backend not wired. Connect signs in with a fake admin
session, scoped to this instance.
{t('connect.form.stubNote')}
</Callout>
<Button
type="submit"
variant="primary"
style={{ marginTop: '0.5rem' }}
>
Connect
{t('connect.form.submit')}
</Button>
</form>
</Card>
@@ -1,9 +1,12 @@
import { Window } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function DownloadsManagerPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Downloads">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.downloads')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</div>
);
-444
View File
@@ -1,444 +0,0 @@
import { useState } from 'react';
import {
Button,
IconButton,
TextField,
TextArea,
SearchField,
Select,
Switch,
Checkbox,
RadioGroup,
RadioItem,
Control,
SegmentedControl,
Slider,
Stepper,
Tabs,
TabsList,
TabsContent,
Progress,
Badge,
Chip,
Card,
List,
Row,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
MenuSeparator,
Tooltip,
Spinner,
Callout,
Table,
THead,
TBody,
Tr,
Th,
Td,
Dialog,
DialogClose,
AlertDialog,
Window,
useTheme,
} from 'modern-sk';
const sectionStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
};
const rowWrap: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
alignItems: 'center',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--color-text-3)',
};
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card style={{ padding: '1.25rem', ...sectionStyle }}>
<span style={labelStyle}>{title}</span>
{children}
</Card>
);
}
export function HomePage() {
const { theme, setTheme } = useTheme();
const [search, setSearch] = useState('');
const [select, setSelect] = useState<string | undefined>();
const [seg, setSeg] = useState('list');
const [tab, setTab] = useState('one');
const [vol, setVol] = useState([60]);
const [count, setCount] = useState(3);
const [chips, setChips] = useState(['rock', 'jazz', 'ambient']);
const [switchOn, setSwitchOn] = useState(true);
const [radio, setRadio] = useState('a');
return (
<div style={{ overflow: 'auto', height: '100%' }}>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
maxWidth: '64rem',
margin: '0 auto',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>
MCMA Component Kitchen Sink
</h1>
<p
style={{
margin: '0.25rem 0 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
modern-sk reference. Project base ready for development.
</p>
</div>
<Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'}`}>
<Button
variant="ghost"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? '☾ Dark' : '☀ Light'}
</Button>
</Tooltip>
</div>
<Section title="Buttons">
<div style={rowWrap}>
<Button variant="key">Key</Button>
<Button variant="primary">Primary</Button>
<Button variant="ember">Ember</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="primary" size="sm">
Small
</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</div>
<div style={rowWrap}>
<IconButton variant="primary" aria-label="Play">
</IconButton>
<IconButton variant="ghost" aria-label="Next">
</IconButton>
<IconButton variant="ember" size="lg" aria-label="Stop">
</IconButton>
<Stepper
onDecrement={() => setCount((c) => c - 1)}
onIncrement={() => setCount((c) => c + 1)}
/>
<span
style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}
>
count: {count}
</span>
</div>
</Section>
<Section title="Inputs">
<div style={rowWrap}>
<TextField placeholder="Text field" style={{ width: '14rem' }} />
<SearchField
icon="⌕"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
style={{ width: '14rem' }}
/>
<Select
placeholder="Pick genre"
aria-label="Genre"
value={select}
onValueChange={setSelect}
items={[
{ value: 'rock', label: 'Rock' },
{ value: 'jazz', label: 'Jazz' },
{ value: 'ambient', label: 'Ambient' },
]}
/>
</div>
<TextArea placeholder="Text area / description…" rows={3} />
</Section>
<Section title="Toggles & selection">
<div style={rowWrap}>
<Control
control={
<Switch checked={switchOn} onCheckedChange={setSwitchOn} />
}
>
Switch
</Control>
<Control control={<Checkbox defaultChecked />}>Checkbox</Control>
</div>
<RadioGroup
value={radio}
onValueChange={setRadio}
style={{ display: 'flex', gap: '1rem' }}
>
<Control control={<RadioItem value="a" />}>Option A</Control>
<Control control={<RadioItem value="b" />}>Option B</Control>
<Control control={<RadioItem value="c" />}>Option C</Control>
</RadioGroup>
<SegmentedControl
value={seg}
onValueChange={setSeg}
items={[
{ value: 'list', label: 'List' },
{ value: 'grid', label: 'Grid' },
{ value: 'compact', label: 'Compact' },
]}
/>
</Section>
<Section title="Sliders & progress">
<Slider
min={0}
max={100}
step={1}
value={vol}
onValueChange={setVol}
notches="bottom"
/>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>
value: {vol[0]}
</span>
<Progress value={vol[0]} />
</Section>
<Section title="Badges, chips, spinner">
<div style={rowWrap}>
<Badge variant="lime" dot>
On server
</Badge>
<Badge variant="ember" dot>
Error
</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Spinner size="sm" />
<Spinner size="lg" />
</div>
<div style={rowWrap}>
{chips.map((c) => (
<Chip
key={c}
onRemove={() => setChips((prev) => prev.filter((x) => x !== c))}
>
{c}
</Chip>
))}
{chips.length === 0 && (
<span
style={{ fontSize: '0.875rem', color: 'var(--color-text-3)' }}
>
all removed
</span>
)}
</div>
</Section>
<Section title="Callouts">
<Callout variant="info">
Info backend address resolves from runtime env relative
/api/v1.
</Callout>
<Callout variant="success">
Success typecheck and lint pass clean.
</Callout>
<Callout variant="warning">
Warning most feature screens are still stubs.
</Callout>
<Callout variant="danger">
Danger destructive actions use AlertDialog.
</Callout>
</Section>
<Section title="Tabs">
<Tabs value={tab} onValueChange={setTab}>
<TabsList
items={[
{ value: 'one', label: 'First' },
{ value: 'two', label: 'Second' },
{ value: 'three', label: 'Third' },
]}
/>
<TabsContent
value="one"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
First panel
</TabsContent>
<TabsContent
value="two"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
Second panel
</TabsContent>
<TabsContent
value="three"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
Third panel
</TabsContent>
</Tabs>
</Section>
<Section title="List & rows">
<List>
<Row style={{ padding: '0.5rem 0.75rem' }}>Track one Artist</Row>
<Row selected style={{ padding: '0.5rem 0.75rem' }}>
Track two Artist (selected)
</Row>
<Row style={{ padding: '0.5rem 0.75rem' }}>
Track three Artist
</Row>
</List>
</Section>
<Section title="Table">
<Table>
<THead>
<Tr>
<Th>Title</Th>
<Th>Artist</Th>
<Th>Duration</Th>
</Tr>
</THead>
<TBody>
<Tr>
<Td>Intro</Td>
<Td>Aphex</Td>
<Td>2:14</Td>
</Tr>
<Tr selected>
<Td>Windowlicker</Td>
<Td>Aphex</Td>
<Td>6:07</Td>
</Tr>
<Tr>
<Td>Avril 14th</Td>
<Td>Aphex</Td>
<Td>2:01</Td>
</Tr>
</TBody>
</Table>
</Section>
<Section title="Menu, Dialog, AlertDialog">
<div style={rowWrap}>
<Menu>
<MenuTrigger asChild>
<Button variant="ghost">Open menu </Button>
</MenuTrigger>
<MenuContent>
<MenuItem>Play</MenuItem>
<MenuItem shortcut="⌘N">Add to queue</MenuItem>
<MenuSeparator />
<MenuItem>Edit metadata</MenuItem>
</MenuContent>
</Menu>
<Dialog
trigger={<Button variant="primary">Open dialog</Button>}
title="Dialog title"
description="Composed from modern-sk primitives."
footer={
<DialogClose asChild>
<Button variant="primary">Done</Button>
</DialogClose>
}
>
<p
style={{
color: 'var(--color-text-2)',
fontSize: '0.875rem',
margin: 0,
}}
>
Dialog body content.
</p>
</Dialog>
<AlertDialog
trigger={<Button variant="ember">Delete</Button>}
title="Delete track?"
description="This permanently removes the file from the server."
actionLabel="Delete"
destructive
onAction={() => undefined}
/>
</div>
</Section>
<Section title="Window">
<Window
title="Now Playing"
badge={
<Badge variant="lime" dot>
live
</Badge>
}
>
<p
style={{
color: 'var(--color-text-2)',
fontSize: '0.875rem',
margin: 0,
}}
>
Window chrome for grouped content.
</p>
</Window>
</Section>
</div>
</div>
);
}
+27 -17
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
Tabs,
TabsList,
@@ -7,7 +8,7 @@ import {
SearchField,
ScrollArea,
Card,
} from 'modern-sk';
} from '@olly/modern-sk';
import {
useGetTracksQuery,
useGetAlbumsQuery,
@@ -24,6 +25,7 @@ import { getCoverUrl } from '../../api/endpoints/streaming';
import { formatDuration } from '../../lib/format';
export function LibraryPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks');
@@ -45,7 +47,7 @@ export function LibraryPage() {
albumArtUrl: t.albumArtUrl,
})),
source: 'manual',
sourceName: 'Library',
sourceName: t('library.title'),
}),
);
};
@@ -63,13 +65,13 @@ export function LibraryPage() {
}}
>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
Library
{t('library.title')}
</h2>
<div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search library…"
placeholder={t('library.searchPlaceholder')}
icon="⌕"
/>
</div>
@@ -94,9 +96,9 @@ export function LibraryPage() {
>
<TabsList
items={[
{ value: 'tracks', label: 'Tracks' },
{ value: 'albums', label: 'Albums' },
{ value: 'artists', label: 'Artists' },
{ value: 'tracks', label: t('library.tabs.tracks') },
{ value: 'albums', label: t('library.tabs.albums') },
{ value: 'artists', label: t('library.tabs.artists') },
]}
/>
</div>
@@ -110,8 +112,8 @@ export function LibraryPage() {
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="Your library is empty. Start by downloading some music."
title={t('library.empty.tracks.title')}
description={t('library.empty.tracks.description')}
/>
)}
{tracksQuery.data &&
@@ -140,7 +142,7 @@ export function LibraryPage() {
fontWeight: 500,
}}
>
Play all ({data.total})
{t('library.playAll', { count: data.total })}
</button>
</div>
{data.items.map((track, i) => (
@@ -166,8 +168,8 @@ export function LibraryPage() {
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
<EmptyState
icon="💿"
title="No albums"
description="No albums in library."
title={t('library.empty.albums.title')}
description={t('library.empty.albums.description')}
/>
)}
{albumsQuery.data && (
@@ -183,7 +185,7 @@ export function LibraryPage() {
<AlbumCard
key={album.id}
album={album}
onClick={() => void navigate(`/library/albums/${album.id}`)}
onClick={() => void navigate(`/albums/${album.id}`)}
/>
))}
</div>
@@ -200,8 +202,8 @@ export function LibraryPage() {
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
<EmptyState
icon="🎤"
title="No artists"
description="No artists in library."
title={t('library.empty.artists.title')}
description={t('library.empty.artists.description')}
/>
)}
{artistsQuery.data && (
@@ -219,6 +221,7 @@ export function LibraryPage() {
}
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
const { t } = useTranslation();
const artUrl = getCoverUrl(album.artUrl);
return (
<Card
@@ -282,7 +285,10 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
{album.artistName}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
{album.trackCount} tracks · {formatDuration(album.totalDurationMs)}
{t('library.albumCard.tracksDuration', {
count: album.trackCount,
duration: formatDuration(album.totalDurationMs),
})}
</div>
</div>
</Card>
@@ -290,6 +296,7 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
}
function ArtistRow({ artist }: { artist: Artist }) {
const { t } = useTranslation();
return (
<div
style={{
@@ -319,7 +326,10 @@ function ArtistRow({ artist }: { artist: Artist }) {
{artist.name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{artist.albumCount} albums · {artist.trackCount} tracks
{t('library.artistRow.meta', {
albumCount: artist.albumCount,
trackCount: artist.trackCount,
})}
</div>
</div>
</div>
@@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
interface Props {
/** Single-track editor vs. batch editor — both A7, same scaffold. */
batch?: boolean;
}
/**
* `/tracks/:trackId/metadata` (single) and `/metadata/batch` (bulk) — A7
* metadata editor with auto-enrichment / diff view. Scaffold only.
*/
export function MetadataEditorPage({ batch = false }: Props) {
const { t } = useTranslation();
return (
<Placeholder title={batch ? t('pages.metadataBatch') : t('pages.metadata')} />
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { EmptyState } from '../../components/common/EmptyState';
/** `*` — 404. Lives inside AppShell so the sidebar/player stay visible. */
export function NotFoundPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<EmptyState
title={t('notFound.title')}
description={t('notFound.description')}
/>
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<Link to="/library" style={{ color: 'var(--color-accent)' }}>
{t('notFound.backToLibrary')}
</Link>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import {
useGetPlaylistQuery,
useGetPlaylistTracksQuery,
@@ -13,6 +14,7 @@ import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format';
export function PlaylistDetailPage() {
const { t } = useTranslation();
const { playlistId } = useParams<{ playlistId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -35,7 +37,7 @@ export function PlaylistDetailPage() {
if (playlistQuery.isError) {
return (
<ErrorState
message="Failed to load playlist"
message={t('playlist.error')}
onRetry={() => playlistQuery.refetch()}
/>
);
@@ -79,7 +81,7 @@ export function PlaylistDetailPage() {
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
aria-label={t('common.back')}
>
</IconButton>
@@ -93,7 +95,7 @@ export function PlaylistDetailPage() {
letterSpacing: '0.05em',
}}
>
Playlist
{t('playlist.type')}
</p>
<h1
style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}
@@ -119,7 +121,7 @@ export function PlaylistDetailPage() {
}}
>
{playlist &&
`${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
`${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
</p>
</div>
<Button
@@ -127,7 +129,7 @@ export function PlaylistDetailPage() {
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
{t('playlist.play')}
</Button>
</div>
@@ -135,7 +137,7 @@ export function PlaylistDetailPage() {
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
message={t('playlist.tracksError')}
onRetry={() => tracksQuery.refetch()}
/>
)}
@@ -144,8 +146,8 @@ export function PlaylistDetailPage() {
tracks.length === 0 && (
<EmptyState
icon="♫"
title="Empty playlist"
description="This playlist has no tracks yet."
title={t('playlist.empty.title')}
description={t('playlist.empty.description')}
/>
)}
{tracks.map((track, i) => (
+8
View File
@@ -0,0 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/** `/playlists` — user's playlist list. Scaffold only. */
export function PlaylistsPage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.playlists')} />;
}
+11
View File
@@ -0,0 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/**
* `/queue` — A11 full-screen play queue (narrow viewports). On desktop the
* queue is the `QueuePanel` drawer in AppShell, not this route. Scaffold only.
*/
export function QueuePage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.queue')} />;
}
@@ -1,9 +1,12 @@
import { Window } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function SearchDownloadPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Search & Download">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.search')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</div>
);
+29 -5
View File
@@ -1,10 +1,34 @@
import { Window } from 'modern-sk';
import { Outlet } from 'react-router';
import { useTranslation } from 'react-i18next';
import { SubNav, type SubNavItem } from '../../components/common/SubNav';
/**
* `/settings` — A10 settings shell. Hosts a secondary nav + nested `<Outlet/>`
* for the profile/playback/scrobbling/instance panels. `/settings` itself
* redirects to `/settings/profile` (see routes).
*/
export function SettingsPage() {
const { t } = useTranslation();
const items: SubNavItem[] = [
{ to: '/settings/profile', label: t('settings.tabs.profile') },
{ to: '/settings/playback', label: t('settings.tabs.playback') },
{ to: '/settings/scrobbling', label: t('settings.tabs.scrobbling') },
{ to: '/settings/instance', label: t('settings.tabs.instance') },
];
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Settings">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<h1 className="page-title">{t('pages.settings')}</h1>
<SubNav items={items} />
<Outlet />
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
/** Labelled settings row: caption on the left, control on the right. */
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span
style={{
fontSize: '0.875rem',
color: 'var(--color-text-2)',
minWidth: '6rem',
}}
>
{label}
</span>
{children}
</div>
);
}
/** `/settings/profile` — profile + app language + theme (all wired). */
export function ProfileSettings() {
const { t, i18n } = useTranslation();
const { theme, setTheme } = useTheme();
return (
<Window title={t('settings.tabs.profile')}>
<div
style={{
padding: '0.75rem 0',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
<SettingRow label={t('settings.language')}>
<SegmentedControl
value={i18n.language}
onValueChange={setLanguage}
items={SUPPORTED_LANGUAGES.map((l) => ({
value: l.code,
label: l.label,
}))}
/>
</SettingRow>
<SettingRow label={t('settings.theme')}>
<SegmentedControl
value={theme}
onValueChange={(v) => setTheme(v === 'light' ? 'light' : 'dark')}
items={[
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'light', label: t('settings.themeLight') },
]}
/>
</SettingRow>
</div>
</Window>
);
}
/** `/settings/playback` — default stream quality / playback behaviour. Scaffold. */
export function PlaybackSettings() {
const { t } = useTranslation();
return (
<Window title={t('settings.tabs.playback')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */
export function ScrobblingSettings() {
const { t } = useTranslation();
return (
<Window title={t('settings.tabs.scrobbling')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
/** `/settings/instance` — switch/forget instance. Scaffold. */
export function InstanceSettings() {
const { t } = useTranslation();
return (
<Window title={t('settings.tabs.instance')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
@@ -0,0 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/** `/storage/maintenance` — A6 maintenance (dupes, broken files, cleanup). Scaffold only. */
export function StorageMaintenancePage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.storageMaintenance')} />;
}
+6 -3
View File
@@ -1,9 +1,12 @@
import { Window } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function StoragePage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Storage">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.storage')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</div>
);
+328
View File
@@ -0,0 +1,328 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
import {
buildUploadFormData,
useUploadTrackMutation,
} from '../../api/endpoints/upload';
/** Pure client-side state — this is a transient upload queue, never server data. */
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
interface QueueItem {
id: string;
file: File;
status: ItemStatus;
error?: string;
trackId?: string;
}
/** The endpoint takes one file per request, so we cap concurrent requests. */
const MAX_CONCURRENCY = 3;
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null) {
const e = err as { data?: { message?: string; detail?: string }; error?: string };
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
}
return 'Upload failed';
}
/** `/upload` — A8 drag-and-drop upload of own files. */
export function UploadPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [uploadTrack] = useUploadTrackMutation();
const [items, setItems] = useState<QueueItem[]>([]);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const idCounter = useRef(0);
const activeCount = useRef(0);
const pending = useRef<QueueItem[]>([]);
const patchItem = (id: string, patch: Partial<QueueItem>) =>
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)));
// Ref-based concurrency pump: refs (not state) so it is safe to call from
// async callbacks without stale closures over the queue.
const pump = () => {
while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) {
const item = pending.current.shift()!;
activeCount.current += 1;
patchItem(item.id, { status: 'uploading', error: undefined });
uploadTrack(buildUploadFormData(item.file))
.unwrap()
.then((res) => {
patchItem(item.id, {
status: res.already_exists ? 'duplicate' : 'done',
trackId: res.track_id,
});
})
.catch((err: unknown) => {
patchItem(item.id, { status: 'error', error: extractError(err) });
})
.finally(() => {
activeCount.current -= 1;
pump();
});
}
};
const addFiles = (files: File[]) => {
if (files.length === 0) return;
const newItems: QueueItem[] = files.map((file) => ({
id: `u${idCounter.current++}`,
file,
status: 'queued',
}));
setItems((prev) => [...prev, ...newItems]);
pending.current.push(...newItems);
pump();
};
const retry = (id: string) => {
let target: QueueItem | undefined;
setItems((prev) => {
target = prev.find((it) => it.id === id);
return prev.map((it) =>
it.id === id ? { ...it, status: 'queued', error: undefined } : it,
);
});
if (target) {
pending.current.push({ ...target, status: 'queued', error: undefined });
pump();
}
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(Array.from(e.target.files));
e.target.value = ''; // allow re-selecting the same file
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files) addFiles(Array.from(e.dataTransfer.files));
};
const completed = items.filter(
(it) => it.status === 'done' || it.status === 'duplicate',
).length;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}}
>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
{t('upload.title')}
</h2>
</div>
<ScrollArea style={{ flex: 1 }}>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<div
onClick={() => inputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
style={{
border: `2px dashed ${
dragging ? 'var(--color-accent)' : 'var(--color-border)'
}`,
borderRadius: 10,
padding: '2.5rem 1.5rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
cursor: 'pointer',
background: dragging ? 'var(--color-surface-2)' : 'transparent',
transition: 'background 120ms, border-color 120ms',
}}
>
<div style={{ fontSize: '2rem' }}></div>
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
{t('upload.dropzone.hint')}
</div>
<Button variant="primary" size="sm" type="button">
{t('upload.dropzone.button')}
</Button>
<input
ref={inputRef}
type="file"
multiple
accept="audio/*"
onChange={onInputChange}
style={{ display: 'none' }}
/>
</div>
<Callout variant="info">{t('upload.metadataPending')}</Callout>
{items.length > 0 && (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
{t('upload.queueTitle', {
completed,
total: items.length,
})}
</span>
{completed > 0 && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={() =>
setItems((prev) =>
prev.filter(
(it) =>
it.status !== 'done' && it.status !== 'duplicate',
),
)
}
>
{t('upload.clearCompleted')}
</Button>
)}
</div>
{items.map((item) => (
<UploadRow
key={item.id}
item={item}
onRetry={() => retry(item.id)}
onEditMetadata={() =>
void navigate(`/tracks/${item.trackId}/metadata`)
}
/>
))}
</div>
)}
</div>
</ScrollArea>
</div>
);
}
function UploadRow({
item,
onRetry,
onEditMetadata,
}: {
item: QueueItem;
onRetry: () => void;
onEditMetadata: () => void;
}) {
const { t } = useTranslation();
const done = item.status === 'done' || item.status === 'duplicate';
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)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={item.file.name}
>
{item.file.name}
</div>
{item.status === 'error' && item.error && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{item.error}
</div>
)}
{done && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{t('upload.unknownArtist')}
</div>
)}
</div>
<StatusBadge status={item.status} />
{item.status === 'error' && (
<Button variant="ghost" size="sm" type="button" onClick={onRetry}>
{t('upload.retry')}
</Button>
)}
{done && item.trackId && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={onEditMetadata}
>
{t('upload.editMetadata')}
</Button>
)}
</div>
);
}
function StatusBadge({ status }: { status: ItemStatus }) {
const { t } = useTranslation();
if (status === 'uploading') {
return (
<Badge variant="neutral">
<Spinner size="sm" /> {t('upload.status.uploading')}
</Badge>
);
}
const cfg: Record<
Exclude<ItemStatus, 'uploading'>,
{ variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string }
> = {
queued: { variant: 'neutral', key: 'upload.status.queued' },
done: { variant: 'lime', key: 'upload.status.done' },
duplicate: { variant: 'outline', key: 'upload.status.duplicate' },
error: { variant: 'ember', key: 'upload.status.error' },
};
const { variant, key } = cfg[status];
return (
<Badge variant={variant} dot>
{t(key)}
</Badge>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useAppSelector } from './useAppDispatch';
type Permission =
export type Permission =
| 'download'
| 'upload'
| 'admin'
+37
View File
@@ -0,0 +1,37 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en';
import ru from './locales/ru';
const STORAGE_KEY = 'mcma_lang';
function detectLanguage(): string {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return stored;
const browser = navigator.language.split('-')[0];
return browser === 'ru' ? 'ru' : 'en';
}
export function setLanguage(lang: string): void {
localStorage.setItem(STORAGE_KEY, lang);
void i18n.changeLanguage(lang);
}
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'ru', label: 'Русский' },
] as const;
void i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
ru: { translation: ru },
},
lng: detectLanguage(),
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
export default i18n;
+211
View File
@@ -0,0 +1,211 @@
const en = {
nav: {
library: 'Library',
search: 'Search & download',
downloads: 'Downloads',
upload: 'Upload',
storage: 'Storage',
playlists: 'Playlists',
newPlaylist: 'New playlist',
admin: 'Admin',
settings: 'Settings',
administration: 'Administration',
},
conn: {
connected: 'Connected',
connecting: 'Connecting…',
disconnected: 'Offline',
error: 'Unreachable',
manage: 'Connection — manage instances',
},
user: {
online: 'online',
offline: 'offline',
signOut: 'Sign out',
},
connect: {
savedInstances: 'Saved instances',
active: 'active',
use: 'Use',
forgetTitle: 'Forget this instance',
form: {
title: 'Connect to a backend',
serverUrl: 'Server URL',
username: 'Username',
password: 'Password',
submit: 'Connect',
stubNote:
'Stub mode — backend not wired. Connect signs in with a fake admin session, scoped to this instance.',
},
},
library: {
title: 'Library',
searchPlaceholder: 'Search library…',
tabs: {
tracks: 'Tracks',
albums: 'Albums',
artists: 'Artists',
},
playAll: '▶ Play all ({{count}})',
empty: {
tracks: {
title: 'No tracks',
description: 'Your library is empty. Start by downloading some music.',
},
albums: {
title: 'No albums',
description: 'No albums in library.',
},
artists: {
title: 'No artists',
description: 'No artists in library.',
},
},
albumCard: {
tracks: '{{count}} tracks',
tracksDuration: '{{count}} tracks · {{duration}}',
},
artistRow: {
meta: '{{albumCount}} albums · {{trackCount}} tracks',
},
},
album: {
type: 'Album',
play: '▶ Play',
error: 'Failed to load album',
tracksError: 'Failed to load tracks',
empty: {
title: 'No tracks',
description: 'This album has no tracks.',
},
},
playlist: {
type: 'Playlist',
play: '▶ Play',
error: 'Failed to load playlist',
tracksError: 'Failed to load tracks',
empty: {
title: 'Empty playlist',
description: 'This playlist has no tracks yet.',
},
},
player: {
nothingPlaying: 'Nothing playing',
shuffle: 'Shuffle',
previous: 'Previous',
next: 'Next',
pause: 'Pause',
play: 'Play',
repeat: 'Repeat: {{mode}}',
streaming: 'Streaming · 320 kbps',
local: 'Local · FLAC',
queue: 'Play queue',
mute: 'Mute',
unmute: 'Unmute',
},
queue: {
title: 'Play queue',
clear: 'Clear queue',
close: 'Close',
from: 'From {{source}}',
radio: 'Radio · {{source}}',
nowPlaying: 'Now playing',
nextUp: 'Next up',
nothingNext: 'Nothing queued next',
empty: 'Queue is empty',
radioActive: 'Radio active',
mixing: '∞ mixing',
familiar: 'Familiar',
new: 'New',
loadingMore: 'Loading more from radio…',
doubleClickPlay: 'Double-click to play',
removeFromQueue: 'Remove from queue',
},
track: {
menu: {
options: 'Track options',
playNow: 'Play now',
playNext: 'Play next',
addToQueue: 'Add to queue',
addToPlaylist: 'Add to playlist…',
editMetadata: 'Edit metadata',
download: 'Download',
delete: 'Delete',
},
},
common: {
error: 'Something went wrong',
retry: 'Retry',
comingSoon: 'Coming soon',
back: 'Back',
},
pages: {
admin: 'Admin',
settings: 'Settings',
downloads: 'Downloads',
search: 'Search & Download',
storage: 'Storage',
login: 'Sign in',
artist: 'Artist',
playlists: 'Playlists',
upload: 'Upload files',
metadata: 'Edit metadata',
metadataBatch: 'Edit metadata (batch)',
storageMaintenance: 'Storage maintenance',
queue: 'Play queue',
},
settings: {
language: 'Language',
theme: 'Theme',
themeDark: 'Dark',
themeLight: 'Light',
tabs: {
profile: 'Profile',
playback: 'Playback',
scrobbling: 'Scrobbling',
instance: 'Instance',
},
},
admin: {
userDetail: 'User',
tabs: {
users: 'Users',
sources: 'Sources',
instance: 'Instance',
},
},
notFound: {
title: 'Page not found',
description: "This screen doesn't exist yet.",
backToLibrary: 'Back to library',
},
upload: {
title: 'Upload files',
dropzone: {
title: 'Drag & drop audio files here',
hint: 'or click to choose files — one or many at a time',
button: 'Choose files',
},
queueTitle: 'Uploads ({{completed}}/{{total}})',
clearCompleted: 'Clear completed',
retry: 'Retry',
editMetadata: 'Edit metadata',
metadataPending:
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
unknownArtist: 'Unknown Artist · metadata pending',
status: {
queued: 'Queued',
uploading: 'Uploading',
done: 'Uploaded',
duplicate: 'Already in library',
error: 'Failed',
},
},
} as const;
export default en;
type DeepString<T> = {
[K in keyof T]: T[K] extends Record<string, unknown> ? DeepString<T[K]> : string;
};
export type Translations = DeepString<typeof en>;
+208
View File
@@ -0,0 +1,208 @@
import type { Translations } from './en';
const ru: Translations = {
nav: {
library: 'Библиотека',
search: 'Поиск и загрузка',
downloads: 'Загрузки',
upload: 'Загрузить',
storage: 'Хранилище',
playlists: 'Плейлисты',
newPlaylist: 'Новый плейлист',
admin: 'Администрирование',
settings: 'Настройки',
administration: 'Администрирование',
},
conn: {
connected: 'Подключено',
connecting: 'Подключение…',
disconnected: 'Нет связи',
error: 'Недоступно',
manage: 'Соединение — управление экземплярами',
},
user: {
online: 'онлайн',
offline: 'офлайн',
signOut: 'Выйти',
},
connect: {
savedInstances: 'Сохранённые серверы',
active: 'активный',
use: 'Выбрать',
forgetTitle: 'Забыть этот сервер',
form: {
title: 'Подключиться к серверу',
serverUrl: 'URL сервера',
username: 'Имя пользователя',
password: 'Пароль',
submit: 'Подключиться',
stubNote:
'Режим заглушки — сервер не подключён. Создаётся фиктивная сессия администратора для этого экземпляра.',
},
},
library: {
title: 'Библиотека',
searchPlaceholder: 'Поиск в библиотеке…',
tabs: {
tracks: 'Треки',
albums: 'Альбомы',
artists: 'Исполнители',
},
playAll: '▶ Воспроизвести все ({{count}})',
empty: {
tracks: {
title: 'Нет треков',
description: 'Библиотека пуста. Начните с загрузки музыки.',
},
albums: {
title: 'Нет альбомов',
description: 'В библиотеке нет альбомов.',
},
artists: {
title: 'Нет исполнителей',
description: 'В библиотеке нет исполнителей.',
},
},
albumCard: {
tracks: '{{count}} треков',
tracksDuration: '{{count}} треков · {{duration}}',
},
artistRow: {
meta: '{{albumCount}} альб. · {{trackCount}} треков',
},
},
album: {
type: 'Альбом',
play: '▶ Слушать',
error: 'Не удалось загрузить альбом',
tracksError: 'Не удалось загрузить треки',
empty: {
title: 'Нет треков',
description: 'В этом альбоме нет треков.',
},
},
playlist: {
type: 'Плейлист',
play: '▶ Слушать',
error: 'Не удалось загрузить плейлист',
tracksError: 'Не удалось загрузить треки',
empty: {
title: 'Плейлист пуст',
description: 'В этом плейлисте пока нет треков.',
},
},
player: {
nothingPlaying: 'Ничего не играет',
shuffle: 'Перемешать',
previous: 'Назад',
next: 'Вперёд',
pause: 'Пауза',
play: 'Воспроизвести',
repeat: 'Повтор: {{mode}}',
streaming: 'Стриминг · 320 kbps',
local: 'Локально · FLAC',
queue: 'Очередь',
mute: 'Выключить звук',
unmute: 'Включить звук',
},
queue: {
title: 'Очередь воспроизведения',
clear: 'Очистить очередь',
close: 'Закрыть',
from: 'Из: {{source}}',
radio: 'Радио · {{source}}',
nowPlaying: 'Сейчас играет',
nextUp: 'Далее',
nothingNext: 'Очередь пуста',
empty: 'Очередь пуста',
radioActive: 'Радио активно',
mixing: '∞ микс',
familiar: 'Знакомое',
new: 'Новое',
loadingMore: 'Загрузка радио…',
doubleClickPlay: 'Двойной клик для воспроизведения',
removeFromQueue: 'Убрать из очереди',
},
track: {
menu: {
options: 'Действия с треком',
playNow: 'Играть сейчас',
playNext: 'Следующим',
addToQueue: 'Добавить в очередь',
addToPlaylist: 'Добавить в плейлист…',
editMetadata: 'Редактировать метаданные',
download: 'Скачать',
delete: 'Удалить',
},
},
common: {
error: 'Что-то пошло не так',
retry: 'Повторить',
comingSoon: 'Скоро',
back: 'Назад',
},
pages: {
admin: 'Администрирование',
settings: 'Настройки',
downloads: 'Загрузки',
search: 'Поиск и загрузка',
storage: 'Хранилище',
login: 'Вход',
artist: 'Артист',
playlists: 'Плейлисты',
upload: 'Загрузка файлов',
metadata: 'Редактирование метаданных',
metadataBatch: 'Редактирование метаданных (массово)',
storageMaintenance: 'Обслуживание хранилища',
queue: 'Очередь воспроизведения',
},
settings: {
language: 'Язык',
theme: 'Тема',
themeDark: 'Тёмная',
themeLight: 'Светлая',
tabs: {
profile: 'Профиль',
playback: 'Воспроизведение',
scrobbling: 'Скробблинг',
instance: 'Сервер',
},
},
admin: {
userDetail: 'Пользователь',
tabs: {
users: 'Пользователи',
sources: 'Источники',
instance: 'Сервер',
},
},
notFound: {
title: 'Страница не найдена',
description: 'Этого экрана пока нет.',
backToLibrary: 'Вернуться в библиотеку',
},
upload: {
title: 'Загрузка файлов',
dropzone: {
title: 'Перетащите аудиофайлы сюда',
hint: 'или нажмите, чтобы выбрать файлы — по одному или сразу несколько',
button: 'Выбрать файлы',
},
queueTitle: 'Загрузки ({{completed}}/{{total}})',
clearCompleted: 'Убрать завершённые',
retry: 'Повторить',
editMetadata: 'Изменить метаданные',
metadataPending:
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
unknownArtist: 'Unknown Artist · метаданные в ожидании',
status: {
queued: 'В очереди',
uploading: 'Загрузка',
done: 'Загружено',
duplicate: 'Уже в библиотеке',
error: 'Ошибка',
},
},
};
export default ru;
+4 -3
View File
@@ -1,12 +1,13 @@
import 'modern-sk/styles.css';
import 'modern-sk/fonts.css';
import '@olly/modern-sk/styles.css';
import '@olly/modern-sk/fonts.css';
import './styles/global.css';
import './styles/shell.css';
import './i18n';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router';
import { ThemeProvider, TooltipProvider } from 'modern-sk';
import { ThemeProvider, TooltipProvider } from '@olly/modern-sk';
import { store } from './store';
import { AppRoutes } from './routes';
+13 -1
View File
@@ -1,13 +1,21 @@
import { Navigate } from 'react-router';
import { useAppSelector } from '../hooks/useAppDispatch';
import { usePermissions, type Permission } from '../hooks/usePermissions';
interface Props {
children: React.ReactNode;
requireAdmin?: boolean;
/** Gate the route on a granular permission (e.g. download, upload). */
requirePermission?: Permission;
}
export function ProtectedRoute({ children, requireAdmin = false }: Props) {
export function ProtectedRoute({
children,
requireAdmin = false,
requirePermission,
}: Props) {
const auth = useAppSelector((s) => s.auth);
const { hasPermission } = usePermissions();
if (!auth.accessToken || !auth.user) {
return <Navigate to="/connect" replace />;
@@ -17,5 +25,9 @@ export function ProtectedRoute({ children, requireAdmin = false }: Props) {
return <Navigate to="/library" replace />;
}
if (requirePermission && !hasPermission(requirePermission)) {
return <Navigate to="/library" replace />;
}
return <>{children}</>;
}
+109 -51
View File
@@ -1,14 +1,38 @@
import { lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router';
import { AppShell } from '../components/layout/AppShell';
import { ProtectedRoute } from './ProtectedRoute';
// Public (outside the shell)
import { ConnectPage } from '../features/connect/ConnectPage';
import { HomePage } from '../features/home/HomePage';
import { LoginPage } from '../features/auth/LoginPage';
// Core screens (eager — first paint inside the shell)
import { LibraryPage } from '../features/library/LibraryPage';
import { AlbumDetailPage } from '../features/album-detail/AlbumDetailPage';
import { ArtistDetailPage } from '../features/artist-detail/ArtistDetailPage';
import { PlaylistsPage } from '../features/playlists/PlaylistsPage';
import { PlaylistDetailPage } from '../features/playlist-detail/PlaylistDetailPage';
import { lazy, Suspense } from 'react';
import { LoadingSkeleton } from '../components/common/LoadingSkeleton';
// Settings / Admin layouts + panels (small, eager)
import { SettingsPage } from '../features/settings/SettingsPage';
import {
ProfileSettings,
PlaybackSettings,
ScrobblingSettings,
InstanceSettings,
} from '../features/settings/panels';
import { AdminPage } from '../features/admin/AdminPage';
import {
AdminUsers,
AdminUserDetail,
AdminSources,
AdminInstance,
} from '../features/admin/panels';
import { NotFoundPage } from '../features/not-found/NotFoundPage';
// Secondary screens — lazily loaded (Suspense boundary lives in AppShell)
const SearchDownloadPage = lazy(() =>
import('../features/search-download/SearchDownloadPage').then((m) => ({
default: m.SearchDownloadPage,
@@ -19,30 +43,36 @@ const DownloadsManagerPage = lazy(() =>
default: m.DownloadsManagerPage,
})),
);
const UploadPage = lazy(() =>
import('../features/upload/UploadPage').then((m) => ({ default: m.UploadPage })),
);
const MetadataEditorPage = lazy(() =>
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
default: m.MetadataEditorPage,
})),
);
const StoragePage = lazy(() =>
import('../features/storage/StoragePage').then((m) => ({
default: m.StoragePage,
})),
);
const AdminPage = lazy(() =>
import('../features/admin/AdminPage').then((m) => ({ default: m.AdminPage })),
);
const SettingsPage = lazy(() =>
import('../features/settings/SettingsPage').then((m) => ({
default: m.SettingsPage,
const StorageMaintenancePage = lazy(() =>
import('../features/storage/StorageMaintenancePage').then((m) => ({
default: m.StorageMaintenancePage,
})),
);
const Fallback = () => (
<div style={{ padding: '2rem' }}>
<LoadingSkeleton />
</div>
const QueuePage = lazy(() =>
import('../features/queue/QueuePage').then((m) => ({ default: m.QueuePage })),
);
export function AppRoutes() {
return (
<Routes>
{/* Public */}
<Route path="/connect" element={<ConnectPage />} />
<Route path="/login" element={<LoginPage />} />
{/* Authenticated shell */}
<Route
element={
<ProtectedRoute>
@@ -50,57 +80,85 @@ export function AppRoutes() {
</ProtectedRoute>
}
>
<Route index element={<HomePage />} />
<Route index element={<Navigate to="/library" replace />} />
{/* Library */}
<Route path="/library" element={<LibraryPage />} />
<Route path="/library/albums/:albumId" element={<AlbumDetailPage />} />
<Route path="/albums/:albumId" element={<AlbumDetailPage />} />
<Route path="/artists/:artistId" element={<ArtistDetailPage />} />
{/* Playlists */}
<Route path="/playlists" element={<PlaylistsPage />} />
<Route path="/playlists/:playlistId" element={<PlaylistDetailPage />} />
{/* Discover & downloads (permission-gated) */}
<Route
path="/library/playlists/:playlistId"
element={<PlaylistDetailPage />}
/>
<Route
path="/search"
path="/discover"
element={
<Suspense fallback={<Fallback />}>
<ProtectedRoute requirePermission="download">
<SearchDownloadPage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/downloads"
element={
<Suspense fallback={<Fallback />}>
<ProtectedRoute requirePermission="download">
<DownloadsManagerPage />
</Suspense>
}
/>
<Route
path="/storage"
element={
<Suspense fallback={<Fallback />}>
<StoragePage />
</Suspense>
}
/>
<Route
path="/settings"
element={
<Suspense fallback={<Fallback />}>
<SettingsPage />
</Suspense>
}
/>
<Route
path="/admin/*"
element={
<ProtectedRoute requireAdmin>
<Suspense fallback={<Fallback />}>
<AdminPage />
</Suspense>
</ProtectedRoute>
}
/>
{/* Upload & metadata */}
<Route
path="/upload"
element={
<ProtectedRoute requirePermission="upload">
<UploadPage />
</ProtectedRoute>
}
/>
<Route
path="/tracks/:trackId/metadata"
element={<MetadataEditorPage />}
/>
<Route path="/metadata/batch" element={<MetadataEditorPage batch />} />
{/* Storage */}
<Route path="/storage" element={<StoragePage />} />
<Route path="/storage/maintenance" element={<StorageMaintenancePage />} />
{/* Queue (narrow viewports) */}
<Route path="/queue" element={<QueuePage />} />
{/* Settings */}
<Route path="/settings" element={<SettingsPage />}>
<Route index element={<Navigate to="/settings/profile" replace />} />
<Route path="profile" element={<ProfileSettings />} />
<Route path="playback" element={<PlaybackSettings />} />
<Route path="scrobbling" element={<ScrobblingSettings />} />
<Route path="instance" element={<InstanceSettings />} />
</Route>
{/* Admin (admin-gated) */}
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<AdminPage />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/admin/users" replace />} />
<Route path="users" element={<AdminUsers />} />
<Route path="users/:userId" element={<AdminUserDetail />} />
<Route path="sources" element={<AdminSources />} />
<Route path="instance" element={<AdminInstance />} />
</Route>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<Navigate to="/library" replace />} />
</Routes>
);
}
+58 -1
View File
@@ -53,12 +53,14 @@
display: flex;
flex-direction: column;
min-height: 0;
overflow-x: hidden;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.22));
border-right: 1px solid var(--hair);
}
.sb-scroll {
flex: 1;
min-height: 0; /* allow scroll inside the column flex so .sb-foot stays pinned */
overflow-x: hidden;
overflow-y: auto;
padding: 14px 12px 6px;
}
@@ -90,6 +92,8 @@
align-items: center;
gap: 11px;
width: 100%;
box-sizing: border-box;
min-width: 0;
padding: 8px 10px;
border-radius: var(--r-md);
font-size: 14px;
@@ -107,6 +111,14 @@
.nav-item .ph {
font-size: 18px;
color: var(--fg-3);
flex-shrink: 0;
}
.nav-item > span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
@@ -150,6 +162,8 @@
align-items: center;
gap: 9px;
width: 100%;
box-sizing: border-box;
min-width: 0;
padding: 6px 10px;
border-radius: var(--r-md);
font-size: 13px;
@@ -263,9 +277,11 @@
/* connection status pill (used in sidebar foot) */
.conn {
display: inline-flex;
display: flex;
align-items: center;
gap: 7px;
width: 100%;
box-sizing: border-box;
padding: 5px 11px 5px 9px;
border-radius: var(--r-pill);
font-size: 12px;
@@ -707,3 +723,44 @@
color: var(--fg-3);
font-size: 12px;
}
/* ============================================================
PAGE HEADER + SECONDARY NAV (Settings, Admin)
============================================================ */
.page-title {
margin: 0;
font-family: var(--font-display, var(--font-sans));
font-size: 22px;
font-weight: 700;
letter-spacing: var(--track-snug);
color: var(--fg-1);
}
.sub-nav {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--hair);
}
.sub-nav-item {
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
color: var(--fg-2);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition:
color 0.12s ease,
border-color 0.12s ease;
}
.sub-nav-item:hover {
color: var(--fg-1);
}
.sub-nav-item.active {
color: var(--fg-1);
border-bottom-color: var(--lime);
}
/* Sidebar section header that doubles as a link (Playlists) */
.sb-sec-link.active {
color: var(--fg-1);
}