diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 29af8ee..dfeb216 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -1,9 +1,9 @@ import { useEffect, type ReactNode } from 'react'; -import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../shared/stores/auth.store.ts'; import { useRuntime } from './providers/runtime-context.tsx'; import { LoginPage } from '../features/auth/pages/LoginPage.tsx'; -import { MediaChannelEntry } from '../features/media/components/channel-navigation.tsx'; +import { AppNav, navMode } from './AppNav.tsx'; import { MediaThemeController } from '../features/media/services/theme.ts'; import { FloatingNotice } from '../ui/floating-notice.tsx'; @@ -24,11 +24,23 @@ function AuthenticatedShell() { return () => runtime.player.removeEventListener('open-controls', openControls); }, [runtime, navigate]); useEffect(() => runtime.player.setVisible(location.pathname.startsWith('/media/')), [runtime, location.pathname]); - // Reading-adjacent pages own their header and discovery actions. The migrated - // shell used to inject a second, generic navigation bar above every page, which - // changed the vertical rhythm and made shelf/library diverge from the legacy UI. - // Media keeps its own channel navigation through MediaLayout. - return ; + const mode = navMode(location.pathname); + // Every page except the book itself wears the chosen theme: the reader keeps its own + // reading themes, everything around it shares one palette. + const themed = !location.pathname.startsWith('/book/'); + useEffect(() => { + if (!themed) return undefined; + const theme = new MediaThemeController(runtime.mediaApi.preferenceScope(), () => undefined); + return () => theme.dispose(); + }, [runtime, themed]); + useEffect(() => { + if (mode === 'hidden') delete document.body.dataset.appNav; else document.body.dataset.appNav = mode; + return () => { delete document.body.dataset.appNav; }; + }, [mode]); + return
+
+ {mode !== 'hidden' && } +
; } export function AuthBoundary({ children }: { children?: ReactNode }) { @@ -37,30 +49,8 @@ export function AuthBoundary({ children }: { children?: ReactNode }) { return <>{children}; } -export function MediaLayout() { - const runtime = useRuntime(); - const location = useLocation(); - const current = location.pathname === '/me' || location.pathname.startsWith('/me/') - ? 'me' - : location.pathname.startsWith('/media/music') - ? 'music' - : location.pathname.startsWith('/media/audiobook') - ? 'audiobook' - : 'video'; - // The legacy media shell applies its own palette while any media route is - // mounted. Keep this scoped to MediaLayout so reading pages continue to use - // the reader palette, and let React CSS consume the injected theme tokens. - useEffect(() => { - const theme = new MediaThemeController(runtime.mediaApi.preferenceScope(), () => undefined); - return () => theme.dispose(); - }, [runtime]); - // Keep the channel rail after the page content in the DOM, matching the legacy - // shell. Its presentation stylesheet may pin it to the viewport, while the - // route-owned page remains the first and primary content surface. - return <>; -} - -export function HomeRedirect() { return ; } +/** Media routes: the page itself. Theme and navigation belong to AuthenticatedShell. */ +export function MediaLayout() { return ; } export function LoadingPage() { return ; } diff --git a/web/src/app/AppNav.tsx b/web/src/app/AppNav.tsx new file mode 100644 index 0000000..e68f8f9 --- /dev/null +++ b/web/src/app/AppNav.tsx @@ -0,0 +1,73 @@ +import { Link, useLocation } from 'react-router-dom'; +import { BookOpen, Clapperboard, Headphones, House, Music2, UserRound, type LucideIcon } from 'lucide-react'; +import { useAuthStore } from '../shared/stores/auth.store.ts'; +import { useRuntime } from './providers/runtime-context.tsx'; + +/** + * hidden: the book and the player own the whole screen. + * top: a top-level destination; the bar shows everywhere. + * push: a page reached from a top-level one (detail, settings, library files): + * on a phone it replaces the bar with its own back arrow, on desktop the + * sidebar stays. + */ +export type NavMode = 'hidden' | 'top' | 'push'; + +const mediaTop = /^\/media\/(video|music|audiobook)(?:\/([^/]+))?$/; +const pushed = new Set(['favorites', 'queue', 'settings', 'history', 'folders', 'player', 'narrators']); + +export function navMode(pathname: string): NavMode { + if (pathname.startsWith('/book/') || /^\/media\/[^/]+\/player(\/|$)/.test(pathname)) return 'hidden'; + if (pathname === '/' || pathname === '/shelf' || pathname === '/me') return 'top'; + const media = mediaTop.exec(pathname); + if (media) return media[2] && pushed.has(media[2]) ? 'push' : 'top'; + return 'push'; +} + +interface Destination { to: string; label: string; icon: LucideIcon; current(path: string): boolean } +const destinations: Destination[] = [ + { to: '/', label: '首页', icon: House, current: path => path === '/' }, + { to: '/shelf', label: '书架', icon: BookOpen, current: path => /^\/(shelf|library|sources)(\/|$)/.test(path) }, + { to: '/media/video', label: '影音', icon: Clapperboard, current: path => path.startsWith('/media') }, + { to: '/me', label: '我的', icon: UserRound, current: path => path === '/me' || path.startsWith('/me/') }, +]; +const channels: Array<{ id: string; to: string; label: string; icon: LucideIcon }> = [ + { id: 'video', to: '/media/video', label: '影视', icon: Clapperboard }, + { id: 'music', to: '/media/music', label: '音乐', icon: Music2 }, + { id: 'audiobook', to: '/media/audiobook', label: '有声书', icon: Headphones }, +]; + +/** The one navigation: a tab bar on a phone, a sidebar from 900px up. */ +export function AppNav({ mode }: { mode: Exclude }) { + const { pathname } = useLocation(); + const runtime = useRuntime(); + const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null); + const channel = /^\/media\/(video|music|audiobook)/.exec(pathname)?.[1]; + const name = user?.displayName || user?.username || ''; + return