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