feat(ui): one Sheet for every overlay; drop 来源与刮削

- ui/sheet.tsx Sheet/ConfirmSheet replace the centred Modal, SweetAlert confirms, DialogView overlays, the reader Panel, the shelf settings floating card, the two media dialogs and Mantine Modal.
- Phones: bottom sheet. Wide windows: right-edge drawer; compact sheets (confirm, prompt, pick) open next to the tap or focused control.
- One set of exits (close, Escape, backdrop), 关闭 label, focus starts on 取消, danger confirms.
- Remove sweetalert2 and the unused @mantine/notifications.
- Remove the 来源与刮削 media settings panel and its entries in 我的 and 影音设置; TMDB and MusicBrainz stay configurable in 系统设置 > 服务配置.
- docs/ui.md 3.7 describes the rule; tests and review scripts follow the new markup.
This commit is contained in:
hectorqin committed 2026-10-21 07:30:27 +08:00
1 parent b5ca02c66c
commit fbb6dc65cf
70 files changed
+888 -1079

No files matched your search

+24 -4
View File
@@ -419,7 +419,26 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
关闭后回到 `body`;消息不抢焦点。提示出现、更新和关闭不得改变内容区尺寸或滚动位置,
这些行为在手机和桌面 Chromium 中验证。数量摘要、搜索统计和操作说明继续作为页面内容展示。
### 3.7 半屏面板
### 3.7 弹层:只有一种 Sheet
所有设置、编辑表单、选择列表、详情和确认框都用 `web/src/ui/sheet.tsx` 的 `Sheet`
(确认框是 `ConfirmSheet`),样式只在 `web/src/styles/sheet.css`。不再使用居中弹窗、
SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
| 视口 | 形态 |
| --- | --- |
| ≤700px | 底部 Sheet:圆角顶边、把手,高度随内容,最高到安全区下方;阅读器内最高 62% |
| ≥701px | 右侧抽屉:距边 1rem,宽 30rem;`size="wide"` 宽 42rem 给复杂表单 |
| ≥701px,`size="compact"` | 确认、单行输入、短选择列表:跟手,出现在触发它的点按位置或聚焦控件的下方(放不下则上方),宽 24rem,始终留在窗口内;找不到触发点时回到右上角 |
- 原生 `<dialog>` + `showModal()`:顶层、背景惰性、焦点限制在内部,关闭后焦点回到打开它的控件。
- 三个出口始终相同:标题栏 ✕(「关闭」)、Esc、点背景;`busy` 时三者都禁用。
- 就地渲染、不走 portal,所以继承打开它的页面主题(阅读器黑白灰、影音别名)。
- 调用方的 `className` 只管**内容**样式,不得设置位置、尺寸、圆角、背景遮罩或标题栏。
- 确认框焦点默认在「取消」;破坏性操作传 `danger`,按钮用危险色。
- 锚定在控件上的下拉(选择框、`⋯` 菜单、选区工具条)不是弹层,不在此列。
以下是阅读器面板最初改成底部半屏的理由,仍然适用于手机形态:
目录和阅读设置是**从底部升起的半屏面板**,带遮罩和把手,上半屏仍然看得见
正文。
@@ -435,8 +454,8 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
- **把手**:从底部升起的面板被理解为可拖拽的。拖动本身没有实现——把手是
*可供性*,遮罩和 ✕ 是两个真实的出口。画一个做不到的把手比不画更糟,所以
它只是视觉线索,真正移动的是面板自己的滚动。
- **高度**:`min(62%, 34rem)`,且不超过 78%。一千二百章的目录想要尽可能多的
空间,十一行的设置不想要比它需要更多的空间。
- **高度**:随内容,阅读器内最高 `min(62dvh, 36rem)`。一千二百章的目录会撑到上限,
十一行的设置不占比它需要更多的空间。
面板打开时**它拥有点击**:旁边露出的那一条正文不翻页(§7 的优先级)。
@@ -669,7 +688,8 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
| `web/src/features/library/pages/LibraryPage.tsx` | 书库浏览、书籍列表和文件管理路由;搜索、面包屑、分页器、上传后重读 |
| `web/src/ui/shelf-membership.ts` | 两个方向的措辞与批量报告那句话的唯一来源(§3.5.2) |
| `web/src/ui/shelf-order.ts` | 排序的**唯一**定义:方向、客户端/服务端的键怎么翻译、阅读时间怎么排 |
| `web/src/ui/dialog.tsx` | 提问(文本框/确认/选择/改资料),两个书库页面共用 |
| `web/src/ui/sheet.tsx` / `web/src/styles/sheet.css` | 唯一的弹层 `Sheet` 与 `ConfirmSheet`(§3.7) |
| `web/src/ui/dialog.tsx` | 提问(文本框/确认/选择/改资料),基于 `Sheet`,两个书库页面共用 |
| `web/src/ui/pager.tsx` | 翻页器,书架与书库共用(§3.5) |
| `web/test/shelf-order.test.ts` | 书架排序(默认项、`recent` 是客户端排)、排序方向和稳定性 |
| `web/test/shelf-order.test.ts` | 排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定 |
+2 -101
View File
@@ -11,7 +11,6 @@
"dependencies": {
"@mantine/core": "^8.3.5",
"@mantine/hooks": "^8.3.5",
"@mantine/notifications": "^8.3.5",
"@tanstack/query-core": "^5.104.0",
"@tanstack/react-query": "^5.90.5",
"dplayer": "^1.27.1",
@@ -22,7 +21,6 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.9.4",
"sweetalert2": "^11.26.25",
"zustand": "^5.0.8"
},
"devDependencies": {
@@ -1118,31 +1116,6 @@
"react": "^18.x || ^19.x"
}
},
"node_modules/@mantine/notifications": {
"version": "8.3.5",
"resolved": "https://registry.npmmirror.com/@mantine/notifications/-/notifications-8.3.5.tgz",
"integrity": "sha512-8TvzrPxfdtOLGTalv7Ei1hy2F6KbR3P7/V73yw3AOKhrf1ydS89sqV2ShbsucHGJk9Pto0wjdTPd8Q7pm5MAYw==",
"license": "MIT",
"dependencies": {
"@mantine/store": "8.3.5",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@mantine/core": "8.3.5",
"@mantine/hooks": "8.3.5",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/store": {
"version": "8.3.5",
"resolved": "https://registry.npmmirror.com/@mantine/store/-/store-8.3.5.tgz",
"integrity": "sha512-qN4fFsDMy86IV9oh1gZlDTv41RAsO0grjx90FGyT5QCv7NTgcavwxB74GBkhp45W8xn+Ms/awKy+6NxnmLmW1w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -2067,6 +2040,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -2148,16 +2122,6 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dplayer": {
"version": "1.27.1",
"resolved": "https://registry.npmmirror.com/dplayer/-/dplayer-1.27.1.tgz",
@@ -2576,6 +2540,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
@@ -2666,18 +2631,6 @@
"immediate": "~3.0.5"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz",
@@ -2793,15 +2746,6 @@
"integrity": "sha512-Mtnp+0qiZxgrH+TzVlzhWyZceHdAZ/UWK0/ju9U0HQeDpap1mZ8cC7H5wSI5mwgni6yeAjaxsTw0sbMK+aSuHw==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -2928,17 +2872,6 @@
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2976,12 +2909,6 @@
"react": "^19.1.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-number-format": {
"version": "5.4.5",
"resolved": "https://registry.npmmirror.com/react-number-format/-/react-number-format-5.4.5.tgz",
@@ -3126,22 +3053,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -3332,16 +3243,6 @@
],
"license": "MIT"
},
"node_modules/sweetalert2": {
"version": "11.26.25",
"resolved": "https://registry.npmmirror.com/sweetalert2/-/sweetalert2-11.26.25.tgz",
"integrity": "sha512-+hunCOJdJ6FLj04T9YSLvvZXRjsvIkTeTKP2e4VF8CaBias961BTnWiSFAy7F/CM5eq3QK2Rraoc5Gzftslvkg==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/limonte"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
-2
View File
@@ -20,7 +20,6 @@
"dependencies": {
"@mantine/core": "^8.3.5",
"@mantine/hooks": "^8.3.5",
"@mantine/notifications": "^8.3.5",
"@tanstack/query-core": "^5.104.0",
"@tanstack/react-query": "^5.90.5",
"dplayer": "^1.27.1",
@@ -31,7 +30,6 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.9.4",
"sweetalert2": "^11.26.25",
"zustand": "^5.0.8"
},
"devDependencies": {
+1 -2
View File
@@ -1,6 +1,5 @@
import { useEffect, type ReactNode } from 'react';
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { QueryClientProvider } from '@tanstack/react-query';
import type { AppRuntime } from '../runtime.ts';
import { RuntimeContext } from './runtime-context.tsx';
@@ -34,7 +33,7 @@ export function AppProviders({ runtime, children }: { runtime: AppRuntime; child
return <RuntimeContext value={runtime}>
<QueryClientProvider client={runtime.queryClient}>
<MantineProvider forceColorScheme={dark ? 'dark' : 'light'}>
<Notifications />{children}
{children}
</MantineProvider>
</QueryClientProvider>
</RuntimeContext>;
+1 -1
View File
@@ -63,7 +63,7 @@ export const routes = [{
{ path: 'settings', element: <Navigate to="/shelf" replace /> },
{ path: 'book/:bookId', element: <ReaderPage /> },
// 我的 shares MediaLayout so it follows the chosen theme and keeps the channel rail.
// Its settings rows open /me/:panel (theme, browse, playback, plugins, account),
// Its settings rows open /me/:panel (theme, browse, playback, account),
// so back from any of them returns to 我的.
{ path: 'me', element: <MediaLayout />, children: [
{ index: true, element: <MinePage /> },
+3 -4
View File
@@ -1,13 +1,13 @@
import { Link } from 'react-router-dom';
import { useState, type ReactNode } from 'react';
import { ChevronRight, Film, LayoutGrid, LibraryBig, LogOut, Palette, PlayCircle, Puzzle, RefreshCw, Rss, Settings, SlidersHorizontal, UserRound, type LucideIcon } from 'lucide-react';
import { ChevronRight, Film, LayoutGrid, LibraryBig, LogOut, Palette, PlayCircle, RefreshCw, Rss, Settings, SlidersHorizontal, UserRound, type LucideIcon } from 'lucide-react';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
import { themeSummary } from '../../../shared/theme/theme.ts';
import { useThemeState } from '../../../shared/theme/use-theme.ts';
import { readMediaPreferences } from '../../media/services/preferences.ts';
import { DENSITY_LABELS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx';
import { DENSITY_LABELS, ShelfSettingsSheet } from '../../../ui/shelf-settings.tsx';
import { OpdsAccess } from '../../../ui/opds-access.tsx';
import { SystemSettings } from '../../../ui/system-settings.tsx';
import '../../home/styles/home.css';
@@ -75,7 +75,6 @@ export function MinePage() {
{admin && <Group title="管理" order={3}>
<Row icon={Settings} label="系统设置" value="用户、注册、服务配置" onClick={() => setSheet('system')} />
<Row icon={RefreshCw} label="扫描与刮削" to="/media/video/settings/tasks" state={{ returnTo: '/me' }} />
<Row icon={Puzzle} label="来源与刮削" to="/me/plugins" />
</Group>}
</div>
<div className="mine-column">
@@ -93,7 +92,7 @@ export function MinePage() {
</div>
</div>
{/* Outside the media screen, so its sheet styles are the shelf's own, not media's. */}
<ShelfSettingsPanel open={sheet === 'shelf'} settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} />
{sheet === 'shelf' && <ShelfSettingsSheet settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} />}
{sheet === 'opds' && <OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} onClose={() => setSheet('shelf')} />}
{sheet === 'system' && <SystemSettings api={runtime.api} onClose={() => setSheet(null)} />}
</>;
@@ -1,13 +1,11 @@
import { Heart, X } from 'lucide-react';
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { Heart } from 'lucide-react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { MediaApi } from '../api/media-api.ts';
import { Sheet } from '../../../ui/sheet.tsx';
/** The audio player's menus: the app's one sheet, with the audio content styles. */
export function AudioToolSheet({title,onClose,children}:{title:string;onClose:()=>void;children:ReactNode}){
const ref=useRef<HTMLDialogElement>(null);
useLayoutEffect(()=>{const dialog=ref.current;if(!dialog)return;const previous=document.activeElement as HTMLElement|null;dialog.showModal();return ()=>{dialog.close();queueMicrotask(()=>{if(previous?.isConnected)previous.focus({preventScroll:true});});};},[]);
return <dialog ref={ref} className="media-audio-sheet" aria-label={title} onCancel={event=>{event.preventDefault();onClose();}} onKeyDown={event=>{if(event.key==='Escape')event.stopPropagation();}} onClick={event=>{if(event.target===event.currentTarget){const rect=event.currentTarget.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)onClose();}}}>
<header><h2>{title}</h2><button aria-label="关闭播放菜单" onClick={onClose}><X size={20} aria-hidden="true"/></button></header>{children}
</dialog>;
return <Sheet className="media-audio-sheet" title={title} onClose={onClose}>{children}</Sheet>;
}
export function PlaybackFavorite({api,itemId,onChanged}:{api:MediaApi;itemId:string;onChanged?:(id:string,favorite:boolean)=>void}){
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {Trash2} from 'lucide-react';
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import type {MediaApi} from '../api/media-api.ts';
export interface CleanupPreview {
@@ -32,6 +32,6 @@ export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned,menu
return <>
<>{menuOnly?<button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button>:<div className="media-folder-operation"><span><strong>失效资源</strong><small>检查当前目录及子目录的缺失记录</small></span><button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button></div>}</>
{message&&<p role="status">{message}</p>}{error&&<p className="media-error" role="alert">{error}</p>}
{preview&&<FloatingConfirm theme="media" title="清理失效资源" confirmText="确认清理" cancelText="取消" onCancel={()=>setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>}
{preview&&<ConfirmSheet danger title="清理失效资源" confirmText="确认清理" cancelText="取消" onCancel={()=>setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>}
</>;
}
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import {Settings2} from 'lucide-react';
import '../styles/folder-recognition.css';
import {Modal} from '../../../ui/modal.tsx';
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet, Sheet } from '../../../ui/sheet.tsx';
import {MediaSelect} from './select.tsx';
import type {MediaApi} from '../api/media-api.ts';
type Mode='auto'|'movie'|'series'|'season'|'ignore';
@@ -42,7 +41,7 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied,
const selectRows=(rows:Proposal[])=>setSelected(current=>[...new Set([...current,...rows.filter(selectable).map(row=>row.assetId)])]);
return <>
<>{menuOnly?<button disabled={disabled} onClick={show}><Settings2 size={16} aria-hidden="true"/>识别规则与预览</button>:<div className="media-folder-operation"><span><strong>影视识别</strong><small>设置当前目录及子目录的识别规则</small></span><button disabled={disabled} onClick={show}><Settings2 size={16} aria-hidden="true"/>识别规则与预览</button></div>}</>
{open&&<Modal className="media-modal" title="目录识别规则" busy={busy} onClose={()=>setOpen(false)}><div className="media-form media-recognition-form">
{open&&<Sheet className="media-modal" size="wide" title="目录识别规则" busy={busy} onClose={()=>setOpen(false)}><div className="media-form media-recognition-form">
<p className="media-folder-note">{path||'库内根目录'} · 包含子目录,子目录自己的规则优先。目录内文件统一应用下方文件正则。规则保存后用于后续扫描;已有作品通过下方预览确认重新识别。</p>
{inherited&&<p>继承自「{inherited.path||'库内根目录'}」:{labels[inherited.mode]}{inherited.title?' · '+inherited.title:''}</p>}
{error&&<p className="media-error" role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
@@ -69,7 +68,7 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied,
{preview.items.slice(page*30,page*30+30).map(row=><label key={row.assetId} className="media-recognition-row"><input type="checkbox" aria-label={'应用 '+row.ref} disabled={busy||!['ready','review'].includes(row.status)} checked={selected.includes(row.assetId)} onChange={e=>setSelected(e.currentTarget.checked?[...selected,row.assetId]:selected.filter(id=>id!==row.assetId))}/><span><strong>{row.ref}</strong><small>当前:{row.before?.title??'未关联'}</small><b>{description(row)}</b><small>{({ready:'可应用',review:'待确认',protected:'保留现有',ignored:'已忽略'} as const)[row.status]} · {row.reason}</small></span></label>)}
{preview.items.length>30&&<nav className="media-toolbar" aria-label="识别预览分页"><button disabled={busy||!page} onClick={()=>setPage(page-1)}>上一页</button><span>{page+1} / {Math.ceil(preview.items.length/30)}</span><button disabled={busy||(page+1)*30>=preview.items.length} onClick={()=>setPage(page+1)}>下一页</button></nav>}
</section>}
</div></Modal>}
{confirm&&<FloatingConfirm theme="media" title="确认重新识别" text={`将按预览调整 ${selected.length} 个文件的标题和作品归属。同一剧、季、集的版本可能合并到一个作品,收藏随之保留。不修改原文件,保留播放进度与队列。`} confirmText="应用识别结果" onCancel={()=>setConfirm(false)} onConfirm={()=>{setConfirm(false);void work(async signal=>{await api.request(base+'/recognition-apply','POST',{path,previewId:preview!.id,assetIds:selected},signal);if(!signal.aborted){setOpen(false);onApplied();}});}}/>}
</div></Sheet>}
{confirm&&<ConfirmSheet title="确认重新识别" text={`将按预览调整 ${selected.length} 个文件的标题和作品归属。同一剧、季、集的版本可能合并到一个作品,收藏随之保留。不修改原文件,保留播放进度与队列。`} confirmText="应用识别结果" onCancel={()=>setConfirm(false)} onConfirm={()=>{setConfirm(false);void work(async signal=>{await api.request(base+'/recognition-apply','POST',{path,previewId:preview!.id,assetIds:selected},signal);if(!signal.aborted){setOpen(false);onApplied();}});}}/>}
</>;
}
@@ -1,4 +1,4 @@
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import { useEffect, useRef, useState } from 'react';
import type { Library, MediaApi, MediaChannel } from '../api/media-api.ts';
import { newId } from '../../../core/id.ts';
@@ -37,7 +37,7 @@ export function MediaLibraryCreate({ api, channel, disabled, onCreated, onJobs,o
};
return <section className="media-library-create">
{!hideHeader && <h2>新建{labels[channel]}媒体库</h2>}
{leaving&&<FloatingConfirm theme="media" title="放弃新建媒体库?" text="媒体库尚未创建,离开后将丢弃已填写的内容。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel?.();}}/>}
{leaving&&<ConfirmSheet danger title="放弃新建媒体库?" text="媒体库尚未创建,离开后将丢弃已填写的内容。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel?.();}}/>}
{error && <p className="media-error" role="alert">{error}</p>}
{created ? <>
<p role="status">媒体库“{created.name}”已创建。{scanStarted ? '扫描请求已提交,可查看任务结果。' : '尚未确认扫描启动,请先查看任务;如无任务,再启动扫描。'}</p>
@@ -1,4 +1,4 @@
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import { useEffect, useRef, useState } from 'react';
import type { Library, LibraryConfiguration, MediaApi } from '../api/media-api.ts';
import {ChevronLeft} from 'lucide-react';
@@ -49,7 +49,7 @@ export function MediaLibraryEditor({ api, library, onSaved, onCancel }: {
};
return <section className="media-library-workspace"><header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="返回媒体库" disabled={saving} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>编辑媒体库</h1></header>
{leaving&&<FloatingConfirm theme="media" title="放弃媒体库修改?" text="媒体库修改尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
{leaving&&<ConfirmSheet danger title="放弃媒体库修改?" text="媒体库修改尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
<form className="media-form media-library-editor" aria-label="媒体库配置" onSubmit={event => { event.preventDefault(); void save(); }}>
{error && <div role="alert" className="media-error">{error}{!configuration && <button type="button" onClick={() => setAttempt(value => value + 1)}>重试读取配置</button>}</div>}
{!configuration && !error && <FloatingNotice message="正在读取媒体库配置…" busy />}
@@ -5,7 +5,7 @@ import {Search} from 'lucide-react';
import {MediaLoading} from './loading.tsx';
import {MediaScreenError} from './screen-error.tsx';
import {ApiError} from '../../../api/errors.ts';
import {Modal} from '../../../ui/modal.tsx';
import {ConfirmSheet} from '../../../ui/sheet.tsx';
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
interface Provider { id: string; label: string; kinds: string[]; configured: boolean }
@@ -123,10 +123,13 @@ export function MetadataMatcher({ api, item, onUpdated,layout='inline',onBusyCha
<div className="media-match-copy"><strong>{candidate.title}</strong><small>{candidate.year || ''} {candidate.artist || ''} · {candidate.provider} / {candidate.externalId}</small>{candidate.evidence&&<small>{({strong:'较强匹配',review:'需要核对',conflict:'存在冲突'})[candidate.evidence.level]}:{candidate.evidence.reasons.join('、')}</small>}{candidate.description && <small className="media-match-excerpt">{candidate.description.length>160?candidate.description.slice(0,160)+'…':candidate.description}</small>}{candidate.description&&candidate.description.length>160&&<details className="media-match-description"><summary>完整简介</summary><p tabIndex={0} aria-label="候选完整简介">{candidate.description}</p></details>}</div>
<button aria-label="预览匹配" disabled={busy} onClick={() => setSelected(candidate)}>{layout==='page'?'选择':'预览匹配'}</button>
</div>)}
{selected && <Modal className="media-modal" title="确认元数据匹配" busy={busy} onClose={()=>{if(!busy)setSelected(null);}}><div className="media-match-confirm" aria-label="确认元数据匹配"><p>将“{item.title}”匹配为“{selected.title}”。在线标题、年份和简介等字段将采用此来源;人工编辑的字段仍优先显示。</p><div className="dialog-actions"><button className="media-primary" disabled={busy} onClick={() => void run(async signal => {
{selected && <ConfirmSheet title="确认元数据匹配" busy={busy} confirmText="确认此匹配"
text={`将“${item.title}”匹配为“${selected.title}”。在线标题、年份和简介等字段将采用此来源;人工编辑的字段仍优先显示。`}
onCancel={() => setSelected(null)}
onConfirm={() => void run(async signal => {
const detail = await api.request<Detail>(`items/${item.id}/match`, 'PUT', { candidateId: selected.candidateId }, signal);
if (!signal.aborted) { onUpdated(detail); setSelected(null); setCandidates([]); setSearched(false); }
})}>确认此匹配</button><button disabled={busy} onClick={()=>setSelected(null)}>取消</button></div></div></Modal>}
})} />}
<small>TMDB 数据来源于 TMDB,本应用未经 TMDB 认可或认证。MusicBrainz 数据来源于 MetaBrainz 社区。</small>
</Wrapper>;
}
@@ -1,4 +1,4 @@
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import { useEffect, useRef, useState } from 'react';
import {ChevronLeft} from 'lucide-react';
import type {Detail,MediaApi} from '../api/media-api.ts';
@@ -18,7 +18,7 @@ export function MediaMetadataPage({api,item,initialView,onViewChange,onUpdated,o
const requestLeave=(next:MetadataView|'back')=>{if(busy)return;if(view==='edit'&&editState.dirty)setDestination(next);else navigate(next);};
return <><header className="media-heading"><div className="media-page-heading"><button className="media-back-button" aria-label="返回作品详情" disabled={busy} onClick={()=>requestLeave('back')}><ChevronLeft size={20} aria-hidden="true"/></button><h1>{view==='edit'?'元数据':'匹配作品'}</h1></div><button className="media-metadata-mode" disabled={busy} onClick={()=>requestLeave(view==='edit'?'match':'edit')}>{view==='edit'?'在线匹配':'手动编辑'}</button></header>
<article className="media-metadata-page" data-view={view} ref={page}>
{destination&&<FloatingConfirm theme="media" title="放弃未保存的修改?" text="有未保存的修改。离开后本次修改会丢失,已保存的资料不受影响。" confirmText="放弃修改并离开" cancelText="继续编辑" onCancel={()=>setDestination(null)} onConfirm={()=>navigate(destination)}/>}
{destination&&<ConfirmSheet danger title="放弃未保存的修改?" text="有未保存的修改。离开后本次修改会丢失,已保存的资料不受影响。" confirmText="放弃修改并离开" cancelText="继续编辑" onCancel={()=>setDestination(null)} onConfirm={()=>navigate(destination)}/>}
{notice&&<p role="status" className="media-metadata-notice">{notice}</p>}
{view==='edit'?<MetadataEditor api={api} item={item} onStateChange={setEditState} onUpdated={updated=>{setNotice('已保存修改。');onUpdated(updated);}}/>:<MetadataMatcher api={api} item={item} layout="page" onBusyChange={setMatching} onUpdated={updated=>{setNotice('在线匹配已更新,人工修正已保留。');onUpdated(updated);}}/>}
</article>
@@ -1,4 +1,4 @@
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import {MediaSelect} from './select.tsx';
import { useEffect, useState, useRef } from 'react';
import {ChevronLeft,Search} from 'lucide-react';
@@ -42,7 +42,7 @@ export function MediaPermissions({api,library,onSaved,onCancel}:{api:MediaApi;li
finally {if(pending.current===controller)pending.current=null;if(!controller.signal.aborted)setSaving(false);}
};
return <section className="media-permissions-page"><header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="返回媒体库" disabled={saving} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>访问权限</h1></header>
{leaving&&<FloatingConfirm theme="media" title="放弃访问权限修改?" text="访问权限尚未保存,离开后将丢弃本次选择。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
{leaving&&<ConfirmSheet danger title="放弃访问权限修改?" text="访问权限尚未保存,离开后将丢弃本次选择。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
<form className="media-form media-permissions-form" aria-label="媒体库访问权限" onSubmit={event=>{event.preventDefault();if(ready&&!saving)void save();}}>
<div className="media-permissions-intro"><h2>{library.name}</h2><p>允许访问的用户共享作品资料,收藏与进度各自独立。</p></div>
{error&&(ready?<div role="alert" className="media-error">{error}</div>:<MediaScreenError fullPage error={cause} message={error} busy={false} onRetry={()=>setAttempt(value=>value+1)}/>)}
@@ -1,29 +0,0 @@
import { useEffect, useState } from 'react';
import {ChevronRight,FileText,Search} from 'lucide-react';
import type {MediaApi} from '../api/media-api.ts';
import {MediaLoading} from './loading.tsx';
import {MediaScreenError} from './screen-error.tsx';
import {BusinessSettings} from '../../../ui/business-settings.tsx';
import {MediaSelect} from './select.tsx';
interface Provider {id:string;label:string;kinds:string[];configured:boolean}
const kinds:Record<string,string>={movie:'电影',series:'剧集',season:'季',episode:'单集',artist:'艺人',album:'专辑',track:'曲目',audiobook:'有声书'};
export function MediaProviderSettings({api}:{api:MediaApi}){
const [providers,setProviders]=useState<Provider[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[retry,setRetry]=useState(0),[selected,setSelected]=useState('');
const [cause,setCause]=useState<unknown>();
useEffect(()=>{const abort=new AbortController();setLoading(true);setError('');setCause(undefined);void api.request<{items:Provider[]}>('metadata/providers','GET',undefined,abort.signal).then(result=>{if(!abort.signal.aborted)setProviders(result.items);}).catch(error=>{if(!abort.signal.aborted){setError(error instanceof Error?error.message:'来源状态读取失败');setCause(error);}}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});return ()=>abort.abort();},[api,retry]);
const provider=providers.find(value=>value.id===selected);
return <div className="media-provider-settings">
<h2>元数据刮削</h2>
{loading&&<MediaLoading layout="list" square label="正在读取来源状态…" count={2}/>} {error&&<MediaScreenError error={cause} message={error} busy={loading} retryLabel="重试读取来源" onRetry={()=>setRetry(value=>value+1)}/>}
{!loading&&!error&&providers.map(value=><button key={value.id} className="media-provider-row" aria-expanded={selected===value.id} onClick={()=>setSelected(selected===value.id?'':value.id)}><Search size={20} aria-hidden="true"/><span><strong>{value.label}</strong><small>{value.kinds.map(kind=>kinds[kind]||kind).join('、')}</small></span><small className="media-status-badge" data-configured={value.configured}>{value.configured?'已配置':'未配置'}</small><ChevronRight size={16} aria-hidden="true"/></button>)}
{!loading&&!error&&!providers.length&&<p>服务器尚未提供在线元数据来源。</p>}
{provider&&!loading&&!error&&<section className="media-provider-note" aria-label={provider.label+'配置说明'}><h3>{provider.label}</h3><p>{provider.configured?'服务器已配置此来源,可以从作品详情搜索候选,或从扫描与刮削页面发起匹配。':'此来源尚未配置,配置完成后才能搜索和匹配元数据。'}</p>
{['tmdb','musicbrainz'].includes(provider.id)?<BusinessSettings selectControl={MediaSelect} api={api} group={provider.id} onSaved={()=>setRetry(value=>value+1)}/>:<p>配置方式由服务器提供此来源的适配器决定。</p>}
<button onClick={()=>{setSelected('');setRetry(value=>value+1);}}>刷新配置状态</button></section>}
<button className="media-provider-row" aria-expanded={selected==='local'} onClick={()=>setSelected(selected==='local'?'':'local')}><FileText size={20} aria-hidden="true"/><span><strong>本地资料</strong><small>NFO、内嵌标签、同目录图片</small></span><small className="media-status-badge">内置</small><ChevronRight size={16} aria-hidden="true"/></button>
{selected==='local'&&<p className="media-provider-note">扫描时读取本地资料,人工覆盖优先于资料源。在线匹配不会修改原始媒体文件。</p>}
<h2>内容来源</h2><p className="media-manager-note">阅读书源保持独立管理。TVBox 兼容由后续影音来源插件提供,当前没有配置入口。</p>
<p className="media-source-attribution">TMDB 数据来源于 TMDB,本应用未经 TMDB 认可或认证。MusicBrainz 数据来源于 MetaBrainz 社区。</p>
</div>;
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Ellipsis, FileText, Layers } from 'lucide-react';
import { Modal } from '../../../ui/modal.tsx';
import { Sheet } from '../../../ui/sheet.tsx';
import { ResourceInfo } from './resource-info.tsx';
import { EditionTools, type EditionDetailsProps } from './edition-details.tsx';
import type { MediaApi } from '../api/media-api.ts';
@@ -19,7 +19,7 @@ type ResourcePanelProps = {
};
/**
* Resource details are rendered in a modal. The legacy detail screen exposes
* Resource details are rendered in a sheet. The legacy detail screen exposes
* the entry point from the page action menu, so the summary trigger is opt-in
* for standalone consumers and hidden on the detail page.
*/
@@ -67,8 +67,9 @@ export function ResourcePanel({
}
const modal = panel && (
<Modal
<Sheet
className="media-modal"
size="wide"
title={panel === 'files' ? '资源信息' : panel === 'source' ? '来源' : manage ? '版本管理' : '播放版本'}
busy={!!editionOptions?.busy}
onClose={close}
@@ -80,16 +81,11 @@ export function ResourcePanel({
? sourceInfo
: <>{versionPicker}{manage && editionOptions && <EditionTools {...editionOptions} managementOnly />}</>}
</div>
</Modal>
</Sheet>
);
if (!showTrigger) return modal;
return <>{modal}<section className="media-resource-panel media-resource-trigger" onClick={event => {
if (event.target instanceof HTMLDialogElement && !editionOptions?.busy) {
const rect = event.target.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close();
}
}}>
return <>{modal}<section className="media-resource-panel media-resource-trigger">
<header>
<h2>资源信息</h2>
{(files.length > 0 || manage || versionPicker || sourceInfo) && <details className="media-actions" ref={menu} onKeyDown={event => {
@@ -1,4 +1,4 @@
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
import {MediaSelect} from './select.tsx';
import { ChevronDown, ChevronRight } from 'lucide-react';
@@ -113,6 +113,6 @@ export function ScrapeJobs({api,libraries,navigate}:{api:MediaApi;libraries:Libr
<button disabled={busy||loading||jobsState!=='ready'||running||!selected.length||!allowed.includes(kind)} onClick={()=>void action('scrape-jobs',{provider,itemIds:selected,...(matchMode!=='strong'?{matchMode}: {})})}>开始批量匹配</button>
</details>
{taskHistory}
{deleteId&&<FloatingConfirm theme="media" title="删除刮削任务" text="仅删除这条任务及结果记录,已保存的媒体资料和匹配候选保留。" confirmText="删除记录" cancelText="取消" onCancel={()=>setDeleteId('')} onConfirm={()=>{const id=deleteId;setDeleteId('');void action('scrape-jobs/'+encodeURIComponent(id),undefined,'DELETE');}}/>}
{deleteId&&<ConfirmSheet danger title="删除刮削任务" text="仅删除这条任务及结果记录,已保存的媒体资料和匹配候选保留。" confirmText="删除记录" cancelText="取消" onCancel={()=>setDeleteId('')} onConfirm={()=>{const id=deleteId;setDeleteId('');void action('scrape-jobs/'+encodeURIComponent(id),undefined,'DELETE');}}/>}
</section>;
}
@@ -1,14 +1,13 @@
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import {MediaSelect} from './select.tsx';
import {PlaybackSettings} from './playback-settings.tsx';
import {MediaThemeSettings} from './theme-settings.tsx';
import type {AppSettings} from '../../../store/settings.ts';
export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'plugins'|'account';
export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'account';
import { useState } from 'react';
import {ChevronLeft,ChevronRight,Clock3,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Puzzle} from 'lucide-react';
import {ChevronLeft,ChevronRight,Clock3,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound} from 'lucide-react';
import type {MediaPlayer} from '../services/player.ts';
import type {MediaApi,MediaAccount} from '../api/media-api.ts';
import {MediaProviderSettings} from './provider-settings.tsx';
import {defaultMediaPreferences,saveMediaPreferences,type MediaPreferences} from '../services/preferences.ts';
/**
@@ -27,17 +26,17 @@ export function MediaSettings({scope,preferences,player,admin,api: _api,account,
function save(){setError('');setSaved(false);try{const value=saveMediaPreferences(scope,draft);onSaved(value);setSaved(true);}catch(error){setError(error instanceof Error?error.message:'保存失败');}}
function change(patch:Partial<MediaPreferences>){setDraft({...draft,...patch});setSaved(false);}
function openBrowse(){if(!dirty)setDraft({...preferences});setError('');setSaved(false);setPanel('browse');}
const title=panel==='theme'?'外观与主题':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='plugins'?'来源与刮削':panel==='account'?'账号与连接':'影音设置';
const title=panel==='theme'?'外观与主题':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='account'?'账号与连接':'影音设置';
const link=(Icon:typeof Clock3,label:string,description:string,onClick:()=>void)=><button className="media-setting-row" onClick={onClick}><Icon size={19} strokeWidth={1.6} aria-hidden="true"/><span>{label}</span><small>{description}</small><ChevronRight size={16} aria-hidden="true"/></button>;
return <section className="media-settings" aria-label="影音设置">
<div className="media-settings-heading"><button className="media-back-button" data-media-back aria-label={panel==='home'?'← 返回影音':backLabel} title={panel==='home'?'返回影音':backLabel} onClick={back}><ChevronLeft size={20} aria-hidden="true"/></button><h1>{title}</h1></div>
{leaving&&<FloatingConfirm theme="media" title="放弃浏览偏好修改?" text="浏览偏好尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);setDraft({...preferences});setPanel('home');}}/>}
{leaving&&<ConfirmSheet danger title="放弃浏览偏好修改?" text="浏览偏好尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);setDraft({...preferences});setPanel('home');}}/>}
{panel==='home'?<>
<section className="media-settings-group"><h2>偏好</h2>
{link(SlidersHorizontal,'浏览偏好',preferences.density==='compact'?'紧凑':'宽松',openBrowse)}
{link(Headphones,'播放设置','倍速与连续播放',()=>setPanel('playback'))}
</section>
{admin&&<section className="media-settings-group"><h2>管理员</h2>{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}{_api&&link(Puzzle,'来源与刮削','TMDB、MusicBrainz 等资料来源',()=>setPanel('plugins'))}</section>}
{admin&&<section className="media-settings-group"><h2>管理员</h2>{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}</section>}
{onGeneral&&<section className="media-settings-group"><h2>通用</h2>{link(UserRound,'外观、账号与系统设置','在「我的」中管理',onGeneral)}</section>}
</>:panel==='theme'?<MediaThemeSettings scope={scope} {...(onReaderSettings?{onReaderSettings}:{})}/>:panel==='browse'?<form className="media-form media-settings-panel" onSubmit={event=>{event.preventDefault();save();}}>
<p>仅影响影音页面,在当前设备为此服务器和账号保存。</p>
@@ -47,6 +46,6 @@ export function MediaSettings({scope,preferences,player,admin,api: _api,account,
<label className="media-settings-check"><span>列表工具栏显示来源库</span><input type="checkbox" checked={draft.showLibraryName} onChange={event=>change({showLibraryName:event.currentTarget.checked})}/></label>
<div className="media-settings-footer"><button type="button" onClick={()=>{setDraft({...defaultMediaPreferences});setSaved(false);}}>恢复默认值</button><button className="media-primary" type="submit">保存偏好</button></div>
{saved&&<p role="status">影音偏好已保存</p>}{error&&<p role="alert">{error}</p>}
</form>:panel==='plugins'?admin&&_api?<MediaProviderSettings api={_api}/>:<section className="media-settings-panel" role="alert"><h2>需要管理员权限</h2><p>请返回影音设置查看可用功能。</p></section>:panel==='account'&&account?<section className="media-account-panel"><div className="media-account-avatar" aria-hidden="true">{Array.from(account.displayName||account.username)[0]}</div><h2>{account.displayName||account.username}</h2><p>@{account.username} · {account.role==='admin'?'管理员':'普通用户'}</p><p className="media-manager-note">共享作品资料,收藏、队列和播放进度按账号隔离。</p><dl><dt>当前服务器</dt><dd>{account.server||window.location.origin}</dd></dl></section>:<PlaybackSettings scope={scope} player={player} {...(onPlayback?{onPlayback}:{})}/>}
</form>:panel==='account'&&account?<section className="media-account-panel"><div className="media-account-avatar" aria-hidden="true">{Array.from(account.displayName||account.username)[0]}</div><h2>{account.displayName||account.username}</h2><p>@{account.username} · {account.role==='admin'?'管理员':'普通用户'}</p><p className="media-manager-note">共享作品资料,收藏、队列和播放进度按账号隔离。</p><dl><dt>当前服务器</dt><dd>{account.server||window.location.origin}</dd></dl></section>:<PlaybackSettings scope={scope} player={player} {...(onPlayback?{onPlayback}:{})}/>}
</section>;
}
@@ -1,6 +1,7 @@
import {VideoEpisodeRail} from './video-episode-rail.tsx';
import {SleepTimerOptions} from './sleep-timer.tsx';
import {Ellipsis,X,Square,Check,Subtitles,AudioLines,Layers,ListVideo,Maximize,Minimize,Lock,LockOpen,Gauge,Expand,Shrink,Upload} from 'lucide-react';
import {Sheet} from '../../../ui/sheet.tsx';
import {Ellipsis,Square,Check,Subtitles,AudioLines,Layers,ListVideo,Maximize,Minimize,Lock,LockOpen,Gauge,Expand,Shrink,Upload} from 'lucide-react';
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
import {subtitleSizes,type MediaPlayer,type SubtitleSize} from '../services/player.ts';
import type {Detail,MediaApi} from '../api/media-api.ts';
@@ -63,11 +64,7 @@ function Volume({player}:{player:MediaPlayer}){
}
function ToolSheet({tool,onClose,children,tabs}:{tool:Tool;onClose:()=>void;children:ReactNode;tabs?:ReactNode}){
const ref=useRef<HTMLDialogElement>(null);
useLayoutEffect(()=>{const dialog=ref.current;if(!dialog)return;dialog.showModal();return ()=>dialog.close();},[]);
return <dialog ref={ref} className="media-video-sheet" aria-label={titles[tool]} onCancel={event=>{event.preventDefault();onClose();}} onKeyDown={event=>{if(event.key==='Escape')event.stopPropagation();}} onClick={event=>{if(event.target===event.currentTarget){const rect=event.currentTarget.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)onClose();}}}>
<header><h2>{titles[tool]}</h2><button className="media-video-icon" aria-label="关闭播放菜单" onClick={onClose}><X size={20} aria-hidden="true"/></button></header>{tabs}{children}
</dialog>;
return <Sheet className="media-video-sheet" title={titles[tool]} onClose={onClose}>{tabs}{children}</Sheet>;
}
/** Rendered in the permanent player's controls host, alongside the live video. */
@@ -8,7 +8,7 @@ import { queueQuery, type Activity } from '../queries/media.queries.ts';
import { channelLabels } from '../hooks/use-media-channel.ts';
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
import { SavedQueue } from '../components/saved-queue.tsx';
import { FloatingConfirm } from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
function queueChannel(kind: string): 'video' | 'music' | 'audiobook' {
return kind === 'audiobook' ? 'audiobook' : ['album', 'artist', 'track'].includes(kind) ? 'music' : 'video';
@@ -72,6 +72,6 @@ export function MediaQueuePage() {
onClear={() => setConfirming(true)}
/>}
{!query.isPending && !query.error && entries.length === 0 && <div className="media-personal-empty"><ListMusic size={30} strokeWidth={1.4} aria-hidden="true" /><p>暂无记录。</p></div>}
{confirming && <FloatingConfirm theme="media" title="清空队列" text={`移除${channel ? `当前${channel === 'video' ? '影视' : channel === 'music' ? '音乐' : '有声书'}` : '全部'}队列中的 ${entries.length} 条记录?不会删除文件或停止当前播放。`} confirmText="确认清空" cancelText="保留队列" onConfirm={() => { setConfirming(false); action.mutate({ type: 'clear' }); }} onCancel={() => setConfirming(false)} />}
{confirming && <ConfirmSheet danger title="清空队列" text={`移除${channel ? `当前${channel === 'video' ? '影视' : channel === 'music' ? '音乐' : '有声书'}` : '全部'}队列中的 ${entries.length} 条记录?不会删除文件或停止当前播放。`} confirmText="确认清空" cancelText="保留队列" onConfirm={() => { setConfirming(false); action.mutate({ type: 'clear' }); }} onCancel={() => setConfirming(false)} />}
</section></MediaPageFrame>;
}
@@ -7,10 +7,10 @@ import type { SettingsPanel } from '../components/settings.tsx';
import { readMediaPreferences } from '../services/preferences.ts';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'plugins', 'account'];
const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'account'];
const titles: Record<SettingsPanel, string> = {
home: '影音设置', theme: '外观与主题', browse: '浏览偏好', playback: '播放设置',
plugins: '来源与刮削', account: '账号与连接',
account: '账号与连接',
};
function safePanel(value: string | undefined): SettingsPanel {
+12 -40
View File
@@ -2,7 +2,7 @@
brand *text* (links, selected icons, outlines); --media-fill is a brand-filled
control and --media-on-accent what sits on it. Dialogs and confirmations opened
from media read the same names. */
.media-screen, .media-player, .media-channel-entry, .media-modal, .confirmation-host[data-media-theme=true] {
.media-screen, .media-player, .media-channel-entry, .media-modal {
--text: var(--color-ink);
--muted: var(--color-muted);
--surface: var(--color-surface-2);
@@ -25,8 +25,9 @@
body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-screen { max-width:1120px; padding:62px 30px 150px; background:var(--media-background); }
.media-screen button, .media-screen input, .media-screen select, .media-screen textarea { font:inherit; }
.media-screen button { border:0; background:transparent; border-radius:7px; min-height:38px; padding:9px 12px; }
.media-screen button:hover:not(:disabled) { background:var(--surface); }
/* The shared sheet's header and footer use the app's own buttons, not media's. */
.media-screen button:not(:where(.sheet-header *, .sheet-footer *)) { border:0; background:transparent; border-radius:7px; min-height:38px; padding:9px 12px; }
.media-screen button:not(:where(.sheet-header *, .sheet-footer *)):hover:not(:disabled) { background:var(--surface); }
.media-screen button:focus-visible, .media-channel-entry a:focus-visible { outline:2px solid var(--media-accent); outline-offset:3px; }
.media-screen input:not([type=range]):not([type=checkbox]):not([type=radio]), .media-screen textarea {
background:var(--media-paper); border:1px solid var(--border); border-radius:7px;
@@ -258,18 +259,14 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-lyrics-empty h3 {margin:0;font-size:16px;font-weight:500;color:var(--text)}
.media-lyrics-loading i {display:block;width:65%;height:12px;background:var(--border);border-radius:5px;opacity:.5}
.media-lyrics-loading i:nth-last-child(2) {width:80%}
.media-screen .media-audio-sheet {position:fixed;inset:auto 0 0;margin:0 auto;padding:20px 22px 25px;width:480px;max-width:100%;max-height:80dvh;box-sizing:border-box;overflow:auto;border:1px solid var(--border);border-bottom:0;border-radius:15px 15px 0 0;background:var(--media-paper);color:var(--text);font-size:14px;line-height:1.6;font-family:inherit;box-shadow:0 -10px 40px #0003}
.media-audio-sheet::backdrop {background:#10160c66}
.media-audio-sheet>header {display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.media-screen .media-audio-sheet h2 {font-size:16px;margin:0;font-weight:600}
.media-audio-sheet>header>button {display:grid;place-items:center;width:44px;height:44px;padding:10px;border:0;background:transparent}
.media-audio-sheet .sheet-body {font-size:14px;line-height:1.6}
.media-audio-sheet .media-volume,.media-audio-speed>label {display:flex;align-items:center;gap:12px;font-size:13px;min-height:48px}
.media-audio-sheet input[type=range] {flex:1;min-width:0;width:100%;accent-color:var(--media-accent);padding:0;border:0;background:transparent}
.media-audio-speed select {flex:1;min-width:0}
.media-audio-shortcuts {display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0}
.media-audio-shortcuts button,.media-audio-sheet .media-end-playback {display:flex;gap:8px;align-items:center;justify-content:center}
.media-audio-sheet>[aria-label="保存播放列表"] {margin:12px 0}
.media-audio-sheet>[aria-label="保存播放列表"] button {margin:4px 6px 4px 0}
.media-audio-sheet .sheet-body>[aria-label="保存播放列表"] {margin:12px 0}
.media-audio-sheet .sheet-body>[aria-label="保存播放列表"] button {margin:4px 6px 4px 0}
.media-audio-sheet p {font-size:12px;line-height:1.7;overflow-wrap:anywhere}
.media-audio-sheet .media-audio-support {color:var(--muted);margin:12px 0}
.media-audio-versions {display:grid;gap:8px}
@@ -277,7 +274,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-audio-versions strong {min-width:0;font-weight:500;overflow-wrap:anywhere}
.media-audio-versions small {flex-shrink:0;font-size:12px;color:var(--muted)}
.media-audio-versions button[aria-current=true] {border-color:var(--media-accent);background:var(--media-selection);opacity:1}
@media(max-width:700px){.media-screen .media-audio-sheet{padding:16px 16px calc(20px + env(safe-area-inset-bottom))}}
@media(max-width:700px){
.media-screen .media-heading:has(.media-mobile-title) {display:grid;grid-template-columns:1fr auto;gap:0;border:0}
.media-mobile-title {display:flex;align-items:center;min-height:49px;grid-row:1;grid-column:1}
@@ -742,10 +738,10 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-video-stage {aspect-ratio:16/9;background:#060906;display:flex;align-items:center;justify-content:center}
.media-video-stage video {display:block;width:100%;height:100%;max-height:none;object-fit:contain}
.media-video-workspace .media-video-controls {display:block;max-width:none;max-height:none;padding:15px 20px 30px;margin:0;border:0;border-radius:0;background:transparent;overflow:visible}
.media-video-workspace button,.media-video-workspace select,.media-video-sheet button,.media-video-sheet input,.media-video-sheet select {font:inherit;color:inherit;box-sizing:border-box;min-height:44px;border:0;border-radius:7px;padding:9px 12px;background:transparent}
.media-video-workspace button,.media-video-sheet button {cursor:pointer}
.media-video-workspace button:disabled,.media-video-sheet button:disabled {opacity:.45;cursor:default}
.media-video-workspace button:hover:not(:disabled),.media-video-sheet button:hover:not(:disabled) {background:var(--surface)}
.media-video-workspace button,.media-video-workspace select,.media-video-sheet .sheet-body button,.media-video-sheet .sheet-body input,.media-video-sheet .sheet-body select {font:inherit;color:inherit;box-sizing:border-box;min-height:44px;border:0;border-radius:7px;padding:9px 12px;background:transparent}
.media-video-workspace button,.media-video-sheet .sheet-body button {cursor:pointer}
.media-video-workspace button:disabled,.media-video-sheet .sheet-body button:disabled {opacity:.45;cursor:default}
.media-video-workspace button:hover:not(:disabled),.media-video-sheet .sheet-body button:hover:not(:disabled) {background:var(--surface)}
.media-video-workspace :focus-visible,.media-video-sheet :focus-visible {outline:2px solid var(--media-accent);outline-offset:2px}
.media-video-toolbar,.media-video-toolbar>div {display:flex;align-items:center;gap:6px;min-width:0}
.media-video-toolbar {justify-content:space-between;margin:5px 0}
@@ -768,10 +764,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-video-workspace .media-playback-problem .media-primary {background:var(--media-fill);color:var(--media-on-accent)}
.media-video-controls .media-screen-error {margin:18px 0;font-size:12px}
.media-video-controls .media-screen-error-actions {display:flex;gap:8px}
.media-video-sheet {position:fixed;inset:auto 0 0;margin:0 auto;padding:20px 22px 25px;border:1px solid var(--border);border-bottom:0;border-radius:15px 15px 0 0;width:540px;max-width:100%;max-height:80dvh;box-sizing:border-box;overflow:auto;background:var(--media-paper);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;box-shadow:0 -10px 50px #0004}
.media-video-sheet::backdrop {background:#10160c88}
.media-video-sheet header {display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}
.media-video-sheet h2 {font-size:17px;margin:0;font-weight:600}
.media-video-sheet .sheet-body {font-size:14px;line-height:1.6}
.media-video-sheet p {font-size:12px;line-height:1.75;color:var(--muted);overflow-wrap:anywhere}
.media-video-sheet .media-volume,.media-video-sleep {display:flex;align-items:center;justify-content:space-between;gap:16px}
.media-video-sheet .media-volume input {flex:1;width:100%;min-width:0;accent-color:var(--media-accent);padding:0}
@@ -795,7 +788,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-player.media-player-expanded:not(:is(.media-player-audio,.media-player-native-video)) {inset:49px 0 0;width:100%}
.media-video-stage {aspect-ratio:16/10}
.media-video-workspace .media-video-controls {padding:13px 13px 25px}
.media-video-sheet {padding:16px 16px calc(20px + env(safe-area-inset-bottom))}
}
@media(min-width:700px) and (max-height:500px){
.media-video-workspace {display:grid;grid-template-columns:58% minmax(0,1fr);align-items:start}
@@ -841,19 +833,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-permission-pagination {font-size:12px;color:var(--muted);margin:0}
.media-user-option>span {min-width:0;overflow-wrap:anywhere;line-height:1.6}
.media-user-option>input {flex-shrink:0}
.media-provider-settings {padding-top:24px}
.media-provider-settings>h2 {margin:0 0 4px;font-size:16px}
.media-provider-settings>h2:not(:first-child) {margin-top:28px}
.media-screen .media-provider-row {display:flex;align-items:center;width:100%;gap:12px;min-height:70px;padding:13px 0;border-bottom:1px solid var(--border);border-radius:0;text-align:left}
.media-provider-row>svg {flex-shrink:0;color:var(--muted)}
.media-provider-row>span {flex:1;min-width:0;overflow-wrap:anywhere}
.media-provider-row strong {font-size:14px;font-weight:500}
.media-provider-row span>small {display:block;color:var(--muted);font-size:12px;margin-top:4px}
.media-status-badge {display:inline-block;font-size:10px;background:var(--media-selection);color:var(--media-accent);padding:3px 6px;border-radius:4px;white-space:nowrap}
.media-provider-note {background:var(--surface);border-radius:8px;padding:14px;font-size:12px;color:var(--muted);line-height:1.8;margin:12px 0}
.media-provider-note h3 {margin:0;color:var(--text);font-size:14px}
.media-provider-note code {overflow-wrap:anywhere;font-size:12px}
.media-source-attribution {font-size:11px;color:var(--muted);line-height:1.8;margin-top:24px}
.media-account-panel {max-width:640px;margin:32px auto}
.media-account-avatar {display:grid;place-items:center;width:60px;height:60px;border-radius:50%;background:var(--media-selection);color:var(--media-accent);font-size:26px}
.media-account-panel h2 {font-size:23px;margin:16px 0 4px;overflow-wrap:anywhere}
@@ -912,7 +892,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-screen .media-resource-panel>header h2 {margin:0;font-size:16px}
.media-resource-panel>p {font-size:12px;line-height:1.8;color:var(--muted);margin:8px 0 20px}
.media-resource-panel .media-actions nav button {display:flex;align-items:center;gap:8px}
.media-resource-panel .source-modal {width:min(620px,calc(100vw - 28px));max-height:85dvh;padding:20px;color:var(--text);background:var(--media-paper);border:1px solid var(--border);border-radius:12px}
.media-resource-dialog {overflow-wrap:anywhere;font-size:13px;line-height:1.8}
.media-resource-expanded {padding:16px 0;border-bottom:1px solid var(--border)}
.media-resource-expanded:last-child {border-bottom:0}
@@ -922,10 +901,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-search-count {font-size:12px;color:var(--muted);margin:12px 0}
/* Resource dialogs: one compact header, with a separately scrolling body. */
.media-resource-panel .source-modal {padding:0;box-sizing:border-box}
.media-resource-panel .source-modal-header {min-height:56px;box-sizing:border-box;padding:6px 12px 6px 18px;gap:12px;border-color:var(--border)}
.media-resource-panel .source-modal-header h2 {margin:0;line-height:24px;font-size:16px}
.media-resource-panel .source-modal-header button {display:grid;place-items:center;width:44px;height:44px;min-height:44px;padding:10px;flex:none;color:var(--text)}
.media-resource-dialog {padding:4px 18px 16px;overflow-y:auto;min-height:0;overscroll-behavior:contain}
.media-resource-dialog .media-edition-tools {margin:0}
.media-resource-dialog .media-edition-admin {border:0;padding:0}
@@ -969,9 +944,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
}
/* Media dialogs and player selects inherit the active media palette. */
.media-modal {background:var(--media-paper);color:var(--text);border-color:var(--border)}
.media-confirm-popup {border-color:var(--color-line)!important;background:var(--color-surface)!important;color:var(--color-ink)!important}
.media-confirm-popup .swal2-html-container {color:var(--color-muted)!important}
.media-video-workspace select,.media-video-sheet select,.media-subtitle-controls select {appearance:none;-webkit-appearance:none;border:1px solid var(--border);background:var(--surface);color:var(--text);padding-right:30px;color-scheme:inherit}
.media-video-workspace select,.media-video-sheet select,.media-subtitle-controls select {background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
.media-native-controls select {appearance:none;-webkit-appearance:none;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 30px 7px 10px;background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
+2 -11
View File
@@ -2,7 +2,7 @@
DPlayer owns the video controls; this sheet only lays out its stage. */
.media-screen {padding:68px 32px 96px;font-size:14px;scrollbar-width:thin}
.media-channel-entry {height:68px;padding-inline:max(32px,calc((100vw - 1056px)/2))}
.media-screen button {min-height:44px;border-radius:9px}
.media-screen button:not(:where(.sheet-header *, .sheet-footer *)) {min-height:44px;border-radius:9px}
.media-screen :is(input:not([type=checkbox]):not([type=radio]):not([type=range]),select) {min-height:44px}
.media-screen :is(.media-heading,.media-settings-heading,.media-narrator-heading) {min-height:56px}
.media-screen :is(.media-page-heading h1,.media-page-heading h2,.media-heading>strong,.media-settings-heading h1) {font-size:16px;font-weight:650;line-height:1.4}
@@ -248,8 +248,6 @@
.searchable-select-options button {display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;min-height:38px;padding:8px 10px;border:0;border-radius:6px;background:transparent;color:var(--text);text-align:left}
.searchable-select-options button:hover,.searchable-select-options button[aria-selected=true] {background:var(--media-selection);color:var(--media-accent)}
.searchable-select-options p {padding:12px;color:var(--muted);font-size:12px}
.media-audio-sheet,.media-video-sheet {max-height:85dvh}
.media-audio-sheet>header,.media-video-sheet>header {height:56px;min-height:56px;position:sticky;top:-16px;background:inherit;z-index:2;margin-bottom:12px}
@media(min-width:701px){
.media-screen>.media-heading:has(.media-tabs:not([hidden])) {display:block;height:auto;min-height:0;margin:0;border:0;padding:0}
.media-screen .media-mobile-title {display:none}
@@ -265,7 +263,6 @@
.media-utility-workspace .media-settings-heading {left:max(24px,calc((100vw - 1060px)/2))}
.media-screen .media-mobile-title {height:68px;left:max(145px,calc((100vw - 1056px)/2 + 110px))}
.media-heading:has(.media-mobile-title) .media-heading-actions {top:12px}
.media-audio-sheet,.media-video-sheet {inset:0;margin:auto;border:1px solid var(--border);border-radius:14px;height:fit-content}
.media-screen .media-playback-status[data-panel=main] {display:grid;grid-template-columns:360px minmax(0,1fr);grid-template-rows:1fr auto auto auto 1fr;column-gap:64px;min-height:540px;align-content:center}
.media-playback-status[data-panel=main] .media-playing-artwork {display:contents}
.media-playback-status[data-panel=main] .media-playing-artwork>.media-cover {grid-column:1;grid-row:1/6;width:360px;margin:auto;align-self:center}
@@ -577,7 +574,6 @@
.media-video-workspace:has(.media-episode-rail) .media-episode-rail {position:absolute;right:0;top:16px;width:300px;box-sizing:border-box;margin:0}
}
@media(max-width:700px){
.media-resource-panel .source-modal {inset:auto 0 0;width:100%;max-width:100%;margin:0;border-radius:14px 14px 0 0;max-height:85dvh}
.media-screen {padding:0 16px calc(24px + env(safe-area-inset-bottom))}
.media-screen.media-catalog-page .media-heading:has(.media-mobile-title) {display:flex;grid-template-rows:none;min-height:56px;height:56px}
.media-screen .media-heading:has(.media-mobile-title) {grid-template-rows:56px 44px;min-height:100px}
@@ -913,14 +909,9 @@
.media-screen .media-actions nav :is(a,button) svg {flex:none;stroke:currentColor}
.media-screen .media-library-management-actions>summary {color:var(--text)}
.media-screen .media-library-management-actions[open]>summary {background:var(--media-selection);color:var(--media-accent)}
.media-modal:has(.media-match-confirm){width:min(560px,calc(100vw - 32px));max-width:560px;padding:0;border-radius:16px;overflow:hidden}
.media-modal:has(.media-match-confirm) .media-match-confirm{margin:0;padding:20px;border:0;border-radius:0;background:var(--media-paper)}
.media-modal:has(.media-match-confirm) .media-match-confirm p{font-size:14px;line-height:1.75;margin:0 0 18px}
.media-modal:has(.media-match-confirm) .media-match-confirm .dialog-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap}
.media-modal:has(.media-match-confirm) .media-match-confirm .dialog-actions button{min-height:44px}
.search-split{position:relative;isolation:isolate}
.sources-search .search-menu{right:0;left:auto;min-width:260px;box-sizing:border-box}
@media(max-width:640px){.media-screen .media-modal .media-match-confirm .dialog-actions{justify-content:stretch}.media-screen .media-modal .media-match-confirm .dialog-actions button{flex:1 1 140px}.sources-search .search-menu{right:0;min-width:min(290px,calc(100vw - 32px))}}
@media(max-width:640px){.sources-search .search-menu{right:0;min-width:min(290px,calc(100vw - 32px))}}
/* Keep the embedded player's own controls independent from the reader theme. */
.media-video-stage,.media-video-stage *{color-scheme:dark}
+5 -4
View File
@@ -2,12 +2,13 @@ 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, LibraryBig, MoreHorizontal, Search, SlidersVertical } from 'lucide-react';
import { BookOpen, LibraryBig, MoreHorizontal, Search } from 'lucide-react';
import type { Book, ContinueReadingItem, ReadingState } from '../../../api/types.ts';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
import { useShelf, useShelfGroups, type ShelfFilters } from '../queries/shelf.queries.ts';
import { SHELF_SORTS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx';
import { SHELF_SORTS, ShelfSettingsSheet } from '../../../ui/shelf-settings.tsx';
import { IconButton } from '../../../ui/toolkit.tsx';
import { OpdsAccess } from '../../../ui/opds-access.tsx';
import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
@@ -116,7 +117,7 @@ export function ShelfPage() {
<header className="shelf-head">
<div className="shelf-head-text"><h1 className="shelf-title">{queryText ? '搜索结果' : '书架'}</h1><p className="shelf-subtitle muted">{subtitle}</p></div>
<div className="shelf-head-actions">
<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>
<IconButton icon="tune" label={`书架设置 · ${densityLabel}`} onClick={() => setSettingsOpen(true)} />
</div>
</header>
<div className="shelf-discovery">
@@ -149,7 +150,7 @@ export function ShelfPage() {
{cards.length > 0 && <div className="book-grid" data-density={settings.shelfDensity} data-show-author={String(settings.shelfShowAuthor)} data-show-progress={String(settings.shelfShowProgress)}>{cards.map(book => <BookCard key={book.id} book={book} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} onRemove={() => setMenuTarget(book)} />)}</div>}
{query.data && pages > 1 && <nav className="shelf-pager" aria-label="翻页"><button type="button" disabled={page <= 1} onClick={() => go({ page: page - 1 })}>上一页</button><span>{page} / {pages}</span><button type="button" disabled={page >= pages} onClick={() => go({ page: page + 1 })}>下一页</button></nav>}
</section>
<ShelfSettingsPanel open={settingsOpen} settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} />
{settingsOpen && <ShelfSettingsSheet settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} />}
{opdsOpen && <OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} onClose={() => { setOpdsOpen(false); setSettingsOpen(true); }} />}
{menuTarget && <DialogView dialog={{ kind: 'pick', title: `《${menuTarget.title || '未命名'}》`, options: menuOptions(menuTarget), resolve: (_value: string | null) => undefined } satisfies Dialog} destructive="从书架拿掉" onClose={answer => { const target = menuTarget; setMenuTarget(null); if (typeof answer === 'string' && answer) void act(target, answer as BookAction); }} />}
{groupsOpen && <DialogView dialog={{ kind: 'pick', title: groups.isPending ? '分组 · 正在读取…' : groups.error ? '分组 · 读取失败' : '分组', options: groupOptions, resolve: (_value: string | null) => undefined } satisfies Dialog} destructive="" onClose={answer => {
@@ -0,0 +1,30 @@
import type { SourceEntry } from '../../../api/sources.ts';
import { Sheet } from '../../../ui/sheet.tsx';
import { Button } from '../../../ui/toolkit.tsx';
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
/**
* A source result's detail, shared by the source hub and a single source's catalog:
* they show the same entry and offer the same 加入书架 choices.
*/
export function SourceBookDetailSheet({ detail, pending, error, acquiring, onAcquire, onClose }: {
detail: SourceEntry;
pending: boolean;
error: Error | null;
acquiring: boolean;
onAcquire(entry: SourceEntry, option?: string): void;
onClose(): void;
}) {
const options = detail.options ?? [];
return <Sheet title={detail.title || '书籍详情'} onClose={onClose}
footer={options.length
? options.map(option => <Button key={option.id} className="primary" disabled={option.available === false || acquiring} onClick={() => onAcquire(detail, option.id)}>{option.label}</Button>)
: <Button className="primary" disabled={acquiring} onClick={() => onAcquire(detail)}>加入书架</Button>}>
<FloatingNotice message={pending ? '正在读取书籍详情…' : ''} busy />
<div className="book-detail">
<p className="book-detail-author">{detail.authors?.join('、') || '作者未知'}</p>
{error && <p className="notice error" role="alert">{error.message}</p>}
<p className="book-detail-description">{detail.description || '暂无简介'}</p>
</div>
</Sheet>;
}
@@ -1,4 +1,5 @@
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
import { Alert, Button, Group, Select, Text } from '@mantine/core';
import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -69,18 +70,7 @@ export function SourceCatalogPage() {
{busy && !page && <FloatingNotice message="正在搜索…" busy />}
<section className="sources-card"><div className="source-results">{page?.items.map(item => { const option = item.options?.[0]; return <article className="sources-row catalog-book" key={item.ref}><div><strong>{item.title}</strong>{item.latestChapter && <small>{item.latestChapter}</small>}{item.authors?.length ? <small>{item.authors.join('、')}</small> : null}{item.description && <p className="source-description">{item.description}</p>}</div><div className="sources-actions"><Button className="button" onClick={() => setDetail(item)}>详情</Button><Button className="button primary" disabled={option?.available === false} loading={acquire.isPending} onClick={() => acquireEntry(item, option?.id)}>{option?.label ?? '加入书架'}</Button></div></article>; })}</div></section>
{page && !busy && page.items.length === 0 && <Alert>没有找到匹配书籍。</Alert>}
<Modal opened={!!detail} onClose={() => setDetail(null)} title={detail?.title ?? '书籍详情'}>
<Stack>
{detailQuery.isPending && <FloatingNotice message="正在读取书籍详情…" busy />}
{detailQuery.error && <Alert color="red">{detailQuery.error.message}</Alert>}
{detail && <>
<Text>{detail.description || '暂无简介'}</Text>
<Text size="sm" c="dimmed">{detail.authors?.join('、') || '作者未知'}</Text>
{detail.options?.map(option => <Button key={option.id} disabled={option.available === false} loading={acquire.isPending} onClick={() => acquireEntry(detail, option.id)}>{option.label}</Button>)}
{(!detail.options || detail.options.length === 0) && <Button loading={acquire.isPending} onClick={() => acquireEntry(detail)}>加入书架</Button>}
</>}
</Stack>
</Modal>
{detail && <SourceBookDetailSheet detail={detail} pending={detailQuery.isPending} error={detailQuery.error} acquiring={acquire.isPending} onAcquire={acquireEntry} onClose={() => setDetail(null)} />}
</main>
</div>;
}
@@ -5,7 +5,7 @@ import type { ExtensionContent, ExtensionField, ExtensionForm, ExtensionPage } f
import { ApiError } from '../../../api/errors.ts';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { FloatingConfirm } from '../../../ui/floating-confirm.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
import { Button, IconButton } from '../../../ui/toolkit.tsx';
@@ -154,7 +154,7 @@ export function SourceExtensionPage() {
<main className="sources-body" aria-busy={busy} tabIndex={-1}>{page.description && <p className="extension-description">{page.description}</p>}
<ContentView content={page} scope="page" values={values} busy={busy} selected={selected} expanded={expanded} onChange={updateValue.bind(null)} onSubmit={submit} onSelectAction={selectAction} onToggle={toggleExpanded} onToggleSelected={toggleSelected} />
{selectedTab && <div role="tabpanel" id={`extension-panel-${selectedTab.id}`} aria-labelledby={`extension-tab-${selectedTab.id}`} tabIndex={-1}>{selectedTab.description && <p className="extension-description">{selectedTab.description}</p>}<ContentView content={selectedTab} scope={`tab:${selectedTab.id}`} values={values} busy={busy} selected={selected} expanded={expanded} onChange={updateValue.bind(null)} onSubmit={submit} onSelectAction={selectAction} onToggle={toggleExpanded} onToggleSelected={toggleSelected} /></div>}
</main>{confirmation && <FloatingConfirm title="确认操作" text={confirmation.form.confirm ?? ''} confirmText={`确认${confirmation.form.submit}`} cancelText="取消" onCancel={() => setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />}
</main>{confirmation && <ConfirmSheet title="确认操作" text={confirmation.form.confirm ?? ''} confirmText={`确认${confirmation.form.submit}`} cancelText="取消" onCancel={() => setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />}
</div>;
}
+12 -3
View File
@@ -1,4 +1,7 @@
import { Alert, Button, Modal, Stack, Text } from '@mantine/core';
import { Alert, Button } from '@mantine/core';
import { Sheet } from '../../../ui/sheet.tsx';
import { Button as UiButton } from '../../../ui/toolkit.tsx';
import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx';
import { Link, useNavigate } from 'react-router-dom';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -144,7 +147,13 @@ export function SourcesPage() {
{tab === 'updates' && <section className="sources-card"><h2>自动追更</h2><p className="notice">服务端定时检查目录,浏览器关闭后继续运行。正文和图片在阅读时缓存。</p>{subscriptions.isPending && <FloatingNotice message="正在加载追更列表…" busy />}{!subscriptions.isPending && !subscriptions.data?.length && <p>先从章节书源加入书籍,再开启追更。</p>}{(subscriptions.data ?? []).map(item => <article className="sources-row" key={item.bookId}><div><strong>{item.title}</strong><small>{item.newChapters ? '新增 ' + item.newChapters + ' 章 · ' : ''}上次成功:{item.lastSuccessAt ? new Date(item.lastSuccessAt).toLocaleString() : '尚未检查'}</small><small>{item.enabled ? '下次检查:' + new Date(item.nextCheckAt).toLocaleString() : '自动追更已关闭'}{item.lastError ? ' · 检查失败:' + item.lastError : ''}</small></div><div className="sources-actions"><label className="source-interval">检查间隔<select aria-label={`${item.title}检查间隔`} value={item.intervalMinutes} disabled={busy === item.bookId} onChange={event => { const intervalMinutes = Number(event.currentTarget.value); setBusy(item.bookId); void runtime.api.configureSubscription(item.bookId, { intervalMinutes }).then(() => queryClient.invalidateQueries({ queryKey: ['subscriptions'] })).finally(() => setBusy(null)); }}><option value="15">15 分钟</option><option value="60">1 小时</option><option value="360">6 小时</option><option value="1440">1 天</option></select></label><button className="button" type="button" disabled={busy === item.bookId} onClick={() => void mutateSubscription(item.bookId, !item.enabled)}>{item.enabled ? '关闭追更' : '开启追更'}</button><button className="button" type="button" disabled={busy === item.bookId} onClick={() => void mutateSubscription(item.bookId)}>立即检查</button><Link className="button" to={'/book/' + encodeURIComponent(item.bookId)}>阅读</Link></div></article>)}</section>}
{tab === 'plugins' && admin && <section className="sources-card"><h2>插件管理</h2><Link className="button primary" to="/sources/plugins">插件安装与管理</Link>{plugins.isPending && <FloatingNotice message="正在加载插件列表…" busy />}{(plugins.data ?? []).map(plugin => <article className="sources-row" key={plugin.pluginId}><div><strong>{plugin.name || plugin.pluginId}</strong><small>{plugin.version || '未知版本'} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}</small></div></article>)}</section>}
</main>
{activeGroup && <Modal opened onClose={() => { setResultGroupKey(null); setSourceNameQuery(''); }} title="书源列表"><Stack><Text>{activeGroup.entry.title} · {activeGroup.entries.length} 条书源</Text><input className="catalog-source-search" type="search" placeholder="搜索书源" value={sourceNameQuery} onChange={event => setSourceNameQuery(event.currentTarget.value)} />{activeGroup.entries.filter(entry => (entry.sourceName ?? selectedSource?.name ?? '').toLocaleLowerCase().includes(sourceNameQuery.trim().toLocaleLowerCase())).map(entry => { const option = entry.options?.[0]; return <article className="catalog-source-item" key={entry.ref}><div><strong>{entry.sourceName ?? selectedSource?.name}</strong><p className="source-description">最新章节:{entry.latestChapter || '暂无信息'}</p></div><div className="sources-actions"><Button className="button" onClick={() => setDetail(entry)}>详情</Button><Button className="button primary" disabled={option?.available === false} onClick={() => acquireEntry(entry, option?.id)}>{option?.label ?? '加入书架'}</Button></div></article>; })}</Stack></Modal>}
<Modal opened={!!detail} onClose={() => setDetail(null)} title={detail?.title ?? '书籍详情'}><Stack>{detailQuery.isPending && <FloatingNotice message="正在读取书籍详情…" busy />}{detailQuery.error && <Alert color="red">{detailQuery.error.message}</Alert>}{detail && <><Text>{detail.description || '暂无简介'}</Text><Text size="sm" c="dimmed">{detail.authors?.join('、') || '作者未知'}</Text>{detail.options?.map(option => <Button key={option.id} disabled={option.available === false} loading={acquire.isPending} onClick={() => acquireEntry(detail, option.id)}>{option.label}</Button>)}{(!detail.options || detail.options.length === 0) && <Button loading={acquire.isPending} onClick={() => acquireEntry(detail)}>加入书架</Button>}</>}</Stack></Modal>
{activeGroup && <Sheet title="书源列表" subtitle={`${activeGroup.entries.length} 条书源`} onClose={() => { setResultGroupKey(null); setSourceNameQuery(''); }}>
<div className="sheet-form">
<p className="sheet-text">{activeGroup.entry.title}</p>
<input className="catalog-source-search" type="search" aria-label="搜索书源" placeholder="搜索书源" value={sourceNameQuery} onChange={event => setSourceNameQuery(event.currentTarget.value)} />
{activeGroup.entries.filter(entry => (entry.sourceName ?? selectedSource?.name ?? '').toLocaleLowerCase().includes(sourceNameQuery.trim().toLocaleLowerCase())).map(entry => { const option = entry.options?.[0]; return <article className="catalog-source-item" key={entry.ref}><div><strong>{entry.sourceName ?? selectedSource?.name}</strong><p className="source-description">最新章节:{entry.latestChapter || '暂无信息'}</p></div><div className="sources-actions"><UiButton onClick={() => setDetail(entry)}>详情</UiButton><UiButton className="primary" disabled={option?.available === false} onClick={() => acquireEntry(entry, option?.id)}>{option?.label ?? '加入书架'}</UiButton></div></article>; })}
</div>
</Sheet>}
{detail && <SourceBookDetailSheet detail={detail} pending={detailQuery.isPending} error={detailQuery.error} acquiring={acquire.isPending} onAcquire={acquireEntry} onClose={() => setDetail(null)} />}
</div>;
}
-1
View File
@@ -1,5 +1,4 @@
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './styles/theme-tokens.css';
import './styles/reader.css';
import './app/app.css';
-9
View File
@@ -1,9 +0,0 @@
.confirmation-host { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0; background: transparent; overflow: hidden; color: var(--reader-ink); }
.confirmation-host::backdrop { background: transparent; }
.confirmation-host .swal2-container { padding: 1.25rem; }
.confirmation-host .reader-confirm-popup { width: 26rem; padding: 1.5rem; border: 1px solid var(--reader-line); border-radius: 1rem; background: var(--reader-paper); color: var(--reader-ink); font: inherit; box-shadow: 0 20px 70px #0003; }
.reader-confirm-popup .swal2-title { padding: 0; font-size: 1.2rem; }
.reader-confirm-popup .swal2-html-container { padding: 0; margin: 1rem 0; color: var(--reader-muted); font-size: .95rem; line-height: 1.7; overflow-wrap: anywhere; }
.reader-confirm-popup .swal2-icon { margin: 0 auto 1rem; font-size: .7rem; }
.reader-confirm-popup .swal2-actions { gap: .75rem; margin: .5rem 0 0; width: 100%; }
.reader-confirm-popup .swal2-actions button { flex: 1; min-height: 48px; }
+24 -295
View File
@@ -731,7 +731,7 @@ book-content {
text-decoration: none;
}
.panel-header .icon-text-button {
.sheet-header .icon-text-button {
padding: 0.45rem 0.6rem;
font-size: 0.82rem;
}
@@ -764,7 +764,7 @@ book-content {
.tts-bar button:focus-visible,
.pager-page:focus-visible,
.pager-step:focus-visible,
.dialog button:focus-visible {
.sheet button:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-offset);
}
@@ -1801,22 +1801,19 @@ book-content {
/* ---- shelf settings sheet ---- */
.shelf-settings .switch-field,
.reader-screen .panel .switch-field {
.sheet .switch-field {
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.shelf-settings .switch-text,
.reader-screen .panel .switch-text {
.sheet .switch-text {
flex: 1 1 auto;
min-width: 0;
font-size: 0.9rem;
}
.shelf-settings .switch-text .muted,
.reader-screen .panel .switch-text .muted {
.sheet .switch-text .muted {
font-size: 0.72rem;
margin-top: 0.15rem;
}
@@ -1829,8 +1826,7 @@ book-content {
* this on" — a separate class would be a second one, and the two would eventually
* disagree.
*/
.shelf-settings .switch,
.reader-screen .panel .switch {
.sheet .switch {
flex: 0 0 auto;
position: relative;
width: 2.7rem;
@@ -1842,8 +1838,7 @@ book-content {
transition: background var(--motion-duration) var(--motion-ease);
}
.shelf-settings .switch::after,
.reader-screen .panel .switch::after {
.sheet .switch::after {
content: '';
position: absolute;
top: 0.2rem;
@@ -1856,13 +1851,11 @@ book-content {
transition: inset-inline-start var(--motion-duration) var(--motion-ease);
}
.shelf-settings .switch[aria-pressed='true'],
.reader-screen .panel .switch[aria-pressed='true'] {
.sheet .switch[aria-pressed='true'] {
background: var(--reader-fill);
}
.shelf-settings .switch[aria-pressed='true']::after,
.reader-screen .panel .switch[aria-pressed='true']::after {
.sheet .switch[aria-pressed='true']::after {
inset-inline-start: 1.3rem;
}
@@ -1877,10 +1870,8 @@ book-content {
animation: none;
}
.shelf-settings .switch,
.shelf-settings .switch::after,
.reader-screen .panel .switch,
.reader-screen .panel .switch::after {
.sheet .switch,
.sheet .switch::after {
transition: none;
}
@@ -1893,123 +1884,13 @@ book-content {
}
}
/* ---- panels: a bottom sheet, half the screen ----
/* ---- reader sheets ----
*
* These used to be `inset: 0` — full-screen opaque pages that replaced the book.
* That is a defensible pattern for a *destination* and the wrong one for a
* *control*: choosing a chapter or nudging the font size is an adjustment to the
* page underneath, and a full-screen panel makes the reader leave the page to make
* it — then come back to a re-laid-out chapter with no memory of what it looked
* like before. The panel is therefore a sheet over the *lower half* of the reading
* area, and the top half stays readable, which is what makes the change visible
* while it is being made.
*
* Three things follow from that and are not decoration:
*
* - **A scrim**, because a sheet without one reads as part of the page. It is
* also the tap target that closes the sheet, which is the second way out, along
* with the ✕ in the header.
* - **A handle**, because a sheet that rises from the bottom is understood to be
* draggable, and a drag target with nothing to grab is a sheet the reader
* cannot close the way they expect. (The drag itself is not implemented; the
* handle is the *affordance*, and the scrim and the ✕ are the two real ways out.
* Promising a drag that does not exist would be worse than not drawing it, so
* the handle is a visual cue and the sheet's own scroll is what moves.)
* - **A height that leaves the top of the page alone.** `min(70%, ...)` rather
* than a fixed viewport fraction: the contents list of a 1200-chapter book wants
* all the room it can get, and the settings list of eleven rows does not want
* more than it needs.
* The contents, appearance, read-aloud and source-switch panels are the app's one
* Sheet (ui/sheet.tsx, styles/sheet.css). On a phone it stops at 62% of the screen
* so the page being adjusted stays visible above it.
*/
.scrim {
position: absolute;
inset: 0;
z-index: var(--reader-layer-panel);
background: color-mix(in srgb, #000 42%, transparent);
animation: scrim-in var(--motion-duration) var(--motion-ease);
}
@keyframes scrim-in {
from { opacity: 0; }
to { opacity: 1; }
}
.panel {
position: absolute;
inset-inline: 0;
bottom: 0;
z-index: var(--reader-layer-panel);
display: flex;
flex-direction: column;
min-height: 0;
/* Half the reading area, and never more than three quarters of it: the reader
has to be able to see the page they are adjusting, and a sheet that covers
everything is the full-screen panel this replaced. */
height: min(62%, 34rem);
max-height: 78%;
background: var(--reader-surface);
border-top: 1px solid var(--reader-line);
border-radius: 16px 16px 0 0;
box-shadow: 0 -14px 36px color-mix(in srgb, #000 26%, transparent);
animation: sheet-in var(--motion-duration) var(--motion-ease);
}
@keyframes sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.panel[hidden] {
display: none;
}
/* The grab handle. Decorative, so it is `aria-hidden` in the markup. */
.panel-grip {
flex: 0 0 auto;
width: 2.25rem;
height: 0.25rem;
margin: 0.5rem auto 0.125rem;
border-radius: 999px;
background: var(--reader-line);
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem 0.5rem 0.9rem;
border-bottom: 1px solid var(--reader-line);
}
.panel-header h2 {
margin: 0;
font-size: var(--text-title);
font-weight: 600;
}
/* The count beside the title. Right-aligned against the ✕ rather than next to the
title, so the header reads as "title … count … close" and the closing control
stays exactly where the reader's thumb expects it. */
.panel-subtitle {
flex: 1 1 auto;
text-align: end;
font-size: var(--text-meta);
color: var(--reader-muted);
font-variant-numeric: tabular-nums;
}
.panel-body {
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
/* `overscroll-behavior: contain` stops a flick at the end of the list from
scrolling the *chapter* behind the sheet, which is the classic bottom-sheet
annoyance: the list stops moving, the text underneath starts, and the reader
has lost their place. */
overscroll-behavior: contain;
padding: var(--space-3) 0.9rem calc(var(--space-3) + var(--safe-bottom));
}
.toc-list {
list-style: none;
margin: 0;
@@ -3525,120 +3406,6 @@ book-content {
color: #c0392b;
}
/* ---- dialogs ---- */
.dialog-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: grid;
place-items: center;
padding: 1rem;
z-index: 30;
}
.dialog {
background: var(--reader-surface);
color: var(--reader-ink);
border: 1px solid var(--reader-line);
border-radius: var(--reader-radius);
padding: 1rem;
width: min(24rem, 100%);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dialog h3 {
margin: 0;
font-size: 1rem;
}
.dialog p {
margin: 0;
font-size: 0.82rem;
line-height: 1.5;
}
.dialog input {
appearance: none;
border: 1px solid var(--reader-line);
background: var(--reader-paper);
color: var(--reader-ink);
border-radius: var(--reader-radius);
padding: 0.6rem 0.7rem;
/* 16px, so iOS does not zoom the page on focus and leave the dialog off-screen. */
font-size: 16px;
font-family: inherit;
width: 100%;
}
.dialog-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.dialog-actions .button {
flex: 0 1 auto;
min-width: 5rem;
}
.dialog-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* ---- batch metadata ---- */
/*
* The batch dialog has seven optional fields, which do not fit on a phone.
*
* The fields scroll and the buttons do not. Scrolling the whole dialog instead
* puts 保存 below the fold, so the reader fills the form, sees no way to commit
* it, and has to work out that the sheet itself moves — and the on-screen
* keyboard takes another third of the height while they are typing in it.
*
* `max-height` on the *fields* rather than on the dialog also keeps the title
* visible, so it stays obvious what is being edited after a scroll.
*/
.dialog:has(.dialog-field) {
max-height: min(86vh, 40rem);
overflow: hidden;
}
.dialog:has(.dialog-field) .dialog-fields {
display: flex;
flex-direction: column;
gap: 0.6rem;
overflow-y: auto;
min-height: 0;
/* Room for the last field's label, so it is not flush against the buttons. */
padding-bottom: 0.2rem;
}
.dialog-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.78rem;
color: var(--reader-muted);
}
.dialog-field input {
appearance: none;
border: 1px solid var(--reader-line);
background: var(--reader-paper);
color: var(--reader-ink);
border-radius: var(--reader-radius);
padding: 0.5rem 0.6rem;
/* 16px, so iOS does not zoom the page on focus and push the dialog off-screen. */
font-size: 16px;
font-family: inherit;
width: 100%;
}
/*
* The file input is the trigger for the ⬆ button and is never shown.
*
@@ -3836,13 +3603,10 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
}
.reader-screen .rail-button { width: 48px; height: 48px; border-radius: 50%; background: var(--reader-surface); box-shadow: 0 3px 12px #00000020; }
.reader-screen .rail-button .icon { font-size: 1.5rem; }
.reader-screen .panel { border-radius: 20px 20px 0 0; max-height: 62%; background: var(--reader-surface); box-shadow: 0 -6px 30px #00000018; }
.reader-screen .panel-header { padding: 0.5rem 1.25rem 0.75rem; }
.reader-screen .panel-body { padding-inline: 1.25rem; }
.reader-screen .panel .field { padding-block: 0.8rem; }
.reader-screen .panel .tts-bar { position: static; border-radius: 12px; width: auto; transform: none; margin-bottom: 1rem; }
.reader-screen .panel-header > .icon-button { margin-inline-start: auto; }
.reader-screen .panel .field { grid-template-columns: minmax(5.5rem, max-content) minmax(0, 1fr); gap: 0.75rem; padding-block: 0.5rem; }
@media (max-width: 700px) { .reader-screen .sheet { max-height: min(62dvh, 36rem); } }
.reader-screen .sheet .field { padding-block: 0.8rem; }
.reader-screen .sheet .tts-bar { position: static; border-radius: 12px; width: auto; transform: none; margin-bottom: 1rem; }
.reader-screen .sheet .field { grid-template-columns: minmax(5.5rem, max-content) minmax(0, 1fr); gap: 0.75rem; padding-block: 0.5rem; }
.reader-screen .field input[type='range'] { min-height: 32px; }
.reader-screen .field input[type='range']::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--reader-line); }
.reader-screen .field input[type='range']::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; margin-top: -7px; border-radius: 50%; background: var(--reader-fill); }
@@ -3945,16 +3709,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.source-picker > label { flex:1; margin:0; font-size:.85rem; color:var(--reader-muted); }
.source-picker > p { flex-basis:100%; margin:0; }
}
.source-modal { color: var(--reader-ink); background: var(--reader-paper); border: 1px solid var(--reader-line); border-radius: 1rem; padding: 0; width: min(36rem, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow: hidden; }
.source-modal[open] { display: flex; flex-direction: column; }
.source-modal::backdrop { background: #0007; }
.source-modal-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .75rem 1rem; border-bottom: 1px solid var(--reader-line); flex: none; }
.source-modal-header h2 { margin: 0; font-size: 1.15rem; }
.source-modal-form.sources-card { display: flex; flex-direction: column; margin: 0; border: 0; padding: 0; width: 100%; min-height: 0; overflow: hidden; }
.source-modal-content { overflow-y: auto; overscroll-behavior: contain; padding: 0 1rem 1rem; }
.source-modal-actions { display: flex; flex-wrap: wrap; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid var(--reader-line); flex: none; }
.source-modal-actions button { min-height: 44px; }
/* Collection pages share a quiet heading, labelled destinations and useful empty states. */
.shelf-head { align-items: center; padding-top: calc(1.25rem + var(--safe-top)); padding-bottom: .85rem; }
.shelf-head-actions { margin-top: 0; }
@@ -4039,21 +3793,11 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.file-manager-header .collection-links { align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: .55rem; }
.file-manager-header .collection-link.button { flex: 0 0 auto; min-width: 8.5rem; min-height: 44px; justify-content: flex-start; }
.file-manager-header .collection-link.button > span { flex: 1 1 auto; }
.library-files-screen .mantine-Menu-dropdown { min-width: 10rem; padding: .35rem; border: 1px solid var(--reader-line); border-radius: .75rem; background: var(--reader-paper); color: var(--reader-ink); box-shadow: 0 .75rem 2rem #0003; }
.library-files-screen .mantine-Menu-item { min-height: 2.5rem; border-radius: .5rem; color: var(--reader-ink); font: inherit; }
.library-files-screen .mantine-Menu-item:hover, .library-files-screen .mantine-Menu-item:focus-visible { background: color-mix(in srgb, var(--reader-accent) 12%, transparent); color: var(--reader-ink); }
.library-files-screen .mantine-Menu-item[data-disabled] { opacity: .45; }
.library-files-screen .mantine-Menu-item[data-color='red'] { color: #b23a31; }
.library-file-menu { min-width: 10rem; padding: .35rem; border: 1px solid var(--reader-line); border-radius: .75rem; background: var(--reader-paper); color: var(--reader-ink); box-shadow: 0 .75rem 2rem #0003; }
.library-file-menu .mantine-Menu-item { min-height: 2.5rem; border-radius: .5rem; color: var(--reader-ink); font: inherit; }
.library-file-menu .mantine-Menu-item:hover, .library-file-menu .mantine-Menu-item:focus-visible { background: color-mix(in srgb, var(--reader-accent) 12%, transparent); color: var(--reader-ink); }
.library-file-menu .mantine-Menu-item[data-disabled] { opacity: .45; }
.library-file-menu .mantine-Menu-item[data-color='red'] { color: #b23a31; }
.shelf-settings.panel { position: fixed; }
.shelf-settings .panel-header { padding: .75rem 1rem; flex: none; }
.shelf-settings .panel-header h2 { flex: 1; }
.shelf-settings .panel-header > .icon-button { flex: none; margin-inline-start: auto; width: 44px; height: 44px; }
.shelf-settings .panel-body { padding-top: 0; }
.shelf-settings .section-title:first-child { margin-top: 0; }
.shelf-discovery { display: contents; }
.plugin-install { margin-block: 1rem 1.5rem; border: 1px solid var(--reader-line); border-radius: .85rem; overflow: hidden; }
@@ -4083,7 +3827,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.library-files-screen .manager-check { grid-row: 1; }
.library-files-screen .manager-meta { grid-column: 3; grid-row: 1; justify-content: flex-end; padding-left: 0; }
.library-files-screen .manager-more { grid-column: 4; grid-row: 1; }
.shelf-settings.panel { inset: 1rem 1rem auto auto; width: 26rem; height: min(42rem, calc(100dvh - 2rem)); max-height: calc(100dvh - 2rem); border: 1px solid var(--reader-line); border-radius: 1rem; box-shadow: 0 12px 48px #0003; overflow: hidden; animation-name: shelf-settings-in; }
.shelf-settings .switch-field { grid-template-columns: minmax(0, 1fr) auto; }
.sources-header { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); max-width: var(--collection-measure); margin-inline: auto; padding: 2rem .9rem .75rem; border-bottom: 0; }
.sources-header h1 { font-size: 1.5rem; font-weight: 650; }
@@ -4107,14 +3850,8 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reader-screen .reader-actions .icon { font-size: 1.1rem; }
.reader-screen .reader-rail { right: max(1rem, calc((100% - var(--desktop-page-width)) / 2 - 4.5rem)); bottom: 2.25rem; }
.reader-screen .reading-indicator { width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; padding-inline: 1.5rem; font-size: .75rem; }
.reader-screen .scrim { background: color-mix(in srgb, var(--reader-ink) 12%, transparent); }
.reader-screen .panel { inset: 1rem 1rem 1rem auto; width: 26rem; height: calc(100% - 2rem); max-height: none; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; animation-name: shelf-settings-in; }
.reader-screen .panel[data-placement='start'] { inset-inline: 1rem auto; width: 23rem; animation-name: reader-contents-in; }
.reader-screen .panel-grip { display: none; }
.reader-screen .panel-header { flex: none; padding: .75rem 1.25rem; }
.reader-screen .panel-header .icon-button { width: 40px; height: 40px; border-radius: .6rem; }
.reader-screen .panel .section-title { font-size: .78rem; font-weight: 650; letter-spacing: .05em; color: var(--reader-accent); padding-top: .9rem; padding-bottom: .65rem; }
.reader-screen .panel-body { padding-top: 0; }
.reader-screen .sheet::backdrop { background: color-mix(in srgb, var(--reader-ink) 12%, transparent); }
.reader-screen .sheet .section-title { font-size: .78rem; font-weight: 650; letter-spacing: .05em; color: var(--reader-accent); padding-top: .9rem; padding-bottom: .65rem; }
.reader-screen .section-title:first-child { margin-top: 0; }
}
@media (max-width: 600px) {
@@ -4127,14 +3864,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.source-interval select { flex:1; }
.library-files-screen .manager-actions { overflow-x:auto; }
}
@keyframes reader-contents-in {
from { opacity: 0; transform: translateX(-1rem); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes shelf-settings-in {
from { opacity: 0; transform: translateX(1rem); }
to { opacity: 1; transform: translateX(0); }
}
@media (max-width: 600px) {
.source-entry-actions { flex-direction: column; gap: 0; }
.source-entry-top { align-items: center; }
@@ -4150,7 +3879,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reader-source-info span { flex: 0 1 auto; max-width: 35%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.reader-source-info a { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: inherit; text-decoration: none; }
.reader-source-info a:hover { text-decoration: underline; }
.book-detail { padding-top: 1rem; }
.book-detail { display: grid; gap: .5rem; }
.book-detail h3 { margin: 0; font-size: 1.35rem; overflow-wrap: anywhere; }
.book-detail-author { color: var(--reader-muted); }
.book-detail dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .6rem 1rem; padding-block: .8rem; border-block: 1px solid var(--reader-line); }
@@ -4160,7 +3889,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reader-book-heading { padding: 0; border: 0; background: transparent; color: inherit; text-align: start; cursor: pointer; min-height: 44px; }
.alternative-preview { padding-block: 1rem; display: grid; gap: .75rem; }
.alternative-preview { display: grid; gap: .75rem; }
.alternative-preview h4 { margin: 0; }
.alternative-preview label { display: grid; gap: .5rem; }
.alternative-preview select { width: 100%; min-width: 0; min-height: 44px; }
@@ -4242,7 +3971,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.annotation-actions button:hover { background: var(--reader-surface); }
.annotation-popover blockquote, .annotation-editor blockquote { margin: .3rem 0 .75rem; padding: .5rem .75rem; border-inline-start: 3px solid var(--reader-accent); max-height: 6rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.annotation-comment { margin: .5rem; max-height: 8rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.annotation-editor { display: grid; gap: 1rem; padding-top: 1rem; }
.annotation-editor { display: grid; gap: 1rem; }
.annotation-editor label { display: grid; gap: .5rem; }
.annotation-editor textarea { box-sizing: border-box; width: 100%; min-height: 6rem; padding: .75rem; font: inherit; border: 1px solid var(--reader-line); border-radius: .5rem; background: var(--reader-paper); color: var(--reader-ink); resize: vertical; }
.reading-tool-advanced { border-top: 1px solid var(--reader-line); padding-top: .5rem; }
+261
View File
@@ -0,0 +1,261 @@
/*
* The one overlay form (web/src/ui/sheet.tsx, docs/ui.md §3.7).
*
* Phones: a bottom sheet as tall as its content. Wider windows: a drawer on the
* right edge — full height, or as tall as its content for `compact`. Callers add a
* class for their *content*; nothing outside this file sets a sheet's position,
* size, radius, backdrop or header.
*/
.sheet {
position: fixed;
inset: auto 0 0 0;
box-sizing: border-box;
width: 100%;
max-width: 100%;
height: auto;
max-height: calc(100dvh - var(--safe-top) - 1.5rem);
margin: 0;
padding: 0;
border: 1px solid var(--reader-line);
border-bottom: 0;
border-radius: 16px 16px 0 0;
background: var(--reader-surface);
color: var(--reader-ink);
font: inherit;
font-size: var(--text-body);
line-height: 1.5;
box-shadow: 0 -14px 36px color-mix(in srgb, #000 22%, transparent);
overflow: hidden;
}
.sheet[open] {
display: flex;
flex-direction: column;
animation: sheet-rise var(--motion-duration) var(--motion-ease);
}
/* The fallback colour is for WebViews whose ::backdrop does not inherit custom properties. */
.sheet::backdrop {
background: var(--color-scrim, rgb(0 0 0 / 0.4));
}
.sheet-grip {
flex: none;
width: 2.25rem;
height: 0.25rem;
margin: 0.5rem auto 0;
border-radius: 999px;
background: var(--reader-line);
}
.sheet-header {
flex: none;
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 3.25rem;
box-sizing: border-box;
padding: var(--space-1) var(--space-2) var(--space-1) var(--space-3);
border-bottom: 1px solid var(--reader-line);
}
.sheet-header h2 {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font-size: var(--text-title);
font-weight: 600;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sheet-subtitle {
flex: none;
font-size: var(--text-meta);
color: var(--reader-muted);
font-variant-numeric: tabular-nums;
}
.sheet-header > .icon-button {
flex: none;
}
.sheet-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: var(--space-3) var(--space-3) calc(var(--space-3) + var(--safe-bottom));
}
.sheet:has(> .sheet-footer) > .sheet-body {
padding-bottom: var(--space-3);
}
.sheet-footer {
flex: none;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-2) var(--space-3) calc(var(--space-2) + var(--safe-bottom));
border-top: 1px solid var(--reader-line);
}
.sheet-footer .button {
min-width: 5rem;
min-height: 44px;
}
/* Running text inside a sheet: a confirmation's question, a form's explanation. */
.sheet-text {
margin: 0;
color: var(--reader-muted);
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* A stacked form: label above field, the field full width. */
.sheet-form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.sheet-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-label);
color: var(--reader-muted);
}
.sheet-field input,
.sheet-field select,
.sheet-field textarea {
appearance: none;
box-sizing: border-box;
width: 100%;
min-height: 44px;
border: 1px solid var(--reader-line);
border-radius: var(--reader-radius);
padding: var(--space-2);
background: var(--reader-paper);
color: var(--reader-ink);
/* 16px, so iOS does not zoom the page on focus and push the sheet off-screen. */
font-size: 16px;
font-family: inherit;
}
/* A switch row: label and hint take the width, the switch sits on the trailing edge. */
.sheet .switch-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
/* A list of choices: one full-width row per option. */
.sheet-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.sheet-list .button {
width: 100%;
justify-content: flex-start;
text-align: start;
}
.button.primary.danger {
background: var(--color-danger);
border-color: var(--color-danger);
color: var(--color-bg);
}
@media (hover: hover) and (pointer: fine) {
.button.primary.danger:hover {
background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink));
}
}
@keyframes sheet-rise {
from { transform: translateY(100%); }
to { transform: none; }
}
@keyframes sheet-pop {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: none; }
}
@keyframes sheet-slide {
from { opacity: 0; transform: translateX(1rem); }
to { opacity: 1; transform: none; }
}
@media (max-width: 700px) {
.sheet-footer .button {
flex: 1 1 0;
}
}
@media (min-width: 701px) {
.sheet {
inset: 1rem 1rem 1rem auto;
width: min(30rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
border-bottom: 1px solid var(--reader-line);
border-radius: 1rem;
box-shadow: 0 12px 48px color-mix(in srgb, #000 20%, transparent);
}
.sheet[open] {
animation-name: sheet-slide;
}
.sheet[data-size='compact'] {
bottom: auto;
}
/* Opened by a tap or a focused control: beside the opener, positioned by sheet.tsx. */
.sheet[data-size='compact'][data-anchored] {
right: auto;
bottom: auto;
width: min(24rem, calc(100vw - 2rem));
box-shadow: 0 12px 40px color-mix(in srgb, #000 18%, transparent);
}
.sheet[data-size='compact'][data-anchored][open] {
animation-name: sheet-pop;
}
.sheet[data-size='compact'][data-anchored]::backdrop {
background: color-mix(in srgb, var(--color-scrim, rgb(0 0 0 / 0.4)) 45%, transparent);
}
.sheet[data-size='wide'] {
width: min(42rem, calc(100vw - 2rem));
}
.sheet-grip {
display: none;
}
.sheet-header {
padding-block: var(--space-2);
}
.sheet-body {
padding-bottom: var(--space-3);
}
.sheet-footer {
padding-bottom: var(--space-2);
}
}
+9 -8
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import type { Note } from '../api/types.ts';
import type { TextAnchor } from './text-anchor.ts';
import { Modal } from './modal.tsx';
import { FloatingConfirm } from './floating-confirm.tsx';
import { ConfirmSheet, Sheet } from './sheet.tsx';
import { FloatingNotice } from './floating-notice.tsx';
export interface AnnotationTarget { anchor: TextAnchor; rect: DOMRect; note?: Note }
@@ -38,18 +37,17 @@ export function AnnotationPopover({ target, save, remove, close }: {
return () => { document.removeEventListener('pointerdown', dismiss); document.removeEventListener('keydown', key, true); window.removeEventListener('resize',moved); document.removeEventListener('scroll',moved,true); };
}, [editing, busy, confirmDelete]);
useEffect(() => { if (editing) input.current?.focus(); }, [editing]);
if (editing) return <Modal title={target.note ? '编辑批注' : '添加批注'} busy={busy} onClose={close}>
<form className="annotation-editor source-modal-content" onSubmit={event => { event.preventDefault(); void work(() => save(comment.trim() ? 'note' : 'highlight', comment, color)); }}>
if (editing) return <Sheet title={target.note ? '编辑批注' : '添加批注'} busy={busy} onClose={close}>
<form className="annotation-editor" onSubmit={event => { event.preventDefault(); void work(() => save(comment.trim() ? 'note' : 'highlight', comment, color)); }}>
<blockquote>{target.anchor.quote}</blockquote>
<label>批注<textarea ref={input} aria-label="批注" maxLength={4000} value={comment} onInput={event => setComment(event.currentTarget.value)} placeholder="写下此刻的想法" disabled={busy} /></label>
<div className="reading-note-colors" role="group" aria-label="高亮颜色">{[['#ffd54f','黄色'],['#80cbc4','绿色'],['#ce93d8','紫色']].map(([value, label]) => <button key={value} type="button" aria-label={label} aria-pressed={color === value} style={{'--note-color':value}} disabled={busy} onClick={() => setColor(value)}>{color === value ? '✓' : ''}</button>)}</div>
<FloatingNotice message={error} error /><div className="reading-tool-actions"><button type="button" className="button" disabled={busy} onClick={close}>取消</button><button className="button primary" disabled={busy}>保存批注</button></div>
</form>
</Modal>;
return <div ref={panel} className="annotation-popover" style={{left,top,width}} role={target.note ? 'dialog' : 'toolbar'} aria-label={target.note ? '批注详情' : '选中文字操作'} onPointerDown={event => event.preventDefault()}>
</Sheet>;
return <><div ref={panel} className="annotation-popover" style={{left,top,width}} role={target.note ? 'dialog' : 'toolbar'} aria-label={target.note ? '批注详情' : '选中文字操作'} onPointerDown={event => event.preventDefault()}>
{target.note && <><blockquote>{target.anchor.quote}</blockquote><p className="annotation-comment">{target.note.comment || '这段文字已高亮,尚未添加批注。'}</p></>}
<FloatingNotice message={error} error />
{confirmDelete && <FloatingConfirm title="删除批注" text="确定删除这段高亮及其批注?" confirmText="确认删除" cancelText="取消" onCancel={()=>setConfirmDelete(false)} onConfirm={()=>{setConfirmDelete(false);void work(remove);}} />}
<div className="annotation-actions">
{!target.note && <button disabled={busy} onClick={() => void work(async () => { if (!navigator.clipboard) throw new Error('当前环境不支持剪贴板,请使用系统复制'); await navigator.clipboard.writeText(target.anchor.quote); })}>复制</button>}
{!target.note && <button disabled={busy} onClick={() => void work(() => save('highlight', '', color))}>高亮</button>}
@@ -58,5 +56,8 @@ export function AnnotationPopover({ target, save, remove, close }: {
{target.note && <button disabled={busy} onClick={() => setConfirmDelete(true)}>删除</button>}
<button disabled={busy} aria-label="关闭选区工具" onClick={close}>关闭</button>
</div>
</div>;
</div>
{/* Outside the popover, so its pointer handling does not swallow the sheet's taps. */}
{confirmDelete && <ConfirmSheet danger title="删除批注" text="确定删除这段高亮及其批注?" confirmText="确认删除" onCancel={()=>setConfirmDelete(false)} onConfirm={()=>{setConfirmDelete(false);void work(remove);}} />}
</>;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import {FloatingNotice} from './floating-notice.tsx';
import {FloatingConfirm} from './floating-confirm.tsx';
import { ConfirmSheet } from './sheet.tsx';
import '../styles/business-settings.css';
import type { ChangeEvent } from 'react';
@@ -63,7 +63,7 @@ export function BusinessSettings({api,group,onSaved,selectControl}:{api:Settings
{audio&&<audio controls src={audio} aria-label="HTTP 朗读试听"/>}
<button className="button" disabled={busy} onClick={()=>setClear(true)}>清理音频缓存</button>
</div>}
{clear&&<FloatingConfirm text="清理服务器音频缓存?下次朗读将重新合成。" onCancel={()=>setClear(false)} onConfirm={()=>{setClear(false);void work(async()=>{await api.businessSettingsRequest('tts/clear-cache','POST');setMessage('音频缓存已清理');});}}/>}
{clear&&<ConfirmSheet danger text="清理服务器音频缓存?下次朗读将重新合成。" onCancel={()=>setClear(false)} onConfirm={()=>{setClear(false);void work(async()=>{await api.businessSettingsRequest('tts/clear-cache','POST');setMessage('音频缓存已清理');});}}/>}
{selected==='webdav'&&<details><summary>上传已验证的离线备份</summary><p>先停服制作完整备份,再启动服务上传。这里不会在线复制运行中的数据库。目录须能从服务器内访问;上传使用已保存的 WebDAV 配置。</p>
<form className="user-manager-form" onSubmit={event=>{event.preventDefault();void work(async()=>{await api.businessSettingsRequest('webdav/upload','POST',{directory,name});setMessage('备份已上传并验证');});}}>
<label>服务器上的备份目录<input required value={directory} onInput={event=>setDirectory(event.currentTarget.value)}/></label>
+61 -81
View File
@@ -1,4 +1,3 @@
import {FloatingConfirm} from './floating-confirm.tsx';
/**
* The questions a screen asks, and the one shape each answer has.
*
@@ -25,10 +24,13 @@ import {FloatingConfirm} from './floating-confirm.tsx';
* prompt attached to a different file) and every path that forgot to remove it was a
* path nobody tested. Held in state, "there is one dialog and it is the current
* question" is not a rule anybody has to keep.
*
* Every kind is drawn as a `Sheet` (ui/sheet.tsx), like every other overlay.
*/
import { Button } from './toolkit.tsx';
import { type JSX, useEffect, useRef } from 'react';
import { ConfirmSheet, Sheet } from './sheet.tsx';
import { type JSX, useLayoutEffect, useRef } from 'react';
/** One question with one answer. Held by the screen, rendered by the tree. */
export type Dialog =
@@ -78,34 +80,22 @@ export function DialogView({
*/
destructive: string;
}): JSX.Element {
if(dialog.kind==='confirm')return <FloatingConfirm title={dialog.title} text={dialog.body} confirmText={destructive} cancelText="取消" onConfirm={()=>onClose(true)} onCancel={()=>onClose(false)}/>;
if (dialog.kind === 'confirm') {
return <ConfirmSheet title={dialog.title} text={dialog.body} confirmText={destructive} danger
onConfirm={() => onClose(true)} onCancel={() => onClose(false)} />;
}
if (dialog.kind === 'prompt') return <PromptDialog title={dialog.title} value={dialog.value} onClose={onClose} />;
if (dialog.kind === 'form') return <MetadataDialog dialog={dialog} onClose={onClose} />;
return (
<div
className="dialog-overlay"
onClick={(event) => {
if (event.target === event.currentTarget) onClose(null);
}}
>
{dialog.kind === 'prompt' ? (
<PromptDialog title={dialog.title} value={dialog.value} onClose={(value) => onClose(value)} />
) : null}
{dialog.kind === 'pick' ? (
<div className="dialog sheet">
<h3>{dialog.title}</h3>
<div className="dialog-list">
{dialog.options.map((option) => (
<Button key={option.value} onClick={() => onClose(option.value)}>
{option.label}
</Button>
))}
</div>
<div className="dialog-actions">
<Button onClick={() => onClose(null)}>取消</Button>
</div>
</div>
) : null}
{dialog.kind === 'form' ? <MetadataDialog dialog={dialog} onClose={(fields) => onClose(fields)} /> : null}
</div>
<Sheet title={dialog.title} size="compact" onClose={() => onClose(null)}>
<div className="sheet-list">
{dialog.options.map((option) => (
<Button key={option.value} onClick={() => onClose(option.value)}>
{option.label}
</Button>
))}
</div>
</Sheet>
);
}
@@ -119,27 +109,27 @@ function PromptDialog({
onClose(value: string | null): void;
}): JSX.Element {
const input = useRef<HTMLInputElement | null>(null);
useEffect(() => {
input.current?.focus();
input.current?.select();
}, []);
// Runs after the sheet has opened and focused the field, so the selection sticks.
useLayoutEffect(() => { input.current?.select(); }, []);
const submit = () => onClose(input.current?.value ?? '');
return (
<form
className="dialog"
onSubmit={(event) => {
event.preventDefault();
onClose(input.current?.value ?? '');
}}
>
<h3>{title}</h3>
<input ref={input} type="text" defaultValue={value} />
<div className="dialog-actions">
<Sheet title={title} size="compact" onClose={() => onClose(null)} initialFocus=".sheet-prompt-input"
footer={<>
<Button onClick={() => onClose(null)}>取消</Button>
<Button type="submit" className="primary">
确定
</Button>
</div>
</form>
<Button className="primary" onClick={submit}>确定</Button>
</>}>
<form className="sheet-form" onSubmit={(event) => { event.preventDefault(); submit(); }}>
<label className="sheet-field">
<input
ref={input}
aria-label={title}
className="sheet-prompt-input"
type="text"
defaultValue={value}
/>
</label>
</form>
</Sheet>
);
}
@@ -151,50 +141,40 @@ function MetadataDialog({
onClose(fields: Record<string, string> | null): void;
}): JSX.Element {
const inputs = useRef(new Map<string, HTMLInputElement>());
const submit = () => {
const patch: Record<string, string> = {};
for (const [key, input] of inputs.current) {
const value = input.value.trim();
if (value !== '') patch[key] = value;
}
onClose(patch);
};
// The fields scroll inside the sheet body and 保存 sits in the pinned footer, so a
// form with seven fields never puts its only way to commit below the fold.
return (
<form
className="dialog"
onSubmit={(event) => {
event.preventDefault();
const patch: Record<string, string> = {};
for (const [key, input] of inputs.current) {
const value = input.value.trim();
if (value !== '') patch[key] = value;
}
onClose(patch);
}}
>
<h3>{dialog.title}</h3>
<p className="muted">留空的字段保持不变。目录会写成它里面每一本书的资料。</p>
{/* The fields are their own scroller so the actions below them stay
reachable: a sheet that scrolls whole puts 保存 past the fold, and a form
with no visible way to commit it reads as a dead end. */}
<div className="dialog-fields">
<Sheet title={dialog.title} onClose={() => onClose(null)} initialFocus="input[name=author]"
footer={<>
<Button onClick={() => onClose(null)}>取消</Button>
<Button className="primary" onClick={submit}>保存</Button>
</>}>
<form className="sheet-form" onSubmit={(event) => { event.preventDefault(); submit(); }}>
<p className="sheet-text">留空的字段保持不变。目录会写成它里面每一本书的资料</p>
{dialog.fields.map((field) => (
<label className="dialog-field" key={field.key}>
<label className="sheet-field" key={field.key}>
<span>{field.label}</span>
<input
type="text"
name={field.key}
placeholder="留空则不改"
ref={(node) => {
if (node) {
inputs.current.set(field.key, node);
if (field.key === 'author') node.focus();
} else {
inputs.current.delete(field.key);
}
if (node) inputs.current.set(field.key, node);
else inputs.current.delete(field.key);
}}
/>
</label>
))}
</div>
<div className="dialog-actions">
<Button onClick={() => onClose(null)}>取消</Button>
<Button type="submit" className="primary">
保存
</Button>
</div>
</form>
</form>
</Sheet>
);
}
-58
View File
@@ -1,58 +0,0 @@
import Swal from 'sweetalert2';
import 'sweetalert2/dist/sweetalert2.min.css';
import { useLayoutEffect, useRef } from 'react';
import { placeNotifications } from './notifications.ts';
import '../styles/confirmation.css';
/** SweetAlert lives in its own top-layer host, including over an existing modal. */
export function FloatingConfirm({title='确认操作',text,confirmText='确认操作',cancelText='取消操作',onConfirm,onCancel,theme}: {
title?: string; text: string; confirmText?: string; cancelText?: string;
onConfirm(): void; onCancel(): void; theme?: 'media';
}) {
const callbacks = useRef({ onConfirm, onCancel });
callbacks.current = { onConfirm, onCancel };
useLayoutEffect(() => {
let active = true;
const previous = document.activeElement;
const host = document.createElement('dialog');
host.className = 'confirmation-host';
host.setAttribute('aria-label', title);
if (theme === 'media') host.dataset.mediaTheme = 'true';
document.body.append(host);
host.showModal();
const cancel = (event: Event) => { event.preventDefault(); Swal.close(); };
host.addEventListener('cancel', cancel);
const cleanup = () => {
host.removeEventListener('cancel', cancel);
host.close();
host.remove();
placeNotifications();
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true });
};
void Swal.fire({ target: host, titleText: title, text, icon: 'warning', showCancelButton: true,
confirmButtonText: confirmText, cancelButtonText: cancelText, focusCancel: true,
returnFocus: false, heightAuto: false, scrollbarPadding: false, animation: false,
customClass: { popup: theme === 'media' ? 'reader-confirm-popup media-confirm-popup' : 'reader-confirm-popup', confirmButton: 'button primary', cancelButton: 'button' },
buttonsStyling: false,
}).then(result => {
if (!active) return;
active = false;
cleanup();
if (result.isConfirmed) callbacks.current.onConfirm(); else callbacks.current.onCancel();
});
return () => {
if (!active) return;
active = false;
Swal.close();
cleanup();
};
}, []);
// SweetAlert renders outside the feature root. Keep a hidden announcement in
// the React tree so tests and assistive technology can observe the reason.
const hiddenStyle = { position: 'absolute' as const, width: 1, height: 1, overflow: 'hidden' as const, clipPath: 'inset(50%)' };
return <span role="alert" style={hiddenStyle}>
{text}
<button type="button" tabIndex={-1} aria-hidden="true" onClick={() => callbacks.current.onConfirm()}>{confirmText}</button>
<button type="button" tabIndex={-1} aria-hidden="true" onClick={() => callbacks.current.onCancel()}>{cancelText}</button>
</span>;
}
-39
View File
@@ -1,39 +0,0 @@
import { type ReactNode, useLayoutEffect, useRef } from 'react';
import { IconButton } from './toolkit.tsx';
import { placeNotifications } from './notifications.ts';
/** Native modal supplies focus containment and makes the background inert. */
export function Modal({ title, busy, onClose, children, className }: {
title: string; busy: boolean; onClose(): void; children: ReactNode; className?: string;
}) {
const ref = useRef<HTMLDialogElement>(null);
useLayoutEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const dialog = ref.current!;
if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open', '');
placeNotifications();
return () => {
if (typeof dialog.close === 'function') dialog.close(); else dialog.removeAttribute('open');
placeNotifications();
queueMicrotask(() => { if (previous?.isConnected) previous.focus({ preventScroll: true }); });
};
}, []);
return <dialog ref={ref} className={['source-modal', className].filter(Boolean).join(' ')} aria-label={title} aria-busy={busy}
onKeyDown={event => {
if (event.key !== 'Tab') return;
const dialog = event.currentTarget;
const controls = [...dialog.querySelectorAll<HTMLElement>('button, input, select, textarea, a[href], [tabindex]')]
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
const first = controls[0], last = controls.at(-1);
if (!first) { event.preventDefault(); return; }
if (event.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement as HTMLElement))) {
event.preventDefault(); last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first.focus();
}
}}
onCancel={event => { event.preventDefault(); if (!busy) onClose(); }}>
<header className="source-modal-header"><h2>{title}</h2><IconButton icon="xmark" label="关闭弹窗" disabled={busy} onClick={onClose} /></header>
{children}
</dialog>;
}
+13 -13
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { ApiError, type ReaderApi } from '../api/client.ts';
import type { OpdsCredential } from '../api/sources.ts';
import { Modal } from './modal.tsx';
import { Sheet } from './sheet.tsx';
import { Button } from './toolkit.tsx';
import { FloatingNotice } from './floating-notice.tsx';
@@ -27,26 +27,26 @@ export function OpdsAccess({ api, onClose, onSignedOut }: { api: ReaderApi; onCl
setItems(value.credentials); setUrl(new URL(value.catalogUrl, api.baseUrl || location.href).href);
};
useEffect(() => { void run(reload); return () => { active.current = false; }; }, []);
return <Modal title="连接外部阅读器" busy={busy} onClose={onClose}>
<div className="sources-card source-modal-form"><div className="source-modal-content">
return <Sheet title="连接外部阅读器" busy={busy} onClose={onClose}>
<div className="sheet-form opds-access">
<FloatingNotice message={busy ? '正在处理…' : error} busy={busy} error={!!error} />
<p>通过 OPDS 1.2 只读目录访问当前账号书架中的文件书籍。章节书和目录漫画暂不发布。</p>
<label>目录地址<input readOnly value={url} /></label>
<p>在外部阅读器中填入目录地址和专用账号/密码。请使用 HTTPS;凭据有效期一年,可随时撤销。</p>
{created && <div role="status"><p>密码仅展示这一次,请先保存。关闭后无法重新查看。</p>
<label>OPDS 用户名<input readOnly value={created.username} /></label>
<label>OPDS 密码<input readOnly value={created.password} autoComplete="off" /></label>
<p className="sheet-text">通过 OPDS 1.2 只读目录访问当前账号书架中的文件书籍。章节书和目录漫画暂不发布</p>
<label className="sheet-field">目录地址<input readOnly value={url} /></label>
<p className="sheet-text">在外部阅读器中填入目录地址和专用账号/密码。请使用 HTTPS;凭据有效期一年,可随时撤销</p>
{created && <div className="sheet-form" role="status"><p className="sheet-text">密码仅展示这一次,请先保存。关闭后无法重新查看</p>
<label className="sheet-field">OPDS 用户名<input readOnly value={created.username} /></label>
<label className="sheet-field">OPDS 密码<input readOnly value={created.password} autoComplete="off" /></label>
</div>}
<form onSubmit={event => { event.preventDefault(); void run(async () => {
<form className="sheet-form" onSubmit={event => { event.preventDefault(); void run(async () => {
const result = await api.createOpdsCredential(name);
if (!active.current) return;
setCreated(result); setName(''); await reload();
}); }}>
<label>客户端名称<input required maxLength={80} value={name} disabled={busy} onInput={event => setName(event.currentTarget.value)} placeholder="例如:平板阅读器" /></label>
<label className="sheet-field">客户端名称<input required maxLength={80} value={name} disabled={busy} onInput={event => setName(event.currentTarget.value)} placeholder="例如:平板阅读器" /></label>
<Button type="submit" disabled={busy || !name.trim()}>创建 OPDS 凭据</Button>
</form>
{items.map(item => <article className="sources-row" key={item.id}><div><strong>{item.name}</strong><small>到期:{new Date(item.expiresAt).toLocaleDateString()}</small></div>
<Button disabled={busy} onClick={() => void run(async () => { await api.revokeOpdsCredential(item.id); if (!active.current) return; if (created?.username === item.id) setCreated(null); await reload(); })}>撤销 {item.name}</Button></article>)}
</div></div>
</Modal>;
</div>
</Sheet>;
}
+12 -73
View File
@@ -1,7 +1,7 @@
import type { CSSProperties } from 'react';
import { paperIds, readerPapers, themes, type AppThemeId, type ModePreference, type ReaderPaper, type ReaderSurface, type ResolvedTheme, type ThemeMode, type ThemePreference } from '../shared/theme/theme.ts';
import type { ChapterQuality } from '../api/sources.ts';
import { Modal } from './modal.tsx';
import { Sheet } from './sheet.tsx';
import type { SourcePage } from '../api/sources.ts';
import { CatalogFeedback } from './catalog-feedback.tsx';
import { FloatingNotice } from './floating-notice.tsx';
@@ -26,7 +26,7 @@ import type { AppSettings } from '../store/settings.ts';
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode } from '../store/settings.ts';
import { ReaderIndicators } from './reader-indicators.tsx';
import type { SpeechEngineKind } from '../render/speech.ts';
import { type ReactNode, type JSX, useLayoutEffect, useRef, useState } from 'react';
import { type ReactNode, type JSX, useState } from 'react';
import { Button, IconButton, SectionTitle, SwitchRow } from './toolkit.tsx';
import { Icon, type IconName } from './icon.tsx';
@@ -241,9 +241,9 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
</div>
{(state.sourceName || state.chapterUrl) && <div className="reader-source-info"><span>{state.sourceName || '当前书源'}</span>{state.chapterUrl && <a href={state.chapterUrl} target="_blank" rel="noopener noreferrer" title={state.chapterUrl}>{state.chapterUrl}</a>}</div>}
</div>
{state.bookInfoOpen && <Modal title="书籍信息" busy={false} onClose={() => handlers.onBookInfo?.()}><article className="book-detail source-modal-content"><h3>{state.title}</h3><p className="book-detail-author">{state.author || '作者未知'}</p><dl><dt>书源</dt><dd>{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}</dd><dt>当前章节</dt><dd>{state.chapterLabel || '暂无信息'}</dd></dl><h4>内容简介</h4><p className="book-detail-description">{state.description || '暂无简介'}</p></article></Modal>}
{state.bookInfoOpen && <Sheet title="书籍信息" onClose={() => handlers.onBookInfo?.()}><article className="book-detail"><h3>{state.title}</h3><p className="book-detail-author">{state.author || '作者未知'}</p><dl><dt>书源</dt><dd>{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}</dd><dt>当前章节</dt><dd>{state.chapterLabel || '暂无信息'}</dd></dl><h4>内容简介</h4><p className="book-detail-description">{state.description || '暂无简介'}</p></article></Sheet>}
{state.aiSummaryBusy && <div className="floating-notice" role="status">正在生成章节总结…</div>}
{state.aiSummary && <Modal title="AI 章节总结" busy={false} onClose={() => handlers.onAiSummary?.()}><article className="book-detail source-modal-content"><p className="book-detail-description">{state.aiSummary}</p></article></Modal>}
{state.aiSummary && <Sheet title="AI 章节总结" onClose={() => handlers.onAiSummary?.()}><article className="book-detail"><p className="book-detail-description">{state.aiSummary}</p></article></Sheet>}
<StageHost stage={stage} />
<ReaderIndicators state={state} />
<div className="reader-rail" role="toolbar" aria-label="阅读快捷操作">
@@ -275,7 +275,7 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
</div>
</div>
{state.tocOpen ? (
<Panel title="目录" placement="start" subtitle={`${state.toc.length} 章`} onClose={handlers.toggleToc} actions={state.canRefresh ? <IconButton icon="refresh" label="刷新目录" disabled={state.refreshing || state.refreshingChapter || state.navigating} onClick={() => handlers.onRefresh?.()} /> : null}>
<Sheet title="目录" subtitle={`${state.toc.length} 章`} onClose={handlers.toggleToc} actions={state.canRefresh ? <IconButton icon="refresh" label="刷新目录" disabled={state.refreshing || state.refreshingChapter || state.navigating} onClick={() => handlers.onRefresh?.()} /> : null}>
{state.toc.length === 0 ? (
<div className="empty-state">这本书没有目录</div>
) : (
@@ -311,11 +311,11 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
})}
</ul>
)}
</Panel>
</Sheet>
) : null}
<FloatingNotice message={state.switching ? "正在获取书源内容…" : ""} busy />
{state.alternatives && <Panel title="切换书源" onClose={() => handlers.onCancelSwitch?.()}><section className="source-switch" aria-label="切换书源">
{state.alternatives && <Sheet title="切换书源" onClose={() => handlers.onCancelSwitch?.()}><section className="source-switch" aria-label="切换书源">
<div className="source-switch-status" role="status"><span>{state.alternativesSearching ? '正在寻找可用书源' : '书源搜索完成'}</span><strong>{state.alternatives.items.length} 个可选</strong>
{state.alternativesSearching && state.alternatives.batch && <progress aria-label="换源搜索进度" max={Math.max(1, state.alternatives.batch.total)} value={state.alternatives.batch.completed} />}
</div>
@@ -330,23 +330,23 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
: state.alternativeChecks[entry.ref]!.error}</span>}
</button>)}</div>
{!!state.alternatives.errors?.length && <CatalogFeedback page={state.alternatives} merged compact searching={!!state.alternativesSearching} />}
{state.alternativeChapters && <Modal title="选择阅读章节" busy={!!state.switching} onClose={() => handlers.onCancelAlternative?.()}><div className="alternative-preview source-modal-content"><h4>{state.alternativeTitle}</h4><label>切换后阅读的章节<select value={state.alternativeChapter ?? ''} disabled={state.switching === true} onChange={event => handlers.onAlternativeChapter?.(event.currentTarget.value)}>
{state.alternativeChapters && <Sheet title="选择阅读章节" size="compact" busy={!!state.switching} onClose={() => handlers.onCancelAlternative?.()}><div className="alternative-preview"><h4>{state.alternativeTitle}</h4><label>切换后阅读的章节<select value={state.alternativeChapter ?? ''} disabled={state.switching === true} onChange={event => handlers.onAlternativeChapter?.(event.currentTarget.value)}>
<option key="empty" value="">请选择章节</option>{state.alternativeChapters.map(chapter => <option key={chapter.id} value={chapter.id}>{chapter.title}</option>)}
</select></label>
{state.alternativeLatest && <p>最新章节:{state.alternativeLatest}</p>}
<Button disabled={state.switching || state.alternativeQualityBusy || !state.alternativeChapter} onClick={() => handlers.onCheckAlternative?.()}>{state.alternativeQualityBusy ? '正在检测正文…' : '检测所选章节 / 重试'}</Button>
{state.alternativeQuality && <div role="status" className="chapter-quality"><strong>正文检测通过</strong><p>{state.alternativeQuality.characters} 字符 · {state.alternativeQuality.images} 张图片 · {(state.alternativeQuality.bytes / 1024).toFixed(1)} KiB · {state.alternativeQuality.elapsedMs} ms</p><p>检测仅说明本次内容可读,不保证章节完整或与原书一致。</p></div>}
{state.alternativeQualityError && <p role="alert">{state.alternativeQualityError}</p>}
<Button type="button" disabled={state.switching || state.alternativeQualityBusy || !state.alternativeChapter} onClick={() => handlers.onSwitchSource?.()}>确认换源并阅读</Button></div></Modal>}
</section></Panel>}
<Button type="button" disabled={state.switching || state.alternativeQualityBusy || !state.alternativeChapter} onClick={() => handlers.onSwitchSource?.()}>确认换源并阅读</Button></div></Sheet>}
</section></Sheet>}
{state.settingsOpen ? (
<Panel title={tab === 'appearance' ? '外观' : tab === 'speech' ? '朗读' : '阅读设置'} onClose={() => handlers.toggleSettings(tab)}>
<Sheet title={tab === 'appearance' ? '外观' : tab === 'speech' ? '朗读' : '阅读设置'} onClose={() => handlers.toggleSettings(tab)}>
{tab === 'speech' ? <>
{state.tts.active ? <SpeechBar state={state.tts} handlers={handlers} /> : null}
<SpeechSettings state={state} handlers={handlers} />
</> : <SettingsBody state={state} handlers={handlers} />}
</Panel>
</Sheet>
) : null}
</>
);
@@ -375,67 +375,6 @@ function StageHost({ stage }: { stage: HTMLElement }): JSX.Element {
);
}
/**
* A bottom sheet on phones and a side panel on desktop.
*
* Bottom sheet rather than a full-screen page, and the comment on `.panel` in the
* stylesheet is where the reasoning lives. What belongs here is the *interaction*:
*
* - The scrim is the second way out. A sheet that covers half the screen has a
* large, obvious, already-under-the-thumb dismissal target beside it, and using
* it is the gesture every reader already has; the ✕ is there for the reader who
* reaches for a button instead.
* - The scrim and the sheet are `role="dialog"`-adjacent but not a dialog: nothing
* behind them is inert, the text stays selectable and readable, and the point of
* the half height is that the reader can still see what they are adjusting. A
* modal that traps focus would be a lie about a sheet the reader can read
* through.
* - The grip is `aria-hidden`: it is an affordance for a drag that is not
* implemented, and announcing a control that does nothing is worse than not
* drawing one. The two real exits are labelled.
*/
function Panel({
title,
placement = 'end',
subtitle,
actions,
onClose,
children,
}: {
title: string;
placement?: 'start' | 'end';
subtitle?: string;
actions?: ReactNode;
onClose(): void;
children: ReactNode;
}): JSX.Element {
const panel = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const trigger = document.activeElement;
panel.current?.querySelector<HTMLButtonElement>('.panel-header button')?.focus({ preventScroll: true });
return () => { if (trigger instanceof HTMLElement && trigger.isConnected) trigger.focus({ preventScroll: true }); };
}, []);
return (
<>
{/* `onClick` on the scrim rather than a document listener: the scrim *is* the
dismiss control, so it should be a node the reader can hit, with the same
processing as any other tap on the page. */}
<div className="scrim" onClick={onClose} role="presentation" />
<div className="panel" data-placement={placement} ref={panel} role="group" aria-label={title}
onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } }}>
<div className="panel-grip" aria-hidden="true" />
<div className="panel-header">
<h2>{title}</h2>
{subtitle ? <span className="panel-subtitle">{subtitle}</span> : null}
{actions}
<IconButton label="关闭" icon="close" onClick={onClose} />
</div>
<div className="panel-body">{children}</div>
</div>
</>
);
}
function SettingsBody({ state, handlers }: { state: ChromeState; handlers: ChromeHandlers }): JSX.Element {
return (
<>
+5 -6
View File
@@ -1,6 +1,6 @@
import type { ReadingOverrides } from './reading-overrides.ts';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Modal } from './modal.tsx';
import { ConfirmSheet, Sheet } from './sheet.tsx';
import type { ReaderApi } from '../api/client.ts';
import type { Manifest, Note } from '../api/types.ts';
import type { OfflineStore } from '../store/offline.ts';
@@ -10,7 +10,6 @@ import { OfflineDownload, type DownloadState } from '../core/offline-download.ts
import { decodeAnchor, encodeAnchor, findText, type SearchHit, type TextAnchor } from './text-anchor.ts';
import { ApiError } from '../api/errors.ts';
import { newId } from '../core/id.ts';
import { FloatingConfirm } from './floating-confirm.tsx';
import { FloatingNotice } from './floating-notice.tsx';
export interface SearchSection { id: string; title: string; text: string }
@@ -119,15 +118,15 @@ export function ReadingTools(props: ReadingToolsProps) {
if (!bookmark) { setComment(''); setEditing(null); setComposing(false); }
setNoteFilter(bookmark ? 'bookmark' : 'all'); props.onNotes(); setMessage(bookmark ? '已添加当前位置书签' : '笔记已保存');
}
return <Modal title="阅读工具" busy={saving} onClose={props.onClose}>
return <Sheet title="阅读工具" busy={saving} onClose={props.onClose}>
<div className="reading-tools">
<nav className="reading-tool-tabs" aria-label="阅读工具分类">{([
['search', '搜索', '书内搜索'], ['notes', '笔记', '笔记'], ['offline', '缓存', '离线缓存'], ['speech', '朗读', '朗读检测'], ['edit', '整理', '内容整理'],
] as const).map(([id, label, accessible]) => <button key={id} className="reading-tool-tab" type="button" aria-label={accessible} aria-pressed={tab === id} aria-controls="reading-tool-panel" onClick={() => { setTab(id); setMessage(''); }}>{label}</button>)}</nav>
<div className="reading-tool-panel" id="reading-tool-panel">
<FloatingNotice message={message} error={messageError} />
{deleteId && <FloatingConfirm title="删除笔记" text="确定删除这条笔记或书签?" confirmText="确认删除" cancelText="取消" onCancel={()=>setDeleteId('')} onConfirm={()=>{const id=deleteId;setDeleteId('');void edit(async()=>{await props.offline.deleteNote(id);props.sync.schedule();setNotes(props.offline.notesFor(bookId));props.onNotes();});}} />}
{removeId && <FloatingConfirm title="清理缓存" text="只删除此设备的书籍缓存,保留原书、进度和笔记。" confirmText="确认清理" cancelText="保留缓存" onCancel={()=>setRemoveId('')} onConfirm={()=>{const id=removeId;setRemoveId('');void action(async()=>{await props.cache.removeBook(id);setDownload(null);await storage();});}} />}
{deleteId && <ConfirmSheet danger title="删除笔记" text="确定删除这条笔记或书签?" confirmText="确认删除" onCancel={()=>setDeleteId('')} onConfirm={()=>{const id=deleteId;setDeleteId('');void edit(async()=>{await props.offline.deleteNote(id);props.sync.schedule();setNotes(props.offline.notesFor(bookId));props.onNotes();});}} />}
{removeId && <ConfirmSheet danger title="清理缓存" text="只删除此设备的书籍缓存,保留原书、进度和笔记。" confirmText="确认清理" cancelText="保留缓存" onCancel={()=>setRemoveId('')} onConfirm={()=>{const id=removeId;setRemoveId('');void action(async()=>{await props.cache.removeBook(id);setDownload(null);await storage();});}} />}
{tab === 'search' && <section aria-label="书内搜索">
<h3>书内搜索</h3>
<form className="reading-tool-search" onSubmit={event => { event.preventDefault(); if (query.trim()) void search(); }}><label>关键词<input placeholder="输入要查找的文字" value={query} maxLength={200} onInput={event => setQuery(event.currentTarget.value)} /></label><button className="button primary" disabled={searching || !query.trim()}>搜索全文</button></form>
@@ -183,5 +182,5 @@ export function ReadingTools(props: ReadingToolsProps) {
{tab === 'speech' && <section aria-label="朗读检测"><p>使用当前朗读设置试听。HTTP 引擎会请求服务端合成试听音频。</p><button className="button" onClick={() => void action(async () => setMessage(await props.previewSpeech()))}>检测并试听</button><button className="button" onClick={props.stopPreview}>停止试听</button><p>若浏览器限制播放,请点击试听后允许音频;可在朗读设置切换系统或 HTTP 引擎。</p></section>}
</div>
</div>
</Modal>;
</Sheet>;
}
+214
View File
@@ -0,0 +1,214 @@
/**
* The one overlay the app has.
*
* Every settings panel, edit form, picker, detail view and confirmation is a
* `Sheet`. There used to be six ways to put something on top of a page — a native
* modal centred on every viewport, a SweetAlert card, a div overlay, the reader's
* half-screen panel, a floating card in the shelf's corner and two hand-written
* media sheets — and each answered "where does it appear, how do I get out, where
* does focus go" slightly differently. The rule is now one rule (docs/ui.md §3.7):
*
* - Phones (≤700px): a bottom sheet with a grip, as tall as its content and never
* taller than the screen allows.
* - Wider windows: a drawer on the right edge; `size="wide"` gives forms that need
* it more width. `size="compact"` (confirmations, prompts, short pick lists) opens
* next to the tap or control that asked the question instead: a two-button
* question belongs where the hand already is, not across the window.
*
* It is a native `<dialog>` opened with `showModal()`: the top layer, an inert
* background and Escape come from the platform. It is rendered in place rather than
* in a portal so it inherits the theme of the screen that opened it (the reader's
* 黑白灰, the media aliases) without each caller re-declaring it.
*
* Three exits, always the same: the ✕ in the header, Escape, and a tap on the
* backdrop. `busy` disables all three, so a save in flight cannot be abandoned
* halfway.
*/
import { type JSX, type ReactNode, useLayoutEffect, useRef } from 'react';
import { Button, IconButton } from './toolkit.tsx';
import { placeNotifications } from './notifications.ts';
import '../styles/sheet.css';
export type SheetSize = 'regular' | 'compact' | 'wide';
export interface SheetProps {
title: string;
onClose(): void;
children: ReactNode;
/** Disables every exit while a write is in flight. */
busy?: boolean;
/** A short readout beside the title, e.g. "128 章". */
subtitle?: ReactNode;
/** Extra header controls, placed before the close button. */
actions?: ReactNode;
/** A pinned action row; the body scrolls and this stays reachable. */
footer?: ReactNode;
size?: SheetSize;
/** Content flavour only (e.g. `media-video-sheet`); geometry belongs to `.sheet`. */
className?: string;
/** A selector inside the sheet to focus on open. Defaults to the platform's choice. */
initialFocus?: string;
role?: 'dialog' | 'alertdialog';
}
export function Sheet({
title, onClose, children, busy = false, subtitle, actions, footer,
size = 'regular', className, initialFocus, role = 'dialog',
}: SheetProps): JSX.Element {
const ref = useRef<HTMLDialogElement>(null);
/** Whether the platform opened it as a modal; without it Escape is ours to handle. */
const native = useRef(false);
/** A backdrop tap is a press *and* a release outside, so a drag out of a field is not a close. */
const pressedOutside = useRef(false);
const state = useRef({ busy, onClose });
state.current = { busy, onClose };
useLayoutEffect(() => {
const dialog = ref.current!;
const previous = document.activeElement as HTMLElement | null;
native.current = false;
if (typeof dialog.showModal === 'function') {
try { dialog.showModal(); native.current = true; } catch { dialog.setAttribute('open', ''); }
} else {
dialog.setAttribute('open', '');
}
if (initialFocus) dialog.querySelector<HTMLElement>(initialFocus)?.focus({ preventScroll: true });
placeNotifications();
// A compact sheet on a wide window opens where the hand is, not at the far edge.
const anchor = size === 'compact' ? anchorPoint(previous) : null;
const place = () => { if (anchor) placeNear(dialog, anchor); };
place();
if (anchor) addEventListener('resize', place);
return () => {
if (anchor) removeEventListener('resize', place);
if (typeof dialog.close === 'function' && dialog.open) dialog.close(); else dialog.removeAttribute('open');
placeNotifications();
queueMicrotask(() => { if (previous?.isConnected) previous.focus({ preventScroll: true }); });
};
}, []);
const requestClose = () => { if (!state.current.busy) state.current.onClose(); };
const outside = (event: { clientX: number; clientY: number; target: EventTarget; currentTarget: HTMLDialogElement }) => {
if (event.target !== event.currentTarget) return false;
const rect = event.currentTarget.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
};
return (
<dialog
ref={ref}
className={className ? `sheet ${className}` : 'sheet'}
data-size={size}
role={role}
aria-label={title}
aria-busy={busy}
onCancel={(event) => { event.preventDefault(); requestClose(); }}
onPointerDown={(event) => { pressedOutside.current = outside(event); }}
onClick={(event) => {
if (pressedOutside.current && outside(event)) requestClose();
pressedOutside.current = false;
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
// Keep the screen behind from reading the same key (the reader closes its
// chrome on Escape); the platform's `cancel` event does the closing.
event.stopPropagation();
if (!native.current) requestClose();
return;
}
if (event.key === 'Tab') trapTab(event.currentTarget, event);
}}
>
<div className="sheet-grip" aria-hidden="true" />
<header className="sheet-header">
<h2>{title}</h2>
{subtitle ? <span className="sheet-subtitle">{subtitle}</span> : null}
{actions}
<IconButton className="sheet-close" icon="close" label="关闭" disabled={busy} onClick={requestClose} />
</header>
<div className="sheet-body">{children}</div>
{footer ? <footer className="sheet-footer">{footer}</footer> : null}
</dialog>
);
}
/** The wide-window breakpoint; below it every sheet is a bottom sheet. */
const WIDE = '(min-width: 701px)';
const MARGIN = 16, GAP = 8;
/** The last press, so a compact sheet opened by a tap can appear beside that tap. */
let lastPress: { x: number; y: number; at: number } | null = null;
if (typeof document !== 'undefined') {
document.addEventListener('pointerdown', (event) => { lastPress = { x: event.clientX, y: event.clientY, at: Date.now() }; }, true);
}
interface Anchor { x: number; top: number; bottom: number }
/**
* Where the opener is: the press that opened it, else the focused control (keyboard),
* else nothing — and nothing keeps the CSS position (the top-right of the window).
*/
function anchorPoint(opener: HTMLElement | null): Anchor | null {
if (typeof matchMedia !== 'function' || !matchMedia(WIDE).matches) return null;
if (lastPress && Date.now() - lastPress.at < 1500) return { x: lastPress.x, top: lastPress.y, bottom: lastPress.y };
if (opener && opener !== document.body && opener.isConnected) {
const rect = opener.getBoundingClientRect();
if (rect.width || rect.height) return { x: rect.left + rect.width / 2, top: rect.top, bottom: rect.bottom };
}
return null;
}
/** Below the anchor if it fits, otherwise above it; always inside the window. */
function placeNear(dialog: HTMLDialogElement, anchor: Anchor): void {
if (!matchMedia(WIDE).matches) { dialog.removeAttribute('data-anchored'); dialog.style.removeProperty('left'); dialog.style.removeProperty('top'); return; }
dialog.setAttribute('data-anchored', '');
const { width, height } = dialog.getBoundingClientRect();
const left = Math.min(Math.max(MARGIN, anchor.x - width / 2), innerWidth - width - MARGIN);
const below = anchor.bottom + GAP, above = anchor.top - GAP - height;
const top = below + height <= innerHeight - MARGIN ? below : above >= MARGIN ? above : Math.max(MARGIN, innerHeight - height - MARGIN);
dialog.style.left = `${Math.round(left)}px`;
dialog.style.top = `${Math.round(top)}px`;
}
/** `showModal` makes the page inert but lets Tab leave for the browser chrome; keep it inside. */
function trapTab(dialog: HTMLDialogElement, event: { shiftKey: boolean; preventDefault(): void }): void {
const controls = [...dialog.querySelectorAll<HTMLElement>('button, input, select, textarea, a[href], summary, [tabindex]')]
.filter((element) => element.tabIndex >= 0 && !element.matches(':disabled') && element.closest('dialog') === dialog && element.getClientRects().length > 0);
const first = controls[0], last = controls.at(-1);
if (!first || !last) { event.preventDefault(); return; }
const active = document.activeElement as HTMLElement | null;
if (event.shiftKey && (active === first || !controls.includes(active!))) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); }
}
export interface ConfirmSheetProps {
title?: string;
text: ReactNode;
confirmText?: string;
cancelText?: string;
/** The confirm button reads as destructive: delete, clear, discard. */
danger?: boolean;
busy?: boolean;
onConfirm(): void;
onCancel(): void;
}
/**
* A question with two answers, in the same sheet as everything else.
*
* Focus starts on 取消, so an Enter pressed out of habit does not delete anything.
*/
export function ConfirmSheet({
title = '确认操作', text, confirmText = '确认', cancelText = '取消', danger = false, busy = false, onConfirm, onCancel,
}: ConfirmSheetProps): JSX.Element {
return (
<Sheet title={title} size="compact" role="alertdialog" busy={busy} onClose={onCancel} initialFocus=".sheet-cancel"
footer={<>
<Button className="sheet-cancel" disabled={busy} onClick={onCancel}>{cancelText}</Button>
<Button className={danger ? 'primary danger' : 'primary'} disabled={busy} onClick={onConfirm}>{confirmText}</Button>
</>}>
<p className="sheet-text">{text}</p>
</Sheet>
);
}
+42 -56
View File
@@ -22,8 +22,9 @@
*/
import type { AppSettings } from '../store/settings.ts';
import { Button, IconButton, SectionTitle, Segmented, SwitchRow } from './toolkit.tsx';
import { type JSX, useLayoutEffect, useRef } from 'react';
import { Button, SectionTitle, Segmented, SwitchRow } from './toolkit.tsx';
import { Sheet } from './sheet.tsx';
import type { JSX } from 'react';
export const DENSITY_LABELS: Record<AppSettings['shelfDensity'], string> = {
compact: '紧凑',
@@ -53,66 +54,51 @@ export interface ShelfSettingsOptions {
onOpenExternalReader(): void;
}
export function ShelfSettingsPanel({
open,
export function ShelfSettingsSheet({
settings,
onPatch,
onClose,
onOpenExternalReader,
}: ShelfSettingsOptions & { open: boolean }): JSX.Element {
const panel = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!open) return;
const trigger = document.activeElement;
panel.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
return () => { if (trigger instanceof HTMLElement && trigger.isConnected) trigger.focus({ preventScroll: true }); };
}, [open]);
}: ShelfSettingsOptions): JSX.Element {
return (
<div className="panel shelf-settings" ref={panel} role="dialog" aria-label="书架设置" hidden={!open}
onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } }}>
<div className="panel-header">
<h2>书架设置</h2>
<IconButton label="关闭" icon="close" onClick={onClose} />
</div>
<div className="panel-body">
<SectionTitle>封面大小</SectionTitle>
<Segmented
label="封面大小"
options={[
{ value: 'compact', label: DENSITY_LABELS.compact },
{ value: 'cozy', label: DENSITY_LABELS.cozy },
{ value: 'comfortable', label: DENSITY_LABELS.comfortable },
]}
value={settings.shelfDensity}
onChange={(value) => onPatch({ shelfDensity: value })}
/>
<Sheet className="shelf-settings" title="书架设置" onClose={onClose}>
<SectionTitle>封面大小</SectionTitle>
<Segmented
label="封面大小"
options={[
{ value: 'compact', label: DENSITY_LABELS.compact },
{ value: 'cozy', label: DENSITY_LABELS.cozy },
{ value: 'comfortable', label: DENSITY_LABELS.comfortable },
]}
value={settings.shelfDensity}
onChange={(value) => onPatch({ shelfDensity: value })}
/>
<SectionTitle>显示</SectionTitle>
<SwitchRow
label="显示作者"
hint="关闭后每本书只显示书名,封面可以更小"
checked={settings.shelfShowAuthor}
onChange={(checked) => onPatch({ shelfShowAuthor: checked })}
/>
<SwitchRow
label="显示阅读进度"
hint="在封面底部画一条进度条"
checked={settings.shelfShowProgress}
onChange={(checked) => onPatch({ shelfShowProgress: checked })}
/>
<SectionTitle>显示</SectionTitle>
<SwitchRow
label="显示作者"
hint="关闭后每本书只显示书名,封面可以更小"
checked={settings.shelfShowAuthor}
onChange={(checked) => onPatch({ shelfShowAuthor: checked })}
/>
<SwitchRow
label="显示阅读进度"
hint="在封面底部画一条进度条"
checked={settings.shelfShowProgress}
onChange={(checked) => onPatch({ shelfShowProgress: checked })}
/>
<SectionTitle>默认排序</SectionTitle>
<div className="notice">书架上的排序按钮会记住最后一次选择,这里设置的是首次打开时的默认值。</div>
<Segmented
label="默认排序"
options={SHELF_SORTS}
value={settings.shelfSort}
onChange={(value) => onPatch({ shelfSort: value })}
/>
<SectionTitle>外部阅读器</SectionTitle>
<div className="notice">通过 OPDS,让其他阅读器只读访问你的书架。</div>
<Button onClick={onOpenExternalReader}>连接外部阅读器</Button>
</div>
</div>
<SectionTitle>默认排序</SectionTitle>
<div className="notice">书架上的排序按钮会记住最后一次选择,这里设置的是首次打开时的默认值</div>
<Segmented
label="默认排序"
options={SHELF_SORTS}
value={settings.shelfSort}
onChange={(value) => onPatch({ shelfSort: value })}
/>
<SectionTitle>外部阅读器</SectionTitle>
<div className="notice">通过 OPDS,让其他阅读器只读访问你的书架</div>
<Button onClick={onOpenExternalReader}>连接外部阅读器</Button>
</Sheet>
);
}
+4 -5
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import type { ReaderApi } from '../api/client.ts';
import type { ManagedUser, RegistrationSettings } from '../api/types.ts';
import { Modal } from './modal.tsx';
import { FloatingConfirm } from './floating-confirm.tsx';
import { ConfirmSheet, Sheet } from './sheet.tsx';
import { FloatingNotice } from './floating-notice.tsx';
import {BusinessSettings} from './business-settings.tsx';
@@ -17,10 +16,10 @@ export function SystemSettings({api,onClose}:{api:ReaderApi;onClose():void}) {
async function work(action:()=>Promise<unknown>,success='已保存') { if(busy)return;setBusy(true);setError('');setMessage('');try{await action();await load();setConfirmation(null);setMessage(success);}catch(e){setError(e instanceof Error?e.message:'操作失败');}finally{setBusy(false);} }
useEffect(()=>{void work(async()=>{},'');},[]);
const self=api.currentSession()?.user.id;
return <Modal title="系统设置" busy={busy} onClose={onClose}><div className="user-management source-modal-content">
return <Sheet title="系统设置" size="wide" busy={busy} onClose={onClose}><div className="user-management">
<nav className="extension-tabs" aria-label="系统设置分类"><button aria-pressed={tab==='users'} onClick={()=>setTab('users')}>用户管理</button><button aria-pressed={tab==='registration'} onClick={()=>setTab('registration')}>注册与邀请</button><button aria-pressed={tab==='business'} onClick={()=>setTab('business')}>服务配置</button></nav>
<FloatingNotice message={error || message} error={!!error} kind="success" />
{confirmation && <FloatingConfirm text={confirmation.text} onCancel={()=>setConfirmation(null)} onConfirm={()=>{setConfirmation(null);void work(confirmation.run);}} />}
{confirmation && <ConfirmSheet text={confirmation.text} onCancel={()=>setConfirmation(null)} onConfirm={()=>{setConfirmation(null);void work(confirmation.run);}} />}
{tab==='users' ? <>
<div className="user-manager-actions"><input aria-label="筛选用户" placeholder="搜索用户名或显示名" value={query} onInput={e=>setQuery(e.currentTarget.value)} /><button className="button primary" disabled={busy} onClick={()=>{setForm(!form);setReset(null);setPassword('');}}>新增用户</button></div>
{form && <form className="user-manager-form" onSubmit={e=>{e.preventDefault();void work(async()=>{await api.adminCreateUser({username:username.trim(),password,displayName:displayName.trim(),role});setForm(false);setUsername('');setPassword('');setDisplayName('');setRole('member');},'用户已创建');}}>
@@ -43,5 +42,5 @@ export function SystemSettings({api,onClose}:{api:ReaderApi;onClose():void}) {
{code && <label className="user-manager-mode">新邀请码(仅本次显示,请复制保存)<input aria-label="新邀请码" readOnly value={code} onFocus={e=>e.currentTarget.select()} /></label>}
<ul className="user-manager-list">{settings.invites.map(invite=><li key={invite.id}><strong>{invite.label||'未备注邀请码'}</strong><p>已用 {invite.usedCount} / {invite.maxUses} 次 · {invite.disabled?'已停用':invite.expiresAt<=Date.now()?'已过期':invite.usedCount>=invite.maxUses?'已用完':'可用'}</p><p className="muted">有效至 {new Date(invite.expiresAt).toLocaleString()}</p>{!invite.disabled && <button className="button" disabled={busy} onClick={()=>setConfirmation({text:'停用此邀请码?已注册账号不受影响。',run:()=>api.disableInvite(invite.id)})}>停用邀请码</button>}</li>)}</ul>
</>}
</div></Modal>;
</div></Sheet>;
}
-27
View File
@@ -185,30 +185,3 @@ export function SwitchRow({ label, hint, checked, onChange }: SwitchRowProps): J
</div>
);
}
export interface ScrimProps {
onDismiss?(): void;
children: ReactNode;
className?: string;
}
/**
* The element panels and dialogs sit on.
*
* Clicking outside a panel to dismiss it is the one interaction a reader expects
* from a sheet, and re-implementing it per screen is how a screen ends up
* dismissing the panel when the reader tapped *inside* it (a click that lands on
* the panel's own padding still bubbles). The check is on the target, once.
*/
export function Scrim({ onDismiss, children, className }: ScrimProps): JSX.Element {
return (
<div
className={className ?? 'panel-host'}
onClick={(event) => {
if (onDismiss && event.target === event.currentTarget) onDismiss();
}}
>
{children}
</div>
);
}
+5 -5
View File
@@ -195,7 +195,7 @@ describe('chapter publication reading', () => {
await vi.waitFor(() => expect(env.screen.element.textContent).toContain('100 字符'));
expect(body(env.screen)?.textContent).toContain('resource:r1:a');
select.dispatchEvent(new Event('change', { bubbles: true }));
expect(env.screen.element.querySelector('dialog')?.textContent).not.toContain('100 字符');
expect(env.screen.element.querySelector('dialog[aria-label="选择阅读章节"]')?.textContent).not.toContain('100 字符');
let finish!: (value: {chapterId:string;title:string;latestChapter:string;characters:number;images:number;bytes:number;elapsedMs:number}) => void;
quality.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await click(env.screen, '检测所选章节 / 重试');
@@ -203,7 +203,7 @@ describe('chapter publication reading', () => {
expect(quality.mock.calls.at(-1)?.[3]?.signal?.aborted).toBe(true);
finish({chapterId:'x',title:'迟到结果',latestChapter:'末章',characters:999,images:0,bytes:999,elapsedMs:1});
await new Promise(resolve => setTimeout(resolve, 0));
expect(env.screen.element.querySelector('dialog')?.textContent).not.toContain('999 字符');
expect(env.screen.element.querySelector('dialog[aria-label="选择阅读章节"]')?.textContent).not.toContain('999 字符');
await click(env.screen, '确认换源并阅读');
await vi.waitFor(() => expect(noticeText()).toContain('正文获取失败'));
expect(body(env.screen)?.textContent).toContain('resource:r1:a');
@@ -421,9 +421,9 @@ it('opens tools, searches the whole book, navigates and returns, and persists a
await vi.waitFor(()=>expect(env.screen.element.querySelector('.reading-note-editor')).toBeNull());
await act(() => click(env.screen,'删除笔记'));
expect(env.offline.notesFor(book.id)).toHaveLength(1);
await act(() => document.querySelector<HTMLButtonElement>('.swal2-cancel')!.click());
await vi.waitFor(()=>expect(document.querySelector('.confirmation-host')).toBeNull());
await act(() => document.querySelector<HTMLButtonElement>('dialog[role=alertdialog] .sheet-cancel')!.click());
await vi.waitFor(()=>expect(document.querySelector('dialog[role=alertdialog]')).toBeNull());
expect(env.offline.notesFor(book.id)).toHaveLength(1);
await act(() => click(env.screen,'删除笔记'));await act(() => document.querySelector<HTMLButtonElement>('.swal2-confirm')!.click());
await act(() => click(env.screen,'删除笔记'));await act(() => document.querySelector<HTMLButtonElement>('dialog[role=alertdialog] .sheet-footer .primary')!.click());
await vi.waitFor(()=>expect(env.offline.notesFor(book.id)).toHaveLength(0));
});
+1
View File
@@ -390,6 +390,7 @@ describe('the stylesheet', () => {
'toolkit.tsx',
'reader-chrome.tsx',
'shelf-settings.tsx',
'sheet.tsx',
'dialog.tsx',
'pager.tsx',
];
+1 -1
View File
@@ -5,7 +5,7 @@ import {act} from 'react';
import {FolderCleanup} from '../src/features/media/components/folder-cleanup.tsx';
import type {MediaApi} from '../src/features/media/api/media-api.ts';
vi.mock('../src/ui/floating-confirm.tsx',()=>({FloatingConfirm:({text,onConfirm,onCancel}:any)=><div role="dialog"><p>{text}</p><button onClick={onConfirm}>确认清理</button><button onClick={onCancel}>取消</button></div>}));
vi.mock('../src/ui/sheet.tsx',()=>({ConfirmSheet:({text,onConfirm,onCancel}:any)=><div role="dialog"><p>{text}</p><button onClick={onConfirm}>确认清理</button><button onClick={onCancel}>取消</button></div>}));
const root=document.createElement('div');document.body.append(root);
afterEach(()=>{act(()=>render(null,root));vi.clearAllMocks();});
const click=async(text:string)=>act(async()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.includes(text))!.click());
+1 -2
View File
@@ -5,8 +5,7 @@ import {act} from 'react';
import {FolderRecognition} from '../src/features/media/components/folder-recognition.tsx';
import type {MediaApi} from '../src/features/media/api/media-api.ts';
vi.mock('../src/ui/modal.tsx',()=>({Modal:({children,title}:any)=><div role="dialog" aria-label={title}>{children}</div>}));
vi.mock('../src/ui/floating-confirm.tsx',()=>({FloatingConfirm:({onConfirm,onCancel}:any)=><div><button onClick={onConfirm}>确认应用</button><button onClick={onCancel}>取消应用</button></div>}));
vi.mock('../src/ui/sheet.tsx',()=>({Sheet:({children,title}:any)=><div role="dialog" aria-label={title}>{children}</div>,ConfirmSheet:({onConfirm,onCancel}:any)=><div><button onClick={onConfirm}>确认应用</button><button onClick={onCancel}>取消应用</button></div>}));
const root=document.createElement('div');document.body.append(root);
afterEach(()=>{act(()=>render(null,root));vi.clearAllMocks();});
const click=async(text:string)=>act(async()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.includes(text))!.click());
+1 -1
View File
@@ -14,7 +14,7 @@ it('keeps unsaved fields when staying and requires a deliberate discard before l
const onBack=vi.fn(),request=vi.fn();
await act(async()=>render(<MediaMetadataPage api={{request} as unknown as MediaApi} item={item} initialView="edit" onUpdated={vi.fn()} onBack={onBack}/>,root));
await fill('标题','尚未保存');await act(async()=>button('返回作品详情').click());
expect(root.querySelector('[role=alert]')?.textContent).toContain('有未保存的修改');expect(onBack).not.toHaveBeenCalled();
expect(root.querySelector('[role=alertdialog]')?.textContent).toContain('有未保存的修改');expect(onBack).not.toHaveBeenCalled();
await act(async()=>button('继续编辑').click());expect(root.querySelector<HTMLInputElement>('[aria-label="标题"]')!.value).toBe('尚未保存');
await act(async()=>button('返回作品详情').click());await act(async()=>button('放弃修改并离开').click());expect(onBack).toHaveBeenCalledOnce();expect(request).not.toHaveBeenCalled();
});
-32
View File
@@ -1,32 +0,0 @@
import { render } from '../src/shared/ui/render-root.ts';
// @vitest-environment jsdom
import {afterEach,expect,it,vi} from 'vitest';
import {act} from 'react';
import {MediaProviderSettings} from '../src/features/media/components/provider-settings.tsx';
import type {MediaApi} from '../src/features/media/api/media-api.ts';
const root=document.createElement('div');document.body.append(root);
afterEach(()=>act(()=>render(null,root)));
it('retries source status and shows only capabilities reported by the server',async()=>{
const request=vi.fn().mockRejectedValueOnce(new Error('网络中断')).mockResolvedValue({items:[{id:'tmdb',label:'TMDB',kinds:['movie','series'],configured:false},{id:'musicbrainz',label:'MusicBrainz',kinds:['album','track'],configured:true}]});
const businessSettingsRequest=vi.fn().mockResolvedValue({groups:[{group:'tmdb',label:'TMDB',revision:1,values:{enabled:false},secrets:{token:false},fields:[{key:'token',label:'读取令牌',type:'password'}]}]});
await act(async()=>render(<MediaProviderSettings api={{request,businessSettingsRequest} as unknown as MediaApi}/>,root));
await vi.waitFor(()=>expect(root.querySelector('[role=alert]')?.textContent).toContain('网络中断'));
await act(async()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='重试读取来源')!.click());
await vi.waitFor(()=>expect(root.querySelectorAll('.media-provider-row')).toHaveLength(3));
const rows=root.querySelectorAll('.media-provider-row');expect(rows).toHaveLength(3);expect(rows[0]!.textContent).toContain('未配置');expect(rows[1]!.textContent).toContain('已配置');
await act(async()=>(rows[0] as HTMLButtonElement).click());await vi.waitFor(()=>expect(root.querySelector('input[type=password]')).not.toBeNull());expect(root.textContent).not.toContain('MEDIA_TMDB_API_KEY');expect(root.textContent).toContain('保存配置');
expect(request.mock.calls.every(call=>call[0]==='metadata/providers'&&call[1]==='GET')).toBe(true);
expect(Array.from(root.querySelectorAll('button')).some(button=>button.textContent?.includes('TVBox'))).toBe(false);
});
it('keeps local information available while loading and aborts a pending source read on exit',async()=>{
let reject!:(error:Error)=>void;
const request=vi.fn(()=>new Promise((_resolve,no)=>reject=no));
await act(async()=>render(<MediaProviderSettings api={{request} as unknown as MediaApi}/>,root));
expect(root.querySelectorAll('.media-skeleton-entry')).toHaveLength(2);
await act(async()=>Array.from(root.querySelectorAll('button')).find(button=>button.textContent?.startsWith('本地资料'))!.click());
expect(root.textContent).toContain('扫描时读取本地资料');expect(root.querySelector('[role=alert]')).toBeNull();
const signal=(request.mock.calls[0] as unknown as unknown[])[3] as AbortSignal;
act(()=>render(null,root));expect(signal.aborted).toBe(true);
await act(async()=>reject(new Error('过期来源错误')));expect(root.textContent).toBe('');
});
+1 -1
View File
@@ -32,7 +32,7 @@ it('allows the page action menu to open resource details without rendering a bod
await act(async () => render(<ResourcePanel api={api} assets={[{ id: 'asset-1', title: '影片' }]} summary="1 个文件 · 原版" sourceInfo={<p>资料来源:TMDB</p>} openPanel="source" onPanelChange={onPanelChange} showTrigger={false} />, root));
expect(root.querySelector('.media-resource-panel')).toBeNull();
expect(document.querySelector('dialog[open]')?.getAttribute('aria-label')).toBe('来源');
const close = document.querySelector<HTMLButtonElement>('dialog button[aria-label="关闭弹窗"]');
const close = document.querySelector<HTMLButtonElement>('dialog button[aria-label="关闭"]');
await act(async () => close?.click());
expect(document.querySelector('dialog[open]')).toBeNull();
expect(onPanelChange).toHaveBeenCalledWith(null);
+3 -8
View File
@@ -67,13 +67,13 @@ it('asks before dropping an unsaved preference draft',async()=>{
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('浏览偏好'))!.click());
act(()=>{const select=root.querySelector('select')!;select.value='comfortable';select.dispatchEvent(new Event('change',{bubbles:true}));});
act(()=>root.querySelector<HTMLButtonElement>('[aria-label="返回影音设置"]')!.click());
expect(root.querySelector('[role=alert]')?.textContent).toContain('尚未保存');
expect(root.querySelector('[role=alertdialog]')?.textContent).toContain('尚未保存');
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='继续编辑')!.click());
expect(root.querySelector('select')!.value).toBe('comfortable');
act(()=>root.querySelector<HTMLButtonElement>('[aria-label="返回影音设置"]')!.click());
act(()=>{const select=root.querySelector('select')!;select.value='compact';select.dispatchEvent(new Event('change',{bubbles:true}));});
act(()=>root.querySelector<HTMLButtonElement>('[aria-label="返回影音设置"]')!.click());
expect(root.querySelector('h1')?.textContent).toBe('影音设置');expect(root.querySelector('[role=alert]')).toBeNull();
expect(root.querySelector('h1')?.textContent).toBe('影音设置');expect(root.querySelector('[role=alertdialog]')).toBeNull();
});
it('retains an unsaved draft through route back/forward and discards it only explicitly',async()=>{
const onPanelChange=vi.fn();let panel:'home'|'browse'='browse';
@@ -85,13 +85,8 @@ it('retains an unsaved draft through route back/forward and discards it only exp
expect(onPanelChange).toHaveBeenLastCalledWith('browse');act(()=>{panel='browse';draw();});
expect(root.querySelector('select')!.value).toBe('comfortable');
act(()=>root.querySelector<HTMLButtonElement>('[aria-label="返回影音设置"]')!.click());
expect(root.querySelector('[role=alert]')?.textContent).toContain('尚未保存');
expect(root.querySelector('[role=alertdialog]')?.textContent).toContain('尚未保存');
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='放弃修改')!.click());
expect(onPanelChange).toHaveBeenLastCalledWith('home');act(()=>{panel='home';draw();});
act(()=>{panel='browse';draw();});expect(root.querySelector('select')!.value).toBe('compact');
});
it('does not show playback settings when a member follows the provider deep link',async()=>{
await act(async()=>render(<MediaSettings scope="member" panel="plugins" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={vi.fn()} onManage={vi.fn()}/>,root));
expect(root.querySelector('[role=alert]')?.textContent).toContain('需要管理员权限');
expect(root.textContent).not.toContain('还没有正在播放');
});
+4 -5
View File
@@ -52,10 +52,10 @@ describe('我的', () => {
it('opens the shelf display sheet in place', () => {
useAuthStore.setState({ verifiedUser: user('member') });
paint();
const sheet = () => root.querySelector<HTMLElement>('[role=dialog][aria-label="书架设置"]')!;
expect(sheet().hidden).toBe(true);
const sheet = () => root.querySelector<HTMLElement>('dialog[aria-label="书架设置"]');
expect(sheet()).toBeNull();
act(() => { [...root.querySelectorAll<HTMLButtonElement>('button.mine-row')].find(b => b.textContent?.startsWith('书架显示'))!.click(); });
expect(sheet().hidden).toBe(false);
expect(sheet()?.hasAttribute('open')).toBe(true);
});
it('gathers administration under 管理 for administrators and shows the chosen theme', () => {
@@ -65,8 +65,7 @@ describe('我的', () => {
const labels = [...root.querySelectorAll('.mine-row-label')].map(el => el.textContent);
expect(labels).toContain('媒体库');
const admin = [...root.querySelectorAll('[aria-label="管理"] .mine-row-label')].map(el => el.textContent);
expect(admin).toEqual(['系统设置', '扫描与刮削', '来源与刮削']);
expect(root.querySelector('[aria-label="管理"] a[href="/me/plugins"]')).not.toBeNull();
expect(admin).toEqual(['系统设置', '扫描与刮削']);
expect(root.textContent).toContain('雾蓝 · 深色');
expect(root.querySelector('.mine-profile')?.textContent).toContain('管理员');
});
-1
View File
@@ -17,7 +17,6 @@ describe('React Router route tree', () => {
['/media/queue', 'MediaQueuePage'],
['/media/music/queue', 'MediaQueuePage'],
['/media/video/settings', 'MediaSettingsPage'],
['/media/video/settings/plugins', 'MediaSettingsPage'],
['/media/video/settings/libraries', 'MediaLibrariesPage'],
['/media/video/settings/tasks', 'MediaTasksPage'],
['/media/video/folders/library-1', 'MediaFoldersPage'],
+9 -9
View File
@@ -167,18 +167,18 @@ describe('ReaderChrome', () => {
it('separates appearance and behavior settings and omits text controls for comics', () => {
let tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
expect(tree.querySelector('.panel')?.textContent).toContain('黑白灰');
expect(tree.querySelector('.panel')?.textContent).not.toContain('点击区域');
expect(tree.querySelector('.sheet')?.textContent).toContain('黑白灰');
expect(tree.querySelector('.sheet')?.textContent).not.toContain('点击区域');
tree = paint({ settingsOpen: true, settingsTab: 'behavior' });
expect(tree.querySelector('.panel')?.textContent).toContain('点击区域');
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
expect(tree.querySelector('.sheet')?.textContent).toContain('点击区域');
expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号');
tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号');
});
it('puts the most-used appearance rows first and folds the rarely changed ones', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
const body = tree.querySelector('.panel-body')!;
const body = tree.querySelector('.sheet-body')!;
const order = [...body.children].map((element) =>
element.matches('details.panel-fold')
? `fold:${element.querySelector('.panel-fold-title')?.textContent}`
@@ -247,13 +247,13 @@ describe('ReaderChrome', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
const folds = [...tree.querySelectorAll('details.panel-fold .panel-fold-title')].map((element) => element.textContent);
expect(folds).toEqual(['页眉页脚']);
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
expect(tree.querySelector('.panel')?.textContent).toContain('黑白灰');
expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号');
expect(tree.querySelector('.sheet')?.textContent).toContain('黑白灰');
});
it('puts the start action first in the read-aloud sheet and folds pitch and volume', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'speech', ttsRate: 1.2, showPitchRow: true });
const body = tree.querySelector('.panel-body')!;
const body = tree.querySelector('.sheet-body')!;
// The action the sheet exists for is not buried under five sliders.
expect(body.firstElementChild?.textContent).toBe('从当前位置朗读');
expect(body.firstElementChild?.classList.contains('primary')).toBe(true);
+2 -2
View File
@@ -63,13 +63,13 @@ it('keeps the shelf entry points available and restores clear, settings and OPDS
const value = mount(<ShelfPage />, runtime(), '/shelf?q=三体');
expect(root.textContent).toContain('书源');
expect(root.textContent).not.toContain('刷新');
expect(root.textContent).toContain('设置');
expect(root.querySelector('button[aria-label^="书架设置"]')).not.toBeNull();
expect(root.querySelector('[aria-label="清除搜索"]')).not.toBeNull();
const search = root.querySelector<HTMLInputElement>('input[type="search"]')!;
await act(async () => root.querySelector<HTMLButtonElement>('[aria-label="清除搜索"]')!.click());
expect(search.value).toBe('');
const settingsButton = [...root.querySelectorAll('button')].find(button => button.textContent === '设置');
const settingsButton = root.querySelector<HTMLButtonElement>('button[aria-label^="书架设置"]');
await act(async () => settingsButton?.click());
expect(root.textContent).toContain('连接外部阅读器');
await act(async () => [...root.querySelectorAll('button')].find(button => button.textContent === '连接外部阅读器')?.click());
+2 -2
View File
@@ -58,8 +58,8 @@ it('shows a card\'s state and marks it from the ⋯ menu', async () => {
paint('/shelf');
expect(root.querySelector('.book-state')?.textContent).toBe('在读');
await act(() => root.querySelector<HTMLButtonElement>('.book-more')!.click());
const options = [...root.querySelectorAll('.dialog button')].map(button => button.textContent);
expect(options).toEqual(['标记为想读', '标记为已读', '清除标记', '从书架拿掉', '取消']);
const options = [...root.querySelectorAll('.sheet-list button')].map(button => button.textContent);
expect(options).toEqual(['标记为想读', '标记为已读', '清除标记', '从书架拿掉']);
await click('标记为已读');
expect(setReadingStatus).toHaveBeenCalledWith('b1', 'finished');
await act(() => root.querySelector<HTMLButtonElement>('.book-more')!.click());
+1 -1
View File
@@ -51,7 +51,7 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
assert.equal(await page.locator('.media-player audio').evaluate(a=>a.currentSrc),source);assert.equal(await page.locator('.media-player audio').evaluate(a=>a.paused),false);assert.equal(starts,1);
const mini=await page.locator('.media-player').boundingBox();assert.ok(mini.y+mini.height>814&&mini.y+mini.height<=844,'mini player must use the freed bottom area');
await page.getByRole('button',{name:'打开播放器',exact:true}).click();await page.getByRole('button',{name:'← 返回浏览',exact:true}).click();await heading('播放设置');await hash('#/media/audiobook/settings/playback');
const member=await browser.newPage();member.on('pageerror',error=>errors.push(error.message));await member.goto(baseUrl+'/#/media/video/settings/libraries');await member.locator('input[autocomplete="username"]').fill('review-member');await member.locator('input[type="password"]').fill('review-test-pass');await member.locator('form button[type=submit]').click();await member.waitForURL('**/#/media/video/settings');await member.getByRole('heading',{name:'影音设置',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:/^媒体库管理/}).count(),0);await member.goto(baseUrl+'/#/media/video/settings/plugins');await member.getByRole('heading',{name:'需要管理员权限',exact:true}).waitFor();await member.close();
const member=await browser.newPage();member.on('pageerror',error=>errors.push(error.message));await member.goto(baseUrl+'/#/media/video/settings/libraries');await member.locator('input[autocomplete="username"]').fill('review-member');await member.locator('input[type="password"]').fill('review-test-pass');await member.locator('form button[type=submit]').click();await member.waitForURL('**/#/media/video/settings');await member.getByRole('heading',{name:'影音设置',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:/^媒体库管理/}).count(),0);await member.goto(baseUrl+'/#/media/video/settings/libraries');await member.getByRole('heading',{name:'需要管理员权限',exact:true}).waitFor();await member.close();
assert.deepEqual(errors,[]);await fs.writeFile(join(out,'verification.json'),JSON.stringify({passed:true,shots,checks:['mobile channel switch and Escape','no mobile bottom bar','settings child reload','personal origin reload and detail return','browser back/forward','desktop one header','reading link preserved','audio source/session survives channel and settings navigation','mini player bottom offset','member deep-link permissions'],browserErrors:errors},null,2));
await fs.writeFile(join(out,'index.html'),`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>影音导航与路由评审</title><style>body{font:14px/1.6 system-ui;background:#f4f3ef;color:#30392c;margin:24px}img{max-width:100%;border:1px solid #ddd}section{margin:28px 0}</style><h1>频道切换与独立页面路由</h1><p>本轮按最新要求移除移动端底部频道栏。频道首页标题旁下拉切换;设置及其子页、收藏、历史有独立地址。二级页统一返回和标题,个人内容标注频道。实际客户端与临时服务、测试账号资料。</p>${shots.map(key=>`<section><h2>${key}</h2><img src="${key}.png"></section>`).join('')}</html>`);console.log(JSON.stringify({passed:true,screenshots:shots.length}));
}finally{if(browser)await browser.close();server.stdin.end();await new Promise(resolve=>server.exitCode!==null?resolve():server.once('exit',resolve));}
+1 -1
View File
@@ -29,7 +29,7 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
await go('#/media/video/items/'+movie.id);await page.getByRole('button',{name:'播放电影',exact:true}).click();await page.locator('.dplayer-host').waitFor();await ready('video');await page.waitForURL(url=>{const routed=new URL('http://route'+url.hash.slice(1));return routed.pathname==='/media/video/player'&&routed.searchParams.get('item')&&routed.searchParams.get('part');});
assert.equal(await page.locator('.dplayer-controller').count(),1);assert.equal(await page.locator('.media-video-timeline').count(),0);await shot('video-mobile');
await page.setViewportSize({width:390,height:844});await shot('video-mobile-narrow');assert.ok(await page.locator('.dplayer-host').evaluate(node=>node.scrollWidth<=node.clientWidth+1));assert.equal(await page.locator('.dplayer-volume-bar-wrap').isVisible(),false);assert.ok((await page.locator('.dplayer-bar-wrap').boundingBox()).width>=80);await page.setViewportSize({width:535,height:834});
const videoUrl=page.url(),videoSource=await page.locator('.media-player video').getAttribute('src');await page.getByRole('button',{name:'字幕',exact:true}).click();await page.getByRole('dialog',{name:'字幕'}).waitFor();await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();assert.equal(await page.locator('.media-player video').getAttribute('src'),videoSource);
const videoUrl=page.url(),videoSource=await page.locator('.media-player video').getAttribute('src');await page.getByRole('button',{name:'字幕',exact:true}).click();await page.getByRole('dialog',{name:'字幕'}).waitFor();await page.locator('dialog[open] .sheet-close').last().click();assert.equal(await page.locator('.media-player video').getAttribute('src'),videoSource);
await page.setViewportSize({width:1120,height:900});await shot('video-desktop');
await page.locator('.dplayer-host').hover();await page.locator('.dplayer-full-icon').click();await page.waitForFunction(()=>!!document.fullscreenElement);await shot('video-fullscreen');await page.locator('.dplayer-host').hover();await page.locator('.dplayer-full-icon').click();await page.waitForFunction(()=>!document.fullscreenElement);
const saveVideo=saved();await page.locator('.media-player video').evaluate(video=>{video.currentTime=20;video.pause();});await saveVideo;await page.reload();await page.locator('.dplayer-host').waitFor();await ready('video');assert.equal(page.url(),videoUrl);assert.ok(await page.locator('.media-player video').evaluate(video=>video.currentTime>=19&&video.paused));await shot('video-restored-desktop');
+5 -5
View File
@@ -62,7 +62,7 @@ const assert=require('node:assert/strict');
await readingPage.close();
const page=await browser.newPage({viewport:{width:390,height:844}}),errors=[];page.on('pageerror',e=>errors.push(e.message));
async function stopPlayback(){
const close=page.getByRole('button',{name:'关闭播放菜单',exact:true});if(await close.isVisible())await close.click();
const close=page.locator('dialog[open] .sheet-close').last();if(await close.isVisible())await close.click();
const videoStop=page.getByRole('button',{name:'结束',exact:true});
if(await videoStop.isVisible()){await videoStop.click();return;}
const audioStop=page.getByRole('button',{name:'结束播放',exact:true});
@@ -410,13 +410,13 @@ const assert=require('node:assert/strict');
await page.getByLabel('更多播放选项',{exact:true}).click();
await page.getByRole('button',{name:'快进 30 秒',exact:true}).click();
await page.getByRole('button',{name:'快退 15 秒',exact:true}).click();
await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();
await page.locator('dialog[open] .sheet-close').last().click();
await page.waitForFunction(()=>{const a=document.querySelector('.media-player audio');return a.paused&&Math.abs(a.currentTime-15)<.2;});
await page.getByRole('button',{name:'继续',exact:true}).click();
await page.getByRole('button',{name:'更多播放选项',exact:true}).click();
await page.getByRole('combobox',{name:'倍速',exact:true}).selectOption('1.75');
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.playbackRate),1.75);
await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();
await page.locator('dialog[open] .sheet-close').last().click();
await page.getByRole('combobox',{name:'定时',exact:true}).selectOption('15');
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'15 分钟');
for(let i=0;i<50&&renewalCount===0;i++)await delay(100);
@@ -516,7 +516,7 @@ const assert=require('node:assert/strict');
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'15 分钟');
await page.getByRole('combobox',{name:'定时',exact:true}).selectOption('0');
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'定时');
await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();
await page.locator('dialog[open] .sheet-close').last().click();
await page.getByRole('button',{name:'字幕',exact:true}).click();
// Exercise native TextTrack events; this validates controls, not container demux support.
await page.locator('.media-player video').evaluate(video=>{
@@ -905,7 +905,7 @@ const assert=require('node:assert/strict');
const savedLiveQueue=await(await fetch(origin+'/api/v1/media/queue',{headers})).json();
assert.deepEqual(savedLiveQueue.items.filter(entry=>entry.kind==='audiobook').map(entry=>entry.partId),[chapters[1].id]);
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.currentSrc),sourceBeforeEdit);
await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();
await page.locator('dialog[open] .sheet-close').last().click();
await stopPlayback();
await page.getByRole('button',{name:'播放',exact:true}).nth(1).click();
+1 -1
View File
@@ -33,7 +33,7 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
await go('#/media/video/items/'+movie.id);await page.locator('summary[aria-label="作品操作"]').click();await page.getByRole('button',{name:'版本管理',exact:true}).click();
const dialog=page.getByRole('dialog',{name:'版本管理'});await dialog.waitFor();
for(const width of [390,535,1120]){
await page.setViewportSize({width,height:834});const header=await dialog.locator('.source-modal-header').boundingBox();assert.ok(header.height<=60,'resource header must be compact');heights.push({theme:id,width,height:header.height});
await page.setViewportSize({width,height:834});const header=await dialog.locator('.sheet-header').boundingBox();assert.ok(header.height<=60,'resource header must be compact');heights.push({theme:id,width,height:header.height});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);await shot('dialog-'+id+'-'+width);
}
const url=page.url();await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});assert.equal(page.url(),url);
+1 -1
View File
@@ -57,7 +57,7 @@ async function saveShot(capture){for(let attempt=0;;attempt++){try{return await
['favorites','favorites','.media-favorite-list'],['history','video/history','.media-history-row'],['folders',`music/folders?library=${album.libraryId}`,'.media-folder-row'],
['settings','music/settings','.media-settings-intro'],['themes','music/settings/theme','.media-theme-card'],['browse-settings','music/settings/browse','.media-settings-panel'],['playback-settings','music/settings/playback','.media-settings-panel'],
['libraries','music/settings/libraries','.media-library-row'],['library-edit','video/settings/libraries/new','.media-library-create'],['permissions',`video/settings/libraries/${movie.libraryId}/permissions`,'.media-permissions-form'],
['tasks','video/settings/tasks','.media-manager'],['metadata',`video/items/${movie.id}/metadata`,'.media-metadata-host'],['match',`video/items/${movie.id}/match`,'.media-metadata-host'],['plugins','video/settings/plugins','.media-settings'],['account','music/settings/account','.media-account-panel'],
['tasks','video/settings/tasks','.media-manager'],['metadata',`video/items/${movie.id}/metadata`,'.media-metadata-host'],['match',`video/items/${movie.id}/match`,'.media-metadata-host'],['account','music/settings/account','.media-account-panel'],
];
for(const [channel,item] of [['music',album],['audiobook',book]]){await go('#/media/'+channel+'/items/'+item.id);await page.locator('.media-detail-favorite').waitFor();await page.locator('.media-detail-favorite').click();await page.waitForFunction(()=>document.querySelector('.media-detail-favorite')?.getAttribute('aria-pressed')==='true');}
const wanted=process.env.MEDIA_V2_ONLY?.split(',');
+5 -5
View File
@@ -67,20 +67,20 @@ try {
await page.setViewportSize({ width, height: 844 });
await page.locator('.shelf-head button[aria-label^=书架设置]').click();
const close = page.locator('.shelf-settings button[aria-label="关闭"]');
await page.locator('.shelf-settings .panel-body').evaluate(el => { el.scrollTop = 0; });
await page.locator('.shelf-settings .sheet-body').evaluate(el => { el.scrollTop = 0; });
await shot('shelf-settings-' + width);
assert.equal(await close.evaluate(el => { const r = el.getBoundingClientRect(), p = el.closest('.panel-header').getBoundingClientRect(); return p.right - r.right <= 20 && r.top >= p.top && r.bottom <= p.bottom; }), true);
assert.equal(await close.evaluate(el => { const r = el.getBoundingClientRect(), p = el.closest('.sheet-header').getBoundingClientRect(); return p.right - r.right <= 20 && r.top >= p.top && r.bottom <= p.bottom; }), true);
if (width >= 1280) {
const desktopPanel = await page.locator('.shelf-settings').evaluate(el => {
const r = el.getBoundingClientRect();
return { rightGap: innerWidth - r.right, width: r.width, top: r.top, bottom: innerHeight - r.bottom };
});
assert.ok(desktopPanel.width >= 400 && desktopPanel.width <= 450, 'desktop settings should be a compact side panel');
assert.ok(desktopPanel.width >= 440 && desktopPanel.width <= 500, 'desktop settings should be a compact side panel');
assert.ok(desktopPanel.rightGap <= 20 && desktopPanel.top >= 10 && desktopPanel.bottom >= 10, 'desktop settings should sit inside the viewport');
assert.equal(await page.locator('.shelf-discovery').evaluate(el => getComputedStyle(el).display), 'flex');
}
await page.setViewportSize({ width, height: width >= 1024 ? 420 : 568 });
const body = page.locator('.shelf-settings .panel-body');
const body = page.locator('.shelf-settings .sheet-body');
await body.evaluate(el => { el.scrollTop = 45; });
await shot('shelf-settings-sticky-' + width);
const sticky = await body.evaluate(el => {
@@ -114,7 +114,7 @@ try {
await shot('shelf-desktop-' + width);
const trigger = page.locator('.shelf-head button[aria-label^=书架设置]');
await trigger.click();
await page.locator('.shelf-settings .panel-body').evaluate(el => { el.scrollTop = 0; });
await page.locator('.shelf-settings .sheet-body').evaluate(el => { el.scrollTop = 0; });
await shot('settings-desktop-' + width);
if (width === 1366) {
await page.evaluate(() => document.documentElement.dataset.theme = 'dark');
+10 -10
View File
@@ -47,7 +47,7 @@ try {
const before = await surface();
const trigger = page.locator('.reader-actions').getByRole('button', { name: label, exact: true });
await trigger.click();
const panel = page.locator('.reader-screen .panel');
const panel = page.locator('.reader-screen .sheet');
await panel.waitFor();
await shot(label + '-' + width);
const bounds = await panel.evaluate(el => {
@@ -55,9 +55,9 @@ try {
return { left: r.left, rightGap: innerWidth - r.right, width: r.width, top: r.top, bottomGap: innerHeight - r.bottom,
overflow: el.scrollWidth > el.clientWidth };
});
assert.ok(bounds.width <= 420 && bounds.width >= 360, 'bounded panel width');
assert.ok(bounds.width <= 500 && bounds.width >= 440, 'bounded panel width');
assert.ok(bounds.top >= 10 && bounds.bottomGap >= 10 && !bounds.overflow, 'panel fits viewport');
assert.ok((label === '目录' ? bounds.left : bounds.rightGap) <= 20, 'contents on left, settings on right');
assert.ok(bounds.rightGap <= 20, 'every reader sheet is the right-edge drawer');
assert.deepEqual(await surface(), before, 'opening ' + label + ' preserves the reading position and geometry');
await panel.getByRole('button', { name: '关闭', exact: true }).press('Escape');
await panel.waitFor({ state: 'detached' });
@@ -73,28 +73,28 @@ try {
await page.locator('.reader-screen[data-chrome=visible]').waitFor();
}
await page.locator('.reader-actions').getByRole('button', {name:'设置',exact:true}).click();
await page.locator('.panel').getByRole('button',{name:'翻页',exact:true}).click();
await page.locator('.panel button[aria-label="关闭"]').click();
await page.locator('.sheet').getByRole('button',{name:'翻页',exact:true}).click();
await page.locator('.sheet button[aria-label="关闭"]').click();
await page.locator('.progress-scrubber').fill('2');
await page.waitForFunction(() => document.querySelector('.progress-page')?.textContent?.includes('第 2/'));
await shot('paged');
const paged = await surface();
await page.locator('.reader-actions').getByRole('button',{name:'目录',exact:true}).click();
await page.locator('.panel button[aria-label="关闭"]').press('Escape');
await page.locator('.sheet button[aria-label="关闭"]').press('Escape');
assert.deepEqual(await surface(),paged,'side panels preserve the selected page in paged mode');
await page.getByRole('button',{name:'夜间',exact:true}).click();
await shot('dark');
await page.locator('.reader-actions').getByRole('button',{name:'外观',exact:true}).click();
await shot('dark-settings');
await page.locator('.panel button[aria-label="关闭"]').click();
await page.locator('.sheet button[aria-label="关闭"]').click();
await page.getByRole('button',{name:'日间',exact:true}).click();
await page.setViewportSize({ width: 1024, height: 420 });
await page.locator('.reader-actions').getByRole('button', { name: '外观', exact: true }).click();
const body = page.locator('.panel-body');
const body = page.locator('.sheet-body');
await body.hover(); await page.mouse.wheel(0, 300);
await page.waitForFunction(() => document.querySelector('.panel-body')?.scrollTop > 0);
await page.waitForFunction(() => document.querySelector('.sheet-body')?.scrollTop > 0);
await shot('short-window-scroll');
await page.locator('.panel button[aria-label="关闭"]').click();
await page.locator('.sheet button[aria-label="关闭"]').click();
assert.deepEqual(errors, []);
console.log('PASS desktop reader: 1024/1366/1920px, four panels, Escape/focus, immersive mode, stable page geometry and short-window scrolling');
} finally {
+4 -4
View File
@@ -60,9 +60,9 @@ try {
assert.ok(await page.locator('book-content').evaluate(host=>host.shadowRoot.querySelectorAll('[data-reader-mark="search"]').length)>0);
await button('工具').click();await button('返回原阅读位置').click();
await button('目录').click();await page.getByRole('button',{name:'第三层',exact:false}).last().waitFor();await page.getByRole('button',{name:'第一层 折叠',exact:true}).click();assert.equal(await page.getByRole('button',{name:'第三层',exact:false}).count(),0);await page.getByRole('button',{name:'第一层 展开',exact:true}).click();await page.getByRole('button',{name:/3.*第三层/}).click();
await button('工具').click();await button('离线缓存').click();await button('下载 / 继续').click();await page.getByText('整书已可离线阅读',{exact:false}).waitFor();await page.screenshot({path:join(shots,'offline.png')});await button('关闭弹窗').click();
await button('工具').click();await button('离线缓存').click();await button('下载 / 继续').click();await page.getByText('整书已可离线阅读',{exact:false}).waitFor();await page.screenshot({path:join(shots,'offline.png')});await page.locator('dialog[open] .sheet-close').last().click();
await context.route('**/api/**',route=>route.abort('internetdisconnected'));await page.reload();await page.locator('book-content h1').waitFor();
await button('工具').click();await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();await page.getByText('离线搜索(仅已缓存内容)',{exact:false}).waitFor();await button('关闭弹窗').click();await context.unroute('**/api/**');
await button('工具').click();await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();await page.getByText('离线搜索(仅已缓存内容)',{exact:false}).waitFor();await page.locator('dialog[open] .sheet-close').last().click();await context.unroute('**/api/**');
// Select actual shadow DOM text, then create a persisted personal correction.
await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
await page.getByRole('toolbar',{name:'选中文字操作'}).waitFor();await page.screenshot({path:join(shots,'selection-toolbar-390.png')});
@@ -89,8 +89,8 @@ try {
await page.getByRole('toolbar',{name:'选中文字操作'}).waitFor();await button('关闭选区工具').click();
await page.locator('book-content').evaluate(host=>{delete host.shadowRoot.getSelection;});
await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
await button('工具').click();await button('内容整理').click();await page.getByLabel('替换为',{exact:true}).fill('已经修正');await button('保存纠错 / 过滤').click();await page.getByText('纠错已保存,正文已更新').waitFor();await button('关闭弹窗').click();await page.reload();await page.locator('book-content p').filter({hasText:'已经修正'}).waitFor();
await button('工具').click();await button('内容整理').click();await button('撤销上一次整理修改').click();await page.getByText('已撤销上一次整理修改').waitFor();await button('关闭弹窗').click();assert.ok(!(await page.locator('book-content p').first().innerText()).includes('已经修正'));
await button('工具').click();await button('内容整理').click();await page.getByLabel('替换为',{exact:true}).fill('已经修正');await button('保存纠错 / 过滤').click();await page.getByText('纠错已保存,正文已更新').waitFor();await page.locator('dialog[open] .sheet-close').last().click();await page.reload();await page.locator('book-content p').filter({hasText:'已经修正'}).waitFor();
await button('工具').click();await button('内容整理').click();await button('撤销上一次整理修改').click();await page.getByText('已撤销上一次整理修改').waitFor();await page.locator('dialog[open] .sheet-close').last().click();assert.ok(!(await page.locator('book-content p').first().innerText()).includes('已经修正'));
await page.goto(base+'/#/book/'+txt.id);await page.locator('book-content p').first().waitFor();await button('工具').click();await button('内容整理').click();await page.getByLabel('标题开头').fill('@@');await button('预览目录').click();await button('应用预览规则').waitFor();await page.screenshot({path:join(shots,'txt-preview.png')});await button('应用预览规则').click();await page.getByText('目录规则已保存').waitFor();
assert.deepEqual(errors,[]);console.log('P1 browser flow passed: search/navigation/highlight, 320–1280px, nested TOC, offline EPUB reopen, persisted correction/undo, TXT preview/apply');
}finally{await browser?.close();await app?.close();db?.close();const cleanupRelative=relative(resolve(tmpdir()),resolve(root));assert.ok(!cleanupRelative.startsWith('..')&&!isAbsolute(cleanupRelative)&&cleanupRelative.startsWith('reader-p1-'));await rm(root,{recursive:true,force:true});}
+2 -2
View File
@@ -44,9 +44,9 @@ try {
const opdsUser=await page.getByLabel('OPDS 用户名',{exact:true}).inputValue(),opdsPassword=await page.getByLabel('OPDS 密码',{exact:true}).inputValue();
const authorization='Basic '+Buffer.from(opdsUser+':'+opdsPassword).toString('base64');
const feed=await fetch(base+'/opds',{headers:{authorization}});assert.equal(feed.status,200);assert.match(await feed.text(),/opds-test/);
await button('关闭弹窗').click();await button('连接外部阅读器').click();await button('撤销 浏览器验收客户端').waitFor();assert.equal(await page.getByLabel('OPDS 密码',{exact:true}).count(),0);
await page.locator('dialog[open] .sheet-close').last().click();await button('连接外部阅读器').click();await button('撤销 浏览器验收客户端').waitFor();assert.equal(await page.getByLabel('OPDS 密码',{exact:true}).count(),0);
for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'opds-access-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
await button('撤销 浏览器验收客户端').click();await button('撤销 浏览器验收客户端').waitFor({state:'hidden'});assert.equal((await fetch(base+'/opds',{headers:{authorization}})).status,401);await button('关闭弹窗').click();
await button('撤销 浏览器验收客户端').click();await button('撤销 浏览器验收客户端').waitFor({state:'hidden'});assert.equal((await fetch(base+'/opds',{headers:{authorization}})).status,401);await page.locator('dialog[open] .sheet-close').last().click();
await page.goto(base+'/#/sources');assert.equal(await button('连接外部阅读器').count(),0);
await page.getByLabel('选择来源').selectOption('quality');
assert.equal(await page.locator('.source-capabilities').count(),0);
+4 -3
View File
@@ -208,8 +208,9 @@ async function runReaderReview(browser, origin, checks, errors) {
assert.equal(await page.getByText(/已选 1 项/).count(), 0);
}
await capture(page, 'library-files', checks, errors);
await page.goto(`${origin}/#/settings`);
await waitHeading(page, '设置');
// /settings redirects to the shelf; 我的 is the settings hub now.
await page.goto(`${origin}/#/me`);
await waitHeading(page, '我的');
await capture(page, 'settings', checks, errors);
await page.goto(`${origin}/#/sources`);
await waitHeading(page, '书源');
@@ -334,7 +335,7 @@ async function runMediaReview(browser, fixture, checks, errors) {
await page.getByRole('button', { name: '清空当前频道队列' }).click();
await page.getByRole('button', { name: '确认清空' }).waitFor({ state: 'visible' });
assert.match(await page.locator('[role="dialog"], .media-floating-confirm').last().innerText().catch(() => ''), /清空队列|确认清空/);
assert.match(await page.locator('dialog[open]').last().innerText().catch(() => ''), /清空队列|确认清空/);
const clear = page.waitForResponse(r => r.request().method() === 'POST' && r.url().endsWith('/api/v1/media/queue/clear'));
await page.getByRole('button', { name: '确认清空' }).click();
assert.equal((await clear).status(), 200, 'queue clear failed');
+13 -13
View File
@@ -46,16 +46,16 @@ try {
await cdp.screenshot(join(shots, 'reader.png'));
for (const label of ['目录', '外观', '设置', '朗读']) {
await cdp.clickText('.reader-actions button', label);
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.run(`await Promise.all(document.querySelector('.panel').getAnimations().map(a=>a.finished));`);
await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.run(`await Promise.all(document.querySelector('.sheet').getAnimations().map(a=>a.finished));`);
const opened = await read();
assert.equal(opened.y, after.y, `${label} must preserve scroll position`);
assert.equal(opened.height, after.height, `${label} must preserve surface height`);
const panel = await cdp.run(`const r=document.querySelector('.panel').getBoundingClientRect();return {top:r.top,bottom:r.bottom};`);
const panel = await cdp.run(`const r=document.querySelector('.sheet').getBoundingClientRect();return {top:r.top,bottom:r.bottom};`);
assert.ok(panel.top > 250 && panel.bottom <= 845, `sheet must leave a preview and fit the screen: ${JSON.stringify(panel)}`);
await cdp.screenshot(join(shots, `panel-${label}.png`));
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.waitFor('document.querySelector(".panel") === null');
await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.waitFor('document.querySelector(".sheet") === null');
assert.equal((await read()).y, after.y, `${label} close must preserve position`);
}
await cdp.clickText('.reader-actions button', '外观');
@@ -69,7 +69,7 @@ try {
const modes = await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)');
assert.deepEqual(modes, ['book','chapter','time','progress']);
await cdp.screenshot(join(shots, 'readout-settings.png'));
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.tapMiddle();
await cdp.screenshot(join(shots, 'readout-custom.png'));
await cdp.navigate(`${origin}/#/shelf`);
@@ -84,8 +84,8 @@ try {
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
await cdp.sleep(100);
}
await cdp.clickText('.panel button', '黑白灰');
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.clickText('.sheet button', '黑白灰');
await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.sleep(300);
assert.equal((await read()).page, after.page, 'theme must preserve page');
await cdp.tapMiddle();
@@ -100,8 +100,8 @@ try {
}
await cdp.tapMiddle();
await cdp.clickText('.reader-actions button', '设置');
await cdp.clickText('.panel button', '翻页');
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.clickText('.sheet button', '翻页');
await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.sleep(300);
await cdp.run(`const el=document.querySelector('.progress-scrubber');el.value='2';el.dispatchEvent(new Event('input',{bubbles:true}));`);
await cdp.sleep(250);
@@ -153,11 +153,11 @@ try {
await cdp.sleep(250);
for(const label of ['外观','设置']) {
await cdp.clickText('.reader-actions button', label);
await cdp.run(`await Promise.all(document.querySelector('.panel').getAnimations().map(a=>a.finished));`);
const overflow=await cdp.run(`const p=document.querySelector('.panel');return p.scrollWidth-p.clientWidth;`);
await cdp.run(`await Promise.all(document.querySelector('.sheet').getAnimations().map(a=>a.finished));`);
const overflow=await cdp.run(`const p=document.querySelector('.sheet');return p.scrollWidth-p.clientWidth;`);
assert.ok(overflow <= 1, `${label} fits a narrow phone`);
await cdp.screenshot(join(shots, `narrow-${label}.png`));
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.click('.sheet button[aria-label="关闭"]');
}
await cdp.navigate(`${origin}/#/book/review-pdf`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("iframe")?.src.startsWith("blob:")');
+8 -8
View File
@@ -170,7 +170,7 @@ async function audit(cdp, origin, scenes, results) {
// geometry step above leaves the *contents* sheet up, and a blind click on the
// settings button would close it and then wait for a sheet that is already there.
const settingsTitle = await cdp.evaluate(
`document.querySelector('.panel')?.querySelector('.panel-title, h2, header')?.textContent?.trim() ?? ''`,
`document.querySelector('.sheet')?.querySelector('.sheet-header h2')?.textContent?.trim() ?? ''`,
);
if (!settingsTitle.includes('设置')) {
if (settingsTitle) {
@@ -178,7 +178,7 @@ async function audit(cdp, origin, scenes, results) {
await cdp.sleep(300);
}
await cdp.click('button[aria-label="设置"]');
await cdp.waitFor('document.querySelector(".panel") !== null', 10_000);
await cdp.waitFor('document.querySelector(".sheet") !== null', 10_000);
}
await cdp.sleep(300);
await cdp.clickText('.segmented button', '滚动');
@@ -1103,7 +1103,7 @@ async function main() {
const theme = scene.theme;
if (theme) {
await cdp.click('button[aria-label="界面"]');
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.clickText('.segmented button', theme);
await cdp.sleep(250);
await cdp.click('button[aria-label="关闭"]');
@@ -1111,12 +1111,12 @@ async function main() {
}
if (scene.openPanel) {
await cdp.click(`button[aria-label="${scene.openPanel}"]`);
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.sleep(350);
}
if (scene.choose) {
await cdp.click(`button[aria-label="${scene.choose[0]}"]`);
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.clickText('.segmented button', scene.choose[1]);
await cdp.sleep(300);
}
@@ -1163,17 +1163,17 @@ async function main() {
// starting position — off the bottom of the screen — and every number derived
// from it is then about the animation rather than about the layout.
await cdp.click('button[aria-label="目录"]');
await cdp.waitFor('document.querySelector(".panel") !== null', 10_000);
await cdp.waitFor('document.querySelector(".sheet") !== null', 10_000);
await cdp.sleep(400);
const panelGeometry = await cdp.evaluate(`(() => {
const panel = document.querySelector('.panel');
const panel = document.querySelector('.sheet');
const stage = document.querySelector('.stage');
if (!panel || !stage) return null;
const p = panel.getBoundingClientRect();
const s = stage.getBoundingClientRect();
return {
panelTop: p.top, panelHeight: p.height, stageTop: s.top, stageHeight: s.height,
hasScrim: !!document.querySelector('.scrim'), grip: !!document.querySelector('.panel-grip'),
hasScrim: !!document.querySelector('dialog.sheet[open]'), grip: !!document.querySelector('.sheet-grip'),
visibleText: p.top - s.top,
};
})()`);
+12 -12
View File
@@ -13,26 +13,26 @@ export async function reviewUserManagement(context,page,base,shots) {
const member=dialog.locator('li').filter({hasText:'@family-ui'});await member.waitFor();
const before=await member.boundingBox();
await member.getByRole('button',{name:'停用',exact:true}).click();
await page.locator('.swal2-popup').waitFor();
await page.locator('dialog[role=alertdialog]').waitFor();
assert.equal(await dialog.locator('.notice[role=alert]').count(),0);
assert.deepEqual(await member.boundingBox(),before);
assert.equal(await button('取消操作').evaluate(el=>el===document.activeElement),true);
assert.equal(await page.locator('dialog[role=alertdialog] .sheet-cancel').evaluate(el=>el===document.activeElement),true);
await page.screenshot({path:join(shots,'floating-confirm-390.png')});
await page.keyboard.press('Escape');await page.locator('.confirmation-host').waitFor({state:'detached'});
await page.keyboard.press('Escape');await page.locator('dialog[role=alertdialog]').waitFor({state:'detached'});
assert.equal(await member.getByRole('button',{name:'停用',exact:true}).evaluate(el=>el===document.activeElement),true);
await member.getByRole('button',{name:'停用',exact:true}).click();await button('取消操作').click();
await member.getByRole('button',{name:'停用',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-cancel').click();
await context.route('**/api/v1/admin/users/*',route=>route.request().method()==='PATCH'?route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEST_FAILURE',message:'暂时无法保存,请重试'}})}):route.continue());
await member.getByRole('button',{name:'停用',exact:true}).click();await button('确认操作').click();
await member.getByRole('button',{name:'停用',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();
await page.locator('.notyf [role=alert]').filter({hasText:'暂时无法保存,请重试'}).waitFor();
assert.equal(await dialog.locator('.user-management > [role=alert]').count(),0);
await context.unroute('**/api/v1/admin/users/*');
await member.getByRole('button',{name:'设为管理员',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'移除管理员',exact:true}).waitFor();
await member.getByRole('button',{name:'移除管理员',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'设为管理员',exact:true}).waitFor();
await member.getByRole('button',{name:'停用',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'启用',exact:true}).waitFor();
await member.getByRole('button',{name:'启用',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'停用',exact:true}).waitFor();
await member.getByRole('button',{name:'设为管理员',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await member.getByRole('button',{name:'移除管理员',exact:true}).waitFor();
await member.getByRole('button',{name:'移除管理员',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await member.getByRole('button',{name:'设为管理员',exact:true}).waitFor();
await member.getByRole('button',{name:'停用',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await member.getByRole('button',{name:'启用',exact:true}).waitFor();
await member.getByRole('button',{name:'启用',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await member.getByRole('button',{name:'停用',exact:true}).waitFor();
await member.getByRole('button',{name:'重置密码',exact:true}).click();await dialog.getByLabel('新密码',{exact:true}).fill('password456');await button('确认重置密码').click();await page.getByText('密码已重置,旧会话已失效',{exact:true}).waitFor();
await page.screenshot({path:join(shots,'admin-users-390.png')});
await button('注册与邀请').click();await dialog.getByLabel('注册方式').selectOption('invite');await button('确认操作').click();await page.getByText('已保存',{exact:true}).waitFor();
await button('注册与邀请').click();await dialog.getByLabel('注册方式').selectOption('invite');await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await page.getByText('已保存',{exact:true}).waitFor();
await page.getByText('创建邀请码',{exact:true}).click();await dialog.getByLabel('备注',{exact:true}).fill('家人注册');await button('生成邀请码').click();await dialog.getByLabel('新邀请码',{exact:true}).waitFor();
const code=await dialog.getByLabel('新邀请码',{exact:true}).inputValue();assert.ok(code.length>=20);
await page.screenshot({path:join(shots,'admin-registration-390.png')});
@@ -44,8 +44,8 @@ export async function reviewUserManagement(context,page,base,shots) {
await visitor.getByRole('button',{name:'注册并登录',exact:true}).click();await visitor.locator('.shelf-screen').waitFor();
assert.equal(await visitor.getByRole('button',{name:'系统设置',exact:true}).count(),0);
} finally {await newcomer.close();}
await dialog.getByLabel('注册方式').selectOption('closed');await button('确认操作').click();await page.getByText('已保存',{exact:true}).waitFor();
await button('关闭弹窗').click();assert.equal(await page.getByRole('dialog',{name:'书架设置'}).isVisible(),false);
await dialog.getByLabel('注册方式').selectOption('closed');await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await page.getByText('已保存',{exact:true}).waitFor();
await page.locator('dialog[open] .sheet-close').last().click();assert.equal(await page.getByRole('dialog',{name:'书架设置'}).isVisible(),false);
const anonymous=await context.browser().newContext(), login=await anonymous.newPage();
try {await login.goto(base);await login.getByRole('button',{name:'注册新账号',exact:true}).click();await login.getByText('此书房暂未开放注册,请联系管理员创建账号或开放注册。',{exact:true}).waitFor();assert.equal(await login.getByRole('button',{name:'注册并登录',exact:true}).isDisabled(),true);}finally{await anonymous.close();}
}