From fbb6dc65cfcfe493d9d0414c7965d574c9f011f3 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Wed, 21 Oct 2026 07:30:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20one=20Sheet=20for=20every=20overlay?= =?UTF-8?q?;=20drop=20=E6=9D=A5=E6=BA=90=E4=B8=8E=E5=88=AE=E5=89=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/sheet.tsx Sheet/ConfirmSheet replace the centred Modal, SweetAlert confirms, DialogView overlays, the reader Panel, the shelf settings floating card, the two media dialogs and Mantine Modal. - Phones: bottom sheet. Wide windows: right-edge drawer; compact sheets (confirm, prompt, pick) open next to the tap or focused control. - One set of exits (close, Escape, backdrop), 关闭 label, focus starts on 取消, danger confirms. - Remove sweetalert2 and the unused @mantine/notifications. - Remove the 来源与刮削 media settings panel and its entries in 我的 and 影音设置; TMDB and MusicBrainz stay configurable in 系统设置 > 服务配置. - docs/ui.md 3.7 describes the rule; tests and review scripts follow the new markup. --- docs/ui.md | 28 +- web/package-lock.json | 103 +----- web/package.json | 2 - web/src/app/providers/AppProviders.tsx | 3 +- web/src/app/router/routes.tsx | 2 +- web/src/features/account/pages/MinePage.tsx | 7 +- .../features/media/components/audio-tools.tsx | 12 +- .../media/components/folder-cleanup.tsx | 4 +- .../media/components/folder-recognition.tsx | 9 +- .../media/components/library-create.tsx | 4 +- .../media/components/library-editor.tsx | 4 +- .../media/components/metadata-matcher.tsx | 9 +- .../media/components/metadata-page.tsx | 4 +- .../features/media/components/permissions.tsx | 4 +- .../media/components/provider-settings.tsx | 29 -- .../media/components/resource-panel.tsx | 16 +- .../features/media/components/scrape-jobs.tsx | 4 +- .../features/media/components/settings.tsx | 15 +- .../media/components/video-controls.tsx | 9 +- .../features/media/pages/MediaQueuePage.tsx | 4 +- .../media/pages/MediaSettingsPage.tsx | 4 +- .../features/media/styles/presentation.css | 52 +-- web/src/features/media/styles/v2.css | 13 +- web/src/features/shelf/pages/ShelfPage.tsx | 9 +- .../components/SourceBookDetailSheet.tsx | 30 ++ .../sources/pages/SourceCatalogPage.tsx | 16 +- .../sources/pages/SourceExtensionPage.tsx | 4 +- .../features/sources/pages/SourcesPage.tsx | 15 +- web/src/main.tsx | 1 - web/src/styles/confirmation.css | 9 - web/src/styles/reader.css | 319 ++---------------- web/src/styles/sheet.css | 261 ++++++++++++++ web/src/ui/annotation-popover.tsx | 17 +- web/src/ui/business-settings.tsx | 4 +- web/src/ui/dialog.tsx | 142 ++++---- web/src/ui/floating-confirm.tsx | 58 ---- web/src/ui/modal.tsx | 39 --- web/src/ui/opds-access.tsx | 26 +- web/src/ui/reader-chrome.tsx | 85 +---- web/src/ui/reading-tools.tsx | 11 +- web/src/ui/sheet.tsx | 214 ++++++++++++ web/src/ui/shelf-settings.tsx | 98 +++--- web/src/ui/system-settings.tsx | 9 +- web/src/ui/toolkit.tsx | 27 -- web/test/chapter-reader.test.ts | 10 +- web/test/icons.test.ts | 1 + web/test/media-folder-cleanup.test.tsx | 2 +- web/test/media-folder-recognition.test.tsx | 3 +- web/test/media-metadata-page.test.tsx | 2 +- web/test/media-provider-settings.test.tsx | 32 -- web/test/media-resource-panel.test.tsx | 2 +- web/test/media-settings.test.tsx | 11 +- web/test/mine-page.test.tsx | 9 +- web/test/react-router-routes.test.tsx | 1 - web/test/reader-chrome.test.ts | 18 +- web/test/shelf-login-parity.test.tsx | 4 +- web/test/shelf-reading-state.test.tsx | 4 +- web/tools/media-navigation-review.cjs | 2 +- web/tools/media-playback-review.cjs | 2 +- web/tools/media-smoke.cjs | 10 +- web/tools/media-theme-review.cjs | 2 +- web/tools/media-v2-review.cjs | 2 +- web/tools/ui-review/collections.mjs | 10 +- web/tools/ui-review/desktop-reader.mjs | 20 +- web/tools/ui-review/p1-reading.mjs | 8 +- web/tools/ui-review/p2-sources.mjs | 4 +- web/tools/ui-review/react-app.mjs | 7 +- web/tools/ui-review/reader-regression.mjs | 26 +- web/tools/ui-review/run.mjs | 16 +- web/tools/ui-review/user-management.mjs | 24 +- 70 files changed, 888 insertions(+), 1079 deletions(-) delete mode 100644 web/src/features/media/components/provider-settings.tsx create mode 100644 web/src/features/sources/components/SourceBookDetailSheet.tsx delete mode 100644 web/src/styles/confirmation.css create mode 100644 web/src/styles/sheet.css delete mode 100644 web/src/ui/floating-confirm.tsx delete mode 100644 web/src/ui/modal.tsx create mode 100644 web/src/ui/sheet.tsx delete mode 100644 web/test/media-provider-settings.test.tsx diff --git a/docs/ui.md b/docs/ui.md index 27b05ae..e9f8757 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -419,7 +419,26 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For 关闭后回到 `body`;消息不抢焦点。提示出现、更新和关闭不得改变内容区尺寸或滚动位置, 这些行为在手机和桌面 Chromium 中验证。数量摘要、搜索统计和操作说明继续作为页面内容展示。 -### 3.7 半屏面板 +### 3.7 弹层:只有一种 Sheet + +所有设置、编辑表单、选择列表、详情和确认框都用 `web/src/ui/sheet.tsx` 的 `Sheet` +(确认框是 `ConfirmSheet`),样式只在 `web/src/styles/sheet.css`。不再使用居中弹窗、 +SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。 + +| 视口 | 形态 | +| --- | --- | +| ≤700px | 底部 Sheet:圆角顶边、把手,高度随内容,最高到安全区下方;阅读器内最高 62% | +| ≥701px | 右侧抽屉:距边 1rem,宽 30rem;`size="wide"` 宽 42rem 给复杂表单 | +| ≥701px,`size="compact"` | 确认、单行输入、短选择列表:跟手,出现在触发它的点按位置或聚焦控件的下方(放不下则上方),宽 24rem,始终留在窗口内;找不到触发点时回到右上角 | + +- 原生 `` + `showModal()`:顶层、背景惰性、焦点限制在内部,关闭后焦点回到打开它的控件。 +- 三个出口始终相同:标题栏 ✕(「关闭」)、Esc、点背景;`busy` 时三者都禁用。 +- 就地渲染、不走 portal,所以继承打开它的页面主题(阅读器黑白灰、影音别名)。 +- 调用方的 `className` 只管**内容**样式,不得设置位置、尺寸、圆角、背景遮罩或标题栏。 +- 确认框焦点默认在「取消」;破坏性操作传 `danger`,按钮用危险色。 +- 锚定在控件上的下拉(选择框、`⋯` 菜单、选区工具条)不是弹层,不在此列。 + +以下是阅读器面板最初改成底部半屏的理由,仍然适用于手机形态: 目录和阅读设置是**从底部升起的半屏面板**,带遮罩和把手,上半屏仍然看得见 正文。 @@ -435,8 +454,8 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For - **把手**:从底部升起的面板被理解为可拖拽的。拖动本身没有实现——把手是 *可供性*,遮罩和 ✕ 是两个真实的出口。画一个做不到的把手比不画更糟,所以 它只是视觉线索,真正移动的是面板自己的滚动。 -- **高度**:`min(62%, 34rem)`,且不超过 78%。一千二百章的目录想要尽可能多的 - 空间,十一行的设置不想要比它需要更多的空间。 +- **高度**:随内容,阅读器内最高 `min(62dvh, 36rem)`。一千二百章的目录会撑到上限, + 十一行的设置不占比它需要更多的空间。 面板打开时**它拥有点击**:旁边露出的那一条正文不翻页(§7 的优先级)。 @@ -669,7 +688,8 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 | `web/src/features/library/pages/LibraryPage.tsx` | 书库浏览、书籍列表和文件管理路由;搜索、面包屑、分页器、上传后重读 | | `web/src/ui/shelf-membership.ts` | 两个方向的措辞与批量报告那句话的唯一来源(§3.5.2) | | `web/src/ui/shelf-order.ts` | 排序的**唯一**定义:方向、客户端/服务端的键怎么翻译、阅读时间怎么排 | -| `web/src/ui/dialog.tsx` | 提问(文本框/确认/选择/改资料),两个书库页面共用 | +| `web/src/ui/sheet.tsx` / `web/src/styles/sheet.css` | 唯一的弹层 `Sheet` 与 `ConfirmSheet`(§3.7) | +| `web/src/ui/dialog.tsx` | 提问(文本框/确认/选择/改资料),基于 `Sheet`,两个书库页面共用 | | `web/src/ui/pager.tsx` | 翻页器,书架与书库共用(§3.5) | | `web/test/shelf-order.test.ts` | 书架排序(默认项、`recent` 是客户端排)、排序方向和稳定性 | | `web/test/shelf-order.test.ts` | 排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定 | diff --git a/web/package-lock.json b/web/package-lock.json index 65e6dfc..b7cab97 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@mantine/core": "^8.3.5", "@mantine/hooks": "^8.3.5", - "@mantine/notifications": "^8.3.5", "@tanstack/query-core": "^5.104.0", "@tanstack/react-query": "^5.90.5", "dplayer": "^1.27.1", @@ -22,7 +21,6 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.9.4", - "sweetalert2": "^11.26.25", "zustand": "^5.0.8" }, "devDependencies": { @@ -1118,31 +1116,6 @@ "react": "^18.x || ^19.x" } }, - "node_modules/@mantine/notifications": { - "version": "8.3.5", - "resolved": "https://registry.npmmirror.com/@mantine/notifications/-/notifications-8.3.5.tgz", - "integrity": "sha512-8TvzrPxfdtOLGTalv7Ei1hy2F6KbR3P7/V73yw3AOKhrf1ydS89sqV2ShbsucHGJk9Pto0wjdTPd8Q7pm5MAYw==", - "license": "MIT", - "dependencies": { - "@mantine/store": "8.3.5", - "react-transition-group": "4.4.5" - }, - "peerDependencies": { - "@mantine/core": "8.3.5", - "@mantine/hooks": "8.3.5", - "react": "^18.x || ^19.x", - "react-dom": "^18.x || ^19.x" - } - }, - "node_modules/@mantine/store": { - "version": "8.3.5", - "resolved": "https://registry.npmmirror.com/@mantine/store/-/store-8.3.5.tgz", - "integrity": "sha512-qN4fFsDMy86IV9oh1gZlDTv41RAsO0grjx90FGyT5QCv7NTgcavwxB74GBkhp45W8xn+Ms/awKy+6NxnmLmW1w==", - "license": "MIT", - "peerDependencies": { - "react": "^18.x || ^19.x" - } - }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -2067,6 +2040,7 @@ "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2148,16 +2122,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dplayer": { "version": "1.27.1", "resolved": "https://registry.npmmirror.com/dplayer/-/dplayer-1.27.1.tgz", @@ -2576,6 +2540,7 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/jsdom": { @@ -2666,18 +2631,6 @@ "immediate": "~3.0.5" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", @@ -2793,15 +2746,6 @@ "integrity": "sha512-Mtnp+0qiZxgrH+TzVlzhWyZceHdAZ/UWK0/ju9U0HQeDpap1mZ8cC7H5wSI5mwgni6yeAjaxsTw0sbMK+aSuHw==", "license": "MIT" }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -2928,17 +2872,6 @@ "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", "license": "MIT" }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -2976,12 +2909,6 @@ "react": "^19.1.0" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/react-number-format": { "version": "5.4.5", "resolved": "https://registry.npmmirror.com/react-number-format/-/react-number-format-5.4.5.tgz", @@ -3126,22 +3053,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -3332,16 +3243,6 @@ ], "license": "MIT" }, - "node_modules/sweetalert2": { - "version": "11.26.25", - "resolved": "https://registry.npmmirror.com/sweetalert2/-/sweetalert2-11.26.25.tgz", - "integrity": "sha512-+hunCOJdJ6FLj04T9YSLvvZXRjsvIkTeTKP2e4VF8CaBias961BTnWiSFAy7F/CM5eq3QK2Rraoc5Gzftslvkg==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/limonte" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/web/package.json b/web/package.json index a24672e..be5741f 100644 --- a/web/package.json +++ b/web/package.json @@ -20,7 +20,6 @@ "dependencies": { "@mantine/core": "^8.3.5", "@mantine/hooks": "^8.3.5", - "@mantine/notifications": "^8.3.5", "@tanstack/query-core": "^5.104.0", "@tanstack/react-query": "^5.90.5", "dplayer": "^1.27.1", @@ -31,7 +30,6 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.9.4", - "sweetalert2": "^11.26.25", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/web/src/app/providers/AppProviders.tsx b/web/src/app/providers/AppProviders.tsx index 4dafccb..85208be 100644 --- a/web/src/app/providers/AppProviders.tsx +++ b/web/src/app/providers/AppProviders.tsx @@ -1,6 +1,5 @@ import { useEffect, type ReactNode } from 'react'; import { MantineProvider } from '@mantine/core'; -import { Notifications } from '@mantine/notifications'; import { QueryClientProvider } from '@tanstack/react-query'; import type { AppRuntime } from '../runtime.ts'; import { RuntimeContext } from './runtime-context.tsx'; @@ -34,7 +33,7 @@ export function AppProviders({ runtime, children }: { runtime: AppRuntime; child return - {children} + {children} ; diff --git a/web/src/app/router/routes.tsx b/web/src/app/router/routes.tsx index ecde96e..c81395c 100644 --- a/web/src/app/router/routes.tsx +++ b/web/src/app/router/routes.tsx @@ -63,7 +63,7 @@ export const routes = [{ { path: 'settings', element: }, { path: 'book/:bookId', element: }, // 我的 shares MediaLayout so it follows the chosen theme and keeps the channel rail. - // Its settings rows open /me/:panel (theme, browse, playback, plugins, account), + // Its settings rows open /me/:panel (theme, browse, playback, account), // so back from any of them returns to 我的. { path: 'me', element: , children: [ { index: true, element: }, diff --git a/web/src/features/account/pages/MinePage.tsx b/web/src/features/account/pages/MinePage.tsx index c71d3a4..6e133d6 100644 --- a/web/src/features/account/pages/MinePage.tsx +++ b/web/src/features/account/pages/MinePage.tsx @@ -1,13 +1,13 @@ import { Link } from 'react-router-dom'; import { useState, type ReactNode } from 'react'; -import { ChevronRight, Film, LayoutGrid, LibraryBig, LogOut, Palette, PlayCircle, Puzzle, RefreshCw, Rss, Settings, SlidersHorizontal, UserRound, type LucideIcon } from 'lucide-react'; +import { ChevronRight, Film, LayoutGrid, LibraryBig, LogOut, Palette, PlayCircle, RefreshCw, Rss, Settings, SlidersHorizontal, UserRound, type LucideIcon } from 'lucide-react'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; import { useAuthStore } from '../../../shared/stores/auth.store.ts'; import { useSettingsStore } from '../../../shared/stores/settings.store.ts'; import { themeSummary } from '../../../shared/theme/theme.ts'; import { useThemeState } from '../../../shared/theme/use-theme.ts'; import { readMediaPreferences } from '../../media/services/preferences.ts'; -import { DENSITY_LABELS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx'; +import { DENSITY_LABELS, ShelfSettingsSheet } from '../../../ui/shelf-settings.tsx'; import { OpdsAccess } from '../../../ui/opds-access.tsx'; import { SystemSettings } from '../../../ui/system-settings.tsx'; import '../../home/styles/home.css'; @@ -75,7 +75,6 @@ export function MinePage() { {admin && setSheet('system')} /> - }
@@ -93,7 +92,7 @@ export function MinePage() {
{/* Outside the media screen, so its sheet styles are the shelf's own, not media's. */} - { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} /> + {sheet === 'shelf' && { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} />} {sheet === 'opds' && { void runtime.api.signOut(); }} onClose={() => setSheet('shelf')} />} {sheet === 'system' && setSheet(null)} />} ; diff --git a/web/src/features/media/components/audio-tools.tsx b/web/src/features/media/components/audio-tools.tsx index 54085e5..977c334 100644 --- a/web/src/features/media/components/audio-tools.tsx +++ b/web/src/features/media/components/audio-tools.tsx @@ -1,13 +1,11 @@ -import { Heart, X } from 'lucide-react'; -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { Heart } from 'lucide-react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { MediaApi } from '../api/media-api.ts'; +import { Sheet } from '../../../ui/sheet.tsx'; +/** The audio player's menus: the app's one sheet, with the audio content styles. */ export function AudioToolSheet({title,onClose,children}:{title:string;onClose:()=>void;children:ReactNode}){ - const ref=useRef(null); - useLayoutEffect(()=>{const dialog=ref.current;if(!dialog)return;const previous=document.activeElement as HTMLElement|null;dialog.showModal();return ()=>{dialog.close();queueMicrotask(()=>{if(previous?.isConnected)previous.focus({preventScroll:true});});};},[]); - return {event.preventDefault();onClose();}} onKeyDown={event=>{if(event.key==='Escape')event.stopPropagation();}} onClick={event=>{if(event.target===event.currentTarget){const rect=event.currentTarget.getBoundingClientRect();if(event.clientXrect.right||event.clientYrect.bottom)onClose();}}}> -

