From aed0572071b704a81e39839d67ce89bd1017f385 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sun, 7 Jun 2026 17:05:21 +0300 Subject: [PATCH] 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 --- src/components/common/Icon.tsx | 2 - src/components/common/Placeholder.tsx | 31 ++ src/components/common/SubNav.tsx | 29 ++ src/components/layout/AppShell.tsx | 12 +- src/components/layout/Sidebar.tsx | 40 +- src/features/admin/AdminPage.tsx | 29 +- src/features/admin/panels.tsx | 37 ++ .../artist-detail/ArtistDetailPage.tsx | 8 + src/features/auth/LoginPage.tsx | 8 + src/features/home/HomePage.tsx | 444 ------------------ src/features/library/LibraryPage.tsx | 2 +- .../metadata-editor/MetadataEditorPage.tsx | 18 + src/features/not-found/NotFoundPage.tsx | 21 + src/features/playlists/PlaylistsPage.tsx | 8 + src/features/queue/QueuePage.tsx | 11 + src/features/settings/SettingsPage.tsx | 44 +- src/features/settings/panels.tsx | 97 ++++ .../storage/StorageMaintenancePage.tsx | 8 + src/features/upload/UploadPage.tsx | 8 + src/hooks/usePermissions.ts | 2 +- src/i18n/locales/en.ts | 35 +- src/i18n/locales/ru.ts | 35 +- src/routes/ProtectedRoute.tsx | 14 +- src/routes/index.tsx | 160 +++++-- src/styles/shell.css | 41 ++ 25 files changed, 603 insertions(+), 541 deletions(-) create mode 100644 src/components/common/Placeholder.tsx create mode 100644 src/components/common/SubNav.tsx create mode 100644 src/features/admin/panels.tsx create mode 100644 src/features/artist-detail/ArtistDetailPage.tsx create mode 100644 src/features/auth/LoginPage.tsx delete mode 100644 src/features/home/HomePage.tsx create mode 100644 src/features/metadata-editor/MetadataEditorPage.tsx create mode 100644 src/features/not-found/NotFoundPage.tsx create mode 100644 src/features/playlists/PlaylistsPage.tsx create mode 100644 src/features/queue/QueuePage.tsx create mode 100644 src/features/settings/panels.tsx create mode 100644 src/features/storage/StorageMaintenancePage.tsx create mode 100644 src/features/upload/UploadPage.tsx diff --git a/src/components/common/Icon.tsx b/src/components/common/Icon.tsx index d919efe..d2ce855 100644 --- a/src/components/common/Icon.tsx +++ b/src/components/common/Icon.tsx @@ -19,7 +19,6 @@ import { GearSix, HardDrives, Heart, - House, MagnifyingGlass, Pause, Play, @@ -48,7 +47,6 @@ import { const ICONS = { 'vinyl-record': VinylRecord, - house: House, 'magnifying-glass': MagnifyingGlass, 'arrow-circle-down': ArrowCircleDown, 'upload-simple': UploadSimple, diff --git a/src/components/common/Placeholder.tsx b/src/components/common/Placeholder.tsx new file mode 100644 index 0000000..65c1e10 --- /dev/null +++ b/src/components/common/Placeholder.tsx @@ -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 ( +
+ +

+ {note ?? t('common.comingSoon')} +

+ {children} +
+
+ ); +} diff --git a/src/components/common/SubNav.tsx b/src/components/common/SubNav.tsx new file mode 100644 index 0000000..93b58d8 --- /dev/null +++ b/src/components/common/SubNav.tsx @@ -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 ( + + ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index a705dac..4bccaf8 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,7 +1,9 @@ import { Outlet } from 'react-router'; +import { Suspense } from 'react'; import { Sidebar } from './Sidebar'; import { PersistentPlayer } from '../player/PersistentPlayer'; import { QueuePanel } from '../player/QueuePanel'; +import { LoadingSkeleton } from '../common/LoadingSkeleton'; export function AppShell() { return ( @@ -17,7 +19,15 @@ export function AppShell() {
- + + +
+ } + > + +
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index a105155..0fa6c25 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { NavLink, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Icon, type IconName } from '../common/Icon'; import { useAppDispatch } from '../../hooks/useAppDispatch'; -import { usePermissions } from '../../hooks/usePermissions'; +import { usePermissions, type Permission } from '../../hooks/usePermissions'; import { useConnectionStatus } from '../../hooks/useConnectionStatus'; import { logout } from '../../store/slices/auth'; import { useGetPlaylistsQuery } from '../../api/endpoints/playlists'; @@ -13,13 +13,15 @@ interface NavDef { labelKey: string; icon: IconName; end?: boolean; + /** Hide this item unless the user holds the permission. */ + perm?: Permission; } const MAIN_NAV: NavDef[] = [ - { to: '/', labelKey: 'nav.home', icon: 'house', end: true }, { to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' }, - { to: '/search', labelKey: 'nav.search', icon: 'magnifying-glass' }, - { to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down' }, + { to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' }, + { to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' }, + { to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' }, { to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' }, ]; @@ -38,7 +40,7 @@ export function Sidebar() { const { t } = useTranslation(); const dispatch = useAppDispatch(); const navigate = useNavigate(); - const { user, isAdmin } = usePermissions(); + const { user, isAdmin, hasPermission } = usePermissions(); const status = useConnectionStatus(); const { data: playlists } = useGetPlaylistsQuery(); const instance = getActiveInstance(); @@ -61,20 +63,24 @@ export function Sidebar() {
- {MAIN_NAV.map(({ to, labelKey, icon, end }) => ( - - - {t(labelKey)} - - ))} + {MAIN_NAV.filter((n) => !n.perm || hasPermission(n.perm)).map( + ({ to, labelKey, icon, end }) => ( + + + {t(labelKey)} + + ), + )}
- {t('nav.playlists')} + + {t('nav.playlists')} + {(playlists?.items ?? []).map((pl) => ( @@ -84,7 +90,7 @@ export function Sidebar() {
- + diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx index 1b894ac..e12f844 100644 --- a/src/features/admin/AdminPage.tsx +++ b/src/features/admin/AdminPage.tsx @@ -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 `` + * 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 ( -
- -

{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' }} - /> -