Compare commits

..

27 Commits

Author SHA1 Message Date
codex-bot 71e6a07054 Merge latest dashboard and Turbo setup into dev while preserving workspace features 2026-11-04 23:16:28 +08:00
codex-bot 61d80f352f Rename the unified application to dashboard 2026-11-04 23:01:32 +08:00
Zayden-Jung 4ec83f2603 Merge pull request '在现有 pnpm monorepo 中接入 Turborepo' (#50) from codex/turborepo into main
Reviewed-on: #50
2026-11-04 14:57:46 +00:00
codex-bot 51347669a4 Adopt Turborepo in the existing pnpm workspace 2026-11-04 22:49:09 +08:00
Zayden-Jung 6e8dc8942a Merge pull request '合并 Wiki 与 Studio,精简为单应用仓库' (#49) from codex/unify-and-prune into main
Reviewed-on: #49
Reviewed-by: Zayden-Jung <zayden.jung@entroia.com>
2026-11-04 14:37:17 +00:00
codex-bot 5d74251032 Unify Wiki and Studio and remove deployment scaffolding 2026-11-04 22:14:35 +08:00
codex-bot 195e8d6deb Restore routine dev preview sync after workspace rollout
Remote development preview source sync / sync-source (push) Successful in 10s
2026-11-04 17:37:58 +08:00
codex-bot cf0bc49732 Verify workspace membership on dev with isolated accounts
Remote development preview source sync / sync-source (push) Successful in 15s
2026-11-04 17:32:36 +08:00
codex-bot 7e104cd660 Apply the workspace schema to the dev preview
Unified application CI and deployment / static-checks (push) Successful in 6s
Remote development preview source sync / sync-source (push) Successful in 9s
Unified application CI and deployment / verify-and-release (push) Has been skipped
2026-11-04 17:28:18 +08:00
codex-bot b6718f9981 Support creating and joining account workspaces 2026-11-04 17:28:18 +08:00
codex-bot 25f6f5921b Keep the workspace canvas tree in the editor
Unified application CI and deployment / static-checks (push) Successful in 6s
Remote development preview source sync / sync-source (push) Successful in 7s
Unified application CI and deployment / verify-and-release (push) Has been skipped
2026-11-04 17:14:00 +08:00
codex-bot 48c7ae0e98 Show recent canvas edits on the workspace landing page
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Successful in 8s
Unified application CI and deployment / verify-and-release (push) Has been skipped
2026-11-04 17:02:14 +08:00
codex-bot 2bc44c6a9e Show canvas subsets in the workspace tree
Unified application CI and deployment / static-checks (push) Successful in 6s
Remote development preview source sync / sync-source (push) Successful in 9s
Unified application CI and deployment / verify-and-release (push) Has been skipped
2026-11-04 16:52:46 +08:00
codex-bot f020551d02 Revert "Import approved PostgreSQL snapshot into dev"
Unified application CI and deployment / static-checks (push) Successful in 7s
Remote development preview source sync / sync-source (push) Successful in 16s
Unified application CI and deployment / verify-and-release (push) Has been skipped
This reverts commit 8d37fabe58.
2026-11-04 16:37:09 +08:00
codex-bot 8d37fabe58 Import approved PostgreSQL snapshot into dev
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Successful in 50s
Unified application CI and deployment / verify-and-release (push) Has been skipped
2026-11-04 16:35:08 +08:00
codex-bot 95df8df552 Revert "Probe development preview from the runner"
Remote development preview source sync / sync-source (push) Successful in 8s
This reverts commit 5a5a2934da.
2026-11-04 16:18:49 +08:00
codex-bot 5a5a2934da Probe development preview from the runner
Remote development preview source sync / sync-source (push) Successful in 14s
2026-11-04 16:15:19 +08:00
codex-bot 484cc0ba84 Build a dual-sidebar workspace dashboard
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Successful in 7s
Unified application CI and deployment / verify-and-release (push) Has been skipped
2026-11-04 15:07:20 +08:00
codex-bot b1df6077cd Track dev in the remote preview
Remote development preview source sync / sync-source (push) Successful in 7s
2026-11-04 14:47:09 +08:00
codex-bot 896c8bb9e5 Record preview synchronization and runtime identity
Unified application CI and deployment / static-checks (push) Successful in 6s
Remote development preview source sync / sync-source (push) Successful in 7s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:34:45 +08:00
codex-bot 1df5790d23 Give the preview process ownership of its Next cache
Unified application CI and deployment / static-checks (push) Successful in 7s
Remote development preview source sync / sync-source (push) Successful in 1m28s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:32:04 +08:00
codex-bot fe26f03cf4 Use numeric container ownership during preview setup
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Failing after 4m54s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:25:41 +08:00
codex-bot 19e179dd20 Run a persistent remote development preview
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Failing after 17s
python CI and deployment / static-checks (push) Successful in 4s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 4s
Unified application CI and deployment / verify-and-release (push) Has been skipped
python CI and deployment / verify-and-deploy (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:24:01 +08:00
codex-bot 7f88ebba71 Run only static checks before main and gate full release on main push
Unified application CI and deployment / static-checks (push) Successful in 1m9s
python CI and deployment / static-checks (push) Successful in 1m7s
Unified application CI and deployment / static-checks (pull_request) Successful in 1m7s
python CI and deployment / static-checks (pull_request) Successful in 1m6s
Unified application CI and deployment / verify-and-release (push) Has been skipped
python CI and deployment / verify-and-deploy (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 04:24:31 +08:00
codex-bot 0a77d03220 Cache dependency downloads by lockfile and preserve Studio runtime checks
python CI and deployment / verify-and-deploy (pull_request) Successful in 1m12s
Unified application CI and deployment / verify-and-release (pull_request) Has been cancelled
2026-11-04 04:02:48 +08:00
codex-bot 6654a1456b Unify Wiki and Studio application and simplify deployment
Unified application CI and deployment / verify-and-release (pull_request) Waiting to run
python CI and deployment / verify-and-deploy (pull_request) Successful in 1m12s
2026-11-04 03:41:16 +08:00
codex-bot 80d23f0e3d Improve Docker dependency caching and isolate Python runtime
Endoleap brand checks / verify (pull_request) Successful in 2m27s
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 3m10s
creative workspace review checks / verify (pull_request) Successful in 42m47s
python CI and deployment / verify-and-deploy (pull_request) Successful in 9m51s
2026-11-02 21:54:14 +08:00
382 changed files with 4129 additions and 19227 deletions
-24
View File
@@ -1,24 +0,0 @@
.git
**/.next
**/node_modules
**/dist
coverage
.env
.env.*
!.env.example
*.tsbuildinfo
.DS_Store
.agents
.codex
# Documentation and CI metadata are not application build inputs.
/docs
/.gitea
/README.md
/AGENTS.md
**/__pycache__
**/.venv
# Local authoring assets are uploaded via APIs, never application build inputs.
/content
-27
View File
@@ -1,27 +0,0 @@
name: Endoleap brand checks
on:
pull_request:
branches: [main]
paths:
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- 'package.json'
- 'AGENTS.md'
- '.gitea/workflows/brand.yml'
push:
branches: [main]
paths:
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/brand.yml'
concurrency:
group: brand-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ces-control
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Verify brand assets, Tailwind compilation and typed exports in Docker
run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" .
-80
View File
@@ -1,80 +0,0 @@
name: creative workspace review checks
on:
pull_request:
branches: [main]
paths:
- 'apps/studio/**'
- 'apps/waterfall-wiki/**'
- '.gitea/workflows/**'
- 'scripts/*studio*'
- 'compose.creative-review.yml'
- 'scripts/integration.sh'
- 'scripts/cleanup-integration.sh'
- 'tests/test_integration_cleanup.py'
- 'scripts/cleanup-review-images.sh'
- 'tests/test_review_image_cleanup.py'
concurrency:
group: creative-workspace-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify-deployed-canvas:
runs-on: ces-control
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Verify production acceptance against the designated deployed server
run: |
node --check scripts/verify-studio-visual.cjs
node --check apps/studio/scripts/infinite-canvas-smoke.cjs
bash -n scripts/verify-studio-production.sh
bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
verify:
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check patch formatting
run: |
git diff --check HEAD^ HEAD
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
node --check scripts/verify-studio-visual.cjs
python3 tests/test_integration_cleanup.py
python3 tests/test_review_image_cleanup.py
node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
- name: Verify Studio types, tests, build and smoke
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
- name: Build Wiki integration and runtime images
run: |
docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" .
docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
- name: Verify isolated PostgreSQL, S3 and browser integration
env:
STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }}
WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/integration.sh
- name: Clean this run disposable integration resources even after cancellation
if: always()
env:
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/cleanup-integration.sh
- name: Remove this run disposable review image tags
if: always()
env:
REVIEW_SHA: ${{ gitea.sha }}
run: bash scripts/cleanup-review-images.sh
-23
View File
@@ -1,23 +0,0 @@
name: python CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and verify python
run: docker build -f apps/python/Dockerfile -t "prompt-python:${{ gitea.sha }}" .
- name: Release python
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: bash scripts/deploy-python.sh "${{ gitea.sha }}"
-71
View File
@@ -1,71 +0,0 @@
name: creative workspace CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/waterfall-wiki/**'
- 'apps/studio/**'
- 'scripts/*studio*'
- 'content/studio-examples/**'
- 'content/studio-tools/layouts-api.md'
- '.gitea/workflows/waterfall-wiki.yml'
- 'Dockerfile'
- 'compose.yml'
- 'scripts/deploy.sh'
- 'scripts/wiki-image-lifecycle.py'
- 'scripts/infra.sh'
- 'scripts/auth-admin.sh'
- 'scripts/integration.sh'
- 'scripts/cleanup-integration.sh'
- 'tests/test_integration_cleanup.py'
- 'pnpm-workspace.yaml'
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check shared content and documentation contracts
run: |
python3 tests/test_integration_cleanup.py
bash -n scripts/integration.sh scripts/cleanup-integration.sh
node scripts/check-studio-contracts.mjs
node --check scripts/export-studio-series.cjs
node --check scripts/save-studio-series.mjs
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
- name: Build and verify Studio
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- name: Build, verify, deploy and retain wiki images
env:
RELEASE_SHA: ${{ gitea.sha }}
run: |
if [ "$(id -u)" -eq 0 ]; then
python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
else
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
fi
- name: Release Studio after Wiki routes are deployed
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
- name: Verify the deployed creative workspace
run: bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
- name: Clean this run disposable integration resources even after cancellation
if: always()
env:
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/cleanup-integration.sh
@@ -1,19 +0,0 @@
name: Wiki image retention checks
on:
push:
branches: [main]
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
jobs:
retention-policy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check retention without building or accessing the Docker daemon
run: |
bash -n scripts/deploy.sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
+5 -3
View File
@@ -1,4 +1,6 @@
node_modules/
.pnpm-store/
.turbo/
.next/
coverage/
.env*
@@ -25,6 +27,6 @@ __pycache__/
/apps/studio/public/brand/
/apps/studio/public/favicon.ico
/apps/studio/public/apple-touch-icon.png
/apps/waterfall-wiki/public/brand/
/apps/waterfall-wiki/public/favicon.ico
/apps/waterfall-wiki/public/apple-touch-icon.png
/apps/dashboard/public/brand/
/apps/dashboard/public/favicon.ico
/apps/dashboard/public/apple-touch-icon.png
+9 -8
View File
@@ -8,14 +8,15 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
<!-- END:nextjs-agent-rules -->
## 构建与验收
## 项目维护
- 在 ces-control 上执行构建、产物生成和需要编译的检查。本地可执行差异审查及无需构建的轻量检查。
- 页面改动经主线部署后,在指定的 [服务器入口](http://8.211.154.134:18131/) 验收。不要启动本地网页预览。
- CI 触发条件与发布路径见 [构建与入口](docs/ci-and-entrypoints.md)。
- Wiki 和 Studio 共用 `apps/dashboard` 的 Pages Router、登录保护和品牌包。保留已发布的路由、媒体标识和数据库迁移。
## 品牌
## 开发分支与集成
- 修改品牌色、主题或 Logo 前,查阅 [共享品牌包](packages/brand/README.md)。
- 使用 home-light、home-dark、canvas-light、canvas-dark 四套主题。画布背景保持中性近白或近黑,用户素材保持原色。
- 色板和 Logo 轮廓分别以品牌包的 `tokens/palette.json` 和 `tokens/geometry.json` 为准,更新时保持 CSS、类型、资产和清单一致。
- `dev` 是日常开发成果的集成分支。开始新任务前检查工作区和远程状态,默认从最新 `origin/dev` 创建或复用对应任务分支,避免夹带其他任务的未完成改动。
- 每项任务完成并通过适当检查后,提交并推送任务分支,再将该任务合并到 `dev` 并推送;用户已持续授权这类向 `dev` 的合并与推送,无需逐次确认。
- 合并前检查完整差异和待合入提交;保留 `dev` 已有工作,冲突解决后重新执行受影响的检查。不强推或重写共享历史,不擅自删除任务分支。
- `main` 保持人工集成:未经用户针对本次操作明确授权,不直接推送 `main`,也不代为合并面向 `main` 的 PR。需要发布到主线时,准备 `dev` 到 `main` 的可评审 PR,等待用户手动合并。
- 当前不使用 CI/CD,不因合并或推送 `dev` 自动恢复旧流水线、远程预览或部署安排。完整构建与运行验证是否执行及其环境,按用户另行授权处理。
- 交付报告说明任务分支、提交、检查结果以及是否已合并并推送 `dev`;区分 `dev` 集成与 `main` 合并、部署。
-41
View File
@@ -1,41 +0,0 @@
FROM node:24-bookworm-slim AS base
LABEL io.entroia.project="waterfall-wiki"
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates chromium fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
WORKDIR /repo/apps/waterfall-wiki
LABEL io.entroia.wiki.build="true"
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
USER node
CMD ["npm", "run", "test:integration"]
FROM base AS runtime
WORKDIR /app/apps/waterfall-wiki
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+30 -28
View File
@@ -1,42 +1,44 @@
# 啡跃 Endoleap
啡跃把笔记、参考素材和创作工作台放在同一个网站。知识库用于浏览、搜索和发布内容,Studio 提供图文排版与无限画布。
[打开啡跃](http://8.211.154.134:18131/)
啡跃把笔记、参考素材和创作工作台放在同一个 Next.js 应用里。应用位于 `apps/dashboard`,共享主题与品牌资源位于 `packages/brand`。
| 入口 | 用途 |
| --- | --- |
| `/` | 浏览内容,打开 `/explore/<id>` 查看单篇笔记 |
| `/` | 浏览内容,打开 `/explore/<id>` 查看笔记 |
| `/guide` | 浏览指导文档 |
| `/graphic` | 打开图文工作台 |
| `/infinite_canvas` | 打开无限画布 |
| `/api?agent_mode=True` | 查看业务 API 目录 |
| `/graphic` | 图文排版 |
| `/infinite_canvas` | 无限画布 |
| `/api?agent_mode=True` | 业务 API 目录 |
| `/studio/api` | Studio API 目录 |
自动化客户端可以在支持的页面或业务接口上加 `?agent_mode=True` 获取纯文本。读写权限与认证要求见对应接口说明。
## 运行
## 仓库结构
准备 Node.js 24、pnpm 11.25.0、PostgreSQL、S3 兼容对象存储和 FFmpeg。参考 [环境变量示例](apps/dashboard/.env.example),将变量注入 Web、初始化和媒体 worker 进程。邮箱注册与找回密码需要可用的 SMTP 服务。
| 路径 | 职责 |
| --- | --- |
| `apps/waterfall-wiki` | Next.js Pages Router 主站、账户、内容和媒体 API,以及后台媒体处理 |
| `apps/studio` | Next.js Pages Router 创作工作台及渲染 API,由主站提供统一入口 |
| `apps/python` | 按需运行的 PNG 和视频导出工具 |
| `packages/brand` | 共享主题、品牌资产及接入规范 |
| `content` | 排版源数据、发布教程和示例,供作者工具与契约检查读取 |
| `scripts` | 构建部署、运维与内容发布工具 |
| `tests` | 跨应用的仓库媒体与运维检查。应用自身的测试位于各自目录 |
在仓库根目录运行 `pnpm install --frozen-lockfile` 和 `pnpm build`。然后在 `apps/dashboard` 目录准备数据库和存储,初始化管理员认证并启动 Web。
## 验证与发布
```sh
node dist/bootstrap.cjs
node dist/auth-cli.cjs init
pnpm start
```
JavaScript 工作区使用 Node.js 24 和 pnpm 11.25.0。生产服务由 Docker Compose 编排,数据保存在 PostgreSQL 和 S3 兼容对象存储中。
媒体 worker 在同一目录作为独立进程运行 `node dist/worker.cjs`。绑定管理员验证器时,在远程交互终端执行 `node dist/auth-cli.cjs qr`。已有实例复用原数据库、对象存储和私有认证文件。
需要编译或生成产物的验证在 ces-control 执行。发布工作流由匹配路径的 `main` 推送触发。页面验收使用部署后的指定服务器,详见 [AGENTS.md](AGENTS.md) 和 [构建与发布说明](docs/ci-and-entrypoints.md)。
## Monorepo 与 Turborepo
## 维护文档
仓库在现有 pnpm workspace 上接入 Turborepo,无需运行面向新项目的 `npx create-turbo@latest`。应用仍位于 `apps/dashboard`,源码品牌包仍位于 `packages/brand`;子包脚本和应用产物路径保持原样。依赖统一锁定在根目录 `pnpm-lock.yaml`,安装从仓库根目录执行。
- [业务 API](docs/api.md)与[数据模型](docs/data-model.md)
- [动态码登录与运维](docs/dynamic-code-login.md)
- [指导文档入口](docs/guidance.md)与[发布协议](docs/linked-guidance/publishing.md)
- [对象存储](docs/s3-media.md)与[镜像保留策略](docs/wiki-image-retention.md)
- [Studio 排版 API 与示例](content/studio-tools/layouts-api.md)
- [共享品牌包](packages/brand/README.md)
根目录命令由 Turbo 调度:`pnpm dev` 启动开发任务,`pnpm dev:dashboard` 仅启动 Wiki,`pnpm build` 构建应用及 worker/CLI,`pnpm typecheck` 生成 Next.js 类型并检查 TypeScript。`pnpm start` 继续直接启动现有应用。也可以在子包目录使用原有命令。
锁文件合并保留应用原有依赖解析。唯一重叠差异是 `@tailwindcss/postcss` 的间接 PostCSS 依赖:品牌包原为 8.5.32,现在沿用应用的 8.5.31;品牌包直接声明的 PostCSS 8.5.6 不变。
迁移阶段所有任务禁用缓存,根命令使用 `--env-mode=loose` 透传既有环境变量,保留品牌资源准备步骤的每次执行。后续启用缓存前,需明确环境变量输入、跨包资源和所有输出。当前不配置 CI/CD,也不自动发布。
仅检查任务计划、不执行编译或生成应用产物时,可运行 `pnpm build --dry=json` 和 `pnpm typecheck --dry=json`。完整构建与运行验证另行执行。
## 开发流程
`dev` 集成已完成的任务。新任务默认从最新 `origin/dev` 创建独立分支,检查完成后提交并推送任务分支,再合并并推送 `dev`。`main` 仍通过 PR 由用户手动合并;推送 `dev` 不代表已发布或部署。当前不使用 CI/CD。
本次同步保留原 `dev` 的双侧栏工作区、画布子集与近期编辑记录、工作区创建和加入 API,以及 `db/018_workspaces.sql` 迁移。已有数据库仍需在应用启动前按“运行”章节执行数据库初始化;合并代码本身不会执行迁移。认证 Cookie 名称继续支持原 `dev` 的 `ACCOUNT_COOKIE_NAME` 与 `AUTH_COOKIE_NAME` 配置,未设置时使用原默认名称。
+18
View File
@@ -0,0 +1,18 @@
# Export to the web, storage bootstrap and worker processes.
DATABASE_URL=postgresql://endoleap:replace-me@127.0.0.1:5432/endoleap
S3_ENDPOINT=http://127.0.0.1:9000
S3_ACCESS_KEY=replace-me
S3_SECRET_KEY=replace-me
S3_BUCKET=prompt
PORT=3000
AUTH_CONFIG_FILE=/path/to/private/auth.json
AUTH_COOKIE_SECURE=true
ACCOUNT_PUBLIC_ORIGIN=https://endoleap.example
ACCOUNT_TRUST_PROXY=false
# Email verification and recovery
SMTP_HOST=
SMTP_PORT=465
SMTP_USERNAME=
SMTP_FROM=
SMTP_PASSWORD=
+22
View File
@@ -0,0 +1,22 @@
BEGIN;
CREATE TABLE workspaces (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES account_users(id),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 40),
purpose text NOT NULL CHECK (purpose IN ('工作与团队','个人创作','学习与研究')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE workspace_members (
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE,
PRIMARY KEY (workspace_id, user_id)
);
CREATE INDEX workspace_members_user ON workspace_members(user_id);
CREATE TABLE workspace_invites (
digest text PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL
);
CREATE INDEX workspace_invites_workspace ON workspace_invites(workspace_id);
INSERT INTO schema_migrations(version) VALUES ('018_workspaces');
COMMIT;
@@ -11,10 +11,11 @@ const nextConfig: NextConfig = {
{source:'/studio',destination:'/workspace',permanent:false},
]},
async rewrites(){return [
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
{source:'/graphic',destination:'http://prompt-studio:3000/studio/graphic'},
{source:'/infinite_canvas',destination:'http://prompt-studio:3000/studio/infinite_canvas'},
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/dashboard/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/dashboard/public/".length),destination:"/api/media/"+asset.id})),
{source:"/studio/api/:path*",destination:"/api/studio-ui/:path*"},
{source:"/studio/brand/:path*",destination:"/brand/:path*"},
{source:"/studio/favicon.ico",destination:"/favicon.ico"},
{source:"/studio/apple-touch-icon.png",destination:"/apple-touch-icon.png"},
{source:"/index.md",destination:"/api/agent-index"},
{source:"/skills/index.md",destination:"/api/guidance/skills"},
{source:"/skills/:path+/index.md",destination:"/api/guidance/skills/:path+"},
@@ -22,8 +23,6 @@ const nextConfig: NextConfig = {
{source:"/agent/index.md",destination:"/api/agent-index"},
{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"},
]},
output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."),
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
poweredByHeader: false,
};
@@ -1,5 +1,5 @@
{
"name": "@prompt/waterfall-wiki",
"name": "@prompt/dashboard",
"version": "0.1.0",
"private": true,
"engines": {
@@ -7,15 +7,11 @@
},
"scripts": {
"dev": "pnpm brand:prepare && next dev",
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts",
"test:smoke": "node scripts/smoke.mjs",
"storage:prepare": "tsx scripts/bootstrap.ts",
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
"worker": "tsx src/worker.ts",
"test:integration": "tsx --test tests/integration.test.ts",
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
},
"dependencies": {

Before

Width:  |  Height:  |  Size: 451 B

After

Width:  |  Height:  |  Size: 451 B

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -1,4 +1,4 @@
/** The approved mark is copied from @endoleap/brand during the remote build. */
export function Brand({ compact = false }: { compact?: boolean }) {
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
export function Brand({ compact = false, markOnly = false }: { compact?: boolean; markOnly?: boolean }) {
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/>{!markOnly && <span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span>}</span>;
}
@@ -0,0 +1,366 @@
import Head from 'next/head';
import {useEffect, useRef, useState, type ReactNode} from 'react';
import {ArrowLeft, ArrowUpRight, BookOpen, Bot, Check, ChevronDown, ChevronRight, Layers, LogOut, User, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
import {Brand} from './Brand';
import {ThemeToggle} from './ThemeToggle';
import {parseCanvasDocument, type CanvasNode} from '@/lib/canvas-document';
import s from '@/styles/workspace.module.css';
const tools = [
{label: '无限画布', detail: '收集灵感,连接文字与参考素材', href: '/infinite_canvas', icon: Workflow},
{label: '图形设计', detail: '编辑文字、图像与版式', href: '/graphic', icon: Palette},
{label: '知识与教程', detail: '查找指南和创作方法', href: '/documents', icon: BookOpen},
{label: 'Agent 接入', detail: '了解如何连接创作工具与 API', href: '/studio/api', icon: Bot},
];
const purposes = [
{label: '工作与团队', detail: '整理项目和工作资料。', icon: LayoutGrid},
{label: '个人创作', detail: '收集灵感,让想法成形。', icon: House},
{label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen},
];
type Space = {id?: string; name: string; purpose: string; owner?: boolean};
const spaceKey = (space: Space) => space.id ?? space.name;
export type Subcanvas = {id: string; name: string; nodes: string[]; editedAt?: string};
const personal = {name: '个人空间', purpose: ''};
type WorkspaceShellProps = {
editor?: (subset: Subcanvas | undefined) => ReactNode;
liveNodes?: CanvasNode[];
};
export function WorkspaceShell({editor, liveNodes}: WorkspaceShellProps) {
const [email, setEmail] = useState('');
const [state, setState] = useState('loading');
const [sidebar, setSidebar] = useState(true);
const [spaces, setSpaces] = useState<Space[]>([]);
const [active, setActive] = useState(personal);
const [query, setQuery] = useState('');
const [step, setStep] = useState<'choice' | 'join' | 'purpose' | 'name' | 'invite'>('choice');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [purpose, setPurpose] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [savedNodes, setNodes] = useState<CanvasNode[]>([]);
const nodes = liveNodes ?? savedNodes;
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
const [selectedCanvas, setSelectedCanvas] = useState(editor ? '' : 'history');
const [draftEditedAt, setDraftEditedAt] = useState<string>();
const [expanded, setExpanded] = useState(true);
const [subsetName, setSubsetName] = useState('');
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
const subsetDialog = useRef<HTMLDialogElement>(null);
const dialog = useRef<HTMLDialogElement>(null);
const switcher = useRef<HTMLDetailsElement>(null);
const search = useRef<HTMLInputElement>(null);
useEffect(() => {
setSidebar(window.matchMedia('(min-width: 761px)').matches);
const request = new AbortController();
fetch('/api/account/session', {signal: request.signal}).then(async response => {
if (!response.ok) throw Error();
const data = await response.json();
if (!data.user) {
if (editor) setState('ready');
else window.location.replace('/account?next=/workspace');
return;
}
setEmail(data.user.email);
let local: Space[] = [];
let activeKey = '';
try {
const saved = JSON.parse(localStorage.getItem('studio.workspace:' + data.user.email) || 'null');
if (Array.isArray(saved?.spaces)) local = saved.spaces.filter((space: Space) => !space.id);
activeKey = saved?.active || '';
} catch {}
let shared: Space[] = [];
try {
const response = await fetch('/api/account/workspaces', {signal: request.signal});
if (response.ok) shared = (await response.json()).spaces;
} catch {}
if (request.signal.aborted) return;
const available = [...local, ...shared];
setSpaces(available);
setActive(available.find(space => spaceKey(space) === activeKey) || personal);
setState('ready');
}).catch(() => {
if (!request.signal.aborted) setState('error');
});
return () => request.abort();
}, []);
useEffect(() => {
if (!notice) return;
const timeout = window.setTimeout(() => setNotice(''), 4500);
return () => window.clearTimeout(timeout);
}, [notice]);
function selectSpace(space: Space, next = spaces) {
localStorage.setItem('studio.workspace:' + email, JSON.stringify({spaces: next, active: spaceKey(space)}));
setSpaces(next);
setActive(space);
switcher.current?.removeAttribute('open');
}
const subsetKey = 'studio.subcanvases:' + email + ':' + spaceKey(active);
useEffect(() => {
if (!email) return;
setSelectedCanvas(editor ? '' : 'history');
setQuery('');
try {
const draft = localStorage.getItem('studio.infinite-canvas.v1');
setDraftEditedAt(localStorage.getItem('studio.infinite-canvas.v1.editedAt') || undefined);
setNodes(draft ? parseCanvasDocument(draft).cards : []);
} catch {
setNodes([]);
}
try {
const saved = JSON.parse(localStorage.getItem(subsetKey) || '[]');
const list: Subcanvas[] = Array.isArray(saved) ? saved : [];
setSubcanvases(list);
if (editor) {
const id = new URLSearchParams(window.location.search).get('subset');
if (list.some(item => item.id === id)) setSelectedCanvas(id!);
}
} catch {
setSubcanvases([]);
}
}, [email, subsetKey]);
function openSubsetDialog(edit?: Subcanvas) {
setError('');
setSubsetName(edit?.name ?? '');
setSubsetNodes(edit?.nodes ?? []);
subsetDialog.current?.showModal();
}
function saveSubset(event: React.FormEvent) {
event.preventDefault();
const name = subsetName.trim();
if (!name || !subsetNodes.length) return setError('请输入名称并选择至少一个节点。');
const id = selectedCanvas || window.crypto?.randomUUID?.() || String(Date.now());
const subset = {id, name, nodes: subsetNodes, editedAt: new Date().toISOString()};
const next = [...subcanvases.filter(item => item.id !== id), subset];
try {
localStorage.setItem(subsetKey, JSON.stringify(next));
setSubcanvases(next);
setSelectedCanvas(id);
setExpanded(true);
subsetDialog.current?.close();
} catch {
setError('浏览器无法保存子画布。');
}
}
async function signOut() {
try {
const response = await fetch('/api/account/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
if (!response.ok) throw Error();
window.location.assign('/account?next=/workspace');
} catch {
setNotice('暂时无法退出登录,请重试。');
}
}
function openDialog() {
setStep('choice');
setCode('');
setPurpose('');
setName('');
setError('');
dialog.current?.showModal();
}
async function workspaceRequest(payload: object) {
const response = await fetch('/api/account/workspaces', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
const result = await response.json();
if (!response.ok) throw new Error(result.error || '空间操作失败');
return result;
}
async function submitWorkspace(event: React.FormEvent) {
event.preventDefault();
if (busy) return;
const clean = name.trim();
if (step === 'name' && !clean) return setError('请填写 Workspace 名称。');
if (step === 'join' && !code.trim()) return setError('请输入邀请码。');
setBusy(true);
setError('');
try {
const {space} = await workspaceRequest(step === 'join' ? {action:'join', code:code.trim()} : {action:'create', name:clean, purpose});
selectSpace(space, [...spaces.filter(item => spaceKey(item) !== spaceKey(space)), space]);
dialog.current?.close();
setNotice((step === 'join' ? '已加入' : '已创建') + '「' + space.name + '」');
} catch (error) {
setError(error instanceof Error ? error.message : '空间操作失败');
} finally {
setBusy(false);
}
}
async function inviteMember() {
if (busy || !active.id) return;
switcher.current?.removeAttribute('open');
setCode('');
setError('');
setStep('invite');
dialog.current?.showModal();
setBusy(true);
try {
const result = await workspaceRequest({action:'invite', workspaceId:active.id});
setCode(result.code);
} catch (error) {
setError(error instanceof Error ? error.message : '无法生成邀请码');
} finally {
setBusy(false);
}
}
const subset = subcanvases.find(item => item.id === selectedCanvas);
const visibleNodes = subset ? nodes.filter(node => subset.nodes.includes(node.id)) : nodes;
const visibleSubsets = subcanvases.filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
const canvasName = '无限画布';
const showHistory = selectedCanvas === 'history';
const dates = new Intl.DateTimeFormat('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'});
const times = new Intl.DateTimeFormat('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false});
const entries = [
...(nodes.length || draftEditedAt ? [{id: '', name: canvasName, editedAt: draftEditedAt, summary: nodes[0]?.text || nodes[0]?.title || '本机草稿'}] : []),
...subcanvases.map(item => ({...item, summary: nodes.find(node => item.nodes.includes(node.id))?.text || '子画布'})),
].filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
entries.sort((a, b) => (b.editedAt ?? '').localeCompare(a.editedAt ?? ''));
const history = new Map<string, typeof entries>();
for (const entry of entries) {
const day = entry.editedAt && Number.isFinite(Date.parse(entry.editedAt)) ? dates.format(new Date(entry.editedAt)) : '编辑时间未记录';
const group = history.get(day) ?? [];
group.push(entry);
history.set(day, group);
}
return <div className={s.app}>
{!editor && <Head><title>工作台 · 啡跃</title></Head>}
<nav className={s.rail} aria-label="全局导航">
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand markOnly/></a>
<button type="button" aria-label="切换工作台导航" title="切换工作台导航" aria-current="page" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><LayoutGrid size={18}/></button>
<a href="/infinite_canvas" aria-label="无限画布" title="无限画布"><Workflow size={18}/></a>
<button type="button" aria-label="搜索画布" title="搜索画布" disabled={state !== 'ready'} onClick={() => {setSidebar(true); requestAnimationFrame(() => search.current?.focus());}}><Search size={18}/></button>
<a href="/graphic" aria-label="图形设计" title="图形设计"><Palette size={18}/></a>
<a href="/studio/api" aria-label="Agent 接入" title="Agent 接入"><Bot size={18}/></a>
<a className={s.settings} href="/account" aria-label="账户设置" title="账户设置"><Settings size={18}/></a>
</nav>
{sidebar && <aside className={s.panel} id="workspace-navigation">
<div className={s.identity}>
<details ref={switcher} className={s.switcher}>
<summary><span className={s.avatar}>{Array.from(active.name)[0]}</span><strong>{active.name}</strong><ChevronDown size={14}/></summary>
{state === 'ready' && <div className={s.menu}>
<div className={s.menuIdentity}><span className={s.avatar}>{Array.from(active.name)[0]}</span><div><strong>{active.name}</strong><small>{active.id ? '当前空间 · 共享空间' : '当前空间 · 保存在此浏览器'}</small></div></div>
{email && <div className={s.menuSection}>
{active.owner && <button type="button" onClick={() => void inviteMember()}><Plus size={16}/>邀请成员</button>}<small>{email}</small>
{[personal, ...spaces].map(space => <button type="button" key={spaceKey(space)} aria-pressed={spaceKey(space) === spaceKey(active)} onClick={() => {
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
}}><span className={s.avatar}>{Array.from(space.name)[0]}</span><span>{space.name}</span>{spaceKey(space) === spaceKey(active) && <Check size={16}/>}</button>)}
<button className={s.menuAdd} type="button" onClick={() => {switcher.current?.removeAttribute('open'); openDialog();}}><Plus size={16}/>添加 Workspace</button>
</div>}
<div className={s.menuSection}><a href="/account"><User size={16}/>账户管理</a>{email && <button type="button" onClick={() => void signOut()}><LogOut size={16}/>退出登录</button>}</div>
</div>}
</details>
<a className={s.avatar} href="/account" aria-label="账户设置" title={email || '账户设置'}>{email ? email.charAt(0).toUpperCase() : <Settings size={15}/>}</a>
</div>
<div className={s.panelTitle}><strong>画布</strong><button type="button" disabled={!email || !nodes.length} onClick={() => {setSelectedCanvas(''); openSubsetDialog();}} aria-label="新建子画布" title="新建子画布"><Plus size={18}/></button></div>
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索画布…" aria-label="搜索画布名称"/></label>
{state === 'ready' && <nav className={s.links} aria-label="画布树">
<button className={s.historyLink} type="button" aria-current={showHistory ? 'page' : undefined} onClick={() => editor ? window.location.assign('/workspace') : setSelectedCanvas('history')}>编辑历史</button>
<p>本机画布</p>
<div className={s.treeRoot}>
<button type="button" aria-label="展开或折叠子画布" aria-expanded={expanded || !!query} disabled={!!query} onClick={() => setExpanded(!expanded)}>{expanded || query ? <ChevronDown size={15}/> : <ChevronRight size={15}/>}</button>
<button type="button" aria-current={!subset && !showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
</div>
{(expanded || query) && <ul className={s.children}>{visibleSubsets.map(item => <li key={item.id}><button type="button" aria-current={item.id === selectedCanvas ? 'page' : undefined} onClick={() => setSelectedCanvas(item.id)}><LayoutGrid size={15}/>{item.name}</button></li>)}</ul>}
{query && !visibleSubsets.length && !canvasName.includes(query.trim()) && <p role="status">没有匹配的画布。</p>}
</nav>}
<small className={s.local}>空间设置保存在此浏览器</small>
</aside>}
<main className={editor ? s.editorMain : s.main}>
{editor ? <>
<button className={s.editorToggle} type="button" aria-label="切换画布树" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button>
{editor(subset)}
</> : <>
<header className={s.top}>
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}</span></div>
<ThemeToggle/>
</header>
{state === 'loading' ? <p className={s.status} role="status">正在打开工作台…</p> : state === 'error' ? <p className={s.status} role="alert">暂时无法读取账户。 <a href="/account?next=/workspace">重新登录</a></p> :
showHistory ? <section className={s.history} aria-labelledby="history-heading">
<header><div><h1 id="history-heading">编辑历史</h1><p>按最近编辑日期排列</p></div><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></header>
{Array.from(history, ([day, items]) => <section className={s.historyGroup} key={day}>
<h2>{day} · {items.length}</h2>
<ul>{items.map(item => <li key={item.id}><button type="button" onClick={() => setSelectedCanvas(item.id)}>
{item.id ? <LayoutGrid size={18}/> : <Layers size={18}/>}
<span className={s.historyItem}><strong>{item.name}</strong><small>{item.summary}</small></span>
{item.editedAt && Number.isFinite(Date.parse(item.editedAt)) && <time dateTime={item.editedAt}>{times.format(new Date(item.editedAt))}</time>}
<ChevronRight size={16}/>
</button></li>)}</ul>
</section>)}
{!entries.length && <p className={s.historyEmpty}>{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}</p>}
</section> : <div className={s.content}>
<section className={s.intro}>
<Layers size={20}/>
<h1>{subset?.name ?? canvasName}</h1>
<p>{subset ? `子画布 · ${visibleNodes.length} 个节点 · 属于无限画布` : `${active.name} · ${nodes.length} 个节点`}</p>
{subset ? <><button className={s.primary} type="button" onClick={() => openSubsetDialog(subset)}>编辑子画布</button><a className={s.openSubset} href={'/infinite_canvas?subset=' + encodeURIComponent(subset.id)}>在编辑器打开 <ArrowUpRight size={16}/></a></> : <a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a>}
</section>
<section aria-labelledby="workspace-nodes">
<h2 id="workspace-nodes">画布内容</h2>
{visibleNodes.length ? <div className={s.tools}>{visibleNodes.map(node => <article className={s.node} key={node.id}><strong>{node.title || '未命名节点'}</strong>{node.image && <img src={node.image} alt=""/>}{node.text && <p>{node.text}</p>}</article>)}</div> : <p>{subset ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}</p>}
</section>
<nav className={s.shortcuts} aria-label="其他创作工具">{tools.slice(1).map(({label, href}) => <a key={href} href={href}>{label} <ArrowUpRight size={14}/></a>)}</nav>
</div>}
</>}
</main>
<dialog className={s.dialog} ref={subsetDialog} aria-labelledby="subcanvas-title">
<div className={s.dialogTop}><h2 id="subcanvas-title">{subset ? '编辑子画布' : '新建子画布'}</h2><button type="button" aria-label="关闭" onClick={() => subsetDialog.current?.close()}><X size={20}/></button></div>
<form onSubmit={saveSubset}>
<p>从无限画布选择节点。原画布会保留这些节点。</p>
<label htmlFor="subcanvas-name">子画布名称</label>
<input id="subcanvas-name" value={subsetName} maxLength={40} onChange={event => setSubsetName(event.target.value)}/>
<fieldset className={s.members}><legend>包含的节点</legend>{nodes.map(node => <label key={node.id}><input type="checkbox" checked={subsetNodes.includes(node.id)} onChange={event => setSubsetNodes(event.target.checked ? [...subsetNodes, node.id] : subsetNodes.filter(id => id !== node.id))}/>{node.title || '未命名节点'}</label>)}</fieldset>
{error && <p className={s.error} role="alert">{error}</p>}
<button className={s.primary} type="submit">保存子画布</button>
</form>
</dialog>
<dialog className={s.dialog} ref={dialog} aria-labelledby="workspace-dialog-title">
<div className={s.dialogTop}>
{step !== 'choice' && step !== 'invite' && <button type="button" aria-label="返回上一步" onClick={() => {setError(''); setStep(step === 'name' ? 'purpose' : 'choice');}}><ArrowLeft size={20}/></button>}
<h2 id="workspace-dialog-title">{{choice:'建立一个创作空间',join:'加入 Workspace',purpose:'这个空间用来做什么?',name:'给你的 Workspace 命名',invite:'邀请成员'}[step]}</h2>
<button type="button" aria-label="关闭" onClick={() => dialog.current?.close()}><X size={20}/></button>
</div>
{step === 'choice' ? <>
<p>加入已有空间,或创建自己的 Workspace。</p>
<div className={s.options}>
<button type="button" onClick={() => setStep('join')}><User size={22}/><span><strong>加入已有空间</strong><small>使用空间所有者提供的邀请码</small></span></button>
<button type="button" onClick={() => setStep('purpose')}><Plus size={22}/><span><strong>创建 Workspace</strong><small>为新项目建立一个空间</small></span></button>
</div>
</> : step === 'join' ? <form onSubmit={submitWorkspace}>
<label htmlFor="workspace-invite">邀请码</label>
<input id="workspace-invite" value={code} maxLength={80} onChange={event => setCode(event.target.value)}/>
{error && <p className={s.error} role="alert">{error}</p>}
<button className={s.primary} type="submit" disabled={busy}>加入 Workspace</button>
</form> : step === 'invite' ? <>
<p>邀请码有效期为 24 小时。把它交给要加入的成员。</p>
{busy && <p role="status">正在生成邀请码…</p>}
{error && <p className={s.error} role="alert">{error}</p>}
{code && <input aria-label="邀请码" readOnly value={code} onFocus={event => event.target.select()}/>}
</> : step === 'purpose' ? <>
<p>选择一个起点。</p>
<div className={s.options}>{purposes.map(({label, detail, icon: Icon}) => <button key={label} type="button" aria-pressed={purpose === label} onClick={() => setPurpose(label)}><Icon size={23}/><span><strong>{label}</strong><small>{detail}</small></span></button>)}</div>
<button className={s.primary} type="button" disabled={!purpose} onClick={() => setStep('name')}>继续 <ArrowUpRight size={16}/></button>
</> : <form onSubmit={submitWorkspace}>
<p>{purpose} · 一个空间,装下你的想法与作品。</p>
<div className={s.iconPreview}><span className={s.avatar}>{Array.from(name.trim())[0] || '空'}</span>空间图标</div>
<label htmlFor="workspace-name">Workspace 名称</label>
<input id="workspace-name" autoFocus value={name} maxLength={40} onChange={e => {setName(e.target.value); setError('');}} aria-invalid={!!error} aria-describedby={error ? 'workspace-error' : undefined} placeholder="输入空间名称"/>
{error && <p id="workspace-error" role="alert" className={s.error}>{error}</p>}
<button className={s.primary} type="submit" disabled={busy}>创建 Workspace <ArrowUpRight size={16}/></button>
</form>}
</dialog>
{notice && <div role="status" className={s.notice}>{notice}</div>}
</div>;
}
@@ -1,5 +1,5 @@
{
"apps/waterfall-wiki/public/learning-skills/cover.png": {
"apps/dashboard/public/learning-skills/cover.png": {
"id": "a9cafd80-8207-567b-aaa4-ebd250edaab5",
"key": "repository-media/sha256/8049b5704e44b2ebf3b97cef2495a85628af9a16d933ede2f7e790bc69b5fab1.png",
"sha256": "8049b5704e44b2ebf3b97cef2495a85628af9a16d933ede2f7e790bc69b5fab1",
@@ -8,7 +8,7 @@
"width": 1080,
"height": 1440
},
"apps/waterfall-wiki/public/skills/shadow-puppet/dancer-example.png": {
"apps/dashboard/public/skills/shadow-puppet/dancer-example.png": {
"id": "6514a180-bb28-558d-ac9e-0dd49279a058",
"key": "repository-media/sha256/94803894dcb06dae84d3744f963f55e077326d34f103a4fce05e32c2f4ab6895.png",
"sha256": "94803894dcb06dae84d3744f963f55e077326d34f103a4fce05e32c2f4ab6895",
@@ -17,7 +17,7 @@
"width": 680,
"height": 900
},
"apps/waterfall-wiki/public/skills/shadow-puppet/egret-example.png": {
"apps/dashboard/public/skills/shadow-puppet/egret-example.png": {
"id": "63aefa65-5431-5a2e-a20c-4293a010410e",
"key": "repository-media/sha256/2dd294282bf6c0dda86902cb2be265ba5b89c160a4d5e01ec50f79dac7ac6b82.png",
"sha256": "2dd294282bf6c0dda86902cb2be265ba5b89c160a4d5e01ec50f79dac7ac6b82",
@@ -26,7 +26,7 @@
"width": 1043,
"height": 1380
},
"apps/waterfall-wiki/public/skills/street-people/reference.png": {
"apps/dashboard/public/skills/street-people/reference.png": {
"id": "26cdf219-7264-5b51-b1bd-b13eaedcb946",
"key": "repository-media/sha256/e488a52338ac3496e6e51ea4843d99b8ca9cfe3bec4fc8d1ade3985bbb4f4c86.png",
"sha256": "e488a52338ac3496e6e51ea4843d99b8ca9cfe3bec4fc8d1ade3985bbb4f4c86",
@@ -35,7 +35,7 @@
"width": 1200,
"height": 2133
},
"apps/waterfall-wiki/public/skills/street-people/sample.png": {
"apps/dashboard/public/skills/street-people/sample.png": {
"id": "c1d98b87-a103-5cf6-829a-3f46321db72f",
"key": "repository-media/sha256/2db53d7593010e6c48dab8f212bc3004b02fc278944ea1686f74d6178a700b88.png",
"sha256": "2db53d7593010e6c48dab8f212bc3004b02fc278944ea1686f74d6178a700b88",
@@ -44,7 +44,7 @@
"width": 1024,
"height": 1536
},
"apps/waterfall-wiki/seed/characters/original/01-florist.png": {
"apps/dashboard/seed/characters/original/01-florist.png": {
"id": "c1d98b87-a103-5cf6-829a-3f46321db72f",
"key": "repository-media/sha256/2db53d7593010e6c48dab8f212bc3004b02fc278944ea1686f74d6178a700b88.png",
"sha256": "2db53d7593010e6c48dab8f212bc3004b02fc278944ea1686f74d6178a700b88",
@@ -53,7 +53,7 @@
"width": 1024,
"height": 1536
},
"apps/waterfall-wiki/seed/characters/original/02-reader.png": {
"apps/dashboard/seed/characters/original/02-reader.png": {
"id": "85c30364-9644-5d4a-9e83-96e982860bbe",
"key": "repository-media/sha256/669c85e689e7e88f8eff9f4bdaf9b4b7816df88c1956e817d82f8fd39bcdf278.png",
"sha256": "669c85e689e7e88f8eff9f4bdaf9b4b7816df88c1956e817d82f8fd39bcdf278",
@@ -62,7 +62,7 @@
"width": 1024,
"height": 1536
},
"apps/waterfall-wiki/seed/characters/original/03-cyclist.png": {
"apps/dashboard/seed/characters/original/03-cyclist.png": {
"id": "0200f54c-a0a9-50bd-bf04-6512059da0c5",
"key": "repository-media/sha256/6dedf97b96fafe9f70a16822b0fe9156a073d9149bad5eeab1450323b67ce575.png",
"sha256": "6dedf97b96fafe9f70a16822b0fe9156a073d9149bad5eeab1450323b67ce575",
@@ -71,7 +71,7 @@
"width": 1347,
"height": 1167
},
"apps/waterfall-wiki/seed/characters/original/04-coffee.png": {
"apps/dashboard/seed/characters/original/04-coffee.png": {
"id": "ebd511bf-559e-588d-886a-095b785216bd",
"key": "repository-media/sha256/ab337b3a2e5c05406c03aa4a69018191624f18de70081accf740e79993a1835e.png",
"sha256": "ab337b3a2e5c05406c03aa4a69018191624f18de70081accf740e79993a1835e",
@@ -80,7 +80,7 @@
"width": 1024,
"height": 1536
},
"apps/waterfall-wiki/seed/characters/reference/01-basket.png": {
"apps/dashboard/seed/characters/reference/01-basket.png": {
"id": "48ff5ce0-2763-5fa5-86cb-86c654bf4e24",
"key": "repository-media/sha256/62e6785bb373676cb5f24d44b9fa71a8ec8f413a4d518419aa29006ae62b7af8.png",
"sha256": "62e6785bb373676cb5f24d44b9fa71a8ec8f413a4d518419aa29006ae62b7af8",
@@ -89,7 +89,7 @@
"width": 1106,
"height": 1422
},
"apps/waterfall-wiki/seed/characters/reference/02-phone.png": {
"apps/dashboard/seed/characters/reference/02-phone.png": {
"id": "d21179b3-29aa-57c4-bc50-79f2459b5ee8",
"key": "repository-media/sha256/f27657c347e73786cc84df290f689f11284bb57822415a651c4c7567c15d034e.png",
"sha256": "f27657c347e73786cc84df290f689f11284bb57822415a651c4c7567c15d034e",
@@ -98,7 +98,7 @@
"width": 1161,
"height": 1354
},
"apps/waterfall-wiki/seed/characters/reference/03-coffee.png": {
"apps/dashboard/seed/characters/reference/03-coffee.png": {
"id": "10e3d787-2575-52f5-8403-cb62072cd1f0",
"key": "repository-media/sha256/cd53d3d1771c014cb4c5c1b7ed4d1846f72ec996d48a889514268461ed9c5f7c.png",
"sha256": "cd53d3d1771c014cb4c5c1b7ed4d1846f72ec996d48a889514268461ed9c5f7c",
@@ -107,7 +107,7 @@
"width": 1191,
"height": 1320
},
"apps/waterfall-wiki/seed/characters/reference/04-red-coat.png": {
"apps/dashboard/seed/characters/reference/04-red-coat.png": {
"id": "3fe8aac2-160f-5069-9b67-6615732fe54f",
"key": "repository-media/sha256/39249450ccd7f0a207f191742e2c27117f8404d35dfbe092ccdaa54ae59e218d.png",
"sha256": "39249450ccd7f0a207f191742e2c27117f8404d35dfbe092ccdaa54ae59e218d",
@@ -116,7 +116,7 @@
"width": 1024,
"height": 1536
},
"apps/waterfall-wiki/seed/characters/reference/05-blue-dogs.png": {
"apps/dashboard/seed/characters/reference/05-blue-dogs.png": {
"id": "8db94ff7-cee3-526d-a594-282a757317cf",
"key": "repository-media/sha256/d6c7ce1c8e3375f8aaa40012a91d11925113a7776b0fe383c74c734d91edfc37.png",
"sha256": "d6c7ce1c8e3375f8aaa40012a91d11925113a7776b0fe383c74c734d91edfc37",
@@ -125,7 +125,7 @@
"width": 1536,
"height": 1024
},
"apps/waterfall-wiki/seed/characters/reference/06-dark-dog.png": {
"apps/dashboard/seed/characters/reference/06-dark-dog.png": {
"id": "a3a00127-0f67-5c8f-9ae9-259e9c390be0",
"key": "repository-media/sha256/9e19ac9d04c30e5d0a552c7d59a8365ffca8be5df1b7f9ec12fb35bef7aa444b.png",
"sha256": "9e19ac9d04c30e5d0a552c7d59a8365ffca8be5df1b7f9ec12fb35bef7aa444b",
@@ -134,7 +134,7 @@
"width": 1372,
"height": 1146
},
"apps/waterfall-wiki/seed/characters/reference/07-pockets.png": {
"apps/dashboard/seed/characters/reference/07-pockets.png": {
"id": "468c8637-c841-5819-8afb-24d246b77821",
"key": "repository-media/sha256/158c1f3a492a7a3223bf4ed56d3750d192c696a054dc66d2c3ae65ce9e0a8dad.png",
"sha256": "158c1f3a492a7a3223bf4ed56d3750d192c696a054dc66d2c3ae65ce9e0a8dad",
@@ -143,7 +143,7 @@
"width": 1024,
"height": 1536
},
"apps/waterfall-wiki/seed/characters/reference/08-blue-board.png": {
"apps/dashboard/seed/characters/reference/08-blue-board.png": {
"id": "1731746a-2a59-5b8b-b91c-8f862567f7ef",
"key": "repository-media/sha256/fa8f0dd989cb8133ad5565c19dc9f798a9c2bd216e3c90342d1a4e45329e8cd9.png",
"sha256": "fa8f0dd989cb8133ad5565c19dc9f798a9c2bd216e3c90342d1a4e45329e8cd9",
@@ -152,7 +152,7 @@
"width": 1130,
"height": 1392
},
"apps/waterfall-wiki/seed/characters/reference/09-scarf-dog.png": {
"apps/dashboard/seed/characters/reference/09-scarf-dog.png": {
"id": "7e99a693-1c95-5544-af4c-ae2ce12824c8",
"key": "repository-media/sha256/8ee2ea052e4985a28eef1ba415190bdbea70e4ca52eb3bdcd5206210a4285bc7.png",
"sha256": "8ee2ea052e4985a28eef1ba415190bdbea70e4ca52eb3bdcd5206210a4285bc7",
@@ -161,7 +161,7 @@
"width": 1220,
"height": 1289
},
"apps/waterfall-wiki/seed/characters/reference/10-elder.png": {
"apps/dashboard/seed/characters/reference/10-elder.png": {
"id": "be125dda-5a86-5a7b-9498-a8dba66df31e",
"key": "repository-media/sha256/45bfe1e35f88e52adfc7f97b0834a5dbeea7dbcf283ebdabb9adf4236eb93eab.png",
"sha256": "45bfe1e35f88e52adfc7f97b0834a5dbeea7dbcf283ebdabb9adf4236eb93eab",
@@ -170,7 +170,7 @@
"width": 1195,
"height": 1316
},
"apps/waterfall-wiki/seed/characters/reference/11-long-coat.png": {
"apps/dashboard/seed/characters/reference/11-long-coat.png": {
"id": "5bb056a0-735e-5f24-bfd1-1036ebb208e8",
"key": "repository-media/sha256/76f00ba08e5b8e7caf4fe2801368cfc5f7cf3c445ae2a80b496a4f431272d053.png",
"sha256": "76f00ba08e5b8e7caf4fe2801368cfc5f7cf3c445ae2a80b496a4f431272d053",
@@ -179,7 +179,7 @@
"width": 1148,
"height": 1371
},
"apps/waterfall-wiki/seed/characters/reference/12-trolley.png": {
"apps/dashboard/seed/characters/reference/12-trolley.png": {
"id": "11da89bb-daa9-59b3-b22f-997f1fba46fe",
"key": "repository-media/sha256/8235f95071abb54f0618b61d66c20e63633e6d929a8ec4d43d4cef121226e078.png",
"sha256": "8235f95071abb54f0618b61d66c20e63633e6d929a8ec4d43d4cef121226e078",
@@ -188,7 +188,7 @@
"width": 1241,
"height": 1267
},
"apps/waterfall-wiki/seed/characters/reference/13-bucket.png": {
"apps/dashboard/seed/characters/reference/13-bucket.png": {
"id": "3f203043-3d69-52c4-91fa-c898daf61eb6",
"key": "repository-media/sha256/2a4b34afdb45d439f2588095abc89378f7772fd842bdc38518e4a03756b15b82.png",
"sha256": "2a4b34afdb45d439f2588095abc89378f7772fd842bdc38518e4a03756b15b82",
@@ -197,7 +197,7 @@
"width": 1140,
"height": 1380
},
"apps/waterfall-wiki/seed/characters/reference/14-curly.png": {
"apps/dashboard/seed/characters/reference/14-curly.png": {
"id": "5cfb45ab-542f-5bd8-89f5-549072e4caf3",
"key": "repository-media/sha256/f10faf8145e56830c9a179b6a52cfdd688de3f460fa33ded82fa2b3fe0678b3e.png",
"sha256": "f10faf8145e56830c9a179b6a52cfdd688de3f460fa33ded82fa2b3fe0678b3e",
@@ -206,7 +206,7 @@
"width": 1141,
"height": 1379
},
"apps/waterfall-wiki/seed/characters/reference/15-red-walk.png": {
"apps/dashboard/seed/characters/reference/15-red-walk.png": {
"id": "41e92c94-adca-54b4-9fe9-db192a266937",
"key": "repository-media/sha256/238eae73ac2faf53e6ac02591b00759115378233fbcbfdcf8e67ce93a804d26e.png",
"sha256": "238eae73ac2faf53e6ac02591b00759115378233fbcbfdcf8e67ce93a804d26e",
@@ -1,7 +1,8 @@
import { randomBytes, scrypt as derive, timingSafeEqual, createHash } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(derive);
export const ACCOUNT_COOKIE = 'wiki_account';
export const ACCOUNT_COOKIE = process.env.ACCOUNT_COOKIE_NAME || 'wiki_account';
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ACCOUNT_COOKIE)) throw Error('Invalid ACCOUNT_COOKIE_NAME');
export const SESSION_TTL = 7 * 86400;
export function normalizeEmail(value: unknown) {
if (typeof value !== 'string') return null;
@@ -1,7 +1,8 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
export const SESSION_COOKIE = 'prompt_session';
export const SESSION_COOKIE = process.env.AUTH_COOKIE_NAME || 'prompt_session';
if (!/^[A-Za-z0-9_-]{1,64}$/.test(SESSION_COOKIE)) throw Error('Invalid AUTH_COOKIE_NAME');
export const SESSION_SECONDS = 7 * 24 * 60 * 60;
export type AuthConfig = { secret: string; jwtKey: string };
export function authConfig(): AuthConfig | null {

Some files were not shown because too many files have changed in this diff Show More