{title}

{children} -
; + return {children}; } export function PlaybackFavorite({api,itemId,onChanged}:{api:MediaApi;itemId:string;onChanged?:(id:string,favorite:boolean)=>void}){ diff --git a/web/src/features/media/components/folder-cleanup.tsx b/web/src/features/media/components/folder-cleanup.tsx index c47b154..e2cd610 100644 --- a/web/src/features/media/components/folder-cleanup.tsx +++ b/web/src/features/media/components/folder-cleanup.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import {Trash2} from 'lucide-react'; -import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; import type {MediaApi} from '../api/media-api.ts'; export interface CleanupPreview { @@ -32,6 +32,6 @@ export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned,menu return <> <>{menuOnly?:
失效资源检查当前目录及子目录的缺失记录
} {message&&

{message}

}{error&&

{error}

} - {preview&&setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>} + {preview&&setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>} ; } diff --git a/web/src/features/media/components/folder-recognition.tsx b/web/src/features/media/components/folder-recognition.tsx index 0f5bc2c..f21bd9c 100644 --- a/web/src/features/media/components/folder-recognition.tsx +++ b/web/src/features/media/components/folder-recognition.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import {Settings2} from 'lucide-react'; import '../styles/folder-recognition.css'; -import {Modal} from '../../../ui/modal.tsx'; -import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet, Sheet } from '../../../ui/sheet.tsx'; import {MediaSelect} from './select.tsx'; import type {MediaApi} from '../api/media-api.ts'; type Mode='auto'|'movie'|'series'|'season'|'ignore'; @@ -42,7 +41,7 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied, const selectRows=(rows:Proposal[])=>setSelected(current=>[...new Set([...current,...rows.filter(selectable).map(row=>row.assetId)])]); return <> <>{menuOnly?:
影视识别设置当前目录及子目录的识别规则
} - {open&&setOpen(false)}>
+ {open&&setOpen(false)}>

{path||'库内根目录'} · 包含子目录,子目录自己的规则优先。目录内文件统一应用下方文件正则。规则保存后用于后续扫描;已有作品通过下方预览确认重新识别。

{inherited&&

继承自「{inherited.path||'库内根目录'}」:{labels[inherited.mode]}{inherited.title?' · '+inherited.title:''}

} {error&&

{error}

}{notice&&

{notice}

} @@ -69,7 +68,7 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied, {preview.items.slice(page*30,page*30+30).map(row=>)} {preview.items.length>30&&} } -
} - {confirm&&setConfirm(false)} onConfirm={()=>{setConfirm(false);void work(async signal=>{await api.request(base+'/recognition-apply','POST',{path,previewId:preview!.id,assetIds:selected},signal);if(!signal.aborted){setOpen(false);onApplied();}});}}/>} +
} + {confirm&&setConfirm(false)} onConfirm={()=>{setConfirm(false);void work(async signal=>{await api.request(base+'/recognition-apply','POST',{path,previewId:preview!.id,assetIds:selected},signal);if(!signal.aborted){setOpen(false);onApplied();}});}}/>} ; } diff --git a/web/src/features/media/components/library-create.tsx b/web/src/features/media/components/library-create.tsx index 764e7ae..b6039ba 100644 --- a/web/src/features/media/components/library-create.tsx +++ b/web/src/features/media/components/library-create.tsx @@ -1,4 +1,4 @@ -import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; import { useEffect, useRef, useState } from 'react'; import type { Library, MediaApi, MediaChannel } from '../api/media-api.ts'; import { newId } from '../../../core/id.ts'; @@ -37,7 +37,7 @@ export function MediaLibraryCreate({ api, channel, disabled, onCreated, onJobs,o }; return
{!hideHeader &&

新建{labels[channel]}媒体库

} - {leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel?.();}}/>} + {leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel?.();}}/>} {error &&

{error}

} {created ? <>

媒体库“{created.name}”已创建。{scanStarted ? '扫描请求已提交,可查看任务结果。' : '尚未确认扫描启动,请先查看任务;如无任务,再启动扫描。'}

diff --git a/web/src/features/media/components/library-editor.tsx b/web/src/features/media/components/library-editor.tsx index 71ad0d8..a92cb97 100644 --- a/web/src/features/media/components/library-editor.tsx +++ b/web/src/features/media/components/library-editor.tsx @@ -1,4 +1,4 @@ -import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; import { useEffect, useRef, useState } from 'react'; import type { Library, LibraryConfiguration, MediaApi } from '../api/media-api.ts'; import {ChevronLeft} from 'lucide-react'; @@ -49,7 +49,7 @@ export function MediaLibraryEditor({ api, library, onSaved, onCancel }: { }; return

编辑媒体库

- {leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>} + {leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
{ event.preventDefault(); void save(); }}> {error &&
{error}{!configuration && }
} {!configuration && !error && } diff --git a/web/src/features/media/components/metadata-matcher.tsx b/web/src/features/media/components/metadata-matcher.tsx index 3695c66..8b3a16b 100644 --- a/web/src/features/media/components/metadata-matcher.tsx +++ b/web/src/features/media/components/metadata-matcher.tsx @@ -5,7 +5,7 @@ import {Search} from 'lucide-react'; import {MediaLoading} from './loading.tsx'; import {MediaScreenError} from './screen-error.tsx'; import {ApiError} from '../../../api/errors.ts'; -import {Modal} from '../../../ui/modal.tsx'; +import {ConfirmSheet} from '../../../ui/sheet.tsx'; import {FloatingNotice} from '../../../ui/floating-notice.tsx'; interface Provider { id: string; label: string; kinds: string[]; configured: boolean } @@ -123,10 +123,13 @@ export function MetadataMatcher({ api, item, onUpdated,layout='inline',onBusyCha
{candidate.title}{candidate.year || ''} {candidate.artist || ''} · {candidate.provider} / {candidate.externalId}{candidate.evidence&&{({strong:'较强匹配',review:'需要核对',conflict:'存在冲突'})[candidate.evidence.level]}:{candidate.evidence.reasons.join('、')}}{candidate.description && {candidate.description.length>160?candidate.description.slice(0,160)+'…':candidate.description}}{candidate.description&&candidate.description.length>160&&
完整简介

{candidate.description}

}
)} - {selected && {if(!busy)setSelected(null);}}>

将“{item.title}”匹配为“{selected.title}”。在线标题、年份和简介等字段将采用此来源;人工编辑的字段仍优先显示。

} + })} />} TMDB 数据来源于 TMDB,本应用未经 TMDB 认可或认证。MusicBrainz 数据来源于 MetaBrainz 社区。 ; } diff --git a/web/src/features/media/components/metadata-page.tsx b/web/src/features/media/components/metadata-page.tsx index 676f594..3bca2bb 100644 --- a/web/src/features/media/components/metadata-page.tsx +++ b/web/src/features/media/components/metadata-page.tsx @@ -1,4 +1,4 @@ -import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; import { useEffect, useRef, useState } from 'react'; import {ChevronLeft} from 'lucide-react'; import type {Detail,MediaApi} from '../api/media-api.ts'; @@ -18,7 +18,7 @@ export function MediaMetadataPage({api,item,initialView,onViewChange,onUpdated,o const requestLeave=(next:MetadataView|'back')=>{if(busy)return;if(view==='edit'&&editState.dirty)setDestination(next);else navigate(next);}; return <>

{view==='edit'?'元数据':'匹配作品'}

- {destination&&setDestination(null)} onConfirm={()=>navigate(destination)}/>} + {destination&&setDestination(null)} onConfirm={()=>navigate(destination)}/>} {notice&&

{notice}

} {view==='edit'?{setNotice('已保存修改。');onUpdated(updated);}}/>:{setNotice('在线匹配已更新,人工修正已保留。');onUpdated(updated);}}/>}
diff --git a/web/src/features/media/components/permissions.tsx b/web/src/features/media/components/permissions.tsx index d0ad065..8e85307 100644 --- a/web/src/features/media/components/permissions.tsx +++ b/web/src/features/media/components/permissions.tsx @@ -1,4 +1,4 @@ -import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; import {MediaSelect} from './select.tsx'; import { useEffect, useState, useRef } from 'react'; import {ChevronLeft,Search} from 'lucide-react'; @@ -42,7 +42,7 @@ export function MediaPermissions({api,library,onSaved,onCancel}:{api:MediaApi;li finally {if(pending.current===controller)pending.current=null;if(!controller.signal.aborted)setSaving(false);} }; return

访问权限

- {leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>} + {leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>} {event.preventDefault();if(ready&&!saving)void save();}}>

