diff --git a/README.md b/README.md index 3127ace..76b33fc 100644 --- a/README.md +++ b/README.md @@ -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`——「关」的意思是「不动它」, diff --git a/docs/architecture.md b/docs/architecture.md index 2779a6d..74ef621 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/` 产物。这不是为了省事, diff --git a/web/package-lock.json b/web/package-lock.json index 028f930..a38f7bc 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 40a17d9..c19aa5d 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/app.ts b/web/src/app.ts index f1d04d6..f5397a8 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -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'; /** diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index f342b8f..450dffa 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; diff --git a/web/src/ui/auth-view.ts b/web/src/ui/auth-view.ts deleted file mode 100644 index bbcc9e7..0000000 --- a/web/src/ui/auth-view.ts +++ /dev/null @@ -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 { - 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 => { - 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 : '操作失败'; - } -} diff --git a/web/src/ui/auth-view.tsx b/web/src/ui/auth-view.tsx new file mode 100644 index 0000000..1a17f62 --- /dev/null +++ b/web/src/ui/auth-view.tsx @@ -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; + 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 { + 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 this.patch(patch)} onSubmit={() => void this.submit()} />; + } + + private patch(patch: Partial): void { + this.state = { ...this.state, ...patch }; + this.draw(); + } + + private async submit(): Promise { + 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): void; + onSubmit(): void; +}): JSX.Element { + const registering = state.mode === 'register'; + return ( +
+

{registering ? '创建第一个账号' : '登录'}

+

{state.hint}

+ + + {state.error ?
{state.error}
: null} +
+ +
+
+ ); +} + +/** + * 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 : '操作失败'; + } +} diff --git a/web/src/ui/dom.ts b/web/src/ui/dom.ts index 75b7d8a..03575e4 100644 --- a/web/src/ui/dom.ts +++ b/web/src/ui/dom.ts @@ -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; diff --git a/web/src/ui/login-screen.ts b/web/src/ui/login-screen.ts deleted file mode 100644 index 2a3d8ef..0000000 --- a/web/src/ui/login-screen.ts +++ /dev/null @@ -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; -} - -/** - * 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 | 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 { - 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 { - 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(); - } -} diff --git a/web/src/ui/login-screen.tsx b/web/src/ui/login-screen.tsx new file mode 100644 index 0000000..3a916c2 --- /dev/null +++ b/web/src/ui/login-screen.tsx @@ -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; +} + +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; + private state: LoginState; + /** The probe's debounce, on the instance so a re-render cannot drop it. */ + private probeTimer: ReturnType | 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): void { + this.state = { ...this.state, ...patch }; + this.draw(); + } + + private view(): JSX.Element { + const state = this.state; + return ( +
{ + event.preventDefault(); + void this.submit(); + }} + > +

reader

+

+ 自部署书库阅读器 +

+ {state.notice ? {state.notice} : null} +
+ + this.onServerUrlChange((event.currentTarget as HTMLInputElement).value)} + /> +
+
+ + this.onUsernameInput((event.currentTarget as HTMLInputElement).value)} + /> +
+
+ + this.patch({ password: (event.currentTarget as HTMLInputElement).value })} + /> +
+ {state.registering ? ( +
+ + this.patch({ displayName: (event.currentTarget as HTMLInputElement).value })} + /> +
+ ) : null} + +
+ +
+ {state.error ?
{state.error}
: null} +
+ ); + } + + private onServerUrlChange(value: string): void { + this.patch({ serverUrl: value }); + } + + private onUsernameInput(value: string): void { + const patch: Partial = { 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 { + 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 { + 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 { + 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 : '登录失败'; +} diff --git a/web/src/ui/manager-screen.ts b/web/src/ui/manager-screen.ts deleted file mode 100644 index 2f9cd2e..0000000 --- a/web/src/ui/manager-screen.ts +++ /dev/null @@ -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(); - 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 | 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 { - await this.load(''); - } - - private async load(path: string): Promise { - 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(`[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('.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 { - 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('.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 { - 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 { - 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>): 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 { - 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 { - 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 | 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(); - 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 | null): void => { - overlay.remove(); - resolve(result); - }; - const form = el('form', { - className: 'dialog', - on: { - submit: (event) => { - event.preventDefault(); - const patch: Record = {}; - 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 { - 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 { - 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 { - 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 { - 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, - label: string, - describe?: (result: never) => string, - ): Promise { - 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 { - 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 { - 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 { - // 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(); - 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}`); -} diff --git a/web/src/ui/manager-screen.tsx b/web/src/ui/manager-screen.tsx new file mode 100644 index 0000000..713278a --- /dev/null +++ b/web/src/ui/manager-screen.tsx @@ -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 | null): void; + }; + +interface ManagerState { + path: string; + listing: BrowseListing | null; + /** Paths ticked for a batch operation. Empty means "navigate" mode. */ + selected: ReadonlySet; + 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(), + loading: false, + busy: false, + status: '', + dialog: null, + }; + private readonly ui: ReturnType; + 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 { + await this.load(''); + } + + private async load(path: string): Promise { + 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(); + // 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): 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 ( + <> +
+ this.options.onClose()}> + ← + +
+ {(listing?.crumbs ?? []).map((crumb, index) => ( + <> + {index > 0 ? / : null} + + + ))} +
+ {/* 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. */} + + +
+
{ + // 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([]); + }} + > +
+ {listing && listing.entries.length === 0 ? ( +
+

这个文件夹是空的

+
+ ) : null} + {(listing?.entries ?? []).map((entry) => ( + 0} + selected={state.selected.has(entry.path)} + onActivate={() => this.activate(entry)} + onToggle={() => this.toggle(entry)} + onMenu={() => void this.openEntryMenu(entry)} + /> + ))} +
+
{state.status || this.summary(listing)}
+
+ 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 ?
已选 {state.selected.size} 项
: null} + {state.dialog ? ( + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>): 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, + label: string, + describe?: (result: never) => string, + ): Promise { + 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 | 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 { + 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 { + 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 { + // 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(); + 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 | 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 | 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 | 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[] = [ + {entry.type === 'dir' ? '' : formatBytes(entry.size)}, + {formatDate(entry.mtime)}, + ]; + if (entry.hiddenByRule) meta.push(扫描忽略); + else if (entry.scanned) meta.push(书籍); + if (entry.hidden && !entry.hiddenByRule) meta.push(隐藏); + + return ( +
{ + // 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(); + }} + > +
+ ); +} + +/** + * 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