chore(product): checkpoint working tree before dandanplay alignment
Includes product polish (auth/dashboard/error pages, i18n, stream/playback fixes), docs site, e2e suites, and related tooling. Excludes local screenshots and caches.
|
|
@ -12,6 +12,10 @@ TURSO_DATABASE_URL=file:local.db
|
|||
# 媒体挂载凭据加密密钥:64 位 hex(openssl rand -hex 32)
|
||||
MEDIA_SECRET_KEY=
|
||||
|
||||
# 找回密码令牌交付:配置后以 POST JSON { email, username, token, resetPath } 推送到该地址
|
||||
# (可对接邮件/企业微信/ bark 等任意通知服务);未配置时令牌写入服务日志
|
||||
PASSWORD_RESET_WEBHOOK_URL=
|
||||
|
||||
# 弹弹play 开放弹幕网络(https://doc.dandanplay.com/open)
|
||||
# 未配置 AppId/AppSecret 时,弹幕接口返回 source=none「未配置开放网络」
|
||||
OPEN_DANMAKU_APP_ID=
|
||||
|
|
|
|||
|
|
@ -30,3 +30,13 @@ storybook-static/
|
|||
.superpowers/
|
||||
.worktrees/
|
||||
worktrees/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# vitepress cache
|
||||
app/docs/.vitepress/cache/
|
||||
# local screenshots / fixtures
|
||||
ui-audit/
|
||||
tmp-e2e-dav/
|
||||
generated-*.png
|
||||
tmp-*.png
|
||||
|
|
|
|||
23
AGENTS.md
|
|
@ -1,10 +1,10 @@
|
|||
# AGENTS.md — app-template 总览
|
||||
# AGENTS.md — 帆幕(Fanmu)总览
|
||||
|
||||
面向 AI 编码代理的仓库导览。修改任何代码前,先读本文件与目标子包的 `AGENTS.md`。
|
||||
|
||||
## 项目是什么
|
||||
|
||||
**可复用全栈脚手架 / 开发模板**(不绑定产品业务)。只保留鉴权 + `user` 垂直示例,演示完整分层接线。视觉基线见 `DESIGN.md`。
|
||||
**帆幕(Fanmu)**:私有影音库 · 弹幕观影 · 网盘挂载。本仓库为**产品仓**,业务代码进入既有分层。视觉基线见 `DESIGN.md`。
|
||||
|
||||
技术栈:Astro 7(SSR)+ React 19 islands · tRPC v11 · Drizzle ORM + Turso/libSQL · Tailwind CSS 4 · TypeScript 7 · Biome · Yarn 4 workspaces + Turbo。
|
||||
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
| `@app/types` | Zod 入参 schema + 共享输出类型 | zod |
|
||||
| `@app/models` | Drizzle 表定义(唯一 schema 源) | drizzle-orm |
|
||||
| `@app/db` | libSQL/Turso 客户端单例 + drizzle-kit | models |
|
||||
| `@app/dao` | 数据访问层(`userDao` 等) | db, models |
|
||||
| `@app/dao` | 数据访问层(`userDao` / `mountDao` / `mediaItemDao` 等) | db, models |
|
||||
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao |
|
||||
| `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, design-tokens, seo-geo, i18n, ui |
|
||||
| `@app/ui` | shadcn 风格基础 UI 组件(源码直出 + Storybook) | design-tokens, react (peer) |
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
| `@app/seo-geo` | SEO/GEO 纯函数(meta、sitemap、JSON-D、llms.txt) | zod, schema-dts, escape-html, fast-xml-builder, serialize-javascript, mdast-util-to-markdown |
|
||||
| `@app/i18n` | 文案与 locale 纯函数(zh-CN / en、t()、Accept-Language) | — |
|
||||
| `@app/adsense` | AdSense 脚本加载 + `AdSlot` React 组件 | react (peer) |
|
||||
| `@app/docs` | 产品文档站(VitePress,中文根路径 + 英文 `/en`) | —(仅 vitepress) |
|
||||
|
||||
依赖方向(只允许向下):
|
||||
|
||||
|
|
@ -35,6 +36,8 @@ web ──► trpc ──► dao ──► db ──► models
|
|||
├──► i18n
|
||||
├──► adsense
|
||||
└──► ui
|
||||
|
||||
docs(独立,不依赖上述业务包)
|
||||
```
|
||||
|
||||
禁止循环依赖。跨包只通过 `package.json` 的 `exports` 入口引用,不要 deep import。
|
||||
|
|
@ -43,8 +46,10 @@ web ──► trpc ──► dao ──► db ──► models
|
|||
|
||||
- 源码在各包 `src/`;构建产物在 `dist/`(git 忽略,勿手改)。
|
||||
- 环境变量模板见 `.env.example`:`TURSO_DATABASE_URL`、`TURSO_AUTH_TOKEN`、`PUBLIC_SITE_URL`。
|
||||
- 根 `.env` 由 `packages/db/drizzle.config.ts` 手动加载(drizzle-kit 在 monorepo 中不会自动读根 env)。
|
||||
- **模板不掺杂业务逻辑**:禁止把产品领域实体/页面写进本仓库;下游业务仓库再扩展。
|
||||
- 根 `.env` 由 `packages/db/drizzle.config.ts` 与 `packages/db/scripts/seed.ts` 手动加载(monorepo 下工具不会自动读根 env)。
|
||||
- **分层不跳层**:业务实体按 types → models → dao → trpc → web 落地;领域逻辑进 service,SQL 进 dao。
|
||||
- `e2e/`:Playwright 端到端测试(配置在根 `playwright.config.ts`),不是 workspace 包。
|
||||
- `app/docs/`:VitePress 文档站源码(`.vitepress/` 配置 + `guide/` 中文 + `en/guide/` 英文)。
|
||||
|
||||
## 常用命令
|
||||
|
||||
|
|
@ -56,9 +61,14 @@ yarn build # 全仓 build(依赖拓扑)
|
|||
yarn build:web # 仅 @app/web + 上游依赖闭包(日常/CI 更快)
|
||||
yarn dev:web # Astro :4321
|
||||
yarn dev:api # 独立 tRPC :4000(web 内嵌 /api/trpc 时通常不需要)
|
||||
yarn docs:dev # VitePress 文档站
|
||||
yarn docs:build
|
||||
yarn test:e2e # Playwright(自动起 webServer)
|
||||
yarn db:generate # Drizzle 迁移生成
|
||||
yarn db:push # 推 schema
|
||||
yarn db:seed # 初始化默认管理员(幂等)
|
||||
yarn db:studio
|
||||
yarn storybook # @app/ui Storybook :6006
|
||||
```
|
||||
|
||||
改某个包时可先 `yarn workspace @app/<name> typecheck`。
|
||||
|
|
@ -73,7 +83,7 @@ yarn db:studio
|
|||
6. **UI**:设计令牌从 `@app/design-tokens` 引入;组件优先用 `@app/ui`(shadcn 风格,源码在 `packages/ui`)+ `cn()`。视觉遵循 `DESIGN.md`。
|
||||
7. **构建顺序**:改 `types`/`models` 后需先 build 上游,再让下游 typecheck(Turbo `dependsOn: ["^build"]` 已配置)。
|
||||
8. **注释**:只写非显而易见的 WHY;默认不写注释。
|
||||
9. **新增业务实体**:复制 `user` 垂直切片(types → models → dao → trpc → web),不要跳层。
|
||||
9. **新增业务实体**:按 types → models → dao → trpc → web 分层落地,不要跳层。
|
||||
|
||||
## 各包入口
|
||||
|
||||
|
|
@ -90,3 +100,4 @@ yarn db:studio
|
|||
- [packages/i18n/AGENTS.md](packages/i18n/AGENTS.md)
|
||||
- [packages/adsense/AGENTS.md](packages/adsense/AGENTS.md)
|
||||
- [packages/ui/AGENTS.md](packages/ui/AGENTS.md)
|
||||
- [app/docs/AGENTS.md](app/docs/AGENTS.md)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# 弹弹 Web — 设计方向
|
||||
# 帆幕(Fanmu)— 设计方向
|
||||
|
||||
产品视觉基线:**shadcn/ui Dashboard**(媒体五页侧栏/顶栏导航、卡片网格、中性灰阶);模板深空工业 token 仍可用于非媒体页,但 **`@app/design-tokens` 色板 token 路径约定不变**(改值可以,改结构需同步全局)。
|
||||
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
| 墨色 | `#F2F2F2` |
|
||||
| 弱化文字 | `#999999` |
|
||||
| 更弱文字 | `#666666` |
|
||||
| 强调(信号黄) | `#FFFA00` |
|
||||
| 强调(品牌金黄) | `#FFB300` |
|
||||
| 信号绿 | `#00FFA2` |
|
||||
| 信号品红 | `#FF1AAC` |
|
||||
| 亮色背景(对照) | `#FAFAFA` |
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ RUN npm install -g corepack && corepack enable
|
|||
FROM base AS deps
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
COPY app/web/package.json app/web/package.json
|
||||
# workspaces: packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链
|
||||
# workspaces: app/* + packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链
|
||||
COPY app/docs/package.json app/docs/package.json
|
||||
COPY packages/adsense/package.json packages/adsense/package.json
|
||||
COPY packages/dao/package.json packages/dao/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 弹弹 Web — 本地片源 · 在线弹幕
|
||||
# 帆幕 — 私有影音库 · 弹幕观影 · 网盘挂载
|
||||
|
||||
多用户 Web 媒体中心:WebDAV/OpenList 挂载、媒体库扫描与 Bangumi 刮削、ArtPlayer 播放与弹幕(开放网络 + 本地 XML)。
|
||||
**帆幕(Fanmu)** 是自建私有影音库:WebDAV/OpenList 挂载片源、媒体库扫描与 Bangumi 刮削、ArtPlayer 播放与弹幕(开放网络 + 本地 XML),多用户进度与权限。
|
||||
|
||||
基于 monorepo 分层(types → models → db → dao → trpc → web),本仓库按**产品仓**维护。
|
||||
|
||||
|
|
@ -49,4 +49,4 @@ yarn dev:web # Astro :4321
|
|||
|
||||
`web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。
|
||||
|
||||
新增业务实体请复制 `user` 垂直切片:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。
|
||||
新增业务实体请按既有分层接线:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { defineConfig } from "vitepress";
|
||||
|
||||
const zhNav = [
|
||||
{ text: "是什么", link: "/guide/what-is" },
|
||||
{ text: "功能", link: "/guide/library" },
|
||||
{ text: "上手", link: "/guide/quick-start" },
|
||||
{ text: "问题", link: "/guide/faq" },
|
||||
];
|
||||
|
||||
const zhSidebar = [
|
||||
{
|
||||
text: "了解帆幕",
|
||||
items: [
|
||||
{ text: "帆幕是什么", link: "/guide/what-is" },
|
||||
{ text: "快速上手", link: "/guide/quick-start" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "怎么用",
|
||||
items: [
|
||||
{ text: "连网盘", link: "/guide/mount" },
|
||||
{ text: "找视频", link: "/guide/browse" },
|
||||
{ text: "媒体库", link: "/guide/library" },
|
||||
{ text: "看片和弹幕", link: "/guide/danmaku" },
|
||||
{ text: "认出是哪部剧", link: "/guide/scrape" },
|
||||
{ text: "账号和进度", link: "/guide/account" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "帮助",
|
||||
items: [{ text: "常见问题", link: "/guide/faq" }],
|
||||
},
|
||||
];
|
||||
|
||||
const enNav = [
|
||||
{ text: "About", link: "/en/guide/what-is" },
|
||||
{ text: "Features", link: "/en/guide/library" },
|
||||
{ text: "Get started", link: "/en/guide/quick-start" },
|
||||
{ text: "FAQ", link: "/en/guide/faq" },
|
||||
];
|
||||
|
||||
const enSidebar = [
|
||||
{
|
||||
text: "About Fanmu",
|
||||
items: [
|
||||
{ text: "What is Fanmu", link: "/en/guide/what-is" },
|
||||
{ text: "Get started", link: "/en/guide/quick-start" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "How to use",
|
||||
items: [
|
||||
{ text: "Mount a drive", link: "/en/guide/mount" },
|
||||
{ text: "Find a video", link: "/en/guide/browse" },
|
||||
{ text: "Library", link: "/en/guide/library" },
|
||||
{ text: "Watch and danmaku", link: "/en/guide/danmaku" },
|
||||
{ text: "Identify the show", link: "/en/guide/scrape" },
|
||||
{ text: "Accounts and progress", link: "/en/guide/account" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Help",
|
||||
items: [{ text: "FAQ", link: "/en/guide/faq" }],
|
||||
},
|
||||
];
|
||||
|
||||
// 文档站:VitePress;产品说明文档,中文在根路径,英文在 /en
|
||||
export default defineConfig({
|
||||
cleanUrls: true,
|
||||
lastUpdated: true,
|
||||
head: [["link", { rel: "icon", href: "/favicon.svg" }]],
|
||||
locales: {
|
||||
root: {
|
||||
label: "简体中文",
|
||||
lang: "zh-CN",
|
||||
link: "/",
|
||||
title: "帆幕文档",
|
||||
description: "帆幕 — 自己的影音库,看片有弹幕。挂网盘、播视频、飘弹幕。",
|
||||
themeConfig: {
|
||||
nav: zhNav,
|
||||
sidebar: zhSidebar,
|
||||
outline: { level: [2, 3], label: "本页目录" },
|
||||
search: {
|
||||
provider: "local",
|
||||
options: {
|
||||
translations: {
|
||||
button: { buttonText: "搜索", buttonAriaLabel: "搜索文档" },
|
||||
modal: {
|
||||
noResultsText: "未找到结果",
|
||||
resetButtonTitle: "清除查询",
|
||||
footer: {
|
||||
selectText: "选择",
|
||||
navigateText: "切换",
|
||||
closeText: "关闭",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
docFooter: { prev: "上一篇", next: "下一篇" },
|
||||
darkModeSwitchLabel: "主题",
|
||||
sidebarMenuLabel: "菜单",
|
||||
returnToTopLabel: "回到顶部",
|
||||
langMenuLabel: "切换语言",
|
||||
lastUpdatedText: "更新于",
|
||||
notFound: {
|
||||
title: "页面不存在",
|
||||
quote: "这里什么都没有",
|
||||
linkLabel: "回首页",
|
||||
linkText: "回首页",
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
label: "English",
|
||||
lang: "en",
|
||||
link: "/en/",
|
||||
title: "Fanmu Docs",
|
||||
description:
|
||||
"Fanmu — your own media library with danmaku. Mount a drive, play video, watch comments fly.",
|
||||
themeConfig: {
|
||||
nav: enNav,
|
||||
sidebar: enSidebar,
|
||||
outline: { level: [2, 3], label: "On this page" },
|
||||
search: {
|
||||
provider: "local",
|
||||
options: {
|
||||
translations: {
|
||||
button: { buttonText: "Search", buttonAriaLabel: "Search docs" },
|
||||
modal: {
|
||||
noResultsText: "No results",
|
||||
resetButtonTitle: "Clear query",
|
||||
footer: {
|
||||
selectText: "Select",
|
||||
navigateText: "Navigate",
|
||||
closeText: "Close",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
docFooter: { prev: "Previous", next: "Next" },
|
||||
darkModeSwitchLabel: "Theme",
|
||||
sidebarMenuLabel: "Menu",
|
||||
returnToTopLabel: "Back to top",
|
||||
langMenuLabel: "Change language",
|
||||
lastUpdatedText: "Updated",
|
||||
notFound: {
|
||||
title: "Page not found",
|
||||
quote: "Nothing lives at this address",
|
||||
linkLabel: "Go home",
|
||||
linkText: "Go home",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
lineNumbers: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* VitePress 主题:使用默认品牌色与明暗切换。
|
||||
* 仅保留排版微调,不覆盖 --vp-c-* 品牌语义变量。
|
||||
*/
|
||||
|
||||
:root {
|
||||
--vp-font-family-base:
|
||||
"Space Grotesk", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--vp-font-family-mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import DefaultTheme from "vitepress/theme";
|
||||
import type { Theme } from "vitepress";
|
||||
// @ts-expect-error vitepress 会处理 css 导入
|
||||
import "./custom.css";
|
||||
|
||||
/** 无本地偏好时默认暗色(与主应用一致),仍可手动切换亮/暗。 */
|
||||
const initTheme = `try { const k='vitepress-theme-appearance'; if(!localStorage.getItem(k)) localStorage.setItem(k,'dark'); } catch {}`;
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
enhanceApp() {
|
||||
if (typeof document !== "undefined") {
|
||||
const s = document.createElement("script");
|
||||
s.textContent = initTheme;
|
||||
document.head.prepend(s);
|
||||
}
|
||||
},
|
||||
} satisfies Theme;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# AGENTS.md — @app/docs
|
||||
|
||||
## 职责
|
||||
|
||||
**产品文档站**(VitePress):面向用户的使用说明,中文在根路径、英文在 `/en`。与 `@app/web` 产品页分离——这里是文档,不是应用路由。
|
||||
|
||||
不依赖 `@app/*` 业务包;纯 Markdown + VitePress 配置,无 tRPC / DB。
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
.vitepress/
|
||||
config.mts # locales(root=zh-CN, en)、nav、sidebar、搜索文案
|
||||
theme/index.ts # 扩展默认主题;无本地偏好时默认暗色(与主应用一致)
|
||||
theme/custom.css # 自定义样式
|
||||
guide/ # 中文文档(权威源)
|
||||
what-is.md | quick-start.md | mount.md | browse.md
|
||||
library.md | danmaku.md | scrape.md | account.md | faq.md
|
||||
en/guide/ # 英文文档(与 guide/ 同构,逐篇对应)
|
||||
index.md # 中文首页
|
||||
en/index.md # 英文首页
|
||||
public/ # 静态资源(favicon 等)
|
||||
```
|
||||
|
||||
## 约定
|
||||
|
||||
- **双语同构**:`guide/*.md` 与 `en/guide/*.md` 文件名一一对应;新增页面两边同时加,并在 `config.mts` 的 `zhSidebar` / `enSidebar` 登记。
|
||||
- 路径用 `cleanUrls: true`(链接不带 `.html`)。
|
||||
- 文案语气与产品一致(「帆幕」/「Fanmu」);中文文档用简体中文,不混繁体。
|
||||
- 搜索为 VitePress local provider,UI 文案在 `config.mts` 的 `themeConfig.search` 内联翻译,不另建 i18n 包。
|
||||
- 主题默认暗色:`theme/index.ts` 在无 `vitepress-theme-appearance` 时写入 `dark`,与 web 的 `app-theme` 偏好独立。
|
||||
|
||||
## 修改指南
|
||||
|
||||
- **加一页文档**:`guide/<slug>.md` + `en/guide/<slug>.md` → 更新 `config.mts` 两侧 sidebar。
|
||||
- **改导航顺序**:只改 `config.mts` 的 `zhNav`/`zhSidebar`/`enNav`/`enSidebar`,保持中英结构对称。
|
||||
- **加静态资源**:放 `public/`,引用用绝对路径 `/xxx`。
|
||||
- 不要引入 Vue 组件或自定义布局(当前只用默认主题 + CSS);确需扩展时走 `theme/index.ts`。
|
||||
- 构建产物 `.vitepress/dist/` 与缓存 `.vitepress/cache/` 勿手改。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
yarn docs:dev # 本地预览
|
||||
yarn docs:build # 构建到 .vitepress/dist/
|
||||
yarn workspace @app/docs typecheck
|
||||
yarn workspace @app/docs lint
|
||||
```
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Accounts and progress
|
||||
|
||||
Fanmu supports more than one person.
|
||||
|
||||
## One account each
|
||||
|
||||
- Register your own account
|
||||
- Each person has their own library and watch progress
|
||||
- Nobody sees the other's list
|
||||
|
||||
The admin (from `db:seed`) can manage users.
|
||||
|
||||
## Progress follows the person
|
||||
|
||||
Zhang San is 12 minutes in. Li Si opens the same file and starts from zero.
|
||||
|
||||
Switching devices is fine too: watch on the phone, continue on the desktop, same account.
|
||||
|
||||
## Forgot password
|
||||
|
||||
Use "Forgot password" on the login page. If mail is not configured, ask the admin to reset it.
|
||||
|
||||
## Default account
|
||||
|
||||
Local `db:seed` creates:
|
||||
|
||||
| User | Password |
|
||||
|------|----------|
|
||||
| admin | admin123 |
|
||||
|
||||
**That password is public. Change it before the app faces the internet.**
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# Find a video
|
||||
|
||||
Once the drive is mounted, just browse.
|
||||
|
||||
## How
|
||||
|
||||
1. Open **Browse**
|
||||
2. Pick a mount
|
||||
3. Open folders like a file manager
|
||||
|
||||
Each file can be **played** directly or **added to the library**.
|
||||
|
||||
## Cannot find anything?
|
||||
|
||||
- **Empty folder**: check the [mount](/en/guide/mount) connection
|
||||
- **Will not play**: browsers handle mp4 and webm best. mkv depends on the browser. Convert the file or try another browser
|
||||
- **Ugly file names**: fine for playback. For posters and titles, [scan into the library](/en/guide/library) or [identify the show](/en/guide/scrape)
|
||||
|
||||
## Browse vs library
|
||||
|
||||
| | Browse | Library |
|
||||
|--|--------|---------|
|
||||
| What you see | Real folders on the drive | A tidy show list |
|
||||
| Good for | Quick look | Regular shows, resume |
|
||||
| Source | Mounted files | Scanned items |
|
||||
|
||||
Both can play. Browse is the file view; library is the show view.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Watch and danmaku
|
||||
|
||||
Open a file and you are on the player page.
|
||||
|
||||
## Playback
|
||||
|
||||
It is a web player. Play/pause, seek, volume, fullscreen are all there.
|
||||
|
||||
Leave halfway and progress is kept.
|
||||
|
||||
## Where danmaku comes from
|
||||
|
||||
Same idea as DanDanPlay: **one episode, one danmaku pool**.
|
||||
|
||||
1. You open a video
|
||||
2. The system knows which show and episode it is (or you identified it earlier)
|
||||
3. It asks the danmaku network for that episode
|
||||
4. Danmaku floats over the video
|
||||
|
||||
So a 720P from one group and a 1080P from another, once matched to the same episode, share the same danmaku.
|
||||
|
||||
## No danmaku?
|
||||
|
||||
- **Unmatched**: [identify the show](/en/guide/scrape) first
|
||||
- **Network down**: video still plays, just silent of danmaku. Retry later
|
||||
- **Prefer a local file**: import an XML
|
||||
|
||||
### Import XML danmaku
|
||||
|
||||
On the player or the [Danmaku settings](#danmaku-settings) page, import a `.xml` danmaku file (the kind exported from Bilibili).
|
||||
|
||||
A video and a same-named danmaku file in one folder is also picked up.
|
||||
|
||||
## Danmaku settings
|
||||
|
||||
The **Danmaku** page lets you set:
|
||||
|
||||
- On or off by default
|
||||
- Opacity (lower it if it blocks the picture)
|
||||
- Density (how many at once on screen)
|
||||
|
||||
You can also toggle while watching.
|
||||
|
||||
## A note on content
|
||||
|
||||
Danmaku comes from a public pool. If something bothers you, turn it off. Fanmu does not edit danmaku text.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# FAQ
|
||||
|
||||
## Playback
|
||||
|
||||
**Black screen / will not play?**
|
||||
|
||||
Browsers do not all handle mkv or ts. Try another browser, or convert to mp4. It may also be a broken mount — test the connection.
|
||||
|
||||
**Seeking? Quality switching?**
|
||||
|
||||
You can seek. There is no quality switch: the file on the drive is what plays.
|
||||
|
||||
**Progress not saved?**
|
||||
|
||||
A flaky network can drop progress reports. Drag the slider if a spot is missing.
|
||||
|
||||
## Danmaku
|
||||
|
||||
**None at all?**
|
||||
|
||||
Work through this:
|
||||
|
||||
1. Was the video identified? If not, [identify it](/en/guide/scrape)
|
||||
2. Is the match correct? A wrong match pulls the wrong pool
|
||||
3. Is the danmaku network up?
|
||||
4. Still nothing? [Import XML](/en/guide/danmaku)
|
||||
|
||||
**Too dense?**
|
||||
|
||||
Lower opacity and density, or turn it off.
|
||||
|
||||
**Can I send danmaku?**
|
||||
|
||||
Yes. It goes to the public pool.
|
||||
|
||||
## Mounts
|
||||
|
||||
**Test connection fails?**
|
||||
|
||||
- Use the WebDAV root, not a web page URL
|
||||
- For OpenList, use the WebDAV endpoint
|
||||
- Check username, password, and the HTTPS certificate
|
||||
|
||||
**Why not SMB / Emby?**
|
||||
|
||||
v1 is WebDAV only. More later if there is demand.
|
||||
|
||||
## Accounts
|
||||
|
||||
**Is email required?**
|
||||
|
||||
Depends on your deployment. Locally, the seeded admin is enough.
|
||||
|
||||
**Can I see what others watched?**
|
||||
|
||||
No. Progress is private.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Library
|
||||
|
||||
Do not want to dig through folders every time? Scan shows into the library.
|
||||
|
||||
## What it looks like
|
||||
|
||||
A grid of posters with titles. Open one and you get the episode list. Watched episodes show progress; one click resumes.
|
||||
|
||||
## How items get in
|
||||
|
||||
1. **Add to library** on a file in Browse
|
||||
2. **Scan** on a mount — takes every video under it
|
||||
|
||||
A scan will:
|
||||
|
||||
- Record the video files
|
||||
- Guess show and episode from the name
|
||||
- Look up title and poster on Bangumi
|
||||
- Mark anything it cannot place as "unmatched" — it will not block playback
|
||||
|
||||
## After the scan
|
||||
|
||||
| You see | Meaning |
|
||||
|---------|---------|
|
||||
| Poster and title | Match worked |
|
||||
| Unmatched | Name was unreadable, or Bangumi had no hit |
|
||||
| Continue | Resume from saved progress |
|
||||
|
||||
## Wrong match?
|
||||
|
||||
Fix it in [Identify the show](/en/guide/scrape).
|
||||
|
||||
## Progress
|
||||
|
||||
Playback position is saved as you watch. Next time, continue.
|
||||
|
||||
Progress is per person. Zhang San and Li Si do not share it. See [Accounts](/en/guide/account).
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Mount a drive
|
||||
|
||||
Videos on a cloud drive? Mount it into Fanmu first.
|
||||
|
||||
## What is supported
|
||||
|
||||
**WebDAV**.
|
||||
|
||||
OpenList (formerly AList) works through its WebDAV endpoint — there is no separate OpenList API.
|
||||
|
||||
SMB, Emby, and OneDrive are not supported yet.
|
||||
|
||||
## How
|
||||
|
||||
1. Open **Mounts** in the sidebar
|
||||
2. Add a mount and fill in:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| Name | Anything, e.g. "My NAS" |
|
||||
| URL | WebDAV root, like `https://nas.example.com/dav` |
|
||||
| Username | Drive account (optional) |
|
||||
| Password | Drive password (optional) |
|
||||
| Root path | Only look under this folder (empty = whole root) |
|
||||
|
||||
3. Click **Test connection**, then save
|
||||
|
||||
## Is the password safe
|
||||
|
||||
The password is **encrypted on the server**. The browser never sees the plaintext. Streams also go through the server, so your browser does not talk to the drive with your password.
|
||||
|
||||
## If it fails
|
||||
|
||||
- Cannot connect: check that the URL is the WebDAV root, and that the account and HTTPS cert are fine
|
||||
- Connects but empty: the root path may be too deep
|
||||
- Do not need it anymore: disable or delete the mount
|
||||
|
||||
## Next
|
||||
|
||||
- [Find a video](/en/guide/browse)
|
||||
- [Scan into the library](/en/guide/library)
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Get started
|
||||
|
||||
Goal: a running app and a login screen in a few minutes.
|
||||
|
||||
## What you need
|
||||
|
||||
- Node.js 22 or newer
|
||||
- Yarn 4 (enable via Corepack)
|
||||
- No database server; local SQLite is the default
|
||||
|
||||
## Four steps
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
corepack enable
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 2. Config
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Local defaults are fine. `TURSO_DATABASE_URL` already points at `file:local.db`.
|
||||
|
||||
To mount a drive you also need a secret (used to encrypt saved drive passwords):
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Put it in `.env` as `MEDIA_SECRET_KEY`.
|
||||
|
||||
### 3. Database and admin
|
||||
|
||||
```bash
|
||||
yarn db:push
|
||||
yarn db:seed
|
||||
```
|
||||
|
||||
`db:seed` creates **admin / admin123**. Change that password before going public.
|
||||
|
||||
### 4. Run
|
||||
|
||||
```bash
|
||||
yarn dev:web
|
||||
```
|
||||
|
||||
Open `http://localhost:4321`. English is at `/en`, Chinese at `/zh-CN`.
|
||||
|
||||
After login you should see:
|
||||
|
||||
- **Library** — your shows
|
||||
- **Browse** — folders on the drive
|
||||
- **Mounts** — drive connections
|
||||
- **Danmaku** — on/off and style
|
||||
- **Watch** — the player page
|
||||
|
||||
## Next
|
||||
|
||||
1. [Mount a drive](/en/guide/mount)
|
||||
2. [Play something](/en/guide/browse)
|
||||
3. [Scan into the library](/en/guide/library)
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Does |
|
||||
|---------|------|
|
||||
| `yarn dev:web` | Dev server on port 4321 |
|
||||
| `yarn build:web` | Build the web app |
|
||||
| `yarn docs:dev` | Preview these docs |
|
||||
| `yarn typecheck` | Type check |
|
||||
| `yarn lint` | Lint |
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Identify the show
|
||||
|
||||
Danmaku and posters both depend on knowing **what this is**. Get this right and the rest is easy.
|
||||
|
||||
## Automatic
|
||||
|
||||
On scan or add-to-library, the file name is used to guess:
|
||||
|
||||
- Which show
|
||||
- Which episode
|
||||
|
||||
Then Fanmu looks it up on **Bangumi** for title, poster, and episode number.
|
||||
|
||||
## If the guess is wrong
|
||||
|
||||
The item is marked **unmatched**.
|
||||
|
||||
Nothing is deleted. Playback still works. You can leave it, or bind it by hand.
|
||||
|
||||
## Fix it by hand
|
||||
|
||||
Open the item:
|
||||
|
||||
1. Search for the show on Bangumi
|
||||
2. Pick the right one
|
||||
3. Confirm
|
||||
|
||||
You can re-scrape after binding to refresh posters and metadata.
|
||||
|
||||
## Why Bangumi
|
||||
|
||||
Bangumi has solid anime data and a real API. Not Douban, not TMDB.
|
||||
|
||||
If a niche title is missing there, you can only hand-edit the name, and danmaku may not match. Use [local XML danmaku](/en/guide/danmaku) as fallback.
|
||||
|
||||
## Common cases
|
||||
|
||||
| Case | What to do |
|
||||
|------|------------|
|
||||
| Name like `VCB-Studio][BanG Dream! Ave Mujica][01][1080p][x265_FLAC].mkv` | Usually auto-matched |
|
||||
| Name like `video.mp4` | Search manually |
|
||||
| Matched the wrong show | Rebind |
|
||||
| OVA / special / movie | Episode number may be off; fix it |
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# What is Fanmu
|
||||
|
||||
Fanmu is a **self-hosted media library**.
|
||||
|
||||
Your videos live on a WebDAV drive (OpenList included). Mount that drive in Fanmu and you can play in a browser, with danmaku on top.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Watching a local or cloud file has one old problem: **no danmaku**.
|
||||
|
||||
DanDanPlay solved this for desktop: play a file from disk, get matching online danmaku. Fanmu does the same idea in a browser:
|
||||
|
||||
- Nothing to install on every machine
|
||||
- Phone, tablet, TV browser all work
|
||||
- Files stay in one place on the drive
|
||||
|
||||
## What it can do
|
||||
|
||||
- **Mount WebDAV** (OpenList uses its WebDAV endpoint)
|
||||
- **Play video** in the browser; progress is saved
|
||||
- **Danmaku**: matched from the open danmaku network; local XML as fallback
|
||||
- **Library**: scan in files, posters, resume where you left off
|
||||
- **Accounts**: each person has their own list and progress
|
||||
|
||||
## How it relates to DanDanPlay
|
||||
|
||||
| | DanDanPlay | Fanmu |
|
||||
|--|------------|-------|
|
||||
| Where it runs | Desktop app | In the browser |
|
||||
| Where files live | Local disk | WebDAV drive |
|
||||
| Danmaku | Open danmaku network | Same network |
|
||||
| Install client | Yes | No |
|
||||
|
||||
Fanmu is not a plugin and does not need DanDanPlay running on your PC. It stands on its own: **video from your drive, danmaku from the web**.
|
||||
|
||||
## Not in v1
|
||||
|
||||
Present in DanDanPlay, not here yet:
|
||||
|
||||
- BT download / RSS
|
||||
- AI subtitles and summaries
|
||||
- Cast, frame interpolation
|
||||
- Emby / Jellyfin / Plex (WebDAV only for now)
|
||||
|
||||
The main path first: mount → find → play → danmaku → progress.
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: Fanmu
|
||||
text: Your own media library, with danmaku
|
||||
tagline: Mount a cloud drive, play video in the browser, and get online danmaku on top. No desktop client needed.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: What it does
|
||||
link: /en/guide/what-is
|
||||
- theme: alt
|
||||
text: Get started
|
||||
link: /en/guide/quick-start
|
||||
|
||||
features:
|
||||
- title: Mount and play
|
||||
details: Hook up a WebDAV drive and play right in the browser. The password stays on the server.
|
||||
- title: Video with danmaku
|
||||
details: Danmaku is matched automatically, the same way DanDanPlay does for local files. You can also import a local XML file.
|
||||
- title: Knows what you are watching
|
||||
details: Scans figure out the show and episode, then pull in posters and metadata. Fix a wrong match by hand if needed.
|
||||
- title: Built for households
|
||||
details: Multiple accounts. Everyone gets their own library and watch progress.
|
||||
---
|
||||
|
||||
## What is this
|
||||
|
||||
**Fanmu** is a media library you host yourself.
|
||||
|
||||
Your videos live on a cloud drive (WebDAV, including OpenList's WebDAV endpoint). Mount it in Fanmu and play in the browser, with danmaku.
|
||||
|
||||
The idea is close to DanDanPlay: local video plus online danmaku. Fanmu just runs in the browser, so you do not install a player on every machine.
|
||||
|
||||
## What you can do
|
||||
|
||||
| Task | Guide |
|
||||
|------|-------|
|
||||
| [Mount a drive](/en/guide/mount) | Connect WebDAV |
|
||||
| [Find a video](/en/guide/browse) | Browse folders and play |
|
||||
| [Library](/en/guide/library) | Scan in shows, posters, resume |
|
||||
| [Watch and danmaku](/en/guide/danmaku) | Playback, danmaku, styling |
|
||||
| [Identify the show](/en/guide/scrape) | Auto match, or fix it yourself |
|
||||
| [Accounts](/en/guide/account) | Sign in, private progress |
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
cp .env.example .env
|
||||
yarn db:push && yarn db:seed
|
||||
yarn dev:web
|
||||
```
|
||||
|
||||
Open `http://localhost:4321` — English lives at `/en`. More detail in [Get started](/en/guide/quick-start).
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# 账号和进度
|
||||
|
||||
帆幕支持多个人用。
|
||||
|
||||
## 一人一个账号
|
||||
|
||||
- 注册自己的账号
|
||||
- 各人有自己的媒体库、挂载(看部署怎么配)、观看进度
|
||||
- 互不干扰
|
||||
|
||||
管理员(`db:seed` 建的那个)可以管用户。
|
||||
|
||||
## 进度跟人走
|
||||
|
||||
张三看到第 12 分钟,李四打开同一个视频,还是从头开始。
|
||||
|
||||
换设备也一样:手机看一半,电脑上接着看(只要登的是同一个账号)。
|
||||
|
||||
## 忘了密码
|
||||
|
||||
登录页有「忘记密码」流程。如果邮件功能没配好,就让管理员帮你重置。
|
||||
|
||||
## 默认账号
|
||||
|
||||
本地开发 `db:seed` 会建:
|
||||
|
||||
| 账号 | 密码 |
|
||||
|------|------|
|
||||
| admin | admin123 |
|
||||
|
||||
**这个密码是公开的,开到公网前一定要改。**
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# 找视频
|
||||
|
||||
挂好网盘之后,就可以翻文件夹了。
|
||||
|
||||
## 怎么找
|
||||
|
||||
1. 打开侧栏 **浏览**
|
||||
2. 选一个挂载
|
||||
3. 一层层点进去,像用文件管理器一样
|
||||
|
||||
每行文件可以直接 **播放**,也可以 **加入媒体库**。
|
||||
|
||||
## 还是找不到?
|
||||
|
||||
- **文件夹是空的**:先去[挂载](/guide/mount)看看连接通不通
|
||||
- **视频格式播不了**:浏览器认 mp4、webm 比较稳;mkv 有的浏览器能播有的不行。播不了的话,可以先把文件转一下,或者换个浏览器试试
|
||||
- **名字乱七八糟**:不影响播。想要好看的海报和标题,去[媒体库](/guide/library)扫描,或[手动认剧](/guide/scrape)
|
||||
|
||||
## 和媒体库什么关系
|
||||
|
||||
| | 浏览 | 媒体库 |
|
||||
|--|------|--------|
|
||||
| 看的是什么 | 网盘真实文件夹 | 整理过的片单 |
|
||||
| 适合 | 随便翻、临时看一眼 | 常看的剧、接着看 |
|
||||
| 来源 | 挂载里的文件 | 扫描进来的 |
|
||||
|
||||
两条路都能播。浏览是「文件视角」,媒体库是「剧集视角」。
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# 看片和弹幕
|
||||
|
||||
点开一个视频,就是播放页。
|
||||
|
||||
## 播放
|
||||
|
||||
播放器是网页里的,不用装东西。基本操作(播放/暂停、进度、音量、全屏)都有。
|
||||
|
||||
看一半关掉不要紧,进度记住了,下次接着看。
|
||||
|
||||
## 弹幕从哪来
|
||||
|
||||
和弹弹play一个思路:**同一集,用同一份弹幕**。
|
||||
|
||||
流程是这样:
|
||||
|
||||
1. 你打开一个视频
|
||||
2. 系统猜这是哪部剧第几集(或者你之前已经认过了)
|
||||
3. 拿这个「身份」去弹幕网络要弹幕
|
||||
4. 弹幕飘在视频上
|
||||
|
||||
所以字幕组 A 的 720P 和字幕组 B 的 1080P,只要认成同一集,弹幕就是同一份。
|
||||
|
||||
## 没有弹幕怎么办
|
||||
|
||||
- **识别失败**:先去[认剧](/guide/scrape)手动认一下
|
||||
- **弹幕网络挂了**:照样能看视频,只是没弹幕。过会儿再试
|
||||
- **就是想要本地弹幕**:导入 XML 文件
|
||||
|
||||
### 导入 XML 弹幕
|
||||
|
||||
播放页或[弹幕设置](#弹幕设置)里可以导入 `.xml` 弹幕文件(B 站导出的那种)。
|
||||
|
||||
同名的视频和弹幕文件放在一个文件夹里,也认得出来。
|
||||
|
||||
## 弹幕设置
|
||||
|
||||
侧栏 **弹幕** 页面可以调:
|
||||
|
||||
- 默认开还是关
|
||||
- 透明度(怕挡画面就调淡一点)
|
||||
- 密度(一屏幕最多飘多少条)
|
||||
|
||||
播放的时候也能随时开关。
|
||||
|
||||
## 弹幕礼仪
|
||||
|
||||
弹幕内容来自公开弹幕库。看到了不舒服的,关掉弹幕就好。帆幕不存储、不修改弹幕内容。
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# 常见问题
|
||||
|
||||
## 播放相关
|
||||
|
||||
**视频点开是黑的 / 播不了?**
|
||||
|
||||
浏览器不一定认得 mkv、ts 这些格式。换个浏览器试试,或者把视频转成 mp4。也可能是网盘断了,去挂载页测一下连接。
|
||||
|
||||
**能快进吗?能选清晰度吗?**
|
||||
|
||||
能快进。清晰度跟文件走——你网盘里是什么文件,就播什么,没有「切换清晰度」这一说。
|
||||
|
||||
**进度没记上?**
|
||||
|
||||
网络不好的时候进度上报会失败。正常看一般没事;如果某次没记上,手动拖一下就好。
|
||||
|
||||
## 弹幕相关
|
||||
|
||||
**完全没有弹幕?**
|
||||
|
||||
按这个顺序查:
|
||||
|
||||
1. 这个视频认成剧了吗?没认成就[手动认](/guide/scrape)
|
||||
2. 认对了吗?认错了弹幕就是别家的
|
||||
3. 弹幕网络通不通?挂了就等一会儿
|
||||
4. 实在没有,[导入 XML 弹幕](/guide/danmaku)
|
||||
|
||||
**弹幕太多挡脸?**
|
||||
|
||||
弹幕设置里调透明度和密度,或者直接关掉。
|
||||
|
||||
**能发弹幕吗?**
|
||||
|
||||
能,发出去的是公共弹幕库,大家都能看到。
|
||||
|
||||
## 挂载相关
|
||||
|
||||
**测试连接失败?**
|
||||
|
||||
- 地址要写 WebDAV 根,不是网页地址
|
||||
- OpenList 用它的 WebDAV 端点
|
||||
- 用户名密码要对
|
||||
- HTTPS 证书要有效
|
||||
|
||||
**为什么不是 SMB / Emby?**
|
||||
|
||||
第一版只做 WebDAV。其他以后看需求。
|
||||
|
||||
## 账号相关
|
||||
|
||||
**注册要邮箱吗?**
|
||||
|
||||
看部署怎么配的。本地开发用 seed 的管理员就行。
|
||||
|
||||
**能看到别人看了什么吗?**
|
||||
|
||||
不能。各人进度各人自己看。
|
||||
|
||||
## 还有别的问题
|
||||
|
||||
到仓库提 issue,附上:
|
||||
|
||||
- 你干了什么
|
||||
- 期望发生什么
|
||||
- 实际发生什么
|
||||
- 截图或报错文字
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# 媒体库
|
||||
|
||||
不想每次都翻文件夹?把片子扫进媒体库,以后按剧找。
|
||||
|
||||
## 它长什么样
|
||||
|
||||
媒体库是一排海报卡片,写着剧名。点进去是这一部的分集列表,看过一半的会显示进度,点一下就能接着看。
|
||||
|
||||
## 怎么进来
|
||||
|
||||
两种方式:
|
||||
|
||||
1. **浏览页里点「加入库」**:加单个文件
|
||||
2. **挂载页里点「扫描」**:把这个挂载下视频都扫一遍
|
||||
|
||||
扫描会做这些事:
|
||||
|
||||
- 把视频文件记下来
|
||||
- 猜这是哪部剧第几集
|
||||
- 去 Bangumi 查剧名、海报
|
||||
- 查不到就标成「未识别」,不会丢,也不会挡住你播
|
||||
|
||||
## 扫完之后
|
||||
|
||||
| 你会看到 | 说明 |
|
||||
|----------|------|
|
||||
| 海报、标题 | 识别成功的话会自动填 |
|
||||
| 未识别 | 文件名猜不出来,或 Bangumi 查不到 |
|
||||
| 继续播放 | 有进度的条目,点进去接着看 |
|
||||
|
||||
## 认错了怎么办
|
||||
|
||||
去[认剧](/guide/scrape)手动改绑。
|
||||
|
||||
## 进度怎么记
|
||||
|
||||
每看到一处,会自动记住看到哪了。下次打开同一个视频,接着来。
|
||||
|
||||
进度跟人走:张三和李四各记各的,互不影响。见[账号](/guide/account)。
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# 连网盘
|
||||
|
||||
想看的片子在网盘里?先把它「挂」到帆幕上。
|
||||
|
||||
## 目前支持什么
|
||||
|
||||
**WebDAV**。
|
||||
|
||||
OpenList(以前的 AList)也用它的 WebDAV 地址,不要去找什么「OpenList 专用接口」。
|
||||
|
||||
其他(SMB、Emby、OneDrive 直连)暂时不支持。
|
||||
|
||||
## 怎么挂
|
||||
|
||||
1. 打开侧栏 **挂载**
|
||||
2. 点添加,填这些:
|
||||
|
||||
| 填什么 | 说明 |
|
||||
|--------|------|
|
||||
| 名称 | 随便起,比如「我的NAS」 |
|
||||
| 地址 | WebDAV 根地址,形如 `https://nas.example.com/dav` |
|
||||
| 用户名 | 网盘账号(可空) |
|
||||
| 密码 | 网盘密码(可空) |
|
||||
| 根路径 | 只看这个目录下面(可空,表示整个根) |
|
||||
|
||||
3. 先点 **测试连接**,通了再保存
|
||||
|
||||
## 密码安全吗
|
||||
|
||||
密码发到服务器后会**加密存起来**,浏览器拿不到明文。每次去网盘拉文件,也是服务器出面,你的浏览器不直接碰网盘密码。
|
||||
|
||||
## 挂错了怎么办
|
||||
|
||||
- 连不上:检查地址是不是 WebDAV 根,用户名密码对不对,HTTPS 证书有没有问题
|
||||
- 能连上但没文件:看看「根路径」是不是填太深了
|
||||
- 不想用了:挂载页里可以停用或删掉
|
||||
|
||||
## 下一步
|
||||
|
||||
- [在网盘里找视频](/guide/browse)
|
||||
- [扫一遍进媒体库](/guide/library)
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# 快速上手
|
||||
|
||||
目标:几分钟内跑起来,登录进去看到界面。
|
||||
|
||||
## 你需要什么
|
||||
|
||||
- Node.js 22 以上
|
||||
- Yarn 4(用 Corepack 打开就行)
|
||||
- 不用装数据库软件,默认用本地 SQLite 文件
|
||||
|
||||
## 四步跑起来
|
||||
|
||||
### 1. 装依赖
|
||||
|
||||
```bash
|
||||
corepack enable
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 2. 准备配置
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
本地开发一般不用改什么。`TURSO_DATABASE_URL` 默认就是 `file:local.db`,数据存在当前目录。
|
||||
|
||||
如果要挂网盘,还需要一个密钥(用来加密你保存的网盘密码):
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
把结果填进 `.env` 的 `MEDIA_SECRET_KEY`。
|
||||
|
||||
### 3. 建库、建管理员
|
||||
|
||||
```bash
|
||||
yarn db:push
|
||||
yarn db:seed
|
||||
```
|
||||
|
||||
`db:seed` 会建一个管理员:**admin / admin123**。上线前记得改密码。
|
||||
|
||||
### 4. 启动
|
||||
|
||||
```bash
|
||||
yarn dev:web
|
||||
```
|
||||
|
||||
打开 `http://localhost:4321`,走中文入口在 `/zh-CN`,英文在 `/en`。
|
||||
|
||||
登录后你应该能看到这些页面:
|
||||
|
||||
- **媒体库**:你的片子
|
||||
- **浏览**:网盘里的文件夹
|
||||
- **挂载**:管理网盘连接
|
||||
- **弹幕**:弹幕开关和样式
|
||||
- **播放**:点开视频后的页面
|
||||
|
||||
## 接下来干什么
|
||||
|
||||
1. [把网盘挂上来](/guide/mount)
|
||||
2. [找个视频播一下](/guide/browse)
|
||||
3. 想要整齐的片单就[扫进媒体库](/guide/library)
|
||||
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 干什么 |
|
||||
|------|--------|
|
||||
| `yarn dev:web` | 开发模式跑主应用(4321 端口) |
|
||||
| `yarn build:web` | 打包主应用 |
|
||||
| `yarn docs:dev` | 本地预览这份文档 |
|
||||
| `yarn typecheck` | 检查类型 |
|
||||
| `yarn lint` | 检查代码风格 |
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# 认出是哪部剧
|
||||
|
||||
弹幕和海报,都靠「认出这是什么」。这一步做对了,后面都省事。
|
||||
|
||||
## 自动认
|
||||
|
||||
扫描或加入媒体库时,会看文件名,猜:
|
||||
|
||||
- 这是哪部作品
|
||||
- 这是第几集
|
||||
|
||||
然后去 **Bangumi** 查资料,把标题、海报、剧集号填上。
|
||||
|
||||
## 猜不准会怎样
|
||||
|
||||
标成 **未识别**。
|
||||
|
||||
不会删文件,不会不让你播,只是先不配弹幕、不配海报。你可以:
|
||||
|
||||
- 留着不管,直接播
|
||||
- 手动绑一下(见下)
|
||||
|
||||
## 手动改
|
||||
|
||||
打开这个条目的详情:
|
||||
|
||||
1. 搜索 Bangumi 上的剧名
|
||||
2. 选中正确的那一部
|
||||
3. 确认绑定
|
||||
|
||||
绑完可以再刮一次(重新拉海报和信息)。
|
||||
|
||||
## 为什么用 Bangumi
|
||||
|
||||
Bangumi 的动画资料比较全,而且有 API。不是豆瓣,也不是 TMDB。
|
||||
|
||||
如果某部冷门作品 Bangumi 上没有,那就只能手动填名字,弹幕也匹配不到——这种情况下用[本地 XML 弹幕](/guide/danmaku)兜底。
|
||||
|
||||
## 常见情况
|
||||
|
||||
| 情况 | 该怎么办 |
|
||||
|------|----------|
|
||||
| 文件名是 `VCB-Studio][BanG Dream! Ave Mujica][01][1080p][x265_FLAC].mkv` | 一般能自动认出来 |
|
||||
| 文件名是 `视频.mp4` | 认不出来,手动搜 |
|
||||
| 认成了别的剧 | 手动改绑 |
|
||||
| 特典 / OVA / 剧场版 | 集数可能对不上,手动调 |
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# 帆幕是什么
|
||||
|
||||
帆幕是一个**自己架的影音库**。
|
||||
|
||||
你的视频存在网盘里(比如 OpenList、支持 WebDAV 的网盘)。挂载到帆幕之后,打开浏览器就能播,而且能飘弹幕。
|
||||
|
||||
## 为什么要这个东西
|
||||
|
||||
看本地片或网盘片有个老问题:**没有弹幕**。
|
||||
|
||||
弹弹play 解决过这个问题——播放硬盘里的视频,自动配上网络弹幕。帆幕的想法差不多,只是跑在浏览器里:
|
||||
|
||||
- 不用在每台电脑上装客户端
|
||||
- 手机、平板、电视浏览器都能开
|
||||
- 片子集中存在网盘,不用到处拷
|
||||
|
||||
## 能干什么
|
||||
|
||||
- **连网盘**:支持 WebDAV(OpenList 也走它的 WebDAV)
|
||||
- **看视频**:浏览器里直接播,进度自动记住
|
||||
- **飘弹幕**:自动去弹幕库匹配;匹配不上就导入本地 XML 弹幕
|
||||
- **媒体库**:扫一遍入库,有海报、有剧集信息,能「继续看」
|
||||
- **多账号**:一人一个片单和进度
|
||||
|
||||
## 跟弹弹play 什么关系
|
||||
|
||||
| | 弹弹play | 帆幕 |
|
||||
|--|----------|------|
|
||||
| 在哪跑 | 电脑上的软件 | 浏览器网页 |
|
||||
| 片子在哪 | 本机硬盘 | WebDAV 网盘 |
|
||||
| 弹幕 | 在线弹幕库 | 同一套开放弹幕网络 |
|
||||
| 装客户端 | 要 | 不用 |
|
||||
|
||||
帆幕不是弹弹play 的插件,也不用先开着电脑上的弹弹play。它是独立的一套东西,能力对齐:**本地/网盘视频 + 在线弹幕**。
|
||||
|
||||
## 还没有的东西
|
||||
|
||||
第一版先不做这些(弹弹play 里有的):
|
||||
|
||||
- BT 下载、RSS 追新
|
||||
- AI 字幕、AI 总结
|
||||
- 投屏、插帧
|
||||
- Emby / Jellyfin / Plex 直连(目前只认 WebDAV)
|
||||
|
||||
先把「挂载 → 找片 → 播放 → 弹幕 → 进度」这条主路走顺。
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: 帆幕
|
||||
text: 自己的影音库,看片有弹幕
|
||||
tagline: 挂上网盘播视频,自动配上网上弹幕。不用装客户端,打开浏览器就能看。
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 看看能干什么
|
||||
link: /guide/what-is
|
||||
- theme: alt
|
||||
text: 马上用起来
|
||||
link: /guide/quick-start
|
||||
|
||||
features:
|
||||
- title: 连上网盘就看
|
||||
details: 把 WebDAV 网盘挂进来,在浏览器里直接播。账号密码留在服务器,不进浏览器。
|
||||
- title: 看片有弹幕
|
||||
details: 播视频时自动去找对应的弹幕,跟在弹弹play里看本地片一样。没有的话也能导入本地弹幕文件。
|
||||
- title: 认得出是哪部剧
|
||||
details: 扫一扫就知道这是哪部作品第几集,自动配上海报和信息。认错了也能手动改。
|
||||
- title: 一家人都能用
|
||||
details: 多账号,各人有各人的片单和进度,互不干扰。
|
||||
---
|
||||
|
||||
## 这是啥
|
||||
|
||||
**帆幕** 就是你自己架的一个影音库。
|
||||
|
||||
片子放在网盘里,用浏览器打开帆幕就能看,而且能飘弹幕。体验上参考了弹弹play:本地视频也能配上网上弹幕,不过帆幕整个跑在浏览器里,不用在电脑上装播放器。
|
||||
|
||||
## 能干啥
|
||||
|
||||
| 事情 | 说明 |
|
||||
|------|------|
|
||||
| [连网盘](/guide/mount) | 把 WebDAV 网盘挂上来 |
|
||||
| [找视频](/guide/browse) | 在网盘里翻文件夹,点开就播 |
|
||||
| [媒体库](/guide/library) | 扫一遍入库,有海报、能接着看 |
|
||||
| [看片和弹幕](/guide/danmaku) | 播放、发弹幕、调弹幕样式 |
|
||||
| [认剧](/guide/scrape) | 自动认出哪部剧第几集,认错能改 |
|
||||
| [账号](/guide/account) | 注册登录,各人看各人的 |
|
||||
|
||||
## 怎么跑起来
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
cp .env.example .env
|
||||
yarn db:push && yarn db:seed
|
||||
yarn dev:web
|
||||
```
|
||||
|
||||
浏览器打开 `http://localhost:4321`,用 `admin` / `admin123` 登录。更细的步骤见[快速上手](/guide/quick-start)。
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@app/docs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev",
|
||||
"build": "vitepress build",
|
||||
"preview": "vitepress preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitepress": "^1.6.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" role="img" aria-label="App Template">
|
||||
<title>App Template</title>
|
||||
<rect width="32" height="32" rx="6" fill="#0E0F0E"/>
|
||||
<path d="M8 22V10h4.2c3.2 0 5.2 1.7 5.2 4.4 0 1.7-.9 3-2.4 3.6L18.8 22h-3.3l-3.2-3.6H11V22H8zm3-6.2h1.3c1.5 0 2.4-.7 2.4-1.9S13.8 12 12.3 12H11v3.8z" fill="#FFFA00"/>
|
||||
<path d="M20 10h2.8v12H20z" fill="#00FFA2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"moduleResolution": "bundler",
|
||||
"allowArbitraryExtensions": true
|
||||
},
|
||||
"include": [".vitepress/**/*.ts", ".vitepress/**/*.mts"]
|
||||
}
|
||||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
## 职责
|
||||
|
||||
Astro SSR 前端(Node standalone):页面、布局、React islands、tRPC HTTP 适配、SEO 文本端点、**i18n 路由**。
|
||||
**薄 SSR** Astro 前端(Node standalone):布局 / head / SEO / 中间件服务端渲染;页面主体为 React 客户端组件(`client:load`)。另含 tRPC HTTP 适配、SEO 文本端点、**i18n 路由**。
|
||||
|
||||
脚手架基线页面:首页、鉴权、仪表盘(user 列表示例)、设置。视觉遵循仓库根 `DESIGN.md` 与 `@app/design-tokens`。
|
||||
产品页面:首页、鉴权、仪表盘、媒体库 / 浏览 / 播放 / 挂载 / 弹幕设置与错误页。视觉遵循仓库根 `DESIGN.md` 与 `@app/design-tokens`。
|
||||
|
||||
## i18n
|
||||
|
||||
- 语言:`zh-CN`(`/zh-CN/...`)、`en`(`/en/...`)。**中文也带前缀**,不把中文留在裸 `/`。
|
||||
- **单套路由**:`src/pages/[locale]/index.astro`、`[locale]/dashboard.astro` + `src/views/*`;禁止再复制 `pages/zh-CN`、`pages/en` 目录。
|
||||
- **单套路由**:`src/pages/[locale]/...` + `src/views/*`;禁止再复制 `pages/zh-CN`、`pages/en` 目录。
|
||||
- 文案:`@app/i18n` 的 `t(locale, key)`;React island 传 `locale` prop,不引入 i18next。
|
||||
- 裸 `/`:`src/middleware.ts` 按 cookie `app-lang` → `Accept-Language` 跳 `/zh-CN` 或 `/en`;响应 `Vary: Accept-Language`;非法/大小写 `zh-cn` 规范化。
|
||||
- 手动切换:header 语言链接写 cookie 后跳转(布局内联脚本)。
|
||||
|
|
@ -20,17 +20,28 @@ Astro SSR 前端(Node standalone):页面、布局、React islands、tRPC H
|
|||
```
|
||||
src/
|
||||
pages/
|
||||
[locale]/index.astro | dashboard.astro | auth/* | settings.astro
|
||||
api/trpc/[...trpc].ts | api/users.ts
|
||||
[locale]/
|
||||
index.astro | dashboard.astro | library.astro | browse.astro
|
||||
watch.astro | mounts.astro | danmaku.astro | settings.astro
|
||||
auth/login.astro | auth/register.astro
|
||||
auth/forgot-password.astro | auth/reset-password.astro
|
||||
api/trpc/[...trpc].ts | api/stream.ts | api/users.ts
|
||||
sitemap.xml.ts | robots.txt.ts | llms.txt.ts | llms-full.txt.ts
|
||||
views/
|
||||
home.astro | dashboard.astro | auth.astro | error.astro
|
||||
middleware.ts # 裸 / 语言检测跳转 + locale 规范化
|
||||
403.astro | 404.astro | 500.astro
|
||||
views/ # SSR 壳 + seo/nav(每页一个)
|
||||
home.astro | dashboard.astro | library.astro | browse.astro
|
||||
watch.astro | mounts.astro | danmaku.astro | auth.astro | error.astro
|
||||
middleware.ts # 裸 / 语言检测跳转 + locale 规范化
|
||||
layouts/AppLayout.astro
|
||||
components/
|
||||
pages/ # 薄 SSR 主体(client:load)
|
||||
HomeBody.tsx | DashboardBody.tsx | LibraryBody.tsx | BrowseBody.tsx
|
||||
WatchBody.tsx | MountsBody.tsx | DanmakuSettingsBody.tsx
|
||||
AuthBody.tsx | ErrorBody.tsx
|
||||
UserList.tsx | UserMenu.tsx | ThemeSwitch.tsx | AuthForm.tsx | errors/*
|
||||
lib/
|
||||
trpc.ts | seo.ts(getSiteSeo(locale))| theme.ts | utils.ts(cn re-export)
|
||||
trpc.ts | seo.ts(getSiteSeo(locale))| nav.ts | theme.ts
|
||||
danmaku-render.ts | errors.ts | utils.ts(cn re-export)
|
||||
styles/globals.css
|
||||
astro.config.ts
|
||||
```
|
||||
|
|
@ -42,24 +53,28 @@ astro.config.ts
|
|||
- `@app/ui` → `packages/ui/src`(源码直出)
|
||||
- typecheck 时 tsconfig paths 指向各包 `src`——**改上游后先 build 再 dev/build web**
|
||||
|
||||
## 页面与 SEO
|
||||
## 页面与 SEO(薄 SSR)
|
||||
|
||||
- **服务端**:`AppLayout.astro`(head、导航、脚注)、`seo` prop、middleware(locale / 鉴权跳转)、API / sitemap 等端点。
|
||||
- **客户端**:`views/*` 只保留壳,主体为 `components/pages/*Body.tsx`,统一 `client:load`。
|
||||
- 布局统一走 `AppLayout.astro`,必传 `locale`,通过 `seo` prop 传 `PageSeoInput`。
|
||||
- 站点 SEO:`lib/seo.ts` 的 `getSiteSeo(locale)`;sitemap 双语 URL + hreflang `alternates`。
|
||||
- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap。
|
||||
- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap;主体写成 `*Body.tsx`。
|
||||
- 导航项集中在 `lib/nav.ts`,改菜单只动这一处。
|
||||
|
||||
## React islands
|
||||
|
||||
- 需要交互才加 `client:load` / `client:visible`。
|
||||
- 页面主体默认 `client:load`(薄 SSR);局部岛仍可用 `client:visible`。
|
||||
- 文案:`import { t } from "@app/i18n"` + `locale` prop;不要绑 i18next Provider。
|
||||
- tRPC:组件内 `QueryClientProvider` + trpc Provider(参考 `UserList.tsx`)。
|
||||
- UI:`@app/ui`(通用基础组件)+ `cn()`(`~/lib/utils` re-export);颜色用 CSS 变量。
|
||||
- 播放页弹幕渲染逻辑在 `lib/danmaku-render.ts`,与 `WatchBody` 解耦。
|
||||
|
||||
## 修改指南
|
||||
|
||||
- **加文案**:先改 `packages/i18n/src/messages/zh-CN.ts`,再补 `en.ts`。
|
||||
- **加业务页面**:在下游仓库扩展,勿把产品业务写回模板。
|
||||
- **调 API**:优先 tRPC;`api/users.ts` 仅演示。
|
||||
- **加业务页面**:沿用 `views/*` + `components/pages/*Body` 薄 SSR 模式扩展。
|
||||
- **调 API**:优先 tRPC(`trpc.media.*`);`api/users.ts` 仅演示;`api/stream.ts` 为受保护的媒体流。
|
||||
- **主题**:`localStorage` key `app-theme`;语言 cookie `app-lang`。
|
||||
- Astro 保留字:组件 props 避免 `slot`。
|
||||
|
||||
|
|
@ -71,4 +86,5 @@ yarn workspace @app/web lint
|
|||
yarn workspace @app/web dev # :4321
|
||||
yarn workspace @app/web build
|
||||
yarn dev:web
|
||||
yarn test:e2e # Playwright(e2e/ 目录)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export default defineConfig({
|
|||
},
|
||||
ssr: {
|
||||
noExternal: ["@app/trpc", "@app/db", "@app/i18n", "@app/ui"],
|
||||
// webdav 必须走 Node 原生 stream,不能被 SSR 打包掏空
|
||||
external: ["webdav"],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"@trpc/client": "^11.19.0",
|
||||
"@trpc/react-query": "^11.19.0",
|
||||
"artplayer": "^5.2.2",
|
||||
"artplayer-plugin-danmuku": "^5.3.0",
|
||||
"astro": "^7.3.3",
|
||||
"gsap": "^3.15.0",
|
||||
"lenis": "^1.3.26",
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const NAV = [
|
||||
{ hrefKey: "nav.library", href: "library", id: "library" },
|
||||
{ hrefKey: "nav.browse", href: "browse", id: "browse" },
|
||||
{ hrefKey: "nav.mounts", href: "mounts", id: "mounts" },
|
||||
{ hrefKey: "nav.danmaku", href: "danmaku", id: "danmaku" },
|
||||
] as const;
|
||||
|
||||
export function AppShell(props: {
|
||||
locale: string;
|
||||
active: (typeof NAV)[number]["id"] | "watch";
|
||||
labels: Record<string, string>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const base = `/${props.locale}`;
|
||||
const product = props.labels["meta.product"] ?? props.labels["meta.brand"] ?? "弹弹 Web";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground md:flex-row">
|
||||
{/* 窄屏:顶栏品牌 + 横向导航(md 以下侧栏隐藏时的唯一入口) */}
|
||||
<div className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur md:hidden">
|
||||
<div className="flex items-center gap-3 px-3 pt-3">
|
||||
<a href={`${base}/library`} className="text-sm font-semibold tracking-wide">
|
||||
{product}
|
||||
</a>
|
||||
</div>
|
||||
<nav
|
||||
className="flex gap-1 overflow-x-auto px-2 pb-2 pt-2"
|
||||
aria-label="media-nav-mobile"
|
||||
>
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`${base}/${item.href}`}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1.5 text-sm transition-colors",
|
||||
props.active === item.id
|
||||
? "bg-primary font-medium text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{props.labels[item.hrefKey] ?? item.href}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 宽屏:侧栏 */}
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
|
||||
<a
|
||||
href={`${base}/library`}
|
||||
className="mb-6 px-2 text-sm font-semibold tracking-wide"
|
||||
>
|
||||
{product}
|
||||
</a>
|
||||
<nav className="flex flex-col gap-1" aria-label="sidebar">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`${base}/${item.href}`}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm transition-colors",
|
||||
props.active === item.id
|
||||
? "bg-background font-medium shadow-sm"
|
||||
: "text-muted-foreground hover:bg-background/60",
|
||||
)}
|
||||
>
|
||||
{props.labels[item.hrefKey] ?? item.href}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col p-4 md:p-6">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
/**
|
||||
* 认证表单岛:登录 / 注册 / 找回 / 重置 / 改密,按 `mode` 切换。
|
||||
* Provider 包在组件内部(与 UserList 同模式)。
|
||||
* 默认登录方式:用户名 + 密码。
|
||||
* Provider 包在组件内部。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { trpc, createTRPCReactClient } from "../lib/trpc";
|
||||
import { go } from "../lib/nav";
|
||||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
import {
|
||||
|
|
@ -48,6 +50,7 @@ export default function AuthForm(props: AuthFormProps) {
|
|||
}
|
||||
|
||||
function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
|
@ -59,7 +62,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
const login = trpc.auth.login.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(t(locale, "auth.msg.loginOk"));
|
||||
window.location.href = next?.startsWith("/") ? next : home(locale);
|
||||
go(next?.startsWith("/") ? next : home(locale));
|
||||
},
|
||||
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
|
||||
});
|
||||
|
|
@ -67,7 +70,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
const register = trpc.auth.register.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(t(locale, "auth.msg.registerOk"));
|
||||
window.location.href = next?.startsWith("/") ? next : home(locale);
|
||||
go(next?.startsWith("/") ? next : home(locale));
|
||||
},
|
||||
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
|
||||
});
|
||||
|
|
@ -86,7 +89,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
const resetPassword = trpc.auth.resetPassword.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(t(locale, "auth.msg.resetOk"));
|
||||
window.location.href = `${home(locale)}/auth/login`;
|
||||
go(`${home(locale)}/auth/login`);
|
||||
},
|
||||
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
|
||||
});
|
||||
|
|
@ -111,9 +114,14 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
e.preventDefault();
|
||||
if (busy) return;
|
||||
if (mode === "login") {
|
||||
login.mutate({ email, password });
|
||||
login.mutate({ username, password });
|
||||
} else if (mode === "register") {
|
||||
register.mutate({ name, email, password });
|
||||
register.mutate({
|
||||
username,
|
||||
password,
|
||||
name: name.trim() || undefined,
|
||||
email: email.trim() || undefined,
|
||||
});
|
||||
} else if (mode === "forgot") {
|
||||
requestReset.mutate({ email });
|
||||
} else if (mode === "reset") {
|
||||
|
|
@ -156,6 +164,22 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{(mode === "login" || mode === "register") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t(locale, "auth.field.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "register" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t(locale, "auth.field.name")}</Label>
|
||||
|
|
@ -163,22 +187,25 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
id="name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mode === "login" || mode === "register" || mode === "forgot") && (
|
||||
{(mode === "register" || mode === "forgot") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t(locale, "auth.field.email")}</Label>
|
||||
<Label htmlFor="email">
|
||||
{mode === "forgot"
|
||||
? t(locale, "auth.forgot.emailLabel")
|
||||
: t(locale, "auth.field.email")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
required={mode === "forgot"}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
|
|
@ -213,6 +240,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "auth.passwordHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -246,6 +276,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "auth.passwordHint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -276,6 +309,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "auth.passwordHint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
/**
|
||||
* 用户 CRUD 演示岛:tRPC + React Query。
|
||||
* Provider 包在组件内部,便于 Astro 以 client:visible 挂载而不污染全局。
|
||||
*/
|
||||
|
||||
import type { RouterOutputs } from "@app/trpc";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Separator,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
toast,
|
||||
} from "@app/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { createTRPCReactClient, trpc } from "../lib/trpc";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const trpcClient = createTRPCReactClient();
|
||||
|
||||
type User = RouterOutputs["user"]["list"]["users"][number];
|
||||
|
||||
export default function UserList() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<UserListInner />
|
||||
</trpc.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function UserListInner() {
|
||||
const users = trpc.user.list.useQuery({ limit: 20, offset: 0 });
|
||||
const createUser = trpc.user.create.useMutation({
|
||||
onSuccess: () => {
|
||||
users.refetch();
|
||||
toast.success("用户创建成功");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`创建失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !email || !password) return;
|
||||
await createUser.mutateAsync({ name, email, password });
|
||||
setName("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户列表</CardTitle>
|
||||
<CardDescription>当前共 {users.data?.total ?? "—"} 位用户</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{users.isPending ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-muted-foreground)]">
|
||||
加载中…
|
||||
</div>
|
||||
) : users.isError ? (
|
||||
<div className="p-6 text-sm text-[var(--color-destructive)]">
|
||||
加载失败: {users.error.message}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[120px]">ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.data?.users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center text-[var(--color-muted-foreground)]"
|
||||
>
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.data?.users.map((u: User) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{u.id.slice(0, 8)}…
|
||||
</TableCell>
|
||||
<TableCell>{u.name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">活跃</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">新建用户</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="输入名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="input@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="至少 8 位"
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={createUser.isPending}>
|
||||
{createUser.isPending ? "创建中…" : "创建用户"}
|
||||
</Button>
|
||||
{createUser.isError && (
|
||||
<span className="text-sm text-[var(--color-destructive)] self-center">
|
||||
{createUser.error.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,8 +26,9 @@ const trpcClient = createTRPCReactClient();
|
|||
|
||||
export interface UserMenuProps {
|
||||
locale?: Locale | undefined;
|
||||
username?: string | undefined;
|
||||
name?: string | undefined;
|
||||
email?: string | undefined;
|
||||
email?: string | null | undefined;
|
||||
avatarUrl?: string | undefined;
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +100,9 @@ function UserMenuInner({ locale, avatarUrl }: { locale: Locale; avatarUrl?: stri
|
|||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{name}</span>
|
||||
<span className="u-mono text-[11px] opacity-70">{user.email}</span>
|
||||
<span className="u-mono text-[11px] opacity-70">
|
||||
{user.email ?? user.username ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 鉴权页主体:薄 SSR — 外层容器 + AuthForm 在浏览器渲染。
|
||||
*/
|
||||
import type { Locale } from "@app/i18n";
|
||||
import { Toaster } from "@app/ui";
|
||||
import AuthForm, { type AuthFormMode } from "~/components/AuthForm";
|
||||
|
||||
export interface AuthBodyProps {
|
||||
locale: Locale;
|
||||
mode: AuthFormMode;
|
||||
next?: string | undefined;
|
||||
token?: string | undefined;
|
||||
}
|
||||
|
||||
export default function AuthBody({ locale, mode, next, token }: AuthBodyProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-4 py-16">
|
||||
<AuthForm locale={locale} mode={mode} next={next} defaultToken={token} />
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
import { isVideoFilename } from "@app/types";
|
||||
import { Button } from "@app/ui";
|
||||
import { Button, Input, toast } from "@app/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { go, queryParam, setQueryParams } from "~/lib/nav";
|
||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||
|
||||
function pathOf(segments: string[]): string {
|
||||
|
|
@ -17,13 +18,51 @@ function fileRelPath(dirPath: string, basename: string): string {
|
|||
function BrowseInner(props: { locale: Locale }) {
|
||||
const { locale } = props;
|
||||
const mounts = trpc.media.mountList.useQuery();
|
||||
const [mountId, setMountId] = useState("");
|
||||
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
|
||||
const [path, setPath] = useState("/");
|
||||
const [crumbs, setCrumbs] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryParams({ mount: mountId || null });
|
||||
}, [mountId]);
|
||||
|
||||
// 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个已挂载目录
|
||||
useEffect(() => {
|
||||
const list = mounts.data?.mounts ?? [];
|
||||
if (list.length === 0) return;
|
||||
if (mountId && list.some((m) => m.id === mountId)) return;
|
||||
const first = list[0];
|
||||
if (first) setMountId(first.id);
|
||||
}, [mounts.data, mountId]);
|
||||
|
||||
const dir = trpc.media.mountListDir.useQuery({ mountId, path }, { enabled: Boolean(mountId) });
|
||||
const scan = trpc.media.libraryScan.useMutation();
|
||||
const ensure = trpc.media.libraryEnsurePath.useMutation();
|
||||
const utils = trpc.useUtils();
|
||||
const batchScrape = trpc.media.scrapeRescrapePath.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(t(locale, "browse.batchDone", { message: data.message }));
|
||||
void utils.media.libraryList.invalidate();
|
||||
void utils.media.libraryGroups.invalidate();
|
||||
},
|
||||
onError: () => toast.error(t(locale, "browse.batchError")),
|
||||
});
|
||||
const bindPath = trpc.media.scrapeBindPath.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(t(locale, "browse.batchDone", { message: data.message }));
|
||||
setBindOpen(false);
|
||||
setBindQ("");
|
||||
void utils.media.libraryList.invalidate();
|
||||
void utils.media.libraryGroups.invalidate();
|
||||
},
|
||||
onError: () => toast.error(t(locale, "browse.batchError")),
|
||||
});
|
||||
const [bindOpen, setBindOpen] = useState(false);
|
||||
const [bindQ, setBindQ] = useState("");
|
||||
const bindSearch = trpc.media.scrapeSearch.useQuery(
|
||||
{ q: bindQ },
|
||||
{ enabled: bindOpen && bindQ.length > 1 },
|
||||
);
|
||||
|
||||
function goRoot() {
|
||||
setCrumbs([]);
|
||||
|
|
@ -52,7 +91,7 @@ function BrowseInner(props: { locale: Locale }) {
|
|||
if (!mountId) return;
|
||||
try {
|
||||
const item = await ensure.mutateAsync({ mountId, path: fileRelPath(path, basename) });
|
||||
window.location.href = `/${locale}/watch?id=${item.id}`;
|
||||
go(`/${locale}/watch?id=${item.id}`);
|
||||
} catch {
|
||||
// error surfaced via ensure.error
|
||||
}
|
||||
|
|
@ -97,12 +136,38 @@ function BrowseInner(props: { locale: Locale }) {
|
|||
{t(locale, "browse.addLibrary")}
|
||||
</Button>
|
||||
)}
|
||||
{mountId && path !== "/" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={batchScrape.isPending}
|
||||
onClick={() => batchScrape.mutate({ mountId, path })}
|
||||
>
|
||||
{t(locale, "browse.batchScrape")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setBindOpen(true)}>
|
||||
{t(locale, "browse.batchBind")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{scan.data && (
|
||||
{(batchScrape.data || bindPath.data) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "browse.scanDone", { count: scan.data.scanned })}
|
||||
{t(locale, "browse.batchDone", {
|
||||
message: batchScrape.data?.message ?? bindPath.data?.message ?? "",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{scan.data && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "browse.scanDone", { count: scan.data.scanned })}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => go(`/${locale}/library`)}>
|
||||
{t(locale, "browse.goLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{scan.isError && (
|
||||
<p className="text-sm text-red-600">{t(locale, "browse.scanError")}</p>
|
||||
)}
|
||||
|
|
@ -128,7 +193,17 @@ function BrowseInner(props: { locale: Locale }) {
|
|||
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
|
||||
)}
|
||||
{mountId && dir.isError && (
|
||||
<p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-red-600">
|
||||
{t(locale, "browse.loadError")}
|
||||
{dir.error instanceof Error && dir.error.message
|
||||
? `:${dir.error.message}`
|
||||
: ""}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => void dir.refetch()}>
|
||||
{t(locale, "error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{ensure.isError && (
|
||||
<p className="text-sm text-red-600">{t(locale, "browse.playError")}</p>
|
||||
|
|
@ -168,24 +243,82 @@ function BrowseInner(props: { locale: Locale }) {
|
|||
);
|
||||
})}
|
||||
{mountId && dir.data?.entries.length === 0 && (
|
||||
<li className="p-4 text-sm text-muted-foreground">
|
||||
{t(locale, "browse.empty")}
|
||||
<li className="p-0">
|
||||
<div className="u-empty border-0 bg-transparent">
|
||||
<p>{t(locale, "browse.empty")}</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{!mountId && (
|
||||
<li className="flex flex-col items-start gap-3 p-4 text-sm text-muted-foreground">
|
||||
<span>{t(locale, "browse.empty")}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.location.href = `/${locale}/mounts`;
|
||||
}}
|
||||
>
|
||||
{t(locale, "browse.addMount")}
|
||||
</Button>
|
||||
{!mountId && mountList.length === 0 && (
|
||||
<li className="p-0">
|
||||
<div className="u-empty border-0 bg-transparent">
|
||||
<p className="u-empty__title">{t(locale, "browse.mountsEmpty")}</p>
|
||||
<div className="u-empty__actions">
|
||||
<Button size="sm" onClick={() => go(`/${locale}/mounts`)}>
|
||||
{t(locale, "browse.addMount")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{!mountId && mountList.length > 0 && (
|
||||
<li className="p-0">
|
||||
<div className="u-empty border-0 bg-transparent">
|
||||
<p>{t(locale, "browse.loading")}</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{bindOpen && mountId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
|
||||
<h2 className="font-medium">{t(locale, "browse.bindFolderTitle")}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "browse.bindFolderHint")}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{path}</p>
|
||||
<Input
|
||||
placeholder={t(locale, "library.searchWork")}
|
||||
value={bindQ}
|
||||
onChange={(e) => setBindQ(e.target.value)}
|
||||
/>
|
||||
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
|
||||
{(bindSearch.data ?? []).map((hit) => (
|
||||
<li
|
||||
key={hit.id}
|
||||
className="flex items-center justify-between gap-2 p-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{hit.nameCn || hit.name}</div>
|
||||
{hit.nameCn && hit.name && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{hit.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={bindPath.isPending}
|
||||
onClick={() =>
|
||||
bindPath.mutate({
|
||||
mountId,
|
||||
path,
|
||||
bangumiId: hit.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(locale, "library.bind")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="outline" onClick={() => setBindOpen(false)}>
|
||||
{t(locale, "library.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
import { Button, toast } from "@app/ui";
|
||||
import { Button, Card, toast } from "@app/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { go } from "~/lib/nav";
|
||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||
|
||||
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
|
||||
|
|
@ -59,49 +60,81 @@ function Inner(props: { locale: Locale }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-4">
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
{t(locale, "danmaku.enabled")}
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t(locale, "danmaku.opacityValue", { value: (opacity * 100).toFixed(0) })}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t(locale, "danmaku.densityValue", {
|
||||
value: (density * 100).toFixed(0),
|
||||
})}
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={density}
|
||||
onChange={(e) => setDensity(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<Button type="button" onClick={onSave}>
|
||||
{t(locale, "danmaku.save")}
|
||||
</Button>
|
||||
{save.isSuccess && (
|
||||
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t(locale, "danmaku.localImportHint")}</p>
|
||||
<Card className="max-w-md space-y-5 p-6">
|
||||
<label className="flex items-center gap-2.5 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
{t(locale, "danmaku.enabled")}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span>{t(locale, "danmaku.opacity")}</span>
|
||||
<span className="u-mono text-xs text-muted-foreground">
|
||||
{(opacity * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span>{t(locale, "danmaku.density")}</span>
|
||||
<span className="u-mono text-xs text-muted-foreground">
|
||||
{(density * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={density}
|
||||
onChange={(e) => setDensity(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button type="button" onClick={onSave}>
|
||||
{t(locale, "danmaku.save")}
|
||||
</Button>
|
||||
{save.isSuccess && (
|
||||
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t(locale, "danmaku.localImportHint")}
|
||||
</p>
|
||||
</Card>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => go(`/${locale}/library`)}
|
||||
>
|
||||
{t(locale, "nav.library")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => go(`/${locale}/browse`)}
|
||||
>
|
||||
{t(locale, "nav.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* 错误页主体:薄 SSR — ErrorView 在浏览器渲染。
|
||||
*/
|
||||
import type { Locale } from "@app/i18n";
|
||||
import { ErrorView } from "~/components/errors/ErrorView";
|
||||
import { createAppError, type AppErrorStatus } from "~/lib/errors";
|
||||
|
||||
export interface ErrorBodyProps {
|
||||
locale: Locale;
|
||||
status: AppErrorStatus;
|
||||
}
|
||||
|
||||
export default function ErrorBody({ locale, status }: ErrorBodyProps) {
|
||||
const error = createAppError(status);
|
||||
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[55vh] items-center justify-center px-4 py-16">
|
||||
<ErrorView error={error} locale={locale} homeHref={homeHref} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,15 +2,52 @@
|
|||
* 首页主体:薄 SSR — Astro 壳负责 head/导航,本组件在浏览器渲染。
|
||||
*/
|
||||
import { t, type Locale } from "@app/i18n";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { go } from "~/lib/nav";
|
||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||
|
||||
export interface HomeBodyProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function HomeBody({ locale }: HomeBodyProps) {
|
||||
function ContinueStrip(props: { locale: Locale }) {
|
||||
const { locale } = props;
|
||||
const list = trpc.media.playbackContinue.useQuery({ limit: 4 }, { retry: false });
|
||||
const items = list.data ?? [];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section className="u-card u-reveal p-6">
|
||||
<p className="u-label">{t(locale, "library.continueTitle")}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.mediaItemId}
|
||||
type="button"
|
||||
className="u-btn u-btn--ghost"
|
||||
onClick={() => go(`/${locale}/watch?id=${item.mediaItemId}`)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeInner({ locale }: HomeBodyProps) {
|
||||
const tr = (key: Parameters<typeof t>[1]) => t(locale, key);
|
||||
const libraryHref = locale === "zh-CN" ? "/zh-CN/library" : "/en/library";
|
||||
const loginHref = locale === "zh-CN" ? "/zh-CN/auth/login" : "/en/auth/login";
|
||||
const me = trpc.auth.me.useQuery(undefined, { retry: false });
|
||||
const loggedIn = me.data?.user != null;
|
||||
|
||||
const steps = [
|
||||
{ key: "nav.mounts", href: `/${locale}/mounts` },
|
||||
{ key: "nav.browse", href: `/${locale}/browse` },
|
||||
{ key: "nav.library", href: `/${locale}/library` },
|
||||
{ key: "nav.danmaku", href: `/${locale}/danmaku` },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<section className="space-y-10 py-10">
|
||||
|
|
@ -32,14 +69,18 @@ export default function HomeBody({ locale }: HomeBodyProps) {
|
|||
<a href={libraryHref} className="u-btn u-btn--edge">
|
||||
{tr("home.ctaDashboard")}
|
||||
</a>
|
||||
<a href={loginHref} className="u-btn u-btn--ghost">
|
||||
{tr("home.ctaLogin")}
|
||||
</a>
|
||||
{!loggedIn && (
|
||||
<a href={loginHref} className="u-btn u-btn--ghost">
|
||||
{tr("home.ctaLogin")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContinueStrip locale={locale} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<section className="u-card p-6 u-reveal">
|
||||
<section className="u-card flex flex-col p-6 u-reveal">
|
||||
<p className="u-label">{tr("home.stackTitle")}</p>
|
||||
<p
|
||||
className="mt-3 text-[14px] leading-relaxed"
|
||||
|
|
@ -48,7 +89,7 @@ export default function HomeBody({ locale }: HomeBodyProps) {
|
|||
{tr("home.stackBody")}
|
||||
</p>
|
||||
</section>
|
||||
<section className="u-card p-6 u-reveal">
|
||||
<section className="u-card flex flex-col p-6 u-reveal">
|
||||
<p className="u-label">{tr("home.layerTitle")}</p>
|
||||
<p
|
||||
className="mt-3 text-[14px] leading-relaxed"
|
||||
|
|
@ -56,8 +97,29 @@ export default function HomeBody({ locale }: HomeBodyProps) {
|
|||
>
|
||||
{tr("home.layerBody")}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-wrap gap-2 pt-4">
|
||||
{steps.map((s, i) => (
|
||||
<a key={s.key} href={s.href} className="u-btn u-btn--ghost">
|
||||
<span className="u-mono">{String(i + 1).padStart(2, "0")}</span>
|
||||
{tr(s.key)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const trpcClient = createTRPCReactClient();
|
||||
|
||||
export default function HomeBody({ locale }: HomeBodyProps) {
|
||||
const [qc] = useState(() => new QueryClient());
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<trpc.Provider client={trpcClient} queryClient={qc}>
|
||||
<HomeInner locale={locale} />
|
||||
</trpc.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,473 @@
|
|||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
import { Button, Input } from "@app/ui";
|
||||
import type { LibraryGroup } from "@app/types";
|
||||
import { Button, Input, toast } from "@app/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { go, queryParam, setQueryParams } from "~/lib/nav";
|
||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||
|
||||
function LibraryInner(props: { locale: Locale }) {
|
||||
const { locale } = props;
|
||||
const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const pageLimit = 24;
|
||||
const list = trpc.media.libraryList.useQuery({
|
||||
limit: pageLimit,
|
||||
offset,
|
||||
scrapeStatus: filter || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
const progressList = list.data?.rows ?? [];
|
||||
const total = list.data?.total ?? 0;
|
||||
const hasMore = offset + progressList.length < total;
|
||||
const resetPaging = () => setOffset(0);
|
||||
const [matchId, setMatchId] = useState<string | null>(null);
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const search = trpc.media.scrapeSearch.useQuery(
|
||||
{ q: searchQ },
|
||||
{ enabled: searchQ.length > 1 && matchId !== null },
|
||||
type ViewMode = "wall" | "list";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function PosterBox(props: { posterUrl: string | null; locale: Locale; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex aspect-[3/4] items-center justify-center bg-muted text-xs text-muted-foreground",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.posterUrl ? (
|
||||
<img
|
||||
src={props.posterUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
t(props.locale, "library.noPoster")
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const bind = trpc.media.scrapeBind.useMutation({
|
||||
onSuccess: () => {
|
||||
setMatchId(null);
|
||||
list.refetch();
|
||||
},
|
||||
}
|
||||
|
||||
function MountTabs(props: {
|
||||
mounts: { id: string; name: string }[];
|
||||
value: string;
|
||||
onChange: (mountId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 rounded-md border border-border p-0.5" role="tablist">
|
||||
{props.mounts.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={m.id === props.value}
|
||||
className={cn(
|
||||
"rounded px-3 py-1.5 text-sm transition-colors",
|
||||
m.id === props.value
|
||||
? "bg-primary font-medium text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => props.onChange(m.id)}
|
||||
>
|
||||
{m.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 作品详情:剧集按需从 librarySeries 拉取(限当前挂载) */
|
||||
function GroupDetail(props: {
|
||||
bangumiId: number;
|
||||
mountId: string;
|
||||
locale: Locale;
|
||||
onBack: () => void;
|
||||
onPlay: (mediaItemId: string, bangumiId: number) => void;
|
||||
onMatch?: ((mediaItemId: string) => void) | undefined;
|
||||
}) {
|
||||
const { locale } = props;
|
||||
const seriesQuery = trpc.media.librarySeries.useQuery(
|
||||
{ bangumiId: props.bangumiId, mountId: props.mountId || undefined },
|
||||
{ enabled: Boolean(props.mountId) },
|
||||
);
|
||||
const group = seriesQuery.data;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" size="sm" onClick={props.onBack}>
|
||||
← {t(locale, "library.backToList")}
|
||||
</Button>
|
||||
{seriesQuery.isPending && (
|
||||
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
|
||||
)}
|
||||
{seriesQuery.isError && (
|
||||
<p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
|
||||
)}
|
||||
{group && (
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="w-full max-w-40 overflow-hidden rounded-lg border border-border sm:w-40">
|
||||
<PosterBox posterUrl={group.posterUrl} locale={locale} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<h2 className="truncate text-lg font-semibold" title={group.title}>
|
||||
{group.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "library.episodeCount", { count: group.episodes.length })}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t(locale, "library.episodes")}
|
||||
</div>
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{group.episodes.map((ep) => (
|
||||
<li
|
||||
key={ep.id}
|
||||
className="flex items-center gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{ep.epNumber != null ? `EP${ep.epNumber}` : "—"}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate"
|
||||
title={ep.rawName}
|
||||
>
|
||||
{ep.rawName}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => props.onPlay(ep.id, props.bangumiId)}
|
||||
>
|
||||
{t(locale, "library.play")}
|
||||
</Button>
|
||||
{props.onMatch && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => props.onMatch?.(ep.id)}
|
||||
>
|
||||
{t(locale, "library.match")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueRow(props: { locale: Locale }) {
|
||||
const { locale } = props;
|
||||
const list = trpc.media.playbackContinue.useQuery({ limit: 6 });
|
||||
const items = list.data ?? [];
|
||||
if (list.isPending) return null;
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="u-label">{t(locale, "library.continueTitle")}</p>
|
||||
<span className="u-dotline flex-1" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{items.map((item) => {
|
||||
const pct =
|
||||
item.durationMs > 0
|
||||
? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={item.mediaItemId}
|
||||
type="button"
|
||||
className="w-40 shrink-0 overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60"
|
||||
onClick={() => go(`/${locale}/watch?id=${item.mediaItemId}`)}
|
||||
>
|
||||
<PosterBox posterUrl={item.posterUrl} locale={locale} />
|
||||
<div className="p-2">
|
||||
<div className="truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-muted-foreground">
|
||||
{t(locale, "library.continue")} · {pct}%
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「加载更多」分页累积:按 offset 缓存已取页。
|
||||
* reqKey/refreshKey 变更时丢弃旧累积,避免 mutation 后旧页与新页混排。
|
||||
*/
|
||||
type PagedAcc<T> = {
|
||||
key: string;
|
||||
total: number;
|
||||
pages: Record<number, T[]>;
|
||||
};
|
||||
|
||||
function usePagedAccumulator<T>(params: {
|
||||
reqKey: string;
|
||||
refreshKey: number;
|
||||
data: { total: number; items: T[] } | undefined;
|
||||
offset: number;
|
||||
}): { items: T[]; total: number; hasMore: boolean } {
|
||||
const { reqKey, refreshKey, data, offset } = params;
|
||||
const [acc, setAcc] = useState<PagedAcc<T> | null>(null);
|
||||
const fullKey = `${reqKey}|r${refreshKey}`;
|
||||
const [prevFullKey, setPrevFullKey] = useState(fullKey);
|
||||
// 渲染期重置:条件或 refreshKey 变化后丢弃旧页
|
||||
if (prevFullKey !== fullKey) {
|
||||
setPrevFullKey(fullKey);
|
||||
setAcc(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setAcc((prev) => {
|
||||
if (!prev || prev.key !== fullKey || offset === 0) {
|
||||
return { key: fullKey, total: data.total, pages: { [offset]: data.items } };
|
||||
}
|
||||
return {
|
||||
key: prev.key,
|
||||
total: data.total,
|
||||
pages: { ...prev.pages, [offset]: data.items },
|
||||
};
|
||||
});
|
||||
}, [data, fullKey, offset]);
|
||||
|
||||
const items: T[] = [];
|
||||
let total = 0;
|
||||
if (acc && acc.key === fullKey) {
|
||||
total = acc.total;
|
||||
for (const pageOffset of Object.keys(acc.pages)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)) {
|
||||
const page = acc.pages[pageOffset];
|
||||
if (page) items.push(...page);
|
||||
}
|
||||
}
|
||||
return { items, total, hasMore: items.length < total };
|
||||
}
|
||||
|
||||
function WallView(props: {
|
||||
locale: Locale;
|
||||
mountId: string;
|
||||
q: string;
|
||||
refreshKey: number;
|
||||
selectedBangumiId: number | null;
|
||||
onSelect: (bangumiId: number | null) => void;
|
||||
onPlay: (mediaItemId: string, bangumiId: number) => void;
|
||||
onMatch?: ((mediaItemId: string) => void) | undefined;
|
||||
onClearSearch?: (() => void) | undefined;
|
||||
hideEmpty?: boolean;
|
||||
mountsEmpty?: boolean;
|
||||
}) {
|
||||
const { locale, mountId, q } = props;
|
||||
const [offset, setOffset] = useState(0);
|
||||
const reqKey = `${mountId}|${q}`;
|
||||
const [prevReqKey, setPrevReqKey] = useState(reqKey);
|
||||
if (prevReqKey !== reqKey) {
|
||||
setPrevReqKey(reqKey);
|
||||
setOffset(0);
|
||||
}
|
||||
const [prevRefresh, setPrevRefresh] = useState(props.refreshKey);
|
||||
if (prevRefresh !== props.refreshKey) {
|
||||
setPrevRefresh(props.refreshKey);
|
||||
setOffset(0);
|
||||
}
|
||||
const groupsQuery = trpc.media.libraryGroups.useQuery(
|
||||
{ mountId, q: q || undefined, limit: PAGE_SIZE, offset },
|
||||
{ enabled: Boolean(mountId) },
|
||||
);
|
||||
const paged = usePagedAccumulator<LibraryGroup>({
|
||||
reqKey,
|
||||
refreshKey: props.refreshKey,
|
||||
data: groupsQuery.data
|
||||
? { total: groupsQuery.data.total, items: groupsQuery.data.groups }
|
||||
: undefined,
|
||||
offset,
|
||||
});
|
||||
const groups = paged.items;
|
||||
const hasMore = paged.hasMore;
|
||||
|
||||
if (props.selectedBangumiId != null) {
|
||||
return (
|
||||
<GroupDetail
|
||||
bangumiId={props.selectedBangumiId}
|
||||
mountId={mountId}
|
||||
locale={locale}
|
||||
onBack={() => props.onSelect(null)}
|
||||
onPlay={props.onPlay}
|
||||
onMatch={props.onMatch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const showEmpty =
|
||||
!props.hideEmpty && (props.mountsEmpty || (groups.length === 0 && !groupsQuery.isPending));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold">{t(locale, "library.title")}</h1>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Input
|
||||
className="w-40"
|
||||
placeholder={t(locale, "library.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
resetPaging();
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value as typeof filter);
|
||||
resetPaging();
|
||||
}}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.bangumiId}
|
||||
type="button"
|
||||
className="overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60"
|
||||
onClick={() => props.onSelect(group.bangumiId)}
|
||||
>
|
||||
<option value="">{t(locale, "library.filterAll")}</option>
|
||||
<option value="ok">{t(locale, "library.filterOk")}</option>
|
||||
<option value="unmatched">{t(locale, "library.unmatched")}</option>
|
||||
</select>
|
||||
<PosterBox posterUrl={group.posterUrl} locale={locale} />
|
||||
<div className="p-2">
|
||||
<div className="truncate text-sm font-medium" title={group.title}>
|
||||
{group.title}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||||
{t(locale, "library.episodeCount", { count: group.episodeCount })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{showEmpty && (
|
||||
<div className="u-empty col-span-full">
|
||||
<p className="u-empty__title">{t(locale, "library.title")}</p>
|
||||
<p>
|
||||
{!props.mountsEmpty && q
|
||||
? t(locale, "library.noResults", { q })
|
||||
: t(locale, "library.groupsEmpty")}
|
||||
</p>
|
||||
<div className="u-empty__actions">
|
||||
{!props.mountsEmpty && q && props.onClearSearch ? (
|
||||
<Button size="sm" variant="outline" onClick={props.onClearSearch}>
|
||||
{t(locale, "library.clearSearch")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" onClick={() => go(`/${locale}/browse`)}>
|
||||
{t(locale, "library.ctaBrowse")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => go(`/${locale}/mounts`)}
|
||||
>
|
||||
{t(locale, "library.ctaMounts")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={groupsQuery.isPending}
|
||||
onClick={() => setOffset(groups.length)}
|
||||
>
|
||||
{t(locale, "library.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView(props: {
|
||||
locale: Locale;
|
||||
mountId: string;
|
||||
q: string;
|
||||
filter: "" | "unmatched" | "ok";
|
||||
refreshKey: number;
|
||||
onFilterChange: (f: "" | "unmatched" | "ok") => void;
|
||||
onMatch: (mediaItemId: string) => void;
|
||||
onPlay: (mediaItemId: string, bangumiId?: number | null) => void;
|
||||
onRescrape?: ((mediaItemId: string) => void) | undefined;
|
||||
onClearSearch?: (() => void) | undefined;
|
||||
hideEmpty?: boolean;
|
||||
mountsEmpty?: boolean;
|
||||
}) {
|
||||
const { locale, mountId, q, filter } = props;
|
||||
const [offset, setOffset] = useState(0);
|
||||
const pageLimit = PAGE_SIZE;
|
||||
const reqKey = `${mountId}|${q}|${filter}`;
|
||||
const [prevReqKey, setPrevReqKey] = useState(reqKey);
|
||||
if (prevReqKey !== reqKey) {
|
||||
setPrevReqKey(reqKey);
|
||||
setOffset(0);
|
||||
}
|
||||
const [prevRefresh, setPrevRefresh] = useState(props.refreshKey);
|
||||
if (prevRefresh !== props.refreshKey) {
|
||||
setPrevRefresh(props.refreshKey);
|
||||
setOffset(0);
|
||||
}
|
||||
const list = trpc.media.libraryList.useQuery({
|
||||
limit: pageLimit,
|
||||
offset,
|
||||
mountId: mountId || undefined,
|
||||
scrapeStatus: filter || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
// 与海报墙同一套累积逻辑:加载更多追加而不是顶掉上一页
|
||||
type MediaListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
posterUrl: string | null;
|
||||
bangumiId: number | null;
|
||||
scrapeStatus: string;
|
||||
};
|
||||
const paged = usePagedAccumulator<MediaListItem>({
|
||||
reqKey,
|
||||
refreshKey: props.refreshKey,
|
||||
data: list.data
|
||||
? {
|
||||
total: list.data.total,
|
||||
items: list.data.rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
posterUrl: r.posterUrl,
|
||||
bangumiId: r.bangumiId,
|
||||
scrapeStatus: r.scrapeStatus,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
offset,
|
||||
});
|
||||
const rows = paged.items;
|
||||
const hasMore = paged.hasMore;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
props.onFilterChange(e.target.value as typeof filter);
|
||||
setOffset(0);
|
||||
}}
|
||||
>
|
||||
<option value="">{t(locale, "library.filterAll")}</option>
|
||||
<option value="ok">{t(locale, "library.filterOk")}</option>
|
||||
<option value="unmatched">{t(locale, "library.unmatched")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{progressList.map((item) => (
|
||||
{rows.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="overflow-hidden rounded-lg border border-border bg-card"
|
||||
>
|
||||
<div className="aspect-[3/4] bg-muted flex items-center justify-center text-xs text-muted-foreground">
|
||||
{item.posterUrl ? (
|
||||
<img
|
||||
src={item.posterUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
t(locale, "library.noPoster")
|
||||
)}
|
||||
</div>
|
||||
<PosterBox posterUrl={item.posterUrl} locale={locale} />
|
||||
<div className="p-2">
|
||||
<div className="truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
|
|
@ -88,49 +476,73 @@ function LibraryInner(props: { locale: Locale }) {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.href = `/${locale}/watch?id=${item.id}`;
|
||||
}}
|
||||
onClick={() => props.onPlay(item.id, item.bangumiId)}
|
||||
>
|
||||
{t(locale, "library.play")}
|
||||
</Button>
|
||||
{item.scrapeStatus !== "ok" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setMatchId(item.id)}
|
||||
>
|
||||
{t(locale, "library.match")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => props.onMatch(item.id)}
|
||||
>
|
||||
{t(locale, "library.match")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => props.onRescrape?.(item.id)}
|
||||
>
|
||||
{t(locale, "library.reScrape")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{progressList.length === 0 && !list.isPending && (
|
||||
<div className="col-span-full flex flex-col items-start gap-3 rounded-lg border border-border bg-card p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "library.empty")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.href = `/${locale}/browse`;
|
||||
}}
|
||||
>
|
||||
{t(locale, "library.ctaBrowse")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = `/${locale}/mounts`;
|
||||
}}
|
||||
>
|
||||
{t(locale, "library.ctaMounts")}
|
||||
</Button>
|
||||
{!props.hideEmpty &&
|
||||
(props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (
|
||||
<div className="u-empty col-span-full">
|
||||
<p className="u-empty__title">{t(locale, "library.title")}</p>
|
||||
<p>
|
||||
{!props.mountsEmpty && q
|
||||
? t(locale, "library.noResults", { q })
|
||||
: !props.mountsEmpty && filter
|
||||
? t(locale, "library.filterEmpty")
|
||||
: t(locale, "library.empty")}
|
||||
</p>
|
||||
<div className="u-empty__actions">
|
||||
{!props.mountsEmpty && q && props.onClearSearch ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={props.onClearSearch}
|
||||
>
|
||||
{t(locale, "library.clearSearch")}
|
||||
</Button>
|
||||
) : !props.mountsEmpty && filter ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => props.onFilterChange("")}
|
||||
>
|
||||
{t(locale, "library.filterAll")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" onClick={() => go(`/${locale}/browse`)}>
|
||||
{t(locale, "library.ctaBrowse")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => go(`/${locale}/mounts`)}
|
||||
>
|
||||
{t(locale, "library.ctaMounts")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
|
|
@ -143,40 +555,273 @@ function LibraryInner(props: { locale: Locale }) {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryInner(props: { locale: Locale }) {
|
||||
const { locale } = props;
|
||||
const [view, setView] = useState<ViewMode>(() => {
|
||||
try {
|
||||
return localStorage.getItem("media-library-view") === "list" ? "list" : "wall";
|
||||
} catch {
|
||||
return "wall";
|
||||
}
|
||||
});
|
||||
const changeView = (v: ViewMode) => {
|
||||
setView(v);
|
||||
try {
|
||||
localStorage.setItem("media-library-view", v);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
|
||||
const [q, setQ] = useState("");
|
||||
const debouncedQ = useDebouncedValue(q, 300);
|
||||
const [matchId, setMatchId] = useState<string | null>(null);
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const [epInput, setEpInput] = useState("");
|
||||
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
|
||||
const [selectedBangumiId, setSelectedBangumiId] = useState<number | null>(() => {
|
||||
const raw = queryParam("g");
|
||||
const parsed = raw ? Number(raw) : Number.NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
});
|
||||
const search = trpc.media.scrapeSearch.useQuery(
|
||||
{ q: searchQ },
|
||||
{ enabled: searchQ.length > 1 && matchId !== null },
|
||||
);
|
||||
const utils = trpc.useUtils();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const bumpRefresh = () => setRefreshKey((k) => k + 1);
|
||||
const bind = trpc.media.scrapeBind.useMutation({
|
||||
onSuccess: () => {
|
||||
setMatchId(null);
|
||||
toast.success(t(locale, "library.matched"));
|
||||
bumpRefresh();
|
||||
void utils.media.libraryList.invalidate();
|
||||
void utils.media.libraryGroups.invalidate();
|
||||
void utils.media.librarySeries.invalidate();
|
||||
void utils.media.playbackContinue.invalidate();
|
||||
},
|
||||
});
|
||||
const rescrape = trpc.media.scrapeRescrape.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(t(locale, "library.reScrape"));
|
||||
bumpRefresh();
|
||||
void utils.media.libraryList.invalidate();
|
||||
void utils.media.libraryGroups.invalidate();
|
||||
void utils.media.librarySeries.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const bindWithEp = (bangumiId: number) => {
|
||||
if (!matchId) return;
|
||||
const epNum = epInput.trim() === "" ? undefined : Number(epInput);
|
||||
bind.mutate({
|
||||
mediaItemId: matchId,
|
||||
bangumiId,
|
||||
...(epNum != null && Number.isFinite(epNum) ? { epNumber: epNum } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const mounts = trpc.media.mountList.useQuery();
|
||||
const probe = trpc.media.libraryList.useQuery({ limit: 1, offset: 0 });
|
||||
const autoScan = trpc.media.libraryScan.useMutation();
|
||||
const autoScanStarted = useRef(false);
|
||||
const [autoScanning, setAutoScanning] = useState(false);
|
||||
const mountList = mounts.data?.mounts ?? [];
|
||||
const mountsEmpty = !mounts.isPending && mountList.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
setQueryParams({ mount: mountId || null });
|
||||
}, [mountId]);
|
||||
|
||||
// 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个
|
||||
useEffect(() => {
|
||||
const list = mounts.data?.mounts ?? [];
|
||||
if (list.length === 0) return;
|
||||
if (mountId && list.some((m) => m.id === mountId)) return;
|
||||
const first = list[0];
|
||||
if (first) setMountId(first.id);
|
||||
}, [mounts.data, mountId]);
|
||||
|
||||
// 空库且已有挂载时,自动把已挂载目录扫进媒体库(仅触发一次;顺序扫描避免并发打满 WebDAV)
|
||||
useEffect(() => {
|
||||
if (autoScanStarted.current) return;
|
||||
if (mounts.isPending || probe.isPending) return;
|
||||
const list = mounts.data?.mounts ?? [];
|
||||
if (list.length === 0) return;
|
||||
if ((probe.data?.total ?? 0) > 0) return;
|
||||
autoScanStarted.current = true;
|
||||
setAutoScanning(true);
|
||||
void (async () => {
|
||||
for (const m of list) {
|
||||
try {
|
||||
await autoScan.mutateAsync({ mountId: m.id });
|
||||
} catch {
|
||||
// 单个挂载失败不阻断其余挂载
|
||||
}
|
||||
}
|
||||
setAutoScanning(false);
|
||||
setRefreshKey((k) => k + 1);
|
||||
void utils.media.libraryList.invalidate();
|
||||
void utils.media.libraryGroups.invalidate();
|
||||
})();
|
||||
}, [mounts.data, mounts.isPending, probe.data, probe.isPending, autoScan, utils]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryParams({ g: selectedBangumiId != null ? String(selectedBangumiId) : null });
|
||||
}, [selectedBangumiId]);
|
||||
|
||||
const play = (id: string, bangumiId?: number | null) => {
|
||||
const g = bangumiId ?? selectedBangumiId;
|
||||
const back = g != null ? `&from=series&g=${g}` : "";
|
||||
go(`/${locale}/watch?id=${id}${back}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ContinueRow locale={locale} />
|
||||
{autoScanning && (
|
||||
<p className="text-sm text-muted-foreground">{t(locale, "library.autoScanning")}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold">{t(locale, "library.title")}</h1>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<div className="flex rounded-md border border-border p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded px-3 py-1.5 text-sm transition-colors",
|
||||
view === "wall"
|
||||
? "bg-primary font-medium text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => {
|
||||
// 海报墙只展示已刮削分组,切过去时列表对齐同一集合
|
||||
changeView("wall");
|
||||
}}
|
||||
>
|
||||
{t(locale, "library.viewWall")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded px-3 py-1.5 text-sm transition-colors",
|
||||
view === "list"
|
||||
? "bg-primary font-medium text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => {
|
||||
changeView("list");
|
||||
if (filter === "") setFilter("ok");
|
||||
}}
|
||||
>
|
||||
{t(locale, "library.viewList")}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
className="w-40"
|
||||
placeholder={t(locale, "library.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mountList.length > 0 && (
|
||||
<MountTabs
|
||||
mounts={mountList}
|
||||
value={mountId}
|
||||
onChange={(id) => {
|
||||
setMountId(id);
|
||||
setSelectedBangumiId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "wall" ? (
|
||||
<WallView
|
||||
locale={locale}
|
||||
mountId={mountId}
|
||||
q={debouncedQ}
|
||||
refreshKey={refreshKey}
|
||||
selectedBangumiId={selectedBangumiId}
|
||||
onSelect={setSelectedBangumiId}
|
||||
onPlay={play}
|
||||
onMatch={setMatchId}
|
||||
onClearSearch={() => setQ("")}
|
||||
hideEmpty={autoScanning}
|
||||
mountsEmpty={mountsEmpty}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
locale={locale}
|
||||
mountId={mountId}
|
||||
q={debouncedQ}
|
||||
filter={filter}
|
||||
refreshKey={refreshKey}
|
||||
onFilterChange={setFilter}
|
||||
onMatch={setMatchId}
|
||||
onPlay={play}
|
||||
onRescrape={(id) => rescrape.mutate({ mediaItemId: id })}
|
||||
onClearSearch={() => setQ("")}
|
||||
hideEmpty={autoScanning}
|
||||
mountsEmpty={mountsEmpty}
|
||||
/>
|
||||
)}
|
||||
|
||||
{matchId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
|
||||
<h2 className="font-medium">{t(locale, "library.matchTitle")}</h2>
|
||||
<h2 className="font-medium">{t(locale, "library.manualTitle")}</h2>
|
||||
<Input
|
||||
placeholder={t(locale, "library.searchWork")}
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(locale, "library.epLabel")}
|
||||
</span>
|
||||
<Input
|
||||
className="w-28"
|
||||
inputMode="numeric"
|
||||
placeholder={t(locale, "library.epPlaceholder")}
|
||||
value={epInput}
|
||||
onChange={(e) => setEpInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "library.candidates")}
|
||||
</p>
|
||||
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
|
||||
{(search.data ?? []).map((hit) => (
|
||||
<li
|
||||
key={hit.id}
|
||||
className="flex items-center justify-between p-2 text-sm"
|
||||
className="flex items-center justify-between gap-2 p-2 text-sm"
|
||||
>
|
||||
<span>{hit.nameCn || hit.name}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
bind.mutate({
|
||||
mediaItemId: matchId,
|
||||
bangumiId: hit.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{hit.nameCn || hit.name}</div>
|
||||
{hit.nameCn && hit.name && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{hit.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => bindWithEp(hit.id)}>
|
||||
{t(locale, "library.bind")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="outline" onClick={() => setMatchId(null)}>
|
||||
{t(locale, "library.cancel")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setMatchId(null)}>
|
||||
{t(locale, "library.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,135 +1,445 @@
|
|||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
import { Button, Input } from "@app/ui";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
toast,
|
||||
} from "@app/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { go } from "~/lib/nav";
|
||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||
import { formatTrpcError } from "~/lib/trpc-error";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type FormMode = "create" | "edit";
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
type FieldErrors = Partial<Record<"name" | "baseUrl", string | undefined>>;
|
||||
|
||||
function validateForm(form: FormState): FieldErrors {
|
||||
const errs: FieldErrors = {};
|
||||
if (!form.name.trim()) errs.name = "name";
|
||||
const url = form.baseUrl.trim();
|
||||
if (!url) {
|
||||
errs.baseUrl = "required";
|
||||
} else {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") errs.baseUrl = "protocol";
|
||||
} catch {
|
||||
errs.baseUrl = "invalid";
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
password: "",
|
||||
rootPath: "/",
|
||||
};
|
||||
|
||||
function MountsInner(props: { locale: Locale }) {
|
||||
const { locale } = props;
|
||||
const utils = trpc.useUtils();
|
||||
const list = trpc.media.mountList.useQuery();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const create = trpc.media.mountCreate.useMutation({
|
||||
onSuccess: () => utils.media.mountList.invalidate(),
|
||||
onSuccess: (data) => {
|
||||
toast.success(t(locale, "mounts.saved"));
|
||||
void utils.media.mountList.invalidate();
|
||||
setForm(EMPTY_FORM);
|
||||
setFieldErrors({});
|
||||
setCreatedMountId(data.mount.id);
|
||||
},
|
||||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.saveError"))),
|
||||
});
|
||||
const update = trpc.media.mountUpdate.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(t(locale, "mounts.updated"));
|
||||
void utils.media.mountList.invalidate();
|
||||
setMode("create");
|
||||
setEditId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFieldErrors({});
|
||||
},
|
||||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.saveError"))),
|
||||
});
|
||||
const test = trpc.media.mountTest.useMutation();
|
||||
const del = trpc.media.mountDelete.useMutation({
|
||||
onSuccess: () => utils.media.mountList.invalidate(),
|
||||
});
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
password: "",
|
||||
rootPath: "/",
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
data.removedItems > 0
|
||||
? t(locale, "mounts.deletedN", { n: data.removedItems })
|
||||
: t(locale, "mounts.deleted"),
|
||||
);
|
||||
void utils.media.mountList.invalidate();
|
||||
if (editId) {
|
||||
setMode("create");
|
||||
setEditId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.deleteError"))),
|
||||
});
|
||||
|
||||
const [mode, setMode] = useState<FormMode>("create");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [createdMountId, setCreatedMountId] = useState<string | null>(null);
|
||||
|
||||
const saving = create.isPending || update.isPending;
|
||||
const mountList = list.data?.mounts ?? [];
|
||||
|
||||
function startEdit(mount: {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
username: string | null;
|
||||
rootPath: string;
|
||||
}) {
|
||||
setMode("edit");
|
||||
setEditId(mount.id);
|
||||
setCreatedMountId(null);
|
||||
test.reset();
|
||||
setForm({
|
||||
name: mount.name,
|
||||
baseUrl: mount.baseUrl,
|
||||
username: mount.username ?? "",
|
||||
password: "",
|
||||
rootPath: mount.rootPath || "/",
|
||||
});
|
||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
formRef.current?.querySelector<HTMLInputElement>("input")?.focus();
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setMode("create");
|
||||
setEditId(null);
|
||||
test.reset();
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit") return;
|
||||
if (mountList.length > 0) setCreatedMountId(null);
|
||||
}, [mountList.length, mode]);
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const errs = validateForm(form);
|
||||
setFieldErrors(errs);
|
||||
if (errs.name || errs.baseUrl) {
|
||||
const firstKey = errs.name ? "mount-name" : "mount-url";
|
||||
formRef.current?.querySelector<HTMLInputElement>(`#${firstKey}`)?.focus();
|
||||
return;
|
||||
}
|
||||
const base = {
|
||||
name: form.name.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
username: form.username.trim() || undefined,
|
||||
rootPath: form.rootPath.trim() || "/",
|
||||
};
|
||||
if (mode === "edit" && editId) {
|
||||
// 密码留空 = 不修改原凭据
|
||||
update.mutate({
|
||||
id: editId,
|
||||
...base,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
});
|
||||
} else {
|
||||
create.mutate({
|
||||
...base,
|
||||
type: "webdav",
|
||||
password: form.password || undefined,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onTest() {
|
||||
test.mutate({
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
username: form.username.trim() || undefined,
|
||||
password: form.password || undefined,
|
||||
rootPath: form.rootPath.trim() || "/",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold">{t(locale, "mounts.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")}
|
||||
</p>
|
||||
<form
|
||||
className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
create.mutate({
|
||||
name: form.name,
|
||||
type: "webdav",
|
||||
baseUrl: form.baseUrl,
|
||||
username: form.username || undefined,
|
||||
password: form.password || undefined,
|
||||
rootPath: form.rootPath || "/",
|
||||
enabled: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
aria-label={t(locale, "mounts.namePlaceholder")}
|
||||
placeholder={t(locale, "mounts.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
aria-label={t(locale, "mounts.baseUrlPlaceholder")}
|
||||
placeholder={t(locale, "mounts.baseUrlPlaceholder")}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
aria-label={t(locale, "mounts.usernamePlaceholder")}
|
||||
placeholder={t(locale, "mounts.usernamePlaceholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
aria-label={t(locale, "mounts.passwordPlaceholder")}
|
||||
placeholder={t(locale, "mounts.passwordPlaceholder")}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
aria-label={t(locale, "mounts.rootPathPlaceholder")}
|
||||
placeholder={t(locale, "mounts.rootPathPlaceholder")}
|
||||
value={form.rootPath}
|
||||
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{t(locale, "mounts.save")}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">{t(locale, "mounts.title")}</h1>
|
||||
<span className="u-mono text-xs text-muted-foreground">
|
||||
{String(mountList.length).padStart(2, "0")}
|
||||
</span>
|
||||
{mode === "edit" ? (
|
||||
<Button size="sm" variant="outline" className="ml-auto" onClick={cancelEdit}>
|
||||
{t(locale, "mounts.cancelEdit")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
test.mutate({
|
||||
baseUrl: form.baseUrl,
|
||||
username: form.username || undefined,
|
||||
password: form.password || undefined,
|
||||
rootPath: form.rootPath || "/",
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
formRef.current?.querySelector<HTMLInputElement>("input")?.focus();
|
||||
}}
|
||||
>
|
||||
{t(locale, "mounts.test")}
|
||||
{t(locale, "mounts.add")}
|
||||
</Button>
|
||||
</div>
|
||||
{test.data && (
|
||||
<p className={test.data.ok ? "text-green-600" : "text-red-600"}>
|
||||
{test.data.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form ref={formRef} className="space-y-4" onSubmit={onSubmit}>
|
||||
<Card className={cn("space-y-4 p-5", mode === "edit" && "border-primary/50")}>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{mode === "edit"
|
||||
? t(locale, "mounts.editHeading")
|
||||
: t(locale, "mounts.add")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(locale, "mounts.credentialHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-name">{t(locale, "mounts.fieldName")}</Label>
|
||||
<Input
|
||||
id="mount-name"
|
||||
aria-label={t(locale, "mounts.namePlaceholder")}
|
||||
placeholder={t(locale, "mounts.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
if (fieldErrors.name)
|
||||
setFieldErrors({ ...fieldErrors, name: undefined });
|
||||
}}
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t(locale, "mounts.errNameRequired")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-url">{t(locale, "mounts.fieldUrl")}</Label>
|
||||
<Input
|
||||
id="mount-url"
|
||||
aria-label={t(locale, "mounts.baseUrlPlaceholder")}
|
||||
placeholder={t(locale, "mounts.baseUrlPlaceholder")}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, baseUrl: e.target.value });
|
||||
if (fieldErrors.baseUrl) {
|
||||
setFieldErrors({ ...fieldErrors, baseUrl: undefined });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{fieldErrors.baseUrl && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t(locale, "mounts.errUrlInvalid")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-user">{t(locale, "mounts.fieldUser")}</Label>
|
||||
<Input
|
||||
id="mount-user"
|
||||
aria-label={t(locale, "mounts.usernamePlaceholder")}
|
||||
placeholder={t(locale, "mounts.usernamePlaceholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-pass">{t(locale, "mounts.fieldPass")}</Label>
|
||||
<Input
|
||||
id="mount-pass"
|
||||
type="password"
|
||||
aria-label={t(locale, "mounts.passwordPlaceholder")}
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? t(locale, "mounts.passwordKeepHint")
|
||||
: t(locale, "mounts.passwordPlaceholder")
|
||||
}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-root">{t(locale, "mounts.fieldRoot")}</Label>
|
||||
<Input
|
||||
id="mount-root"
|
||||
aria-label={t(locale, "mounts.rootPathPlaceholder")}
|
||||
placeholder={t(locale, "mounts.rootPathPlaceholder")}
|
||||
value={form.rootPath}
|
||||
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{mode === "edit"
|
||||
? t(locale, "mounts.saveEdit")
|
||||
: t(locale, "mounts.save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={test.isPending || !form.baseUrl.trim()}
|
||||
onClick={onTest}
|
||||
>
|
||||
{t(locale, "mounts.test")}
|
||||
</Button>
|
||||
{mode === "edit" && (
|
||||
<Button type="button" variant="ghost" onClick={cancelEdit}>
|
||||
{t(locale, "mounts.cancelEdit")}
|
||||
</Button>
|
||||
)}
|
||||
{test.data && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
test.data.ok ? "text-green-600" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{test.data.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "mounts.openlistHint")}
|
||||
</p>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{(list.data?.mounts ?? []).map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between gap-3 p-3">
|
||||
<div>
|
||||
<div className="font-medium">{m.name}</div>
|
||||
<div className="text-xs text-muted-foreground break-all">
|
||||
{m.baseUrl}
|
||||
{createdMountId && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4">
|
||||
<p className="text-sm">{t(locale, "mounts.afterCreateHint")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => go(`/${locale}/browse?mount=${createdMountId}`)}
|
||||
>
|
||||
{t(locale, "mounts.toBrowse")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<p className="u-label">{t(locale, "mounts.listTitle")}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{mountList.map((m) => (
|
||||
<Card
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
editId === m.id && "border-primary/60",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<CardTitle className="text-base">{m.name}</CardTitle>
|
||||
<div className="ml-auto flex gap-1.5">
|
||||
{editId === m.id && (
|
||||
<Badge className="border-primary/50 text-primary">
|
||||
{t(locale, "mounts.editing")}
|
||||
</Badge>
|
||||
)}
|
||||
{!m.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t(locale, "mounts.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="break-all">
|
||||
{m.baseUrl}
|
||||
{m.rootPath && m.rootPath !== "/" ? ` · ${m.rootPath}` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 pt-0">
|
||||
<Button size="sm" variant="secondary" onClick={() => startEdit(m)}>
|
||||
{t(locale, "mounts.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => go(`/${locale}/browse?mount=${m.id}`)}
|
||||
>
|
||||
{t(locale, "mounts.browseThis")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
if (confirmDeleteId === m.id) {
|
||||
setConfirmDeleteId(null);
|
||||
del.mutate({ id: m.id });
|
||||
} else {
|
||||
setConfirmDeleteId(m.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{confirmDeleteId === m.id
|
||||
? t(locale, "mounts.confirmDelete")
|
||||
: t(locale, "mounts.delete")}
|
||||
</Button>
|
||||
{confirmDeleteId === m.id && (
|
||||
<span className="self-center text-xs text-destructive">
|
||||
{t(locale, "mounts.deleteHint")}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{list.isSuccess && mountList.length === 0 && (
|
||||
<div className="u-empty col-span-full">
|
||||
<p className="u-empty__title">{t(locale, "mounts.empty")}</p>
|
||||
<p>{t(locale, "mounts.openlistHint")}</p>
|
||||
<div className="u-empty__actions">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
formRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
formRef.current
|
||||
?.querySelector<HTMLInputElement>("input")
|
||||
?.focus();
|
||||
}}
|
||||
>
|
||||
{t(locale, "mounts.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => del.mutate({ id: m.id })}
|
||||
>
|
||||
{t(locale, "mounts.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{list.data?.mounts.length === 0 && (
|
||||
<li className="p-4 text-sm text-muted-foreground">
|
||||
{t(locale, "mounts.empty")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,32 @@
|
|||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
import { Button } from "@app/ui";
|
||||
import { Button, toast } from "@app/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Artplayer from "artplayer";
|
||||
import artplayerPluginDanmuku from "artplayer-plugin-danmuku";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { parseDanmakuXml, startDanmakuLoop } from "~/lib/danmaku-render";
|
||||
import { parseDanmakuXml, thinDanmaku } from "~/lib/danmaku-render";
|
||||
import { go, queryParam } from "~/lib/nav";
|
||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
|
||||
|
||||
type DanmakuPrefs = { enabled: boolean; opacity: number; density: number };
|
||||
|
||||
type DanmukuPlugin = {
|
||||
load: (danmuku: unknown) => Promise<unknown> | unknown;
|
||||
show: () => unknown;
|
||||
hide: () => unknown;
|
||||
};
|
||||
|
||||
function danmukuPlugin(art: Artplayer): DanmukuPlugin | null {
|
||||
const plugins = art.plugins as Record<string, unknown>;
|
||||
const dm = plugins["artplayerPluginDanmuku"];
|
||||
if (!dm || typeof dm !== "object") return null;
|
||||
return dm as DanmukuPlugin;
|
||||
}
|
||||
|
||||
type ProgressInput = {
|
||||
mediaItemId: string;
|
||||
positionMs: number;
|
||||
|
|
@ -38,6 +54,35 @@ function readDanmakuPrefs(): DanmakuPrefs {
|
|||
}
|
||||
}
|
||||
|
||||
function writeDanmakuPrefs(prefs: DanmakuPrefs) {
|
||||
try {
|
||||
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
const DANMAKU_XML_PREFIX = "media-danmaku-xml:";
|
||||
/** 单条弹幕 XML 本地持久化上限(localStorage 常规配额约 5MB,留余量) */
|
||||
const DANMAKU_XML_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function readLocalXml(mediaItemId: string): string {
|
||||
try {
|
||||
return localStorage.getItem(DANMAKU_XML_PREFIX + mediaItemId) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalXml(mediaItemId: string, xml: string) {
|
||||
try {
|
||||
if (new Blob([xml]).size > DANMAKU_XML_MAX_BYTES) return;
|
||||
localStorage.setItem(DANMAKU_XML_PREFIX + mediaItemId, xml);
|
||||
} catch {
|
||||
/* 配额不足时仅内存生效 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */
|
||||
function sendProgressBeacon(input: ProgressInput) {
|
||||
const url = "/api/trpc/media.playbackReport?batch=1";
|
||||
|
|
@ -65,14 +110,22 @@ function sendProgressBeacon(input: ProgressInput) {
|
|||
|
||||
function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||
const { locale, mediaItemId } = props;
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<HTMLDivElement>(null);
|
||||
const artRef = useRef<Artplayer | null>(null);
|
||||
const progressStartRef = useRef(0);
|
||||
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
|
||||
const [localXml, setLocalXml] = useState("");
|
||||
const [danmakuOn, setDanmakuOn] = useState(true);
|
||||
const [danmakuOpacity, setDanmakuOpacity] = useState(0.8);
|
||||
const [danmakuDensity, setDanmakuDensity] = useState(1);
|
||||
const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled);
|
||||
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [playError, setPlayError] = useState(false);
|
||||
|
||||
// 进入/切换条目时恢复本地导入的弹幕
|
||||
useEffect(() => {
|
||||
setLocalXml(readLocalXml(mediaItemId));
|
||||
setPlayError(false);
|
||||
setEnded(false);
|
||||
}, [mediaItemId]);
|
||||
|
||||
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
|
||||
const progress = trpc.media.playbackGet.useQuery({ mediaItemId });
|
||||
|
|
@ -84,10 +137,37 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
reportRef.current = report;
|
||||
});
|
||||
|
||||
const bangumiId = detail.data?.bangumiId ?? null;
|
||||
const series = trpc.media.librarySeries.useQuery(
|
||||
{ bangumiId: bangumiId ?? 0 },
|
||||
{ enabled: bangumiId != null && bangumiId > 0 },
|
||||
);
|
||||
const episodes = useMemo(() => {
|
||||
const list = series.data?.episodes ?? [];
|
||||
return [...list].sort((a, b) => (a.epNumber ?? 0) - (b.epNumber ?? 0));
|
||||
}, [series.data]);
|
||||
const epIndex = episodes.findIndex((ep) => ep.id === mediaItemId);
|
||||
const prevEp = epIndex > 0 ? episodes[epIndex - 1] : null;
|
||||
const nextEp = epIndex >= 0 && epIndex < episodes.length - 1 ? episodes[epIndex + 1] : null;
|
||||
|
||||
const openEpisode = useCallback(
|
||||
(id: string) => {
|
||||
const g = bangumiId != null ? `&from=series&g=${bangumiId}` : "";
|
||||
go(`/${locale}/watch?id=${id}${g}`);
|
||||
},
|
||||
[locale, bangumiId],
|
||||
);
|
||||
|
||||
const goSeries = useCallback(() => {
|
||||
const g = queryParam("g") ?? (bangumiId != null ? String(bangumiId) : null);
|
||||
if (g != null) go(`/${locale}/library?g=${g}`);
|
||||
else go(`/${locale}/library`);
|
||||
}, [locale, bangumiId]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefs = readDanmakuPrefs();
|
||||
prefsRef.current = prefs;
|
||||
setDanmakuOn(prefs.enabled);
|
||||
setDanmakuOpacity(prefs.opacity);
|
||||
setDanmakuDensity(prefs.density);
|
||||
}, []);
|
||||
|
||||
|
|
@ -101,6 +181,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
useEffect(() => {
|
||||
const mountEl = playerRef.current;
|
||||
if (!mountEl || !detailId) return;
|
||||
const prefs = prefsRef.current;
|
||||
const art = new Artplayer({
|
||||
container: mountEl,
|
||||
url: `/api/stream?id=${encodeURIComponent(mediaItemId)}`,
|
||||
|
|
@ -111,10 +192,56 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
playbackRate: true,
|
||||
aspectRatio: true,
|
||||
fullscreen: true,
|
||||
theme: "#3b82f6",
|
||||
theme: "#FB7299",
|
||||
plugins: [
|
||||
artplayerPluginDanmuku({
|
||||
danmuku: [],
|
||||
opacity: prefs.opacity,
|
||||
fontSize: 25,
|
||||
speed: 5,
|
||||
margin: [10, "25%"],
|
||||
color: "#FFFFFF",
|
||||
mode: 0,
|
||||
antiOverlap: true,
|
||||
visible: prefs.enabled,
|
||||
emitter: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
artRef.current = art;
|
||||
|
||||
// 常用快捷键(ArtPlayer 默认不含 f/m,补注册)
|
||||
art.hotkey.add("KeyF", () => {
|
||||
art.fullscreen = !art.fullscreen;
|
||||
});
|
||||
art.hotkey.add("KeyM", () => {
|
||||
art.muted = !art.muted;
|
||||
});
|
||||
|
||||
// 播放失败 → 业务级错误卡(含重试);加载成功后清除
|
||||
const onVideoError = () => {
|
||||
setPlayError(true);
|
||||
};
|
||||
const onVideoReady = () => {
|
||||
setPlayError(false);
|
||||
};
|
||||
art.on("video:error", onVideoError);
|
||||
art.on("video:loadeddata", onVideoReady);
|
||||
art.on("playing", onVideoReady);
|
||||
|
||||
// 播放器内弹幕设置变更 → 回写本地默认偏好
|
||||
art.on("artplayerPluginDanmuku:config", (...args: unknown[]) => {
|
||||
const option = (args[0] ?? {}) as { opacity?: number; visible?: boolean };
|
||||
const prev = prefsRef.current;
|
||||
const next: DanmakuPrefs = {
|
||||
enabled: option.visible ?? prev.enabled,
|
||||
opacity: typeof option.opacity === "number" ? option.opacity : prev.opacity,
|
||||
density: prev.density,
|
||||
};
|
||||
prefsRef.current = next;
|
||||
writeDanmakuPrefs(next);
|
||||
});
|
||||
|
||||
const seekToStart = () => {
|
||||
const target = progressStartRef.current / 1000;
|
||||
if (target > 0 && art.duration > target) {
|
||||
|
|
@ -146,11 +273,13 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
};
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
art.on("play", () => {
|
||||
// video:play / video:pause 是原生事件转发:覆盖 UI 按钮与系统媒体键/程序化暂停
|
||||
art.on("video:play", () => {
|
||||
setEnded(false);
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(reportNow, 5000);
|
||||
});
|
||||
art.on("pause", () => {
|
||||
art.on("video:pause", () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
|
|
@ -163,6 +292,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
timer = null;
|
||||
}
|
||||
reportNow();
|
||||
setEnded(true);
|
||||
});
|
||||
|
||||
const onVisibility = () => {
|
||||
|
|
@ -194,24 +324,28 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : "";
|
||||
const items = useMemo(() => {
|
||||
const all = xmlSource ? parseDanmakuXml(xmlSource) : [];
|
||||
// density < 1 时按比例抽稀,避免同屏弹幕过密
|
||||
if (danmakuDensity >= 1) return all;
|
||||
const keepEvery = Math.max(1, Math.round(1 / danmakuDensity));
|
||||
return all.filter((_, i) => i % keepEvery === 0);
|
||||
return thinDanmaku(all, danmakuDensity);
|
||||
}, [xmlSource, danmakuDensity]);
|
||||
const danmakuCount = items.length;
|
||||
|
||||
// 弹幕数据变化时交给官方插件渲染
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || items.length === 0) return;
|
||||
const stop = startDanmakuLoop(
|
||||
wrapper,
|
||||
() => items,
|
||||
() => artRef.current?.currentTime ?? 0,
|
||||
{ opacity: danmakuOpacity },
|
||||
);
|
||||
return stop;
|
||||
}, [items, danmakuOpacity]);
|
||||
const art = artRef.current;
|
||||
if (!art) return;
|
||||
const dm = danmukuPlugin(art);
|
||||
if (!dm) return;
|
||||
void dm.load(items);
|
||||
}, [items]);
|
||||
|
||||
// 外部开关同步到插件显隐
|
||||
useEffect(() => {
|
||||
const art = artRef.current;
|
||||
if (!art) return;
|
||||
const dm = danmukuPlugin(art);
|
||||
if (!dm) return;
|
||||
if (danmakuOn) dm.show();
|
||||
else dm.hide();
|
||||
}, [danmakuOn]);
|
||||
|
||||
const onImportXml = useCallback(
|
||||
(file: File) => {
|
||||
|
|
@ -219,15 +353,37 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
reader.onload = () => {
|
||||
const text = String(reader.result ?? "");
|
||||
setLocalXml(text);
|
||||
importMeta.mutate({ mediaItemId, byteSize: file.size });
|
||||
writeLocalXml(mediaItemId, text);
|
||||
importMeta.mutate(
|
||||
{ mediaItemId, byteSize: file.size },
|
||||
{
|
||||
onSuccess: () => toast.success(t(locale, "watch.importOk")),
|
||||
onError: () => toast.error(t(locale, "watch.importFail")),
|
||||
},
|
||||
);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[importMeta, mediaItemId],
|
||||
[importMeta, mediaItemId, locale],
|
||||
);
|
||||
|
||||
const replay = useCallback(() => {
|
||||
const art = artRef.current;
|
||||
if (!art) return;
|
||||
setEnded(false);
|
||||
art.currentTime = 0;
|
||||
void art.play();
|
||||
}, []);
|
||||
|
||||
const retryStream = useCallback(() => {
|
||||
const art = artRef.current;
|
||||
if (!art) return;
|
||||
setPlayError(false);
|
||||
art.switchUrl(`/api/stream?id=${encodeURIComponent(mediaItemId)}`);
|
||||
}, [mediaItemId]);
|
||||
|
||||
const goLibrary = useCallback(() => {
|
||||
window.location.href = `/${locale}/library`;
|
||||
go(`/${locale}/library`);
|
||||
}, [locale]);
|
||||
|
||||
if (detail.isError) {
|
||||
|
|
@ -257,18 +413,22 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xl font-semibold">{t(locale, "watch.notFound")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, "watch.notFoundHint")}</p>
|
||||
<Button size="sm" variant="ghost" onClick={goLibrary}>
|
||||
{t(locale, "watch.backToLibrary")}
|
||||
</Button>
|
||||
<div className="u-empty">
|
||||
<p className="u-empty__title">{t(locale, "watch.notFound")}</p>
|
||||
<p>{t(locale, "watch.notFoundHint")}</p>
|
||||
<div className="u-empty__actions">
|
||||
<Button size="sm" onClick={goLibrary}>
|
||||
{t(locale, "watch.backToLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sourceLabel = !danmakuOn
|
||||
? t(locale, "watch.danmakuOff")
|
||||
? localXml
|
||||
? t(locale, "watch.danmakuDisabledHint")
|
||||
: t(locale, "watch.danmakuOff")
|
||||
: localXml
|
||||
? t(locale, "watch.danmakuCount", { count: danmakuCount })
|
||||
: openDanmaku.data?.source === "none"
|
||||
|
|
@ -283,14 +443,12 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold">{detail.data.title}</h1>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={danmakuOn ? "default" : "outline"}
|
||||
onClick={() => setDanmakuOn((v) => !v)}
|
||||
>
|
||||
{danmakuOn ? t(locale, "watch.danmakuOn") : t(locale, "watch.danmakuOff")}
|
||||
{bangumiId != null && (
|
||||
<Button size="sm" variant="ghost" onClick={goSeries}>
|
||||
← {t(locale, "watch.backToSeries")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<label className="inline-flex h-9 cursor-pointer items-center rounded-md border border-border px-3 text-sm">
|
||||
{t(locale, "watch.importXml")}
|
||||
<input
|
||||
|
|
@ -309,16 +467,113 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative aspect-video w-full overflow-hidden rounded-lg bg-black"
|
||||
>
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
|
||||
<div ref={playerRef} className="absolute inset-0" />
|
||||
{playError && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/80 p-6 text-center">
|
||||
<p className="text-base font-medium text-white">
|
||||
{t(locale, "watch.playFailed")}
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-white/70">
|
||||
{t(locale, "watch.playFailedHint")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={retryStream}>
|
||||
{t(locale, "error.retry")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={goLibrary}>
|
||||
{t(locale, "watch.backToLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||
<span>{sourceLabel}</span>
|
||||
<span>{t(locale, "watch.progressHint")}</span>
|
||||
</div>
|
||||
|
||||
{episodes.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="u-label">{t(locale, "watch.episodes")}</p>
|
||||
<span className="u-dotline flex-1" aria-hidden="true" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!prevEp}
|
||||
onClick={() => prevEp && openEpisode(prevEp.id)}
|
||||
>
|
||||
← {t(locale, "watch.prevEpisode")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!nextEp}
|
||||
onClick={() => nextEp && openEpisode(nextEp.id)}
|
||||
>
|
||||
{t(locale, "watch.nextEpisode")} →
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{episodes.map((ep) => {
|
||||
const current = ep.id === mediaItemId;
|
||||
return (
|
||||
<li key={ep.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||||
current
|
||||
? "border-primary bg-primary/10 font-medium text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => !current && openEpisode(ep.id)}
|
||||
>
|
||||
{ep.epNumber != null
|
||||
? t(locale, "watch.episodeN", { n: ep.epNumber })
|
||||
: ep.rawName}
|
||||
{current && (
|
||||
<span className="ml-2 text-[11px]">
|
||||
{t(locale, "watch.currentEpisode")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ended && nextEp && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4">
|
||||
<p className="text-sm">
|
||||
{t(locale, "watch.episodeN", {
|
||||
n: nextEp.epNumber ?? episodes.indexOf(nextEp) + 1,
|
||||
})}
|
||||
</p>
|
||||
<Button size="sm" className="ml-auto" onClick={() => openEpisode(nextEp.id)}>
|
||||
{t(locale, "watch.playNextCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ended && !nextEp && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 p-4">
|
||||
<p className="text-sm">{t(locale, "watch.playbackFinished")}</p>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={replay}>
|
||||
{t(locale, "watch.replay")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={bangumiId != null ? goSeries : goLibrary}>
|
||||
{bangumiId != null
|
||||
? t(locale, "watch.backToSeries")
|
||||
: t(locale, "watch.backToLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
---
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
import "../styles/globals.css";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import ThemeSwitch from "~/components/ThemeSwitch";
|
||||
|
|
@ -9,20 +10,19 @@ import {
|
|||
toJsonLdScript,
|
||||
type PageSeoInput,
|
||||
} from "@app/seo-geo";
|
||||
import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, t } from "@app/i18n";
|
||||
import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, localePath, t } from "@app/i18n";
|
||||
import { getSiteSeo } from "~/lib/seo";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
locale: Locale;
|
||||
brand?: string;
|
||||
nav?: { label: string; href: string; n: string }[];
|
||||
/** 当前页,用于侧栏高亮 */
|
||||
active?: "home" | "library" | "browse" | "mounts" | "danmaku";
|
||||
switchHref?: string;
|
||||
switchLabel?: string;
|
||||
seo?: PageSeoInput;
|
||||
/** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */
|
||||
hideSideRail?: boolean;
|
||||
/** 媒体页隐藏模板页脚 */
|
||||
/** 播放页隐藏模板页脚 */
|
||||
hideFooter?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -30,16 +30,14 @@ const {
|
|||
title,
|
||||
locale,
|
||||
brand,
|
||||
nav: navProp,
|
||||
active,
|
||||
switchHref,
|
||||
switchLabel,
|
||||
seo,
|
||||
hideSideRail = false,
|
||||
hideFooter = false,
|
||||
} = Astro.props;
|
||||
|
||||
const brandName = brand ?? t(locale, "meta.brand");
|
||||
const nav = navProp ?? [];
|
||||
|
||||
const siteSeo = getSiteSeo(locale);
|
||||
const pageSeoInput: PageSeoInput = {
|
||||
|
|
@ -52,10 +50,18 @@ const headHtml = renderHeadTagsHtml(headTags);
|
|||
const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput));
|
||||
const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title;
|
||||
|
||||
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
|
||||
const libraryHref = locale === "zh-CN" ? "/zh-CN/library" : "/en/library";
|
||||
const brandHref = hideSideRail ? libraryHref : homeHref;
|
||||
const homeHref = localePath(locale, "/");
|
||||
const brandHref = homeHref;
|
||||
const brandInitial = brandName.charAt(0) || "弹";
|
||||
|
||||
/** 全站统一侧栏导航 */
|
||||
const nav = [
|
||||
{ id: "home", n: "00", label: t(locale, "nav.home"), href: homeHref },
|
||||
{ id: "library", n: "01", label: t(locale, "nav.library"), href: localePath(locale, "/library") },
|
||||
{ id: "browse", n: "02", label: t(locale, "nav.browse"), href: localePath(locale, "/browse") },
|
||||
{ id: "mounts", n: "03", label: t(locale, "nav.mounts"), href: localePath(locale, "/mounts") },
|
||||
{ id: "danmaku", n: "04", label: t(locale, "nav.danmaku"), href: localePath(locale, "/danmaku") },
|
||||
] as const;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
|
|
@ -77,6 +83,7 @@ const brandInitial = brandName.charAt(0) || "弹";
|
|||
<title set:html={pageTitle} />
|
||||
<Fragment set:html={headHtml} />
|
||||
<Fragment set:html={jsonLdHtml} />
|
||||
<ClientRouter />
|
||||
<script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}>
|
||||
(function () {
|
||||
function apply(mode) {
|
||||
|
|
@ -88,36 +95,46 @@ const brandInitial = brandName.charAt(0) || "弹";
|
|||
document.documentElement.classList.toggle("light", !dark);
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
}
|
||||
var mode = "system";
|
||||
try {
|
||||
var t = localStorage.getItem("app-theme");
|
||||
if (t === "light" || t === "dark" || t === "system") mode = t;
|
||||
} catch (e) {}
|
||||
apply(mode);
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", function () {
|
||||
try {
|
||||
if ((localStorage.getItem("app-theme") || "system") === "system") {
|
||||
function applyFromStorage() {
|
||||
var mode = "system";
|
||||
try {
|
||||
var t = localStorage.getItem("app-theme");
|
||||
if (t === "light" || t === "dark" || t === "system") mode = t;
|
||||
} catch (e) {}
|
||||
apply(mode);
|
||||
}
|
||||
applyFromStorage();
|
||||
|
||||
// ClientRouter 软导航后 <html> class 会被新页覆盖,需按偏好重放主题
|
||||
document.addEventListener("astro:after-swap", applyFromStorage);
|
||||
|
||||
if (!window.__fanmuChromeInit) {
|
||||
window.__fanmuChromeInit = true;
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", function () {
|
||||
try {
|
||||
if ((localStorage.getItem("app-theme") || "system") === "system") {
|
||||
apply("system");
|
||||
}
|
||||
} catch (e) {
|
||||
apply("system");
|
||||
}
|
||||
} catch (e) {
|
||||
apply("system");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 语言切换:写偏好 cookie 后由 <a> 跳转(捕获阶段)
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
var el = e.target && e.target.closest ? e.target.closest("[data-switch-locale]") : null;
|
||||
if (!el) return;
|
||||
var target = el.getAttribute("data-switch-locale") || nextLocale;
|
||||
document.cookie =
|
||||
cookieName + "=" + target + "; path=/; max-age=" + maxAge + "; SameSite=Lax";
|
||||
},
|
||||
true,
|
||||
);
|
||||
// 语言切换:写偏好 cookie 后由 <a> 跳转(捕获阶段)
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
var el = e.target && e.target.closest ? e.target.closest("[data-switch-locale]") : null;
|
||||
if (!el) return;
|
||||
var target = el.getAttribute("data-switch-locale") || nextLocale;
|
||||
document.cookie =
|
||||
cookieName + "=" + target + "; path=/; max-age=" + maxAge + "; SameSite=Lax";
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
|
@ -133,42 +150,40 @@ const brandInitial = brandName.charAt(0) || "弹";
|
|||
<div aria-hidden="true" class="bg-vignette pointer-events-none fixed inset-0 z-0"></div>
|
||||
|
||||
{
|
||||
!hideSideRail && (
|
||||
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
|
||||
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
|
||||
<span
|
||||
class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold"
|
||||
style="background: var(--edge); color: #101110;"
|
||||
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
|
||||
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
|
||||
<span
|
||||
class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold"
|
||||
style="background: var(--edge); color: #101110;"
|
||||
>
|
||||
{brandInitial}
|
||||
</span>
|
||||
<span class="side-rail__brand-text">{brandName}</span>
|
||||
</a>
|
||||
<div class="side-rail__rule" aria-hidden="true"></div>
|
||||
<nav class="side-rail__nav">
|
||||
{nav.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={["side-rail__link", { "is-active": active === item.id }]}
|
||||
aria-current={active === item.id ? "page" : undefined}
|
||||
>
|
||||
{brandInitial}
|
||||
</span>
|
||||
<span class="side-rail__brand-text">{brandName}</span>
|
||||
</a>
|
||||
<div class="side-rail__rule" aria-hidden="true"></div>
|
||||
<nav class="side-rail__nav">
|
||||
<a href={brandHref} class="side-rail__link is-home">
|
||||
<span class="u-mono">00</span>
|
||||
<span>{t(locale, "nav.home")}</span>
|
||||
<span class="u-mono">{item.n}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{nav.map((item) => (
|
||||
<a href={item.href} class="side-rail__link">
|
||||
<span class="u-mono">{item.n}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="side-rail__rule" aria-hidden="true"></div>
|
||||
<div class="side-rail__foot">
|
||||
<ThemeSwitch client:visible />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
))}
|
||||
</nav>
|
||||
<div class="side-rail__rule" aria-hidden="true"></div>
|
||||
<div class="side-rail__foot">
|
||||
<ThemeSwitch client:only="react" />
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
|
||||
<div class:list={["site-shell", { "site-shell--no-rail": hideSideRail }]}>
|
||||
<div class="site-shell">
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a href={brandHref} class="site-header__brand md:hidden" aria-label={brandName}>
|
||||
<a href={brandHref} class="site-header__brand lg:hidden" aria-label={brandName}>
|
||||
<span
|
||||
class="brand-signal flex size-7 items-center justify-center text-[11px] font-bold"
|
||||
style="background: var(--edge); color: #101110;"
|
||||
|
|
@ -180,7 +195,11 @@ const brandInitial = brandName.charAt(0) || "弹";
|
|||
<nav class="site-header__nav" aria-label={t(locale, "nav.primary")}>
|
||||
{
|
||||
nav.map((item) => (
|
||||
<a href={item.href} class="u-linelink">
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={["u-linelink", { "is-active": active === item.id }]}
|
||||
aria-current={active === item.id ? "page" : undefined}
|
||||
>
|
||||
<span class="u-mono">{item.n}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
|
|
@ -201,10 +220,10 @@ const brandInitial = brandName.charAt(0) || "弹";
|
|||
</a>
|
||||
)
|
||||
}
|
||||
<span class={hideSideRail ? "" : "md:hidden"}>
|
||||
<ThemeSwitch client:visible />
|
||||
<span class="lg:hidden">
|
||||
<ThemeSwitch client:only="react" />
|
||||
</span>
|
||||
<UserMenu client:visible locale={locale} />
|
||||
<UserMenu client:only="react" locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-header__dash" aria-hidden="true"></div>
|
||||
|
|
|
|||
|
|
@ -1,95 +1,49 @@
|
|||
export type DanmakuItem = { time: number; text: string; mode: number };
|
||||
export type DanmakuItem = {
|
||||
time: number;
|
||||
text: string;
|
||||
/** 0 滚动,1 顶部,2 底部(artplayer-plugin-danmuku) */
|
||||
mode: 0 | 1 | 2;
|
||||
color?: string | undefined;
|
||||
};
|
||||
|
||||
/** B 站 p 属性 mode → artplayer 插件 mode */
|
||||
function toPluginMode(raw: number): 0 | 1 | 2 {
|
||||
// 1/2/3 滚动,4 底部,5 顶部
|
||||
if (raw === 4) return 2;
|
||||
if (raw === 5) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function toHexColor(decimal: number): string | undefined {
|
||||
if (!Number.isFinite(decimal) || decimal < 0) return undefined;
|
||||
return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** 解析 B 站弹幕 XML(含颜色/模式),供 artplayer-plugin-danmuku 使用 */
|
||||
export function parseDanmakuXml(xml: string): DanmakuItem[] {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
const nodes = Array.from(doc.querySelectorAll("d"));
|
||||
const out: DanmakuItem[] = [];
|
||||
for (const n of nodes) {
|
||||
const p = n.getAttribute("p") ?? "";
|
||||
const [t, mode] = p.split(",");
|
||||
const [t, modeRaw, , colorRaw] = p.split(",");
|
||||
const time = Number(t);
|
||||
if (!Number.isFinite(time)) continue;
|
||||
out.push({ time, text: n.textContent ?? "", mode: Number(mode) || 1 });
|
||||
const text = (n.textContent ?? "").trim();
|
||||
if (!text) continue;
|
||||
out.push({
|
||||
time,
|
||||
text,
|
||||
mode: toPluginMode(Number(modeRaw) || 1),
|
||||
color: toHexColor(Number(colorRaw)),
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export type DanmakuLoopOptions = {
|
||||
opacity?: number | undefined;
|
||||
/** 每秒横向位移 px */
|
||||
speed?: number | undefined;
|
||||
};
|
||||
|
||||
export function startDanmakuLoop(
|
||||
container: HTMLElement,
|
||||
getItems: () => DanmakuItem[],
|
||||
getCurrentTime: () => number,
|
||||
options?: DanmakuLoopOptions,
|
||||
): () => void {
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "absolute inset-0 overflow-hidden pointer-events-none";
|
||||
layer.style.position = "absolute";
|
||||
layer.style.zIndex = "5";
|
||||
const opacity = options?.opacity;
|
||||
if (typeof opacity === "number") {
|
||||
layer.style.opacity = String(Math.min(1, Math.max(0, opacity)));
|
||||
}
|
||||
container.style.position = container.style.position || "relative";
|
||||
container.appendChild(layer);
|
||||
|
||||
const active: { el: HTMLDivElement; start: number }[] = [];
|
||||
const speed = options?.speed ?? 160;
|
||||
let raf = 0;
|
||||
let cursor = 0;
|
||||
let lastTime = 0;
|
||||
|
||||
const clearActive = () => {
|
||||
for (const a of active) a.el.remove();
|
||||
active.length = 0;
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
const now = getCurrentTime();
|
||||
const items = getItems();
|
||||
// 进度回跳时重置游标,避免一次性涌出全部历史弹幕
|
||||
if (now + 0.5 < lastTime) {
|
||||
cursor = 0;
|
||||
clearActive();
|
||||
}
|
||||
lastTime = now;
|
||||
while (cursor < items.length) {
|
||||
const it = items[cursor];
|
||||
if (!it || it.time > now) break;
|
||||
cursor += 1;
|
||||
// 快进/加载导致的过期弹幕直接丢弃,防止一帧内刷屏
|
||||
if (it.time < now - 2) continue;
|
||||
const el = document.createElement("div");
|
||||
el.textContent = it.text;
|
||||
el.style.cssText =
|
||||
"position:absolute;left:100%;white-space:nowrap;color:#fff;font-size:22px;text-shadow:0 1px 2px #000;will-change:transform;";
|
||||
const maxY = Math.max(0, container.clientHeight - 40);
|
||||
el.style.top = `${Math.random() * maxY}px`;
|
||||
layer.appendChild(el);
|
||||
active.push({ el, start: performance.now() });
|
||||
}
|
||||
const t = performance.now();
|
||||
for (let i = active.length - 1; i >= 0; i -= 1) {
|
||||
const a = active[i];
|
||||
if (!a) continue;
|
||||
const elapsed = (t - a.start) / 1000;
|
||||
const x = container.clientWidth - elapsed * speed;
|
||||
a.el.style.transform = `translateX(${x - container.clientWidth}px)`;
|
||||
if (x < -a.el.offsetWidth) {
|
||||
a.el.remove();
|
||||
active.splice(i, 1);
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearActive();
|
||||
layer.remove();
|
||||
};
|
||||
/** density < 1 时按比例抽稀,避免同屏过密 */
|
||||
export function thinDanmaku(items: DanmakuItem[], density: number): DanmakuItem[] {
|
||||
if (density >= 1) return items;
|
||||
const keepEvery = Math.max(1, Math.round(1 / density));
|
||||
return items.filter((_, i) => i % keepEvery === 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { navigate } from "astro:transitions/client";
|
||||
|
||||
/** 站内软导航(ClientRouter),避免整页 GET。 */
|
||||
export function go(href: string): void {
|
||||
void navigate(href);
|
||||
}
|
||||
|
||||
/** 读取当前 URL 查询参数(浏览器侧)。 */
|
||||
export function queryParam(name: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
/** 更新查询参数(不刷新,便于刷新/分享后停留在同一选集)。 */
|
||||
export function setQueryParams(params: Record<string, string | null>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value == null) url.searchParams.delete(key);
|
||||
else url.searchParams.set(key, value);
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
|
@ -29,11 +29,11 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig {
|
|||
organization: { name, url: siteUrl },
|
||||
llms: {
|
||||
summary: isZh
|
||||
? "弹弹 Web:多用户 Web 媒体中心。WebDAV 挂载、媒体库扫描、Bangumi 刮削、ArtPlayer 播放与弹弹play 开放弹幕网络中继,本地 XML 兜底。"
|
||||
: "DanDan Web: multi-user web media hub. WebDAV mounts, library scan, Bangumi scraping, ArtPlayer playback, dandanplay open danmaku relay with local XML fallback.",
|
||||
? "帆幕:私有影音库与弹幕观影。WebDAV 挂载、媒体库扫描、Bangumi 刮削、ArtPlayer 播放与弹弹play 开放弹幕网络中继,本地 XML 兜底。"
|
||||
: "Fanmu: private media library with danmaku. WebDAV mounts, library scan, Bangumi scraping, ArtPlayer playback, dandanplay open danmaku relay with local XML fallback.",
|
||||
details: [
|
||||
isZh
|
||||
? "分层:types → models → db → dao → trpc → web。媒体域按垂直切片扩展。"
|
||||
? "分层:types → models → db → dao → trpc → web。媒体域按业务分层扩展。"
|
||||
: "Layers: types → models → db → dao → trpc → web. Extend the media domain as a vertical slice.",
|
||||
isZh
|
||||
? "挂载凭据仅存服务端加密;浏览器不直连网盘密码。"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
/** tRPC 错误 → 用户可读文案:提取 zod 数组消息、兜底 HTML/网络异常。 */
|
||||
export function formatTrpcError(err: unknown, fallback: string): string {
|
||||
const raw = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
||||
if (!raw) return fallback;
|
||||
if (raw.startsWith("<") || raw.includes("<!DOCTYPE")) {
|
||||
return "服务暂时不可用,请稍后重试";
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const msgs = parsed
|
||||
.map((e) =>
|
||||
e && typeof e === "object" ? (e as { message?: unknown }).message : null,
|
||||
)
|
||||
.filter((m): m is string => typeof m === "string" && m.length > 0);
|
||||
if (msgs.length > 0) return msgs.join(";");
|
||||
}
|
||||
if (parsed && typeof parsed === "object" && "message" in parsed) {
|
||||
const m = (parsed as { message?: unknown }).message;
|
||||
if (typeof m === "string" && m) return m;
|
||||
}
|
||||
} catch {
|
||||
/* 非 JSON,按原文处理 */
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
|
@ -8,6 +8,26 @@ import type { AppRouter } from "@app/trpc";
|
|||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
|
||||
/** 网关/框架可能对 /api/trpc 返回 HTML 错误页:转换成 JSON 错误,避免客户端 JSON.parse 抛原始异常给用户 */
|
||||
async function safeFetch(input: RequestInfo | URL, init?: unknown): Promise<Response> {
|
||||
const res = await fetch(input, init as RequestInit | undefined);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!res.ok && !contentType.includes("application/json")) {
|
||||
const message = res.status >= 500 ? "服务暂时不可用,请稍后重试" : "请求失败,请重试";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
code: -32603,
|
||||
data: { code: "INTERNAL_SERVER_ERROR", httpStatus: res.status },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function createTRPCReactClient() {
|
||||
return trpc.createClient({
|
||||
links: [
|
||||
|
|
@ -15,6 +35,7 @@ export function createTRPCReactClient() {
|
|||
url: "/api/trpc",
|
||||
// 便于服务端日志区分 island 流量
|
||||
headers: () => ({ "x-trpc-source": "react-island" }),
|
||||
fetch: safeFetch,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
// 小写 zh-cn → 规范 zh-CN(保留 rest)
|
||||
const seg = path.split("/").filter(Boolean)[0];
|
||||
if (seg && seg.toLowerCase() === "zh-cn" && seg !== "zh-CN") {
|
||||
const rest = path.slice(seg.length) || "/";
|
||||
// 去掉 "/seg" 本身,剩余为 "/rest"(off-by-one 会产生 /zh-CNn)
|
||||
const rest = path.slice(seg.length + 1) || "/";
|
||||
return context.redirect(`/zh-CN${rest === "/" ? "" : rest}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
---
|
||||
import { COOKIE_LANG, resolvePreferredLocale } from "@app/i18n";
|
||||
import { COOKIE_LANG, isLocale, resolvePreferredLocale } from "@app/i18n";
|
||||
import ErrorViewPage from "~/views/error.astro";
|
||||
|
||||
// Astro 全局 404:按 cookie / Accept-Language 选 locale(无 [locale] 段)
|
||||
const locale = resolvePreferredLocale(
|
||||
Astro.cookies.get(COOKIE_LANG)?.value,
|
||||
Astro.request.headers.get("accept-language"),
|
||||
);
|
||||
// Astro 全局 404:优先跟随 URL 的 locale 段(/zh-CN/* 显示中文),否则按 cookie / Accept-Language
|
||||
const seg = Astro.url.pathname.split("/")[1];
|
||||
const pathLocale = seg && isLocale(seg) ? seg : null;
|
||||
const locale =
|
||||
pathLocale ??
|
||||
resolvePreferredLocale(
|
||||
Astro.cookies.get(COOKIE_LANG)?.value,
|
||||
Astro.request.headers.get("accept-language"),
|
||||
);
|
||||
Astro.response.status = 404;
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
|
||||
import Dashboard from "~/views/dashboard.astro";
|
||||
|
||||
const raw = Astro.params["locale"];
|
||||
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
|
||||
|
|
@ -14,6 +13,8 @@ if (!normalized || !isLocale(normalized)) {
|
|||
}
|
||||
|
||||
const locale: Locale = normalized;
|
||||
---
|
||||
|
||||
<Dashboard locale={locale} />
|
||||
// /dashboard 是旧路由:登录态(middleware 已鉴权)在页面渲染前重定向到媒体库。
|
||||
// 必须在页面层 redirect —— 放在子组件里会与流式渲染冲突抛 ResponseSentError。
|
||||
return Astro.redirect(localePath(locale, "/library"));
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/** 认证后的 Range 流代理:鉴权在本路由,webdav 细节封装在 @app/trpc 的 openRemoteFileStream。 */
|
||||
import type { APIRoute } from "astro";
|
||||
import { COOKIE_SESSION } from "@app/types";
|
||||
import { authService, openRemoteFileStream } from "@app/trpc";
|
||||
import { authService, openRemoteFileStream, StreamSourceError } from "@app/trpc";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
|
|
@ -25,6 +25,9 @@ export const GET: APIRoute = async ({ request, cookies, url }) => {
|
|||
headers: result.headers,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof StreamSourceError) {
|
||||
return new Response(e.message, { status: e.status });
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : "stream failed";
|
||||
return new Response(msg, { status: 502 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,11 @@ export const prerender = false;
|
|||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
// 内部转发到 tRPC handler(同进程内,避免跨网络)
|
||||
// tRPC v11 的 query 走 GET,input 放 query 参数
|
||||
const url = new URL(request.url);
|
||||
url.pathname = "/api/trpc/user.list";
|
||||
const req = new Request(url.toString(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ json: { limit: 20, offset: 0 } }),
|
||||
});
|
||||
url.search = `?input=${encodeURIComponent(JSON.stringify({ limit: 20, offset: 0 }))}`;
|
||||
const req = new Request(url.toString(), { method: "GET" });
|
||||
|
||||
// 调用同一个 Astro 进程的 tRPC endpoint
|
||||
const res = await fetch(req);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function buildFor(locale: Locale): string {
|
|||
summary: t(locale, "home.layerBody"),
|
||||
points: [
|
||||
"多用户:每人独立媒体库与播放进度",
|
||||
"媒体页 shadcn Dashboard 导航:媒体库 / 浏览 / 挂载 / 弹幕设置",
|
||||
"统一左侧边栏导航:首页 / 媒体库 / 文件浏览 / 挂载管理 / 弹幕设置",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
@import "tailwindcss";
|
||||
@import "@app/design-tokens/tokens.css";
|
||||
|
||||
/* 扫描 monorepo 里的 UI 包,避免 dropdown 等组件类未生成 */
|
||||
@source "../../../../packages/ui/src";
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
|
|
@ -8,6 +11,126 @@
|
|||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* 原生控件跟品牌色,避免浏览器默认蓝 */
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
accent-color: var(--edge);
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
|
||||
linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 16px) calc(50% + 2px),
|
||||
calc(100% - 11px) calc(50% + 2px);
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--line);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -5px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--edge);
|
||||
box-shadow: 0 1px 3px color-mix(in srgb, #000 30%, transparent);
|
||||
}
|
||||
input[type="range"]::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--line);
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--edge);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--edge);
|
||||
/* Chrome 部分主题下忽略 accent-color,强制品牌色勾选框 */
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--panel);
|
||||
display: inline-grid;
|
||||
place-content: center;
|
||||
}
|
||||
input[type="checkbox"]::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transform: scale(0);
|
||||
transition: transform 0.12s ease;
|
||||
background: #101110;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
input[type="checkbox"]:checked {
|
||||
background: var(--edge);
|
||||
border-color: var(--edge);
|
||||
}
|
||||
input[type="checkbox"]:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* 空态:居中卡片,避免大片死白 */
|
||||
.u-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 220px;
|
||||
padding: 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 55%, transparent);
|
||||
color: var(--ink-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
.u-empty__title {
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.u-empty__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
scroll-behavior: smooth;
|
||||
|
|
@ -241,19 +364,25 @@ html.has-reveal astro-island .u-reveal {
|
|||
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.u-linelink:hover,
|
||||
.u-linelink:focus-visible {
|
||||
.u-linelink:focus-visible,
|
||||
.u-linelink.is-active {
|
||||
color: var(--ink);
|
||||
}
|
||||
.u-linelink:hover::after,
|
||||
.u-linelink:focus-visible::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
.u-linelink.is-active::after {
|
||||
transform: scaleX(1);
|
||||
height: 2px;
|
||||
}
|
||||
.u-linelink .u-mono {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.u-linelink:hover .u-mono {
|
||||
.u-linelink:hover .u-mono,
|
||||
.u-linelink.is-active .u-mono {
|
||||
color: var(--edge-as-text);
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +431,8 @@ html.has-reveal astro-island .u-reveal {
|
|||
|
||||
/* —— 卡片:5px 圆角 + 轻阴影 —— */
|
||||
.u-card {
|
||||
background: var(--panel);
|
||||
position: relative;
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--ink) 2%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 7px 12px color-mix(in srgb, #000 18%, transparent);
|
||||
|
|
@ -356,6 +486,24 @@ html.has-reveal astro-island .u-reveal {
|
|||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* 流水线读出条 */
|
||||
.u-hud-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||
color: var(--ink-3);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.u-hud-strip .u-mono {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* —— 左侧固定竖栏 —— */
|
||||
.side-rail {
|
||||
display: none;
|
||||
|
|
@ -431,13 +579,27 @@ html.has-reveal astro-island .u-reveal {
|
|||
}
|
||||
.side-rail__link:hover,
|
||||
.side-rail__link:focus-visible {
|
||||
background: color-mix(in srgb, var(--edge) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--edge) 35%, var(--line));
|
||||
background: color-mix(in srgb, var(--edge) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--edge) 30%, var(--line));
|
||||
color: var(--ink);
|
||||
}
|
||||
.side-rail__link:hover .u-mono {
|
||||
.side-rail__link.is-active {
|
||||
background: color-mix(in srgb, var(--edge) 14%, var(--sink));
|
||||
border-color: color-mix(in srgb, var(--edge) 45%, var(--line));
|
||||
color: var(--ink);
|
||||
box-shadow: inset 2px 0 0 var(--edge);
|
||||
}
|
||||
.side-rail__link:hover .u-mono,
|
||||
.side-rail__link:focus-visible .u-mono {
|
||||
color: var(--edge-as-text);
|
||||
}
|
||||
.side-rail__link.is-active .u-mono {
|
||||
color: var(--edge-as-text);
|
||||
}
|
||||
.light .side-rail__link.is-active {
|
||||
background: color-mix(in srgb, var(--edge) 12%, var(--panel));
|
||||
box-shadow: inset 2px 0 0 var(--edge-deep);
|
||||
}
|
||||
.side-rail__foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -471,18 +633,12 @@ html.has-reveal astro-island .u-reveal {
|
|||
padding-left: 8.5rem;
|
||||
padding-right: 3.5rem;
|
||||
}
|
||||
.site-shell--no-rail {
|
||||
padding-left: 3.5rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.site-shell {
|
||||
padding-left: 10.5rem;
|
||||
padding-right: 5rem;
|
||||
}
|
||||
.site-shell--no-rail {
|
||||
padding-left: 5rem;
|
||||
}
|
||||
}
|
||||
.site-shell--page {
|
||||
height: 100dvh;
|
||||
|
|
@ -541,18 +697,25 @@ html.has-reveal astro-island .u-reveal {
|
|||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* 桌面已有侧栏品牌,顶栏品牌隐藏(Tailwind lg:hidden 会被上面的 display:flex 压掉) */
|
||||
@media (min-width: 1024px) {
|
||||
.site-header__brand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.site-header__nav {
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.35rem;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.site-header__nav {
|
||||
display: flex;
|
||||
}
|
||||
.site-header__nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* 桌面已有侧栏时,顶栏可隐藏重复分类导航,仅保留品牌/操作 */
|
||||
/* 桌面已有侧栏时,顶栏隐藏重复分类导航,仅保留品牌/操作 */
|
||||
@media (min-width: 1024px) {
|
||||
.site-header__nav {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { Toaster } from "@app/ui";
|
||||
import AuthForm from "~/components/AuthForm";
|
||||
import AuthBody from "~/components/pages/AuthBody";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
export interface Props {
|
||||
|
|
@ -24,18 +23,16 @@ const titleKey =
|
|||
: mode === "reset"
|
||||
? "auth.reset.title"
|
||||
: "auth.settings.title";
|
||||
|
||||
const pageTitle = `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`;
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={`${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`}
|
||||
title={pageTitle}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{ title: `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
|
||||
seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
|
||||
>
|
||||
<div class="flex min-h-[60vh] items-center justify-center px-4 py-16">
|
||||
<AuthForm client:load locale={locale} mode={mode} next={next} defaultToken={token} />
|
||||
</div>
|
||||
<Toaster client:load />
|
||||
<AuthBody client:load locale={locale} mode={mode} next={next} token={token} />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { Toaster } from "@app/ui";
|
||||
import { AppShell } from "~/components/AppShell";
|
||||
import { BrowseBody } from "~/components/pages/BrowseBody";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -11,22 +10,12 @@ export interface Props {
|
|||
|
||||
const { locale } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"meta.product": t(locale, "meta.product"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "browse.title")}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
hideFooter
|
||||
active="browse"
|
||||
brand={t(locale, "meta.product")}
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
|
|
@ -36,9 +25,6 @@ const labels: Record<string, string> = {
|
|||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<AppShell locale={locale} active="browse" labels={labels}>
|
||||
<BrowseBody locale={locale} client:load />
|
||||
</AppShell>
|
||||
<BrowseBody locale={locale} client:load />
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { Toaster } from "@app/ui";
|
||||
import { AppShell } from "~/components/AppShell";
|
||||
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -11,22 +10,12 @@ export interface Props {
|
|||
|
||||
const { locale } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"meta.product": t(locale, "meta.product"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "danmaku.title")}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
hideFooter
|
||||
active="danmaku"
|
||||
brand={t(locale, "meta.product")}
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
|
|
@ -36,8 +25,6 @@ const labels: Record<string, string> = {
|
|||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<AppShell locale={locale} active="danmaku" labels={labels}>
|
||||
<DanmakuSettingsBody locale={locale} client:load />
|
||||
</AppShell>
|
||||
<DanmakuSettingsBody locale={locale} client:load />
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
---
|
||||
import { alternatePath, type Locale, localePath, t } from "@app/i18n";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
export interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
|
||||
// 产品:/dashboard 即媒体库(保留路由以免旧链接与 e2e 404)
|
||||
return Astro.redirect(localePath(locale, "/library"));
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "dashboard.title")}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{
|
||||
title: `${t(locale, "dashboard.title")} · ${t(locale, "meta.brand")}`,
|
||||
path: Astro.url.pathname,
|
||||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<p class="py-10 text-sm text-muted-foreground">{t(locale, "dashboard.desc")}</p>
|
||||
</AppLayout>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { ErrorView } from "~/components/errors/ErrorView";
|
||||
import ErrorBody from "~/components/pages/ErrorBody";
|
||||
import { createAppError, type AppErrorStatus } from "~/lib/errors";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -12,7 +12,6 @@ export interface Props {
|
|||
const { locale, status } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const error = createAppError(status);
|
||||
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
|
||||
const titleKey = `error.${error.kind}.title` as never;
|
||||
const pageTitle = t(locale, titleKey);
|
||||
---
|
||||
|
|
@ -20,7 +19,6 @@ const pageTitle = t(locale, titleKey);
|
|||
<AppLayout
|
||||
title={`${pageTitle} · ${t(locale, "meta.brand")}`}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{
|
||||
|
|
@ -29,7 +27,5 @@ const pageTitle = t(locale, titleKey);
|
|||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<div class="flex min-h-[55vh] items-center justify-center px-4 py-16">
|
||||
<ErrorView error={error} locale={locale} homeHref={homeHref} />
|
||||
</div>
|
||||
<ErrorBody client:load locale={locale} status={status} />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const switchHref = alternatePath(Astro.url.pathname, otherLocale);
|
|||
<AppLayout
|
||||
title={t(locale, "home.title")}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
active="home"
|
||||
switchHref={switchHref}
|
||||
switchLabel={t(otherLocale, `locale.${otherLocale}` as never)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { Toaster } from "@app/ui";
|
||||
import { AppShell } from "~/components/AppShell";
|
||||
import { LibraryBody } from "~/components/pages/LibraryBody";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -11,22 +10,12 @@ export interface Props {
|
|||
|
||||
const { locale } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"meta.product": t(locale, "meta.product"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "library.title")}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
hideFooter
|
||||
active="library"
|
||||
brand={t(locale, "meta.product")}
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
|
|
@ -36,8 +25,6 @@ const labels: Record<string, string> = {
|
|||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<AppShell locale={locale} active="library" labels={labels}>
|
||||
<LibraryBody locale={locale} client:load />
|
||||
</AppShell>
|
||||
<LibraryBody locale={locale} client:load />
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { Toaster } from "@app/ui";
|
||||
import { AppShell } from "~/components/AppShell";
|
||||
import { MountsBody } from "~/components/pages/MountsBody";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -11,22 +10,12 @@ export interface Props {
|
|||
|
||||
const { locale } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"meta.product": t(locale, "meta.product"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "mounts.title")}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
hideFooter
|
||||
active="mounts"
|
||||
brand={t(locale, "meta.product")}
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
|
|
@ -36,8 +25,6 @@ const labels: Record<string, string> = {
|
|||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<AppShell locale={locale} active="mounts" labels={labels}>
|
||||
<MountsBody locale={locale} client:load />
|
||||
</AppShell>
|
||||
<MountsBody locale={locale} client:load />
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { Toaster } from "@app/ui";
|
||||
import { AppShell } from "~/components/AppShell";
|
||||
import { WatchBody } from "~/components/pages/WatchBody";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -12,23 +11,13 @@ export interface Props {
|
|||
|
||||
const { locale, mediaItemId } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"meta.product": t(locale, "meta.product"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "watch.title")}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
hideFooter
|
||||
brand={t(locale, "meta.product")}
|
||||
hideFooter
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{
|
||||
|
|
@ -37,8 +26,6 @@ const labels: Record<string, string> = {
|
|||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<AppShell locale={locale} active="watch" labels={labels}>
|
||||
<WatchBody locale={locale} mediaItemId={mediaItemId} client:load />
|
||||
</AppShell>
|
||||
<WatchBody locale={locale} mediaItemId={mediaItemId} client:load />
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 665 KiB |
|
|
@ -35,6 +35,8 @@
|
|||
"includes": [
|
||||
"**",
|
||||
"!**/storybook-static",
|
||||
"!**/.vitepress/cache",
|
||||
"!**/.vitepress/dist",
|
||||
"!**/dist",
|
||||
"!**/.turbo",
|
||||
"!**/node_modules",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 在 `app-template` 产品化全栈上实现多用户 Web 媒体中心:WebDAV 挂载(含 OpenList)→ 扫描/Bangumi 刮削 → ArtPlayer 播放 + 弹幕(开放网络中继/本地 XML)→ 进度与 shadcn 风格 dashboard UI。
|
||||
**目标:** 在 本产品仓 产品化全栈上实现多用户 Web 媒体中心:WebDAV 挂载(含 OpenList)→ 扫描/Bangumi 刮削 → ArtPlayer 播放 + 弹幕(开放网络中继/本地 XML)→ 进度与 shadcn 风格 dashboard UI。
|
||||
|
||||
**架构:** 方案 A 紧凑垂直切片 `media`:types Zod → models 表 → dao → trpc service/router(挂载代理、stream、刮削、弹幕、库、进度)→ `@app/web` 薄 SSR + React islands。浏览器不持网盘密码;流经 `/api/stream` Range 代理。
|
||||
|
||||
|
|
@ -1848,7 +1848,7 @@ export function AppShell(props: {
|
|||
<div className="flex min-h-screen bg-background text-foreground">
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
|
||||
<div className="mb-6 px-2 text-sm font-semibold tracking-wide">
|
||||
{props.labels["meta.brand"] ?? "弹弹 Web"}
|
||||
{props.labels["meta.brand"] ?? "帆幕"}
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1" aria-label="sidebar">
|
||||
{NAV.map((item) => (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
- **日期**:2026-09-25
|
||||
- **状态**:已获设计批准,待实现计划(writing-plans)
|
||||
- **仓库**:`app-template`(按**产品仓**维护,业务进入现有分层)
|
||||
- **仓库**:本产品仓(按**产品仓**维护,业务进入现有分层)
|
||||
|
||||
## 1. 背景与调研结论
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -0,0 +1,25 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("GET /api/users 转发 tRPC user.list 返回用户列表", async ({ request }) => {
|
||||
const res = await request.get("/api/users");
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body?.result?.data?.users)).toBe(true);
|
||||
// 安全:任何对外出口不得泄露 passwordHash
|
||||
expect(JSON.stringify(body)).not.toContain("passwordHash");
|
||||
});
|
||||
|
||||
test("tRPC user.list 直接调用不返回 passwordHash", async ({ request }) => {
|
||||
const input = encodeURIComponent(JSON.stringify({ limit: 20, offset: 0 }));
|
||||
const res = await request.get(`/api/trpc/user.list?input=${input}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(JSON.stringify(await res.json())).not.toContain("passwordHash");
|
||||
});
|
||||
|
||||
test("tRPC HTTP 适配可调用", async ({ request }) => {
|
||||
const res = await request.get("/api/trpc/user.list");
|
||||
// 未登录/无输入时允许 200 或 4xx,但服务必须可达且返回 JSON
|
||||
expect(res.headers()["content-type"]).toContain("application/json");
|
||||
const body = await res.text();
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("未登录访问 /zh-CN/dashboard 跳转登录并带 next", async ({ page }) => {
|
||||
await page.goto("/zh-CN/dashboard");
|
||||
await expect(page).toHaveURL(/\/zh-CN\/auth\/login\?next=/);
|
||||
await expect(page.getByRole("heading", { name: "登录" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("未登录访问 /en/library 跳转英文登录页", async ({ page }) => {
|
||||
await page.goto("/en/library");
|
||||
await expect(page).toHaveURL(/\/en\/auth\/login\?next=/);
|
||||
});
|
||||
|
||||
test("登录页渲染用户名/密码表单", async ({ page }) => {
|
||||
await page.goto("/zh-CN/auth/login");
|
||||
await expect(page.getByLabel("用户名")).toBeVisible();
|
||||
await expect(page.getByLabel("密码")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("注册页可达", async ({ page }) => {
|
||||
const res = await page.goto("/zh-CN/auth/register");
|
||||
expect(res?.status()).toBe(200);
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("未知路径返回 404 页面", async ({ page }) => {
|
||||
const res = await page.goto("/zh-CN/definitely-not-a-page");
|
||||
expect(res?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("裸非法路径视为非法 locale 前缀,302 到本地化首页", async ({ request }) => {
|
||||
const res = await request.get("/no-such-page", {
|
||||
headers: { "accept-language": "zh-CN" },
|
||||
maxRedirects: 0,
|
||||
});
|
||||
expect(res.status()).toBe(302);
|
||||
expect(res.headers()["location"]).toBe("/zh-CN");
|
||||
});
|
||||
|
|
@ -3,8 +3,8 @@ import { expect, test } from "@playwright/test";
|
|||
test("中文首页渲染 hero 与 CTA", async ({ page }) => {
|
||||
await page.goto("/zh-CN");
|
||||
const h1 = page.getByRole("heading", { level: 1 });
|
||||
await expect(h1).toContainText("本地片源");
|
||||
await expect(h1).toContainText("在线弹幕");
|
||||
await expect(h1).toContainText("私有影音库");
|
||||
await expect(h1).toContainText("弹幕观影");
|
||||
await expect(page.getByRole("link", { name: "登录 / 注册" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "打开媒体库" })).toBeVisible();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("裸 / 重定向", () => {
|
||||
test("默认 Accept-Language (en-US) 跳 /en", async ({ request }) => {
|
||||
const res = await request.get("/", {
|
||||
headers: { "accept-language": "en-US" },
|
||||
maxRedirects: 0,
|
||||
});
|
||||
expect(res.status()).toBeGreaterThanOrEqual(300);
|
||||
expect(res.status()).toBeLessThan(400);
|
||||
expect(res.headers()["location"]).toContain("/en");
|
||||
});
|
||||
|
||||
test("Accept-Language zh-CN 跳 /zh-CN", async ({ request }) => {
|
||||
const res = await request.get("/", {
|
||||
headers: { "accept-language": "zh-CN,zh;q=0.9" },
|
||||
maxRedirects: 0,
|
||||
});
|
||||
expect(res.status()).toBeGreaterThanOrEqual(300);
|
||||
expect(res.status()).toBeLessThan(400);
|
||||
expect(res.headers()["location"]).toContain("/zh-CN");
|
||||
});
|
||||
|
||||
test("cookie app-lang 优先于 Accept-Language", async ({ request }) => {
|
||||
const res = await request.get("/", {
|
||||
headers: {
|
||||
cookie: "app-lang=zh-CN",
|
||||
"accept-language": "en-US",
|
||||
},
|
||||
maxRedirects: 0,
|
||||
});
|
||||
expect(res.headers()["location"]).toContain("/zh-CN");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("locale 规范化", () => {
|
||||
test("小写 /zh-cn 重定向到 /zh-CN", async ({ request }) => {
|
||||
const res = await request.get("/zh-cn", { maxRedirects: 0 });
|
||||
expect(res.status()).toBeGreaterThanOrEqual(300);
|
||||
expect(res.status()).toBeLessThan(400);
|
||||
expect(res.headers()["location"]).toBe("/zh-CN");
|
||||
});
|
||||
});
|
||||
|
||||
test("header 语言切换链接跳到另一语言", async ({ page }) => {
|
||||
await page.goto("/zh-CN");
|
||||
const switchLink = page.locator('[data-switch-locale="en"]');
|
||||
await expect(switchLink).toBeVisible();
|
||||
await switchLink.click();
|
||||
await expect(page).toHaveURL(/\/en$/);
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "app-template",
|
||||
"name": "fanmu",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
|
|
@ -16,7 +16,11 @@
|
|||
"db:generate": "turbo run db:generate",
|
||||
"db:push": "turbo run db:push",
|
||||
"db:studio": "turbo run db:studio",
|
||||
"db:seed": "yarn workspace @app/db seed",
|
||||
"docs:dev": "yarn workspace @app/docs dev",
|
||||
"docs:build": "yarn workspace @app/docs build",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "turbo run lint",
|
||||
"clean": "turbo run clean",
|
||||
"format": "turbo run format",
|
||||
|
|
@ -33,6 +37,7 @@
|
|||
"@commitlint/cli": "^21.2.3",
|
||||
"@commitlint/config-conventional": "^21.2.3",
|
||||
"@emnapi/runtime": "^1.11.3",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@storybook/addon-a11y": "^9.1.10",
|
||||
"@storybook/addon-docs": "^9.1.10",
|
||||
"@storybook/react": "^9.1.10",
|
||||
|
|
|
|||
|
|
@ -2,16 +2,20 @@
|
|||
|
||||
## 职责
|
||||
|
||||
**数据访问层(DAO)**:封装 Drizzle 查询(`userDao` / `sessionDao` / `passwordResetDao`)。service 只调本包,不直接 `db.select`。不含业务规则、HTTP/tRPC。
|
||||
**数据访问层(DAO)**:封装 Drizzle 查询。service 只调本包,不直接 `db.select`。不含业务规则、HTTP/tRPC。
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # 出口:daos + 行类型
|
||||
users.ts # userDao
|
||||
sessions.ts # sessionDao
|
||||
password-resets.ts # passwordResetDao
|
||||
index.ts # 出口:daos + 行类型
|
||||
users.ts # userDao
|
||||
sessions.ts # sessionDao
|
||||
password-resets.ts # passwordResetDao
|
||||
mounts.ts # mountDao
|
||||
media-items.ts # mediaItemDao
|
||||
playback-progress.ts # playbackProgressDao
|
||||
danmaku-cache.ts # danmakuCacheDao
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
|
@ -21,15 +25,17 @@ src/
|
|||
|
||||
## 约定
|
||||
|
||||
- 一表一个 dao 对象;方法名 `list` / `getById` / `create` / `update` / `delete`。
|
||||
- 一表一个 dao 对象;方法名 `list` / `getById` / `create` / `update` / `delete`(媒体域可加 `getByPath`、`upsert` 等,但保持动词清晰)。
|
||||
- `exactOptionalPropertyTypes`:update 入参用 `Partial<Omit<Row, "id" | "createdAt" | "updatedAt">>`。
|
||||
- `noUncheckedIndexedAccess`:`returning()[0]` 判空后抛错或 `?? null`。
|
||||
- 不读 env、不建 client;环境变量校验在 `@app/db`。
|
||||
- 分页/过滤等查询参数类型一并从本包导出(如 `ListUsersParams`)。
|
||||
|
||||
## 修改指南
|
||||
|
||||
- 加查询:新建或扩展对应 `*.ts`,在 `index.ts` 导出。
|
||||
- 新表:先改 `@app/models` → `yarn db:generate`/`db:push` → 本包加 dao。
|
||||
- 媒体域查询注意 `mediaItems` 唯一键含 `mountId`,按路径查找时必须带挂载维度。
|
||||
|
||||
## 命令
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { and, desc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { mediaItems, type MediaItem } from "@app/models";
|
||||
import { and, asc, desc, eq, inArray, isNotNull, like, or, sql } from "drizzle-orm";
|
||||
import { mediaItems, mounts, type MediaItem } from "@app/models";
|
||||
import { db } from "@app/db";
|
||||
|
||||
export type MediaItemRow = MediaItem;
|
||||
|
|
@ -10,11 +10,13 @@ export const mediaItemDao = {
|
|||
params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
mountId?: string | undefined;
|
||||
scrapeStatus?: string | undefined;
|
||||
q?: string | undefined;
|
||||
},
|
||||
): Promise<{ rows: MediaItemRow[]; total: number }> {
|
||||
const conds = [eq(mediaItems.userId, userId)];
|
||||
if (params.mountId) conds.push(eq(mediaItems.mountId, params.mountId));
|
||||
if (params.scrapeStatus) conds.push(eq(mediaItems.scrapeStatus, params.scrapeStatus));
|
||||
if (params.q) {
|
||||
const likeQ = `%${params.q}%`;
|
||||
|
|
@ -35,6 +37,143 @@ export const mediaItemDao = {
|
|||
.where(where);
|
||||
return { rows, total: Number(countRow?.["count"] ?? 0) };
|
||||
},
|
||||
/**
|
||||
* 海报墙分组分页:指定挂载下已刮削条目按 bangumiId 分组。
|
||||
* q 命中任一集即收入该组,但 episodeCount 始终为该组全量集数(与列表筛选「已刮削」对得上)。
|
||||
*/
|
||||
async listScrapedGroups(
|
||||
userId: string,
|
||||
params: {
|
||||
mountId: string;
|
||||
q?: string | undefined;
|
||||
limit: number;
|
||||
offset: number;
|
||||
},
|
||||
): Promise<{
|
||||
rows: {
|
||||
bangumiId: number;
|
||||
title: string;
|
||||
posterUrl: string | null;
|
||||
episodeCount: number;
|
||||
updatedAt: Date;
|
||||
}[];
|
||||
total: number;
|
||||
}> {
|
||||
const baseConds = [
|
||||
eq(mediaItems.userId, userId),
|
||||
eq(mediaItems.mountId, params.mountId),
|
||||
eq(mediaItems.scrapeStatus, "ok"),
|
||||
isNotNull(mediaItems.bangumiId),
|
||||
];
|
||||
const fullWhere = and(...baseConds);
|
||||
|
||||
// 先找出 q 命中的作品 id;命中集合决定「有哪些组」,不缩小组内集数
|
||||
let matchIds: number[] | null = null;
|
||||
if (params.q) {
|
||||
const likeQ = `%${params.q}%`;
|
||||
const qCond = or(like(mediaItems.title, likeQ), like(mediaItems.rawName, likeQ));
|
||||
const matched = await db
|
||||
.select({ bangumiId: mediaItems.bangumiId })
|
||||
.from(mediaItems)
|
||||
.where(and(...baseConds, qCond))
|
||||
.groupBy(mediaItems.bangumiId);
|
||||
matchIds = matched.map((r) => r.bangumiId).filter((x): x is number => x != null);
|
||||
if (matchIds.length === 0) return { rows: [], total: 0 };
|
||||
}
|
||||
|
||||
const scopeWhere =
|
||||
matchIds == null ? fullWhere : and(fullWhere, inArray(mediaItems.bangumiId, matchIds));
|
||||
|
||||
// rn=1 即每组 updated_at 最新的一行,其 title/posterUrl 就代表该组
|
||||
const ranked = db
|
||||
.select({
|
||||
bangumiId: mediaItems.bangumiId,
|
||||
title: mediaItems.title,
|
||||
posterUrl: mediaItems.posterUrl,
|
||||
updatedAt: mediaItems.updatedAt,
|
||||
rn: sql<number>`row_number() over (partition by ${mediaItems.bangumiId} order by ${mediaItems.updatedAt} desc, ${mediaItems.id} desc)`.as(
|
||||
"rn",
|
||||
),
|
||||
episodeCount: sql<number>`count(*) over (partition by ${mediaItems.bangumiId})`.as(
|
||||
"episode_count",
|
||||
),
|
||||
})
|
||||
.from(mediaItems)
|
||||
.where(scopeWhere)
|
||||
.as("ranked");
|
||||
const pageRows = await db
|
||||
.select({
|
||||
bangumiId: ranked.bangumiId,
|
||||
title: ranked.title,
|
||||
posterUrl: ranked.posterUrl,
|
||||
episodeCount: ranked.episodeCount,
|
||||
updatedAt: ranked.updatedAt,
|
||||
})
|
||||
.from(ranked)
|
||||
.where(eq(ranked.rn, 1))
|
||||
.orderBy(desc(ranked.updatedAt), desc(ranked.bangumiId))
|
||||
.limit(params.limit)
|
||||
.offset(params.offset);
|
||||
const total =
|
||||
matchIds != null
|
||||
? matchIds.length
|
||||
: await this.countScrapedSeries(userId, params.mountId);
|
||||
const rows: {
|
||||
bangumiId: number;
|
||||
title: string;
|
||||
posterUrl: string | null;
|
||||
episodeCount: number;
|
||||
updatedAt: Date;
|
||||
}[] = [];
|
||||
for (const row of pageRows) {
|
||||
const bid = row.bangumiId;
|
||||
if (bid == null) continue;
|
||||
rows.push({
|
||||
bangumiId: bid,
|
||||
title: row.title,
|
||||
posterUrl: row.posterUrl,
|
||||
episodeCount: Number(row.episodeCount),
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
return { rows, total };
|
||||
},
|
||||
async countScrapedSeries(userId: string, mountId: string): Promise<number> {
|
||||
const [countRow] = await db
|
||||
.select({ count: sql<number>`count(distinct ${mediaItems.bangumiId})` })
|
||||
.from(mediaItems)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaItems.userId, userId),
|
||||
eq(mediaItems.mountId, mountId),
|
||||
eq(mediaItems.scrapeStatus, "ok"),
|
||||
isNotNull(mediaItems.bangumiId),
|
||||
),
|
||||
);
|
||||
return Number(countRow?.["count"] ?? 0);
|
||||
},
|
||||
/** 路径前缀下的条目(文件夹级批量刮削/指定用);精确目录边界,避免 /Foo 误吞 /FooBar */
|
||||
async listByPathPrefix(userId: string, mountId: string, path: string): Promise<MediaItemRow[]> {
|
||||
const normPrefix = path.replace(/\/+$/, "") || "/";
|
||||
const base = [eq(mediaItems.userId, userId), eq(mediaItems.mountId, mountId)];
|
||||
if (normPrefix === "/") {
|
||||
return db
|
||||
.select()
|
||||
.from(mediaItems)
|
||||
.where(and(...base))
|
||||
.orderBy(asc(mediaItems.path));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(mediaItems)
|
||||
.where(
|
||||
and(
|
||||
...base,
|
||||
or(eq(mediaItems.path, normPrefix), like(mediaItems.path, `${normPrefix}/%`)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(mediaItems.path));
|
||||
},
|
||||
async getByIdForUser(id: string, userId: string): Promise<MediaItemRow | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
|
|
@ -43,6 +182,20 @@ export const mediaItemDao = {
|
|||
.limit(1);
|
||||
return row ?? null;
|
||||
},
|
||||
/** 同作品(bangumiId)剧集,按集数升序;mountId 可选,库内详情只看该挂载 */
|
||||
async listByBangumiId(
|
||||
userId: string,
|
||||
bangumiId: number,
|
||||
mountId?: string | undefined,
|
||||
): Promise<MediaItemRow[]> {
|
||||
const conds = [eq(mediaItems.userId, userId), eq(mediaItems.bangumiId, bangumiId)];
|
||||
if (mountId) conds.push(eq(mediaItems.mountId, mountId));
|
||||
return db
|
||||
.select()
|
||||
.from(mediaItems)
|
||||
.where(and(...conds))
|
||||
.orderBy(asc(mediaItems.epNumber), asc(mediaItems.rawName));
|
||||
},
|
||||
async getByUserPath(
|
||||
userId: string,
|
||||
mountId: string,
|
||||
|
|
@ -107,6 +260,8 @@ export const mediaItemDao = {
|
|||
userId: string,
|
||||
data: {
|
||||
bangumiId?: number | null;
|
||||
dandanplayEpisodeId?: number | null;
|
||||
matchedHash?: string | null;
|
||||
epNumber?: number | null;
|
||||
scrapeStatus: string;
|
||||
scrapedAt?: Date | null;
|
||||
|
|
@ -121,4 +276,37 @@ export const mediaItemDao = {
|
|||
.returning();
|
||||
return row ?? null;
|
||||
},
|
||||
/** 弹幕识别身份落库(owner 校验):episodeId + 识别用文件指纹,后续播放免重 match */
|
||||
async setDanmakuMatch(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: { episodeId: number; matchedHash?: string | null },
|
||||
): Promise<MediaItemRow | null> {
|
||||
const [row] = await db
|
||||
.update(mediaItems)
|
||||
.set({
|
||||
dandanplayEpisodeId: data.episodeId,
|
||||
matchedHash: data.matchedHash ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId)))
|
||||
.returning();
|
||||
return row ?? null;
|
||||
},
|
||||
/** 删除挂载下的全部条目(挂载删除级联),返回被删条目 id 供清理进度 */
|
||||
async deleteByMount(mountId: string, userId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.delete(mediaItems)
|
||||
.where(and(eq(mediaItems.mountId, mountId), eq(mediaItems.userId, userId)))
|
||||
.returning({ id: mediaItems.id });
|
||||
return rows.map((r) => r.id);
|
||||
},
|
||||
/** 清理挂载已不存在的孤儿条目(历史脏数据兜底),返回删除数量 */
|
||||
async cleanupOrphans(): Promise<number> {
|
||||
const rows = await db
|
||||
.delete(mediaItems)
|
||||
.where(sql`${mediaItems.mountId} not in (select id from ${mounts})`)
|
||||
.returning({ id: mediaItems.id });
|
||||
return rows.length;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { playbackProgress, type PlaybackProgress } from "@app/models";
|
||||
import { and, desc, eq, gt, inArray, lt, or, sql } from "drizzle-orm";
|
||||
import { mediaItems, playbackProgress, type PlaybackProgress } from "@app/models";
|
||||
import { db } from "@app/db";
|
||||
|
||||
export type PlaybackProgressRow = PlaybackProgress;
|
||||
|
||||
export const playbackProgressDao = {
|
||||
async get(userId: string, mediaItemId: string): Promise<PlaybackProgress | null> {
|
||||
const [row] = await db
|
||||
|
|
@ -16,6 +18,24 @@ export const playbackProgressDao = {
|
|||
.limit(1);
|
||||
return row ?? null;
|
||||
},
|
||||
/** 未看完的最近进度(positionMs>0 且未接近结尾),按更新时间倒序 */
|
||||
async listUnfinished(userId: string, limit: number): Promise<PlaybackProgress[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(playbackProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(playbackProgress.userId, userId),
|
||||
gt(playbackProgress.positionMs, 0),
|
||||
or(
|
||||
eq(playbackProgress.durationMs, 0),
|
||||
lt(playbackProgress.positionMs, playbackProgress.durationMs),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(playbackProgress.updatedAt))
|
||||
.limit(limit);
|
||||
},
|
||||
async upsert(
|
||||
userId: string,
|
||||
mediaItemId: string,
|
||||
|
|
@ -40,4 +60,17 @@ export const playbackProgressDao = {
|
|||
if (!row) throw new Error("Failed to insert progress");
|
||||
return row;
|
||||
},
|
||||
/** 删除指定媒体条目的进度(挂载/条目删除级联) */
|
||||
async deleteByMediaItems(mediaItemIds: string[]): Promise<void> {
|
||||
if (mediaItemIds.length === 0) return;
|
||||
await db
|
||||
.delete(playbackProgress)
|
||||
.where(inArray(playbackProgress.mediaItemId, mediaItemIds));
|
||||
},
|
||||
/** 清理媒体条目已不存在的孤儿进度(历史脏数据兜底) */
|
||||
async cleanupOrphans(): Promise<void> {
|
||||
await db
|
||||
.delete(playbackProgress)
|
||||
.where(sql`${playbackProgress.mediaItemId} not in (select id from ${mediaItems})`);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,15 @@ export const userDao = {
|
|||
return row ?? null;
|
||||
},
|
||||
|
||||
async getByUsername(username: string): Promise<UserRow | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username.toLowerCase()))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
async getByEmail(email: string): Promise<UserRow | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
|
|
@ -36,9 +45,17 @@ export const userDao = {
|
|||
},
|
||||
|
||||
async create(
|
||||
data: Omit<UserRow, "id" | "createdAt" | "updatedAt"> & { id?: string },
|
||||
data: Omit<UserRow, "id" | "createdAt" | "updatedAt" | "username" | "email"> & {
|
||||
id?: string;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
},
|
||||
): Promise<UserRow> {
|
||||
const payload = { ...data, email: data.email.toLowerCase() };
|
||||
const payload = {
|
||||
...data,
|
||||
username: data.username.toLowerCase(),
|
||||
email: data.email ? data.email.toLowerCase() : null,
|
||||
};
|
||||
const rows = await db.insert(users).values(payload).returning();
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("Failed to create user");
|
||||
|
|
@ -50,7 +67,10 @@ export const userDao = {
|
|||
data: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">>,
|
||||
): Promise<UserRow | null> {
|
||||
const payload: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">> = { ...data };
|
||||
if (payload.email !== undefined) payload.email = payload.email.toLowerCase();
|
||||
if (payload.username !== undefined) payload.username = payload.username.toLowerCase();
|
||||
if (payload.email !== undefined) {
|
||||
payload.email = payload.email ? payload.email.toLowerCase() : null;
|
||||
}
|
||||
const [row] = await db
|
||||
.update(users)
|
||||
.set({ ...payload, updatedAt: new Date() })
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 职责
|
||||
|
||||
创建并导出 **Drizzle + libSQL 客户端单例**,以及 drizzle-kit 配置。查询在 `@app/dao`,业务在 `@app/trpc` service。
|
||||
创建并导出 **Drizzle + libSQL 客户端单例**,以及 drizzle-kit 配置与种子脚本。查询在 `@app/dao`,业务在 `@app/trpc` service。
|
||||
|
||||
## 结构
|
||||
|
||||
|
|
@ -10,6 +10,8 @@
|
|||
src/
|
||||
client.ts # export const db = drizzle(client, { schema })
|
||||
index.ts # 对外 re-export
|
||||
scripts/
|
||||
seed.ts # 幂等初始化默认管理员(yarn db:seed)
|
||||
drizzle.config.ts # dialect: turso;schema 指向 ../models/dist/index.js
|
||||
```
|
||||
|
||||
|
|
@ -18,8 +20,14 @@ drizzle.config.ts # dialect: turso;schema 指向 ../models/dist/index.js
|
|||
- 读环境变量:`TURSO_DATABASE_URL`(必填,缺失则启动抛错)、`TURSO_AUTH_TOKEN`(可选)。
|
||||
- 本地开发常用 `TURSO_DATABASE_URL=file:local.db`。
|
||||
- `schema` 来自 `@app/models`——**模型定义请去 models 改**,本包只聚合导入。
|
||||
- **不写查询**;SQL/Drizzle query 在 `@app/dao`。
|
||||
- `drizzle.config.ts` 会手动解析根目录 `.env`(monorepo 下 drizzle-kit 不自动加载)。改 env 路径时两处保持一致。
|
||||
- **不写业务查询**;SQL/Drizzle query 在 `@app/dao`。
|
||||
- `drizzle.config.ts` 与 `scripts/seed.ts` 都会手动解析根目录 `.env`(monorepo 下 drizzle-kit / tsx 不自动加载)。改 env 路径时保持一致。
|
||||
|
||||
## seed 脚本
|
||||
|
||||
- `yarn db:seed`:创建默认管理员(用户名 + 密码),已存在同名用户则跳过(幂等)。
|
||||
- 密码哈希格式与 `@app/trpc` 的 `password.ts` 一致(scrypt `salt:hash` hex)——改哈希算法时两处同步。
|
||||
- 脚本可直接依赖 `@app/models` 与 `@libsql/client`,但不要在 `src/` 里塞业务逻辑。
|
||||
|
||||
## 修改指南
|
||||
|
||||
|
|
@ -34,5 +42,6 @@ yarn workspace @app/db typecheck
|
|||
yarn workspace @app/db build
|
||||
yarn db:generate
|
||||
yarn db:push
|
||||
yarn db:seed
|
||||
yarn db:studio
|
||||
```
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
"lint": "biome check .",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"seed": "tsx scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/models": "workspace:*",
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^22.15.29",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Seed:初始化默认管理员(用户名 + 密码登录)。
|
||||
* 用法:`yarn db:seed`(幂等:已存在同名用户则跳过)。
|
||||
* 密码哈希格式与 `@app/trpc` 的 `password.ts` 一致(scrypt `salt:hash` hex)。
|
||||
*/
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import * as schema from "@app/models";
|
||||
|
||||
// monorepo 根 .env(tsx/node 不会自动加载)
|
||||
try {
|
||||
const rootEnv = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env");
|
||||
const env = readFileSync(rootEnv, "utf-8");
|
||||
for (const line of env.split("\n")) {
|
||||
const [key, ...vals] = line.split("=");
|
||||
if (key && !key.startsWith("#")) {
|
||||
process.env[key.trim()] ??= vals.join("=").trim();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const url = process.env["TURSO_DATABASE_URL"];
|
||||
if (!url) throw new Error("TURSO_DATABASE_URL is required");
|
||||
|
||||
const clientConfig: { url: string; authToken?: string } = { url };
|
||||
const token = process.env["TURSO_AUTH_TOKEN"];
|
||||
if (token) clientConfig.authToken = token;
|
||||
|
||||
const client = createClient(clientConfig);
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(password, salt, 64).toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
const ADMIN_USERNAME = (process.env["SEED_ADMIN_USERNAME"] ?? "admin").toLowerCase();
|
||||
const ADMIN_PASSWORD = process.env["SEED_ADMIN_PASSWORD"] ?? "admin123";
|
||||
const ADMIN_NAME = process.env["SEED_ADMIN_NAME"] ?? "Admin";
|
||||
const ADMIN_EMAIL = process.env["SEED_ADMIN_EMAIL"] ?? "admin@example.com";
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
const { users } = schema;
|
||||
const existing = await db
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.username, ADMIN_USERNAME))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
console.log(`[seed] admin 已存在(username=${ADMIN_USERNAME}),跳过创建`);
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(users).values({
|
||||
username: ADMIN_USERNAME,
|
||||
name: ADMIN_NAME,
|
||||
email: ADMIN_EMAIL.toLowerCase(),
|
||||
passwordHash: hashPassword(ADMIN_PASSWORD),
|
||||
});
|
||||
|
||||
console.log(`[seed] 已创建管理员用户 username=${ADMIN_USERNAME} password=${ADMIN_PASSWORD}`);
|
||||
console.log("[seed] 生产环境请通过 SEED_ADMIN_PASSWORD 覆盖默认密码,并尽快登录后修改");
|
||||
}
|
||||
|
||||
seed()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error("[seed] 失败:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -7,5 +7,5 @@
|
|||
"@app/models": ["../models/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,27 @@
|
|||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
|
||||
/* shadcn 工具类映射(bg-card / bg-popover / text-muted-foreground …) */
|
||||
--color-background: var(--paper);
|
||||
--color-foreground: var(--ink);
|
||||
--color-card: var(--panel);
|
||||
--color-card-foreground: var(--ink);
|
||||
--color-popover: var(--panel);
|
||||
--color-popover-foreground: var(--ink);
|
||||
--color-primary: var(--edge);
|
||||
--color-primary-foreground: #101110;
|
||||
--color-muted: var(--sink);
|
||||
--color-muted-foreground: var(--ink-2);
|
||||
--color-border: var(--line);
|
||||
--color-input: var(--line);
|
||||
--color-ring: var(--edge);
|
||||
--color-accent: var(--sink);
|
||||
--color-accent-foreground: var(--ink);
|
||||
--color-secondary: var(--line-strong);
|
||||
--color-secondary-foreground: var(--ink);
|
||||
--color-destructive: var(--err);
|
||||
--color-destructive-foreground: #f2f2f2;
|
||||
}
|
||||
|
||||
/* 终末地设计语义
|
||||
|
|
@ -28,11 +49,11 @@
|
|||
--ink-2: #a3a6a1;
|
||||
--ink-3: #7a7e78;
|
||||
|
||||
--edge: #fffa00;
|
||||
--edge-rgb: 255, 250, 0;
|
||||
--edge-deep: #e6e100;
|
||||
--edge-on-paper: #fffa00;
|
||||
--edge-as-text: #fffa00;
|
||||
--edge: #ffb300;
|
||||
--edge-rgb: 255, 179, 0;
|
||||
--edge-deep: #e09a00;
|
||||
--edge-on-paper: #ffb300;
|
||||
--edge-as-text: #ffb300;
|
||||
|
||||
--sig-green: #00ffa2;
|
||||
--sig-magenta: #ff1aac;
|
||||
|
|
@ -45,17 +66,20 @@
|
|||
--warn: #ffb700;
|
||||
|
||||
--hazard: repeating-linear-gradient(-45deg, #191919 0 8px, #0e0f0e 8px 16px);
|
||||
--hazard-edge: repeating-linear-gradient(-45deg, #fffa00 0 8px, #0e0f0e 8px 16px);
|
||||
--hazard-edge: repeating-linear-gradient(-45deg, #ffb300 0 8px, #0e0f0e 8px 16px);
|
||||
|
||||
--color-background: var(--paper);
|
||||
--color-foreground: var(--ink);
|
||||
--color-card: var(--panel);
|
||||
--color-card-foreground: var(--ink);
|
||||
--color-popover: var(--panel);
|
||||
--color-popover-foreground: var(--ink);
|
||||
--color-primary: var(--edge);
|
||||
--color-primary-foreground: #101110;
|
||||
--color-muted: var(--sink);
|
||||
--color-muted-foreground: var(--ink-2);
|
||||
--color-border: var(--line);
|
||||
--color-input: var(--line);
|
||||
--color-ring: var(--edge);
|
||||
--color-accent: var(--sink);
|
||||
--color-accent-foreground: var(--ink);
|
||||
|
|
@ -78,11 +102,11 @@
|
|||
--ink-2: #4a4d48;
|
||||
--ink-3: #5c5f5a;
|
||||
|
||||
--edge: #fffa00;
|
||||
--edge-rgb: 255, 250, 0;
|
||||
--edge-deep: #e6e100;
|
||||
--edge-on-paper: #d4cf00;
|
||||
--edge-as-text: #6b6500;
|
||||
--edge: #ffb300;
|
||||
--edge-rgb: 255, 179, 0;
|
||||
--edge-deep: #e09a00;
|
||||
--edge-on-paper: #b88600;
|
||||
--edge-as-text: #7a5c00;
|
||||
|
||||
--sig-green: #00c982;
|
||||
--sig-magenta: #e01090;
|
||||
|
|
@ -95,17 +119,20 @@
|
|||
--warn: #d9822b;
|
||||
|
||||
--hazard: repeating-linear-gradient(-45deg, #e5e5e5 0 8px, #f2f2f2 8px 16px);
|
||||
--hazard-edge: repeating-linear-gradient(-45deg, #fffa00 0 8px, #191919 8px 16px);
|
||||
--hazard-edge: repeating-linear-gradient(-45deg, #ffb300 0 8px, #191919 8px 16px);
|
||||
|
||||
--color-background: var(--paper);
|
||||
--color-foreground: var(--ink);
|
||||
--color-card: var(--panel);
|
||||
--color-card-foreground: var(--ink);
|
||||
--color-popover: var(--panel);
|
||||
--color-popover-foreground: var(--ink);
|
||||
--color-primary: var(--edge);
|
||||
--color-primary-foreground: #101110;
|
||||
--color-muted: var(--sink);
|
||||
--color-muted-foreground: var(--ink-2);
|
||||
--color-border: var(--line);
|
||||
--color-input: var(--line);
|
||||
--color-ring: var(--edge);
|
||||
--color-accent: var(--sink);
|
||||
--color-accent-foreground: var(--ink);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ src/
|
|||
index.ts
|
||||
```
|
||||
|
||||
key 按域分组(`<domain>.<name>`):`nav` / `home` / `dashboard` / `auth` / `library` / `browse` / `watch` / `mounts` / `danmaku` / `error` / `meta` / `locale`。
|
||||
|
||||
## 约定
|
||||
|
||||
- 中文 `/zh-CN`、英文 `/en` 前缀;裸 `/` 仅由 middleware 检测后 302,不挂页面。
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import type { MessageKey } from "./zh-CN.js";
|
|||
|
||||
/** 与 zh-CN 同构;类型强制 key 对齐。 */
|
||||
export const en: Record<MessageKey, string> = {
|
||||
"meta.brand": "DanDan Web",
|
||||
"meta.product": "DanDan Web",
|
||||
"meta.brand": "Fanmu",
|
||||
"meta.product": "Fanmu",
|
||||
"meta.productTagline": "Local files · online danmaku",
|
||||
"meta.tagline": "Local video · online danmaku",
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ export const en: Record<MessageKey, string> = {
|
|||
"nav.mounts": "Mounts",
|
||||
"nav.danmaku": "Danmaku settings",
|
||||
|
||||
"home.title": "DanDan Web",
|
||||
"home.title": "Fanmu",
|
||||
"home.subtitle":
|
||||
"Mount WebDAV / OpenList libraries, scan into your media library, play with ArtPlayer and online danmaku.",
|
||||
"home.heroLine1": "Local files",
|
||||
|
|
@ -30,7 +30,7 @@ export const en: Record<MessageKey, string> = {
|
|||
"home.layerTitle": "Get started",
|
||||
"home.layerBody":
|
||||
"Register → add a WebDAV mount → scan in Browse → play from the Library with danmaku.",
|
||||
"home.footer": "DANDAN WEB · SSR",
|
||||
"home.footer": "Private library · Danmaku",
|
||||
|
||||
"dashboard.title": "Console",
|
||||
"dashboard.heading": "Media hub",
|
||||
|
|
@ -56,6 +56,34 @@ export const en: Record<MessageKey, string> = {
|
|||
"library.searchWork": "Search by title",
|
||||
"library.bind": "Bind",
|
||||
"library.cancel": "Cancel",
|
||||
"library.viewWall": "Poster wall",
|
||||
"library.viewList": "List",
|
||||
"library.episodeCount": "{count} eps",
|
||||
"library.groupsEmpty":
|
||||
"No scraped titles yet. Scan a mount or match manually to see them here.",
|
||||
"library.episodes": "Episodes",
|
||||
"library.expand": "Show episodes",
|
||||
"library.collapse": "Collapse",
|
||||
"library.epLabel": "Episode",
|
||||
"library.epPlaceholder": "Optional, e.g. 12",
|
||||
"library.reScrape": "Re-scrape",
|
||||
"library.manualTitle": "Manual match",
|
||||
"library.candidates": "Candidates (click to bind)",
|
||||
"browse.batchScrape": "Batch scrape folder",
|
||||
"browse.batchBind": "Bind folder as title",
|
||||
"browse.batchDone": "{message}",
|
||||
"browse.batchError": "Batch action failed",
|
||||
"browse.folderActions": "Folder actions",
|
||||
"browse.bindFolderTitle": "Bind folder to a title",
|
||||
"browse.bindFolderHint":
|
||||
"Search and pick a title; all videos under this folder will bind to it, episodes parsed from filenames.",
|
||||
"library.backToList": "Back to library",
|
||||
"library.continueTitle": "Continue watching",
|
||||
"library.matched": "Matched",
|
||||
"library.autoScanning": "Auto-scanning mounted directories into the library…",
|
||||
"library.noResults": "No results matching “{q}”",
|
||||
"library.filterEmpty": "Nothing under the current filter — try another filter",
|
||||
"library.clearSearch": "Clear search",
|
||||
|
||||
"browse.title": "File browser",
|
||||
"browse.play": "Play",
|
||||
|
|
@ -71,6 +99,7 @@ export const en: Record<MessageKey, string> = {
|
|||
"browse.playError": "Failed to add to library",
|
||||
"browse.scanDone": "Scan complete: {count} files",
|
||||
"browse.scanError": "Scan failed. Check the mount configuration.",
|
||||
"browse.goLibrary": "Go to library",
|
||||
|
||||
"mounts.title": "Mounts",
|
||||
"mounts.add": "Add WebDAV",
|
||||
|
|
@ -83,8 +112,34 @@ export const en: Record<MessageKey, string> = {
|
|||
"mounts.passwordPlaceholder": "Password / token",
|
||||
"mounts.rootPathPlaceholder": "Root path /",
|
||||
"mounts.save": "Save",
|
||||
"mounts.saveEdit": "Save changes",
|
||||
"mounts.saved": "Mount saved",
|
||||
"mounts.updated": "Mount updated",
|
||||
"mounts.saveError": "Save failed — try again",
|
||||
"mounts.delete": "Delete",
|
||||
"mounts.deleted": "Mount deleted",
|
||||
"mounts.deleteError": "Delete failed",
|
||||
"mounts.edit": "Edit",
|
||||
"mounts.editing": "Editing",
|
||||
"mounts.editHeading": "Edit mount",
|
||||
"mounts.cancelEdit": "Cancel edit",
|
||||
"mounts.passwordKeepHint": "Password / token (leave blank to keep)",
|
||||
"mounts.fieldName": "Name",
|
||||
"mounts.fieldUrl": "WebDAV URL",
|
||||
"mounts.fieldUser": "Username",
|
||||
"mounts.fieldPass": "Password / token",
|
||||
"mounts.fieldRoot": "Root path",
|
||||
"mounts.listTitle": "Mounted",
|
||||
"mounts.disabled": "Disabled",
|
||||
"mounts.empty": "No mounts yet",
|
||||
"mounts.browseThis": "Browse files",
|
||||
"mounts.afterCreateHint": "Mount saved — browse its videos now.",
|
||||
"mounts.toBrowse": "Browse",
|
||||
"mounts.errNameRequired": "Enter a mount name",
|
||||
"mounts.errUrlInvalid": "Enter a valid http(s) URL, e.g. https://host/dav",
|
||||
"mounts.confirmDelete": "Confirm delete?",
|
||||
"mounts.deleteHint": "Media items scanned from this mount will also be removed",
|
||||
"mounts.deletedN": "Mount deleted ({n} media items removed)",
|
||||
|
||||
"watch.title": "Watch",
|
||||
"watch.loading": "Loading…",
|
||||
|
|
@ -103,6 +158,23 @@ export const en: Record<MessageKey, string> = {
|
|||
"watch.loadErrorHint": "Could not load this media. Check the link or try again later.",
|
||||
"watch.notFound": "Media not found",
|
||||
"watch.notFoundHint": "This media may have been removed, or you do not have access.",
|
||||
"watch.episodes": "Episodes",
|
||||
"watch.prevEpisode": "Prev",
|
||||
"watch.nextEpisode": "Next",
|
||||
"watch.currentEpisode": "Now playing",
|
||||
"watch.playNextCta": "Play next",
|
||||
"watch.backToSeries": "Back to series",
|
||||
"watch.danmakuSettings": "Danmaku settings",
|
||||
"watch.episodeN": "EP {n}",
|
||||
"watch.importOk": "Local danmaku imported",
|
||||
"watch.importFail": "Danmaku import failed",
|
||||
"watch.playFailed": "Playback source failed",
|
||||
"watch.playFailedHint":
|
||||
"Cannot read the video stream — the mount may be removed or the source unavailable.",
|
||||
"watch.replay": "Replay",
|
||||
"watch.playbackFinished": "Episode finished",
|
||||
"watch.danmakuDisabledHint":
|
||||
"Danmaku is off — enable it via the button above or in danmaku settings",
|
||||
|
||||
"danmaku.title": "Danmaku settings",
|
||||
"danmaku.opacity": "Opacity",
|
||||
|
|
@ -161,7 +233,10 @@ export const en: Record<MessageKey, string> = {
|
|||
"auth.msg.logoutOk": "Signed out",
|
||||
"auth.msg.passwordOk": "Password updated",
|
||||
"auth.msg.resetOk": "Password reset — please sign in",
|
||||
"auth.msg.resetRequested": "If the email exists, the reset request was accepted",
|
||||
"auth.msg.resetRequested":
|
||||
"If the email exists, the reset request was accepted; a reset link will be sent to the mailbox (if no mail service is configured, contact the admin or check server logs)",
|
||||
"auth.passwordHint": "At least 8 characters",
|
||||
"auth.forgot.emailLabel": "Registered email",
|
||||
"auth.err.generic": "Something went wrong — try again",
|
||||
|
||||
"locale.switch": "Language",
|
||||
|
|
|
|||