{library.name}

允许访问的用户共享作品资料,收藏与进度各自独立。

{error&&(ready?
{error}
:setAttempt(value=>value+1)}/>)} diff --git a/web/src/features/media/components/provider-settings.tsx b/web/src/features/media/components/provider-settings.tsx deleted file mode 100644 index 6497977..0000000 --- a/web/src/features/media/components/provider-settings.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useState } from 'react'; -import {ChevronRight,FileText,Search} from 'lucide-react'; -import type {MediaApi} from '../api/media-api.ts'; -import {MediaLoading} from './loading.tsx'; -import {MediaScreenError} from './screen-error.tsx'; -import {BusinessSettings} from '../../../ui/business-settings.tsx'; -import {MediaSelect} from './select.tsx'; - -interface Provider {id:string;label:string;kinds:string[];configured:boolean} -const kinds:Record={movie:'电影',series:'剧集',season:'季',episode:'单集',artist:'艺人',album:'专辑',track:'曲目',audiobook:'有声书'}; -export function MediaProviderSettings({api}:{api:MediaApi}){ - const [providers,setProviders]=useState([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[retry,setRetry]=useState(0),[selected,setSelected]=useState(''); - const [cause,setCause]=useState(); - useEffect(()=>{const abort=new AbortController();setLoading(true);setError('');setCause(undefined);void api.request<{items:Provider[]}>('metadata/providers','GET',undefined,abort.signal).then(result=>{if(!abort.signal.aborted)setProviders(result.items);}).catch(error=>{if(!abort.signal.aborted){setError(error instanceof Error?error.message:'来源状态读取失败');setCause(error);}}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});return ()=>abort.abort();},[api,retry]); - const provider=providers.find(value=>value.id===selected); - return
-

元数据刮削

- {loading&&} {error&&setRetry(value=>value+1)}/>} - {!loading&&!error&&providers.map(value=>)} - {!loading&&!error&&!providers.length&&

服务器尚未提供在线元数据来源。

} - {provider&&!loading&&!error&&

{provider.label}

{provider.configured?'服务器已配置此来源,可以从作品详情搜索候选,或从扫描与刮削页面发起匹配。':'此来源尚未配置,配置完成后才能搜索和匹配元数据。'}

- {['tmdb','musicbrainz'].includes(provider.id)?setRetry(value=>value+1)}/>:

配置方式由服务器提供此来源的适配器决定。

} -
} - - {selected==='local'&&

扫描时读取本地资料,人工覆盖优先于资料源。在线匹配不会修改原始媒体文件。

} -

内容来源

阅读书源保持独立管理。TVBox 兼容由后续影音来源插件提供,当前没有配置入口。

-

TMDB 数据来源于 TMDB,本应用未经 TMDB 认可或认证。MusicBrainz 数据来源于 MetaBrainz 社区。

-
; -} diff --git a/web/src/features/media/components/resource-panel.tsx b/web/src/features/media/components/resource-panel.tsx index eeeff10..3da7957 100644 --- a/web/src/features/media/components/resource-panel.tsx +++ b/web/src/features/media/components/resource-panel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Ellipsis, FileText, Layers } from 'lucide-react'; -import { Modal } from '../../../ui/modal.tsx'; +import { Sheet } from '../../../ui/sheet.tsx'; import { ResourceInfo } from './resource-info.tsx'; import { EditionTools, type EditionDetailsProps } from './edition-details.tsx'; import type { MediaApi } from '../api/media-api.ts'; @@ -19,7 +19,7 @@ type ResourcePanelProps = { }; /** - * Resource details are rendered in a modal. The legacy detail screen exposes + * Resource details are rendered in a sheet. The legacy detail screen exposes * the entry point from the page action menu, so the summary trigger is opt-in * for standalone consumers and hidden on the detail page. */ @@ -67,8 +67,9 @@ export function ResourcePanel({ } const modal = panel && ( - {versionPicker}{manage && editionOptions && }} - + ); if (!showTrigger) return modal; - return <>{modal}
{ - if (event.target instanceof HTMLDialogElement && !editionOptions?.busy) { - const rect = event.target.getBoundingClientRect(); - if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close(); - } - }}> + return <>{modal}

资源信息

