-
- {t('common.comingSoon')}
-
+
+
{t('pages.admin')}
+
+
);
}
diff --git a/src/features/admin/panels.tsx b/src/features/admin/panels.tsx
new file mode 100644
index 0000000..055dd52
--- /dev/null
+++ b/src/features/admin/panels.tsx
@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next';
+import { Window } from '@olly/modern-sk';
+
+function StubPanel({ title }: { title: string }) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t('common.comingSoon')}
+
+
+ );
+}
+
+/** `/admin/users` — user list (add/remove). Scaffold. */
+export function AdminUsers() {
+ const { t } = useTranslation();
+ return
;
+}
+
+/** `/admin/users/:userId` — per-user permissions / reset password / status. Scaffold. */
+export function AdminUserDetail() {
+ const { t } = useTranslation();
+ return
;
+}
+
+/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
+export function AdminSources() {
+ const { t } = useTranslation();
+ return
;
+}
+
+/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */
+export function AdminInstance() {
+ const { t } = useTranslation();
+ return
;
+}
diff --git a/src/features/artist-detail/ArtistDetailPage.tsx b/src/features/artist-detail/ArtistDetailPage.tsx
new file mode 100644
index 0000000..5b4e4c4
--- /dev/null
+++ b/src/features/artist-detail/ArtistDetailPage.tsx
@@ -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
;
+}
diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx
new file mode 100644
index 0000000..7d15275
--- /dev/null
+++ b/src/features/auth/LoginPage.tsx
@@ -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
;
+}
diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx
deleted file mode 100644
index 5d9a35e..0000000
--- a/src/features/home/HomePage.tsx
+++ /dev/null
@@ -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 (
-
- {title}
- {children}
-
- );
-}
-
-export function HomePage() {
- const { theme, setTheme } = useTheme();
- const [search, setSearch] = useState('');
- const [select, setSelect] = useState
();
- 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 (
-
-
-
-
-
- ♫ MCMA — Component Kitchen Sink
-
-
- modern-sk reference. Project base ready for development.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ▶
-
-
- ⏭
-
-
- ⏹
-
- setCount((c) => c - 1)}
- onIncrement={() => setCount((c) => c + 1)}
- />
-
- count: {count}
-
-
-
-
-
-
-
- setSearch(e.target.value)}
- placeholder="Search…"
- style={{ width: '14rem' }}
- />
-
-
-
-
-
-
-
-
- }
- >
- Switch
-
- }>Checkbox
-
-
- }>Option A
- }>Option B
- }>Option C
-
-
-
-
-
-
-
- value: {vol[0]}
-
-
-
-
-
-
-
- On server
-
-
- Error
-
- Neutral
- Outline
-
-
-
-
- {chips.map((c) => (
- setChips((prev) => prev.filter((x) => x !== c))}
- >
- {c}
-
- ))}
- {chips.length === 0 && (
-
- all removed
-
- )}
-
-
-
-
-
- Info — backend address resolves from runtime → env → relative
- /api/v1.
-
-
- Success — typecheck and lint pass clean.
-
-
- Warning — most feature screens are still stubs.
-
-
- Danger — destructive actions use AlertDialog.
-
-
-
-
-
-
-
- First panel
-
-
- Second panel
-
-
- Third panel
-
-
-
-
-
-
- Track one — Artist
-
- Track two — Artist (selected)
-
-
- Track three — Artist
-
-
-
-
-
-
-
-
- | Title |
- Artist |
- Duration |
-
-
-
-
- | Intro |
- Aphex |
- 2:14 |
-
-
- | Windowlicker |
- Aphex |
- 6:07 |
-
-
- | Avril 14th |
- Aphex |
- 2:01 |
-
-
-
-
-
-
-
-
-
-
-
-
Delete…}
- title="Delete track?"
- description="This permanently removes the file from the server."
- actionLabel="Delete"
- destructive
- onAction={() => undefined}
- />
-
-
-
-
-
- live
-
- }
- >
-
- Window chrome for grouped content.
-
-
-
-
-
- );
-}
diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx
index b4a21ca..7d81896 100644
--- a/src/features/library/LibraryPage.tsx
+++ b/src/features/library/LibraryPage.tsx
@@ -185,7 +185,7 @@ export function LibraryPage() {
void navigate(`/library/albums/${album.id}`)}
+ onClick={() => void navigate(`/albums/${album.id}`)}
/>
))}
diff --git a/src/features/metadata-editor/MetadataEditorPage.tsx b/src/features/metadata-editor/MetadataEditorPage.tsx
new file mode 100644
index 0000000..3b70ef5
--- /dev/null
+++ b/src/features/metadata-editor/MetadataEditorPage.tsx
@@ -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 (
+
-
-
-
-
- Language
-
- ({ value: l.code, label: l.label }))}
- />
-
-
-
+
+
{t('pages.settings')}
+
+
);
}
diff --git a/src/features/settings/panels.tsx b/src/features/settings/panels.tsx
new file mode 100644
index 0000000..ba78df6
--- /dev/null
+++ b/src/features/settings/panels.tsx
@@ -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 (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+/** `/settings/profile` — profile + app language + theme (all wired). */
+export function ProfileSettings() {
+ const { t, i18n } = useTranslation();
+ const { theme, setTheme } = useTheme();
+ return (
+
+
+
+ ({
+ value: l.code,
+ label: l.label,
+ }))}
+ />
+
+
+ setTheme(v === 'light' ? 'light' : 'dark')}
+ items={[
+ { value: 'dark', label: t('settings.themeDark') },
+ { value: 'light', label: t('settings.themeLight') },
+ ]}
+ />
+
+
+
+ );
+}
+
+/** `/settings/playback` — default stream quality / playback behaviour. Scaffold. */
+export function PlaybackSettings() {
+ const { t } = useTranslation();
+ return (
+
+
+ {t('common.comingSoon')}
+
+
+ );
+}
+
+/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */
+export function ScrobblingSettings() {
+ const { t } = useTranslation();
+ return (
+
+
+ {t('common.comingSoon')}
+
+
+ );
+}
+
+/** `/settings/instance` — switch/forget instance. Scaffold. */
+export function InstanceSettings() {
+ const { t } = useTranslation();
+ return (
+
+
+ {t('common.comingSoon')}
+
+
+ );
+}
diff --git a/src/features/storage/StorageMaintenancePage.tsx b/src/features/storage/StorageMaintenancePage.tsx
new file mode 100644
index 0000000..a926d1d
--- /dev/null
+++ b/src/features/storage/StorageMaintenancePage.tsx
@@ -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
;
+}
diff --git a/src/features/upload/UploadPage.tsx b/src/features/upload/UploadPage.tsx
new file mode 100644
index 0000000..bd86225
--- /dev/null
+++ b/src/features/upload/UploadPage.tsx
@@ -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
;
+}
diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts
index 50439b1..d85fb46 100644
--- a/src/hooks/usePermissions.ts
+++ b/src/hooks/usePermissions.ts
@@ -1,6 +1,6 @@
import { useAppSelector } from './useAppDispatch';
-type Permission =
+export type Permission =
| 'download'
| 'upload'
| 'admin'
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 4b70cae..de8c741 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -1,9 +1,9 @@
const en = {
nav: {
- home: 'Home',
library: 'Library',
search: 'Search & download',
downloads: 'Downloads',
+ upload: 'Upload',
storage: 'Storage',
playlists: 'Playlists',
newPlaylist: 'New playlist',
@@ -145,6 +145,39 @@ const en = {
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',
},
} as const;
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index e9e640d..dfc7250 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -2,10 +2,10 @@ import type { Translations } from './en';
const ru: Translations = {
nav: {
- home: 'Главная',
library: 'Библиотека',
search: 'Поиск и загрузка',
downloads: 'Загрузки',
+ upload: 'Загрузить',
storage: 'Хранилище',
playlists: 'Плейлисты',
newPlaylist: 'Новый плейлист',
@@ -147,6 +147,39 @@ const ru: Translations = {
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: 'Вернуться в библиотеку',
},
};
diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx
index 5bbd7a0..8b81fb5 100644
--- a/src/routes/ProtectedRoute.tsx
+++ b/src/routes/ProtectedRoute.tsx
@@ -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
;
@@ -17,5 +25,9 @@ export function ProtectedRoute({ children, requireAdmin = false }: Props) {
return
;
}
+ if (requirePermission && !hasPermission(requirePermission)) {
+ return
;
+ }
+
return <>{children}>;
}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 63bbc91..cfa0c62 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -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 = () => (
-
-
-
+const QueuePage = lazy(() =>
+ import('../features/queue/QueuePage').then((m) => ({ default: m.QueuePage })),
);
export function AppRoutes() {
return (
+ {/* Public */}
} />
+ } />
+
+ {/* Authenticated shell */}
@@ -50,57 +80,85 @@ export function AppRoutes() {
}
>
- } />
+ } />
+
+ {/* Library */}
} />
- } />
+ } />
+ } />
+
+ {/* Playlists */}
+ } />
+ } />
+
+ {/* Discover & downloads (permission-gated) */}
}
- />
- }>
+
-
+
}
/>
}>
+
-
- }
- />
- }>
-
-
- }
- />
- }>
-
-
- }
- />
-
- }>
-
-
}
/>
+
+ {/* Upload & metadata */}
+
+
+
+ }
+ />
+ }
+ />
+ } />
+
+ {/* Storage */}
+ } />
+ } />
+
+ {/* Queue (narrow viewports) */}
+ } />
+
+ {/* Settings */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Admin (admin-gated) */}
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* 404 */}
+ } />
- } />
);
}
diff --git a/src/styles/shell.css b/src/styles/shell.css
index 4719ef9..1215e13 100644
--- a/src/styles/shell.css
+++ b/src/styles/shell.css
@@ -723,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);
+}