Compare commits

..

28 Commits

Author SHA1 Message Date
codex-bot 1ecfabcd03 Remove stale runner and deployment instructions after monorepo cleanup 2026-11-04 23:22:52 +08:00
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
383 changed files with 4153 additions and 19252 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 -6
View File
@@ -1,4 +1,6 @@
node_modules/
.pnpm-store/
.turbo/
.next/
coverage/
.env*
@@ -22,9 +24,6 @@ __pycache__/
# Approved, versioned brand resources are package source assets.
!packages/brand/assets/**/*.png
!packages/brand/docs/logo-preview.png
/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=
@@ -1,6 +1,7 @@
# Email accounts
`/account` offers registration, password login, email verification/resend, and logout.
`/account` offers registration, password login, email verification/resend, password
recovery, and logout.
`/api/account/session` returns the current account; other actions use same-origin POST.
Passwords require at least 12 characters and at most 256 UTF-8 bytes. Only verified
accounts can log in. Verification links expire after 30 minutes and are consumed once.
@@ -9,24 +10,22 @@ Only token SHA-256 digests are stored. Passwords use individually salted scrypt.
## Runtime configuration
The existing production deployment reads `/etc/prompt.env` on ces-control. Set its
ownership to root and mode to 0600. The operator should update SMTP_PASSWORD there
through a secure remote session, without putting it in command arguments, repository,
CI output, release.env, or chat. Existing compose deployment reads this file without
copying credentials into the release directory. Do not run `docker compose config`
without `--quiet` against the production environment because interpolation shows secrets.
Docker administrators can inspect container environment; access to Docker is privileged.
Use [the environment example](.env.example) and the root README to configure the Web,
bootstrap, and worker processes. The repository no longer supplies CI/CD or Compose
deployment configuration. Keep SMTP_PASSWORD and other credentials in the process
environment or an operator-managed private file with restricted permissions, outside
the repository. Do not put credentials in command arguments, logs, or chat.
Set ACCOUNT_PUBLIC_ORIGIN to the exact browser origin and SMTP_HOST=smtpdm.aliyun.com,
SMTP_PORT=465, SMTP_USERNAME and SMTP_FROM=noreply@notice.entroia.com. TLS certificate
Set ACCOUNT_PUBLIC_ORIGIN to the exact browser origin and configure SMTP_HOST,
SMTP_PORT, SMTP_USERNAME, SMTP_FROM, and SMTP_PASSWORD for the mail provider. TLS certificate
validation stays enabled. Missing SMTP configuration returns a generic service failure;
resend with the registration password can recover an account whose initial delivery failed.
Keep AUTH_CONFIG_FILE configured for administrator access. Missing administrator
configuration fails closed. AUTH_DISABLED_FOR_TESTS is false by default and is enabled
only by the disposable legacy integration stack; never enable it in production.
only for an explicitly isolated test environment; never enable it in production.
Email account cookies never satisfy administrator authorization. With the current HTTP
acceptance origin use AUTH_COOKIE_SECURE=false; use true after enabling HTTPS.
runtime origin use AUTH_COOKIE_SECURE=false; use true for HTTPS.
Limits persist in PostgreSQL: 10 logins and 3 registration/resend attempts per email per
15 minutes, plus 300 requests per 15 minutes globally. ACCOUNT_TRUST_PROXY defaults
@@ -37,13 +36,16 @@ response for existing/unknown emails, and login uses a dummy KDF for unknown acc
The rate limit itself can reveal recent activity for a known email; it does not reveal
whether an account exists. Synchronous SMTP latency can still expose a timing side channel;
a durable asynchronous mail queue would be needed to fully decouple delivery timing.
Logout remains available when login/mail limits are exhausted. No password recovery or
account deletion is included.
Logout remains available when login/mail limits are exhausted. Password recovery uses
a single-use reset link that expires after 30 minutes; resetting the password revokes
existing sessions. Account deletion is not included.
## Verification
ces-control runs crypto/origin tests, PostgreSQL verification-token replay races,
expired/revoked sessions, concurrent limit consumption and unverified login rejection.
Production-auth exercises verify that account cookies cannot authorize legacy writes.
Actual SMTP delivery and visual acceptance at http://8.211.154.134:18131/account require
operator runtime configuration and the normal manually approved deployment.
There is no automatic verification pipeline. Turbo dry-runs only verify task planning;
they do not exercise account behavior. Runtime verification requires an explicitly
authorized environment with PostgreSQL and SMTP configuration. Cover token replay,
expired/revoked sessions, concurrent limits, password recovery, and rejection of
unverified logins. Verify separately that account cookies cannot authorize administrator
writes, that real email is delivered, and that the account page works in a browser.
Report the checks actually executed; merging into `dev` does not deploy the application.
+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

+4
View File
@@ -0,0 +1,4 @@
/** The approved mark is copied from @endoleap/brand by brand:prepare. */
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