{(files.length > 0 || manage || versionPicker || sourceInfo) &&
{ diff --git a/web/src/features/media/components/scrape-jobs.tsx b/web/src/features/media/components/scrape-jobs.tsx index 80c9733..5080c78 100644 --- a/web/src/features/media/components/scrape-jobs.tsx +++ b/web/src/features/media/components/scrape-jobs.tsx @@ -1,4 +1,4 @@ -import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; import {FloatingNotice} from '../../../ui/floating-notice.tsx'; import {MediaSelect} from './select.tsx'; import { ChevronDown, ChevronRight } from 'lucide-react'; @@ -113,6 +113,6 @@ export function ScrapeJobs({api,libraries,navigate}:{api:MediaApi;libraries:Libr
{taskHistory} - {deleteId&&setDeleteId('')} onConfirm={()=>{const id=deleteId;setDeleteId('');void action('scrape-jobs/'+encodeURIComponent(id),undefined,'DELETE');}}/>} + {deleteId&&setDeleteId('')} onConfirm={()=>{const id=deleteId;setDeleteId('');void action('scrape-jobs/'+encodeURIComponent(id),undefined,'DELETE');}}/>}
; } diff --git a/web/src/features/media/components/settings.tsx b/web/src/features/media/components/settings.tsx index 8e3dc38..4246d0c 100644 --- a/web/src/features/media/components/settings.tsx +++ b/web/src/features/media/components/settings.tsx @@ -1,14 +1,13 @@ -import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; import {MediaSelect} from './select.tsx'; import {PlaybackSettings} from './playback-settings.tsx'; import {MediaThemeSettings} from './theme-settings.tsx'; import type {AppSettings} from '../../../store/settings.ts'; -export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'plugins'|'account'; +export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'account'; import { useState } from 'react'; -import {ChevronLeft,ChevronRight,Clock3,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Puzzle} from 'lucide-react'; +import {ChevronLeft,ChevronRight,Clock3,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound} from 'lucide-react'; import type {MediaPlayer} from '../services/player.ts'; import type {MediaApi,MediaAccount} from '../api/media-api.ts'; -import {MediaProviderSettings} from './provider-settings.tsx'; import {defaultMediaPreferences,saveMediaPreferences,type MediaPreferences} from '../services/preferences.ts'; /** @@ -27,17 +26,17 @@ export function MediaSettings({scope,preferences,player,admin,api: _api,account, function save(){setError('');setSaved(false);try{const value=saveMediaPreferences(scope,draft);onSaved(value);setSaved(true);}catch(error){setError(error instanceof Error?error.message:'保存失败');}} function change(patch:Partial){setDraft({...draft,...patch});setSaved(false);} function openBrowse(){if(!dirty)setDraft({...preferences});setError('');setSaved(false);setPanel('browse');} - const title=panel==='theme'?'外观与主题':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='plugins'?'来源与刮削':panel==='account'?'账号与连接':'影音设置'; + const title=panel==='theme'?'外观与主题':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='account'?'账号与连接':'影音设置'; const link=(Icon:typeof Clock3,label:string,description:string,onClick:()=>void)=>; return

{title}

- {leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);setDraft({...preferences});setPanel('home');}}/>} + {leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);setDraft({...preferences});setPanel('home');}}/>} {panel==='home'?<>

偏好

{link(SlidersHorizontal,'浏览偏好',preferences.density==='compact'?'紧凑':'宽松',openBrowse)} {link(Headphones,'播放设置','倍速与连续播放',()=>setPanel('playback'))}
- {admin&&

管理员

{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}{_api&&link(Puzzle,'来源与刮削','TMDB、MusicBrainz 等资料来源',()=>setPanel('plugins'))}
} + {admin&&

管理员

{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}
} {onGeneral&&

通用

{link(UserRound,'外观、账号与系统设置','在「我的」中管理',onGeneral)}
} :panel==='theme'?:panel==='browse'?{event.preventDefault();save();}}>

仅影响影音页面,在当前设备为此服务器和账号保存。

@@ -47,6 +46,6 @@ export function MediaSettings({scope,preferences,player,admin,api: _api,account,
{saved&&

影音偏好已保存

}{error&&

{error}

} - :panel==='plugins'?admin&&_api?:

需要管理员权限

请返回影音设置查看可用功能。

:panel==='account'&&account?

{account.displayName||account.username}

@{account.username} · {account.role==='admin'?'管理员':'普通用户'}

共享作品资料,收藏、队列和播放进度按账号隔离。

当前服务器
{account.server||window.location.origin}
:} + :panel==='account'&&account?

{account.displayName||account.username}

@{account.username} · {account.role==='admin'?'管理员':'普通用户'}

共享作品资料,收藏、队列和播放进度按账号隔离。

当前服务器
{account.server||window.location.origin}
:}
; } diff --git a/web/src/features/media/components/video-controls.tsx b/web/src/features/media/components/video-controls.tsx index 738378d..59d1026 100644 --- a/web/src/features/media/components/video-controls.tsx +++ b/web/src/features/media/components/video-controls.tsx @@ -1,6 +1,7 @@ import {VideoEpisodeRail} from './video-episode-rail.tsx'; import {SleepTimerOptions} from './sleep-timer.tsx'; -import {Ellipsis,X,Square,Check,Subtitles,AudioLines,Layers,ListVideo,Maximize,Minimize,Lock,LockOpen,Gauge,Expand,Shrink,Upload} from 'lucide-react'; +import {Sheet} from '../../../ui/sheet.tsx'; +import {Ellipsis,Square,Check,Subtitles,AudioLines,Layers,ListVideo,Maximize,Minimize,Lock,LockOpen,Gauge,Expand,Shrink,Upload} from 'lucide-react'; import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; import {subtitleSizes,type MediaPlayer,type SubtitleSize} from '../services/player.ts'; import type {Detail,MediaApi} from '../api/media-api.ts'; @@ -63,11 +64,7 @@ function Volume({player}:{player:MediaPlayer}){ } function ToolSheet({tool,onClose,children,tabs}:{tool:Tool;onClose:()=>void;children:ReactNode;tabs?:ReactNode}){ - const ref=useRef(null); - useLayoutEffect(()=>{const dialog=ref.current;if(!dialog)return;dialog.showModal();return ()=>dialog.close();},[]); - return {event.preventDefault();onClose();}} onKeyDown={event=>{if(event.key==='Escape')event.stopPropagation();}} onClick={event=>{if(event.target===event.currentTarget){const rect=event.currentTarget.getBoundingClientRect();if(event.clientXrect.right||event.clientYrect.bottom)onClose();}}}> -

{titles[tool]}

{tabs}{children} -
; + return {tabs}{children}; } /** Rendered in the permanent player's controls host, alongside the live video. */ diff --git a/web/src/features/media/pages/MediaQueuePage.tsx b/web/src/features/media/pages/MediaQueuePage.tsx index cfb608b..b825513 100644 --- a/web/src/features/media/pages/MediaQueuePage.tsx +++ b/web/src/features/media/pages/MediaQueuePage.tsx @@ -8,7 +8,7 @@ import { queueQuery, type Activity } from '../queries/media.queries.ts'; import { channelLabels } from '../hooks/use-media-channel.ts'; import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx'; import { SavedQueue } from '../components/saved-queue.tsx'; -import { FloatingConfirm } from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; function queueChannel(kind: string): 'video' | 'music' | 'audiobook' { return kind === 'audiobook' ? 'audiobook' : ['album', 'artist', 'track'].includes(kind) ? 'music' : 'video'; @@ -72,6 +72,6 @@ export function MediaQueuePage() { onClear={() => setConfirming(true)} />} {!query.isPending && !query.error && entries.length === 0 &&
} - {confirming && { setConfirming(false); action.mutate({ type: 'clear' }); }} onCancel={() => setConfirming(false)} />} + {confirming && { setConfirming(false); action.mutate({ type: 'clear' }); }} onCancel={() => setConfirming(false)} />}
; } diff --git a/web/src/features/media/pages/MediaSettingsPage.tsx b/web/src/features/media/pages/MediaSettingsPage.tsx index 7957f41..dbc8165 100644 --- a/web/src/features/media/pages/MediaSettingsPage.tsx +++ b/web/src/features/media/pages/MediaSettingsPage.tsx @@ -7,10 +7,10 @@ import type { SettingsPanel } from '../components/settings.tsx'; import { readMediaPreferences } from '../services/preferences.ts'; import { useAuthStore } from '../../../shared/stores/auth.store.ts'; -const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'plugins', 'account']; +const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'account']; const titles: Record = { home: '影音设置', theme: '外观与主题', browse: '浏览偏好', playback: '播放设置', - plugins: '来源与刮削', account: '账号与连接', + account: '账号与连接', }; function safePanel(value: string | undefined): SettingsPanel { diff --git a/web/src/features/media/styles/presentation.css b/web/src/features/media/styles/presentation.css index 58cc89e..9306431 100644 --- a/web/src/features/media/styles/presentation.css +++ b/web/src/features/media/styles/presentation.css @@ -2,7 +2,7 @@ brand *text* (links, selected icons, outlines); --media-fill is a brand-filled control and --media-on-accent what sits on it. Dialogs and confirmations opened from media read the same names. */ -.media-screen, .media-player, .media-channel-entry, .media-modal, .confirmation-host[data-media-theme=true] { +.media-screen, .media-player, .media-channel-entry, .media-modal { --text: var(--color-ink); --muted: var(--color-muted); --surface: var(--color-surface-2); @@ -25,8 +25,9 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-screen { max-width:1120px; padding:62px 30px 150px; background:var(--media-background); } .media-screen button, .media-screen input, .media-screen select, .media-screen textarea { font:inherit; } -.media-screen button { border:0; background:transparent; border-radius:7px; min-height:38px; padding:9px 12px; } -.media-screen button:hover:not(:disabled) { background:var(--surface); } +/* The shared sheet's header and footer use the app's own buttons, not media's. */ +.media-screen button:not(:where(.sheet-header *, .sheet-footer *)) { border:0; background:transparent; border-radius:7px; min-height:38px; padding:9px 12px; } +.media-screen button:not(:where(.sheet-header *, .sheet-footer *)):hover:not(:disabled) { background:var(--surface); } .media-screen button:focus-visible, .media-channel-entry a:focus-visible { outline:2px solid var(--media-accent); outline-offset:3px; } .media-screen input:not([type=range]):not([type=checkbox]):not([type=radio]), .media-screen textarea { background:var(--media-paper); border:1px solid var(--border); border-radius:7px; @@ -258,18 +259,14 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-lyrics-empty h3 {margin:0;font-size:16px;font-weight:500;color:var(--text)} .media-lyrics-loading i {display:block;width:65%;height:12px;background:var(--border);border-radius:5px;opacity:.5} .media-lyrics-loading i:nth-last-child(2) {width:80%} -.media-screen .media-audio-sheet {position:fixed;inset:auto 0 0;margin:0 auto;padding:20px 22px 25px;width:480px;max-width:100%;max-height:80dvh;box-sizing:border-box;overflow:auto;border:1px solid var(--border);border-bottom:0;border-radius:15px 15px 0 0;background:var(--media-paper);color:var(--text);font-size:14px;line-height:1.6;font-family:inherit;box-shadow:0 -10px 40px #0003} -.media-audio-sheet::backdrop {background:#10160c66} -.media-audio-sheet>header {display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px} -.media-screen .media-audio-sheet h2 {font-size:16px;margin:0;font-weight:600} -.media-audio-sheet>header>button {display:grid;place-items:center;width:44px;height:44px;padding:10px;border:0;background:transparent} +.media-audio-sheet .sheet-body {font-size:14px;line-height:1.6} .media-audio-sheet .media-volume,.media-audio-speed>label {display:flex;align-items:center;gap:12px;font-size:13px;min-height:48px} .media-audio-sheet input[type=range] {flex:1;min-width:0;width:100%;accent-color:var(--media-accent);padding:0;border:0;background:transparent} .media-audio-speed select {flex:1;min-width:0} .media-audio-shortcuts {display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0} .media-audio-shortcuts button,.media-audio-sheet .media-end-playback {display:flex;gap:8px;align-items:center;justify-content:center} -.media-audio-sheet>[aria-label="保存播放列表"] {margin:12px 0} -.media-audio-sheet>[aria-label="保存播放列表"] button {margin:4px 6px 4px 0} +.media-audio-sheet .sheet-body>[aria-label="保存播放列表"] {margin:12px 0} +.media-audio-sheet .sheet-body>[aria-label="保存播放列表"] button {margin:4px 6px 4px 0} .media-audio-sheet p {font-size:12px;line-height:1.7;overflow-wrap:anywhere} .media-audio-sheet .media-audio-support {color:var(--muted);margin:12px 0} .media-audio-versions {display:grid;gap:8px} @@ -277,7 +274,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-audio-versions strong {min-width:0;font-weight:500;overflow-wrap:anywhere} .media-audio-versions small {flex-shrink:0;font-size:12px;color:var(--muted)} .media-audio-versions button[aria-current=true] {border-color:var(--media-accent);background:var(--media-selection);opacity:1} -@media(max-width:700px){.media-screen .media-audio-sheet{padding:16px 16px calc(20px + env(safe-area-inset-bottom))}} @media(max-width:700px){ .media-screen .media-heading:has(.media-mobile-title) {display:grid;grid-template-columns:1fr auto;gap:0;border:0} .media-mobile-title {display:flex;align-items:center;min-height:49px;grid-row:1;grid-column:1} @@ -742,10 +738,10 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-video-stage {aspect-ratio:16/9;background:#060906;display:flex;align-items:center;justify-content:center} .media-video-stage video {display:block;width:100%;height:100%;max-height:none;object-fit:contain} .media-video-workspace .media-video-controls {display:block;max-width:none;max-height:none;padding:15px 20px 30px;margin:0;border:0;border-radius:0;background:transparent;overflow:visible} -.media-video-workspace button,.media-video-workspace select,.media-video-sheet button,.media-video-sheet input,.media-video-sheet select {font:inherit;color:inherit;box-sizing:border-box;min-height:44px;border:0;border-radius:7px;padding:9px 12px;background:transparent} -.media-video-workspace button,.media-video-sheet button {cursor:pointer} -.media-video-workspace button:disabled,.media-video-sheet button:disabled {opacity:.45;cursor:default} -.media-video-workspace button:hover:not(:disabled),.media-video-sheet button:hover:not(:disabled) {background:var(--surface)} +.media-video-workspace button,.media-video-workspace select,.media-video-sheet .sheet-body button,.media-video-sheet .sheet-body input,.media-video-sheet .sheet-body select {font:inherit;color:inherit;box-sizing:border-box;min-height:44px;border:0;border-radius:7px;padding:9px 12px;background:transparent} +.media-video-workspace button,.media-video-sheet .sheet-body button {cursor:pointer} +.media-video-workspace button:disabled,.media-video-sheet .sheet-body button:disabled {opacity:.45;cursor:default} +.media-video-workspace button:hover:not(:disabled),.media-video-sheet .sheet-body button:hover:not(:disabled) {background:var(--surface)} .media-video-workspace :focus-visible,.media-video-sheet :focus-visible {outline:2px solid var(--media-accent);outline-offset:2px} .media-video-toolbar,.media-video-toolbar>div {display:flex;align-items:center;gap:6px;min-width:0} .media-video-toolbar {justify-content:space-between;margin:5px 0} @@ -768,10 +764,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-video-workspace .media-playback-problem .media-primary {background:var(--media-fill);color:var(--media-on-accent)} .media-video-controls .media-screen-error {margin:18px 0;font-size:12px} .media-video-controls .media-screen-error-actions {display:flex;gap:8px} -.media-video-sheet {position:fixed;inset:auto 0 0;margin:0 auto;padding:20px 22px 25px;border:1px solid var(--border);border-bottom:0;border-radius:15px 15px 0 0;width:540px;max-width:100%;max-height:80dvh;box-sizing:border-box;overflow:auto;background:var(--media-paper);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;box-shadow:0 -10px 50px #0004} -.media-video-sheet::backdrop {background:#10160c88} -.media-video-sheet header {display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px} -.media-video-sheet h2 {font-size:17px;margin:0;font-weight:600} +.media-video-sheet .sheet-body {font-size:14px;line-height:1.6} .media-video-sheet p {font-size:12px;line-height:1.75;color:var(--muted);overflow-wrap:anywhere} .media-video-sheet .media-volume,.media-video-sleep {display:flex;align-items:center;justify-content:space-between;gap:16px} .media-video-sheet .media-volume input {flex:1;width:100%;min-width:0;accent-color:var(--media-accent);padding:0} @@ -795,7 +788,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-player.media-player-expanded:not(:is(.media-player-audio,.media-player-native-video)) {inset:49px 0 0;width:100%} .media-video-stage {aspect-ratio:16/10} .media-video-workspace .media-video-controls {padding:13px 13px 25px} - .media-video-sheet {padding:16px 16px calc(20px + env(safe-area-inset-bottom))} } @media(min-width:700px) and (max-height:500px){ .media-video-workspace {display:grid;grid-template-columns:58% minmax(0,1fr);align-items:start} @@ -841,19 +833,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-permission-pagination {font-size:12px;color:var(--muted);margin:0} .media-user-option>span {min-width:0;overflow-wrap:anywhere;line-height:1.6} .media-user-option>input {flex-shrink:0} -.media-provider-settings {padding-top:24px} -.media-provider-settings>h2 {margin:0 0 4px;font-size:16px} -.media-provider-settings>h2:not(:first-child) {margin-top:28px} -.media-screen .media-provider-row {display:flex;align-items:center;width:100%;gap:12px;min-height:70px;padding:13px 0;border-bottom:1px solid var(--border);border-radius:0;text-align:left} -.media-provider-row>svg {flex-shrink:0;color:var(--muted)} -.media-provider-row>span {flex:1;min-width:0;overflow-wrap:anywhere} -.media-provider-row strong {font-size:14px;font-weight:500} -.media-provider-row span>small {display:block;color:var(--muted);font-size:12px;margin-top:4px} .media-status-badge {display:inline-block;font-size:10px;background:var(--media-selection);color:var(--media-accent);padding:3px 6px;border-radius:4px;white-space:nowrap} -.media-provider-note {background:var(--surface);border-radius:8px;padding:14px;font-size:12px;color:var(--muted);line-height:1.8;margin:12px 0} -.media-provider-note h3 {margin:0;color:var(--text);font-size:14px} -.media-provider-note code {overflow-wrap:anywhere;font-size:12px} -.media-source-attribution {font-size:11px;color:var(--muted);line-height:1.8;margin-top:24px} .media-account-panel {max-width:640px;margin:32px auto} .media-account-avatar {display:grid;place-items:center;width:60px;height:60px;border-radius:50%;background:var(--media-selection);color:var(--media-accent);font-size:26px} .media-account-panel h2 {font-size:23px;margin:16px 0 4px;overflow-wrap:anywhere} @@ -912,7 +892,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-screen .media-resource-panel>header h2 {margin:0;font-size:16px} .media-resource-panel>p {font-size:12px;line-height:1.8;color:var(--muted);margin:8px 0 20px} .media-resource-panel .media-actions nav button {display:flex;align-items:center;gap:8px} -.media-resource-panel .source-modal {width:min(620px,calc(100vw - 28px));max-height:85dvh;padding:20px;color:var(--text);background:var(--media-paper);border:1px solid var(--border);border-radius:12px} .media-resource-dialog {overflow-wrap:anywhere;font-size:13px;line-height:1.8} .media-resource-expanded {padding:16px 0;border-bottom:1px solid var(--border)} .media-resource-expanded:last-child {border-bottom:0} @@ -922,10 +901,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } .media-search-count {font-size:12px;color:var(--muted);margin:12px 0} /* Resource dialogs: one compact header, with a separately scrolling body. */ -.media-resource-panel .source-modal {padding:0;box-sizing:border-box} -.media-resource-panel .source-modal-header {min-height:56px;box-sizing:border-box;padding:6px 12px 6px 18px;gap:12px;border-color:var(--border)} -.media-resource-panel .source-modal-header h2 {margin:0;line-height:24px;font-size:16px} -.media-resource-panel .source-modal-header button {display:grid;place-items:center;width:44px;height:44px;min-height:44px;padding:10px;flex:none;color:var(--text)} .media-resource-dialog {padding:4px 18px 16px;overflow-y:auto;min-height:0;overscroll-behavior:contain} .media-resource-dialog .media-edition-tools {margin:0} .media-resource-dialog .media-edition-admin {border:0;padding:0} @@ -969,9 +944,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); } } /* Media dialogs and player selects inherit the active media palette. */ -.media-modal {background:var(--media-paper);color:var(--text);border-color:var(--border)} -.media-confirm-popup {border-color:var(--color-line)!important;background:var(--color-surface)!important;color:var(--color-ink)!important} -.media-confirm-popup .swal2-html-container {color:var(--color-muted)!important} .media-video-workspace select,.media-video-sheet select,.media-subtitle-controls select {appearance:none;-webkit-appearance:none;border:1px solid var(--border);background:var(--surface);color:var(--text);padding-right:30px;color-scheme:inherit} .media-video-workspace select,.media-video-sheet select,.media-subtitle-controls select {background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat} .media-native-controls select {appearance:none;-webkit-appearance:none;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 30px 7px 10px;background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat} diff --git a/web/src/features/media/styles/v2.css b/web/src/features/media/styles/v2.css index 454ec57..6134f03 100644 --- a/web/src/features/media/styles/v2.css +++ b/web/src/features/media/styles/v2.css @@ -2,7 +2,7 @@ DPlayer owns the video controls; this sheet only lays out its stage. */ .media-screen {padding:68px 32px 96px;font-size:14px;scrollbar-width:thin} .media-channel-entry {height:68px;padding-inline:max(32px,calc((100vw - 1056px)/2))} -.media-screen button {min-height:44px;border-radius:9px} +.media-screen button:not(:where(.sheet-header *, .sheet-footer *)) {min-height:44px;border-radius:9px} .media-screen :is(input:not([type=checkbox]):not([type=radio]):not([type=range]),select) {min-height:44px} .media-screen :is(.media-heading,.media-settings-heading,.media-narrator-heading) {min-height:56px} .media-screen :is(.media-page-heading h1,.media-page-heading h2,.media-heading>strong,.media-settings-heading h1) {font-size:16px;font-weight:650;line-height:1.4} @@ -248,8 +248,6 @@ .searchable-select-options button {display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;min-height:38px;padding:8px 10px;border:0;border-radius:6px;background:transparent;color:var(--text);text-align:left} .searchable-select-options button:hover,.searchable-select-options button[aria-selected=true] {background:var(--media-selection);color:var(--media-accent)} .searchable-select-options p {padding:12px;color:var(--muted);font-size:12px} -.media-audio-sheet,.media-video-sheet {max-height:85dvh} -.media-audio-sheet>header,.media-video-sheet>header {height:56px;min-height:56px;position:sticky;top:-16px;background:inherit;z-index:2;margin-bottom:12px} @media(min-width:701px){ .media-screen>.media-heading:has(.media-tabs:not([hidden])) {display:block;height:auto;min-height:0;margin:0;border:0;padding:0} .media-screen .media-mobile-title {display:none} @@ -265,7 +263,6 @@ .media-utility-workspace .media-settings-heading {left:max(24px,calc((100vw - 1060px)/2))} .media-screen .media-mobile-title {height:68px;left:max(145px,calc((100vw - 1056px)/2 + 110px))} .media-heading:has(.media-mobile-title) .media-heading-actions {top:12px} - .media-audio-sheet,.media-video-sheet {inset:0;margin:auto;border:1px solid var(--border);border-radius:14px;height:fit-content} .media-screen .media-playback-status[data-panel=main] {display:grid;grid-template-columns:360px minmax(0,1fr);grid-template-rows:1fr auto auto auto 1fr;column-gap:64px;min-height:540px;align-content:center} .media-playback-status[data-panel=main] .media-playing-artwork {display:contents} .media-playback-status[data-panel=main] .media-playing-artwork>.media-cover {grid-column:1;grid-row:1/6;width:360px;margin:auto;align-self:center} @@ -577,7 +574,6 @@ .media-video-workspace:has(.media-episode-rail) .media-episode-rail {position:absolute;right:0;top:16px;width:300px;box-sizing:border-box;margin:0} } @media(max-width:700px){ - .media-resource-panel .source-modal {inset:auto 0 0;width:100%;max-width:100%;margin:0;border-radius:14px 14px 0 0;max-height:85dvh} .media-screen {padding:0 16px calc(24px + env(safe-area-inset-bottom))} .media-screen.media-catalog-page .media-heading:has(.media-mobile-title) {display:flex;grid-template-rows:none;min-height:56px;height:56px} .media-screen .media-heading:has(.media-mobile-title) {grid-template-rows:56px 44px;min-height:100px} @@ -913,14 +909,9 @@ .media-screen .media-actions nav :is(a,button) svg {flex:none;stroke:currentColor} .media-screen .media-library-management-actions>summary {color:var(--text)} .media-screen .media-library-management-actions[open]>summary {background:var(--media-selection);color:var(--media-accent)} -.media-modal:has(.media-match-confirm){width:min(560px,calc(100vw - 32px));max-width:560px;padding:0;border-radius:16px;overflow:hidden} -.media-modal:has(.media-match-confirm) .media-match-confirm{margin:0;padding:20px;border:0;border-radius:0;background:var(--media-paper)} -.media-modal:has(.media-match-confirm) .media-match-confirm p{font-size:14px;line-height:1.75;margin:0 0 18px} -.media-modal:has(.media-match-confirm) .media-match-confirm .dialog-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap} -.media-modal:has(.media-match-confirm) .media-match-confirm .dialog-actions button{min-height:44px} .search-split{position:relative;isolation:isolate} .sources-search .search-menu{right:0;left:auto;min-width:260px;box-sizing:border-box} -@media(max-width:640px){.media-screen .media-modal .media-match-confirm .dialog-actions{justify-content:stretch}.media-screen .media-modal .media-match-confirm .dialog-actions button{flex:1 1 140px}.sources-search .search-menu{right:0;min-width:min(290px,calc(100vw - 32px))}} +@media(max-width:640px){.sources-search .search-menu{right:0;min-width:min(290px,calc(100vw - 32px))}} /* Keep the embedded player's own controls independent from the reader theme. */ .media-video-stage,.media-video-stage *{color-scheme:dark} diff --git a/web/src/features/shelf/pages/ShelfPage.tsx b/web/src/features/shelf/pages/ShelfPage.tsx index 7fd2219..f943906 100644 --- a/web/src/features/shelf/pages/ShelfPage.tsx +++ b/web/src/features/shelf/pages/ShelfPage.tsx @@ -2,12 +2,13 @@ import { Alert } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; import { Link, useSearchParams } from 'react-router-dom'; import { useEffect, useMemo, useState, type FormEvent } from 'react'; -import { BookOpen, LibraryBig, MoreHorizontal, Search, SlidersVertical } from 'lucide-react'; +import { BookOpen, LibraryBig, MoreHorizontal, Search } from 'lucide-react'; import type { Book, ContinueReadingItem, ReadingState } from '../../../api/types.ts'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; import { useSettingsStore } from '../../../shared/stores/settings.store.ts'; import { useShelf, useShelfGroups, type ShelfFilters } from '../queries/shelf.queries.ts'; -import { SHELF_SORTS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx'; +import { SHELF_SORTS, ShelfSettingsSheet } from '../../../ui/shelf-settings.tsx'; +import { IconButton } from '../../../ui/toolkit.tsx'; import { OpdsAccess } from '../../../ui/opds-access.tsx'; import { DialogView, type Dialog } from '../../../ui/dialog.tsx'; import { FloatingNotice } from '../../../ui/floating-notice.tsx'; @@ -116,7 +117,7 @@ export function ShelfPage() {

{queryText ? '搜索结果' : '书架'}

{subtitle}

- + setSettingsOpen(true)} />
@@ -149,7 +150,7 @@ export function ShelfPage() { {cards.length > 0 &&
{cards.map(book => setMenuTarget(book)} />)}
} {query.data && pages > 1 && }
- { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} /> + {settingsOpen && { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} />} {opdsOpen && { void runtime.api.signOut(); }} onClose={() => { setOpdsOpen(false); setSettingsOpen(true); }} />} {menuTarget && undefined } satisfies Dialog} destructive="从书架拿掉" onClose={answer => { const target = menuTarget; setMenuTarget(null); if (typeof answer === 'string' && answer) void act(target, answer as BookAction); }} />} {groupsOpen && undefined } satisfies Dialog} destructive="" onClose={answer => { diff --git a/web/src/features/sources/components/SourceBookDetailSheet.tsx b/web/src/features/sources/components/SourceBookDetailSheet.tsx new file mode 100644 index 0000000..771a436 --- /dev/null +++ b/web/src/features/sources/components/SourceBookDetailSheet.tsx @@ -0,0 +1,30 @@ +import type { SourceEntry } from '../../../api/sources.ts'; +import { Sheet } from '../../../ui/sheet.tsx'; +import { Button } from '../../../ui/toolkit.tsx'; +import { FloatingNotice } from '../../../ui/floating-notice.tsx'; + +/** + * A source result's detail, shared by the source hub and a single source's catalog: + * they show the same entry and offer the same 加入书架 choices. + */ +export function SourceBookDetailSheet({ detail, pending, error, acquiring, onAcquire, onClose }: { + detail: SourceEntry; + pending: boolean; + error: Error | null; + acquiring: boolean; + onAcquire(entry: SourceEntry, option?: string): void; + onClose(): void; +}) { + const options = detail.options ?? []; + return ) + : }> + +
+

{detail.authors?.join('、') || '作者未知'}

+ {error &&

{error.message}

} +

{detail.description || '暂无简介'}

+
+
; +} diff --git a/web/src/features/sources/pages/SourceCatalogPage.tsx b/web/src/features/sources/pages/SourceCatalogPage.tsx index b125b5c..066107f 100644 --- a/web/src/features/sources/pages/SourceCatalogPage.tsx +++ b/web/src/features/sources/pages/SourceCatalogPage.tsx @@ -1,4 +1,5 @@ -import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core'; +import { Alert, Button, Group, Select, Text } from '@mantine/core'; +import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; @@ -69,18 +70,7 @@ export function SourceCatalogPage() { {busy && !page && }
{page?.items.map(item => { const option = item.options?.[0]; return
{item.title}{item.latestChapter && {item.latestChapter}}{item.authors?.length ? {item.authors.join('、')} : null}{item.description &&

{item.description}

}
; })}
{page && !busy && page.items.length === 0 && 没有找到匹配书籍。} - setDetail(null)} title={detail?.title ?? '书籍详情'}> - - {detailQuery.isPending && } - {detailQuery.error && {detailQuery.error.message}} - {detail && <> - {detail.description || '暂无简介'} - {detail.authors?.join('、') || '作者未知'} - {detail.options?.map(option => )} - {(!detail.options || detail.options.length === 0) && } - } - - + {detail && setDetail(null)} />} ; } diff --git a/web/src/features/sources/pages/SourceExtensionPage.tsx b/web/src/features/sources/pages/SourceExtensionPage.tsx index d77625a..f46fc75 100644 --- a/web/src/features/sources/pages/SourceExtensionPage.tsx +++ b/web/src/features/sources/pages/SourceExtensionPage.tsx @@ -5,7 +5,7 @@ import type { ExtensionContent, ExtensionField, ExtensionForm, ExtensionPage } f import { ApiError } from '../../../api/errors.ts'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; import { useAuthStore } from '../../../shared/stores/auth.store.ts'; -import { FloatingConfirm } from '../../../ui/floating-confirm.tsx'; +import { ConfirmSheet } from '../../../ui/sheet.tsx'; import { FloatingNotice } from '../../../ui/floating-notice.tsx'; import { Button, IconButton } from '../../../ui/toolkit.tsx'; @@ -154,7 +154,7 @@ export function SourceExtensionPage() {
{page.description &&

{page.description}

} {selectedTab &&
{selectedTab.description &&

{selectedTab.description}

}
} -
{confirmation && setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />} + {confirmation && setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />} ; } diff --git a/web/src/features/sources/pages/SourcesPage.tsx b/web/src/features/sources/pages/SourcesPage.tsx index a40fa1b..d02101a 100644 --- a/web/src/features/sources/pages/SourcesPage.tsx +++ b/web/src/features/sources/pages/SourcesPage.tsx @@ -1,4 +1,7 @@ -import { Alert, Button, Modal, Stack, Text } from '@mantine/core'; +import { Alert, Button } from '@mantine/core'; +import { Sheet } from '../../../ui/sheet.tsx'; +import { Button as UiButton } from '../../../ui/toolkit.tsx'; +import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx'; import { Link, useNavigate } from 'react-router-dom'; import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -144,7 +147,13 @@ export function SourcesPage() { {tab === 'updates' &&

