From cecbef148c7a2a7da508a26051389b79ff25bb4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 07:33:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(shell):=20V3=20navigation,=20=E9=A6=96?= =?UTF-8?q?=E9=A1=B5,=20and=20one=20theme=20for=20reading=20and=20media?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One navigation replaces the media channel rail: a four-tab bar on phones (首页 / 书架 / 影音 / 我的) and a sidebar from 900px up, with the three media channels under 影音. The bar shows on top-level pages, gives way to each page's own back arrow on pushed pages, and is absent for the book and the player. AuthenticatedShell now applies the chosen theme to every page except the book, so shelf, library and sources wear the same palette as media. Channel colours become theme tokens (--media-theme-ch-*) held to AA by test. 首页 shows where you left off: the book (continueReading), unfinished video and audiobook (history), and the shelf. It replaces the redirect to /media/video. Fixed headers and the search/more actions, which were positioned against the old rail, now clear the sidebar. --- web/src/app/App.tsx | 52 ++++----- web/src/app/AppNav.tsx | 73 +++++++++++++ web/src/app/router/routes.tsx | 5 +- web/src/app/shell.css | 101 ++++++++++++++++++ web/src/features/home/pages/HomePage.tsx | 87 +++++++++++++++ web/src/features/home/styles/home.css | 46 ++++++++ .../media/components/channel-navigation.tsx | 2 +- web/src/features/media/services/theme.ts | 12 +++ web/src/features/shelf/pages/ShelfPage.tsx | 7 +- web/src/main.tsx | 1 + web/test/home-page.test.tsx | 79 ++++++++++++++ web/test/media-shell.test.tsx | 94 +++++++++++----- web/test/media-theme-contrast.test.ts | 16 ++- web/test/mine-page.test.tsx | 8 +- 14 files changed, 513 insertions(+), 70 deletions(-) create mode 100644 web/src/app/AppNav.tsx create mode 100644 web/src/app/shell.css create mode 100644 web/src/features/home/pages/HomePage.tsx create mode 100644 web/src/features/home/styles/home.css create mode 100644 web/test/home-page.test.tsx 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