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:
@@ -1,13 +1,32 @@
|
||||
import { Outlet } from 'react-router';
|
||||
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() {
|
||||
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={t('pages.admin')}>
|
||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
key={album.id}
|
||||
album={album}
|
||||
onClick={() => void navigate(`/library/albums/${album.id}`)}
|
||||
onClick={() => void navigate(`/albums/${album.id}`)}
|
||||
/>
|
||||
))}
|
||||
</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 { Window, SegmentedControl } from '@olly/modern-sk';
|
||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||
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, 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 (
|
||||
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<Window title={t('pages.settings')}>
|
||||
<div style={{ padding: '0.75rem 0', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)', minWidth: '6rem' }}>
|
||||
Language
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={i18n.language}
|
||||
onValueChange={setLanguage}
|
||||
items={SUPPORTED_LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')} />;
|
||||
}
|
||||
Reference in New Issue
Block a user