diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3320744735e..3e98939fc38 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -205,8 +205,43 @@ EdgeOne 构建阶段会按仓库中的 `.nvmrc` 切换 Node 版本。若控制 2. **自建仓库**:在 EdgeOne 控制台选择与 `.nvmrc` **完全一致**的 Node 版本;仍失败时检查构建日志是否仍在读取旧的 `.nvmrc`(需推送后再构建)。 3. `package.json` 中 `engines.node` 为 `>=20 <25`,在 Node 20 系列内均可构建;关键是 **构建环境实际安装的版本**能解析 `.nvmrc`。 +**兼容尝试(按顺序,任选其一即可,不必全做):** + +| 尝试 | 做法 | +|------|------| +| A. 对齐补丁号 | 打开 EdgeOne **项目设置 → Node.js 版本**,选择与仓库根目录 **`.nvmrc` 完全一致**的一项(例如均为 `20.18.0`),保存后 **重新部署**。 | +| B. 改自建 fork | 若平台下拉列表**没有**当前 `.nvmrc` 里的补丁号:把 fork 里的 `.nvmrc` 改成平台**已有**的某一档(仍为 Node 20 即可),推送后再构建。 | +| C. 清缓存 / 换分支 | 确认构建日志里 **Switching node** 读到的版本已更新;关闭「使用构建缓存」或触发一次无缓存构建,排除旧 `.nvmrc` 缓存。 | +| D. `engines` 报错时 | 若日志是 **Yarn does not satisfy engine** 而非 **Switching node**:在构建环境设 `YARN_IGNORE_ENGINES=1`(仅当确为 engines 校验问题时使用)。 | +| E. 不要用「只写 major」** | 少数平台不支持 `.nvmrc` 仅写 `20`;若遇解析错误,改为 **`20.18.0`** 这类完整补丁号。 | + +说明:Next.js 14 与当前依赖不要求锁在某一补丁版本;**兼容的核心是「平台实际能装上的 Node」与「`.nvmrc` / 控制台选择」一致**。 + 构建命令与静态导出等与其它平台相同,按需配置环境变量(至少 `NOTION_PAGE_ID`)。 +### `yarn install` 报 `ENOSPC: no space left on device` + +与 **Node 版本** 无关(日志里已出现 `Now, we're on node version v20.18.0` 即表示切换成功)。错误 **`ENOSPC`** 表示构建机 **磁盘或 `/dev/shm`(内存盘)空间不足**,在安装依赖从 cache 拷贝到 `node_modules` 时写满。 + +**可尝试:** + +1. **向 EdgeOne / 腾讯云工单反馈**:说明构建任务在 `yarn install` 阶段 `ENOSPC`,申请更大构建盘或确认是否为平台侧临时配额。 +2. **减少单次写入体积**(若控制台支持自定义安装命令): + - 使用 `yarn install --frozen-lockfile --prefer-offline` 且**开启依赖缓存**(命中缓存时少下载);或 + - 在构建前增加 `yarn cache clean`(会多下载,仅当怀疑缓存损坏时尝试,**不一定**缓解 ENOSPC)。 +3. **自定义 Yarn 缓存目录**(若平台文档支持挂载更大分区):设置环境变量 **`YARN_CACHE_FOLDER`** 到有足够剩余空间的路径(具体以 EdgeOne 构建环境说明为准)。 +4. **换构建方案**:在磁盘更大的 CI(如 GitHub Actions)完成 `yarn build` / `next build`,将产物同步到 EdgeOne(仅静态托管),绕过 Pages 内置构建机容量限制。 + +**备选:怀疑「构建/文件缓存」把空间占满时** + +| 做法 | 适用阶段 | 说明 | +|------|----------|------| +| **关闭 EdgeOne「依赖/构建缓存」再构建** | `yarn install` 前后 | 若平台在恢复缓存后又解压一份 `node_modules`,可能出现「缓存 + 工作区」双份占用 **`/dev/shm`**。在控制台**关闭缓存恢复**后重试(构建会变慢,但有时能腾出空间;视平台实现而定)。 | +| **`YARN_CACHE_FOLDER` 指到大磁盘路径** | `yarn install` | 避免 Yarn 默认缓存与仓库同落在小容量分区;路径以 **EdgeOne 官方文档** 为准。 | +| **构建环境变量 `ENABLE_CACHE=false`** | **`next build` 阶段** | 关闭 Notion 数据**读**盘缓存为主,会**增加 Notion 请求与耗时**;**锁文件/会话目录仍可能写入**。对日志里 **`yarn install` 拷贝 `node_modules` 即失败**的 ENOSPC **通常无效**,因尚未执行到 Next 构建。 | + +仓库本身无法通过改 `package.json` 消除平台磁盘上限;根本解决依赖 **构建环境配额** 或 **外置构建**。 + ## Docker 部署 ### Dockerfile diff --git a/conf/themeSwitch.manifest.js b/conf/themeSwitch.manifest.js index dedd68c5ee1..99239c6a79a 100644 --- a/conf/themeSwitch.manifest.js +++ b/conf/themeSwitch.manifest.js @@ -106,6 +106,10 @@ export const THEME_SWITCH_MANIFEST = { claude: { name: 'Claude', summary: '类 Claude Docs 的文档与终端氛围。' + }, + thoughtlite: { + name: 'ThoughtLite', + summary: '轻阅读向时间线与 Latest 卡片,单列列表与文章卡片排版。' } } diff --git a/docs/themes/README.en.md b/docs/themes/README.en.md index fd6ccd040f0..fd0ea2291a0 100644 --- a/docs/themes/README.en.md +++ b/docs/themes/README.en.md @@ -11,6 +11,7 @@ This directory centralizes documentation for all themes, including design intent | Claude | [CLAUDE.en.md](./CLAUDE.en.md) | [CLAUDE.md](./CLAUDE.md) | | Endspace | [ENDSPACE.en.md](./ENDSPACE.en.md) | [ENDSPACE.md](./ENDSPACE.md) | | Fuwari | [FUWARI.md](./FUWARI.md) | [FUWARI.md](./FUWARI.md) | +| ThoughtLite (WIP) | [THOUGHTLITE.en.md](./THOUGHTLITE.en.md) | [THOUGHTLITE.md](./THOUGHTLITE.md) · [plan (zh)](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md) | ## Maintenance Rules diff --git a/docs/themes/README.md b/docs/themes/README.md index e8cb18d9dd5..d99e7cd8145 100644 --- a/docs/themes/README.md +++ b/docs/themes/README.md @@ -11,6 +11,7 @@ | Claude | [CLAUDE.md](./CLAUDE.md) | [CLAUDE.en.md](./CLAUDE.en.md) | | Endspace | [ENDSPACE.md](./ENDSPACE.md) | [ENDSPACE.en.md](./ENDSPACE.en.md) | | Fuwari | [FUWARI.md](./FUWARI.md) | [FUWARI.md](./FUWARI.md) | +| ThoughtLite(移植中) | [THOUGHTLITE.md](./THOUGHTLITE.md) | [THOUGHTLITE.en.md](./THOUGHTLITE.en.md) · [迁移计划](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md) | ## 维护约定 diff --git a/docs/themes/THOUGHTLITE.en.md b/docs/themes/THOUGHTLITE.en.md new file mode 100644 index 00000000000..ec5f7d562c4 --- /dev/null +++ b/docs/themes/THOUGHTLITE.en.md @@ -0,0 +1,104 @@ +# ThoughtLite theme (NotionNext) + +[中文](./THOUGHTLITE.md) | Task plan (Chinese): [THOUGHTLITE_MIGRATION_PLAN.zh-CN.md](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md) + +This document is for **developers who maintain the ThoughtLite port** in NotionNext: why it exists, upstream provenance, original author repository, compliance notes, and day-to-day maintenance. + +--- + +## 1. Why it was added + +- **Community request**: [Issue #3987](https://github.com/notionnext-org/NotionNext/issues/3987) proposed a new theme inspired by the open-source Astro theme **ThoughtLite** (timeline-style home, reading-first layout). +- **Goal**: Provide an optional React skin that **stays compatible with NotionNext data contracts** (`posts`, `post`, `customNav`, comments, plugins, etc.) while **approximating ThoughtLite’s look and information architecture**. + +--- + +## 2. Upstream, author, and repositories + +| Item | Link | +|------|------| +| **Upstream theme** | ThoughtLite (Astro) | +| **Author** | [tuyuritio](https://github.com/tuyuritio) | +| **Upstream source** | [tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite) | +| **Live demo** | [thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/) | +| **Upstream stack** | Astro, Svelte, Tailwind CSS (see upstream README) | +| **Upstream license** | **GPL-3.0** | + +NotionNext is primarily **MIT**. This theme is implemented as a **design-informed React rewrite**, **without copying** upstream `.astro` / `.svelte` source wholesale. If someone later wants to vendor GPL code paths, maintainers must assess **GPL/MIT compatibility** (see [migration plan §0](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md)). + +--- + +## 3. Mapping in this repo + +| Item | Location | +|------|----------| +| **Theme folder** | `themes/thoughtlite/` | +| **Root scope** | `#theme-thoughtlite` | +| **Config keys** | `THOUGHTLITE_*` in `themes/thoughtlite/config.js` via `siteConfig(..., CONFIG)` | +| **Baseline** | Started from `themes/example`, then restyled toward ThoughtLite | +| **Shared features** | `@/components/*` (Notion body, comments, search, etc.) | + +--- + +## 4. Features (maintenance map) + +- **Header**: title, horizontal nav (incl. custom menu), search, theme toggle (`Header.js`, `MenuList.js`, `MenuItemDrop.js`). +- **Home**: optional **Latest** card; **timeline** grouped by `publishDay` (`HomeTimeline.js`, `BlogItem` `timeline` variant); respects global `POST_LIST_STYLE` (page vs scroll). +- **Post**: card header, `PostMeta`, `NotionPage`, `ShareBar`, comments (`dynamic` without SSR), sidebar TOC (`LayoutSlug` in `index.js`). +- **Archive / category / tag / search**: `TlPageHero`; archive uses the same rail style; taxonomy uses `tl-chip` (`style.js`). +- **Footer**: copyright, beian, `PoweredBy`, plus **ThoughtLite name + upstream repo and author links** (`Footer.js`). +- **Theme switcher**: `conf/themeSwitch.manifest.js` entry `thoughtlite`; previews under `public/images/themes-preview/thoughtlite.{png,webp}` (replace placeholders before release; optionally run `yarn perf:compress-theme-previews`). + +--- + +## 5. Config keys (`themes/thoughtlite/config.js`) + +| Key | Purpose | +|-----|---------| +| `THOUGHTLITE_MENU_*` | Show/hide nav entries (category, tag, archive, search) | +| `THOUGHTLITE_HOME_TIMELINE` | Timeline on bare home routes | +| `THOUGHTLITE_HOME_LATEST_CARD` | Latest summary card on home | +| `THOUGHTLITE_SIDEBAR_ONLY_ON_POST` | Sidebar only on post detail | +| `THOUGHTLITE_POST_LIST_COVER` | Cover thumbnails in list mode | +| `THOUGHTLITE_TITLE_IMAGE` | Hero background image on non-post title bar | +| `THOUGHTLITE_HOME_MINIMAL_HEADER` | Minimal home title area | +| `THOUGHTLITE_ARTICLE_LAYOUT_VERTICAL` | Stack main + sidebar on posts | +| `THOUGHTLITE_ARTICLE_HIDDEN_NOTIFICATION` | Hide announcement on post | + +Global **`LAYOUT_SIDEBAR_REVERSE`** still applies at site level. + +--- + +## 6. Enable + +```bash +NEXT_PUBLIC_THEME=thoughtlite +``` + +Use `yarn dev` locally; `?theme=thoughtlite` may work where the app exposes theme switching. + +--- + +## 7. Maintenance checklist + +1. Prefer editing **`#theme-thoughtlite`** tokens and classes in `themes/thoughtlite/style.js`. +2. Layout entry points: `themes/thoughtlite/index.js` (`Layout*`); presentational pieces under `themes/thoughtlite/components/`. +3. When **matching upstream visuals**, reference the [public demo](https://thought-lite.ttio.workers.dev/)—avoid pasting **GPL source files**; document any exception in the PR. +4. Timeline relies on **`publishDay` / `publishDate`** from Notion mapping (`lib/db/notion/getPageProperties.js`). +5. Cross-theme conventions: see [FUWARI.md](./FUWARI.md) and [THEME_MIGRATION_GUIDE](../THEME_MIGRATION_GUIDE.md). +6. Refresh theme previews under `public/images/themes-preview/` when the UI changes materially. +7. Smoke test: `yarn lint --dir themes/thoughtlite` and click through main routes with `NEXT_PUBLIC_THEME=thoughtlite`. + +--- + +## 8. Links + +- Issue: [notionnext-org/NotionNext#3987](https://github.com/notionnext-org/NotionNext/issues/3987) +- Upstream: [tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite) +- Demo: [thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/) + +--- + +## 9. Status + +This is an **initial, merge-ready port**: core flows work; **further polish** (tokens, spacing, motion, a11y, plugin edge cases) is expected in follow-up PRs. diff --git a/docs/themes/THOUGHTLITE.md b/docs/themes/THOUGHTLITE.md new file mode 100644 index 00000000000..ccee6d3bd62 --- /dev/null +++ b/docs/themes/THOUGHTLITE.md @@ -0,0 +1,107 @@ +# ThoughtLite 主题(NotionNext) + +[English](./THOUGHTLITE.en.md) | 任务计划:[THOUGHTLITE_MIGRATION_PLAN.zh-CN.md](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md) + +本文面向**后续维护本主题的开发者**,说明主题加入原因、上游来源、原作者仓库、合规注意与日常维护要点。 + +--- + +## 1. 加入背景与原因 + +- **社区提议**:用户在 [Issue #3987](https://github.com/notionnext-org/NotionNext/issues/3987) 中建议新增主题,参考对象为开源 Astro 主题 **ThoughtLite**,认为其阅读向、时间线式首页与轻量导航适合作为 NotionNext 的可选皮肤之一。 +- **产品目标**:在 **不破坏 NotionNext 既有数据契约**(`posts` / `post` / `customNav` / 评论与插件等)的前提下,提供一套**视觉与信息架构上贴近 ThoughtLite** 的 React 主题,便于站点一键切换试用。 + +--- + +## 2. 来源、原作者与仓库 + +| 说明 | 链接 | +|------|------| +| **上游主题名** | ThoughtLite(Astro 生态) | +| **原作者 GitHub** | [tuyuritio](https://github.com/tuyuritio) | +| **上游源码仓库** | [tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite) | +| **上游在线演示** | [thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/) | +| **上游技术栈** | Astro、Svelte、Tailwind CSS 等(见上游 README) | +| **上游许可证** | **GPL-3.0** | + +NotionNext 本仓库主体为 **MIT**。本主题目录下的实现为 **基于上游「设计参考」在 Next.js + React 中重写**,**不直接复制**上游 `.astro` / `.svelte` 等 GPL 源码文本;若未来有人希望合入上游组件源码,须由维护者单独评估 **GPL 与 MIT 的兼容性**(详见 [迁移计划 §0](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md))。 + +--- + +## 3. 与本仓库的对应关系 + +| 维度 | 说明 | +|------|------| +| **主题目录** | `themes/thoughtlite/` | +| **根节点** | `#theme-thoughtlite`(样式与全局隔离) | +| **配置前缀** | `THOUGHTLITE_*`,集中在 `themes/thoughtlite/config.js`,通过 `siteConfig('KEY', default, CONFIG)` 读取 | +| **起点** | 初期由 `themes/example` 骨架复制并改名,再逐步替换为 ThoughtLite 取向的 UI | +| **通用逻辑** | 评论、Notion 正文、搜索等复用 `@/components/*`,与其它主题一致 | + +--- + +## 4. 功能概览(维护时对照) + +- **顶栏**:站点标题、横向导航(含自定义菜单)、搜索入口、深浅色切换(`components/Header.js`、`MenuList.js`、`MenuItemDrop.js`)。 +- **首页**:可选 **Latest** 卡片、按 **`publishDay`** 分组的时间线列表(`HomeTimeline.js`、`BlogItem` 的 `timeline` 变体);支持分页 / 滚动列表两种全局 `POST_LIST_STYLE`。 +- **文章页**:卡片式标题区、`PostMeta`、`NotionPage`、`ShareBar`、评论区(`dynamic` 关闭 SSR)、侧栏目录等(`index.js` 中 `LayoutSlug`)。 +- **归档 / 分类 / 标签 / 搜索**:统一 `TlPageHero` 页头;归档为时间线侧轨;分类与标签为 `tl-chip`(`style.js`)。 +- **页脚**:版权、备案、`PoweredBy`,以及 **ThoughtLite 主题名 + 上游仓库与原作者链接**(`components/Footer.js`)。 +- **主题切换**:`conf/themeSwitch.manifest.js` 中 `thoughtlite` 条目;预览图 `public/images/themes-preview/thoughtlite.{png,webp}`(当前可为占位图,发布前建议替换真实截图并执行 `yarn perf:compress-theme-previews`)。 + +--- + +## 5. 配置项一览(`themes/thoughtlite/config.js`) + +| 键 | 含义 | +|----|------| +| `THOUGHTLITE_MENU_CATEGORY` / `TAG` / `ARCHIVE` / `SEARCH` | 顶栏是否展示对应菜单项 | +| `THOUGHTLITE_HOME_TIMELINE` | 纯首页是否使用按日时间线 | +| `THOUGHTLITE_HOME_LATEST_CARD` | 首页是否展示 Latest 摘要块 | +| `THOUGHTLITE_SIDEBAR_ONLY_ON_POST` | 是否仅在文章详情页显示侧栏 | +| `THOUGHTLITE_POST_LIST_COVER` | 列表是否显示封面(时间线场景建议 `false`) | +| `THOUGHTLITE_TITLE_IMAGE` | 非文章页的 TitleBar 是否使用背景大图 | +| `THOUGHTLITE_HOME_MINIMAL_HEADER` | 首页是否隐藏大块 TitleBar | +| `THOUGHTLITE_ARTICLE_LAYOUT_VERTICAL` | 文章页主区与侧栏是否改为上下堆叠 | +| `THOUGHTLITE_ARTICLE_HIDDEN_NOTIFICATION` | 文章页是否隐藏公告 | + +全局项 **`LAYOUT_SIDEBAR_REVERSE`** 仍由站点级配置控制,与本主题 `LayoutBase` 共同作用。 + +--- + +## 6. 启用方式 + +```bash +# .env.local 或部署环境 +NEXT_PUBLIC_THEME=thoughtlite +``` + +本地开发:`yarn dev` 后访问站点;也可用 URL 参数 `?theme=thoughtlite` 在支持主题切换的场景下试用(以实际路由与配置为准)。 + +--- + +## 7. 迁移与维护事项(给后续开发者) + +1. **改样式优先** `themes/thoughtlite/style.js` 中的 **`#theme-thoughtlite`** 下 CSS 变量与类名,避免污染其它主题。 +2. **改布局与路由** 以 `themes/thoughtlite/index.js` 中各 `Layout*` 为入口;列表子组件在 `themes/thoughtlite/components/`。 +3. **对齐上游视觉** 时建议只对照 [上游演示站](https://thought-lite.ttio.workers.dev/) 与公开 UI 行为,**避免整文件拷贝**上游 GPL 源码;大段引用需在 PR 中说明合规路径。 +4. **数据字段**:时间线依赖 `post.publishDay`、`post.publishDate`(由 Notion 属性映射,见 `lib/db/notion/getPageProperties.js`);勿写死 Notion 内部 ID。 +5. **与其它主题对齐行为** 时,可参考 [FUWARI.md](./FUWARI.md) 与 [主题迁移指南(中文)](../THEME_MIGRATION_GUIDE.zh-CN.md) 中的菜单、评论、TOC、插件位等约定。 +6. **更新主题切换预览** 时替换 `public/images/themes-preview/thoughtlite.png`,并运行 `yarn perf:compress-theme-previews` 更新 webp(若仓库保留该脚本)。 +7. **合并前自检**:`yarn lint --dir themes/thoughtlite`、`NEXT_PUBLIC_THEME=thoughtlite` 下手动点一遍主要路由(首页、文章、归档、分类、标签、搜索、404、加密文)。 + +更细的分阶段清单见 [THOUGHTLITE_MIGRATION_PLAN.zh-CN.md](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md)。 + +--- + +## 8. 相关链接汇总 + +- 需求:[notionnext-org/NotionNext#3987](https://github.com/notionnext-org/NotionNext/issues/3987) +- 上游仓库:[tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite) +- 上游演示:[thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/) + +--- + +## 9. 当前阶段说明(给审阅者与贡献者) + +本主题为 **初步合并可用的实现**:核心列表与文章流已接通,视觉与交互仍会**按迭代优化**(色板、间距、动效、无障碍、与其它插件的边角兼容等)。合并 PR 不代表 Issue 中所有「像素级对齐上游」诉求已全部完成;后续可在同一主题目录内持续提交小 PR 演进。 diff --git a/docs/themes/THOUGHTLITE_MIGRATION_PLAN.zh-CN.md b/docs/themes/THOUGHTLITE_MIGRATION_PLAN.zh-CN.md new file mode 100644 index 00000000000..ab4380a0710 --- /dev/null +++ b/docs/themes/THOUGHTLITE_MIGRATION_PLAN.zh-CN.md @@ -0,0 +1,103 @@ +# ThoughtLite 主题迁移任务计划(NotionNext) + +关联需求:[Issue #3987 · 新主题 ThoughtLite](https://github.com/notionnext-org/NotionNext/issues/3987) + +| 项目 | 链接 | +|------|------| +| 上游源码(Astro + Svelte + Tailwind) | [tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite) | +| 在线预览 | [thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/) | +| 本仓库工作分支 | `feat/theme-thoughtlite` | +| 当前骨架目录 | `themes/thoughtlite/`(由 `themes/example` 复制并重命名配置前缀,**可运行、非最终视觉**) | + +--- + +## 0. 许可证与合规(必须先确认) + +上游仓库为 **GPL-3.0**。NotionNext 主仓库为 **MIT**。 + +- **直接复制**上游 `.astro` / `.svelte` 等源码进本仓库,可能触发 **GPL 与 MIT 的兼容性**问题,需在合并前由维护者确认策略(例如:主题单独以 GPL 子目录说明、或仅「参考设计」用 React 重写而不复制 GPL 文本等)。 +- 请在 **PR 描述与 `docs/themes/THOUGHTLITE.md`** 中写明许可证结论后再合入 `main`。 + +--- + +## 1. 与 Fuwari(Astro → Next)迁移的相同注意点 + +对照 [`docs/themes/FUWARI.md`](./FUWARI.md) 与 [主题迁移指南(中文)](../THEME_MIGRATION_GUIDE.zh-CN.md): + +1. **技术栈**:上游为 **Astro + Svelte**,NotionNext 主题为 **Next.js Pages Router + React**;不能「直接挂载」Astro 工程,只能 **移植视觉与信息架构**。 +2. **数据契约**:仅使用 NotionNext 传入的 `props`(`siteInfo`、`posts`、`post`、`customNav`、`customMenu`、`notice`、`tagOptions` 等),见迁移指南 §3。 +3. **必接能力**:菜单(含 `CUSTOM_MENU`)、公告 `NotionPage`、深浅色 `useGlobal`、文章 TOC/评论/分享/版权/相邻篇、侧栏挂件与 `rightAreaSlot`、联系邮箱 `handleEmailClick` / `resolveContactEmail` 约定(迁移指南 §7)。 +4. **配置**:使用 `siteConfig('THOUGHTLITE_*', default, CONFIG)`,集中在 `themes/thoughtlite/config.js`,避免魔法常量。 +5. **目录隔离**:不引用其他主题目录组件;通用能力用 `@/components/*`。 + +--- + +## 2. 分阶段任务(建议顺序) + +### Phase A — 骨架与联调(当前分支目标) + +- [x] 新建分支 `feat/theme-thoughtlite`。 +- [x] 建立 `themes/thoughtlite`(example 骨架 + `THOUGHTLITE_*` 配置前缀 + `id='theme-thoughtlite'`)。 +- [x] 顶栏 / 页脚 / 首页时间线与 Latest 卡片 / 文章卡片化初版(React 自研样式,非复制上游 Astro 源码)。 +- [ ] 本地 `NEXT_PUBLIC_THEME=thoughtlite`(或 URL `?theme=thoughtlite`)跑通:首页、文章、归档、分类、标签、搜索、404。 +- [ ] `yarn lint` / `yarn type-check` 针对本主题目录无新增错误。 + +### Phase B — 版式对齐 ThoughtLite + +- [ ] 阅读上游 `src/layouts`、`src/pages` 与首页「时间线 / Latest」结构,画出与 NotionNext 路由的映射表(`/`、`/[prefix]/[slug]` 等)。 +- [x] 替换顶栏导航:站点名 + 横向菜单 + 搜索入口 + 深浅色切换。 +- [x] 首页:按 `publishDay` 分组的时间线列表(滚动/分页均支持)。 +- [x] 归档页:按月分组列表与首页一致的时间线视觉。 +- [x] 分类 / 标签索引:`tl-chip` 导航块。 +- [x] 搜索页:`TlPageHero` + 搜索框卡片化;关键词高亮 `useEffect` 依赖补全。 +- [ ] `style.js`:继续对齐上游色板、间距与字体(当前为首版 token)。 + +### Phase C — 文章与侧栏 + +- [ ] 文章页:正文 `NotionPage`、封面、元信息密度对齐上游文章页。 +- [ ] 侧栏/页脚:按需保留或简化;接入统计、广告、插件位(与 Fuwari 侧栏模块对齐思路)。 +- [ ] 可选:上游 **Swup** 类全页过渡在 Next 中成本高,可用 `framer-motion` 或弱化动效替代。 + +### Phase D — 上架主仓库清单 + +- [x] `docs/themes/THOUGHTLITE.md` 与 **`docs/themes/THOUGHTLITE.en.md`**:功能、配置、上游致谢与许可证说明。 +- [x] `docs/themes/README.md` / `README.en.md` 导航表更新。 +- [x] 按 [主题迁移指南 §8](../THEME_MIGRATION_GUIDE.zh-CN.md) 提交 `public/images/themes-preview/thoughtlite.png` / `.webp`(当前为占位图,可后续替换为真实截图)与 `conf/themeSwitch.manifest.js` 条目。 +- [ ] 合并 PR 时由维护者决定是否 **`Closes #3987`**(若仍有多阶段需求可保留 Issue 子任务)。 + +--- + +## 3. 风险与依赖 + +| 风险 | 缓解 | +|------|------| +| GPL / MIT | 见 §0,PR 前书面结论。 | +| 上游用 **pnpm + Svelte**,与本仓库 **yarn + React** 不一致 | 仅借鉴 UI,不混用包管理器。 | +| i18n | 与全局 `next.config` `locales` 一致;上游多语言文案迁到 `lib/lang` 或主题内字典。 | +| 构建体积 | 勿引入过大依赖;图标优先已有 Iconify / Heroicons 体系。 | + +--- + +## 4. 验证命令(开发自检) + +```bash +yarn dev +# 浏览器访问: http://localhost:3000/?theme=thoughtlite + +yarn build +yarn type-check +``` + +--- + +维护者可按 Phase 勾选推进;每阶段结束建议 squash 或清晰 commit message 便于审阅。 + +--- + +## 5. 维护文档索引 + +| 文档 | 用途 | +|------|------| +| [THOUGHTLITE.md](./THOUGHTLITE.md) | 加入原因、上游来源、原作者仓库、配置与维护清单(**主入口**) | +| [THOUGHTLITE.en.md](./THOUGHTLITE.en.md) | 上述内容的英文版,便于国际贡献者 | +| 本文(迁移计划) | 分阶段任务、风险、验证命令;与主文档交叉引用 | diff --git a/lib/cache/build_session.js b/lib/cache/build_session.js index 7d0593e1b1d..838c4b7cad0 100644 --- a/lib/cache/build_session.js +++ b/lib/cache/build_session.js @@ -1,21 +1,66 @@ import fs from 'fs' +import os from 'os' import path from 'path' -const NOTION_CACHE_ROOT = path.join(process.cwd(), '.next', 'cache', 'notion') -const BUILD_SESSION_FILE = path.join(NOTION_CACHE_ROOT, 'build-session.json') +/** 惰性解析:构建/本地可写 `.next/cache/notion`;Serverless(如 Netlify)只读盘则落到 `os.tmpdir()` */ +let cachedNotionCacheRoot = null + +function resolveNotionCacheRoot() { + if (cachedNotionCacheRoot) { + return cachedNotionCacheRoot + } + + const fromEnv = process.env.NOTION_NEXT_NOTION_CACHE_DIR + if (fromEnv) { + const dir = path.resolve(fromEnv) + fs.mkdirSync(dir, { recursive: true }) + cachedNotionCacheRoot = dir + return cachedNotionCacheRoot + } + + const primary = path.join(process.cwd(), '.next', 'cache', 'notion') + try { + fs.mkdirSync(primary, { recursive: true }) + cachedNotionCacheRoot = primary + return cachedNotionCacheRoot + } catch (err) { + const code = err && err.code + if ( + code === 'ENOENT' || + code === 'EROFS' || + code === 'EACCES' || + code === 'EPERM' + ) { + const fallback = path.join(os.tmpdir(), 'notionnext-notion-cache') + fs.mkdirSync(fallback, { recursive: true }) + cachedNotionCacheRoot = fallback + if (process.env.NODE_ENV !== 'test') { + console.warn( + '[NotionNext] Notion file cache root (read-only deploy, using tmpdir):', + fallback + ) + } + return cachedNotionCacheRoot + } + throw err + } +} function sanitizeSessionId(sessionId) { return String(sessionId || 'default').replace(/[^a-z0-9_-]/gi, '_') } export function getNotionCacheRoot() { - fs.mkdirSync(NOTION_CACHE_ROOT, { recursive: true }) - return NOTION_CACHE_ROOT + return resolveNotionCacheRoot() } export function getBuildSessionId() { + const buildSessionFile = path.join( + getNotionCacheRoot(), + 'build-session.json' + ) try { - const raw = fs.readFileSync(BUILD_SESSION_FILE, 'utf8') + const raw = fs.readFileSync(buildSessionFile, 'utf8') const parsed = JSON.parse(raw) if (parsed?.sessionId) { return sanitizeSessionId(parsed.sessionId) @@ -26,5 +71,10 @@ export function getBuildSessionId() { } export function getBuildSessionPath(...parts) { - return path.join(getNotionCacheRoot(), 'sessions', getBuildSessionId(), ...parts) + return path.join( + getNotionCacheRoot(), + 'sessions', + getBuildSessionId(), + ...parts + ) } diff --git a/public/images/themes-preview/thoughtlite.png b/public/images/themes-preview/thoughtlite.png new file mode 100644 index 00000000000..b826d48e0d2 Binary files /dev/null and b/public/images/themes-preview/thoughtlite.png differ diff --git a/public/images/themes-preview/thoughtlite.webp b/public/images/themes-preview/thoughtlite.webp new file mode 100644 index 00000000000..dff9568eaa3 Binary files /dev/null and b/public/images/themes-preview/thoughtlite.webp differ diff --git a/themes/thoughtlite/components/Announcement.js b/themes/thoughtlite/components/Announcement.js new file mode 100644 index 00000000000..f6786ce597d --- /dev/null +++ b/themes/thoughtlite/components/Announcement.js @@ -0,0 +1,30 @@ +import { useGlobal } from '@/lib/global' +import dynamic from 'next/dynamic' + +const NotionPage = dynamic(() => import('@/components/NotionPage')) + +/** + * 公告模块(单篇 Notion 页) + */ +const Announcement = ({ post, className }) => { + const { locale } = useGlobal() + if (!post || Object.keys(post).length === 0) { + return <> + } + return ( + + ) +} +export default Announcement diff --git a/themes/thoughtlite/components/BlogItem.js b/themes/thoughtlite/components/BlogItem.js new file mode 100644 index 00000000000..02334db937c --- /dev/null +++ b/themes/thoughtlite/components/BlogItem.js @@ -0,0 +1,119 @@ +import LazyImage from '@/components/LazyImage' +import NotionIcon from '@/components/NotionIcon' +import TwikooCommentCount from '@/components/TwikooCommentCount' +import { siteConfig } from '@/lib/config' +import SmartLink from '@/components/SmartLink' +import CONFIG from '../config' + +/** + * 博客列表的单个卡片 + * @param {{ post: object, variant?: 'default' | 'timeline' }} props + */ +const BlogItem = ({ post, variant = 'default' }) => { + const showPageCover = + variant === 'default' && + siteConfig('THOUGHTLITE_POST_LIST_COVER', null, CONFIG) && + post?.pageCoverThumbnail + + if (variant === 'timeline') { + return ( +
+
+ + {siteConfig('POST_TITLE_ICON') && ( + + + + )} + {post?.title} + + {post?.type !== 'Page' && post?.category && ( + + #{post.category} + + )} +
+ {post.summary && !post.results ? ( +

+ {post.summary} +

+ ) : null} + {post.results ? ( +

+ {post.results.map((r, index) => ( + {r} + ))} +

+ ) : null} +
+ +
+
+ ) + } + + return ( +
+
+

+ + {siteConfig('POST_TITLE_ICON') && ( + + )} + {post?.title} + +

+ +
+ by{' '} + {siteConfig('AUTHOR')} + {' '} + on {post.date?.start_date || post.createdTime} + + {post.category && ( + <> + | + + {post.category} + + + )} +
+ + {!post.results && ( +

+ {post.summary} +

+ )} + {post.results && ( +

+ {post.results.map((r, index) => ( + {r} + ))} +

+ )} +
+ {showPageCover && ( +
+ + + +
+ )} +
+ ) +} + +export default BlogItem diff --git a/themes/thoughtlite/components/BlogListArchive.js b/themes/thoughtlite/components/BlogListArchive.js new file mode 100644 index 00000000000..278d1d0c6c2 --- /dev/null +++ b/themes/thoughtlite/components/BlogListArchive.js @@ -0,0 +1,30 @@ +import SmartLink from '@/components/SmartLink' + +/** + * 归档:按月(或站点约定)分组的文章列表 + */ +export default function BlogListArchive({ archiveTitle, archivePosts }) { + const posts = archivePosts[archiveTitle] || [] + const sectionId = `archive-${String(archiveTitle).replace(/\s+/g, '-')}` + return ( +
+

+ {archiveTitle} +

+ +
+ ) +} diff --git a/themes/thoughtlite/components/BlogListPage.js b/themes/thoughtlite/components/BlogListPage.js new file mode 100644 index 00000000000..eb831362846 --- /dev/null +++ b/themes/thoughtlite/components/BlogListPage.js @@ -0,0 +1,82 @@ +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import SmartLink from '@/components/SmartLink' +import { useRouter } from 'next/router' +import CONFIG from '../config' +import BlogItem from './BlogItem' +import HomeTimeline from './HomeTimeline' + +/** + * 使用分页插件的博客列表 + * @param {*} props + * @returns + */ +export const BlogListPage = props => { + const { + page = 1, + posts, + postCount, + useTimeline: useTimelineProp + } = props + const { locale, NOTION_CONFIG } = useGlobal() + const router = useRouter() + const totalPage = Math.ceil( + postCount / siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG) + ) + const currentPage = +page + + const showPrev = currentPage > 1 + const showNext = page < totalPage + const pagePrefix = router.asPath + .split('?')[0] + .replace(/\/page\/[1-9]\d*/, '') + .replace(/\/$/, '') + .replace('.html', '') + + const showPageCover = siteConfig('THOUGHTLITE_POST_LIST_COVER', null, CONFIG) + + const useTimeline = + useTimelineProp ?? + (siteConfig('THOUGHTLITE_HOME_TIMELINE', true, CONFIG) && + !props.category && + !props.tag && + !props.keyword && + !router?.query?.s && + (router.pathname === '/' || router.pathname === '/page/[page]')) + + return ( +
+ {useTimeline ? ( + + ) : ( +
+ {posts?.map(post => ( + + ))} +
+ )} + +
+ + {locale.PAGINATION.PREV} + + + {locale.PAGINATION.NEXT} + +
+
+ ) +} diff --git a/themes/thoughtlite/components/BlogListScroll.js b/themes/thoughtlite/components/BlogListScroll.js new file mode 100644 index 00000000000..28c0eac4ead --- /dev/null +++ b/themes/thoughtlite/components/BlogListScroll.js @@ -0,0 +1,91 @@ +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import throttle from 'lodash.throttle' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import CONFIG from '../config' +import BlogItem from './BlogItem' +import HomeTimeline from './HomeTimeline' + +/** + * 使用滚动无限加载的博客列表 + * @param {*} props + * @returns + */ +export const BlogListScroll = props => { + const { posts, useTimeline: useTimelineProp } = props + const router = useRouter() + const { locale, NOTION_CONFIG } = useGlobal() + const [page, updatePage] = useState(1) + const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG) + + const postsToShow = posts + ? Object.assign([], posts).slice(0, POSTS_PER_PAGE * page) + : [] + + const hasMore = Boolean(posts?.length) && page * POSTS_PER_PAGE < posts.length + + const handleGetMore = useCallback(() => { + updatePage(p => { + const total = posts?.length ?? 0 + if (p * POSTS_PER_PAGE >= total) return p + return p + 1 + }) + }, [posts, POSTS_PER_PAGE]) + + const targetRef = useRef(null) + + const scrollTrigger = useMemo( + () => + throttle(() => { + const scrollS = window.scrollY + window.outerHeight + const clientHeight = targetRef.current?.clientHeight ?? 0 + if (scrollS > clientHeight + 100) { + handleGetMore() + } + }, 500), + [handleGetMore] + ) + + useEffect(() => { + window.addEventListener('scroll', scrollTrigger) + return () => { + scrollTrigger.cancel() + window.removeEventListener('scroll', scrollTrigger) + } + }, [scrollTrigger]) + + const showPageCover = siteConfig('THOUGHTLITE_POST_LIST_COVER', null, CONFIG) + + const useTimeline = + useTimelineProp ?? + (siteConfig('THOUGHTLITE_HOME_TIMELINE', true, CONFIG) && + !props.category && + !props.tag && + !props.keyword && + !router?.query?.s && + (router.pathname === '/' || router.pathname === '/page/[page]')) + + return ( +
+ {useTimeline ? ( + + ) : ( +
+ {postsToShow?.map(post => ( + + ))} +
+ )} + +
+ {' '} + {hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE} 😰`}{' '} +
+
+ ) +} diff --git a/themes/thoughtlite/components/Catalog.js b/themes/thoughtlite/components/Catalog.js new file mode 100644 index 00000000000..c6589b1f6b1 --- /dev/null +++ b/themes/thoughtlite/components/Catalog.js @@ -0,0 +1,98 @@ +import throttle from 'lodash.throttle' +import { uuidToId } from 'notion-utils' +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * 目录导航组件 + * @param toc + * @returns {JSX.Element} + * @constructor + */ +const Catalog = ({ toc }) => { + // 监听滚动事件 + useEffect(() => { + window.addEventListener('scroll', actionSectionScrollSpy) + actionSectionScrollSpy() + return () => { + window.removeEventListener('scroll', actionSectionScrollSpy) + } + }, []) + + // 目录自动滚动 + const tRef = useRef(null) + const tocIds = [] + + // 同步选中目录事件 + const [activeSection, setActiveSection] = useState(null) + const throttleMs = 200 + const actionSectionScrollSpy = useCallback( + throttle(() => { + const sections = document.getElementsByClassName('notion-h') + let prevBBox = null + let currentSectionId = activeSection + for (let i = 0; i < sections.length; ++i) { + const section = sections[i] + if (!section || !(section instanceof Element)) continue + if (!currentSectionId) { + currentSectionId = section.getAttribute('data-id') + } + const bbox = section.getBoundingClientRect() + const prevHeight = prevBBox ? bbox.top - prevBBox.bottom : 0 + const offset = Math.max(150, prevHeight / 4) + // GetBoundingClientRect returns values relative to viewport + if (bbox.top - offset < 0) { + currentSectionId = section.getAttribute('data-id') + prevBBox = bbox + continue + } + // No need to continue loop, if last element has been detected + break + } + setActiveSection(currentSectionId) + const index = tocIds.indexOf(currentSectionId) || 0 + tRef?.current?.scrollTo({ top: 28 * index, behavior: 'smooth' }) + }, throttleMs) + ) + + // 无目录就直接返回空 + if (!toc || toc.length < 1) { + return <> + } + + return ( +
+
+ +
+
+ ) +} + +export default Catalog diff --git a/themes/thoughtlite/components/Footer.js b/themes/thoughtlite/components/Footer.js new file mode 100644 index 00000000000..7f685032730 --- /dev/null +++ b/themes/thoughtlite/components/Footer.js @@ -0,0 +1,44 @@ +import { BeiAnGongAn } from '@/components/BeiAnGongAn' +import BeiAnSite from '@/components/BeiAnSite' +import CopyRightDate from '@/components/CopyRightDate' +import PoweredBy from '@/components/PoweredBy' + +const UPSTREAM_REPO = 'https://github.com/tuyuritio/astro-theme-thought-lite' +const UPSTREAM_AUTHOR = 'https://github.com/tuyuritio' + +export const Footer = () => { + return ( + + ) +} diff --git a/themes/thoughtlite/components/Header.js b/themes/thoughtlite/components/Header.js new file mode 100644 index 00000000000..2ef3bd5e2db --- /dev/null +++ b/themes/thoughtlite/components/Header.js @@ -0,0 +1,49 @@ +import SmartLink from '@/components/SmartLink' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import CONFIG from '../config' +import { MenuList } from './MenuList' + +/** + * ThoughtLite 风格顶栏:站点名 + 横向菜单 + 搜索 / 深浅色 + */ +export const Header = props => { + const { isDarkMode, toggleDarkMode } = useGlobal() + + const openSearch = () => { + window.location.href = '/search' + } + + return ( +
+
+ + {siteConfig('TITLE')} + + + + +
+ {siteConfig('THOUGHTLITE_MENU_SEARCH', null, CONFIG) && ( + + )} + +
+
+
+ ) +} diff --git a/themes/thoughtlite/components/HomeTimeline.js b/themes/thoughtlite/components/HomeTimeline.js new file mode 100644 index 00000000000..561acb58862 --- /dev/null +++ b/themes/thoughtlite/components/HomeTimeline.js @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import BlogItem from './BlogItem' + +/** + * 按「发布日」分组的时间线列表(对齐 ThoughtLite 首页观感)。 + * @param {{ posts: object[] }} props + */ +export default function HomeTimeline({ posts }) { + const groups = useMemo(() => { + if (!posts?.length) return [] + const sorted = [...posts].sort( + (a, b) => (b.publishDate || 0) - (a.publishDate || 0) + ) + const dayOrder = [] + const seen = new Set() + for (const p of sorted) { + const day = p.publishDay || p.date?.start_date || '' + if (!seen.has(day)) { + seen.add(day) + dayOrder.push(day) + } + } + return dayOrder.map(day => ({ + day, + posts: sorted.filter( + p => (p.publishDay || p.date?.start_date || '') === day + ) + })) + }, [posts]) + + if (!groups.length) return null + + return ( +
+ {groups.map(({ day, posts: dayPosts }) => ( +
+

{day || '—'}

+
    + {dayPosts.map(post => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ) +} diff --git a/themes/thoughtlite/components/LatestCard.js b/themes/thoughtlite/components/LatestCard.js new file mode 100644 index 00000000000..e956318b051 --- /dev/null +++ b/themes/thoughtlite/components/LatestCard.js @@ -0,0 +1,28 @@ +import SmartLink from '@/components/SmartLink' +import { siteConfig } from '@/lib/config' +import CONFIG from '../config' + +/** + * 首页「Latest」摘要块(对齐 ThoughtLite 演示站顶部引用样式)。 + */ +export default function LatestCard({ post }) { + if (!post?.href) return null + const enabled = siteConfig('THOUGHTLITE_HOME_LATEST_CARD', true, CONFIG) + if (!enabled) return null + + return ( + + ) +} diff --git a/themes/thoughtlite/components/MenuItemDrop.js b/themes/thoughtlite/components/MenuItemDrop.js new file mode 100644 index 00000000000..9b079b744e3 --- /dev/null +++ b/themes/thoughtlite/components/MenuItemDrop.js @@ -0,0 +1,74 @@ +import SmartLink from '@/components/SmartLink' +import { useState } from 'react' + +/** + * 支持下拉二级的菜单 + * @param {*} param0 + * @returns + */ +export const MenuItemDrop = ({ link, variant = 'default' }) => { + const [show, changeShow] = useState(false) + const hasSubMenu = link?.subMenus?.length > 0 + const isInline = variant === 'inline' + + const itemShell = isInline + ? 'relative flex-shrink-0' + : 'cursor-pointer' + + const linkBox = isInline + ? 'rounded-md px-2 py-1.5 tl-nav-link no-underline flex items-center gap-1.5 whitespace-nowrap' + : 'rounded px-2 md:pl-0 md:mr-3 my-4 md:pr-3 text-gray-700 dark:text-gray-200 no-underline md:border-r border-gray-light' + + return ( +
  • changeShow(true)} + onMouseOut={() => changeShow(false)}> + {!hasSubMenu && ( +
    + + {link?.icon && } {link?.name} + {hasSubMenu && } + +
    + )} + + {hasSubMenu && ( +
    + {link?.icon && } {link?.name} + +
    + )} + + {/* 子菜单 */} + {hasSubMenu && ( +
      + {link.subMenus.map((sLink, index) => { + return ( +
    • + + + {link?.icon &&   } + {sLink.title} + + +
    • + ) + })} +
    + )} +
  • + ) +} diff --git a/themes/thoughtlite/components/MenuList.js b/themes/thoughtlite/components/MenuList.js new file mode 100644 index 00000000000..dcc39f7c80b --- /dev/null +++ b/themes/thoughtlite/components/MenuList.js @@ -0,0 +1,86 @@ +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import CONFIG from '../config' +import { MenuItemDrop } from './MenuItemDrop' + +/** + * 导航菜单列表 + * @param {*} props + * @param {'stack'|'header'} [props.variant] stack:旧版通栏导航;header:顶栏内横向滚动 + * @returns + */ +export const MenuList = props => { + const { customNav, customMenu, variant = 'stack' } = props + const { locale } = useGlobal() + + let links = [ + { + id: 1, + icon: 'fas fa-search', + name: locale.NAV.SEARCH, + href: '/search', + show: siteConfig('THOUGHTLITE_MENU_SEARCH', null, CONFIG) + }, + { + id: 2, + icon: 'fas fa-archive', + name: locale.NAV.ARCHIVE, + href: '/archive', + show: siteConfig('THOUGHTLITE_MENU_ARCHIVE', null, CONFIG) + }, + { + id: 3, + icon: 'fas fa-folder', + name: locale.COMMON.CATEGORY, + href: '/category', + show: siteConfig('THOUGHTLITE_MENU_CATEGORY', null, CONFIG) + }, + { + id: 4, + icon: 'fas fa-tag', + name: locale.COMMON.TAGS, + href: '/tag', + show: siteConfig('THOUGHTLITE_MENU_TAG', null, CONFIG) + } + ] + + if (customNav) { + links = links.concat(customNav) + } + + // 如果 开启自定义菜单,则不再使用 Page生成菜单。 + if (siteConfig('CUSTOM_MENU')) { + links = customMenu + } + + if (!links || links.length === 0) { + return null + } + + if (variant === 'header') { + return ( + + ) + } + + return ( + + ) +} diff --git a/themes/thoughtlite/components/PostLock.js b/themes/thoughtlite/components/PostLock.js new file mode 100644 index 00000000000..bd42b97317d --- /dev/null +++ b/themes/thoughtlite/components/PostLock.js @@ -0,0 +1,56 @@ +import { useGlobal } from '@/lib/global' +import { useEffect, useRef } from 'react' + +/** + * 文章锁;通过此组件校验密码访问文章 + */ +export const PostLock = props => { + const { validPassword } = props + const { locale } = useGlobal() + + const submitPassword = () => { + const p = document.getElementById('password') + if (!validPassword(p?.value)) { + const tips = document.getElementById('tips') + if (tips) { + tips.innerHTML = '' + tips.innerHTML = `
    ${locale.COMMON.PASSWORD_ERROR}
    ` + } + } + } + const passwordInputRef = useRef(null) + useEffect(() => { + passwordInputRef.current?.focus() + }, []) + + return ( +
    +
    +
    + {locale.COMMON.ARTICLE_LOCK_TIPS} +
    +
    + { + if (e.key === 'Enter') { + submitPassword() + } + }} + ref={passwordInputRef} + className='min-w-0 flex-1 border-0 bg-transparent py-3 pl-4 text-sm text-[var(--tl-text)] outline-none' + /> +