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>
This commit is contained in:
@@ -19,7 +19,6 @@ import {
|
|||||||
GearSix,
|
GearSix,
|
||||||
HardDrives,
|
HardDrives,
|
||||||
Heart,
|
Heart,
|
||||||
House,
|
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
@@ -48,7 +47,6 @@ import {
|
|||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
'vinyl-record': VinylRecord,
|
'vinyl-record': VinylRecord,
|
||||||
house: House,
|
|
||||||
'magnifying-glass': MagnifyingGlass,
|
'magnifying-glass': MagnifyingGlass,
|
||||||
'arrow-circle-down': ArrowCircleDown,
|
'arrow-circle-down': ArrowCircleDown,
|
||||||
'upload-simple': UploadSimple,
|
'upload-simple': UploadSimple,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Outlet } from 'react-router';
|
import { Outlet } from 'react-router';
|
||||||
|
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 { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
return (
|
return (
|
||||||
@@ -17,7 +19,15 @@ export function AppShell() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<div className="app-screen">
|
<div className="app-screen">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div style={{ padding: '2rem' }}>
|
||||||
|
<LoadingSkeleton />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<QueuePanel />
|
<QueuePanel />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NavLink, useNavigate } from 'react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Icon, type IconName } from '../common/Icon';
|
import { Icon, type IconName } from '../common/Icon';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
import { usePermissions, type Permission } from '../../hooks/usePermissions';
|
||||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||||
import { logout } from '../../store/slices/auth';
|
import { logout } from '../../store/slices/auth';
|
||||||
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
||||||
@@ -13,13 +13,15 @@ interface NavDef {
|
|||||||
labelKey: string;
|
labelKey: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
end?: boolean;
|
end?: boolean;
|
||||||
|
/** Hide this item unless the user holds the permission. */
|
||||||
|
perm?: Permission;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAIN_NAV: NavDef[] = [
|
const MAIN_NAV: NavDef[] = [
|
||||||
{ to: '/', labelKey: 'nav.home', icon: 'house', end: true },
|
|
||||||
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
||||||
{ to: '/search', labelKey: 'nav.search', icon: 'magnifying-glass' },
|
{ to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' },
|
||||||
{ to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down' },
|
{ 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' },
|
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ export function Sidebar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAdmin } = usePermissions();
|
const { user, isAdmin, hasPermission } = usePermissions();
|
||||||
const status = useConnectionStatus();
|
const status = useConnectionStatus();
|
||||||
const { data: playlists } = useGetPlaylistsQuery();
|
const { data: playlists } = useGetPlaylistsQuery();
|
||||||
const instance = getActiveInstance();
|
const instance = getActiveInstance();
|
||||||
@@ -61,20 +63,24 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb-sec">
|
<div className="sb-sec">
|
||||||
{MAIN_NAV.map(({ to, labelKey, icon, end }) => (
|
{MAIN_NAV.filter((n) => !n.perm || hasPermission(n.perm)).map(
|
||||||
|
({ to, labelKey, icon, end }) => (
|
||||||
<NavLink key={to} to={to} end={end} className={navClass}>
|
<NavLink key={to} to={to} end={end} className={navClass}>
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
<span>{t(labelKey)}</span>
|
<span>{t(labelKey)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb-sec">
|
<div className="sb-sec">
|
||||||
<span className="msk-label">{t('nav.playlists')}</span>
|
<NavLink to="/playlists" end className="msk-label sb-sec-link">
|
||||||
|
{t('nav.playlists')}
|
||||||
|
</NavLink>
|
||||||
{(playlists?.items ?? []).map((pl) => (
|
{(playlists?.items ?? []).map((pl) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={pl.id}
|
key={pl.id}
|
||||||
to={`/library/playlists/${pl.id}`}
|
to={`/playlists/${pl.id}`}
|
||||||
className={navClass}
|
className={navClass}
|
||||||
>
|
>
|
||||||
<Icon name="playlist" />
|
<Icon name="playlist" />
|
||||||
@@ -84,7 +90,7 @@ export function Sidebar() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pl-item"
|
className="pl-item"
|
||||||
onClick={() => void navigate('/library')}
|
onClick={() => void navigate('/playlists')}
|
||||||
>
|
>
|
||||||
<Icon name="plus" />
|
<Icon name="plus" />
|
||||||
<span className="pl-name">{t('nav.newPlaylist')}</span>
|
<span className="pl-name">{t('nav.newPlaylist')}</span>
|
||||||
@@ -138,7 +144,11 @@ export function Sidebar() {
|
|||||||
{user.role} · {online ? t('user.online') : t('user.offline')}
|
{user.role} · {online ? t('user.online') : t('user.offline')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="uc-action" onClick={handleLogout} title={t('user.signOut')}>
|
<span
|
||||||
|
className="uc-action"
|
||||||
|
onClick={handleLogout}
|
||||||
|
title={t('user.signOut')}
|
||||||
|
>
|
||||||
<Icon name="sign-out" />
|
<Icon name="sign-out" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
|
import { Outlet } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window } from '@olly/modern-sk';
|
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() {
|
export function AdminPage() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div
|
||||||
<Window title={t('pages.admin')}>
|
style={{
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
padding: '1.5rem',
|
||||||
</Window>
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="page-title">{t('pages.admin')}</h1>
|
||||||
|
<SubNav items={items} />
|
||||||
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -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 '@olly/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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -185,7 +185,7 @@ export function LibraryPage() {
|
|||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
onClick={() => void navigate(`/library/albums/${album.id}`)}
|
onClick={() => void navigate(`/albums/${album.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</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')} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -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,26 +1,34 @@
|
|||||||
|
import { Outlet } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window, SegmentedControl } from '@olly/modern-sk';
|
import { SubNav, type SubNavItem } from '../../components/common/SubNav';
|
||||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/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() {
|
export function SettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
<div
|
||||||
<Window title={t('pages.settings')}>
|
style={{
|
||||||
<div style={{ padding: '0.75rem 0', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
padding: '1.5rem',
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
display: 'flex',
|
||||||
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)', minWidth: '6rem' }}>
|
flexDirection: 'column',
|
||||||
Language
|
gap: '1.25rem',
|
||||||
</span>
|
}}
|
||||||
<SegmentedControl
|
>
|
||||||
value={i18n.language}
|
<h1 className="page-title">{t('pages.settings')}</h1>
|
||||||
onValueChange={setLanguage}
|
<SubNav items={items} />
|
||||||
items={SUPPORTED_LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
|
<Outlet />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Window>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Placeholder } from '../../components/common/Placeholder';
|
||||||
|
|
||||||
|
/** `/upload` — A8 drag-and-drop upload of own files. Scaffold only. */
|
||||||
|
export function UploadPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <Placeholder title={t('pages.upload')} />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useAppSelector } from './useAppDispatch';
|
import { useAppSelector } from './useAppDispatch';
|
||||||
|
|
||||||
type Permission =
|
export type Permission =
|
||||||
| 'download'
|
| 'download'
|
||||||
| 'upload'
|
| 'upload'
|
||||||
| 'admin'
|
| 'admin'
|
||||||
|
|||||||
+34
-1
@@ -1,9 +1,9 @@
|
|||||||
const en = {
|
const en = {
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Home',
|
|
||||||
library: 'Library',
|
library: 'Library',
|
||||||
search: 'Search & download',
|
search: 'Search & download',
|
||||||
downloads: 'Downloads',
|
downloads: 'Downloads',
|
||||||
|
upload: 'Upload',
|
||||||
storage: 'Storage',
|
storage: 'Storage',
|
||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
newPlaylist: 'New playlist',
|
newPlaylist: 'New playlist',
|
||||||
@@ -145,6 +145,39 @@ const en = {
|
|||||||
downloads: 'Downloads',
|
downloads: 'Downloads',
|
||||||
search: 'Search & Download',
|
search: 'Search & Download',
|
||||||
storage: 'Storage',
|
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',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -2,10 +2,10 @@ import type { Translations } from './en';
|
|||||||
|
|
||||||
const ru: Translations = {
|
const ru: Translations = {
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Главная',
|
|
||||||
library: 'Библиотека',
|
library: 'Библиотека',
|
||||||
search: 'Поиск и загрузка',
|
search: 'Поиск и загрузка',
|
||||||
downloads: 'Загрузки',
|
downloads: 'Загрузки',
|
||||||
|
upload: 'Загрузить',
|
||||||
storage: 'Хранилище',
|
storage: 'Хранилище',
|
||||||
playlists: 'Плейлисты',
|
playlists: 'Плейлисты',
|
||||||
newPlaylist: 'Новый плейлист',
|
newPlaylist: 'Новый плейлист',
|
||||||
@@ -147,6 +147,39 @@ const ru: Translations = {
|
|||||||
downloads: 'Загрузки',
|
downloads: 'Загрузки',
|
||||||
search: 'Поиск и загрузка',
|
search: 'Поиск и загрузка',
|
||||||
storage: 'Хранилище',
|
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: 'Вернуться в библиотеку',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
import { useAppSelector } from '../hooks/useAppDispatch';
|
import { useAppSelector } from '../hooks/useAppDispatch';
|
||||||
|
import { usePermissions, type Permission } from '../hooks/usePermissions';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
requireAdmin?: boolean;
|
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 auth = useAppSelector((s) => s.auth);
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
if (!auth.accessToken || !auth.user) {
|
if (!auth.accessToken || !auth.user) {
|
||||||
return <Navigate to="/connect" replace />;
|
return <Navigate to="/connect" replace />;
|
||||||
@@ -17,5 +25,9 @@ export function ProtectedRoute({ children, requireAdmin = false }: Props) {
|
|||||||
return <Navigate to="/library" replace />;
|
return <Navigate to="/library" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requirePermission && !hasPermission(requirePermission)) {
|
||||||
|
return <Navigate to="/library" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-51
@@ -1,14 +1,38 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router';
|
import { Routes, Route, Navigate } from 'react-router';
|
||||||
import { AppShell } from '../components/layout/AppShell';
|
import { AppShell } from '../components/layout/AppShell';
|
||||||
import { ProtectedRoute } from './ProtectedRoute';
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
|
||||||
|
// Public (outside the shell)
|
||||||
import { ConnectPage } from '../features/connect/ConnectPage';
|
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 { LibraryPage } from '../features/library/LibraryPage';
|
||||||
import { AlbumDetailPage } from '../features/album-detail/AlbumDetailPage';
|
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 { 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(() =>
|
const SearchDownloadPage = lazy(() =>
|
||||||
import('../features/search-download/SearchDownloadPage').then((m) => ({
|
import('../features/search-download/SearchDownloadPage').then((m) => ({
|
||||||
default: m.SearchDownloadPage,
|
default: m.SearchDownloadPage,
|
||||||
@@ -19,30 +43,36 @@ const DownloadsManagerPage = lazy(() =>
|
|||||||
default: m.DownloadsManagerPage,
|
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(() =>
|
const StoragePage = lazy(() =>
|
||||||
import('../features/storage/StoragePage').then((m) => ({
|
import('../features/storage/StoragePage').then((m) => ({
|
||||||
default: m.StoragePage,
|
default: m.StoragePage,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const AdminPage = lazy(() =>
|
const StorageMaintenancePage = lazy(() =>
|
||||||
import('../features/admin/AdminPage').then((m) => ({ default: m.AdminPage })),
|
import('../features/storage/StorageMaintenancePage').then((m) => ({
|
||||||
);
|
default: m.StorageMaintenancePage,
|
||||||
const SettingsPage = lazy(() =>
|
|
||||||
import('../features/settings/SettingsPage').then((m) => ({
|
|
||||||
default: m.SettingsPage,
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const QueuePage = lazy(() =>
|
||||||
const Fallback = () => (
|
import('../features/queue/QueuePage').then((m) => ({ default: m.QueuePage })),
|
||||||
<div style={{ padding: '2rem' }}>
|
|
||||||
<LoadingSkeleton />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public */}
|
||||||
<Route path="/connect" element={<ConnectPage />} />
|
<Route path="/connect" element={<ConnectPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* Authenticated shell */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
@@ -50,57 +80,85 @@ export function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<Navigate to="/library" replace />} />
|
||||||
|
|
||||||
|
{/* Library */}
|
||||||
<Route path="/library" element={<LibraryPage />} />
|
<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
|
<Route
|
||||||
path="/library/playlists/:playlistId"
|
path="/discover"
|
||||||
element={<PlaylistDetailPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/search"
|
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Fallback />}>
|
<ProtectedRoute requirePermission="download">
|
||||||
<SearchDownloadPage />
|
<SearchDownloadPage />
|
||||||
</Suspense>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/downloads"
|
path="/downloads"
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Fallback />}>
|
<ProtectedRoute requirePermission="download">
|
||||||
<DownloadsManagerPage />
|
<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>
|
</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>
|
||||||
<Route path="*" element={<Navigate to="/library" replace />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -723,3 +723,44 @@
|
|||||||
color: var(--fg-3);
|
color: var(--fg-3);
|
||||||
font-size: 12px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user