自动追更

服务端定时检查目录,浏览器关闭后继续运行。正文和图片在阅读时缓存。

{subscriptions.isPending && }{!subscriptions.isPending && !subscriptions.data?.length &&

先从章节书源加入书籍,再开启追更。

}{(subscriptions.data ?? []).map(item =>
{item.title}{item.newChapters ? '新增 ' + item.newChapters + ' 章 · ' : ''}上次成功:{item.lastSuccessAt ? new Date(item.lastSuccessAt).toLocaleString() : '尚未检查'}{item.enabled ? '下次检查:' + new Date(item.nextCheckAt).toLocaleString() : '自动追更已关闭'}{item.lastError ? ' · 检查失败:' + item.lastError : ''}
阅读
)}
} {tab === 'plugins' && admin &&

插件管理

插件安装与管理{plugins.isPending && }{(plugins.data ?? []).map(plugin =>
{plugin.name || plugin.pluginId}{plugin.version || '未知版本'} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}
)}
} - {activeGroup && { setResultGroupKey(null); setSourceNameQuery(''); }} title="书源列表">{activeGroup.entry.title} · {activeGroup.entries.length} 条书源 setSourceNameQuery(event.currentTarget.value)} />{activeGroup.entries.filter(entry => (entry.sourceName ?? selectedSource?.name ?? '').toLocaleLowerCase().includes(sourceNameQuery.trim().toLocaleLowerCase())).map(entry => { const option = entry.options?.[0]; return
{entry.sourceName ?? selectedSource?.name}

最新章节:{entry.latestChapter || '暂无信息'}

; })}
} - setDetail(null)} title={detail?.title ?? '书籍详情'}>{detailQuery.isPending && }{detailQuery.error && {detailQuery.error.message}}{detail && <>{detail.description || '暂无简介'}{detail.authors?.join('、') || '作者未知'}{detail.options?.map(option => )}{(!detail.options || detail.options.length === 0) && }} + {activeGroup && { setResultGroupKey(null); setSourceNameQuery(''); }}> +
+

