mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
feat(shell): V3 navigation, 首页, and one theme for reading and media
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.
This commit is contained in:
1 parent
f232787213
commit
cecbef148c
14 files changed
+513
-70
No files matched your search
+21
-31
@@ -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 <Outlet />;
|
||||
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 <div className="app-shell">
|
||||
<div className="app-shell-body"><Outlet /></div>
|
||||
{mode !== 'hidden' && <AppNav mode={mode} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 <><Outlet /><MediaChannelEntry current={current} /></>;
|
||||
}
|
||||
|
||||
export function HomeRedirect() { return <Navigate to="/media/video" replace />; }
|
||||
/** Media routes: the page itself. Theme and navigation belong to AuthenticatedShell. */
|
||||
export function MediaLayout() { return <Outlet />; }
|
||||
|
||||
export function LoadingPage() { return <FloatingNotice message="正在加载…" busy />; }
|
||||
|
||||
|
||||
@@ -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<NavMode, 'hidden'> }) {
|
||||
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 <nav className="app-nav" aria-label="主导航" data-mode={mode}>
|
||||
<Link className="app-nav-brand" to="/" aria-label="reader. 首页">reader.</Link>
|
||||
<ul className="app-nav-list">
|
||||
{destinations.map(({ to, label, icon: Icon, current }) => {
|
||||
const active = current(pathname);
|
||||
return <li key={to}>
|
||||
<Link className="app-nav-item" to={to} aria-current={active ? 'page' : undefined}>
|
||||
<span className="app-nav-icon" aria-hidden="true"><Icon size={22} strokeWidth={active ? 2.1 : 1.8} /></span>
|
||||
<span className="app-nav-label">{label}</span>
|
||||
</Link>
|
||||
{to === '/media/video' && <ul className="app-nav-sub" aria-label="影音频道">
|
||||
{channels.map(item => <li key={item.id}>
|
||||
<Link className="app-nav-subitem" data-channel={item.id} to={item.to} aria-current={channel === item.id ? 'page' : undefined}>
|
||||
<i aria-hidden="true" />{item.label}
|
||||
</Link>
|
||||
</li>)}
|
||||
</ul>}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
{user && <Link className="app-nav-user" to="/me" aria-label="我的账号">
|
||||
<span className="app-nav-avatar" aria-hidden="true">{Array.from(name)[0]}</span>
|
||||
<span className="app-nav-user-text"><strong>{name}</strong><small>{host(runtime.api.baseUrl)}</small></span>
|
||||
</Link>}
|
||||
</nav>;
|
||||
}
|
||||
|
||||
function host(baseUrl: string): string { try { return new URL(baseUrl).host; } catch { return baseUrl; } }
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, type RouteObject } from 'react-router-dom';
|
||||
import { AppShell, HomeRedirect, MediaLayout } from '../App.tsx';
|
||||
import { AppShell, MediaLayout } from '../App.tsx';
|
||||
import { MediaCatalogPage } from '../../features/media/pages/MediaCatalogPage.tsx';
|
||||
import { MediaSearchPage } from '../../features/media/pages/MediaSearchPage.tsx';
|
||||
import { MediaFavoritesPage } from '../../features/media/pages/MediaFavoritesPage.tsx';
|
||||
@@ -14,6 +14,7 @@ import { LibraryPage, LibraryBrowsePage, LibraryFilesPage } from '../../features
|
||||
import { SourcesPage } from '../../features/sources/pages/SourcesPage.tsx';
|
||||
import { ReaderPage } from '../../features/reader/pages/ReaderPage.tsx';
|
||||
import { MinePage } from '../../features/account/pages/MinePage.tsx';
|
||||
import { HomePage } from '../../features/home/pages/HomePage.tsx';
|
||||
import { MediaLibrariesPage, MediaLibraryCreatePage } from '../../features/media/pages/MediaLibrariesPage.tsx';
|
||||
import { MediaLibraryEditPage, MediaLibraryPermissionsPage } from '../../features/media/pages/MediaLibraryEditorPages.tsx';
|
||||
import { MediaTasksPage } from '../../features/media/pages/MediaTasksPage.tsx';
|
||||
@@ -34,7 +35,7 @@ import { PluginManagementPage } from '../../features/sources/pages/PluginManagem
|
||||
|
||||
export const routes = [{
|
||||
path: '/', element: <AppShell />, errorElement: <RouteErrorBoundary />, children: [
|
||||
{ index: true, element: <HomeRedirect /> },
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: 'shelf', element: <ShelfPage /> },
|
||||
{ path: 'library', element: <LibraryBrowsePage /> },
|
||||
{ path: 'library/books', element: <LibraryPage /> },
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/* V3 shell: one navigation for reading and media.
|
||||
* A tab bar below 900px, a sidebar from 900px up. Colours come from the theme
|
||||
* tokens that AuthenticatedShell applies to <body>, so every theme restyles it. */
|
||||
:root { --app-nav-width: 232px; --app-nav-bar: 64px; }
|
||||
|
||||
.app-shell { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
|
||||
/* Pages were direct children of #app (a flex column that clips); keep exactly that
|
||||
* contract for them one level down. */
|
||||
.app-shell-body { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
|
||||
.app-nav {
|
||||
--nav-ink: var(--media-theme-text, var(--reader-ink));
|
||||
--nav-muted: var(--media-theme-muted, var(--reader-muted));
|
||||
--nav-line: var(--media-theme-border, var(--reader-line));
|
||||
--nav-surface: var(--media-theme-paper, var(--reader-surface));
|
||||
--nav-accent: var(--media-theme-accent, var(--reader-accent));
|
||||
--nav-soft: var(--media-theme-selection, var(--reader-paper));
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-ink);
|
||||
border-color: var(--nav-line);
|
||||
}
|
||||
.app-nav ul { list-style: none; margin: 0; padding: 0; }
|
||||
.app-nav a { color: inherit; text-decoration: none; }
|
||||
.app-nav a:focus-visible { outline: 2px solid var(--nav-accent); outline-offset: 2px; }
|
||||
.app-nav-icon { display: grid; place-items: center; }
|
||||
|
||||
/* ---- phone: tab bar ---- */
|
||||
@media (max-width: 899px) {
|
||||
.app-nav { order: 2; display: flex; height: calc(var(--app-nav-bar) + env(safe-area-inset-bottom)); padding: 6px 12px env(safe-area-inset-bottom); border-top: 1px solid var(--nav-line); }
|
||||
.app-nav[data-mode='push'] { display: none; }
|
||||
.app-nav-brand, .app-nav-sub, .app-nav-user { display: none; }
|
||||
.app-nav-list { display: flex; flex: 1; }
|
||||
.app-nav-list > li { flex: 1; min-width: 0; }
|
||||
.app-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; height: 52px; font-size: 11px; font-weight: 500; color: var(--nav-muted); }
|
||||
.app-nav-item[aria-current='page'] { color: var(--nav-accent); font-weight: 600; }
|
||||
.app-nav-item .app-nav-icon { width: 52px; height: 30px; border-radius: 15px; }
|
||||
.app-nav-item[aria-current='page'] .app-nav-icon { background: var(--nav-soft); }
|
||||
}
|
||||
|
||||
/* ---- desktop: sidebar ---- */
|
||||
@media (min-width: 900px) {
|
||||
.app-shell { flex-direction: row; }
|
||||
/* After the page in the DOM (so the phone bar follows the content), before it on screen. */
|
||||
.app-nav { order: -1; display: flex; flex-direction: column; width: var(--app-nav-width); padding: 24px 14px; border-right: 1px solid var(--nav-line); overflow-y: auto; }
|
||||
.app-nav-brand { padding: 0 14px 22px; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 26px; font-weight: 700; color: var(--nav-accent); }
|
||||
.app-nav-item { display: flex; align-items: center; gap: 12px; height: 44px; padding: 0 14px; border-radius: 12px; font-size: 15px; color: var(--nav-ink); }
|
||||
.app-nav-item:hover { background: var(--media-theme-surface, var(--reader-paper)); }
|
||||
.app-nav-item[aria-current='page'] { background: var(--nav-soft); color: var(--nav-accent); font-weight: 600; }
|
||||
.app-nav-sub { margin-top: 2px; }
|
||||
.app-nav-subitem { display: flex; align-items: center; gap: 12px; height: 38px; padding: 0 14px 0 46px; border-radius: 10px; font-size: 14px; color: var(--nav-muted); }
|
||||
.app-nav-subitem i { width: 8px; height: 8px; border-radius: 50%; background: var(--media-theme-ch-video, var(--nav-accent)); }
|
||||
.app-nav-subitem[data-channel='music'] i { background: var(--media-theme-ch-music, var(--nav-accent)); }
|
||||
.app-nav-subitem[data-channel='audiobook'] i { background: var(--media-theme-ch-audio, var(--nav-accent)); }
|
||||
.app-nav-subitem[aria-current='page'] { background: var(--media-theme-surface, var(--reader-paper)); color: var(--nav-ink); font-weight: 600; }
|
||||
.app-nav-user { display: flex; align-items: center; gap: 10px; margin-top: auto; padding: 10px 12px; border-radius: 14px; background: var(--media-theme-background, var(--reader-paper)); }
|
||||
.app-nav-avatar { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 50%; background: var(--nav-accent); color: var(--media-theme-on-accent, #fff); font-weight: 600; }
|
||||
.app-nav-user-text { min-width: 0; }
|
||||
.app-nav-user-text strong, .app-nav-user-text small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.app-nav-user-text strong { font-size: 14px; }
|
||||
.app-nav-user-text small { font-size: 11px; color: var(--nav-muted); }
|
||||
}
|
||||
|
||||
/* ---- the persistent mini player sits clear of the bar / sidebar ---- */
|
||||
@media (max-width: 899px) {
|
||||
body[data-app-nav='top'] .media-player:is(.media-player-audio, .media-player-native-video) { bottom: calc(var(--app-nav-bar) + env(safe-area-inset-bottom) + 10px); }
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
body[data-app-nav] .media-player:is(.media-player-audio, .media-player-native-video) { left: calc(var(--app-nav-width) + 24px); }
|
||||
}
|
||||
|
||||
/* ---- reading pages wear the same theme as media ---- */
|
||||
body[data-media-theme] :is(.shelf-screen, .library-screen, .sources-screen, .home-page) {
|
||||
--reader-ink: var(--media-theme-text);
|
||||
--reader-paper: var(--media-theme-background);
|
||||
--reader-surface: var(--media-theme-paper);
|
||||
--reader-muted: var(--media-theme-muted);
|
||||
--reader-line: var(--media-theme-border);
|
||||
--reader-accent: var(--media-theme-accent);
|
||||
background: var(--media-theme-background);
|
||||
color: var(--media-theme-text);
|
||||
}
|
||||
|
||||
/* The old fixed channel rail is gone; its top gutter goes with it. */
|
||||
@media (min-width: 701px) {
|
||||
/* Only pages whose header scrolls with them; pages with a fixed header keep their 68px. */
|
||||
body[data-app-nav] .media-screen:is(.media-catalog-page, .mine-page, .home-page) { padding-top: 24px; }
|
||||
/* Search and the ⋯ menu used to hang from the rail with a negative offset. */
|
||||
body[data-app-nav] .media-heading:has(.media-mobile-title) .media-heading-actions { position: fixed; z-index: 81; top: 12px; right: 32px; }
|
||||
}
|
||||
|
||||
/* Fixed page headers were placed to clear the old rail's brand; they now clear the sidebar. */
|
||||
@media (min-width: 900px) {
|
||||
body[data-app-nav] .media-screen .media-mobile-title,
|
||||
body[data-app-nav] .media-screen.media-secondary-page > .media-heading,
|
||||
body[data-app-nav] .media-narrators:not([data-view=directory]) > .media-narrator-heading,
|
||||
body[data-app-nav] .media-utility-workspace .media-settings-heading,
|
||||
body[data-app-nav] .media-manager-workspace > .media-heading { left: calc(var(--app-nav-width) + 24px); right: 32px; }
|
||||
body[data-app-nav] .media-screen.media-detail-shell > .media-heading { left: var(--app-nav-width); right: 0; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRight, Clapperboard, Headphones, Play, type LucideIcon } from 'lucide-react';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { BookCard, useCover } from '../../shelf/pages/ShelfPage.tsx';
|
||||
import { useShelf } from '../../shelf/queries/shelf.queries.ts';
|
||||
import { historyQuery, type Activity } from '../../media/queries/media.queries.ts';
|
||||
import type { ContinueReadingItem } from '../../../api/types.ts';
|
||||
import '../styles/home.css';
|
||||
|
||||
export function greeting(hour: number): string {
|
||||
return hour < 6 ? '夜深了' : hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好';
|
||||
}
|
||||
|
||||
function clock(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds));
|
||||
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60;
|
||||
return (h ? `${h}:${String(m).padStart(2, '0')}` : String(m)) + `:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Progress through the unfinished activity, 0..1; unknown length reads as 0 rather than a guess. */
|
||||
export function activityProgress(activity: Activity): number {
|
||||
const length = activity.end === null ? 0 : activity.end - activity.start;
|
||||
return length > 0 && activity.position !== undefined ? Math.max(0, Math.min(1, (activity.position - activity.start) / length)) : 0;
|
||||
}
|
||||
|
||||
function Resume({ book }: { book: ContinueReadingItem }) {
|
||||
const runtime = useRuntime();
|
||||
const src = useCover(runtime.api, book.coverUrl);
|
||||
const percent = Math.round(Math.max(0, Math.min(1, book.percentage)) * 100);
|
||||
return <Link className="home-hero" to={`/book/${encodeURIComponent(book.id)}`} aria-label={`继续阅读 ${book.title}`}>
|
||||
<span className="home-hero-cover" aria-hidden="true">{src ? <img src={src} alt="" /> : <b>{book.title}</b>}</span>
|
||||
<span className="home-hero-text">
|
||||
<span className="home-pill">继续阅读</span>
|
||||
<strong>{book.title || '未命名'}</strong>
|
||||
<small>{book.chapterTitle}</small>
|
||||
<span className="home-progress" aria-hidden="true"><i style={{ width: `${percent}%` }} /></span>
|
||||
<span className="home-hero-action"><Play size={14} fill="currentColor" aria-hidden="true" />继续 · {percent}%</span>
|
||||
</span>
|
||||
</Link>;
|
||||
}
|
||||
|
||||
function ResumeMedia({ channel, label, icon: Icon, activity }: { channel: 'video' | 'audiobook'; label: string; icon: LucideIcon; activity: Activity }) {
|
||||
const progress = activityProgress(activity);
|
||||
const meta = activity.end !== null && activity.position !== undefined
|
||||
? `${channel === 'video' ? '已看' : '已听'} ${clock(activity.position - activity.start)} / ${clock(activity.end - activity.start)}`
|
||||
: activity.partTitle;
|
||||
return <Link className="home-card" data-channel={channel} to={`/media/${channel}/items/${encodeURIComponent(activity.itemId)}`}>
|
||||
<span className="home-card-label"><Icon size={16} aria-hidden="true" />{label}</span>
|
||||
<strong>{activity.title}{activity.partTitle && activity.partTitle !== activity.title ? ` · ${activity.partTitle}` : ''}</strong>
|
||||
<small>{meta}</small>
|
||||
<span className="home-progress" aria-hidden="true"><i style={{ width: `${Math.round(progress * 100)}%` }} /></span>
|
||||
</Link>;
|
||||
}
|
||||
|
||||
/** 首页: where you left off, across reading and media, then the shelf. */
|
||||
export function HomePage() {
|
||||
const runtime = useRuntime();
|
||||
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
|
||||
const reading = useQuery({
|
||||
queryKey: ['home', 'reading', runtime.api.baseUrl, user?.id],
|
||||
queryFn: ({ signal }) => runtime.api.continueReading(1, { signal }),
|
||||
});
|
||||
const shelf = useShelf(1, 'recent');
|
||||
const video = useQuery(historyQuery(runtime, 'video', new URLSearchParams()));
|
||||
const audio = useQuery(historyQuery(runtime, 'audiobook', new URLSearchParams()));
|
||||
const unfinished = (items?: Activity[]) => items?.find(item => !item.completed);
|
||||
const book = reading.data?.[0];
|
||||
const videoActivity = unfinished(video.data?.items), audioActivity = unfinished(audio.data?.items);
|
||||
const books = (shelf.data?.items ?? []).slice(0, 8);
|
||||
const name = user?.displayName || user?.username || '';
|
||||
return <div className="media-screen media-page home-page">
|
||||
<header className="home-header"><h1>{greeting(new Date().getHours())}{name ? `,${name}` : ''}</h1><p>从上次停下的地方继续</p></header>
|
||||
{book
|
||||
? <Resume book={book} />
|
||||
: <section className="home-empty" aria-label="继续阅读"><strong>还没有在读的书</strong><Link to="/shelf">去书架挑一本</Link></section>}
|
||||
{(videoActivity || audioActivity) && <div className="home-cards">
|
||||
{videoActivity && <ResumeMedia channel="video" label="影视" icon={Clapperboard} activity={videoActivity} />}
|
||||
{audioActivity && <ResumeMedia channel="audiobook" label="听书" icon={Headphones} activity={audioActivity} />}
|
||||
</div>}
|
||||
{books.length > 0 && <section className="home-shelf" aria-label="我的书架">
|
||||
<div className="home-section"><h2>我的书架</h2><Link to="/shelf">全部<ChevronRight size={16} aria-hidden="true" /></Link></div>
|
||||
<div className="home-shelf-row">{books.map(item => <BookCard key={item.id} book={item} showAuthor={false} showProgress />)}</div>
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* 首页: continue where you left off. Uses the shared theme tokens. */
|
||||
.home-page { max-width: 1000px; }
|
||||
.media-screen.home-page { padding-top: 20px; }
|
||||
.home-header h1 { margin: 6px 0 0; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 28px; line-height: 1.2; }
|
||||
.home-header p { margin: 4px 0 0; font-size: 14px; color: var(--muted); }
|
||||
|
||||
.home-pill { align-self: flex-start; display: inline-flex; align-items: center; height: 22px; padding: 0 10px; border-radius: 11px; background: rgb(255 255 255 / 0.18); font-size: 12px; }
|
||||
.home-progress { display: block; height: 4px; border-radius: 2px; background: var(--border); overflow: hidden; }
|
||||
.home-progress > i { display: block; height: 100%; border-radius: 2px; background: currentColor; }
|
||||
|
||||
.home-hero, .home-empty {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: var(--media-accent);
|
||||
color: var(--media-on-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.home-hero:focus-visible, .home-card:focus-visible { outline: 2px solid var(--media-accent); outline-offset: 3px; }
|
||||
.home-hero-cover { display: grid; place-items: center; flex: none; width: 84px; height: 120px; overflow: hidden; border-radius: 12px; background: #e9dfc9; color: #1b1a17; box-shadow: 0 6px 16px rgb(27 26 23 / 0.25); }
|
||||
.home-hero-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.home-hero-cover b { padding: 10px; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 15px; line-height: 1.3; letter-spacing: 0.05em; overflow-wrap: anywhere; }
|
||||
.home-hero-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.home-hero-text strong { margin-top: 8px; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 21px; overflow-wrap: anywhere; }
|
||||
.home-hero-text small { font-size: 13px; opacity: 0.85; }
|
||||
.home-hero-text .home-progress { margin-top: auto; background: rgb(255 255 255 / 0.25); color: var(--media-on-accent); }
|
||||
.home-hero-action { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; height: 40px; margin-top: 12px; padding: 0 18px; border-radius: 20px; background: var(--media-on-accent); color: var(--media-accent); font-size: 14px; font-weight: 600; }
|
||||
|
||||
.home-empty { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.home-empty a { color: inherit; font-weight: 600; }
|
||||
|
||||
.home-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-top: 12px; }
|
||||
.home-card { display: flex; flex-direction: column; gap: 2px; padding: 14px; border-radius: 20px; text-decoration: none; color: var(--text); background: var(--media-theme-ch-video-soft, var(--surface)); --ch: var(--media-theme-ch-video, var(--media-accent)); }
|
||||
.home-card[data-channel='audiobook'] { background: var(--media-theme-ch-audio-soft, var(--surface)); --ch: var(--media-theme-ch-audio, var(--media-accent)); }
|
||||
.home-card-label { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--ch); }
|
||||
.home-card strong { margin-top: 8px; font-size: 15px; overflow-wrap: anywhere; }
|
||||
.home-card small { margin-bottom: 10px; font-size: 12px; color: var(--muted); }
|
||||
.home-card .home-progress { background: rgb(0 0 0 / 0.1); color: var(--ch); }
|
||||
|
||||
.home-section { display: flex; align-items: center; margin: 24px 0 10px; }
|
||||
.home-section h2 { flex: 1; margin: 0; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 19px; }
|
||||
.home-section a { display: inline-flex; align-items: center; color: var(--muted); font-size: 13px; text-decoration: none; }
|
||||
.home-shelf-row { display: flex; gap: 14px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
|
||||
.home-shelf-row .book-card { flex: none; width: 108px; }
|
||||
@@ -17,7 +17,7 @@ export function ChannelSwitcher({current,label}:{current:string;label?:string})
|
||||
useEffect(()=>{const close=(event:PointerEvent)=>{if(root.current&&!root.current.contains(event.target as Node))root.current.open=false;};document.addEventListener('pointerdown',close);return()=>document.removeEventListener('pointerdown',close);},[]);
|
||||
return <details className="media-channel-switcher" ref={root} onKeyDown={event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();root.current!.open=false;root.current!.querySelector('summary')?.focus();}}}>
|
||||
<summary aria-label="切换频道" title="切换频道">{label&&<strong>{label}</strong>}<ChevronsUpDown size={18} aria-hidden="true"/></summary>
|
||||
<nav aria-label="切换内容频道">{channels.map(({id,label,icon:Icon})=><a key={id} href={'#'+channelPath(id)} aria-current={id===current?'page':undefined} onClick={()=>{if(root.current)root.current.open=false;}}><Icon size={18} aria-hidden="true"/><span>{label}</span>{id===current&&<Check size={16} aria-hidden="true"/>}</a>)}</nav>
|
||||
<nav aria-label="切换内容频道">{channels.filter(({id})=>id&&id!=='me').map(({id,label,icon:Icon})=><a key={id} href={'#'+channelPath(id)} aria-current={id===current?'page':undefined} onClick={()=>{if(root.current)root.current.open=false;}}><Icon size={18} aria-hidden="true"/><span>{label}</span>{id===current&&<Check size={16} aria-hidden="true"/>}</a>)}</nav>
|
||||
</details>;
|
||||
}
|
||||
export function MediaChannelEntry({ current }: { current: string }) {
|
||||
|
||||
@@ -22,6 +22,12 @@ export function saveMediaTheme(scope:string,value:MediaThemeId){
|
||||
export function mediaThemePalette(id:MediaThemeId,dark=false){return mediaThemes.find(theme=>theme.id===(id==='system'?(dark?'midnight':'sand'):id))!;}
|
||||
|
||||
/** Only prefixed media variables are placed on the body; reading tokens remain untouched. */
|
||||
// [ink, soft background] per channel. Ink is used as text on its own soft background, so
|
||||
// every pair is held to 4.5:1 by test/media-theme-contrast.test.ts.
|
||||
const lightChannels={read:['#935825','#f3e6d8'],video:['#4b4fd6','#e4e5fa'],music:['#b23656','#f6e0e6'],audio:['#0c7272','#d9eeee']} as const;
|
||||
const darkChannels={read:['#e0a36a','#33291f'],video:['#9a9dff','#25264a'],music:['#f28ba6','#3c2229'],audio:['#5fd0cb','#17363a']} as const;
|
||||
export const channelColors={light:lightChannels,dark:darkChannels};
|
||||
|
||||
export class MediaThemeController {
|
||||
current:MediaThemeId;
|
||||
private readonly system=typeof window.matchMedia==='function'?window.matchMedia('(prefers-color-scheme: dark)'):undefined;
|
||||
@@ -45,11 +51,17 @@ export class MediaThemeController {
|
||||
document.body.style.setProperty('--media-theme-scheme',theme.dark?'dark':'light');
|
||||
document.body.style.setProperty('--media-theme-error-ink',theme.dark?'#f4a89d':'#943c32');
|
||||
document.body.style.setProperty('--media-theme-error-background',theme.dark?'#f49a8c18':'#a4504515');
|
||||
// Channel colours: each content type keeps one hue everywhere (icons, progress, labels).
|
||||
const channel=theme.dark?darkChannels:lightChannels;
|
||||
for(const [name,[ink,soft]] of Object.entries(channel)){document.body.style.setProperty('--media-theme-ch-'+name,ink);document.body.style.setProperty('--media-theme-ch-'+name+'-soft',soft);}
|
||||
document.body.style.setProperty('--media-theme-on-ch',theme.dark?'#0b1a33':'#ffffff');
|
||||
}
|
||||
dispose(){
|
||||
this.system?.removeEventListener('change',this.onSystem);window.removeEventListener('storage',this.onStorage);window.removeEventListener(themeChangeEvent,this.onThemeChange);
|
||||
delete document.body.dataset.mediaTheme;
|
||||
tokens.forEach(token=>document.body.style.removeProperty('--media-theme-'+token));
|
||||
for(const token of ['scheme','error-ink','error-background'])document.body.style.removeProperty('--media-theme-'+token);
|
||||
for(const name of Object.keys(lightChannels)){document.body.style.removeProperty('--media-theme-ch-'+name);document.body.style.removeProperty('--media-theme-ch-'+name+'-soft');}
|
||||
document.body.style.removeProperty('--media-theme-on-ch');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Alert } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { BookOpen, Headphones, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical, UserRound } from 'lucide-react';
|
||||
import { BookOpen, Headphones, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical } from 'lucide-react';
|
||||
import type { Book, ContinueReadingItem } from '../../../api/types.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
@@ -15,7 +15,7 @@ import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
const coverCache = new WeakMap<object, Map<string, string>>();
|
||||
function useCover(api: ReturnType<typeof useRuntime>['api'], coverUrl: string | null | undefined): string | null {
|
||||
export function useCover(api: ReturnType<typeof useRuntime>['api'], coverUrl: string | null | undefined): string | null {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!coverUrl) { setSrc(null); return; }
|
||||
@@ -33,7 +33,7 @@ function useCover(api: ReturnType<typeof useRuntime>['api'], coverUrl: string |
|
||||
return src;
|
||||
}
|
||||
|
||||
function BookCard({ book, showAuthor, showProgress, onRemove }: { book: Book | ContinueReadingItem; showAuthor: boolean; showProgress: boolean; onRemove?: () => void }) {
|
||||
export function BookCard({ book, showAuthor, showProgress, onRemove }: { book: Book | ContinueReadingItem; showAuthor: boolean; showProgress: boolean; onRemove?: () => void }) {
|
||||
const runtime = useRuntime();
|
||||
const src = useCover(runtime.api, book.coverUrl);
|
||||
const progress = 'percentage' in book ? book.percentage : runtime.offline?.progressFor(book.id)?.percentage;
|
||||
@@ -80,7 +80,6 @@ export function ShelfPage() {
|
||||
<div className="shelf-head-text"><h1 className="shelf-title">{queryText ? '搜索结果' : '我的书架'}</h1><p className="shelf-subtitle muted">{subtitle}</p></div>
|
||||
<div className="shelf-head-actions">
|
||||
<Link className="icon-button" aria-label="影音" title="影音" to="/media/video"><Headphones className="icon" size="1em" strokeWidth={1.8} /></Link>
|
||||
<Link className="icon-button" aria-label="我的" title="我的" to="/me"><UserRound className="icon" size="1em" strokeWidth={1.8} /></Link>
|
||||
{user?.role === 'admin' && <button className="icon-button" aria-label="系统设置" title="系统设置" onClick={() => setSystemSettingsOpen(true)}><Settings className="icon" size="1em" strokeWidth={1.8} /><span className="media-visually-hidden">系统设置</span></button>}
|
||||
<button className="icon-button" aria-label={`书架设置 · ${densityLabel}`} title={`书架设置 · ${densityLabel}`} onClick={() => setSettingsOpen(true)}><SlidersVertical className="icon" size="1em" strokeWidth={1.8} /><span className="media-visually-hidden">设置</span></button>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import './app/app.css';
|
||||
import './features/media/styles/media.css';
|
||||
import './features/media/styles/presentation.css';
|
||||
import './features/media/styles/v2.css';
|
||||
import './app/shell.css';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { AppProviders } from './app/providers/AppProviders.tsx';
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render } from '../src/shared/ui/render-root.ts';
|
||||
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
|
||||
import { HomePage, activityProgress, greeting } from '../src/features/home/pages/HomePage.tsx';
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.append(root);
|
||||
const user = { id: 'u1', username: 'lin', displayName: '林间', role: 'member', createdAt: 0 };
|
||||
const book = { id: 'b1', title: '夜航船', author: '示例', format: 'epub', coverUrl: null, percentage: 0.42, chapterTitle: '第十二章 夜雨', lastReadAt: 1 };
|
||||
const activity = (over: object) => ({ id: 'a', itemId: 'i1', partId: 'p', assetId: 's', libraryId: 'l', kind: 'episode', title: '示例剧集', partTitle: '第 3 集', start: 0, end: 2480, available: 1, position: 1748, completed: false, ...over });
|
||||
|
||||
function mount({ reading = [book], video = [activity({})], audio = [] as object[] }: { reading?: object[]; video?: object[]; audio?: object[] } = {}) {
|
||||
const runtime = {
|
||||
api: {
|
||||
baseUrl: 'https://reader.example.com',
|
||||
currentSession: () => ({ user }),
|
||||
continueReading: vi.fn().mockResolvedValue(reading),
|
||||
listBooks: vi.fn().mockResolvedValue({ items: [book], total: 1 }),
|
||||
coverBytes: vi.fn().mockRejectedValue(new Error('none')),
|
||||
},
|
||||
mediaApi: {
|
||||
preferenceScope: () => 'home-test',
|
||||
request: vi.fn(async (path: string) => ({ items: path.includes('channel=video') ? video : audio, total: 0 })),
|
||||
},
|
||||
} as any;
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
act(() => render(
|
||||
<RuntimeContext.Provider value={runtime}><QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={['/']}><Routes><Route path="/" element={<HomePage />} /></Routes></MemoryRouter>
|
||||
</QueryClientProvider></RuntimeContext.Provider>,
|
||||
root,
|
||||
));
|
||||
}
|
||||
const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
|
||||
afterEach(() => { act(() => render(null, root)); useAuthStore.setState({ session: null, verifiedUser: null }); });
|
||||
|
||||
describe('首页', () => {
|
||||
it('greets by time of day', () => {
|
||||
expect([2, 8, 14, 21].map(greeting)).toEqual(['夜深了', '早上好', '下午好', '晚上好']);
|
||||
});
|
||||
|
||||
it('reads progress from position inside the activity span, and treats an unknown length as zero', () => {
|
||||
expect(activityProgress(activity({ start: 100, end: 300, position: 200 }) as any)).toBe(0.5);
|
||||
expect(activityProgress(activity({ end: null }) as any)).toBe(0);
|
||||
expect(activityProgress(activity({ position: 99999 }) as any)).toBe(1);
|
||||
});
|
||||
|
||||
it('resumes the book first, then the unfinished media, then the shelf', async () => {
|
||||
useAuthStore.setState({ verifiedUser: user as any });
|
||||
mount();
|
||||
await settle();
|
||||
expect(root.querySelector('h1')?.textContent).toContain('林间');
|
||||
const hero = root.querySelector<HTMLAnchorElement>('a.home-hero')!;
|
||||
expect(hero.getAttribute('href')).toBe('/book/b1');
|
||||
expect(hero.textContent).toContain('第十二章 夜雨');
|
||||
expect(hero.textContent).toContain('42%');
|
||||
const card = root.querySelector<HTMLAnchorElement>('a.home-card[data-channel="video"]')!;
|
||||
expect(card.getAttribute('href')).toBe('/media/video/items/i1');
|
||||
expect(card.textContent).toContain('示例剧集 · 第 3 集');
|
||||
expect(card.textContent).toContain('已看 29:08 / 41:20');
|
||||
expect(root.querySelector('a.home-card[data-channel="audiobook"]')).toBeNull();
|
||||
expect(root.querySelectorAll('.home-shelf .book-card').length).toBe(1);
|
||||
});
|
||||
|
||||
it('skips finished media and invites the reader to the shelf when nothing is in progress', async () => {
|
||||
useAuthStore.setState({ verifiedUser: user as any });
|
||||
mount({ reading: [], video: [activity({ completed: true })] });
|
||||
await settle();
|
||||
expect(root.querySelector('a.home-hero')).toBeNull();
|
||||
expect(root.querySelector('.home-empty a')?.getAttribute('href')).toBe('/shelf');
|
||||
expect(root.querySelector('a.home-card')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { render } from '../src/shared/ui/render-root.ts';
|
||||
import { MediaLayout } from '../src/app/App.tsx';
|
||||
import { AppShell } from '../src/app/App.tsx';
|
||||
import { navMode } from '../src/app/AppNav.tsx';
|
||||
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.append(root);
|
||||
const runtime = { mediaApi: { preferenceScope: () => 'media-shell-test' } } as any;
|
||||
const listeners = { addEventListener: () => undefined, removeEventListener: () => undefined };
|
||||
const runtime = {
|
||||
api: { baseUrl: 'https://reader.example.com' },
|
||||
mediaApi: { preferenceScope: () => 'shell-test' },
|
||||
player: { ...listeners, setVisible: () => undefined },
|
||||
playback: { snapshot: () => ({}) },
|
||||
} as any;
|
||||
const user = { id: 'u', username: 'lin', displayName: '林间', role: 'member', createdAt: 0 };
|
||||
const paint = (path: string) => {
|
||||
act(() => render(null, root)); // a fresh router per call
|
||||
act(() => render(
|
||||
<RuntimeContext.Provider value={runtime}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes><Route element={<AppShell />}><Route path="*" element={<main data-testid="page">页面</main>} /></Route></Routes>
|
||||
</MemoryRouter>
|
||||
</RuntimeContext.Provider>,
|
||||
root,
|
||||
));
|
||||
};
|
||||
|
||||
beforeEach(() => useAuthStore.setState({ session: { user } as any, verifiedUser: user as any }));
|
||||
|
||||
afterEach(() => {
|
||||
act(() => render(null, root));
|
||||
useAuthStore.setState({ session: null, verifiedUser: null });
|
||||
delete document.body.dataset.appNav;
|
||||
delete document.body.dataset.mediaTheme;
|
||||
});
|
||||
|
||||
describe('media shell navigation placement', () => {
|
||||
it('keeps channel navigation after the route-owned media page content', () => {
|
||||
act(() => render(
|
||||
<RuntimeContext.Provider value={runtime}>
|
||||
<MemoryRouter initialEntries={['/media/video']}>
|
||||
<Routes>
|
||||
<Route path="/media/:channel" element={<MediaLayout />}>
|
||||
<Route index element={<main data-testid="media-page">影视页面</main>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</RuntimeContext.Provider>,
|
||||
root,
|
||||
));
|
||||
const page = root.querySelector<HTMLElement>('[data-testid="media-page"]');
|
||||
const channels = root.querySelector<HTMLElement>('nav.media-channel-entry');
|
||||
expect(root.innerHTML).toContain('影视页面');
|
||||
expect(page).not.toBeNull();
|
||||
expect(channels).not.toBeNull();
|
||||
expect(page!.compareDocumentPosition(channels!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(channels!.getAttribute('aria-label')).toBe('内容频道');
|
||||
expect(channels!.querySelector('[aria-current="page"]')?.textContent).toContain('影视');
|
||||
describe('shell navigation', () => {
|
||||
it('decides where the bar shows: top-level pages, pushed pages, and the full-screen ones', () => {
|
||||
expect(['/', '/shelf', '/me', '/media/video', '/media/music/albums', '/media/audiobook/books'].map(navMode)).toEqual(Array(6).fill('top'));
|
||||
expect(['/library', '/sources', '/media/video/items/x', '/media/video/settings', '/media/video/favorites', '/media/queue'].map(navMode)).toEqual(Array(6).fill('push'));
|
||||
expect(['/book/abc', '/media/video/player', '/media/music/player/lyrics'].map(navMode)).toEqual(Array(3).fill('hidden'));
|
||||
});
|
||||
|
||||
it('puts the navigation after the page, marks 影音 and the channel current, and themes the body', () => {
|
||||
paint('/media/music/albums');
|
||||
const page = root.querySelector('[data-testid="page"]')!;
|
||||
const nav = root.querySelector<HTMLElement>('nav.app-nav')!;
|
||||
expect(nav.getAttribute('aria-label')).toBe('主导航');
|
||||
expect(page.closest('.app-shell-body')).not.toBeNull();
|
||||
expect(page.compareDocumentPosition(nav) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect([...nav.querySelectorAll('.app-nav-item')].map(a => a.textContent)).toEqual(['首页', '书架', '影音', '我的']);
|
||||
expect(nav.querySelector('.app-nav-item[aria-current="page"]')?.textContent).toBe('影音');
|
||||
expect(nav.querySelector('.app-nav-subitem[aria-current="page"]')?.textContent).toBe('音乐');
|
||||
expect(document.body.dataset.appNav).toBe('top');
|
||||
expect(document.body.dataset.mediaTheme).toBe('system');
|
||||
});
|
||||
|
||||
it('keeps the sidebar on pushed pages, and removes the bar for the book and the player', () => {
|
||||
paint('/media/video/items/film-1');
|
||||
expect(root.querySelector('nav.app-nav')?.getAttribute('data-mode')).toBe('push');
|
||||
expect(document.body.dataset.appNav).toBe('push');
|
||||
paint('/media/video/player');
|
||||
expect(root.querySelector('nav.app-nav')).toBeNull();
|
||||
expect(document.body.dataset.appNav).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the book to its own reading theme', () => {
|
||||
paint('/book/abc');
|
||||
expect(root.querySelector('nav.app-nav')).toBeNull();
|
||||
expect(document.body.dataset.mediaTheme).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks the reading destinations under 书架 and the account page under 我的', () => {
|
||||
paint('/sources');
|
||||
expect(root.querySelector('.app-nav-item[aria-current="page"]')?.textContent).toBe('书架');
|
||||
paint('/me');
|
||||
expect(root.querySelector('.app-nav-item[aria-current="page"]')?.textContent).toBe('我的');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mediaThemes } from '../src/features/media/services/theme.ts';
|
||||
import { channelColors, mediaThemes } from '../src/features/media/services/theme.ts';
|
||||
|
||||
// colors order: background, paper, surface, text, muted, border, accent, on-accent, selection
|
||||
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
@@ -30,4 +30,18 @@ describe('media theme contrast', () => {
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
// Channel hues label a card of their own soft colour, with the theme's text on top.
|
||||
for (const theme of mediaThemes) {
|
||||
it(`${theme.name} keeps channel labels and card text legible`, () => {
|
||||
const channels = theme.dark ? channelColors.dark : channelColors.light;
|
||||
const failures = Object.entries(channels).flatMap(([name, [ink, soft]]) => [
|
||||
{ label: `${name} label on soft`, value: ratio(ink, soft), min: 4.5 },
|
||||
{ label: `${name} icon on background`, value: ratio(ink, theme.colors[0]), min: 3 },
|
||||
{ label: `text on ${name} soft`, value: ratio(theme.colors[3], soft), min: 4.5 },
|
||||
{ label: `muted on ${name} soft`, value: ratio(theme.colors[4], soft), min: 4.5 },
|
||||
]).filter(item => item.value < item.min).map(item => `${item.label}: ${item.value.toFixed(2)} < ${item.min}`);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { act } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { render } from '../src/shared/ui/render-root.ts';
|
||||
import { MediaLayout } from '../src/app/App.tsx';
|
||||
import { MinePage } from '../src/features/account/pages/MinePage.tsx';
|
||||
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
|
||||
@@ -20,7 +19,7 @@ const user = (role: 'admin' | 'member') => ({ id: 'u', username: 'lin', displayN
|
||||
const paint = () => act(() => render(
|
||||
<RuntimeContext.Provider value={runtime}>
|
||||
<MemoryRouter initialEntries={['/me']}>
|
||||
<Routes><Route path="/me" element={<MediaLayout />}><Route index element={<MinePage />} /></Route></Routes>
|
||||
<Routes><Route path="/me" element={<MinePage />} /></Routes>
|
||||
</MemoryRouter>
|
||||
</RuntimeContext.Provider>,
|
||||
root,
|
||||
@@ -58,13 +57,10 @@ describe('我的', () => {
|
||||
expect(root.querySelector('.mine-profile')?.textContent).toContain('管理员');
|
||||
});
|
||||
|
||||
it('signs out through the API and keeps the channel rail with 我的 marked current', () => {
|
||||
it('signs out through the API', () => {
|
||||
useAuthStore.setState({ verifiedUser: user('member') });
|
||||
paint();
|
||||
act(() => { root.querySelector<HTMLButtonElement>('.mine-row.is-danger')!.click(); });
|
||||
expect(signOut).toHaveBeenCalledTimes(1);
|
||||
const current = root.querySelector('nav.media-channel-entry a[aria-current="page"]');
|
||||
expect(current?.textContent).toContain('我的');
|
||||
expect(current?.getAttribute('href')).toBe('/me');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user