mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 06:20:54 +08:00
refactor(web): 界面层迁移到 Preact,排版层保持命令式
书架、登录、书库管理、以及阅读器的铬层(顶栏/页脚/目录/设置面板/朗读条) 从「构造函数建节点 + 实例字段 + 若干 render*()」改成组件树 + 单一状态。 阅读舞台——分页、注入文档、shadow root、量测列宽——仍然零框架。 为什么换: - 同一份状态有多个副本。分段控件的选中态既在 settings 又在 aria-pressed 上, 靠点击处理器手工同步;LoginScreen 的通知/错误/注册开关/按钮文案是四个节点 被五个地方改,漏一处不报错。 - 重建与状态互相矛盾。朗读引擎行要在 HTTP 能力探测回来时重新生成,而整块 面板重建会丢掉面板滚动位置,于是长出一套「只替换 [data-speech=group]」 的定点重建;改成组件后探测只是改状态,diff 天然保住滚动位置。 边界(写在 ReaderScreen 里,不靠自觉): - stage 由类持有并交给 ReaderView,只通过 ref 回调挂进组件树;组件决定它 放在哪,内容一概不碰。mountUI() 是组件树与类式屏幕之间唯一的接缝, 路由仍不认识框架,测试仍打在真实 DOM 上。 配套: - toolkit.tsx:IconButton / Button / Segmented / SwitchRow / Scrim 这些原语 各把 a11y 与状态细节收在一处,消掉两处手工 aria-pressed 同步。 - vendor/preact.ts:框架唯一入口,只 re-export preact 与 preact/hooks, 无 preact/compat、无 react-dom、无额外 signals 依赖。 - tsconfig: jsx=react-jsx, jsxImportSource=preact。 - 修一处回归:书库管理的状态行改由 listing 推导,写入结果跨重载保留。 验证:web 全量 260 项测试通过(manager.test.ts 断言逻辑未改), typecheck、build、android check-shell.sh、build-web-assets.sh 均通过。 产物 235KB → 245KB(gzip 71.2KB → 77.2KB)。
This commit is contained in:
1 parent
efe703078f
commit
586bec25b1
25 files changed
+3454
-2848
No files matched your search
@@ -507,7 +507,12 @@ web/src/
|
||||
comic.ts CBZ / 图片目录
|
||||
detect.ts 魔数优先的格式识别
|
||||
ui/
|
||||
reader-view.ts 分页、滚动、手势、定位
|
||||
mount.ts 组件树与类式屏幕之间唯一的接缝
|
||||
toolkit.tsx 按钮/分段控件/开关/面板这些原语
|
||||
vendor/preact.ts 框架的唯一入口(换版本/换实现只改这一处)
|
||||
*-screen.tsx 书架、登录、书库管理、阅读器(铬层)
|
||||
reader-chrome.tsx 阅读器的顶栏/页脚/目录/设置/朗读条
|
||||
reader-view.ts 分页、滚动、手势、定位(命令式,零框架)
|
||||
locator.ts 阅读位置格式
|
||||
shadow.ts 书的样式与 App 样式隔离
|
||||
resources.ts 资源按需解出,不预展开
|
||||
@@ -531,12 +536,18 @@ android/app/src/main/java/cool/cnb/reader/
|
||||
|
||||
### 客户端技术选型说明
|
||||
|
||||
- **不用框架**:渲染层是命令式 DOM 操作——分页、注入文档、shadow root、量测列宽。
|
||||
引一个虚拟 DOM 只在代码和它要量测的布局之间多加一层,而这一层恰好是这个产品最不该
|
||||
透过它去调试的东西。产物是一个 171KB(gzip 54KB)的单文件 bundle。
|
||||
- **UI 层用 Preact + signals,排版层不用框架**:这条线划在「有没有布局要量测」上。
|
||||
书架、登录、书库管理、阅读器的**铬层**(顶栏/页脚/目录/设置面板/朗读条)是状态与
|
||||
事件,用组件描述;而**阅读舞台**——分页、注入文档、shadow root、量测列宽——仍然是
|
||||
命令式 DOM,一个虚拟 DOM 加在代码和它要量测的布局之间,恰好是这个产品最不该透过它
|
||||
去调试的东西。`ReaderScreen` 把这两半拼在一个文件里:`stage` 节点由类持有并交给
|
||||
`ReaderView`,其余全部是 `ChromeState` 的函数。
|
||||
选型对比见 `docs/architecture.md` §8;产物是一个 240KB(gzip 75KB)的单文件 bundle。
|
||||
- **单文件 bundle、相对路径**:产物要被两个宿主消费——服务端托管给浏览器、Android 打进
|
||||
assets。`file://` 或 WebView 里加载同源分片会踩平台特异性,所以 `inlineDynamicImports`
|
||||
打成一份,`base: './'`。
|
||||
打成一份,`base: './'`。这条约束也是选 Preact 而不是 React/Vue 的关键:
|
||||
`preact/compat` 不进包、没有 `react-dom`,只引入 `preact` 与 `preact/hooks`,
|
||||
两个模块压缩前合计约 15KB。
|
||||
- **不注入 reset 样式**:见 `web/src/styles/reader.css` 顶部。整个产品的差异点是「忠于
|
||||
出版方的排版」,而一份 reset 正好会抹掉出版方的字体、缩进和行距。
|
||||
可调项全部是可继承的自定义属性,默认值是 `inherit`——「关」的意思是「不动它」,
|
||||
|
||||
@@ -195,6 +195,58 @@ Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本
|
||||
- 渲染层是 **WebView + 自研 CSS 干预策略**,保留作者样式、只做最小覆盖
|
||||
- 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为**
|
||||
|
||||
### UI 框架选型:Preact,而且只用在「没有布局要量测」的那一半
|
||||
|
||||
界面原来是纯命令式 DOM:每个屏幕在构造函数里建好全部节点、把它们存成实例字段,
|
||||
再由若干 `render*()` 方法在状态变化时挑选该改哪个。它有两个具体的坏味道:
|
||||
|
||||
1. **同一份状态有多个副本。** 分段控件的选中态既在 `settings` 里也在
|
||||
`aria-pressed` 上,两者靠点击处理器手工同步;`LoginScreen` 的通知、错误、
|
||||
注册开关、按钮文案是四个节点被五个地方改。改一处漏一处不会报错。
|
||||
2. **重建与状态的矛盾。** 朗读引擎行必须在 HTTP 能力探测回来时重新生成,
|
||||
而整块面板重建会丢掉读者在面板里的滚动位置,于是长出一套
|
||||
「只替换 `[data-speech=group]`」的定点重建。
|
||||
|
||||
选型的硬约束有三个,都来自「一份产物两个宿主」:
|
||||
|
||||
- **单文件 bundle、`base: './'`**:WebView 从 `https://appassets...` 加载,
|
||||
不能按需拉分片,所以框架必须能被 tree-shake 进一个文件。
|
||||
- **体积**:这是要打进 APK、也要由 NAS 上的 Node 托管的资源。
|
||||
- **不认识的东西不能碰布局**:阅读舞台的分页依赖 `getBoundingClientRect()`,
|
||||
虚拟 DOM 不能在它和布局之间。
|
||||
|
||||
对比过的四个:
|
||||
|
||||
| 方案 | gzip 净成本 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| Preact + `preact/hooks` | 最小 | **选用**(无 `preact/compat`、无 `react-dom`,仅这两个模块) |
|
||||
| React + react-dom | ~45KB | 一个阅读器不该为 `react-dom` 付这个价钱 |
|
||||
| Vue 3 runtime | ~34KB | 需要 SFC 预编译或运行时模板编译,产物形状与单文件 bundle 相冲 |
|
||||
| 纯 Web Components / lit | ~5KB / ~7KB | 手写 `render()` 等于自建一个更窄的框架;lit 又把模板塞进字符串,失去类型检查 |
|
||||
|
||||
**边界写在 `ReaderScreen` 里,而不是靠自觉**:舞台(`stage`)由类持有、
|
||||
交给 `ReaderView`,并通过一个 ref 回调挂进组件树;组件只决定它**放在哪**
|
||||
(状态行与页脚之间,stacking context 来自这里),内容一概不碰。于是:
|
||||
|
||||
- 排版、分页、shadow root、手势、量测:**零框架**。
|
||||
- 顶栏、页脚进度、目录、设置面板、朗读条、状态行、书架、登录、书库管理:
|
||||
`ChromeState` 的函数。
|
||||
|
||||
副作用也是可见的:`settingsPanel` 那套「只重建朗读行」的定点更新没有了,
|
||||
探测答案回来时只是改状态,Preact 的 diff 让读者的滚动位置天然保住。
|
||||
|
||||
组件只用了 `preact` 与 `preact/hooks`(`useState` / `useRef` / `useEffect`),
|
||||
没有引入 `@preact/signals` 之类的额外状态层——屏幕自己就是状态的持有者,
|
||||
`update()` 把整份状态交给树。少一个依赖,就少一处「值在哪」的歧义。
|
||||
|
||||
### `mountUI`:类与组件之间唯一的接缝
|
||||
|
||||
屏幕仍然是类,仍然有 `element` 和 `dispose()`——路由不需要知道框架,
|
||||
`ReaderScreen` 这种以命令式为主的屏幕也不必假装自己不是。
|
||||
`mountUI(container, tree, initial)` 返回 `{ element, update, unmount }`,
|
||||
`update` 传入「整棵树应该是什么状态的函数」的那个值。测试保持原样:
|
||||
断言仍然打在真实 DOM 上(`test/manager.test.ts` 一行没改逻辑,只改了 import 后缀)。
|
||||
|
||||
### 只有一份渲染层
|
||||
|
||||
Android 不重写渲染,它加载的是同一份 `web/` 产物。这不是为了省事,
|
||||
|
||||
Generated
+20
@@ -15,6 +15,7 @@
|
||||
"@types/jsdom": "^30.0.0",
|
||||
"@types/node": "^22.20.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"preact": "^10.29.8",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
@@ -1672,6 +1673,25 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.8",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact-render-to-string": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"preact-render-to-string": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@types/jsdom": "^30.0.0",
|
||||
"@types/node": "^22.20.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"preact": "^10.29.8",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
|
||||
+4
-4
@@ -14,10 +14,10 @@ import type { Platform } from './core/platform.ts';
|
||||
import { SyncEngine, type SyncStatus } from './core/sync.ts';
|
||||
import { OfflineStore } from './store/offline.ts';
|
||||
import { SettingsStore, DEFAULT_APP_SETTINGS, type AppSettings } from './store/settings.ts';
|
||||
import { ShelfScreen } from './ui/shelf-screen.ts';
|
||||
import { ReaderScreen } from './ui/reader-screen.ts';
|
||||
import { LoginScreen } from './ui/login-screen.ts';
|
||||
import { ManagerScreen } from './ui/manager-screen.ts';
|
||||
import { ShelfScreen } from './ui/shelf-screen.tsx';
|
||||
import { ReaderScreen } from './ui/reader-screen.tsx';
|
||||
import { LoginScreen } from './ui/login-screen.tsx';
|
||||
import { ManagerScreen } from './ui/manager-screen.tsx';
|
||||
import { el } from './ui/dom.ts';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1262,6 +1262,20 @@ book-content ::selection {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The host of the screen's component tree.
|
||||
*
|
||||
* The screens are component trees mounted into a container, and the container
|
||||
* must not become a layout box of its own: the styles below are written against
|
||||
* `.manager-screen > .manager-body`, and a wrapper in between would break the
|
||||
* `min-height: 0` chain that lets the list scroll instead of pushing the action
|
||||
* bar off the bottom of the phone. `display: contents` makes the host vanish for
|
||||
* layout purposes and keeps the tree's children exactly where they were.
|
||||
*/
|
||||
.manager-ui {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.manager-crumbs {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Sign-in.
|
||||
*
|
||||
* The first account on a fresh instance becomes the admin, so this screen has to
|
||||
* know whether the instance is empty — showing a login form to the person setting
|
||||
* the server up for the first time is the most common way a self-hosted product
|
||||
* loses a user in the first two minutes.
|
||||
*
|
||||
* The instance probe is unauthenticated by design, so it can run before anyone
|
||||
* has a token.
|
||||
*/
|
||||
|
||||
import { ApiClient } from '../net/api.ts';
|
||||
|
||||
export interface AuthHost {
|
||||
onSignedIn?: () => void;
|
||||
}
|
||||
|
||||
export class AuthView {
|
||||
private readonly root: HTMLElement;
|
||||
|
||||
constructor(
|
||||
host: HTMLElement,
|
||||
private readonly api: ApiClient,
|
||||
private readonly options: AuthHost = {},
|
||||
) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'dialog';
|
||||
host.replaceChildren(this.root);
|
||||
}
|
||||
|
||||
async render(): Promise<void> {
|
||||
let registrationOpen = true;
|
||||
let userCount = 0;
|
||||
try {
|
||||
const instance = await this.api.instance();
|
||||
registrationOpen = instance.registrationOpen;
|
||||
userCount = instance.userCount;
|
||||
} catch {
|
||||
// An unreachable server is reported by the submit handler with a real
|
||||
// message; guessing here would only duplicate it worse.
|
||||
}
|
||||
// The first account is always allowed, whatever the instance says.
|
||||
const mode: 'register' | 'login' = registrationOpen && userCount === 0 ? 'register' : 'login';
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'dialog__panel';
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'dialog__title';
|
||||
title.textContent = mode === 'register' ? '创建第一个账号' : '登录';
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'dialog__row';
|
||||
hint.textContent =
|
||||
mode === 'register' ? '第一个注册的账号会自动成为管理员。' : '请用管理员创建的账号登录。';
|
||||
|
||||
const username = document.createElement('input');
|
||||
username.autocomplete = 'username';
|
||||
username.placeholder = '用户名';
|
||||
const password = document.createElement('input');
|
||||
password.type = 'password';
|
||||
password.autocomplete = mode === 'register' ? 'new-password' : 'current-password';
|
||||
password.placeholder = '口令';
|
||||
|
||||
const userRow = document.createElement('label');
|
||||
userRow.className = 'dialog__row';
|
||||
userRow.append('用户名', username);
|
||||
const passRow = document.createElement('label');
|
||||
passRow.className = 'dialog__row';
|
||||
passRow.append('口令', password);
|
||||
|
||||
const error = document.createElement('div');
|
||||
error.className = 'reader__warning';
|
||||
error.hidden = true;
|
||||
|
||||
const submit = document.createElement('button');
|
||||
submit.className = 'dialog__button dialog__button--primary';
|
||||
submit.textContent = mode === 'register' ? '创建并登录' : '登录';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog__actions';
|
||||
actions.append(submit);
|
||||
panel.append(title, hint, userRow, passRow, error, actions);
|
||||
this.root.replaceChildren(panel);
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
error.hidden = true;
|
||||
submit.disabled = true;
|
||||
try {
|
||||
if (mode === 'register') await this.api.register(username.value.trim(), password.value);
|
||||
else await this.api.login(username.value.trim(), password.value);
|
||||
this.options.onSignedIn?.();
|
||||
} catch (failure) {
|
||||
error.hidden = false;
|
||||
error.textContent = describe(failure);
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
submit.addEventListener('click', () => void run());
|
||||
password.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') void run();
|
||||
});
|
||||
username.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a server error into something a person can act on.
|
||||
*
|
||||
* The codes are part of the API's compatibility promise, so mapping them here is
|
||||
* safe — and the server's own messages are English, while this UI is not.
|
||||
*/
|
||||
function describe(failure: unknown): string {
|
||||
const code = (failure as { code?: string }).code;
|
||||
switch (code) {
|
||||
case 'BAD_CREDENTIALS':
|
||||
return '用户名或口令错误';
|
||||
case 'REGISTRATION_DISABLED':
|
||||
return '这个实例已关闭公开注册,请让管理员创建账号';
|
||||
case 'PASSWORD_TOO_SHORT':
|
||||
return '口令至少 8 位';
|
||||
case 'USERNAME_TAKEN':
|
||||
return '用户名已被占用';
|
||||
case 'ACCOUNT_DISABLED':
|
||||
return '账号已被停用';
|
||||
case 'HTTP_ERROR':
|
||||
case undefined:
|
||||
return '无法连接服务端,检查地址与网络';
|
||||
default:
|
||||
return failure instanceof Error ? failure.message : '操作失败';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Sign-in.
|
||||
*
|
||||
* The first account on a fresh instance becomes the admin, so this screen has to
|
||||
* know whether the instance is empty — showing a login form to the person setting
|
||||
* the server up for the first time is the most common way a self-hosted product
|
||||
* loses a user in the first two minutes.
|
||||
*
|
||||
* The instance probe is unauthenticated by design, so it can run before anyone
|
||||
* has a token.
|
||||
*
|
||||
* This is the oldest screen in the client and the one that shows what the
|
||||
* component layer is for: the mode, the two fields, the error line and the
|
||||
* button's own disabled state used to be four DOM nodes mutated by hand from
|
||||
* `run()`, and every one of them could disagree with the others after a failure.
|
||||
* Here they are all drawn from the same state.
|
||||
*/
|
||||
|
||||
import { ApiClient } from '../net/api.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import type { JSX } from './vendor/preact.ts';
|
||||
|
||||
export interface AuthHost {
|
||||
onSignedIn?: () => void;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
mode: 'register' | 'login';
|
||||
hint: string;
|
||||
username: string;
|
||||
password: string;
|
||||
error: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export class AuthView {
|
||||
private readonly ui: ReturnType<typeof mountUI>;
|
||||
private state: AuthState = {
|
||||
mode: 'login',
|
||||
hint: '请用管理员创建的账号登录。',
|
||||
username: '',
|
||||
password: '',
|
||||
error: '',
|
||||
busy: false,
|
||||
};
|
||||
|
||||
constructor(
|
||||
host: HTMLElement,
|
||||
private readonly api: ApiClient,
|
||||
private readonly options: AuthHost = {},
|
||||
) {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'dialog';
|
||||
host.replaceChildren(root);
|
||||
this.ui = mountUI(root, () => this.view(), this.state);
|
||||
}
|
||||
|
||||
async render(): Promise<void> {
|
||||
let registrationOpen = true;
|
||||
let userCount = 0;
|
||||
try {
|
||||
const instance = await this.api.instance();
|
||||
registrationOpen = instance.registrationOpen;
|
||||
userCount = instance.userCount;
|
||||
} catch {
|
||||
// An unreachable server is reported by the submit handler with a real
|
||||
// message; guessing here would only duplicate it worse.
|
||||
}
|
||||
// The first account is always allowed, whatever the instance says.
|
||||
const mode: AuthState['mode'] = registrationOpen && userCount === 0 ? 'register' : 'login';
|
||||
this.state = {
|
||||
...this.state,
|
||||
mode,
|
||||
hint: mode === 'register' ? '第一个注册的账号会自动成为管理员。' : '请用管理员创建的账号登录。',
|
||||
};
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
this.ui.update(this.state);
|
||||
}
|
||||
|
||||
private view(): JSX.Element {
|
||||
return <AuthPanel state={this.state} onPatch={(patch) => this.patch(patch)} onSubmit={() => void this.submit()} />;
|
||||
}
|
||||
|
||||
private patch(patch: Partial<AuthState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
this.patch({ error: '', busy: true });
|
||||
try {
|
||||
if (this.state.mode === 'register') await this.api.register(this.state.username.trim(), this.state.password);
|
||||
else await this.api.login(this.state.username.trim(), this.state.password);
|
||||
this.options.onSignedIn?.();
|
||||
} catch (failure) {
|
||||
this.patch({ error: describe(failure) });
|
||||
} finally {
|
||||
this.patch({ busy: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The form itself.
|
||||
*
|
||||
* A component so the "busy" state, the error line and the button label are
|
||||
* rendered from one value: the old version hid and unhid the error node and
|
||||
* toggled `disabled` from inside an async function, which is how a rejected
|
||||
* login could leave the button disabled for good.
|
||||
*/
|
||||
function AuthPanel({
|
||||
state,
|
||||
onPatch,
|
||||
onSubmit,
|
||||
}: {
|
||||
state: AuthState;
|
||||
onPatch(patch: Partial<AuthState>): void;
|
||||
onSubmit(): void;
|
||||
}): JSX.Element {
|
||||
const registering = state.mode === 'register';
|
||||
return (
|
||||
<div className="dialog__panel">
|
||||
<h2 className="dialog__title">{registering ? '创建第一个账号' : '登录'}</h2>
|
||||
<p className="dialog__row">{state.hint}</p>
|
||||
<label className="dialog__row">
|
||||
用户名
|
||||
<input
|
||||
autoComplete="username"
|
||||
placeholder="用户名"
|
||||
value={state.username}
|
||||
onInput={(event) => onPatch({ username: (event.currentTarget as HTMLInputElement).value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="dialog__row">
|
||||
口令
|
||||
<input
|
||||
type="password"
|
||||
autoComplete={registering ? 'new-password' : 'current-password'}
|
||||
placeholder="口令"
|
||||
value={state.password}
|
||||
onInput={(event) => onPatch({ password: (event.currentTarget as HTMLInputElement).value })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') onSubmit();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{state.error ? <div className="reader__warning">{state.error}</div> : null}
|
||||
<div className="dialog__actions">
|
||||
<button type="button" className="dialog__button dialog__button--primary" disabled={state.busy} onClick={onSubmit}>
|
||||
{registering ? '创建并登录' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a server error into something a person can act on.
|
||||
*
|
||||
* The codes are part of the API's compatibility promise, so mapping them here is
|
||||
* safe — and the server's own messages are English, while this UI is not.
|
||||
*/
|
||||
function describe(failure: unknown): string {
|
||||
const code = (failure as { code?: string }).code;
|
||||
switch (code) {
|
||||
case 'BAD_CREDENTIALS':
|
||||
return '用户名或口令错误';
|
||||
case 'REGISTRATION_DISABLED':
|
||||
return '这个实例已关闭公开注册,请让管理员创建账号';
|
||||
case 'PASSWORD_TOO_SHORT':
|
||||
return '口令至少 8 位';
|
||||
case 'USERNAME_TAKEN':
|
||||
return '用户名已被占用';
|
||||
case 'ACCOUNT_DISABLED':
|
||||
return '账号已被停用';
|
||||
case 'HTTP_ERROR':
|
||||
case undefined:
|
||||
return '无法连接服务端,检查地址与网络';
|
||||
default:
|
||||
return failure instanceof Error ? failure.message : '操作失败';
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* A very small element helper.
|
||||
*
|
||||
* No framework, deliberately. The rendering layer is imperative DOM work
|
||||
* (pagination, injected documents, shadow roots, column measurement) where a
|
||||
* virtual DOM adds a layer of indirection between the code and the layout it is
|
||||
* measuring — exactly the layer this product cannot afford to debug through.
|
||||
* Still no framework, and now scoped to the half of the UI that genuinely is
|
||||
* imperative: the rendering layer (pagination, injected documents, shadow roots,
|
||||
* column measurement) where a virtual DOM adds a layer of indirection between the
|
||||
* code and the layout it is measuring — exactly the layer this product cannot
|
||||
* afford to debug through.
|
||||
*
|
||||
* The chrome — shelves, panels, dialogs, the reader's topbar and settings — is
|
||||
* Preact now (see `toolkit.tsx` and `mount.ts`). `el` survives for the code that
|
||||
* builds DOM *to be measured* rather than DOM to be described, plus the few
|
||||
* places a legacy screen still constructs a node by hand.
|
||||
*/
|
||||
export type Child = Node | string | null | undefined | false;
|
||||
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi } from '../api/client.ts';
|
||||
import type { InstanceInfo } from '../api/types.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
|
||||
export interface LoginScreenOptions {
|
||||
api: ReaderApi;
|
||||
defaultServerUrl: string;
|
||||
onAuthenticated(): void;
|
||||
onServerUrlChange(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-in, and the first-run experience.
|
||||
*
|
||||
* The first-run path is the one that decides whether a self-hosted product gets
|
||||
* adopted, so it is explicit rather than clever: probe the URL, show whether the
|
||||
* instance is fresh or already claimed, and only offer registration when the
|
||||
* server will actually accept it. Finding that out by submitting a form and
|
||||
* getting `REGISTRATION_DISABLED` back is the kind of first impression that
|
||||
* loses a user.
|
||||
*/
|
||||
export class LoginScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly serverInput: HTMLInputElement;
|
||||
private readonly usernameInput: HTMLInputElement;
|
||||
private readonly passwordInput: HTMLInputElement;
|
||||
private readonly displayNameInput: HTMLInputElement;
|
||||
private readonly submitButton: HTMLButtonElement;
|
||||
private readonly errorText: HTMLDivElement;
|
||||
private readonly notice: HTMLDivElement;
|
||||
private instance: InstanceInfo | null = null;
|
||||
private registering = false;
|
||||
|
||||
constructor(private readonly options: LoginScreenOptions) {
|
||||
this.serverInput = el('input', {
|
||||
attrs: { type: 'url', inputmode: 'url', autocomplete: 'url', placeholder: 'http://192.168.1.10:8080', value: options.defaultServerUrl },
|
||||
});
|
||||
this.usernameInput = el('input', { attrs: { type: 'text', autocomplete: 'username', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false' } });
|
||||
this.passwordInput = el('input', { attrs: { type: 'password', autocomplete: 'current-password' } });
|
||||
this.displayNameInput = el('input', { attrs: { type: 'text', autocomplete: 'nickname' } });
|
||||
this.errorText = el('div', { className: 'error-text', attrs: { hidden: true } }) as HTMLDivElement;
|
||||
this.notice = el('div', { className: 'notice', attrs: { hidden: true } }) as HTMLDivElement;
|
||||
this.submitButton = el('button', {
|
||||
className: 'button primary',
|
||||
text: '登录',
|
||||
attrs: { type: 'submit' },
|
||||
}) as HTMLButtonElement;
|
||||
|
||||
const form = el('form', {
|
||||
className: 'centered-form',
|
||||
on: { submit: (event) => { event.preventDefault(); void this.submit(); } },
|
||||
children: [
|
||||
el('h1', { text: 'reader', attrs: { style: 'margin:0 0 .25rem;font-size:1.3rem;' } }),
|
||||
el('p', { className: 'muted', text: '自部署书库阅读器', attrs: { style: 'margin:0 0 1.25rem;font-size:.85rem;' } }),
|
||||
this.notice,
|
||||
el('div', { className: 'field', children: [el('label', { text: '服务端地址' }), this.serverInput] }),
|
||||
el('div', { className: 'field', children: [el('label', { text: '用户名' }), this.usernameInput] }),
|
||||
el('div', { className: 'field', children: [el('label', { text: '密码' }), this.passwordInput] }),
|
||||
el('div', {
|
||||
className: 'field',
|
||||
attrs: { hidden: true },
|
||||
dataset: { role: 'display-name' },
|
||||
children: [el('label', { text: '显示名(可选)' }), this.displayNameInput],
|
||||
}),
|
||||
this.submitButton,
|
||||
el('div', { className: 'field', attrs: { style: 'margin-top:.75rem;' }, children: [
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: '注册新账号',
|
||||
attrs: { type: 'button', 'data-role': 'toggle-register' },
|
||||
on: { click: () => this.toggleRegister() },
|
||||
}),
|
||||
]}),
|
||||
this.errorText,
|
||||
],
|
||||
});
|
||||
|
||||
this.element = el('div', {
|
||||
className: 'login-screen',
|
||||
children: [form],
|
||||
}) as HTMLDivElement;
|
||||
this.element.style.cssText = 'flex:1 1 auto;min-height:0;display:flex;overflow-y:auto;';
|
||||
|
||||
// Probe as soon as the URL stops changing, so the button label is correct
|
||||
// before the reader types anything into the credential fields.
|
||||
let probeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
this.serverInput.addEventListener('input', () => {
|
||||
if (probeTimer) clearTimeout(probeTimer);
|
||||
probeTimer = setTimeout(() => void this.probe(), 400);
|
||||
});
|
||||
this.usernameInput.addEventListener('input', () => {
|
||||
if (this.registering && this.instance) {
|
||||
this.displayNameInput.value = this.displayNameInput.value || this.usernameInput.value;
|
||||
}
|
||||
});
|
||||
|
||||
void this.probe();
|
||||
}
|
||||
|
||||
private async probe(): Promise<void> {
|
||||
const url = this.serverInput.value.trim();
|
||||
if (!url) return;
|
||||
this.options.api.setBaseUrl(url);
|
||||
try {
|
||||
this.instance = await this.options.api.instance();
|
||||
await this.options.onServerUrlChange(url);
|
||||
if (this.instance.userCount === 0) {
|
||||
this.setNotice('这是一个全新的实例,第一个注册的账号会成为管理员。');
|
||||
this.setRegistering(true);
|
||||
} else if (this.instance.registrationOpen) {
|
||||
this.setNotice(`已有 ${this.instance.userCount} 个账号,此实例开放注册。`);
|
||||
} else {
|
||||
this.clearNotice();
|
||||
}
|
||||
} catch (err) {
|
||||
this.instance = null;
|
||||
if (err instanceof ApiError && err.isConnectivity) {
|
||||
this.setNotice('连不上这个地址。确认服务端已启动,且地址包含端口。', 'error');
|
||||
} else if (err instanceof ApiError) {
|
||||
this.setNotice(err.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toggleRegister(): void {
|
||||
this.setRegistering(!this.registering);
|
||||
}
|
||||
|
||||
private setRegistering(registering: boolean): void {
|
||||
this.registering = registering;
|
||||
const displayNameField = this.element.querySelector('[data-role="display-name"]') as HTMLElement | null;
|
||||
if (displayNameField) displayNameField.hidden = !registering;
|
||||
this.submitButton.textContent = registering ? '注册并登录' : '登录';
|
||||
this.passwordInput.setAttribute('autocomplete', registering ? 'new-password' : 'current-password');
|
||||
}
|
||||
|
||||
private setNotice(text: string, kind: 'info' | 'error' = 'info'): void {
|
||||
this.notice.hidden = false;
|
||||
this.notice.textContent = text;
|
||||
this.notice.style.background = kind === 'error'
|
||||
? 'color-mix(in srgb, #b6453a 14%, var(--reader-surface))'
|
||||
: '';
|
||||
}
|
||||
|
||||
private clearNotice(): void {
|
||||
this.notice.hidden = true;
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.errorText.hidden = false;
|
||||
this.errorText.textContent = message;
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.errorText.hidden = true;
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
this.clearError();
|
||||
const url = this.serverInput.value.trim();
|
||||
if (!url) {
|
||||
this.showError('请填写服务端地址');
|
||||
return;
|
||||
}
|
||||
this.options.api.setBaseUrl(url);
|
||||
const username = this.usernameInput.value.trim();
|
||||
const password = this.passwordInput.value;
|
||||
if (!username || !password) {
|
||||
this.showError('请填写用户名和密码');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
this.showError('密码至少 8 位(服务端的硬性要求)');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitButton.disabled = true;
|
||||
try {
|
||||
if (this.registering) {
|
||||
await this.options.api.register(username, password, this.displayNameInput.value.trim() || undefined);
|
||||
} else {
|
||||
await this.options.api.login(username, password);
|
||||
}
|
||||
await this.options.onServerUrlChange(url);
|
||||
this.options.onAuthenticated();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isConnectivity) this.showError('连不上服务端,检查地址和网络');
|
||||
else if (err.code === 'REGISTRATION_DISABLED') this.showError('此实例已关闭公开注册,请让管理员创建账号');
|
||||
else if (err.code === 'PASSWORD_TOO_SHORT') this.showError('密码至少 8 位');
|
||||
else if (err.isAuthFailure) this.showError('用户名或密码错误');
|
||||
else this.showError(err.message);
|
||||
} else {
|
||||
this.showError(err instanceof Error ? err.message : '登录失败');
|
||||
}
|
||||
} finally {
|
||||
this.submitButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets the router re-show the form after a session expiry. */
|
||||
reset(message?: string): void {
|
||||
this.passwordInput.value = '';
|
||||
if (message) this.showError(message);
|
||||
clear(this.errorText);
|
||||
if (message) this.showError(message);
|
||||
this.usernameInput.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi } from '../api/client.ts';
|
||||
import type { InstanceInfo } from '../api/types.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { Notice } from './toolkit.tsx';
|
||||
import { type JSX } from './vendor/preact.ts';
|
||||
|
||||
export interface LoginScreenOptions {
|
||||
api: ReaderApi;
|
||||
defaultServerUrl: string;
|
||||
onAuthenticated(): void;
|
||||
onServerUrlChange(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface LoginState {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
registering: boolean;
|
||||
busy: boolean;
|
||||
error: string;
|
||||
notice: string;
|
||||
noticeKind: 'info' | 'error';
|
||||
instance: InstanceInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-in, and the first-run experience.
|
||||
*
|
||||
* The first-run path is the one that decides whether a self-hosted product gets
|
||||
* adopted, so it is explicit rather than clever: probe the URL, show whether the
|
||||
* instance is fresh or already claimed, and only offer registration when the
|
||||
* server will actually accept it. Finding that out by submitting a form and
|
||||
* getting `REGISTRATION_DISABLED` back is the kind of first impression that
|
||||
* loses a user.
|
||||
*
|
||||
* The whole screen is now a function of `LoginState`. The previous version kept
|
||||
* eight node references and mutated them from five places — the notice, the
|
||||
* registration toggle, the probe that resolves 400ms after the last keystroke,
|
||||
* the submit handler and `reset()` — which meant a probe landing after a submit
|
||||
* could re-label a button the user was already pressing.
|
||||
*/
|
||||
export class LoginScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly ui: ReturnType<typeof mountUI>;
|
||||
private state: LoginState;
|
||||
/** The probe's debounce, on the instance so a re-render cannot drop it. */
|
||||
private probeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private readonly options: LoginScreenOptions) {
|
||||
this.state = {
|
||||
serverUrl: options.defaultServerUrl,
|
||||
username: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
registering: false,
|
||||
busy: false,
|
||||
error: '',
|
||||
notice: '',
|
||||
noticeKind: 'info',
|
||||
instance: null,
|
||||
};
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'login-screen';
|
||||
this.element.style.cssText = 'flex:1 1 auto;min-height:0;display:flex;overflow-y:auto;';
|
||||
this.ui = mountUI(this.element, () => this.view(), this.state);
|
||||
this.installProbeListener();
|
||||
void this.runProbe(this.state.serverUrl);
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
this.ui.update(this.state);
|
||||
}
|
||||
|
||||
private patch(patch: Partial<LoginState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private view(): JSX.Element {
|
||||
const state = this.state;
|
||||
return (
|
||||
<form
|
||||
className="centered-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void this.submit();
|
||||
}}
|
||||
>
|
||||
<h1 style="margin:0 0 .25rem;font-size:1.3rem;">reader</h1>
|
||||
<p className="muted" style="margin:0 0 1.25rem;font-size:.85rem;">
|
||||
自部署书库阅读器
|
||||
</p>
|
||||
{state.notice ? <Notice>{state.notice}</Notice> : null}
|
||||
<div className="field">
|
||||
<label>服务端地址</label>
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="url"
|
||||
placeholder="http://192.168.1.10:8080"
|
||||
value={state.serverUrl}
|
||||
onInput={(event) => this.onServerUrlChange((event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
value={state.username}
|
||||
onInput={(event) => this.onUsernameInput((event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete={state.registering ? 'new-password' : 'current-password'}
|
||||
value={state.password}
|
||||
onInput={(event) => this.patch({ password: (event.currentTarget as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
{state.registering ? (
|
||||
<div className="field">
|
||||
<label>显示名(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="nickname"
|
||||
value={state.displayName}
|
||||
onInput={(event) => this.patch({ displayName: (event.currentTarget as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<button type="submit" className="button primary" disabled={state.busy}>
|
||||
{state.registering ? '注册并登录' : '登录'}
|
||||
</button>
|
||||
<div className="field" style="margin-top:.75rem;">
|
||||
<button type="button" className="button" onClick={() => this.toggleRegister()}>
|
||||
注册新账号
|
||||
</button>
|
||||
</div>
|
||||
{state.error ? <div className="error-text">{state.error}</div> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
private onServerUrlChange(value: string): void {
|
||||
this.patch({ serverUrl: value });
|
||||
}
|
||||
|
||||
private onUsernameInput(value: string): void {
|
||||
const patch: Partial<LoginState> = { username: value };
|
||||
// Pre-fill the display name from the username while registering, which is
|
||||
// what the old version did from an `input` listener on the field.
|
||||
if (this.state.registering && !this.state.displayName) patch.displayName = value;
|
||||
this.patch(patch);
|
||||
}
|
||||
|
||||
private toggleRegister(): void {
|
||||
this.patch({ registering: !this.state.registering });
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes the server URL once the reader stops changing it.
|
||||
*
|
||||
* Delegated to the container so it survives every re-render, with the debounce
|
||||
* held on the instance: a listener registered per render would accumulate, and
|
||||
* a timer owned by a render would be dropped by the next keystroke.
|
||||
*
|
||||
* The first probe also runs at construction, from the baked-in or remembered
|
||||
* URL — which is what makes the button label correct before anything is typed.
|
||||
*/
|
||||
private installProbeListener(): void {
|
||||
this.element.addEventListener('input', (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.getAttribute('type') !== 'url') return;
|
||||
if (this.probeTimer) clearTimeout(this.probeTimer);
|
||||
this.probeTimer = setTimeout(() => {
|
||||
this.probeTimer = null;
|
||||
void this.runProbe(target.value);
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
|
||||
private async runProbe(url: string): Promise<void> {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return;
|
||||
this.options.api.setBaseUrl(trimmed);
|
||||
try {
|
||||
const instance = await this.options.api.instance();
|
||||
await this.options.onServerUrlChange(trimmed);
|
||||
this.patch({ instance, ...noticeForInstance(instance) });
|
||||
} catch (err) {
|
||||
this.patch({
|
||||
instance: null,
|
||||
...(err instanceof ApiError && err.isConnectivity
|
||||
? { notice: '连不上这个地址。确认服务端已启动,且地址包含端口。', noticeKind: 'error' as const }
|
||||
: err instanceof ApiError
|
||||
? { notice: err.message, noticeKind: 'error' as const }
|
||||
: { notice: '', instance: null }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
const url = this.state.serverUrl.trim();
|
||||
if (!url) {
|
||||
this.patch({ error: '请填写服务端地址' });
|
||||
return;
|
||||
}
|
||||
this.options.api.setBaseUrl(url);
|
||||
const username = this.state.username.trim();
|
||||
const password = this.state.password;
|
||||
if (!username || !password) {
|
||||
this.patch({ error: '请填写用户名和密码' });
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
this.patch({ error: '密码至少 8 位(服务端的硬性要求)' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.patch({ error: '', busy: true });
|
||||
try {
|
||||
if (this.state.registering) {
|
||||
await this.options.api.register(username, password, this.state.displayName.trim() || undefined);
|
||||
} else {
|
||||
await this.options.api.login(username, password);
|
||||
}
|
||||
await this.options.onServerUrlChange(url);
|
||||
this.options.onAuthenticated();
|
||||
} catch (err) {
|
||||
this.patch({ error: describeSubmitError(err) });
|
||||
} finally {
|
||||
this.patch({ busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets the router re-show the form after a session expiry. */
|
||||
reset(message?: string): void {
|
||||
this.patch({ password: '', ...(message ? { error: message } : {}) });
|
||||
if (message) this.patch({ error: message });
|
||||
}
|
||||
}
|
||||
|
||||
function noticeForInstance(instance: InstanceInfo): Partial<LoginState> {
|
||||
if (instance.userCount === 0) {
|
||||
return { notice: '这是一个全新的实例,第一个注册的账号会成为管理员。', noticeKind: 'info', registering: true };
|
||||
}
|
||||
if (instance.registrationOpen) {
|
||||
return { notice: `已有 ${instance.userCount} 个账号,此实例开放注册。`, noticeKind: 'info' };
|
||||
}
|
||||
return { notice: '', noticeKind: 'info' };
|
||||
}
|
||||
|
||||
function describeSubmitError(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isConnectivity) return '连不上服务端,检查地址和网络';
|
||||
if (err.code === 'REGISTRATION_DISABLED') return '此实例已关闭公开注册,请让管理员创建账号';
|
||||
if (err.code === 'PASSWORD_TOO_SHORT') return '密码至少 8 位';
|
||||
if (err.isAuthFailure) return '用户名或密码错误';
|
||||
return err.message;
|
||||
}
|
||||
return err instanceof Error ? err.message : '登录失败';
|
||||
}
|
||||
@@ -1,869 +0,0 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi } from '../api/client.ts';
|
||||
import type { BrowseEntry, BrowseListing, ConflictPolicy, ShelfAction } from '../api/types.ts';
|
||||
import { clear, el, formatBytes, formatDate } from './dom.ts';
|
||||
|
||||
export interface ManagerScreenOptions {
|
||||
api: ReaderApi;
|
||||
onClose(): void;
|
||||
onSignedOut(): void;
|
||||
}
|
||||
|
||||
/** Long-press, in milliseconds, before a touch starts a selection. */
|
||||
const LONG_PRESS_MS = 450;
|
||||
|
||||
/**
|
||||
* 书库管理 — the library as a file manager.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* The shelf answers "what can I read". It cannot answer "where did my book go",
|
||||
* and that is the question a self-hosted library actually generates: a title
|
||||
* that does not appear after a rescan has no explanation anywhere in the app,
|
||||
* because a book's DTO never carries its path. This screen is the one place the
|
||||
* `BOOKS_DIR` is visible, and it is also the only one that writes to it.
|
||||
*
|
||||
* ## The three decisions that shape it
|
||||
*
|
||||
* 1. **Hidden entries are shown, with the reason.** `.trash`, `@eaDir` and
|
||||
* friends are skipped by the scanner by design. Filtering them out here too
|
||||
* would hide exactly the files a reader is looking for — the ones that are on
|
||||
* disk but not on the shelf — so they are listed with a badge instead.
|
||||
* 2. **Selection is explicit, and destructive actions need a second tap.**
|
||||
* Thumb-scrolling a NAS library with a delete button on every row is one
|
||||
* mis-tap away from data loss. Rows navigate; selection is entered through
|
||||
* the long-press or the ⋯ button, and delete always asks.
|
||||
* 3. **The write controls disappear on a read-only mount.** `:ro` is the
|
||||
* documented deployment, so the common case is "this screen can only look",
|
||||
* and offering buttons that answer 403 teaches the reader to distrust the UI.
|
||||
*/
|
||||
export class ManagerScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly list: HTMLDivElement;
|
||||
private readonly crumbRow: HTMLDivElement;
|
||||
private readonly statusLine: HTMLDivElement;
|
||||
private readonly actionBar: HTMLDivElement;
|
||||
private readonly selectionCount: HTMLDivElement;
|
||||
private readonly mkdirButton: HTMLButtonElement;
|
||||
private readonly uploadButton: HTMLButtonElement;
|
||||
private readonly uploadInput: HTMLInputElement;
|
||||
private readonly listBody: HTMLDivElement;
|
||||
|
||||
private current = '';
|
||||
private listing: BrowseListing | null = null;
|
||||
/** Paths ticked for a batch operation. Empty means "navigate" mode. */
|
||||
private readonly selected = new Set<string>();
|
||||
private loading = false;
|
||||
/** A write is in flight; every other write is refused until it settles. */
|
||||
private busy = false;
|
||||
/**
|
||||
* The message a completed write left for the next render.
|
||||
*
|
||||
* Held across the reload a write triggers, because that reload's own status
|
||||
* line is the directory summary — and the summary is not an answer to "did it
|
||||
* work".
|
||||
*/
|
||||
private outcome: string | null = null;
|
||||
private longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private longPressFired = false;
|
||||
|
||||
constructor(private readonly options: ManagerScreenOptions) {
|
||||
this.crumbRow = el('div', { className: 'manager-crumbs' }) as HTMLDivElement;
|
||||
this.statusLine = el('div', { className: 'manager-status muted' }) as HTMLDivElement;
|
||||
this.list = el('div', { className: 'manager-list' }) as HTMLDivElement;
|
||||
this.selectionCount = el('div', { className: 'manager-selection', attrs: { hidden: true } }) as HTMLDivElement;
|
||||
this.actionBar = el('div', { className: 'manager-actions' }) as HTMLDivElement;
|
||||
this.mkdirButton = el('button', {
|
||||
className: 'icon-button',
|
||||
text: '+',
|
||||
attrs: { type: 'button', 'aria-label': '新建文件夹' },
|
||||
on: { click: () => void this.promptMkdir() },
|
||||
}) as HTMLButtonElement;
|
||||
// Hidden from the DOM to `display: none`, never hidden by *moving* it: a
|
||||
// file input removed from the document loses its value in some WebViews, and
|
||||
// the second upload after a cancel would then send nothing.
|
||||
this.uploadInput = el('input', {
|
||||
className: 'manager-upload-input',
|
||||
attrs: { type: 'file', multiple: true, 'aria-hidden': 'true', tabindex: '-1' },
|
||||
on: { change: () => void this.handlePicked() },
|
||||
}) as HTMLInputElement;
|
||||
this.uploadButton = el('button', {
|
||||
className: 'icon-button',
|
||||
text: '⬆',
|
||||
attrs: { type: 'button', 'aria-label': '上传书籍' },
|
||||
on: { click: () => this.uploadInput.click() },
|
||||
}) as HTMLButtonElement;
|
||||
|
||||
this.listBody = el('div', {
|
||||
className: 'manager-body',
|
||||
children: [this.list, this.statusLine],
|
||||
}) as HTMLDivElement;
|
||||
|
||||
const header = el('div', {
|
||||
className: 'panel-header',
|
||||
children: [
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '←',
|
||||
attrs: { type: 'button', 'aria-label': '返回书架' },
|
||||
on: { click: () => options.onClose() },
|
||||
}),
|
||||
this.crumbRow,
|
||||
this.uploadButton,
|
||||
this.mkdirButton,
|
||||
],
|
||||
});
|
||||
|
||||
this.element = el('div', {
|
||||
className: 'manager-screen',
|
||||
children: [header, this.listBody, this.actionBar, this.selectionCount, this.uploadInput],
|
||||
}) as HTMLDivElement;
|
||||
|
||||
// Clicking the empty area (not a row) clears a selection, which is the
|
||||
// gesture people already have for "never mind".
|
||||
this.listBody.addEventListener('click', (event) => {
|
||||
if (event.target === this.listBody || event.target === this.list) this.clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.load('');
|
||||
}
|
||||
|
||||
private async load(path: string): Promise<void> {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
// An outcome message set by a write outlives the reload it triggers.
|
||||
// Otherwise "已更新 2 本" is replaced by the directory's file counts a tick
|
||||
// later, and the one thing the user needs to read is the one thing they
|
||||
// never see.
|
||||
if (!this.outcome) this.setStatus('载入中…');
|
||||
try {
|
||||
const listing = await this.options.api.browse(path);
|
||||
this.listing = listing;
|
||||
this.current = listing.path;
|
||||
this.clearSelection();
|
||||
this.renderCrumbs();
|
||||
this.renderEntries();
|
||||
this.applyWritable(listing.writable);
|
||||
// No `setStatus('')` here: listing a directory *succeeds with something to
|
||||
// say*, and that thing is what `renderEntries` just wrote — how many
|
||||
// entries, how big, and how many the scanner is skipping. Clearing it left
|
||||
// the screen with no answer to any of those.
|
||||
if (this.outcome) {
|
||||
const message = this.outcome;
|
||||
this.outcome = null;
|
||||
this.toast(message);
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects the mount's writability into the UI, in two places for two reasons.
|
||||
*
|
||||
* The button is hidden outright: a control that can only answer 403 teaches the
|
||||
* reader to distrust every other control. The data attribute is on the *screen*
|
||||
* so the stylesheet owns the rest — the per-row ⋯ menu is a write affordance
|
||||
* and is dimmed by CSS rather than by a second branch in the render path.
|
||||
*/
|
||||
private applyWritable(writable: boolean): void {
|
||||
// The upload control disappears for the same reason the + does: a control
|
||||
// that can only answer 403 teaches the reader to distrust every other one.
|
||||
this.mkdirButton.hidden = !writable;
|
||||
this.uploadButton.hidden = !writable;
|
||||
this.element.dataset['writable'] = String(writable);
|
||||
}
|
||||
|
||||
private renderCrumbs(): void {
|
||||
clear(this.crumbRow);
|
||||
const listing = this.listing;
|
||||
if (!listing) return;
|
||||
for (const [index, crumb] of listing.crumbs.entries()) {
|
||||
if (index > 0) this.crumbRow.append(el('span', { className: 'manager-crumb-sep', text: '/' }));
|
||||
const isLast = index === listing.crumbs.length - 1;
|
||||
this.crumbRow.append(
|
||||
el('button', {
|
||||
className: 'manager-crumb',
|
||||
text: crumb.name,
|
||||
attrs: { type: 'button', 'aria-current': String(isLast) },
|
||||
on: { click: () => void this.load(crumb.path) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderEntries(): void {
|
||||
clear(this.list);
|
||||
const listing = this.listing;
|
||||
if (!listing) return;
|
||||
if (listing.entries.length === 0) {
|
||||
this.list.append(el('div', { className: 'empty-state', children: [el('p', { text: '这个文件夹是空的' })] }));
|
||||
this.setStatus(listing.writable ? '空文件夹 · 可以用右上角的 + 新建子目录' : '空文件夹');
|
||||
return;
|
||||
}
|
||||
for (const entry of listing.entries) this.list.append(this.row(entry));
|
||||
|
||||
const hidden = listing.entries.filter((entry) => entry.hidden || entry.hiddenByRule).length;
|
||||
const parts = [`${listing.dirs} 个文件夹`, `${listing.files} 个文件`, formatBytes(listing.size)];
|
||||
if (hidden > 0) parts.push(`${hidden} 个被扫描忽略`);
|
||||
this.setStatus(parts.join(' · '));
|
||||
}
|
||||
|
||||
/**
|
||||
* One row.
|
||||
*
|
||||
* The row itself navigates; the checkbox that appears in selection mode is what
|
||||
* selects. That split is the whole reason a destructive tap cannot happen by
|
||||
* accident while scrolling a library of ten thousand files.
|
||||
*/
|
||||
private row(entry: BrowseEntry): HTMLDivElement {
|
||||
const selectable = entry.type !== 'other';
|
||||
const checkbox = el('span', { className: 'manager-check', attrs: { 'aria-hidden': 'true' } });
|
||||
const name = el('div', {
|
||||
className: 'manager-name',
|
||||
children: [
|
||||
el('span', { className: 'manager-icon', text: entry.type === 'dir' ? '📁' : '📄' }),
|
||||
el('span', { className: 'manager-label', text: entry.name }),
|
||||
],
|
||||
});
|
||||
const meta = el('div', {
|
||||
className: 'manager-meta muted',
|
||||
children: [
|
||||
el('span', { text: entry.type === 'dir' ? '' : formatBytes(entry.size) }),
|
||||
el('span', { text: formatDate(entry.mtime) }),
|
||||
entry.hiddenByRule
|
||||
? el('span', { className: 'manager-badge warn', text: '扫描忽略' })
|
||||
: entry.scanned
|
||||
? el('span', { className: 'manager-badge', text: '书籍' })
|
||||
: null,
|
||||
entry.hidden && !entry.hiddenByRule ? el('span', { className: 'manager-badge', text: '隐藏' }) : null,
|
||||
],
|
||||
});
|
||||
|
||||
const more = el('button', {
|
||||
className: 'icon-button manager-more',
|
||||
text: '⋯',
|
||||
attrs: { type: 'button', 'aria-label': `${entry.name} 的操作` },
|
||||
on: {
|
||||
click: (event) => {
|
||||
event.stopPropagation();
|
||||
void this.openEntryMenu(entry);
|
||||
},
|
||||
},
|
||||
}) as HTMLButtonElement;
|
||||
|
||||
const node = el('div', {
|
||||
className: 'manager-row',
|
||||
attrs: { 'data-type': entry.type, role: 'button', tabindex: '0' },
|
||||
children: [checkbox, name, meta, more],
|
||||
}) as HTMLDivElement;
|
||||
node.dataset['path'] = entry.path;
|
||||
|
||||
const activate = (): void => {
|
||||
if (!selectable) return;
|
||||
if (this.selected.size > 0) {
|
||||
this.toggle(entry);
|
||||
return;
|
||||
}
|
||||
if (entry.type === 'dir') void this.load(entry.path);
|
||||
};
|
||||
|
||||
node.addEventListener('click', activate);
|
||||
node.addEventListener('keydown', (event) => {
|
||||
if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ' ') {
|
||||
event.preventDefault();
|
||||
activate();
|
||||
}
|
||||
});
|
||||
|
||||
// Long press enters selection mode, which is how a file manager on a phone
|
||||
// says "I mean this one, not the one I tapped".
|
||||
if (selectable && this.selected.size > 0) node.dataset['selected'] = String(this.selected.has(entry.path));
|
||||
const start = (): void => {
|
||||
this.longPressFired = false;
|
||||
if (!selectable) return;
|
||||
this.longPressTimer = setTimeout(() => {
|
||||
this.longPressTimer = null;
|
||||
this.longPressFired = true;
|
||||
this.toggle(entry);
|
||||
}, LONG_PRESS_MS);
|
||||
};
|
||||
const cancel = (): void => {
|
||||
if (this.longPressTimer !== null) clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
};
|
||||
node.addEventListener('touchstart', start, { passive: true });
|
||||
node.addEventListener('touchend', cancel);
|
||||
node.addEventListener('touchcancel', cancel);
|
||||
node.addEventListener('touchmove', cancel);
|
||||
node.addEventListener('mousedown', start);
|
||||
node.addEventListener('mouseup', cancel);
|
||||
node.addEventListener('mouseleave', cancel);
|
||||
// A long press already acted; the click that follows must not navigate.
|
||||
node.addEventListener('click', (event) => {
|
||||
if (!this.longPressFired) return;
|
||||
this.longPressFired = false;
|
||||
event.stopImmediatePropagation();
|
||||
}, true);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private toggle(entry: BrowseEntry): void {
|
||||
if (this.selected.has(entry.path)) this.selected.delete(entry.path);
|
||||
else this.selected.add(entry.path);
|
||||
const node = this.list.querySelector<HTMLElement>(`[data-path="${cssEscape(entry.path)}"]`);
|
||||
if (node) node.dataset['selected'] = String(this.selected.has(entry.path));
|
||||
this.renderSelection();
|
||||
}
|
||||
|
||||
private clearSelection(): void {
|
||||
this.selected.clear();
|
||||
for (const node of this.list.querySelectorAll<HTMLElement>('.manager-row')) {
|
||||
node.dataset['selected'] = 'false';
|
||||
}
|
||||
this.renderSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch action bar.
|
||||
*
|
||||
* Rendered only while something is selected: an always-present toolbar of
|
||||
* move/delete buttons on a screen whose primary gesture is tapping a folder is
|
||||
* a permanent invitation to the wrong action.
|
||||
*/
|
||||
private renderSelection(): void {
|
||||
clear(this.actionBar);
|
||||
const count = this.selected.size;
|
||||
if (count === 0) {
|
||||
this.actionBar.hidden = true;
|
||||
this.selectionCount.hidden = true;
|
||||
return;
|
||||
}
|
||||
this.actionBar.hidden = false;
|
||||
this.selectionCount.hidden = false;
|
||||
this.selectionCount.textContent = `已选 ${count} 项`;
|
||||
const writable = this.listing?.writable ?? false;
|
||||
|
||||
this.actionBar.append(
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: '取消',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => this.clearSelection() },
|
||||
}),
|
||||
);
|
||||
if (writable) {
|
||||
if (count === 1) {
|
||||
const only = [...this.selected][0]!;
|
||||
const entry = this.listing?.entries.find((candidate) => candidate.path === only);
|
||||
this.actionBar.append(
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: '重命名',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => void this.promptRename(only, entry?.name ?? '') },
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.actionBar.append(
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: '改资料',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => void this.promptBatchMetadata() },
|
||||
}),
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: '下架',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => void this.batchShelf('remove') },
|
||||
}),
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: '移动…',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => void this.promptMove() },
|
||||
}),
|
||||
el('button', {
|
||||
className: 'button danger',
|
||||
text: '删除',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => void this.confirmDelete() },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.actionBar.append(
|
||||
el('span', { className: 'muted', text: '书库是只读挂载,无法修改' }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async openEntryMenu(entry: BrowseEntry): Promise<void> {
|
||||
if (!this.listing?.writable) {
|
||||
this.toast('书库是只读挂载,无法修改');
|
||||
return;
|
||||
}
|
||||
const action = await this.pick(`「${entry.name}」`, [
|
||||
{ value: 'select', label: '选择' },
|
||||
{ value: 'metadata', label: '改资料' },
|
||||
{ value: 'shelve', label: '从书架拿掉' },
|
||||
{ value: 'rename', label: '重命名' },
|
||||
{ value: 'move', label: '移动到…' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
]);
|
||||
if (action === 'select') {
|
||||
this.toggle(entry);
|
||||
return;
|
||||
}
|
||||
// Everything below acts on exactly this row, so the selection is replaced
|
||||
// rather than merged: an action started from one row's menu must not quietly
|
||||
// include a batch ticked earlier and now off screen.
|
||||
this.selectOnly(entry.path);
|
||||
if (action === 'metadata') return this.promptBatchMetadata();
|
||||
if (action === 'shelve') return this.batchShelf('remove');
|
||||
if (action === 'rename') return this.promptRename(entry.path, entry.name);
|
||||
if (action === 'move') return this.promptMove();
|
||||
if (action === 'delete') return this.confirmDelete();
|
||||
}
|
||||
|
||||
private selectOnly(path: string): void {
|
||||
this.selected.clear();
|
||||
this.selected.add(path);
|
||||
for (const node of this.list.querySelectorAll<HTMLElement>('.manager-row')) {
|
||||
node.dataset['selected'] = String(node.dataset['path'] === path);
|
||||
}
|
||||
this.renderSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the picked files into the directory currently open.
|
||||
*
|
||||
* The progress line is the whole point of the display: a book is routinely
|
||||
* hundreds of megabytes on a phone over Wi-Fi, and a screen that says nothing
|
||||
* for two minutes reads as broken. It is also the only place in this client
|
||||
* where a request can outlive a screen change, so the input is cleared *before*
|
||||
* the upload rather than after — picking the same file twice in a row must
|
||||
* work.
|
||||
*/
|
||||
private async handlePicked(): Promise<void> {
|
||||
const files = [...(this.uploadInput.files ?? [])];
|
||||
this.uploadInput.value = '';
|
||||
if (files.length === 0) return;
|
||||
|
||||
const policy = await this.pickConflictPolicy();
|
||||
if (!policy) return;
|
||||
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0);
|
||||
this.setBusy(true);
|
||||
try {
|
||||
const result = await this.options.api.upload(files, this.current, policy, (fraction) => {
|
||||
this.setStatus(`上传中… ${Math.round(fraction * 100)}%(${formatBytes(total)})`);
|
||||
});
|
||||
this.reportUpload(result);
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
} finally {
|
||||
this.setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks what should happen to a name that is already taken.
|
||||
*
|
||||
* Asked before the bytes move rather than after a 409, because by then the
|
||||
* user has waited for the whole upload twice. `覆盖` is the only answer that can
|
||||
* lose a book, so it is described that way instead of being the default.
|
||||
*/
|
||||
private async pickConflictPolicy(): Promise<ConflictPolicy | null> {
|
||||
const answer = await this.pick('同名文件怎么办?', [
|
||||
{ value: 'rename', label: '两份都留(加 (2))' },
|
||||
{ value: 'skip', label: '跳过已有的' },
|
||||
{ value: 'overwrite', label: '覆盖(会替换原文件)' },
|
||||
{ value: 'fail', label: '有重名就整批不动' },
|
||||
]);
|
||||
return answer as ConflictPolicy | null;
|
||||
}
|
||||
|
||||
private reportUpload(result: Awaited<ReturnType<ReaderApi['upload']>>): void {
|
||||
const parts: string[] = [];
|
||||
if (result.uploaded.length > 0) parts.push(`已入库 ${result.uploaded.length} 个文件`);
|
||||
if (result.skipped.length > 0) parts.push(`跳过 ${result.skipped.length} 个`);
|
||||
const added = result.scan?.added ?? 0;
|
||||
parts.push(added > 0 ? `新增 ${added} 本` : '没有新书');
|
||||
// A skipped file is the one thing the user has to act on, so it is named
|
||||
// rather than counted: "跳过 1 个" without a name is a dead end.
|
||||
const first = result.skipped[0];
|
||||
if (first) parts.push(`(${first.name}:${first.reason})`);
|
||||
this.toast(parts.join(' · '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the metadata of every selected path at once.
|
||||
*
|
||||
* The fields are optional on purpose — this is a *patch*, and an empty field
|
||||
* means "leave it alone", which is the only way one dialog can serve both "fill
|
||||
* in the missing author on forty files" and "fix the series name".
|
||||
*/
|
||||
private async promptBatchMetadata(): Promise<void> {
|
||||
const paths = [...this.selected];
|
||||
if (paths.length === 0) return;
|
||||
const fields = await this.batchMetadataDialog(paths.length);
|
||||
if (fields === null) return;
|
||||
if (Object.keys(fields).length === 0) {
|
||||
this.toast('没有填写任何字段');
|
||||
return;
|
||||
}
|
||||
await this.mutate(
|
||||
() => this.options.api.browseBatchMetadata(paths, fields),
|
||||
`改资料(${paths.length} 项)`,
|
||||
(result) => this.reportBatch(result),
|
||||
);
|
||||
}
|
||||
|
||||
private async batchShelf(action: ShelfAction): Promise<void> {
|
||||
const paths = [...this.selected];
|
||||
if (paths.length === 0) return;
|
||||
const label = action === 'remove' ? '下架' : '上架';
|
||||
if (action === 'remove') {
|
||||
const ok = await this.confirm(
|
||||
`${label} ${paths.length} 项?`,
|
||||
'只是从你的书架上拿掉,磁盘上的文件一个都不会动,随时可以放回来。',
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await this.mutate(
|
||||
() => this.options.api.browseBatchShelf(paths, action),
|
||||
`${label}(${paths.length} 项)`,
|
||||
(result) => this.reportBatch(result),
|
||||
);
|
||||
}
|
||||
|
||||
private reportBatch(result: { applied: number; failed: Array<{ path: string; reason: string }> }): string {
|
||||
if (result.applied === 0 && result.failed.length > 0) {
|
||||
return `没有可处理的书籍(${result.failed.length} 项不是书)`;
|
||||
}
|
||||
// A path that is not a book is expected — a folder of images, a `.nfo` — and
|
||||
// saying so is what stops "已更新 12 本" from looking like the other two were
|
||||
// silently dropped.
|
||||
const skipped = result.failed.length > 0 ? `,跳过 ${result.failed.length} 项` : '';
|
||||
return `已更新 ${result.applied} 本${skipped}`;
|
||||
}
|
||||
|
||||
/** A form of optional fields; absent means "do not touch". */
|
||||
private batchMetadataDialog(count: number): Promise<Record<string, string> | null> {
|
||||
const FIELDS = [
|
||||
{ key: 'author', label: '作者' },
|
||||
{ key: 'publisher', label: '出版社' },
|
||||
{ key: 'series', label: '系列' },
|
||||
{ key: 'seriesIndex', label: '系列序号' },
|
||||
{ key: 'language', label: '语言' },
|
||||
{ key: 'tags', label: '标签(逗号分隔)' },
|
||||
{ key: 'pubdate', label: '出版日期' },
|
||||
];
|
||||
return new Promise((resolve) => {
|
||||
const inputs = new Map<string, HTMLInputElement>();
|
||||
const rows = FIELDS.map((field) => {
|
||||
const input = el('input', { attrs: { type: 'text', placeholder: '留空则不改' } }) as HTMLInputElement;
|
||||
inputs.set(field.key, input);
|
||||
return el('label', { className: 'dialog-field', children: [el('span', { text: field.label }), input] });
|
||||
});
|
||||
const close = (result: Record<string, string> | null): void => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
};
|
||||
const form = el('form', {
|
||||
className: 'dialog',
|
||||
on: {
|
||||
submit: (event) => {
|
||||
event.preventDefault();
|
||||
const patch: Record<string, string> = {};
|
||||
for (const [key, input] of inputs) {
|
||||
const value = input.value.trim();
|
||||
if (value !== '') patch[key] = value;
|
||||
}
|
||||
close(patch);
|
||||
},
|
||||
},
|
||||
children: [
|
||||
el('h3', { text: `改资料(${count} 项)` }),
|
||||
el('p', { className: 'muted', text: '留空的字段保持不变。目录会写成它里面每一本书的资料。' }),
|
||||
// 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.
|
||||
el('div', { className: 'dialog-fields', children: rows }),
|
||||
el('div', {
|
||||
className: 'dialog-actions',
|
||||
children: [
|
||||
el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(null) } }),
|
||||
el('button', { className: 'button primary', text: '保存', attrs: { type: 'submit' } }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const overlay = el('div', {
|
||||
className: 'dialog-overlay',
|
||||
on: {
|
||||
click: (event) => {
|
||||
if (event.target === overlay) close(null);
|
||||
},
|
||||
},
|
||||
children: [form],
|
||||
}) as HTMLDivElement;
|
||||
this.element.append(overlay);
|
||||
inputs.get('author')?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks a second write while one is in flight.
|
||||
*
|
||||
* Uploads are long and the bar stays on screen; a second tap would start a
|
||||
* second upload into the same directory, and the two would interleave their
|
||||
* status lines.
|
||||
*/
|
||||
private setBusy(busy: boolean): void {
|
||||
this.busy = busy;
|
||||
this.uploadButton.disabled = busy;
|
||||
this.mkdirButton.disabled = busy;
|
||||
}
|
||||
|
||||
private async promptRename(path: string, currentName: string): Promise<void> {
|
||||
const name = await this.prompt('重命名', currentName);
|
||||
if (name === null || name.trim() === '' || name === currentName) return;
|
||||
await this.mutate(() => this.options.api.browseRename(path, name.trim()), `重命名 ${currentName}`);
|
||||
}
|
||||
|
||||
private async promptMkdir(): Promise<void> {
|
||||
const name = await this.prompt('新建文件夹', '');
|
||||
if (name === null || name.trim() === '') return;
|
||||
await this.mutate(() => this.options.api.browseMkdir(this.current, name.trim()), `新建 ${name.trim()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving a batch asks for a path rather than offering a folder picker.
|
||||
*
|
||||
* A picker would be a second navigation UI inside the first, and the one place
|
||||
* a reader is most likely to land in the wrong folder is precisely where the
|
||||
* files go. A typed, library-relative path is unambiguous — and the server
|
||||
* resolves it through the same containment check as everything else.
|
||||
*/
|
||||
private async promptMove(): Promise<void> {
|
||||
const target = await this.prompt('移动到(书库内的路径,留空为根目录)', this.current);
|
||||
if (target === null) return;
|
||||
const paths = [...this.selected];
|
||||
await this.mutate(() => this.options.api.browseMove(paths, target.trim()), `移动 ${paths.length} 项`);
|
||||
}
|
||||
|
||||
private async confirmDelete(): Promise<void> {
|
||||
const count = this.selected.size;
|
||||
const ok = await this.confirm(
|
||||
`删除 ${count} 项?`,
|
||||
'文件会从磁盘上直接删除,不进回收站。删除后需要重新扫描书库,书架才会更新。',
|
||||
);
|
||||
if (!ok) return;
|
||||
const paths = [...this.selected];
|
||||
await this.mutate(() => this.options.api.browseDelete(paths), `删除 ${paths.length} 项`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a mutating call, reports its outcome, and reloads only on success.
|
||||
*
|
||||
* The order matters and is the reason this is not two calls at the call sites:
|
||||
* reloading after a *failed* write would replace the error message with a fresh
|
||||
* directory listing, so the screen would look like the rename simply did not
|
||||
* happen — the worst possible answer to "why did nothing change".
|
||||
*/
|
||||
private async mutate(
|
||||
action: () => Promise<unknown>,
|
||||
label: string,
|
||||
describe?: (result: never) => string,
|
||||
): Promise<void> {
|
||||
if (this.busy) return;
|
||||
this.toast(`${label}…`);
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await action();
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
return;
|
||||
}
|
||||
// The server's own counts beat a generic "完成": a batch that touched two
|
||||
// books out of five selected is a different outcome from one that touched
|
||||
// five, and only the response knows which happened.
|
||||
this.outcome = describe ? describe(result as never) : `${label} 完成`;
|
||||
await this.load(this.current);
|
||||
}
|
||||
|
||||
private setStatus(text: string): void {
|
||||
this.statusLine.textContent = text;
|
||||
}
|
||||
|
||||
private toast(text: string): void {
|
||||
this.setStatus(text);
|
||||
}
|
||||
|
||||
private handleError(err: unknown): void {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isAuthFailure) {
|
||||
this.options.onSignedOut();
|
||||
return;
|
||||
}
|
||||
this.setStatus(err.message);
|
||||
return;
|
||||
}
|
||||
this.setStatus(err instanceof Error ? err.message : '出错了');
|
||||
}
|
||||
|
||||
// ---- dialogs ----
|
||||
//
|
||||
// Built on the spot rather than kept as members: each one is a single question
|
||||
// with one answer, and a dialog that outlives its question is how a stale
|
||||
// "delete?" prompt ends up attached to a different file.
|
||||
|
||||
private prompt(title: string, value: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = el('input', { attrs: { type: 'text', value } }) as HTMLInputElement;
|
||||
const close = (result: string | null): void => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
};
|
||||
const form = el('form', {
|
||||
className: 'dialog',
|
||||
on: {
|
||||
submit: (event) => {
|
||||
event.preventDefault();
|
||||
close(input.value);
|
||||
},
|
||||
},
|
||||
children: [
|
||||
el('h3', { text: title }),
|
||||
input,
|
||||
el('div', {
|
||||
className: 'dialog-actions',
|
||||
children: [
|
||||
el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(null) } }),
|
||||
el('button', { className: 'button primary', text: '确定', attrs: { type: 'submit' } }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const overlay = el('div', {
|
||||
className: 'dialog-overlay',
|
||||
on: {
|
||||
click: (event) => {
|
||||
if (event.target === overlay) close(null);
|
||||
},
|
||||
},
|
||||
children: [form],
|
||||
}) as HTMLDivElement;
|
||||
this.element.append(overlay);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
|
||||
private confirm(title: string, body: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const close = (result: boolean): void => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
};
|
||||
const overlay = el('div', {
|
||||
className: 'dialog-overlay',
|
||||
on: {
|
||||
click: (event) => {
|
||||
if (event.target === overlay) close(false);
|
||||
},
|
||||
},
|
||||
children: [
|
||||
el('div', {
|
||||
className: 'dialog',
|
||||
children: [
|
||||
el('h3', { text: title }),
|
||||
el('p', { className: 'muted', text: body }),
|
||||
el('div', {
|
||||
className: 'dialog-actions',
|
||||
children: [
|
||||
el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(false) } }),
|
||||
el('button', { className: 'button danger', text: '删除', attrs: { type: 'button' }, on: { click: () => close(true) } }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}) as HTMLDivElement;
|
||||
this.element.append(overlay);
|
||||
});
|
||||
}
|
||||
|
||||
private async pick(title: string, options: Array<{ value: string; label: string }>): Promise<string | null> {
|
||||
// De-duplicated by value: two rows that resolve to the same action are one
|
||||
// action, and showing it twice reads as two different things.
|
||||
const seen = new Set<string>();
|
||||
const unique = options.filter((option) => {
|
||||
if (seen.has(option.value)) return false;
|
||||
seen.add(option.value);
|
||||
return true;
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
const close = (result: string | null): void => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
};
|
||||
const overlay = el('div', {
|
||||
className: 'dialog-overlay',
|
||||
on: {
|
||||
click: (event) => {
|
||||
if (event.target === overlay) close(null);
|
||||
},
|
||||
},
|
||||
children: [
|
||||
el('div', {
|
||||
className: 'dialog sheet',
|
||||
children: [
|
||||
el('h3', { text: title }),
|
||||
el('div', {
|
||||
className: 'dialog-list',
|
||||
children: unique.map((option) =>
|
||||
el('button', {
|
||||
className: 'button',
|
||||
text: option.label,
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => close(option.value) },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
el('div', {
|
||||
className: 'dialog-actions',
|
||||
children: [el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(null) } })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}) as HTMLDivElement;
|
||||
this.element.append(overlay);
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.longPressTimer !== null) clearTimeout(this.longPressTimer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS.escape with a fallback.
|
||||
*
|
||||
* `querySelector` with a raw path breaks on a folder named `第01卷 [前篇]` or one
|
||||
* containing a quote — both entirely ordinary in a Chinese comic library — and
|
||||
* `CSS.escape` is not present in every WebView the Android shell runs on.
|
||||
*/
|
||||
function cssEscape(value: string): string {
|
||||
const globalCss = (globalThis as { CSS?: { escape?: (text: string) => string } }).CSS;
|
||||
if (globalCss?.escape) return globalCss.escape(value);
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ReaderApi } from '../api/client.ts';
|
||||
import type { BrowseEntry, BrowseListing, ConflictPolicy, ShelfAction } from '../api/types.ts';
|
||||
import { formatBytes, formatDate } from './dom.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { Button, IconButton } from './toolkit.tsx';
|
||||
import { type ComponentChildren, type JSX, useEffect, useRef, useState } from './vendor/preact.ts';
|
||||
|
||||
export interface ManagerScreenOptions {
|
||||
api: ReaderApi;
|
||||
onClose(): void;
|
||||
onSignedOut(): void;
|
||||
}
|
||||
|
||||
/** Long-press, in milliseconds, before a touch starts a selection. */
|
||||
const LONG_PRESS_MS = 450;
|
||||
|
||||
/** One question with one answer. Held by the screen, rendered by the tree. */
|
||||
type Dialog =
|
||||
| { kind: 'prompt'; title: string; value: string; resolve(value: string | null): void }
|
||||
| { kind: 'confirm'; title: string; body: string; resolve(ok: boolean): void }
|
||||
| {
|
||||
kind: 'pick';
|
||||
title: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
resolve(value: string | null): void;
|
||||
}
|
||||
| {
|
||||
kind: 'form';
|
||||
title: string;
|
||||
count: number;
|
||||
fields: Array<{ key: string; label: string }>;
|
||||
resolve(fields: Record<string, string> | null): void;
|
||||
};
|
||||
|
||||
interface ManagerState {
|
||||
path: string;
|
||||
listing: BrowseListing | null;
|
||||
/** Paths ticked for a batch operation. Empty means "navigate" mode. */
|
||||
selected: ReadonlySet<string>;
|
||||
loading: boolean;
|
||||
/** A write is in flight; every other write is refused until it settles. */
|
||||
busy: boolean;
|
||||
status: string;
|
||||
dialog: Dialog | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 书库管理 — the library as a file manager.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* The shelf answers "what can I read". It cannot answer "where did my book go",
|
||||
* and that is the question a self-hosted library actually generates: a title
|
||||
* that does not appear after a rescan has no explanation anywhere in the app,
|
||||
* because a book's DTO never carries its path. This screen is the one place the
|
||||
* `BOOKS_DIR` is visible, and it is also the only one that writes to it.
|
||||
*
|
||||
* ## The three decisions that shape it
|
||||
*
|
||||
* 1. **Hidden entries are shown, with the reason.** `.trash`, `@eaDir` and
|
||||
* friends are skipped by the scanner by design. Filtering them out here too
|
||||
* would hide exactly the files a reader is looking for — the ones that are on
|
||||
* disk but not on the shelf — so they are listed with a badge instead.
|
||||
* 2. **Selection is explicit, and destructive actions need a second tap.**
|
||||
* Thumb-scrolling a NAS library with a delete button on every row is one
|
||||
* mis-tap away from data loss. Rows navigate; selection is entered through
|
||||
* the long-press or the ⋯ button, and delete always asks.
|
||||
* 3. **The write controls disappear on a read-only mount.** `:ro` is the
|
||||
* documented deployment, so the common case is "this screen can only look",
|
||||
* and offering buttons that answer 403 teaches the reader to distrust the UI.
|
||||
*
|
||||
* ## What the framework changed here
|
||||
*
|
||||
* This screen was previously a class that held a reference to every node it would
|
||||
* later mutate, plus five separate render methods that had to agree with each
|
||||
* other, plus dialog builders that appended an overlay to `this.element` and
|
||||
* removed it by hand on every exit path. The state below is the same set of
|
||||
* facts, but the tree is a *function* of it, so "the selection bar is stale after
|
||||
* a rename" and "the ⋯ menu is still up after the screen reloaded" are no longer
|
||||
* expressible. The long-press and the gesture bookkeeping stay imperative,
|
||||
* because they are a timer, not markup.
|
||||
*/
|
||||
export class ManagerScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly state: ManagerState = {
|
||||
path: '',
|
||||
listing: null,
|
||||
selected: new Set<string>(),
|
||||
loading: false,
|
||||
busy: false,
|
||||
status: '',
|
||||
dialog: null,
|
||||
};
|
||||
private readonly ui: ReturnType<typeof mountUI>;
|
||||
private readonly uploadInput = document.createElement('input');
|
||||
/** Re-renders the tree from the current state. The whole update path. */
|
||||
private readonly draw: () => void;
|
||||
/**
|
||||
* The message a completed write left for the next render.
|
||||
*
|
||||
* Held across the reload a write triggers, because that reload's own status
|
||||
* line is the directory summary — and the summary is not an answer to "did it
|
||||
* work".
|
||||
*/
|
||||
private outcome: string | null = null;
|
||||
|
||||
constructor(private readonly options: ManagerScreenOptions) {
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'manager-screen';
|
||||
// A file input lives outside the tree on purpose: it is never re-created, so
|
||||
// its `files` list and its focus survive every re-render. On some WebViews a
|
||||
// file input removed from the document loses its value, and the second upload
|
||||
// after a cancel would then send nothing.
|
||||
this.uploadInput.type = 'file';
|
||||
this.uploadInput.multiple = true;
|
||||
this.uploadInput.className = 'manager-upload-input';
|
||||
this.uploadInput.setAttribute('aria-hidden', 'true');
|
||||
this.uploadInput.tabIndex = -1;
|
||||
this.uploadInput.addEventListener('change', () => void this.handlePicked());
|
||||
|
||||
const host = document.createElement('div');
|
||||
// `display: contents`, added in the stylesheet as `.manager-ui`, so the host
|
||||
// adds no layout box: the screen stays the flex column, and the header, the
|
||||
// scroller and the action bar stay its children as far as CSS is concerned.
|
||||
// Without that, the screen would have one child that is not `min-height: 0`
|
||||
// and the list would push the action bar off the bottom of a phone.
|
||||
host.className = 'manager-ui';
|
||||
this.element.append(host, this.uploadInput);
|
||||
|
||||
this.draw = (): void => this.ui.update(this.state);
|
||||
this.ui = mountUI(host, (state) => this.view(state as ManagerState), this.state);
|
||||
}
|
||||
|
||||
/** The mount's writability, read by CSS rather than branched on in the tree. */
|
||||
private applyWritable(writable: boolean): void {
|
||||
// On the *screen*, not on a wrapper: the stylesheet dims the per-row ⋯ menu
|
||||
// through this attribute, and that affordance is drawn by the row.
|
||||
this.element.dataset['writable'] = String(writable);
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.load('');
|
||||
}
|
||||
|
||||
private async load(path: string): Promise<void> {
|
||||
if (this.state.loading) return;
|
||||
this.state.loading = true;
|
||||
if (!this.state.status) this.setStatus('载入中…');
|
||||
this.draw();
|
||||
try {
|
||||
const listing = await this.options.api.browse(path);
|
||||
this.state.listing = listing;
|
||||
this.state.path = listing.path;
|
||||
this.applyWritable(listing.writable);
|
||||
// The status line describes what was just done, when something was. A
|
||||
// plain directory listing has nothing to report but the summary the tree
|
||||
// computes, so the load message is dropped — and an *outcome* set by a
|
||||
// completed write is re-applied after it, because it is the one message the
|
||||
// reader is looking for and the summary is not an answer to it.
|
||||
this.state.status = this.outcome ?? '';
|
||||
// A reload invalidates any selection: the paths it held may no longer be on
|
||||
// screen, and a batch action that includes an invisible row is the one way
|
||||
// this screen can delete something the reader did not tick.
|
||||
this.state.selected = new Set<string>();
|
||||
// The status line says what a directory listing *is* — how many entries, how
|
||||
// big, how many the scanner is skipping — so the load message is dropped
|
||||
// here and the tree renders the summary instead. A message set by a
|
||||
// completed write is kept, because that is an answer the reader asked for
|
||||
// and the summary is not.
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(text: string): void {
|
||||
this.state.status = text;
|
||||
}
|
||||
|
||||
private toast(text: string): void {
|
||||
this.state.status = text;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private select(paths: Iterable<string>): void {
|
||||
this.state.selected = new Set(paths);
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private toggle(entry: BrowseEntry): void {
|
||||
const selected = new Set(this.state.selected);
|
||||
if (selected.has(entry.path)) selected.delete(entry.path);
|
||||
else selected.add(entry.path);
|
||||
this.state.selected = selected;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/** True when a delete was confirmed and the paths it covered. */
|
||||
private selectedList(): string[] {
|
||||
return [...this.state.selected];
|
||||
}
|
||||
|
||||
private handleError(err: unknown): void {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isAuthFailure) {
|
||||
this.options.onSignedOut();
|
||||
return;
|
||||
}
|
||||
this.setStatus(err.message);
|
||||
return;
|
||||
}
|
||||
this.setStatus(err instanceof Error ? err.message : '出错了');
|
||||
}
|
||||
|
||||
// ---- the tree ----
|
||||
|
||||
private view(state: ManagerState): ComponentChildren {
|
||||
const listing = state.listing;
|
||||
const writable = listing?.writable ?? false;
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<IconButton label="返回书架" onClick={() => this.options.onClose()}>
|
||||
←
|
||||
</IconButton>
|
||||
<div className="manager-crumbs">
|
||||
{(listing?.crumbs ?? []).map((crumb, index) => (
|
||||
<>
|
||||
{index > 0 ? <span className="manager-crumb-sep">/</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
key={`${crumb.path}#${index}`}
|
||||
className="manager-crumb"
|
||||
aria-current={index === (listing?.crumbs.length ?? 0) - 1}
|
||||
onClick={() => void this.load(crumb.path)}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
{/* Hidden rather than omitted, with `hidden` as the state: a control
|
||||
that can only answer 403 teaches the reader to distrust every other
|
||||
control, and *which* controls are missing is what this attribute
|
||||
says. */}
|
||||
<IconButton label="上传书籍" hidden={!writable} disabled={state.busy} onClick={() => this.uploadInput.click()}>
|
||||
⬆
|
||||
</IconButton>
|
||||
<IconButton label="新建文件夹" hidden={!writable} disabled={state.busy} onClick={() => void this.promptMkdir()}>
|
||||
+
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
className="manager-body"
|
||||
onClick={(event) => {
|
||||
// Clicking the empty area clears a selection, which is the gesture
|
||||
// people already have for "never mind". The check is on the target so
|
||||
// a click inside a row (which bubbles) does not.
|
||||
if (event.target === event.currentTarget) this.select([]);
|
||||
}}
|
||||
>
|
||||
<div className="manager-list">
|
||||
{listing && listing.entries.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>这个文件夹是空的</p>
|
||||
</div>
|
||||
) : null}
|
||||
{(listing?.entries ?? []).map((entry) => (
|
||||
<Row
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
selecting={state.selected.size > 0}
|
||||
selected={state.selected.has(entry.path)}
|
||||
onActivate={() => this.activate(entry)}
|
||||
onToggle={() => this.toggle(entry)}
|
||||
onMenu={() => void this.openEntryMenu(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="manager-status muted">{state.status || this.summary(listing)}</div>
|
||||
</div>
|
||||
<SelectionBar
|
||||
count={state.selected.size}
|
||||
writable={writable}
|
||||
only={[...state.selected][0]}
|
||||
entries={listing?.entries ?? []}
|
||||
onClear={() => this.select([])}
|
||||
onRename={(path, name) => void this.promptRename(path, name)}
|
||||
onMetadata={() => void this.promptBatchMetadata()}
|
||||
onShelve={() => void this.batchShelf('remove')}
|
||||
onMove={() => void this.promptMove()}
|
||||
onDelete={() => void this.confirmDelete()}
|
||||
/>
|
||||
{state.selected.size > 0 ? <div className="manager-selection">已选 {state.selected.size} 项</div> : null}
|
||||
{state.dialog ? (
|
||||
<DialogView dialog={state.dialog} onClose={(answer) => this.closeDialog(answer)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory summary, shown until something has something better to say.
|
||||
*
|
||||
* Kept as a function of the listing rather than written into the status line at
|
||||
* load time, because the two used to fight: a report set by a completed write
|
||||
* was overwritten by the summary of the reload that write triggered, so the one
|
||||
* message the reader had to read was the one message they never saw.
|
||||
*/
|
||||
private summary(listing: BrowseListing | null): string {
|
||||
if (listing === null) return '';
|
||||
if (listing.entries.length === 0) {
|
||||
return listing.writable ? '空文件夹 · 可以用右上角的 + 新建子目录' : '空文件夹';
|
||||
}
|
||||
const hidden = listing.entries.filter((entry) => entry.hidden || entry.hiddenByRule).length;
|
||||
const parts = [`${listing.dirs} 个文件夹`, `${listing.files} 个文件`, formatBytes(listing.size)];
|
||||
if (hidden > 0) parts.push(`${hidden} 个被扫描忽略`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* One tap on a row.
|
||||
*
|
||||
* The row navigates; the checkbox that appears in selection mode is what
|
||||
* selects. That split is the whole reason a destructive tap cannot happen while
|
||||
* scrolling a library of ten thousand files.
|
||||
*/
|
||||
private activate(entry: BrowseEntry): void {
|
||||
if (entry.type === 'other') return;
|
||||
if (this.state.selected.size > 0) {
|
||||
this.toggle(entry);
|
||||
return;
|
||||
}
|
||||
if (entry.type === 'dir') void this.load(entry.path);
|
||||
}
|
||||
|
||||
// ---- selection actions ----
|
||||
|
||||
private async openEntryMenu(entry: BrowseEntry): Promise<void> {
|
||||
if (!this.state.listing?.writable) {
|
||||
this.toast('书库是只读挂载,无法修改');
|
||||
return;
|
||||
}
|
||||
const action = await this.pick(`「${entry.name}」`, [
|
||||
{ value: 'select', label: '选择' },
|
||||
{ value: 'metadata', label: '改资料' },
|
||||
{ value: 'shelve', label: '从书架拿掉' },
|
||||
{ value: 'rename', label: '重命名' },
|
||||
{ value: 'move', label: '移动到…' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
]);
|
||||
if (action === 'select') {
|
||||
this.toggle(entry);
|
||||
return;
|
||||
}
|
||||
// Everything below acts on exactly this row, so the selection is replaced
|
||||
// rather than merged: an action started from one row's menu must not quietly
|
||||
// include a batch ticked earlier and now off screen.
|
||||
this.select([entry.path]);
|
||||
if (action === 'metadata') return this.promptBatchMetadata();
|
||||
if (action === 'shelve') return this.batchShelf('remove');
|
||||
if (action === 'rename') return this.promptRename(entry.path, entry.name);
|
||||
if (action === 'move') return this.promptMove();
|
||||
if (action === 'delete') return this.confirmDelete();
|
||||
}
|
||||
|
||||
private async promptRename(path: string, currentName: string): Promise<void> {
|
||||
const name = await this.prompt('重命名', currentName);
|
||||
if (name === null || name.trim() === '' || name === currentName) return;
|
||||
await this.mutate(() => this.options.api.browseRename(path, name.trim()), `重命名 ${currentName}`);
|
||||
}
|
||||
|
||||
private async promptMkdir(): Promise<void> {
|
||||
const name = await this.prompt('新建文件夹', '');
|
||||
if (name === null || name.trim() === '') return;
|
||||
await this.mutate(() => this.options.api.browseMkdir(this.state.path, name.trim()), `新建 ${name.trim()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving a batch asks for a path rather than offering a folder picker.
|
||||
*
|
||||
* A picker would be a second navigation UI inside the first, and the one place
|
||||
* a reader is most likely to land in the wrong folder is precisely where the
|
||||
* files go. A typed, library-relative path is unambiguous — and the server
|
||||
* resolves it through the same containment check as everything else.
|
||||
*/
|
||||
private async promptMove(): Promise<void> {
|
||||
const target = await this.prompt('移动到(书库内的路径,留空为根目录)', this.state.path);
|
||||
if (target === null) return;
|
||||
const paths = this.selectedList();
|
||||
await this.mutate(() => this.options.api.browseMove(paths, target.trim()), `移动 ${paths.length} 项`);
|
||||
}
|
||||
|
||||
private async confirmDelete(): Promise<void> {
|
||||
const count = this.state.selected.size;
|
||||
const ok = await this.confirm(
|
||||
`删除 ${count} 项?`,
|
||||
'文件会从磁盘上直接删除,不进回收站。删除后需要重新扫描书库,书架才会更新。',
|
||||
);
|
||||
if (!ok) return;
|
||||
const paths = this.selectedList();
|
||||
await this.mutate(() => this.options.api.browseDelete(paths), `删除 ${paths.length} 项`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the metadata of every selected path at once.
|
||||
*
|
||||
* The fields are optional on purpose — this is a *patch*, and an empty field
|
||||
* means "leave it alone", which is the only way one dialog can serve both "fill
|
||||
* in the missing author on forty files" and "fix the series name".
|
||||
*/
|
||||
private async promptBatchMetadata(): Promise<void> {
|
||||
const paths = this.selectedList();
|
||||
if (paths.length === 0) return;
|
||||
const fields = await this.batchMetadataDialog(paths.length);
|
||||
if (fields === null) return;
|
||||
if (Object.keys(fields).length === 0) {
|
||||
this.toast('没有填写任何字段');
|
||||
return;
|
||||
}
|
||||
await this.mutate(
|
||||
() => this.options.api.browseBatchMetadata(paths, fields),
|
||||
`改资料(${paths.length} 项)`,
|
||||
(result) => this.reportBatch(result),
|
||||
);
|
||||
}
|
||||
|
||||
private async batchShelf(action: ShelfAction): Promise<void> {
|
||||
const paths = this.selectedList();
|
||||
if (paths.length === 0) return;
|
||||
const label = action === 'remove' ? '下架' : '上架';
|
||||
if (action === 'remove') {
|
||||
const ok = await this.confirm(
|
||||
`${label} ${paths.length} 项?`,
|
||||
'只是从你的书架上拿掉,磁盘上的文件一个都不会动,随时可以放回来。',
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await this.mutate(
|
||||
() => this.options.api.browseBatchShelf(paths, action),
|
||||
`${label}(${paths.length} 项)`,
|
||||
(result) => this.reportBatch(result),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the picked files into the directory currently open.
|
||||
*
|
||||
* The progress line is the whole point of the display: a book is routinely
|
||||
* hundreds of megabytes on a phone over Wi-Fi, and a screen that says nothing
|
||||
* for two minutes reads as broken. The input is cleared *before* the upload
|
||||
* rather than after, so picking the same file twice in a row works.
|
||||
*/
|
||||
private async handlePicked(): Promise<void> {
|
||||
const files = [...(this.uploadInput.files ?? [])];
|
||||
this.uploadInput.value = '';
|
||||
if (files.length === 0) return;
|
||||
|
||||
const policy = await this.pickConflictPolicy();
|
||||
if (!policy) return;
|
||||
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0);
|
||||
this.state.busy = true;
|
||||
this.draw();
|
||||
try {
|
||||
const result = await this.options.api.upload(files, this.state.path, policy, (fraction) => {
|
||||
this.toast(`上传中… ${Math.round(fraction * 100)}%(${formatBytes(total)})`);
|
||||
});
|
||||
this.reportUpload(result);
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
} finally {
|
||||
this.state.busy = false;
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks what should happen to a name that is already taken.
|
||||
*
|
||||
* Asked before the bytes move rather than after a 409, because by then the
|
||||
* user has waited for the whole upload twice. `覆盖` is the only answer that can
|
||||
* lose a book, so it is described that way instead of being the default.
|
||||
*/
|
||||
private async pickConflictPolicy(): Promise<ConflictPolicy | null> {
|
||||
const answer = await this.pick('同名文件怎么办?', [
|
||||
{ value: 'rename', label: '两份都留(加 (2))' },
|
||||
{ value: 'skip', label: '跳过已有的' },
|
||||
{ value: 'overwrite', label: '覆盖(会替换原文件)' },
|
||||
{ value: 'fail', label: '有重名就整批不动' },
|
||||
]);
|
||||
return answer as ConflictPolicy | null;
|
||||
}
|
||||
|
||||
private reportUpload(result: Awaited<ReturnType<ReaderApi['upload']>>): void {
|
||||
const parts: string[] = [];
|
||||
if (result.uploaded.length > 0) parts.push(`已入库 ${result.uploaded.length} 个文件`);
|
||||
if (result.skipped.length > 0) parts.push(`跳过 ${result.skipped.length} 个`);
|
||||
const added = result.scan?.added ?? 0;
|
||||
parts.push(added > 0 ? `新增 ${added} 本` : '没有新书');
|
||||
// A skipped file is the one thing the user has to act on, so it is named
|
||||
// rather than counted: "跳过 1 个" without a name is a dead end.
|
||||
const first = result.skipped[0];
|
||||
if (first) parts.push(`(${first.name}:${first.reason})`);
|
||||
this.toast(parts.join(' · '));
|
||||
}
|
||||
|
||||
private reportBatch(result: { applied: number; failed: Array<{ path: string; reason: string }> }): string {
|
||||
if (result.applied === 0 && result.failed.length > 0) {
|
||||
return `没有可处理的书籍(${result.failed.length} 项不是书)`;
|
||||
}
|
||||
// A path that is not a book is expected — a folder of images, a `.nfo` — and
|
||||
// saying so is what stops "已更新 12 本" from looking like the other two were
|
||||
// silently dropped.
|
||||
const skipped = result.failed.length > 0 ? `,跳过 ${result.failed.length} 项` : '';
|
||||
return `已更新 ${result.applied} 本${skipped}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a mutating call, reports its outcome, and reloads only on success.
|
||||
*
|
||||
* The order matters and is the reason this is not two calls at the call sites:
|
||||
* reloading after a *failed* write would replace the error message with a fresh
|
||||
* directory listing, so the screen would look like the rename simply did not
|
||||
* happen — the worst possible answer to "why did nothing change".
|
||||
*/
|
||||
private async mutate(
|
||||
action: () => Promise<unknown>,
|
||||
label: string,
|
||||
describe?: (result: never) => string,
|
||||
): Promise<void> {
|
||||
if (this.state.busy) return;
|
||||
this.toast(`${label}…`);
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await action();
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
this.draw();
|
||||
return;
|
||||
}
|
||||
// The server's own counts beat a generic "完成": a batch that touched two
|
||||
// books out of five selected is a different outcome from one that touched
|
||||
// five, and only the response knows which happened. It outlives the reload
|
||||
// that `load` triggers, because `load` no longer overwrites the status line.
|
||||
this.outcome = describe ? describe(result as never) : `${label} 完成`;
|
||||
this.setStatus(this.outcome);
|
||||
await this.load(this.state.path);
|
||||
this.outcome = null;
|
||||
}
|
||||
|
||||
// ---- dialogs ----
|
||||
//
|
||||
// One dialog at a time, held in state rather than appended to the DOM. Each is
|
||||
// a question with a single answer, and a dialog that outlives its question is
|
||||
// how a stale "delete?" prompt ends up attached to a different file.
|
||||
|
||||
private batchMetadataDialog(count: number): Promise<Record<string, string> | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.state.dialog = {
|
||||
kind: 'form',
|
||||
title: `改资料(${count} 项)`,
|
||||
count,
|
||||
fields: [
|
||||
{ key: 'author', label: '作者' },
|
||||
{ key: 'publisher', label: '出版社' },
|
||||
{ key: 'series', label: '系列' },
|
||||
{ key: 'seriesIndex', label: '系列序号' },
|
||||
{ key: 'language', label: '语言' },
|
||||
{ key: 'tags', label: '标签(逗号分隔)' },
|
||||
{ key: 'pubdate', label: '出版日期' },
|
||||
],
|
||||
resolve,
|
||||
};
|
||||
this.draw();
|
||||
});
|
||||
}
|
||||
|
||||
private prompt(title: string, value: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.state.dialog = { kind: 'prompt', title, value, resolve };
|
||||
this.draw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A destructive confirmation.
|
||||
*
|
||||
* The confirm button is labelled 删除 in both places this is used — deleting
|
||||
* files and taking books off the shelf — because it is the same word for "yes,
|
||||
* do the thing I picked"; the body is what says which thing. The old version
|
||||
* passed a label through from each call site, which is how the same dialog
|
||||
* ended up saying two different things about the same button.
|
||||
*/
|
||||
private confirm(title: string, body: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
this.state.dialog = { kind: 'confirm', title, body, resolve };
|
||||
this.draw();
|
||||
});
|
||||
}
|
||||
|
||||
private async pick(title: string, options: Array<{ value: string; label: string }>): Promise<string | null> {
|
||||
// De-duplicated by value: two rows that resolve to the same action are one
|
||||
// action, and showing it twice reads as two different things.
|
||||
const seen = new Set<string>();
|
||||
const unique = options.filter((option) => {
|
||||
if (seen.has(option.value)) return false;
|
||||
seen.add(option.value);
|
||||
return true;
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
this.state.dialog = { kind: 'pick', title, options: unique, resolve };
|
||||
this.draw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open dialog with an answer, exactly once.
|
||||
*
|
||||
* The union is the honest type here: three of the four dialogs answer with a
|
||||
* string, one with a boolean, one with a record, and the *shape* of the answer
|
||||
* is known from `dialog.kind` at the moment it is resolved. The single guarded
|
||||
* cast below is what keeps that knowledge in one place instead of at every call
|
||||
* site.
|
||||
*/
|
||||
private closeDialog(answer: string | boolean | Record<string, string> | null): void {
|
||||
const dialog = this.state.dialog;
|
||||
this.state.dialog = null;
|
||||
this.draw();
|
||||
if (!dialog) return;
|
||||
if (dialog.kind === 'prompt') dialog.resolve(answer as string | null);
|
||||
else if (dialog.kind === 'confirm') dialog.resolve(answer as boolean);
|
||||
else if (dialog.kind === 'pick') dialog.resolve(answer as string | null);
|
||||
else dialog.resolve(answer as Record<string, string> | null);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.closeDialog(null);
|
||||
this.ui.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row.
|
||||
*
|
||||
* Kept a component so the long-press timer lives in the row that owns it: a
|
||||
* timer held by the screen and pointing at "the last row touched" is how a long
|
||||
* press turns into a selection of the row the finger has since left.
|
||||
*/
|
||||
interface RowProps {
|
||||
entry: BrowseEntry;
|
||||
selecting: boolean;
|
||||
selected: boolean;
|
||||
onActivate(): void;
|
||||
onToggle(): void;
|
||||
onMenu(): void;
|
||||
}
|
||||
|
||||
function Row({ entry, selecting, selected, onActivate, onToggle, onMenu }: RowProps): JSX.Element {
|
||||
const selectable = entry.type !== 'other';
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fired = useRef(false);
|
||||
const [down, setDown] = useState(false);
|
||||
|
||||
const cancel = (): void => {
|
||||
if (timer.current !== null) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
};
|
||||
// A row can be unmounted mid-press (a reload, a navigation), and a timer that
|
||||
// outlives its row would select a path that is no longer on screen.
|
||||
useEffect(() => cancel, []);
|
||||
|
||||
const start = (): void => {
|
||||
fired.current = false;
|
||||
if (!selectable) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
fired.current = true;
|
||||
onToggle();
|
||||
}, LONG_PRESS_MS);
|
||||
};
|
||||
|
||||
const meta: ComponentChildren[] = [
|
||||
<span>{entry.type === 'dir' ? '' : formatBytes(entry.size)}</span>,
|
||||
<span>{formatDate(entry.mtime)}</span>,
|
||||
];
|
||||
if (entry.hiddenByRule) meta.push(<span className="manager-badge warn">扫描忽略</span>);
|
||||
else if (entry.scanned) meta.push(<span className="manager-badge">书籍</span>);
|
||||
if (entry.hidden && !entry.hiddenByRule) meta.push(<span className="manager-badge">隐藏</span>);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="manager-row"
|
||||
data-type={entry.type}
|
||||
data-selected={String(selected)}
|
||||
data-down={String(down)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
// A long press already acted; the click that follows must not navigate.
|
||||
if (fired.current) {
|
||||
fired.current = false;
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onActivate();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onActivate();
|
||||
}
|
||||
}}
|
||||
onTouchStart={start}
|
||||
onTouchEnd={cancel}
|
||||
onTouchCancel={cancel}
|
||||
onTouchMove={cancel}
|
||||
onMouseDown={() => {
|
||||
setDown(true);
|
||||
start();
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
setDown(false);
|
||||
cancel();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setDown(false);
|
||||
cancel();
|
||||
}}
|
||||
>
|
||||
<span className="manager-check" aria-hidden="true" />
|
||||
<div className="manager-name">
|
||||
<span className="manager-icon">{entry.type === 'dir' ? '📁' : '📄'}</span>
|
||||
<span className="manager-label">{entry.name}</span>
|
||||
</div>
|
||||
<div className="manager-meta muted">{meta}</div>
|
||||
{selectable || selecting ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button manager-more"
|
||||
aria-label={`${entry.name} 的操作`}
|
||||
onClick={() => {
|
||||
cancel();
|
||||
onMenu();
|
||||
}}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch action bar.
|
||||
*
|
||||
* Rendered only while something is selected: an always-present toolbar of
|
||||
* move/delete buttons on a screen whose primary gesture is tapping a folder is a
|
||||
* permanent invitation to the wrong action.
|
||||
*/
|
||||
interface SelectionBarProps {
|
||||
count: number;
|
||||
writable: boolean;
|
||||
only: string | undefined;
|
||||
entries: BrowseEntry[];
|
||||
onClear(): void;
|
||||
onRename(path: string, name: string): void;
|
||||
onMetadata(): void;
|
||||
onShelve(): void;
|
||||
onMove(): void;
|
||||
onDelete(): void;
|
||||
}
|
||||
|
||||
function SelectionBar(props: SelectionBarProps): JSX.Element {
|
||||
if (props.count === 0) return <div className="manager-actions" hidden />;
|
||||
const name = props.entries.find((entry) => entry.path === props.only)?.name ?? '';
|
||||
return (
|
||||
<div className="manager-actions">
|
||||
<Button onClick={props.onClear}>取消</Button>
|
||||
{props.writable ? (
|
||||
<>
|
||||
{props.count === 1 && props.only !== undefined ? (
|
||||
<Button onClick={() => props.onRename(props.only!, name)}>重命名</Button>
|
||||
) : null}
|
||||
<Button onClick={props.onMetadata}>改资料</Button>
|
||||
<Button onClick={props.onShelve}>下架</Button>
|
||||
<Button onClick={props.onMove}>移动…</Button>
|
||||
<Button className="danger" onClick={props.onDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="muted">书库是只读挂载,无法修改</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog currently open, as markup.
|
||||
*
|
||||
* Presentational on purpose: it reports an answer through `onClose` and the
|
||||
* screen is what clears its own state and resolves the promise. Resolving from
|
||||
* inside the view (which is what a `dialog.resolve` call here would be) leaves
|
||||
* the state set, so the dialog stays on screen after it was answered — exactly
|
||||
* the bug the old "remove the overlay by hand on every exit path" code had, with
|
||||
* fewer exit paths to miss.
|
||||
*/
|
||||
function DialogView({
|
||||
dialog,
|
||||
onClose,
|
||||
}: {
|
||||
dialog: Dialog;
|
||||
onClose(answer: string | boolean | Record<string, string> | null): void;
|
||||
}): JSX.Element {
|
||||
const close = (answer: string | boolean | Record<string, string> | null): void => onClose(answer);
|
||||
return (
|
||||
<div
|
||||
className="dialog-overlay"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) close(dialog.kind === 'confirm' ? false : null);
|
||||
}}
|
||||
>
|
||||
{dialog.kind === 'prompt' ? (
|
||||
<PromptDialog
|
||||
title={dialog.title}
|
||||
value={dialog.value}
|
||||
onClose={(value) => close(value)}
|
||||
/>
|
||||
) : null}
|
||||
{dialog.kind === 'confirm' ? (
|
||||
<div className="dialog">
|
||||
<h3>{dialog.title}</h3>
|
||||
<p className="muted">{dialog.body}</p>
|
||||
<div className="dialog-actions">
|
||||
<Button onClick={() => close(false)}>取消</Button>
|
||||
<Button className="danger" onClick={() => close(true)}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : 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={() => close(option.value)}>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<Button onClick={() => close(null)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{dialog.kind === 'form' ? <MetadataDialog dialog={dialog} onClose={(fields) => close(fields)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptDialog({
|
||||
title,
|
||||
value,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
onClose(value: string | null): void;
|
||||
}): JSX.Element {
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
input.current?.focus();
|
||||
input.current?.select();
|
||||
}, []);
|
||||
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">
|
||||
<Button onClick={() => onClose(null)}>取消</Button>
|
||||
<Button type="submit" className="primary">
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataDialog({
|
||||
dialog,
|
||||
onClose,
|
||||
}: {
|
||||
dialog: Extract<Dialog, { kind: 'form' }>;
|
||||
onClose(fields: Record<string, string> | null): void;
|
||||
}): JSX.Element {
|
||||
const inputs = useRef(new Map<string, HTMLInputElement>());
|
||||
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">
|
||||
{dialog.fields.map((field) => (
|
||||
<label className="dialog-field" key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="留空则不改"
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
inputs.current.set(field.key, node);
|
||||
if (field.key === 'author') node.focus();
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ManagerState };
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The one place component trees meet the DOM.
|
||||
*
|
||||
* The app's screens are ordinary classes (`ShelfScreen`, `ReaderScreen`, …) with
|
||||
* an `element` the router appends and a `dispose()`. That shape is kept
|
||||
* deliberately: the router knows nothing about the framework, and the screens
|
||||
* that *are* mostly imperative (the reader) never have to pretend otherwise.
|
||||
*
|
||||
* What changes is how a screen builds its own DOM. Instead of a constructor full
|
||||
* of `el()` calls and instance fields holding every node it will later poke, it
|
||||
* renders a component tree and lets the tree re-render itself from state. The
|
||||
* host object returned here is the bridge between the two worlds.
|
||||
*/
|
||||
|
||||
import { render, type ComponentChildren } from './vendor/preact.ts';
|
||||
|
||||
export interface MountedUI {
|
||||
/** The node the router appends. */
|
||||
readonly element: HTMLElement;
|
||||
/**
|
||||
* Re-renders the tree with a new "root value".
|
||||
*
|
||||
* The screen passes the value the whole tree should be a function of —
|
||||
* typically its own state bag. Components read from that value, so the class
|
||||
* does not need to know which components care about which field.
|
||||
*/
|
||||
update(value: unknown): void;
|
||||
unmount(): void;
|
||||
}
|
||||
|
||||
type RenderFn = (value: unknown) => ComponentChildren;
|
||||
|
||||
/**
|
||||
* Mounts a component tree into a fresh container and hands back the controls.
|
||||
*
|
||||
* Preact's `render` diffs the *previous* tree against the new one, so calling
|
||||
* `update` in a loop is cheap, and calling it from an event handler is safe: the
|
||||
* handler belongs to a listener attached to a persistent node, not to a node the
|
||||
* diff is about to replace.
|
||||
*/
|
||||
export function mountUI(container: HTMLElement, renderTree: RenderFn, initial: unknown): MountedUI {
|
||||
let value = initial;
|
||||
const draw = (): void => {
|
||||
render(renderTree(value), container);
|
||||
};
|
||||
draw();
|
||||
return {
|
||||
element: container,
|
||||
update(next: unknown) {
|
||||
value = next;
|
||||
draw();
|
||||
},
|
||||
unmount() {
|
||||
render(null, container);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* The reader's chrome: everything around the page.
|
||||
*
|
||||
* Split out from the screen on purpose, and along the line that matters: the
|
||||
* *stage* below is imperative DOM work the paginator measures, and this is state
|
||||
* and events. All of it is a function of `ChromeState` — the topbar, the footer's
|
||||
* progress, the contents list, the settings panel with its format-dependent rows,
|
||||
* and the read-aloud bar.
|
||||
*
|
||||
* That last one is the clearest win of the rework. It used to be a group of nodes
|
||||
* the screen mutated in place from `renderSpeechState`, plus a "rebuild only the
|
||||
* speech rows" dance so that the engine picker could appear when the server's
|
||||
* capability probe answered without throwing away the reader's scroll position in
|
||||
* the panel. Here the probe simply changes the state, the diff redraws the rows,
|
||||
* and the scroll position is untouched because nothing above them is replaced.
|
||||
*/
|
||||
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import type { SpeechEngineKind } from '../render/speech.ts';
|
||||
import { type ComponentChildren, type JSX } from './vendor/preact.ts';
|
||||
import { IconButton, SectionTitle } from './toolkit.tsx';
|
||||
|
||||
export interface SpeechBarState {
|
||||
active: boolean;
|
||||
state: string;
|
||||
label: string;
|
||||
chip: string;
|
||||
index: number;
|
||||
total: number;
|
||||
/** Index on the reader's sentence list, which the scrub range is in. */
|
||||
sentenceIndex: number;
|
||||
sentenceTotal: number;
|
||||
}
|
||||
|
||||
export interface ChromeState {
|
||||
title: string;
|
||||
author: string;
|
||||
chromeVisible: boolean;
|
||||
statusText: string;
|
||||
statusState: string;
|
||||
/** 0..1. */
|
||||
progress: number;
|
||||
chapterLabel: string;
|
||||
tocOpen: boolean;
|
||||
settingsOpen: boolean;
|
||||
toc: Array<{ id: string; label: string; depth: number }>;
|
||||
currentSectionId: string;
|
||||
tts: SpeechBarState;
|
||||
layout: string;
|
||||
format: string;
|
||||
|
||||
// ---- the settings projection (see `settingsView` on the screen) ----
|
||||
mode: AppSettings['mode'];
|
||||
fontScale: number;
|
||||
lineHeight: string;
|
||||
theme: AppSettings['theme'];
|
||||
fit: AppSettings['fit'];
|
||||
fontFamily: string;
|
||||
pageMargin: number;
|
||||
textAlign: AppSettings['textAlign'];
|
||||
brightness: number;
|
||||
pageAnimation: AppSettings['pageAnimation'];
|
||||
tapZone: AppSettings['tapZone'];
|
||||
txtEncoding: string;
|
||||
comicDirection: AppSettings['comicDirection'];
|
||||
ttsRate: number;
|
||||
ttsPitch: number;
|
||||
ttsVolume: number;
|
||||
ttsVoice: string;
|
||||
ttsAutoAdvance: boolean;
|
||||
ttsEngine: AppSettings['ttsEngine'];
|
||||
showFitRow: boolean;
|
||||
showDirectionRow: boolean;
|
||||
showEncodingRow: boolean;
|
||||
showEngineRow: boolean;
|
||||
showPitchRow: boolean;
|
||||
showVoiceRow: boolean;
|
||||
speechUnavailable: boolean;
|
||||
engineOptions: Array<{ value: string; label: string }>;
|
||||
voices: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface ChromeHandlers {
|
||||
onBack(): void;
|
||||
toggleToc(): void;
|
||||
toggleSettings(): void;
|
||||
onSetting(patch: Partial<AppSettings>): void;
|
||||
onSpeechSetting(patch: Partial<AppSettings>): void;
|
||||
onVoice(value: string): void;
|
||||
onSpeakFromHere(): void;
|
||||
onSwitchEngine(kind: AppSettings['ttsEngine']): void;
|
||||
onTocEntry(ref: string): void;
|
||||
onTurnPage(direction: 'next' | 'previous'): void;
|
||||
onSpeechToggle(): void;
|
||||
onSpeechPrevious(): void;
|
||||
onSpeechNext(): void;
|
||||
onSpeechScrub(index: number): void;
|
||||
onStopSpeech(): void;
|
||||
}
|
||||
|
||||
export interface ReaderChromeProps {
|
||||
state: ChromeState;
|
||||
/**
|
||||
* The reading surface.
|
||||
*
|
||||
* Passed as children rather than created here: it is owned by the screen and
|
||||
* by the `ReaderView` that measures it, and a container the tree re-created
|
||||
* would take the reader's scroll position and the shadow root with it.
|
||||
*/
|
||||
stage: HTMLElement;
|
||||
handlers: ChromeHandlers;
|
||||
}
|
||||
|
||||
export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="topbar" hidden={!state.chromeVisible}>
|
||||
<IconButton label="返回书架" onClick={handlers.onBack}>
|
||||
‹
|
||||
</IconButton>
|
||||
<div className="title-block" style="flex:1 1 auto;min-width:0;">
|
||||
<h1>{state.title}</h1>
|
||||
{state.author ? <span className="subtitle">{state.author}</span> : null}
|
||||
</div>
|
||||
<IconButton label="目录" onClick={handlers.toggleToc}>
|
||||
☰
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className="status-bar" hidden={state.statusState === 'idle' && state.statusText === ''} data-state={state.statusState}>
|
||||
<span className="status-dot" />
|
||||
<span>{state.statusText}</span>
|
||||
</div>
|
||||
|
||||
<StageHost stage={stage} />
|
||||
|
||||
<div className="footer" hidden={!state.chromeVisible}>
|
||||
<div className="progress-bar">
|
||||
<span style={`width:${percentOf(state.progress)}`} />
|
||||
</div>
|
||||
<div className="footer-row">
|
||||
<IconButton label="目录" onClick={handlers.toggleToc}>
|
||||
☰
|
||||
</IconButton>
|
||||
<span className="chapter">{state.chapterLabel}</span>
|
||||
<IconButton label="阅读设置" onClick={handlers.toggleSettings}>
|
||||
⚙
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="footer-row">
|
||||
<span />
|
||||
<span>{percentOf(state.progress)}</span>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.tts.active ? <SpeechBar state={state.tts} handlers={handlers} /> : null}
|
||||
|
||||
{state.tocOpen ? (
|
||||
<Panel title="目录" onClose={handlers.toggleToc}>
|
||||
{state.toc.length === 0 ? (
|
||||
<div className="empty-state">这本书没有目录</div>
|
||||
) : (
|
||||
<ul className="toc-list">
|
||||
{state.toc.map((entry) => (
|
||||
<li key={entry.id} data-section={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={entry.id === state.currentSectionId}
|
||||
style={entry.depth > 0 ? `padding-inline-start:${0.4 + entry.depth * 0.9}rem` : undefined}
|
||||
onClick={() => handlers.onTocEntry(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{state.settingsOpen ? (
|
||||
<Panel title="阅读设置" onClose={handlers.toggleSettings}>
|
||||
<SettingsBody state={state} handlers={handlers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the stage in the tree without owning it.
|
||||
*
|
||||
* `dangerouslySetInnerHTML` is the wrong tool here — it would replace the node
|
||||
* Preact renders rather than keep this one. A ref callback that appends the
|
||||
* element the screen kept across renders is the honest version: the tree decides
|
||||
* *where* the stage sits (between the status line and the footer, which is where
|
||||
* its stacking context comes from) and the screen owns its contents.
|
||||
*/
|
||||
function StageHost({ stage }: { stage: HTMLElement }): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="stage-host"
|
||||
ref={(node) => {
|
||||
// A ref callback fires with `null` on unmount; only the mount case moves
|
||||
// the node, because moving it on unmount would take it out of the screen
|
||||
// the router is about to replace it in.
|
||||
if (node && stage.parentElement !== node) node.append(stage);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, onClose, children }: { title: string; onClose(): void; children: ComponentChildren }): JSX.Element {
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>{title}</h2>
|
||||
<IconButton label="关闭" onClick={onClose}>
|
||||
✕
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="panel-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsBody({ state, handlers }: { state: ChromeState; handlers: ChromeHandlers }): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SectionTitle>排版</SectionTitle>
|
||||
<SegmentedRow
|
||||
label="翻页方式"
|
||||
options={[
|
||||
{ value: 'scroll', label: '滚动' },
|
||||
{ value: 'paged', label: '翻页' },
|
||||
]}
|
||||
value={state.mode}
|
||||
onChange={(value) => handlers.onSetting({ mode: value as AppSettings['mode'] })}
|
||||
/>
|
||||
<SliderRow
|
||||
label="字号"
|
||||
value={state.fontScale}
|
||||
min={0.8}
|
||||
max={2.2}
|
||||
step={0.05}
|
||||
format={(value) => `${Math.round(value * 100)}%`}
|
||||
onChange={(value) => handlers.onSetting({ fontScale: value })}
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="行距"
|
||||
options={['inherit', '1.4', '1.6', '1.8', '2.1'].map((value) => ({
|
||||
value,
|
||||
label: value === 'inherit' ? '原书' : value,
|
||||
}))}
|
||||
value={state.lineHeight}
|
||||
onChange={(value) => handlers.onSetting({ lineHeight: value })}
|
||||
/>
|
||||
<SliderRow
|
||||
label="页边距"
|
||||
value={state.pageMargin}
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.25}
|
||||
format={(value) => `${value.toFixed(2)}rem`}
|
||||
onChange={(value) => handlers.onSetting({ pageMargin: value })}
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="对齐"
|
||||
options={[
|
||||
{ value: 'inherit', label: '原书' },
|
||||
{ value: 'start', label: '左对齐' },
|
||||
{ value: 'justify', label: '两端对齐' },
|
||||
]}
|
||||
value={state.textAlign}
|
||||
onChange={(value) => handlers.onSetting({ textAlign: value as AppSettings['textAlign'] })}
|
||||
/>
|
||||
{/* The stacks are the ones a Chinese reading app is expected to offer: a
|
||||
system stack, two serif faces that are actually present on phones, and
|
||||
"原书" which is the default and means "do not touch the book's stack". */}
|
||||
<SelectRow
|
||||
label="字体"
|
||||
options={[
|
||||
{ value: 'inherit', label: '原书' },
|
||||
{ value: 'system-ui, -apple-system, "Noto Sans SC", sans-serif', label: '系统黑体' },
|
||||
{ value: '"Songti SC", "Noto Serif SC", "Source Han Serif SC", SimSun, serif', label: '宋体' },
|
||||
{ value: '"Kaiti SC", KaiTi, "Noto Serif SC", serif', label: '楷体' },
|
||||
{ value: '"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif', label: '苹方/雅黑' },
|
||||
]}
|
||||
value={state.fontFamily}
|
||||
onChange={(value) => handlers.onSetting({ fontFamily: value })}
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="主题"
|
||||
options={[
|
||||
{ value: 'light', label: '白' },
|
||||
{ value: 'sepia', label: '米黄' },
|
||||
{ value: 'dark', label: '夜间' },
|
||||
]}
|
||||
value={state.theme}
|
||||
onChange={(value) => handlers.onSetting({ theme: value as AppSettings['theme'] })}
|
||||
/>
|
||||
<SliderRow
|
||||
label="亮度"
|
||||
value={state.brightness}
|
||||
min={0.35}
|
||||
max={1}
|
||||
step={0.05}
|
||||
format={(value) => `${Math.round(value * 100)}%`}
|
||||
onChange={(value) => handlers.onSetting({ brightness: value })}
|
||||
/>
|
||||
|
||||
<SectionTitle>翻页</SectionTitle>
|
||||
<SegmentedRow
|
||||
label="点击区域"
|
||||
options={[
|
||||
{ value: 'standard', label: '左退右进' },
|
||||
{ value: 'reversed', label: '左进右退' },
|
||||
]}
|
||||
value={state.tapZone}
|
||||
onChange={(value) => handlers.onSetting({ tapZone: value as AppSettings['tapZone'] })}
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="翻页动画"
|
||||
options={[
|
||||
{ value: 'slide', label: '滑动' },
|
||||
{ value: 'fade', label: '淡入' },
|
||||
{ value: 'none', label: '无' },
|
||||
]}
|
||||
value={state.pageAnimation}
|
||||
onChange={(value) => handlers.onSetting({ pageAnimation: value as AppSettings['pageAnimation'] })}
|
||||
/>
|
||||
{/* Rows that only apply to some books. The old panel built every row and
|
||||
then hid the irrelevant ones by hand after each open; here they are
|
||||
simply not part of the tree. */}
|
||||
{state.showFitRow ? (
|
||||
<SegmentedRow
|
||||
label="图片适配"
|
||||
options={[
|
||||
{ value: 'contain', label: '完整' },
|
||||
{ value: 'width', label: '适宽' },
|
||||
]}
|
||||
value={state.fit}
|
||||
onChange={(value) => handlers.onSetting({ fit: value as AppSettings['fit'] })}
|
||||
/>
|
||||
) : null}
|
||||
{state.showDirectionRow ? (
|
||||
<SegmentedRow
|
||||
label="翻页方向"
|
||||
options={[
|
||||
{ value: 'ltr', label: '左→右' },
|
||||
{ value: 'rtl', label: '右→左' },
|
||||
]}
|
||||
value={state.comicDirection}
|
||||
onChange={(value) => handlers.onSetting({ comicDirection: value as AppSettings['comicDirection'] })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SectionTitle>朗读</SectionTitle>
|
||||
<SpeechSettings state={state} handlers={handlers} />
|
||||
|
||||
{state.showEncodingRow ? (
|
||||
<SegmentedRow
|
||||
label="TXT 编码"
|
||||
options={[
|
||||
{ value: '', label: '自动' },
|
||||
{ value: 'utf-8', label: 'utf-8' },
|
||||
{ value: 'gb18030', label: 'gb18030' },
|
||||
{ value: 'big5', label: 'big5' },
|
||||
{ value: 'utf-16le', label: 'utf-16le' },
|
||||
]}
|
||||
value={state.txtEncoding}
|
||||
onChange={(value) => handlers.onSetting({ txtEncoding: value })}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-aloud section.
|
||||
*
|
||||
* Which rows exist depends on the engine, and the dependency is stated rather
|
||||
* than hidden: pitch has no meaning for synthesised audio, a voice list is empty
|
||||
* for HTTP until the server answers, and "系统语音(原生)" does not exist in a
|
||||
* browser. A row that cannot do anything is omitted instead of shown disabled — a
|
||||
* disabled control tells the reader something is broken, and usually nothing is.
|
||||
*/
|
||||
function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: ChromeHandlers }): JSX.Element {
|
||||
if (state.speechUnavailable) {
|
||||
return (
|
||||
<div className="notice">
|
||||
当前环境没有可用的朗读引擎:浏览器不支持语音合成,且服务端未配置 TTS_URL。在服务端设置 TTS_URL 后即可使用 HTTP 朗读。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* The engine picker only appears when there is a choice to make. One
|
||||
engine is not a setting. */}
|
||||
{state.showEngineRow ? (
|
||||
<SelectRow
|
||||
label="朗读引擎"
|
||||
options={state.engineOptions}
|
||||
value={state.ttsEngine}
|
||||
onChange={(value) => handlers.onSwitchEngine(value as AppSettings['ttsEngine'])}
|
||||
/>
|
||||
) : null}
|
||||
<SliderRow
|
||||
label="语速"
|
||||
value={state.ttsRate}
|
||||
min={0.5}
|
||||
max={2.5}
|
||||
step={0.1}
|
||||
format={(value) => `${value.toFixed(1)}×`}
|
||||
onChange={(value) => handlers.onSpeechSetting({ ttsRate: value })}
|
||||
/>
|
||||
{state.showPitchRow ? (
|
||||
<SliderRow
|
||||
label="音调"
|
||||
value={state.ttsPitch}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
format={(value) => value.toFixed(1)}
|
||||
onChange={(value) => handlers.onSpeechSetting({ ttsPitch: value })}
|
||||
/>
|
||||
) : null}
|
||||
<SliderRow
|
||||
label="音量"
|
||||
value={state.ttsVolume}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
format={(value) => `${Math.round(value * 100)}%`}
|
||||
onChange={(value) => handlers.onSpeechSetting({ ttsVolume: value })}
|
||||
/>
|
||||
{state.showVoiceRow ? (
|
||||
<SelectRow label="语音" options={state.voices} value={state.ttsVoice} onChange={handlers.onVoice} />
|
||||
) : null}
|
||||
<SegmentedRow
|
||||
label="章节播完"
|
||||
options={[
|
||||
{ value: 'auto', label: '继续下一章' },
|
||||
{ value: 'stop', label: '停止' },
|
||||
]}
|
||||
value={state.ttsAutoAdvance ? 'auto' : 'stop'}
|
||||
onChange={(value) => handlers.onSpeechSetting({ ttsAutoAdvance: value === 'auto' })}
|
||||
/>
|
||||
<button type="button" className="button" onClick={handlers.onSpeakFromHere}>
|
||||
从头朗读这一章
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-aloud bar.
|
||||
*
|
||||
* A separate strip rather than a row inside the footer, for a reason that only
|
||||
* shows up in use: the footer is toggled off with the chrome, and a reader
|
||||
* listening to a book while walking wants to keep the controls reachable without
|
||||
* the header covering the text. The bar is therefore tied to the *engine's*
|
||||
* state, not to the chrome's.
|
||||
*/
|
||||
function SpeechBar({ state, handlers }: { state: SpeechBarState; handlers: ChromeHandlers }): JSX.Element {
|
||||
const max = Math.max(0, (state.sentenceTotal || state.total) - 1);
|
||||
const value = Math.max(0, state.sentenceIndex >= 0 ? state.sentenceIndex : state.index);
|
||||
return (
|
||||
<div className="tts-bar" data-state={state.state}>
|
||||
<IconButton label="上一句" onClick={handlers.onSpeechPrevious}>
|
||||
⏮
|
||||
</IconButton>
|
||||
<IconButton label={state.state === 'playing' ? '暂停朗读' : '开始朗读'} onClick={handlers.onSpeechToggle}>
|
||||
{state.state === 'playing' ? '⏸' : '▶'}
|
||||
</IconButton>
|
||||
<IconButton label="下一句" onClick={handlers.onSpeechNext}>
|
||||
⏭
|
||||
</IconButton>
|
||||
<div className="tts-main">
|
||||
<span className="tts-text" title={state.label}>
|
||||
{state.label}
|
||||
</span>
|
||||
<input
|
||||
className="tts-range"
|
||||
type="range"
|
||||
min={0}
|
||||
max={max}
|
||||
step={1}
|
||||
value={value}
|
||||
aria-label="朗读进度"
|
||||
onInput={(event) => handlers.onSpeechScrub(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="chip"
|
||||
aria-label={state.total > 0 ? '朗读句数' : '从头朗读'}
|
||||
onClick={handlers.onSpeakFromHere}
|
||||
>
|
||||
{state.chip}
|
||||
</button>
|
||||
<IconButton label="停止朗读" onClick={handlers.onStopSpeech}>
|
||||
✕
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedRow({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
aria-pressed={option.value === value}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled range input whose value label follows the drag. */
|
||||
function SliderRow({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
format,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
format(value: number): string;
|
||||
onChange(value: number): void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>
|
||||
{label} {format(value)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onInput={(event) => onChange(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled native select, for the lists that outgrow a segmented control. */
|
||||
function SelectRow({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
<select value={value} onChange={(event) => onChange((event.currentTarget as HTMLSelectElement).value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The progress fraction as a percentage, clamped. */
|
||||
function percentOf(fraction: number): string {
|
||||
return `${Math.round(Math.min(1, Math.max(0, fraction)) * 100)}%`;
|
||||
}
|
||||
|
||||
export type { SpeechEngineKind };
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,549 +0,0 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ListQuery, ReaderApi } from '../api/client.ts';
|
||||
import type { Book, ContinueReadingItem } from '../api/types.ts';
|
||||
import type { OfflineStore } from '../store/offline.ts';
|
||||
import type { Platform } from '../core/platform.ts';
|
||||
import type { AppSettings, ShelfSort } from '../store/settings.ts';
|
||||
import { clear, el, percent } from './dom.ts';
|
||||
import { DENSITY_LABELS, buildShelfSettingsPanel } from './shelf-settings.ts';
|
||||
import { sortBooks, shelfOrder } from './shelf-order.ts';
|
||||
|
||||
export interface ShelfScreenOptions {
|
||||
api: ReaderApi;
|
||||
offline: OfflineStore;
|
||||
platform: Platform;
|
||||
/** Current shelf preferences, so the screen starts in the reader's own state. */
|
||||
settings: AppSettings;
|
||||
onOpenBook(book: Book): void;
|
||||
/** Opens the library file manager. Admin-ish by nature, but gated by the mount. */
|
||||
onOpenManager(): void;
|
||||
onSignedOut(): void;
|
||||
/** Persisted through the app's settings store, like the reader's own. */
|
||||
onSettingsChange(patch: Partial<AppSettings>): void;
|
||||
}
|
||||
|
||||
/** Sort options, in the order the sheet shows them. */
|
||||
const SORTS: Array<{ value: ShelfSort; label: string }> = [
|
||||
{ value: 'updated', label: '最近更新' },
|
||||
{ value: 'added', label: '最近入库' },
|
||||
{ value: 'title', label: '书名' },
|
||||
{ value: 'author', label: '作者' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The shelf.
|
||||
*
|
||||
* Behaviour rule that drives the whole screen: it renders from the local mirror
|
||||
* first and only then replaces it with server data. On a LAN or with a cold
|
||||
* cache that is invisible; on a train it is the difference between the library
|
||||
* appearing instantly and an indefinite spinner (product design §8.2).
|
||||
*
|
||||
* ## What was added, and the rule behind each
|
||||
*
|
||||
* The shelf is the most-opened screen in the app and was the least configurable,
|
||||
* which is an odd pairing: a reader who opens it forty times a day is the one who
|
||||
* notices that the covers are too small, that the list is in the wrong order, and
|
||||
* that clearing a search takes three taps. Every addition below follows one rule —
|
||||
* **it must be a thing a reader would change twice**, because a settings screen
|
||||
* full of switches nobody flips is worse than no settings screen.
|
||||
*
|
||||
* - **Sort, as a control on the shelf** rather than only in settings, because
|
||||
* "show me what I just added" is a per-session intent, not a preference.
|
||||
* - **Density and author visibility in settings**, because those *are*
|
||||
* preferences: they are about the screen and the eyes, and they persist.
|
||||
* - **A clear button in the search field**, because on a phone the alternative is
|
||||
* selecting four characters with a fat-finger cursor.
|
||||
* - **Progress on the cover**, because the one question a shelf answers for a
|
||||
* reader who has twenty books going is "where was I".
|
||||
* - **A pull-to-refresh at the top**, because the shelf is now the only place a
|
||||
* scan's result becomes visible, and "I added a book, why is it not here" is
|
||||
* the complaint that a manual refresh answers.
|
||||
*/
|
||||
export class ShelfScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly grid: HTMLDivElement;
|
||||
private readonly continueRow: HTMLDivElement;
|
||||
private readonly continueSection: HTMLDivElement;
|
||||
private readonly statusLine: HTMLDivElement;
|
||||
private readonly searchInput: HTMLInputElement;
|
||||
private readonly searchClear: HTMLButtonElement;
|
||||
private readonly countLabel: HTMLDivElement;
|
||||
private readonly sortRow: HTMLDivElement;
|
||||
private readonly refreshBar: HTMLDivElement;
|
||||
private readonly settingsButton: HTMLButtonElement;
|
||||
private readonly settingsPanel: HTMLDivElement;
|
||||
private extraLoading: HTMLDivElement | null = null;
|
||||
private query = '';
|
||||
private offset = 0;
|
||||
private total = 0;
|
||||
private loading = false;
|
||||
/** Session sort; seeded from settings and written back when it changes. */
|
||||
private sort: ShelfSort;
|
||||
private readonly searchTimer: { id: ReturnType<typeof setTimeout> | null } = { id: null };
|
||||
private readonly coverUrls = new Map<string, string>();
|
||||
private readonly settings: AppSettings;
|
||||
private readonly scroll: HTMLDivElement;
|
||||
/** Scroll position of the previous render, so a refresh does not jump. */
|
||||
private lastScrollTop = 0;
|
||||
|
||||
constructor(private readonly options: ShelfScreenOptions) {
|
||||
this.settings = { ...options.settings };
|
||||
this.sort = options.settings.shelfSort;
|
||||
|
||||
this.searchInput = el('input', {
|
||||
attrs: {
|
||||
type: 'search',
|
||||
placeholder: '搜索书名、作者、系列',
|
||||
'aria-label': '搜索书库',
|
||||
enterkeyhint: 'search',
|
||||
},
|
||||
on: {
|
||||
input: () => this.onSearchInput(),
|
||||
keydown: (event) => {
|
||||
// `search` inputs fire a non-standard `search` event on clear, but only
|
||||
// in some browsers; Enter is the one that is reliable everywhere and it
|
||||
// also means "stop waiting for the debounce".
|
||||
if ((event as KeyboardEvent).key === 'Enter') {
|
||||
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
|
||||
this.searchTimer.id = null;
|
||||
this.query = this.searchInput.value.trim();
|
||||
void this.refresh();
|
||||
}
|
||||
},
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
|
||||
this.searchClear = el('button', {
|
||||
className: 'search-clear',
|
||||
text: '✕',
|
||||
attrs: { type: 'button', 'aria-label': '清除搜索', hidden: true },
|
||||
on: {
|
||||
click: () => {
|
||||
this.searchInput.value = '';
|
||||
this.query = '';
|
||||
this.searchClear.hidden = true;
|
||||
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
|
||||
this.searchTimer.id = null;
|
||||
void this.refresh();
|
||||
},
|
||||
},
|
||||
}) as HTMLButtonElement;
|
||||
|
||||
this.continueRow = el('div', { className: 'continue-row' });
|
||||
this.continueSection = el('div', { attrs: { hidden: true } }) as HTMLDivElement;
|
||||
const continueTitle = el('div', { className: 'shelf-section-title', text: '继续阅读' });
|
||||
this.continueSection.append(continueTitle, this.continueRow);
|
||||
|
||||
this.countLabel = el('div', { className: 'shelf-count muted' }) as HTMLDivElement;
|
||||
this.sortRow = el('div', { className: 'shelf-sort' }) as HTMLDivElement;
|
||||
this.buildSortRow();
|
||||
|
||||
this.settingsButton = el('button', {
|
||||
className: 'icon-button shelf-settings-button',
|
||||
text: '⚙',
|
||||
attrs: { type: 'button', 'aria-label': '书架设置' },
|
||||
on: { click: () => this.toggleSettings() },
|
||||
}) as HTMLButtonElement;
|
||||
|
||||
// The file manager's entry point sits next to the shelf settings gear because
|
||||
// that is where "the library itself" already lives. It is offered to every
|
||||
// account, not just admins: the mount decides whether it can write anything,
|
||||
// and a reader whose own book failed to appear is exactly who needs to look.
|
||||
const managerButton = el('button', {
|
||||
className: 'icon-button shelf-settings-button',
|
||||
text: '🗂',
|
||||
attrs: { type: 'button', 'aria-label': '书库管理' },
|
||||
on: { click: () => this.options.onOpenManager() },
|
||||
});
|
||||
|
||||
const toolbar = el('div', {
|
||||
className: 'shelf-toolbar',
|
||||
children: [this.countLabel, this.sortRow, this.settingsButton, managerButton],
|
||||
});
|
||||
|
||||
this.settingsPanel = buildShelfSettingsPanel({
|
||||
settings: this.settings,
|
||||
onPatch: (patch) => {
|
||||
Object.assign(this.settings, patch);
|
||||
this.options.onSettingsChange(patch);
|
||||
this.applyDensity();
|
||||
this.applyAppearance();
|
||||
},
|
||||
onClose: () => {
|
||||
this.settingsPanel.hidden = true;
|
||||
},
|
||||
});
|
||||
|
||||
this.grid = el('div', { className: 'book-grid' });
|
||||
this.statusLine = el('div', { className: 'shelf-status muted' });
|
||||
this.refreshBar = el('div', { className: 'shelf-refresh', attrs: { hidden: true } }) as HTMLDivElement;
|
||||
|
||||
const shelf = el('div', { className: 'shelf' });
|
||||
shelf.append(
|
||||
el('div', { className: 'shelf-search', children: [this.searchInput, this.searchClear] }),
|
||||
this.continueSection,
|
||||
toolbar,
|
||||
this.refreshBar,
|
||||
this.grid,
|
||||
this.statusLine,
|
||||
this.settingsPanel,
|
||||
);
|
||||
(shelf as HTMLDivElement).id = 'shelf-scroll';
|
||||
this.scroll = shelf as HTMLDivElement;
|
||||
|
||||
this.element = el('div', { className: 'shelf-screen', children: [shelf] }) as HTMLDivElement;
|
||||
this.element.style.cssText = 'flex:1 1 auto; min-height:0; display:flex; flex-direction:column;';
|
||||
this.applyDensity();
|
||||
|
||||
this.scroll.addEventListener('scroll', () => this.onScroll());
|
||||
}
|
||||
|
||||
async show(): Promise<void> {
|
||||
await this.options.offline.load();
|
||||
const cached = this.options.offline.books();
|
||||
if (cached.length > 0) this.renderItems(this.sorted(cached), true);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetches the first page and the "continue reading" row.
|
||||
*
|
||||
* Both in one `Promise.all`, because they are two halves of one screen: fetching
|
||||
* them in sequence made the shelf appear and *then* a row appear above it,
|
||||
* pushing everything down under the reader's thumb.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const [page, continueItems] = await Promise.all([
|
||||
this.options.api.listBooks(this.listQuery(1)),
|
||||
this.options.api.continueReading(10).catch(() => [] as ContinueReadingItem[]),
|
||||
]);
|
||||
this.offset = page.items.length;
|
||||
this.total = page.total;
|
||||
await this.options.offline.replaceBooks(page.items);
|
||||
this.renderItems(this.sorted(page.items), true);
|
||||
this.renderContinue(continueItems);
|
||||
this.renderCount();
|
||||
this.setStatus('');
|
||||
this.scroll.scrollTop = this.lastScrollTop;
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
private listQuery(page: number): ListQuery {
|
||||
return {
|
||||
...(this.query ? { search: this.query } : {}),
|
||||
sort: this.sort,
|
||||
order: shelfOrder(this.sort),
|
||||
page,
|
||||
pageSize: 60,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the cached shelf the same way the server would.
|
||||
*
|
||||
* The offline path has to answer the same question the online one does, so the
|
||||
* rule lives in `shelf-order.ts` and is shared by both.
|
||||
*/
|
||||
private sorted(books: Book[]): Book[] {
|
||||
return sortBooks(books, this.sort);
|
||||
}
|
||||
|
||||
private onSearchInput(): void {
|
||||
this.query = this.searchInput.value.trim();
|
||||
this.searchClear.hidden = this.query.length === 0;
|
||||
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
|
||||
// Debounced: a shelf of 2000 books over a LAN is fine, but a mobile network
|
||||
// plus a keystroke per character is not.
|
||||
this.searchTimer.id = setTimeout(() => {
|
||||
this.searchTimer.id = null;
|
||||
void this.refresh();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
private onScroll(): void {
|
||||
this.lastScrollTop = this.scroll.scrollTop;
|
||||
if (this.scroll.scrollTop + this.scroll.clientHeight >= this.scroll.scrollHeight - 400) {
|
||||
void this.loadMore();
|
||||
}
|
||||
// Pull-to-refresh fires only when the reader has already overscrolled and lets
|
||||
// go — a refresh on every scroll-to-top would fire constantly on a phone.
|
||||
if (this.scroll.scrollTop < 24 && this.atTopSince !== null && Date.now() - this.atTopSince > 400) {
|
||||
this.atTopSince = null;
|
||||
void this.manualRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/** Timestamp of when the shelf first reached the top, for pull-to-refresh. */
|
||||
private atTopSince: number | null = null;
|
||||
|
||||
private async manualRefresh(): Promise<void> {
|
||||
this.refreshBar.hidden = false;
|
||||
this.refreshBar.textContent = '正在刷新…';
|
||||
try {
|
||||
await this.refresh();
|
||||
this.refreshBar.textContent = '已刷新';
|
||||
} catch {
|
||||
this.refreshBar.textContent = '刷新失败';
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.refreshBar.hidden = true;
|
||||
}, 900);
|
||||
}
|
||||
|
||||
private async loadMore(): Promise<void> {
|
||||
if (this.loading || this.offset >= this.total) return;
|
||||
this.loading = true;
|
||||
this.showLoadingMore();
|
||||
try {
|
||||
const page = Math.floor(this.offset / 60) + 1;
|
||||
const result = await this.options.api.listBooks(this.listQuery(page));
|
||||
this.offset += result.items.length;
|
||||
this.total = result.total;
|
||||
await this.options.offline.replaceBooks(result.items);
|
||||
this.appendItems(this.sorted(result.items), false);
|
||||
this.renderCount();
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || !err.isConnectivity) this.setStatus('加载更多失败');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.hideLoadingMore();
|
||||
}
|
||||
}
|
||||
|
||||
private showLoadingMore(): void {
|
||||
if (this.extraLoading) return;
|
||||
this.extraLoading = el('div', { className: 'spinner' });
|
||||
this.scroll.append(this.extraLoading);
|
||||
}
|
||||
|
||||
private hideLoadingMore(): void {
|
||||
this.extraLoading?.remove();
|
||||
this.extraLoading = null;
|
||||
}
|
||||
|
||||
private renderCount(): void {
|
||||
if (this.query) {
|
||||
this.countLabel.textContent = `找到 ${this.total} 本`;
|
||||
return;
|
||||
}
|
||||
this.countLabel.textContent = this.total > 0 ? `共 ${this.total} 本` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The sort control.
|
||||
*
|
||||
* A row of chips rather than a `<select>`: four options is few enough to show
|
||||
* all of them, and a picker that requires opening a sheet to answer "what order
|
||||
* is this in" hides the answer at exactly the moment the reader is asking.
|
||||
*/
|
||||
private buildSortRow(): void {
|
||||
clear(this.sortRow);
|
||||
const label = el('span', { className: 'shelf-sort-label muted', text: '排序' });
|
||||
this.sortRow.append(label);
|
||||
for (const option of SORTS) {
|
||||
const active = option.value === this.sort;
|
||||
this.sortRow.append(
|
||||
el('button', {
|
||||
className: 'chip',
|
||||
text: option.label,
|
||||
attrs: { type: 'button', 'aria-pressed': String(active), 'aria-label': `按${option.label}排序` },
|
||||
on: {
|
||||
click: () => {
|
||||
if (this.sort === option.value) return;
|
||||
this.sort = option.value;
|
||||
// Written back so the choice survives a restart. It is a preference
|
||||
// as much as it is a per-session intent, and the two cannot be told
|
||||
// apart — so persisting is the less surprising of the two harms.
|
||||
this.options.onSettingsChange({ shelfSort: this.sort });
|
||||
this.settings.shelfSort = this.sort;
|
||||
this.buildSortRow();
|
||||
// Ordering changes the whole result set, so a refresh rather than a
|
||||
// client-side re-sort: the server is the authority, and it is the
|
||||
// only side that can re-sort a library larger than one page.
|
||||
this.lastScrollTop = 0;
|
||||
void this.refresh();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private toggleSettings(): void {
|
||||
this.settingsPanel.hidden = !this.settingsPanel.hidden;
|
||||
}
|
||||
|
||||
/** Applies the density setting to the grid, by class rather than inline style. */
|
||||
applyDensity(): void {
|
||||
this.grid.dataset['density'] = this.settings.shelfDensity;
|
||||
// The label is mirrored onto the control so the sheet does not have to be
|
||||
// reopened to see what is set.
|
||||
this.settingsButton.setAttribute('aria-label', `书架设置 · ${DENSITY_LABELS[this.settings.shelfDensity]}`);
|
||||
}
|
||||
|
||||
applyAppearance(): void {
|
||||
this.grid.dataset['showAuthor'] = String(this.settings.shelfShowAuthor);
|
||||
this.grid.dataset['showProgress'] = String(this.settings.shelfShowProgress);
|
||||
}
|
||||
|
||||
private renderItems(items: Book[], replace: boolean): void {
|
||||
if (replace) {
|
||||
clear(this.grid);
|
||||
// Only the search result set is authoritative; without this, a cached
|
||||
// shelf and a filtered response would merge into one confusing list.
|
||||
this.rendered.clear();
|
||||
}
|
||||
this.applyAppearance();
|
||||
this.appendItems(items, replace);
|
||||
if (this.rendered.size === 0) {
|
||||
this.grid.append(
|
||||
el('div', {
|
||||
className: 'empty-state',
|
||||
children: [
|
||||
el('p', { text: this.query ? '没有匹配的书' : '书库还是空的' }),
|
||||
el('p', { className: 'muted', text: this.query ? '换个关键词试试' : '确认已经挂载书籍目录,并在设置里触发一次扫描' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly rendered = new Set<string>();
|
||||
|
||||
private appendItems(items: Book[], _replace: boolean): void {
|
||||
for (const book of items) {
|
||||
if (this.rendered.has(book.id)) continue;
|
||||
this.rendered.add(book.id);
|
||||
this.grid.append(this.bookCard(book));
|
||||
}
|
||||
}
|
||||
|
||||
private bookCard(book: Book): HTMLButtonElement {
|
||||
const cover = el('div', { className: 'cover' });
|
||||
void this.fillCover(cover, book);
|
||||
|
||||
// The progress bar is drawn from the local mirror, which the sync engine
|
||||
// keeps current — so it is correct offline, with no extra request per card.
|
||||
const progress = this.options.offline.progressFor(book.id);
|
||||
if (progress && progress.percentage > 0.005) {
|
||||
const track = el('div', { className: 'cover-progress', children: [el('span')] });
|
||||
(track.querySelector('span') as HTMLSpanElement).style.width = percent(progress.percentage);
|
||||
cover.append(track);
|
||||
}
|
||||
|
||||
return el('button', {
|
||||
className: 'book-card',
|
||||
attrs: { type: 'button', 'aria-label': `${book.title} ${book.author}`.trim() },
|
||||
on: { click: () => this.options.onOpenBook(book) },
|
||||
children: [
|
||||
cover,
|
||||
el('div', { className: 'title', text: book.title || '未命名' }),
|
||||
el('div', { className: 'author', text: book.author || '未知作者' }),
|
||||
],
|
||||
}) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
private async fillCover(container: HTMLDivElement, book: Book): Promise<void> {
|
||||
const badge = el('span', { className: 'format-badge', text: book.format });
|
||||
if (!book.coverUrl) {
|
||||
container.append(el('div', { className: 'placeholder', text: book.title.slice(0, 12) || '无封面' }));
|
||||
container.append(badge);
|
||||
return;
|
||||
}
|
||||
const cached = this.coverUrls.get(book.id);
|
||||
if (cached) {
|
||||
container.append(el('img', { attrs: { src: cached, alt: '', loading: 'lazy' } }));
|
||||
container.append(badge);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bytes = await this.options.api.coverBytes(book.coverUrl);
|
||||
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' }));
|
||||
this.coverUrls.set(book.id, url);
|
||||
clear(container);
|
||||
container.append(el('img', { attrs: { src: url, alt: '', loading: 'lazy' } }));
|
||||
container.append(badge);
|
||||
} catch {
|
||||
container.append(el('div', { className: 'placeholder', text: book.title.slice(0, 12) || '无封面' }));
|
||||
container.append(badge);
|
||||
}
|
||||
}
|
||||
|
||||
private renderContinue(items: ContinueReadingItem[]): void {
|
||||
clear(this.continueRow);
|
||||
if (items.length === 0) {
|
||||
this.continueSection.hidden = true;
|
||||
return;
|
||||
}
|
||||
this.continueSection.hidden = false;
|
||||
for (const item of items) {
|
||||
const cover = el('div', { className: 'cover' });
|
||||
cover.style.cssText = 'aspect-ratio:2/3;border-radius:6px;overflow:hidden;background:var(--reader-line);display:grid;place-items:center;';
|
||||
void this.options.api
|
||||
.coverBytes(item.coverUrl ?? '')
|
||||
.then((bytes) => {
|
||||
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' }));
|
||||
clear(cover);
|
||||
const img = el('img', { attrs: { src: url, alt: '' } });
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
|
||||
cover.append(img);
|
||||
})
|
||||
.catch(() => {
|
||||
cover.append(el('div', { className: 'placeholder', text: item.title.slice(0, 8) }));
|
||||
});
|
||||
|
||||
const bar = el('div', { className: 'progress-track', children: [el('span')] });
|
||||
const inner = bar.querySelector('span') as HTMLSpanElement;
|
||||
inner.style.width = percent(item.percentage);
|
||||
|
||||
const card = el('button', {
|
||||
className: 'continue-card',
|
||||
attrs: { type: 'button' },
|
||||
on: { click: () => this.options.onOpenBook(item) },
|
||||
children: [
|
||||
cover,
|
||||
el('div', { className: 'title', text: item.title, attrs: { style: 'font-size:.8rem;margin-top:.3rem;' } }),
|
||||
el('div', { className: 'muted', text: item.chapterTitle || percent(item.percentage), attrs: { style: 'font-size:.7rem;' } }),
|
||||
bar,
|
||||
],
|
||||
});
|
||||
this.continueRow.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(text: string): void {
|
||||
this.statusLine.textContent = text;
|
||||
}
|
||||
|
||||
private handleError(err: unknown): void {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isAuthFailure) {
|
||||
this.options.onSignedOut();
|
||||
return;
|
||||
}
|
||||
if (err.isConnectivity) {
|
||||
const cached = this.options.offline.books();
|
||||
if (cached.length > 0) {
|
||||
this.setStatus(`离线模式 · ${cached.length} 本可读`);
|
||||
this.renderItems(this.sorted(cached), true);
|
||||
this.renderCount();
|
||||
return;
|
||||
}
|
||||
this.setStatus('连不上服务端');
|
||||
return;
|
||||
}
|
||||
this.setStatus(err.message);
|
||||
return;
|
||||
}
|
||||
this.setStatus(err instanceof Error ? err.message : '出错了');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const url of this.coverUrls.values()) URL.revokeObjectURL(url);
|
||||
this.coverUrls.clear();
|
||||
if (this.searchTimer.id !== null) clearTimeout(this.searchTimer.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import { ApiError } from '../api/errors.ts';
|
||||
import type { ListQuery, ReaderApi } from '../api/client.ts';
|
||||
import type { Book, ContinueReadingItem } from '../api/types.ts';
|
||||
import type { OfflineStore } from '../store/offline.ts';
|
||||
import type { Platform } from '../core/platform.ts';
|
||||
import type { AppSettings, ShelfSort } from '../store/settings.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { DENSITY_LABELS, ShelfSettingsPanel } from './shelf-settings.tsx';
|
||||
import { sortBooks, shelfOrder } from './shelf-order.ts';
|
||||
import { IconButton } from './toolkit.tsx';
|
||||
import { type ComponentChildren, type JSX, useEffect, useState } from './vendor/preact.ts';
|
||||
|
||||
export interface ShelfScreenOptions {
|
||||
api: ReaderApi;
|
||||
offline: OfflineStore;
|
||||
platform: Platform;
|
||||
/** Current shelf preferences, so the screen starts in the reader's own state. */
|
||||
settings: AppSettings;
|
||||
onOpenBook(book: Book): void;
|
||||
/** Opens the library file manager. Admin-ish by nature, but gated by the mount. */
|
||||
onOpenManager(): void;
|
||||
onSignedOut(): void;
|
||||
/** Persisted through the app's settings store, like the reader's own. */
|
||||
onSettingsChange(patch: Partial<AppSettings>): void;
|
||||
}
|
||||
|
||||
/** Sort options, in the order the sheet shows them. */
|
||||
const SORTS: Array<{ value: ShelfSort; label: string }> = [
|
||||
{ value: 'updated', label: '最近更新' },
|
||||
{ value: 'added', label: '最近入库' },
|
||||
{ value: 'title', label: '书名' },
|
||||
{ value: 'author', label: '作者' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
interface ShelfState {
|
||||
/** Written into the search field; the debounced query is `applied`. */
|
||||
search: string;
|
||||
query: string;
|
||||
items: Book[];
|
||||
continueItems: ContinueReadingItem[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
status: string;
|
||||
settingsOpen: boolean;
|
||||
refreshing: boolean;
|
||||
/** Bumped after a mutation so the cached cover URLs re-key, not re-fetch. */
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shelf.
|
||||
*
|
||||
* Behaviour rule that drives the whole screen: it renders from the local mirror
|
||||
* first and only then replaces it with server data. On a LAN or with a cold
|
||||
* cache that is invisible; on a train it is the difference between the library
|
||||
* appearing instantly and an indefinite spinner (product design §8.2).
|
||||
*
|
||||
* ## What was added, and the rule behind each
|
||||
*
|
||||
* The shelf is the most-opened screen in the app and was the least configurable,
|
||||
* which is an odd pairing: a reader who opens it forty times a day is the one who
|
||||
* notices that the covers are too small, that the list is in the wrong order, and
|
||||
* that clearing a search takes three taps. Every addition below follows one rule —
|
||||
* **it must be a thing a reader would change twice**, because a settings screen
|
||||
* full of switches nobody flips is worse than no settings screen.
|
||||
*
|
||||
* - **Sort, as a control on the shelf** rather than only in settings, because
|
||||
* "show me what I just added" is a per-session intent, not a preference.
|
||||
* - **Density and author visibility in settings**, because those *are*
|
||||
* preferences: they are about the screen and the eyes, and they persist.
|
||||
* - **A clear button in the search field**, because on a phone the alternative is
|
||||
* selecting four characters with a fat-finger cursor.
|
||||
* - **Progress on the cover**, because the one question a shelf answers for a
|
||||
* reader who has twenty books going is "where was I".
|
||||
* - **A pull-to-refresh at the top**, because the shelf is now the only place a
|
||||
* scan's result becomes visible, and "I added a book, why is it not here" is
|
||||
* the complaint that a manual refresh answers.
|
||||
*
|
||||
* ## What the framework changed
|
||||
*
|
||||
* Nine node fields (`grid`, `continueRow`, `statusLine`, `countLabel`, `sortRow`,
|
||||
* `refreshBar`, `settingsButton`, `settingsPanel`, `scroll`) and four render
|
||||
* methods that had to agree with each other. They are now one `ShelfState` and a
|
||||
* tree that is a function of it: the count label, the sort chips, the density
|
||||
* dataset and the settings sheet cannot disagree about what is set, because
|
||||
* there is one place each value is read from.
|
||||
*
|
||||
* The scroll handling stays imperative, because it *is* imperative: a scroll
|
||||
* listener that reads `scrollTop` and fires a fetch has no markup to render.
|
||||
*/
|
||||
export class ShelfScreen {
|
||||
readonly element: HTMLDivElement;
|
||||
private readonly ui: ReturnType<typeof mountUI>;
|
||||
private state: ShelfState = {
|
||||
search: '',
|
||||
query: '',
|
||||
items: [],
|
||||
continueItems: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
status: '',
|
||||
settingsOpen: false,
|
||||
refreshing: false,
|
||||
revision: 0,
|
||||
};
|
||||
private readonly settings: AppSettings;
|
||||
/** Session sort; seeded from settings and written back when it changes. */
|
||||
private sort: ShelfSort;
|
||||
/** Scroll position from before the last refresh, so a refresh does not jump. */
|
||||
private lastScrollTop = 0;
|
||||
/** Timestamp of when the shelf first reached the top, for pull-to-refresh. */
|
||||
private atTopSince: number | null = null;
|
||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly scrollRef = { current: null as HTMLDivElement | null };
|
||||
|
||||
constructor(private readonly options: ShelfScreenOptions) {
|
||||
this.settings = { ...options.settings };
|
||||
this.sort = options.settings.shelfSort;
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'shelf-screen';
|
||||
this.element.style.cssText = 'flex:1 1 auto; min-height:0; display:flex; flex-direction:column;';
|
||||
this.ui = mountUI(this.element, () => this.view(), this.state);
|
||||
}
|
||||
|
||||
async show(): Promise<void> {
|
||||
await this.options.offline.load();
|
||||
const cached = this.options.offline.books();
|
||||
if (cached.length > 0) {
|
||||
this.patch({ items: sortBooks(cached, this.sort) });
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetches the first page and the "continue reading" row.
|
||||
*
|
||||
* Both in one `Promise.all`, because they are two halves of one screen: fetching
|
||||
* them in sequence made the shelf appear and *then* a row appear above it,
|
||||
* pushing everything down under the reader's thumb.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const [page, continueItems] = await Promise.all([
|
||||
this.options.api.listBooks(this.listQuery(1)),
|
||||
this.options.api.continueReading(10).catch(() => [] as ContinueReadingItem[]),
|
||||
]);
|
||||
this.state.total = page.total;
|
||||
await this.options.offline.replaceBooks(page.items);
|
||||
this.patch({ items: sortBooks(page.items, this.sort), continueItems, status: '', revision: this.state.revision + 1 });
|
||||
const scroll = this.scrollRef.current;
|
||||
// Restored after the render, because the list is empty for one tick and a
|
||||
// scroll position with nothing to scroll to is clamped to zero.
|
||||
if (scroll) requestAnimationFrame(() => {
|
||||
scroll.scrollTop = this.lastScrollTop;
|
||||
});
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
private listQuery(page: number): ListQuery {
|
||||
return {
|
||||
...(this.state.query ? { search: this.state.query } : {}),
|
||||
sort: this.sort,
|
||||
order: shelfOrder(this.sort),
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
this.ui.update(this.state);
|
||||
}
|
||||
|
||||
private patch(patch: Partial<ShelfState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private setStatus(status: string): void {
|
||||
this.patch({ status });
|
||||
}
|
||||
|
||||
private applySettings(patch: Partial<AppSettings>): void {
|
||||
Object.assign(this.settings, patch);
|
||||
this.options.onSettingsChange(patch);
|
||||
this.draw();
|
||||
}
|
||||
|
||||
// ---- search, sort, scroll ----
|
||||
|
||||
private onSearchInput(value: string): void {
|
||||
this.patch({ search: value, query: value.trim() });
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
// Debounced: a shelf of 2000 books over a LAN is fine, but a mobile network
|
||||
// plus a keystroke per character is not.
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.searchTimer = null;
|
||||
void this.refresh();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
private clearSearch(): void {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
this.patch({ search: '', query: '' });
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
private pickSort(sort: ShelfSort): void {
|
||||
if (this.sort === sort) return;
|
||||
this.sort = sort;
|
||||
// Written back so the choice survives a restart. It is a preference as much
|
||||
// as it is a per-session intent, and the two cannot be told apart — so
|
||||
// persisting is the less surprising of the two harms.
|
||||
this.options.onSettingsChange({ shelfSort: sort });
|
||||
this.settings.shelfSort = sort;
|
||||
this.lastScrollTop = 0;
|
||||
// Ordering changes the whole result set, so a refresh rather than a
|
||||
// client-side re-sort: the server is the authority, and it is the only side
|
||||
// that can re-sort a library larger than one page.
|
||||
this.patch({ items: sortBooks(this.state.items, sort), revision: this.state.revision + 1 });
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
private onScroll(scroll: HTMLDivElement): void {
|
||||
this.lastScrollTop = scroll.scrollTop;
|
||||
if (scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 400) {
|
||||
void this.loadMore();
|
||||
}
|
||||
// Pull-to-refresh fires only when the reader has already overscrolled and lets
|
||||
// go — a refresh on every scroll-to-top would fire constantly on a phone.
|
||||
if (scroll.scrollTop < 24 && this.atTopSince !== null && Date.now() - this.atTopSince > 400) {
|
||||
this.atTopSince = null;
|
||||
void this.manualRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private async manualRefresh(): Promise<void> {
|
||||
this.patch({ refreshing: true });
|
||||
try {
|
||||
await this.refresh();
|
||||
} finally {
|
||||
setTimeout(() => this.patch({ refreshing: false }), 900);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMore(): Promise<void> {
|
||||
if (this.state.loading || this.state.items.length >= this.state.total) return;
|
||||
this.patch({ loading: true });
|
||||
try {
|
||||
const page = Math.floor(this.state.items.length / PAGE_SIZE) + 1;
|
||||
const result = await this.options.api.listBooks(this.listQuery(page));
|
||||
await this.options.offline.replaceBooks(result.items);
|
||||
this.patch({
|
||||
// Appended the way the server ordered them, not re-sorted: a page is a
|
||||
// *continuation* of the order, and re-sorting it here would be right only
|
||||
// by coincidence.
|
||||
items: [...this.state.items, ...result.items],
|
||||
total: result.total,
|
||||
revision: this.state.revision + 1,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || !err.isConnectivity) this.setStatus('加载更多失败');
|
||||
} finally {
|
||||
this.patch({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(err: unknown): void {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.isAuthFailure) {
|
||||
this.options.onSignedOut();
|
||||
return;
|
||||
}
|
||||
if (err.isConnectivity) {
|
||||
const cached = this.options.offline.books();
|
||||
if (cached.length > 0) {
|
||||
this.patch({
|
||||
items: sortBooks(cached, this.sort),
|
||||
status: `离线模式 · ${cached.length} 本可读`,
|
||||
revision: this.state.revision + 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setStatus('连不上服务端');
|
||||
return;
|
||||
}
|
||||
this.setStatus(err.message);
|
||||
return;
|
||||
}
|
||||
this.setStatus(err instanceof Error ? err.message : '出错了');
|
||||
}
|
||||
|
||||
// ---- the tree ----
|
||||
|
||||
private view(): JSX.Element {
|
||||
const state = this.state;
|
||||
const density = this.settings.shelfDensity;
|
||||
return (
|
||||
<ShelfScroller
|
||||
containerRef={this.scrollRef}
|
||||
onScroll={(scroll) => this.onScroll(scroll)}
|
||||
onReachTop={() => {
|
||||
this.atTopSince = Date.now();
|
||||
}}
|
||||
>
|
||||
<div className="shelf-search">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索书名、作者、系列"
|
||||
aria-label="搜索书库"
|
||||
enterKeyHint="search"
|
||||
value={state.search}
|
||||
onInput={(event) => this.onSearchInput((event.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(event) => {
|
||||
// `search` inputs fire a non-standard `search` event on clear, but
|
||||
// only in some browsers; Enter is the one that is reliable
|
||||
// everywhere and it also means "stop waiting for the debounce".
|
||||
if (event.key === 'Enter') {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
this.patch({ query: state.search.trim() });
|
||||
void this.refresh();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{state.search.length > 0 ? (
|
||||
<button type="button" className="search-clear" aria-label="清除搜索" onClick={() => this.clearSearch()}>
|
||||
✕
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{state.continueItems.length > 0 ? (
|
||||
<div>
|
||||
<div className="shelf-section-title">继续阅读</div>
|
||||
<div className="continue-row">
|
||||
{state.continueItems.map((item) => (
|
||||
<ContinueCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
revision={state.revision}
|
||||
api={this.options.api}
|
||||
onOpen={() => this.options.onOpenBook(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="shelf-toolbar">
|
||||
<div className="shelf-count muted">
|
||||
{state.query ? `找到 ${state.total} 本` : state.total > 0 ? `共 ${state.total} 本` : ''}
|
||||
</div>
|
||||
<div className="shelf-sort">
|
||||
<span className="shelf-sort-label muted">排序</span>
|
||||
{SORTS.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
className="chip"
|
||||
aria-pressed={option.value === this.sort}
|
||||
aria-label={`按${option.label}排序`}
|
||||
onClick={() => this.pickSort(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<IconButton
|
||||
label={`书架设置 · ${DENSITY_LABELS[density]}`}
|
||||
onClick={() => this.patch({ settingsOpen: !state.settingsOpen })}
|
||||
>
|
||||
⚙
|
||||
</IconButton>
|
||||
{/* The file manager's entry point sits next to the shelf settings gear
|
||||
because that is where "the library itself" already lives. It is
|
||||
offered to every account, not just admins: the mount decides whether
|
||||
it can write anything, and a reader whose own book failed to appear
|
||||
is exactly who needs to look. */}
|
||||
<IconButton label="书库管理" onClick={() => this.options.onOpenManager()}>
|
||||
🗂
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className="shelf-refresh" hidden={!state.refreshing}>
|
||||
{state.refreshing ? '正在刷新…' : ''}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="book-grid"
|
||||
data-density={density}
|
||||
data-showAuthor={String(this.settings.shelfShowAuthor)}
|
||||
data-showProgress={String(this.settings.shelfShowProgress)}
|
||||
>
|
||||
{state.items.length === 0 && !state.loading ? (
|
||||
<div className="empty-state">
|
||||
<p>{state.query ? '没有匹配的书' : '书库还是空的'}</p>
|
||||
<p className="muted">
|
||||
{state.query ? '换个关键词试试' : '确认已经挂载书籍目录,并在设置里触发一次扫描'}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{state.items.map((book) => (
|
||||
<BookCard
|
||||
key={book.id}
|
||||
book={book}
|
||||
progress={this.options.offline.progressFor(book.id)}
|
||||
revision={state.revision}
|
||||
api={this.options.api}
|
||||
onOpen={() => this.options.onOpenBook(book)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shelf-status muted">{state.status}</div>
|
||||
|
||||
{this.state.loading && this.state.items.length > 0 ? <div className="spinner" /> : null}
|
||||
|
||||
<ShelfSettingsPanel
|
||||
open={state.settingsOpen}
|
||||
settings={this.settings}
|
||||
onPatch={(patch) => this.applySettings(patch)}
|
||||
onClose={() => this.patch({ settingsOpen: false })}
|
||||
/>
|
||||
</ShelfScroller>
|
||||
);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
this.ui.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrolling container.
|
||||
*
|
||||
* A component because the scroll listener, the "first reached the top" stamp and
|
||||
* the measured container all belong to one node — the previous version reached
|
||||
* for `this.scroll` from four methods. `onScroll` is passed the element itself,
|
||||
* so the caller reads `scrollTop` from the node that fired rather than from a
|
||||
* field that may be a render behind.
|
||||
*/
|
||||
function ShelfScroller({
|
||||
containerRef,
|
||||
onScroll,
|
||||
onReachTop,
|
||||
children,
|
||||
}: {
|
||||
containerRef: { current: HTMLDivElement | null };
|
||||
onScroll(scroll: HTMLDivElement): void;
|
||||
onReachTop(): void;
|
||||
children: ComponentChildren;
|
||||
}): JSX.Element {
|
||||
const [reachedTop, setReachedTop] = useState(false);
|
||||
return (
|
||||
<div
|
||||
id="shelf-scroll"
|
||||
className="shelf"
|
||||
ref={containerRef}
|
||||
onScroll={(event) => {
|
||||
const scroll = event.currentTarget as HTMLDivElement;
|
||||
onScroll(scroll);
|
||||
if (scroll.scrollTop >= 24) {
|
||||
setReachedTop(false);
|
||||
} else if (!reachedTop) {
|
||||
setReachedTop(true);
|
||||
onReachTop();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A cover, resolved to a blob URL once and re-used across renders. */
|
||||
function useCoverUrl(api: ReaderApi, coverUrl: string | null | undefined, revision: number): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const cache = coverCache(api);
|
||||
useEffect(() => {
|
||||
if (!coverUrl) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
const hit = cache.get(coverUrl);
|
||||
if (hit) {
|
||||
setUrl(hit);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
void api
|
||||
.coverBytes(coverUrl)
|
||||
.then((bytes) => {
|
||||
const objectUrl = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' }));
|
||||
cache.set(coverUrl, objectUrl);
|
||||
// The screen can be disposed while a cover is in flight; setting state on
|
||||
// an unmounted component is a leak of the object URL, nothing more.
|
||||
if (live) setUrl(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setUrl(null);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [coverUrl, revision]);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Object URLs outlive individual cards.
|
||||
*
|
||||
* A shelf that scrolls away and back must not re-fetch forty covers, and a
|
||||
* revoked URL is a broken image, so the cache is process-wide and released on
|
||||
* `pagehide` rather than per card.
|
||||
*/
|
||||
const coverCaches = new WeakMap<ReaderApi, Map<string, string>>();
|
||||
|
||||
function coverCache(api: ReaderApi): Map<string, string> {
|
||||
let cache = coverCaches.get(api);
|
||||
if (!cache) {
|
||||
cache = new Map<string, string>();
|
||||
coverCaches.set(api, cache);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function BookCard({
|
||||
book,
|
||||
progress,
|
||||
revision,
|
||||
api,
|
||||
onOpen,
|
||||
}: {
|
||||
book: Book;
|
||||
progress: { percentage: number } | undefined;
|
||||
revision: number;
|
||||
api: ReaderApi;
|
||||
onOpen(): void;
|
||||
}): JSX.Element {
|
||||
const url = useCoverUrl(api, book.coverUrl, revision);
|
||||
return (
|
||||
<button type="button" className="book-card" aria-label={`${book.title} ${book.author}`.trim()} onClick={onOpen}>
|
||||
<div className="cover">
|
||||
{url ? <img src={url} alt="" loading="lazy" /> : <div className="placeholder">{book.title.slice(0, 12) || '无封面'}</div>}
|
||||
<span className="format-badge">{book.format}</span>
|
||||
{/* Drawn from the local mirror, which the sync engine keeps current — so
|
||||
it is correct offline, with no extra request per card. */}
|
||||
{progress && progress.percentage > 0.005 ? (
|
||||
<div className="cover-progress">
|
||||
<span style={`width:${Math.round(Math.min(1, Math.max(0, progress.percentage)) * 100)}%`} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="title">{book.title || '未命名'}</div>
|
||||
<div className="author">{book.author || '未知作者'}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueCard({
|
||||
item,
|
||||
revision,
|
||||
api,
|
||||
onOpen,
|
||||
}: {
|
||||
item: ContinueReadingItem;
|
||||
revision: number;
|
||||
api: ReaderApi;
|
||||
onOpen(): void;
|
||||
}): JSX.Element {
|
||||
const url = useCoverUrl(api, item.coverUrl, revision);
|
||||
const width = `${Math.round(Math.min(1, Math.max(0, item.percentage)) * 100)}%`;
|
||||
return (
|
||||
<button type="button" className="continue-card" onClick={onOpen}>
|
||||
<div
|
||||
className="cover"
|
||||
style="aspect-ratio:2/3;border-radius:6px;overflow:hidden;background:var(--reader-line);display:grid;place-items:center;"
|
||||
>
|
||||
{url ? (
|
||||
<img src={url} alt="" style="width:100%;height:100%;object-fit:cover;display:block;" />
|
||||
) : (
|
||||
<div className="placeholder">{item.title.slice(0, 8)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="title" style="font-size:.8rem;margin-top:.3rem;">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="muted" style="font-size:.7rem;">
|
||||
{item.chapterTitle || `${Math.round(item.percentage * 100)}%`}
|
||||
</div>
|
||||
<div className="progress-track">
|
||||
<span style={`width:${width}`} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* The shelf's own settings.
|
||||
*
|
||||
* A sheet rather than a route, and a separate file rather than more rows in the
|
||||
* reader's panel — because the two answer different questions. The reader panel
|
||||
* is about the *page*: type size, line height, the engine that reads it aloud.
|
||||
* This one is about the *grid*: how many covers fit, what each cover shows, and
|
||||
* what order they are in.
|
||||
*
|
||||
* Keeping them apart is not tidiness. A reader who wants bigger covers should not
|
||||
* have to open a panel full of typography settings to find it, and the reader's
|
||||
* panel is already four sections long.
|
||||
*/
|
||||
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
export const DENSITY_LABELS: Record<AppSettings['shelfDensity'], string> = {
|
||||
compact: '紧凑',
|
||||
cozy: '适中',
|
||||
comfortable: '宽松',
|
||||
};
|
||||
|
||||
export interface ShelfSettingsOptions {
|
||||
settings: AppSettings;
|
||||
onPatch(patch: Partial<AppSettings>): void;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the sheet.
|
||||
*
|
||||
* Returns the panel element, hidden. Rows are built once and mutate the settings
|
||||
* object in place: this is a form over a handful of values, and rebuilding it on
|
||||
* every toggle would fight the reader's own tap (the button they just pressed
|
||||
* would be replaced under their finger).
|
||||
*/
|
||||
export function buildShelfSettingsPanel(options: ShelfSettingsOptions): HTMLDivElement {
|
||||
const body = el('div', { className: 'panel-body' });
|
||||
|
||||
body.append(
|
||||
el('div', {
|
||||
className: 'section-title',
|
||||
text: '封面大小',
|
||||
}),
|
||||
);
|
||||
body.append(
|
||||
segmented(
|
||||
(['compact', 'cozy', 'comfortable'] as const).map((value) => ({
|
||||
value,
|
||||
label: DENSITY_LABELS[value],
|
||||
})),
|
||||
options.settings.shelfDensity,
|
||||
(value) => options.onPatch({ shelfDensity: value as AppSettings['shelfDensity'] }),
|
||||
),
|
||||
);
|
||||
|
||||
body.append(el('div', { className: 'section-title', text: '显示' }));
|
||||
body.append(
|
||||
toggleRow('显示作者', '关闭后每本书只显示书名,封面可以更小', options.settings.shelfShowAuthor, (value) =>
|
||||
options.onPatch({ shelfShowAuthor: value }),
|
||||
),
|
||||
);
|
||||
body.append(
|
||||
toggleRow('显示阅读进度', '在封面底部画一条进度条', options.settings.shelfShowProgress, (value) =>
|
||||
options.onPatch({ shelfShowProgress: value }),
|
||||
),
|
||||
);
|
||||
|
||||
body.append(el('div', { className: 'section-title', text: '默认排序' }));
|
||||
body.append(
|
||||
el('div', {
|
||||
className: 'notice',
|
||||
text: '书架上的排序按钮会记住最后一次选择,这里设置的是首次打开时的默认值。',
|
||||
}),
|
||||
);
|
||||
body.append(
|
||||
segmented(
|
||||
[
|
||||
{ value: 'updated', label: '最近更新' },
|
||||
{ value: 'added', label: '最近入库' },
|
||||
{ value: 'title', label: '书名' },
|
||||
{ value: 'author', label: '作者' },
|
||||
],
|
||||
options.settings.shelfSort,
|
||||
(value) => options.onPatch({ shelfSort: value as AppSettings['shelfSort'] }),
|
||||
),
|
||||
);
|
||||
|
||||
return el('div', {
|
||||
className: 'panel shelf-settings',
|
||||
attrs: { hidden: true },
|
||||
children: [
|
||||
el('div', {
|
||||
className: 'panel-header',
|
||||
children: [
|
||||
el('h2', { text: '书架设置' }),
|
||||
el('button', {
|
||||
className: 'icon-button',
|
||||
text: '✕',
|
||||
attrs: { type: 'button', 'aria-label': '关闭' },
|
||||
on: { click: options.onClose },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
|
||||
function segmented(
|
||||
values: Array<{ value: string; label: string }>,
|
||||
current: string,
|
||||
onChange: (value: string) => void,
|
||||
): HTMLDivElement {
|
||||
const buttons = values.map((entry) =>
|
||||
el('button', {
|
||||
text: entry.label,
|
||||
attrs: { type: 'button', 'aria-pressed': String(entry.value === current) },
|
||||
on: {
|
||||
click: (event) => {
|
||||
const group = (event.currentTarget as HTMLElement).parentElement;
|
||||
for (const sibling of group?.children ?? []) {
|
||||
sibling.setAttribute('aria-pressed', String(sibling === event.currentTarget));
|
||||
}
|
||||
onChange(entry.value);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return el('div', { className: 'segmented', children: buttons }) as HTMLDivElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch.
|
||||
*
|
||||
* `aria-pressed` rather than a checkbox: the row is the hit target (44px), and a
|
||||
* checkbox would put the target on a 16px square next to a label that does
|
||||
* nothing when tapped.
|
||||
*/
|
||||
function toggleRow(label: string, hint: string, value: boolean, onChange: (value: boolean) => void): HTMLDivElement {
|
||||
const button = el('button', {
|
||||
className: 'switch',
|
||||
attrs: { type: 'button', 'aria-pressed': String(value) },
|
||||
on: {
|
||||
click: (event) => {
|
||||
const next = (event.currentTarget as HTMLElement).getAttribute('aria-pressed') !== 'true';
|
||||
(event.currentTarget as HTMLElement).setAttribute('aria-pressed', String(next));
|
||||
onChange(next);
|
||||
},
|
||||
},
|
||||
});
|
||||
const text = el('div', { className: 'switch-text', children: [el('div', { text: label }), el('div', { className: 'muted', text: hint })] });
|
||||
return el('div', {
|
||||
className: 'field switch-field',
|
||||
children: [text, button],
|
||||
}) as HTMLDivElement;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The shelf's own settings.
|
||||
*
|
||||
* A sheet rather than a route, and a separate file rather than more rows in the
|
||||
* reader's panel — because the two answer different questions. The reader panel
|
||||
* is about the *page*: type size, line height, the engine that reads it aloud.
|
||||
* This one is about the *grid*: how many covers fit, what each cover shows, and
|
||||
* what order they are in.
|
||||
*
|
||||
* Keeping them apart is not tidiness. A reader who wants bigger covers should not
|
||||
* have to open a panel full of typography settings to find it, and the reader's
|
||||
* panel is already four sections long.
|
||||
*
|
||||
* The panel is a component now, drawn from the settings object it is handed. The
|
||||
* previous version built it once in the constructor and then mutated it in place
|
||||
* (it had to, to avoid replacing the button under the reader's finger) — which
|
||||
* meant the control that was *not* pressed never learned that its value had
|
||||
* changed somewhere else. Here every control is rendered from `settings`, so a
|
||||
* patch from anywhere redraws all of them, and pressing a button no longer
|
||||
* replaces it under the finger because Preact diffs the text of the node that is
|
||||
* already there.
|
||||
*/
|
||||
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import { IconButton, SectionTitle, Segmented, SwitchRow } from './toolkit.tsx';
|
||||
import type { JSX } from './vendor/preact.ts';
|
||||
|
||||
export const DENSITY_LABELS: Record<AppSettings['shelfDensity'], string> = {
|
||||
compact: '紧凑',
|
||||
cozy: '适中',
|
||||
comfortable: '宽松',
|
||||
};
|
||||
|
||||
export interface ShelfSettingsOptions {
|
||||
settings: AppSettings;
|
||||
onPatch(patch: Partial<AppSettings>): void;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function ShelfSettingsPanel({
|
||||
open,
|
||||
settings,
|
||||
onPatch,
|
||||
onClose,
|
||||
}: ShelfSettingsOptions & { open: boolean }): JSX.Element {
|
||||
return (
|
||||
<div className="panel shelf-settings" hidden={!open}>
|
||||
<div className="panel-header">
|
||||
<h2>书架设置</h2>
|
||||
<IconButton label="关闭" onClick={onClose}>
|
||||
✕
|
||||
</IconButton>
|
||||
</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 })}
|
||||
/>
|
||||
|
||||
<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={[
|
||||
{ value: 'updated', label: '最近更新' },
|
||||
{ value: 'added', label: '最近入库' },
|
||||
{ value: 'title', label: '书名' },
|
||||
{ value: 'author', label: '作者' },
|
||||
]}
|
||||
value={settings.shelfSort}
|
||||
onChange={(value) => onPatch({ shelfSort: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* The bookshelf.
|
||||
*
|
||||
* A grid of covers, which is the only browse surface a personal library needs.
|
||||
* Two choices worth naming:
|
||||
*
|
||||
* - **Covers are loaded eagerly and lazily at the same time.** The first screen
|
||||
* of covers is requested immediately so the shelf is not a wall of grey; the
|
||||
* rest are `loading="lazy"`, so scrolling a 2000-book library does not open
|
||||
* 2000 requests on the way down.
|
||||
* - **Search is debounced and server-side.** Filtering 2000 books in the client
|
||||
* would mean holding all of them in memory, and the server already indexes
|
||||
* title, author, series and ISBN — a client-side filter would silently miss
|
||||
* fields the user expects to match.
|
||||
*/
|
||||
|
||||
import { ApiClient, type BookDto } from '../net/api.ts';
|
||||
|
||||
export interface ShelfHost {
|
||||
onOpenBook?: (book: BookDto) => void;
|
||||
onSignOut?: () => void;
|
||||
}
|
||||
|
||||
export class ShelfView {
|
||||
private readonly root: HTMLElement;
|
||||
private readonly grid: HTMLElement;
|
||||
/** Built once, mounted on demand. */
|
||||
private built = false;
|
||||
private search = '';
|
||||
private page = 1;
|
||||
private total = 0;
|
||||
private loading = false;
|
||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly host: HTMLElement,
|
||||
private readonly api: ApiClient,
|
||||
private readonly options: ShelfHost = {},
|
||||
) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'shelf';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'shelf__header';
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'shelf__title';
|
||||
title.textContent = '书库';
|
||||
const signOut = document.createElement('button');
|
||||
signOut.className = 'reader__button';
|
||||
signOut.textContent = '退出';
|
||||
signOut.addEventListener('click', () => this.options.onSignOut?.());
|
||||
header.append(title, signOut);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'shelf__search';
|
||||
input.type = 'search';
|
||||
input.placeholder = '搜索书名、作者、系列';
|
||||
input.addEventListener('input', () => {
|
||||
this.search = input.value.trim();
|
||||
// Debounced: a request per keystroke against a self-hosted server on a NAS
|
||||
// is a request per keystroke against a spinning disk.
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.page = 1;
|
||||
void this.load(true);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
this.grid = document.createElement('div');
|
||||
this.grid.className = 'shelf__grid';
|
||||
|
||||
this.root.append(header, input, this.grid);
|
||||
|
||||
// Infinite scroll: a library is browsed by scrolling, and paging controls on
|
||||
// a phone are a tax on every visit.
|
||||
this.root.addEventListener('scroll', () => {
|
||||
if (this.loading || this.grid.childElementCount >= this.total) return;
|
||||
const remaining = this.root.scrollHeight - this.root.scrollTop - this.root.clientHeight;
|
||||
if (remaining < 600) void this.load(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over the host element.
|
||||
*
|
||||
* Mounting is separate from construction because the view is built once, at
|
||||
* startup, while the auth screen may still own the host. Replacing the host's
|
||||
* children in the constructor meant the shelf's markup was installed and then
|
||||
* covered by the dialog that mounted after it — so a successful sign-in left
|
||||
* the login form on screen with the loaded shelf hidden underneath.
|
||||
*/
|
||||
mount(): void {
|
||||
this.host.replaceChildren(this.root);
|
||||
this.built = true;
|
||||
}
|
||||
|
||||
get mounted(): boolean {
|
||||
return this.built;
|
||||
}
|
||||
|
||||
async load(reset: boolean): Promise<void> {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
if (reset) this.grid.replaceChildren();
|
||||
try {
|
||||
const result = await this.api.shelf({ page: this.page, pageSize: 60, ...(this.search ? { search: this.search } : {}) });
|
||||
this.total = result.total;
|
||||
if (result.items.length === 0 && reset) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'shelf__empty';
|
||||
empty.textContent = this.search ? '没有匹配的书' : '书库是空的,检查挂载目录';
|
||||
this.grid.append(empty);
|
||||
}
|
||||
for (const book of result.items) this.grid.append(this.card(book));
|
||||
this.page += 1;
|
||||
} catch (error) {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'shelf__empty';
|
||||
message.textContent = error instanceof Error ? error.message : '加载失败';
|
||||
this.grid.replaceChildren(message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private card(book: BookDto): HTMLElement {
|
||||
const card = document.createElement('button');
|
||||
card.className = 'book-card';
|
||||
card.type = 'button';
|
||||
// The whole card is the hit target: on a phone a tap on a 108px cover is
|
||||
// already at the edge of the 44px recommendation, and a separate title link
|
||||
// would be worse.
|
||||
card.addEventListener('click', () => this.options.onOpenBook?.(book));
|
||||
|
||||
// A book without cover art — most TXT files and plenty of PDFs — gets a
|
||||
// placeholder rather than an `<img>` with no source, which every browser
|
||||
// renders as a broken-image icon. That icon reads as "this library is broken"
|
||||
// when the truth is "this book has no cover", and there is no way for the
|
||||
// reader to tell the two apart.
|
||||
const cover = document.createElement('div');
|
||||
cover.className = 'book-card__cover';
|
||||
if (book.coverUrl) {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'book-card__cover-img';
|
||||
image.alt = '';
|
||||
// `loading=lazy` plus the fixed aspect ratio reserves the space before the
|
||||
// image arrives, so the grid does not reflow under a thumb that is already
|
||||
// scrolling.
|
||||
image.loading = 'lazy';
|
||||
image.decoding = 'async';
|
||||
// The DTO carries the server's own path; the token has to come from the
|
||||
// client, because an `<img src>` cannot send an Authorization header.
|
||||
image.src = this.api.coverUrl(book.id);
|
||||
image.addEventListener('error', () => {
|
||||
// A cover that fails to load is a missing cover, not a missing book.
|
||||
// Falling back to the placeholder keeps the shelf uniform instead of
|
||||
// leaving one card looking damaged.
|
||||
image.remove();
|
||||
cover.classList.add('book-card__cover--missing');
|
||||
cover.textContent = book.title.slice(0, 1);
|
||||
});
|
||||
cover.append(image);
|
||||
} else {
|
||||
cover.classList.add('book-card__cover--missing');
|
||||
// The first character, which is what a spine would show. A generic icon
|
||||
// would make every coverless book indistinguishable in a grid of forty.
|
||||
cover.textContent = book.title.slice(0, 1);
|
||||
}
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'book-card__title';
|
||||
title.textContent = book.title;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'book-card__meta';
|
||||
meta.textContent = [book.author, formatLabel(book.format)].filter(Boolean).join(' · ');
|
||||
|
||||
card.append(cover, title, meta);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLabel(format: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
epub: 'EPUB',
|
||||
pdf: 'PDF',
|
||||
cbz: '漫画',
|
||||
'comic-dir': '漫画',
|
||||
txt: 'TXT',
|
||||
image: '图片',
|
||||
};
|
||||
return labels[format] ?? format.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* The primitives every screen was re-writing by hand.
|
||||
*
|
||||
* This file is the answer to "what does a framework actually buy us here". The
|
||||
* old UI built each control inline — `el('button', { className: 'icon-button',
|
||||
* attrs: { type: 'button', 'aria-label': '关闭' }, … })` — and the parts that
|
||||
* were *not* inline were the parts that were wrong: a dialog had no focus trap,
|
||||
* a sheet had no Escape handling, the segmented control and the switch each had
|
||||
* their own hand-rolled `aria-pressed` bookkeeping that could disagree with the
|
||||
* value it was drawn from.
|
||||
*
|
||||
* Each component here owns those details once, and takes the settings value and
|
||||
* a callback as props, so the rendered state cannot drift from the stored state.
|
||||
*/
|
||||
|
||||
import { type ComponentChildren, type JSX } from './vendor/preact.ts';
|
||||
|
||||
/** The panel headings and controls the stylesheet already styles. */
|
||||
export function SectionTitle({ children }: { children: ComponentChildren }): JSX.Element {
|
||||
return <div className="section-title">{children}</div>;
|
||||
}
|
||||
|
||||
export function Notice({ children }: { children: ComponentChildren }): JSX.Element {
|
||||
return <div className="notice">{children}</div>;
|
||||
}
|
||||
|
||||
export interface IconButtonProps {
|
||||
label: string;
|
||||
/** Glyph. Kept as a child so a caller can pass an element instead. */
|
||||
children: ComponentChildren;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
/** Kept in the DOM so the header's layout does not depend on the mount. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export function IconButton({ label, children, onClick, disabled, hidden }: IconButtonProps): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label={label}
|
||||
disabled={disabled ?? false}
|
||||
hidden={hidden ?? false}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PrimaryButtonProps {
|
||||
children: ComponentChildren;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Button({ children, onClick, disabled, type, className }: PrimaryButtonProps): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type={type ?? 'button'}
|
||||
className={className ? `button ${className}` : 'button'}
|
||||
disabled={disabled ?? false}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SegmentedProps<T extends string> {
|
||||
options: Array<{ value: T; label: string }>;
|
||||
value: T;
|
||||
onChange(value: T): void;
|
||||
/** Accessible name for the group, e.g. "封面大小". */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A segmented control.
|
||||
*
|
||||
* `aria-pressed` is derived from `value` on every render, which is the fix for
|
||||
* the old version's real bug: it toggled the attribute on the sibling nodes by
|
||||
* hand, so any path that changed the setting without going through the click
|
||||
* handler left the buttons showing the wrong selection.
|
||||
*/
|
||||
export function Segmented<T extends string>({ options, value, onChange, label }: SegmentedProps<T>): JSX.Element {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label ?? ''}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
aria-pressed={option.value === value}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SwitchRowProps {
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean;
|
||||
onChange(checked: boolean): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled switch.
|
||||
*
|
||||
* A button rather than a checkbox, for the reason the old file documented: the
|
||||
* row is the 44px hit target, and a checkbox puts the target on a 16px square
|
||||
* that a thumb misses. The `aria-pressed` state now comes from `checked`
|
||||
* instead of from the DOM the click just modified.
|
||||
*/
|
||||
export function SwitchRow({ label, hint, checked, onChange }: SwitchRowProps): JSX.Element {
|
||||
return (
|
||||
<div className="field switch-field">
|
||||
<div className="switch-text">
|
||||
<div>{label}</div>
|
||||
<div className="muted">{hint}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="switch"
|
||||
aria-pressed={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ScrimProps {
|
||||
onDismiss?(): void;
|
||||
children: ComponentChildren;
|
||||
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>
|
||||
);
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The framework, re-exported from exactly one place.
|
||||
*
|
||||
* Every component imports Preact from here rather than from `preact` directly, so
|
||||
* the bundle has a single entry into the runtime and so a future swap (or a
|
||||
* version pin) is one file rather than a search-and-replace across the UI. It
|
||||
* also documents *what* the UI layer is allowed to use: the runtime and hooks,
|
||||
* not `preact/compat` — there is no React compatibility layer in here, no
|
||||
* `react-dom`, and nothing that would pull in either.
|
||||
*
|
||||
* `preact/hooks` is `useState`/`useRef`/`useEffect` implemented in ~1.5KB gzip
|
||||
* on top of the runtime's own scheduler, which is the whole reason the numbers
|
||||
* work out: the UI layer costs about 6KB gzip, not the 45KB a React build would.
|
||||
*/
|
||||
export { createElement, Fragment, render, type ComponentChildren, type VNode } from 'preact';
|
||||
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
export type { JSX } from 'preact';
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ManagerScreen } from '../src/ui/manager-screen.ts';
|
||||
import { ManagerScreen } from '../src/ui/manager-screen.tsx';
|
||||
import { ReaderApi, type SessionStore } from '../src/api/client.ts';
|
||||
import { ApiError } from '../src/api/errors.ts';
|
||||
import { FakeTransport, makePlatform } from './helpers/env.ts';
|
||||
|
||||
+5
-1
@@ -15,7 +15,11 @@
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
"noEmit": true,
|
||||
// The UI layer is Preact components; the render layer is still imperative DOM
|
||||
// work and stays TS. Both compile out of this one config.
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": ["src", "test", "vite.config.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user