{activeGroup.entry.title}

+ setSourceNameQuery(event.currentTarget.value)} /> + {activeGroup.entries.filter(entry => (entry.sourceName ?? selectedSource?.name ?? '').toLocaleLowerCase().includes(sourceNameQuery.trim().toLocaleLowerCase())).map(entry => { const option = entry.options?.[0]; return
{entry.sourceName ?? selectedSource?.name}

最新章节:{entry.latestChapter || '暂无信息'}

setDetail(entry)}>详情 acquireEntry(entry, option?.id)}>{option?.label ?? '加入书架'}
; })} +
+
} + {detail && setDetail(null)} />} ; } diff --git a/web/src/main.tsx b/web/src/main.tsx index fe2f21d..ece75ab 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,5 +1,4 @@ import '@mantine/core/styles.css'; -import '@mantine/notifications/styles.css'; import './styles/theme-tokens.css'; import './styles/reader.css'; import './app/app.css'; diff --git a/web/src/styles/confirmation.css b/web/src/styles/confirmation.css deleted file mode 100644 index 416c47b..0000000 --- a/web/src/styles/confirmation.css +++ /dev/null @@ -1,9 +0,0 @@ -.confirmation-host { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0; background: transparent; overflow: hidden; color: var(--reader-ink); } -.confirmation-host::backdrop { background: transparent; } -.confirmation-host .swal2-container { padding: 1.25rem; } -.confirmation-host .reader-confirm-popup { width: 26rem; padding: 1.5rem; border: 1px solid var(--reader-line); border-radius: 1rem; background: var(--reader-paper); color: var(--reader-ink); font: inherit; box-shadow: 0 20px 70px #0003; } -.reader-confirm-popup .swal2-title { padding: 0; font-size: 1.2rem; } -.reader-confirm-popup .swal2-html-container { padding: 0; margin: 1rem 0; color: var(--reader-muted); font-size: .95rem; line-height: 1.7; overflow-wrap: anywhere; } -.reader-confirm-popup .swal2-icon { margin: 0 auto 1rem; font-size: .7rem; } -.reader-confirm-popup .swal2-actions { gap: .75rem; margin: .5rem 0 0; width: 100%; } -.reader-confirm-popup .swal2-actions button { flex: 1; min-height: 48px; } diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 9994596..b327741 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -731,7 +731,7 @@ book-content { text-decoration: none; } -.panel-header .icon-text-button { +.sheet-header .icon-text-button { padding: 0.45rem 0.6rem; font-size: 0.82rem; } @@ -764,7 +764,7 @@ book-content { .tts-bar button:focus-visible, .pager-page:focus-visible, .pager-step:focus-visible, -.dialog button:focus-visible { +.sheet button:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); } @@ -1801,22 +1801,19 @@ book-content { /* ---- shelf settings sheet ---- */ -.shelf-settings .switch-field, -.reader-screen .panel .switch-field { +.sheet .switch-field { flex-direction: row; align-items: center; gap: 0.75rem; } -.shelf-settings .switch-text, -.reader-screen .panel .switch-text { +.sheet .switch-text { flex: 1 1 auto; min-width: 0; font-size: 0.9rem; } -.shelf-settings .switch-text .muted, -.reader-screen .panel .switch-text .muted { +.sheet .switch-text .muted { font-size: 0.72rem; margin-top: 0.15rem; } @@ -1829,8 +1826,7 @@ book-content { * this on" — a separate class would be a second one, and the two would eventually * disagree. */ -.shelf-settings .switch, -.reader-screen .panel .switch { +.sheet .switch { flex: 0 0 auto; position: relative; width: 2.7rem; @@ -1842,8 +1838,7 @@ book-content { transition: background var(--motion-duration) var(--motion-ease); } -.shelf-settings .switch::after, -.reader-screen .panel .switch::after { +.sheet .switch::after { content: ''; position: absolute; top: 0.2rem; @@ -1856,13 +1851,11 @@ book-content { transition: inset-inline-start var(--motion-duration) var(--motion-ease); } -.shelf-settings .switch[aria-pressed='true'], -.reader-screen .panel .switch[aria-pressed='true'] { +.sheet .switch[aria-pressed='true'] { background: var(--reader-fill); } -.shelf-settings .switch[aria-pressed='true']::after, -.reader-screen .panel .switch[aria-pressed='true']::after { +.sheet .switch[aria-pressed='true']::after { inset-inline-start: 1.3rem; } @@ -1877,10 +1870,8 @@ book-content { animation: none; } - .shelf-settings .switch, - .shelf-settings .switch::after, - .reader-screen .panel .switch, - .reader-screen .panel .switch::after { + .sheet .switch, + .sheet .switch::after { transition: none; } @@ -1893,123 +1884,13 @@ book-content { } } -/* ---- panels: a bottom sheet, half the screen ---- +/* ---- reader sheets ---- * - * These used to be `inset: 0` — full-screen opaque pages that replaced the book. - * That is a defensible pattern for a *destination* and the wrong one for a - * *control*: choosing a chapter or nudging the font size is an adjustment to the - * page underneath, and a full-screen panel makes the reader leave the page to make - * it — then come back to a re-laid-out chapter with no memory of what it looked - * like before. The panel is therefore a sheet over the *lower half* of the reading - * area, and the top half stays readable, which is what makes the change visible - * while it is being made. - * - * Three things follow from that and are not decoration: - * - * - **A scrim**, because a sheet without one reads as part of the page. It is - * also the tap target that closes the sheet, which is the second way out, along - * with the ✕ in the header. - * - **A handle**, because a sheet that rises from the bottom is understood to be - * draggable, and a drag target with nothing to grab is a sheet the reader - * cannot close the way they expect. (The drag itself is not implemented; the - * handle is the *affordance*, and the scrim and the ✕ are the two real ways out. - * Promising a drag that does not exist would be worse than not drawing it, so - * the handle is a visual cue and the sheet's own scroll is what moves.) - * - **A height that leaves the top of the page alone.** `min(70%, ...)` rather - * than a fixed viewport fraction: the contents list of a 1200-chapter book wants - * all the room it can get, and the settings list of eleven rows does not want - * more than it needs. + * The contents, appearance, read-aloud and source-switch panels are the app's one + * Sheet (ui/sheet.tsx, styles/sheet.css). On a phone it stops at 62% of the screen + * so the page being adjusted stays visible above it. */ -.scrim { - position: absolute; - inset: 0; - z-index: var(--reader-layer-panel); - background: color-mix(in srgb, #000 42%, transparent); - animation: scrim-in var(--motion-duration) var(--motion-ease); -} - -@keyframes scrim-in { - from { opacity: 0; } - to { opacity: 1; } -} - -.panel { - position: absolute; - inset-inline: 0; - bottom: 0; - z-index: var(--reader-layer-panel); - display: flex; - flex-direction: column; - min-height: 0; - /* Half the reading area, and never more than three quarters of it: the reader - has to be able to see the page they are adjusting, and a sheet that covers - everything is the full-screen panel this replaced. */ - height: min(62%, 34rem); - max-height: 78%; - background: var(--reader-surface); - border-top: 1px solid var(--reader-line); - border-radius: 16px 16px 0 0; - box-shadow: 0 -14px 36px color-mix(in srgb, #000 26%, transparent); - animation: sheet-in var(--motion-duration) var(--motion-ease); -} - -@keyframes sheet-in { - from { transform: translateY(100%); } - to { transform: translateY(0); } -} - -.panel[hidden] { - display: none; -} - -/* The grab handle. Decorative, so it is `aria-hidden` in the markup. */ -.panel-grip { - flex: 0 0 auto; - width: 2.25rem; - height: 0.25rem; - margin: 0.5rem auto 0.125rem; - border-radius: 999px; - background: var(--reader-line); -} - -.panel-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.25rem 0.5rem 0.5rem 0.9rem; - border-bottom: 1px solid var(--reader-line); -} - -.panel-header h2 { - margin: 0; - font-size: var(--text-title); - font-weight: 600; -} - -/* The count beside the title. Right-aligned against the ✕ rather than next to the - title, so the header reads as "title … count … close" and the closing control - stays exactly where the reader's thumb expects it. */ -.panel-subtitle { - flex: 1 1 auto; - text-align: end; - font-size: var(--text-meta); - color: var(--reader-muted); - font-variant-numeric: tabular-nums; -} - -.panel-body { - flex: 1 1 auto; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - /* `overscroll-behavior: contain` stops a flick at the end of the list from - scrolling the *chapter* behind the sheet, which is the classic bottom-sheet - annoyance: the list stops moving, the text underneath starts, and the reader - has lost their place. */ - overscroll-behavior: contain; - padding: var(--space-3) 0.9rem calc(var(--space-3) + var(--safe-bottom)); -} - .toc-list { list-style: none; margin: 0; @@ -3525,120 +3406,6 @@ book-content { color: #c0392b; } -/* ---- dialogs ---- */ - -.dialog-overlay { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.4); - display: grid; - place-items: center; - padding: 1rem; - z-index: 30; -} - -.dialog { - background: var(--reader-surface); - color: var(--reader-ink); - border: 1px solid var(--reader-line); - border-radius: var(--reader-radius); - padding: 1rem; - width: min(24rem, 100%); - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.dialog h3 { - margin: 0; - font-size: 1rem; -} - -.dialog p { - margin: 0; - font-size: 0.82rem; - line-height: 1.5; -} - -.dialog input { - appearance: none; - border: 1px solid var(--reader-line); - background: var(--reader-paper); - color: var(--reader-ink); - border-radius: var(--reader-radius); - padding: 0.6rem 0.7rem; - /* 16px, so iOS does not zoom the page on focus and leave the dialog off-screen. */ - font-size: 16px; - font-family: inherit; - width: 100%; -} - -.dialog-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; -} - -.dialog-actions .button { - flex: 0 1 auto; - min-width: 5rem; -} - -.dialog-list { - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -/* ---- batch metadata ---- */ - -/* - * The batch dialog has seven optional fields, which do not fit on a phone. - * - * The fields scroll and the buttons do not. Scrolling the whole dialog instead - * puts 保存 below the fold, so the reader fills the form, sees no way to commit - * it, and has to work out that the sheet itself moves — and the on-screen - * keyboard takes another third of the height while they are typing in it. - * - * `max-height` on the *fields* rather than on the dialog also keeps the title - * visible, so it stays obvious what is being edited after a scroll. - */ -.dialog:has(.dialog-field) { - max-height: min(86vh, 40rem); - overflow: hidden; -} - -.dialog:has(.dialog-field) .dialog-fields { - display: flex; - flex-direction: column; - gap: 0.6rem; - overflow-y: auto; - min-height: 0; - /* Room for the last field's label, so it is not flush against the buttons. */ - padding-bottom: 0.2rem; -} - -.dialog-field { - display: flex; - flex-direction: column; - gap: 0.25rem; - font-size: 0.78rem; - color: var(--reader-muted); -} - -.dialog-field input { - appearance: none; - border: 1px solid var(--reader-line); - background: var(--reader-paper); - color: var(--reader-ink); - border-radius: var(--reader-radius); - padding: 0.5rem 0.6rem; - /* 16px, so iOS does not zoom the page on focus and push the dialog off-screen. */ - font-size: 16px; - font-family: inherit; - width: 100%; -} - /* * The file input is the trigger for the ⬆ button and is never shown. * @@ -3836,13 +3603,10 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti } .reader-screen .rail-button { width: 48px; height: 48px; border-radius: 50%; background: var(--reader-surface); box-shadow: 0 3px 12px #00000020; } .reader-screen .rail-button .icon { font-size: 1.5rem; } -.reader-screen .panel { border-radius: 20px 20px 0 0; max-height: 62%; background: var(--reader-surface); box-shadow: 0 -6px 30px #00000018; } -.reader-screen .panel-header { padding: 0.5rem 1.25rem 0.75rem; } -.reader-screen .panel-body { padding-inline: 1.25rem; } -.reader-screen .panel .field { padding-block: 0.8rem; } -.reader-screen .panel .tts-bar { position: static; border-radius: 12px; width: auto; transform: none; margin-bottom: 1rem; } -.reader-screen .panel-header > .icon-button { margin-inline-start: auto; } -.reader-screen .panel .field { grid-template-columns: minmax(5.5rem, max-content) minmax(0, 1fr); gap: 0.75rem; padding-block: 0.5rem; } +@media (max-width: 700px) { .reader-screen .sheet { max-height: min(62dvh, 36rem); } } +.reader-screen .sheet .field { padding-block: 0.8rem; } +.reader-screen .sheet .tts-bar { position: static; border-radius: 12px; width: auto; transform: none; margin-bottom: 1rem; } +.reader-screen .sheet .field { grid-template-columns: minmax(5.5rem, max-content) minmax(0, 1fr); gap: 0.75rem; padding-block: 0.5rem; } .reader-screen .field input[type='range'] { min-height: 32px; } .reader-screen .field input[type='range']::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--reader-line); } .reader-screen .field input[type='range']::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; margin-top: -7px; border-radius: 50%; background: var(--reader-fill); } @@ -3945,16 +3709,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .source-picker > label { flex:1; margin:0; font-size:.85rem; color:var(--reader-muted); } .source-picker > p { flex-basis:100%; margin:0; } } -.source-modal { color: var(--reader-ink); background: var(--reader-paper); border: 1px solid var(--reader-line); border-radius: 1rem; padding: 0; width: min(36rem, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow: hidden; } -.source-modal[open] { display: flex; flex-direction: column; } -.source-modal::backdrop { background: #0007; } -.source-modal-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .75rem 1rem; border-bottom: 1px solid var(--reader-line); flex: none; } -.source-modal-header h2 { margin: 0; font-size: 1.15rem; } -.source-modal-form.sources-card { display: flex; flex-direction: column; margin: 0; border: 0; padding: 0; width: 100%; min-height: 0; overflow: hidden; } -.source-modal-content { overflow-y: auto; overscroll-behavior: contain; padding: 0 1rem 1rem; } -.source-modal-actions { display: flex; flex-wrap: wrap; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid var(--reader-line); flex: none; } -.source-modal-actions button { min-height: 44px; } - /* Collection pages share a quiet heading, labelled destinations and useful empty states. */ .shelf-head { align-items: center; padding-top: calc(1.25rem + var(--safe-top)); padding-bottom: .85rem; } .shelf-head-actions { margin-top: 0; } @@ -4039,21 +3793,11 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .file-manager-header .collection-links { align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: .55rem; } .file-manager-header .collection-link.button { flex: 0 0 auto; min-width: 8.5rem; min-height: 44px; justify-content: flex-start; } .file-manager-header .collection-link.button > span { flex: 1 1 auto; } -.library-files-screen .mantine-Menu-dropdown { min-width: 10rem; padding: .35rem; border: 1px solid var(--reader-line); border-radius: .75rem; background: var(--reader-paper); color: var(--reader-ink); box-shadow: 0 .75rem 2rem #0003; } -.library-files-screen .mantine-Menu-item { min-height: 2.5rem; border-radius: .5rem; color: var(--reader-ink); font: inherit; } -.library-files-screen .mantine-Menu-item:hover, .library-files-screen .mantine-Menu-item:focus-visible { background: color-mix(in srgb, var(--reader-accent) 12%, transparent); color: var(--reader-ink); } -.library-files-screen .mantine-Menu-item[data-disabled] { opacity: .45; } -.library-files-screen .mantine-Menu-item[data-color='red'] { color: #b23a31; } .library-file-menu { min-width: 10rem; padding: .35rem; border: 1px solid var(--reader-line); border-radius: .75rem; background: var(--reader-paper); color: var(--reader-ink); box-shadow: 0 .75rem 2rem #0003; } .library-file-menu .mantine-Menu-item { min-height: 2.5rem; border-radius: .5rem; color: var(--reader-ink); font: inherit; } .library-file-menu .mantine-Menu-item:hover, .library-file-menu .mantine-Menu-item:focus-visible { background: color-mix(in srgb, var(--reader-accent) 12%, transparent); color: var(--reader-ink); } .library-file-menu .mantine-Menu-item[data-disabled] { opacity: .45; } .library-file-menu .mantine-Menu-item[data-color='red'] { color: #b23a31; } -.shelf-settings.panel { position: fixed; } -.shelf-settings .panel-header { padding: .75rem 1rem; flex: none; } -.shelf-settings .panel-header h2 { flex: 1; } -.shelf-settings .panel-header > .icon-button { flex: none; margin-inline-start: auto; width: 44px; height: 44px; } -.shelf-settings .panel-body { padding-top: 0; } .shelf-settings .section-title:first-child { margin-top: 0; } .shelf-discovery { display: contents; } .plugin-install { margin-block: 1rem 1.5rem; border: 1px solid var(--reader-line); border-radius: .85rem; overflow: hidden; } @@ -4083,7 +3827,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .library-files-screen .manager-check { grid-row: 1; } .library-files-screen .manager-meta { grid-column: 3; grid-row: 1; justify-content: flex-end; padding-left: 0; } .library-files-screen .manager-more { grid-column: 4; grid-row: 1; } - .shelf-settings.panel { inset: 1rem 1rem auto auto; width: 26rem; height: min(42rem, calc(100dvh - 2rem)); max-height: calc(100dvh - 2rem); border: 1px solid var(--reader-line); border-radius: 1rem; box-shadow: 0 12px 48px #0003; overflow: hidden; animation-name: shelf-settings-in; } .shelf-settings .switch-field { grid-template-columns: minmax(0, 1fr) auto; } .sources-header { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); max-width: var(--collection-measure); margin-inline: auto; padding: 2rem .9rem .75rem; border-bottom: 0; } .sources-header h1 { font-size: 1.5rem; font-weight: 650; } @@ -4107,14 +3850,8 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .reader-screen .reader-actions .icon { font-size: 1.1rem; } .reader-screen .reader-rail { right: max(1rem, calc((100% - var(--desktop-page-width)) / 2 - 4.5rem)); bottom: 2.25rem; } .reader-screen .reading-indicator { width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; padding-inline: 1.5rem; font-size: .75rem; } - .reader-screen .scrim { background: color-mix(in srgb, var(--reader-ink) 12%, transparent); } - .reader-screen .panel { inset: 1rem 1rem 1rem auto; width: 26rem; height: calc(100% - 2rem); max-height: none; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; animation-name: shelf-settings-in; } - .reader-screen .panel[data-placement='start'] { inset-inline: 1rem auto; width: 23rem; animation-name: reader-contents-in; } - .reader-screen .panel-grip { display: none; } - .reader-screen .panel-header { flex: none; padding: .75rem 1.25rem; } - .reader-screen .panel-header .icon-button { width: 40px; height: 40px; border-radius: .6rem; } - .reader-screen .panel .section-title { font-size: .78rem; font-weight: 650; letter-spacing: .05em; color: var(--reader-accent); padding-top: .9rem; padding-bottom: .65rem; } - .reader-screen .panel-body { padding-top: 0; } + .reader-screen .sheet::backdrop { background: color-mix(in srgb, var(--reader-ink) 12%, transparent); } + .reader-screen .sheet .section-title { font-size: .78rem; font-weight: 650; letter-spacing: .05em; color: var(--reader-accent); padding-top: .9rem; padding-bottom: .65rem; } .reader-screen .section-title:first-child { margin-top: 0; } } @media (max-width: 600px) { @@ -4127,14 +3864,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .source-interval select { flex:1; } .library-files-screen .manager-actions { overflow-x:auto; } } -@keyframes reader-contents-in { - from { opacity: 0; transform: translateX(-1rem); } - to { opacity: 1; transform: translateX(0); } -} -@keyframes shelf-settings-in { - from { opacity: 0; transform: translateX(1rem); } - to { opacity: 1; transform: translateX(0); } -} @media (max-width: 600px) { .source-entry-actions { flex-direction: column; gap: 0; } .source-entry-top { align-items: center; } @@ -4150,7 +3879,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .reader-source-info span { flex: 0 1 auto; max-width: 35%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .reader-source-info a { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: inherit; text-decoration: none; } .reader-source-info a:hover { text-decoration: underline; } -.book-detail { padding-top: 1rem; } +.book-detail { display: grid; gap: .5rem; } .book-detail h3 { margin: 0; font-size: 1.35rem; overflow-wrap: anywhere; } .book-detail-author { color: var(--reader-muted); } .book-detail dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .6rem 1rem; padding-block: .8rem; border-block: 1px solid var(--reader-line); } @@ -4160,7 +3889,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .reader-book-heading { padding: 0; border: 0; background: transparent; color: inherit; text-align: start; cursor: pointer; min-height: 44px; } -.alternative-preview { padding-block: 1rem; display: grid; gap: .75rem; } +.alternative-preview { display: grid; gap: .75rem; } .alternative-preview h4 { margin: 0; } .alternative-preview label { display: grid; gap: .5rem; } .alternative-preview select { width: 100%; min-width: 0; min-height: 44px; } @@ -4242,7 +3971,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .annotation-actions button:hover { background: var(--reader-surface); } .annotation-popover blockquote, .annotation-editor blockquote { margin: .3rem 0 .75rem; padding: .5rem .75rem; border-inline-start: 3px solid var(--reader-accent); max-height: 6rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } .annotation-comment { margin: .5rem; max-height: 8rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } -.annotation-editor { display: grid; gap: 1rem; padding-top: 1rem; } +.annotation-editor { display: grid; gap: 1rem; } .annotation-editor label { display: grid; gap: .5rem; } .annotation-editor textarea { box-sizing: border-box; width: 100%; min-height: 6rem; padding: .75rem; font: inherit; border: 1px solid var(--reader-line); border-radius: .5rem; background: var(--reader-paper); color: var(--reader-ink); resize: vertical; } .reading-tool-advanced { border-top: 1px solid var(--reader-line); padding-top: .5rem; } diff --git a/web/src/styles/sheet.css b/web/src/styles/sheet.css new file mode 100644 index 0000000..6a8b2b6 --- /dev/null +++ b/web/src/styles/sheet.css @@ -0,0 +1,261 @@ +/* + * The one overlay form (web/src/ui/sheet.tsx, docs/ui.md §3.7). + * + * Phones: a bottom sheet as tall as its content. Wider windows: a drawer on the + * right edge — full height, or as tall as its content for `compact`. Callers add a + * class for their *content*; nothing outside this file sets a sheet's position, + * size, radius, backdrop or header. + */ + +.sheet { + position: fixed; + inset: auto 0 0 0; + box-sizing: border-box; + width: 100%; + max-width: 100%; + height: auto; + max-height: calc(100dvh - var(--safe-top) - 1.5rem); + margin: 0; + padding: 0; + border: 1px solid var(--reader-line); + border-bottom: 0; + border-radius: 16px 16px 0 0; + background: var(--reader-surface); + color: var(--reader-ink); + font: inherit; + font-size: var(--text-body); + line-height: 1.5; + box-shadow: 0 -14px 36px color-mix(in srgb, #000 22%, transparent); + overflow: hidden; +} + +.sheet[open] { + display: flex; + flex-direction: column; + animation: sheet-rise var(--motion-duration) var(--motion-ease); +} + +/* The fallback colour is for WebViews whose ::backdrop does not inherit custom properties. */ +.sheet::backdrop { + background: var(--color-scrim, rgb(0 0 0 / 0.4)); +} + +.sheet-grip { + flex: none; + width: 2.25rem; + height: 0.25rem; + margin: 0.5rem auto 0; + border-radius: 999px; + background: var(--reader-line); +} + +.sheet-header { + flex: none; + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 3.25rem; + box-sizing: border-box; + padding: var(--space-1) var(--space-2) var(--space-1) var(--space-3); + border-bottom: 1px solid var(--reader-line); +} + +.sheet-header h2 { + flex: 1 1 auto; + min-width: 0; + margin: 0; + font-size: var(--text-title); + font-weight: 600; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sheet-subtitle { + flex: none; + font-size: var(--text-meta); + color: var(--reader-muted); + font-variant-numeric: tabular-nums; +} + +.sheet-header > .icon-button { + flex: none; +} + +.sheet-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding: var(--space-3) var(--space-3) calc(var(--space-3) + var(--safe-bottom)); +} + +.sheet:has(> .sheet-footer) > .sheet-body { + padding-bottom: var(--space-3); +} + +.sheet-footer { + flex: none; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-2) var(--space-3) calc(var(--space-2) + var(--safe-bottom)); + border-top: 1px solid var(--reader-line); +} + +.sheet-footer .button { + min-width: 5rem; + min-height: 44px; +} + +/* Running text inside a sheet: a confirmation's question, a form's explanation. */ +.sheet-text { + margin: 0; + color: var(--reader-muted); + line-height: 1.7; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* A stacked form: label above field, the field full width. */ +.sheet-form { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.sheet-field { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: var(--text-label); + color: var(--reader-muted); +} + +.sheet-field input, +.sheet-field select, +.sheet-field textarea { + appearance: none; + box-sizing: border-box; + width: 100%; + min-height: 44px; + border: 1px solid var(--reader-line); + border-radius: var(--reader-radius); + padding: var(--space-2); + background: var(--reader-paper); + color: var(--reader-ink); + /* 16px, so iOS does not zoom the page on focus and push the sheet off-screen. */ + font-size: 16px; + font-family: inherit; +} + +/* A switch row: label and hint take the width, the switch sits on the trailing edge. */ +.sheet .switch-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +/* A list of choices: one full-width row per option. */ +.sheet-list { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.sheet-list .button { + width: 100%; + justify-content: flex-start; + text-align: start; +} + +.button.primary.danger { + background: var(--color-danger); + border-color: var(--color-danger); + color: var(--color-bg); +} + +@media (hover: hover) and (pointer: fine) { + .button.primary.danger:hover { + background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); + } +} + +@keyframes sheet-rise { + from { transform: translateY(100%); } + to { transform: none; } +} + +@keyframes sheet-pop { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: none; } +} + +@keyframes sheet-slide { + from { opacity: 0; transform: translateX(1rem); } + to { opacity: 1; transform: none; } +} + +@media (max-width: 700px) { + .sheet-footer .button { + flex: 1 1 0; + } +} + +@media (min-width: 701px) { + .sheet { + inset: 1rem 1rem 1rem auto; + width: min(30rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + border-bottom: 1px solid var(--reader-line); + border-radius: 1rem; + box-shadow: 0 12px 48px color-mix(in srgb, #000 20%, transparent); + } + + .sheet[open] { + animation-name: sheet-slide; + } + + .sheet[data-size='compact'] { + bottom: auto; + } + + /* Opened by a tap or a focused control: beside the opener, positioned by sheet.tsx. */ + .sheet[data-size='compact'][data-anchored] { + right: auto; + bottom: auto; + width: min(24rem, calc(100vw - 2rem)); + box-shadow: 0 12px 40px color-mix(in srgb, #000 18%, transparent); + } + + .sheet[data-size='compact'][data-anchored][open] { + animation-name: sheet-pop; + } + + .sheet[data-size='compact'][data-anchored]::backdrop { + background: color-mix(in srgb, var(--color-scrim, rgb(0 0 0 / 0.4)) 45%, transparent); + } + + .sheet[data-size='wide'] { + width: min(42rem, calc(100vw - 2rem)); + } + + .sheet-grip { + display: none; + } + + .sheet-header { + padding-block: var(--space-2); + } + + .sheet-body { + padding-bottom: var(--space-3); + } + + .sheet-footer { + padding-bottom: var(--space-2); + } +} diff --git a/web/src/ui/annotation-popover.tsx b/web/src/ui/annotation-popover.tsx index 6e011f4..51422f3 100644 --- a/web/src/ui/annotation-popover.tsx +++ b/web/src/ui/annotation-popover.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { Note } from '../api/types.ts'; import type { TextAnchor } from './text-anchor.ts'; -import { Modal } from './modal.tsx'; -import { FloatingConfirm } from './floating-confirm.tsx'; +import { ConfirmSheet, Sheet } from './sheet.tsx'; import { FloatingNotice } from './floating-notice.tsx'; export interface AnnotationTarget { anchor: TextAnchor; rect: DOMRect; note?: Note } @@ -38,18 +37,17 @@ export function AnnotationPopover({ target, save, remove, close }: { return () => { document.removeEventListener('pointerdown', dismiss); document.removeEventListener('keydown', key, true); window.removeEventListener('resize',moved); document.removeEventListener('scroll',moved,true); }; }, [editing, busy, confirmDelete]); useEffect(() => { if (editing) input.current?.focus(); }, [editing]); - if (editing) return -
{ event.preventDefault(); void work(() => save(comment.trim() ? 'note' : 'highlight', comment, color)); }}> + if (editing) return + { event.preventDefault(); void work(() => save(comment.trim() ? 'note' : 'highlight', comment, color)); }}>
{target.anchor.quote}