Compare commits

..

25 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 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
238 changed files with 1003 additions and 94 deletions
+3 -6
View File
@@ -24,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
+10 -1
View File
@@ -10,4 +10,13 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
## 项目维护
- Wiki 和 Studio 共用 `apps/waterfall-wiki` 的 Pages Router、登录保护和品牌包。保留已发布的路由、媒体标识和数据库迁移。
- Wiki 和 Studio 共用 `apps/dashboard` 的 Pages Router、登录保护和品牌包。保留已发布的路由、媒体标识和数据库迁移。
## 开发分支与集成
- `dev` 是日常开发成果的集成分支。开始新任务前检查工作区和远程状态,默认从最新 `origin/dev` 创建或复用对应任务分支,避免夹带其他任务的未完成改动。
- 每项任务完成并通过适当检查后,提交并推送任务分支,再将该任务合并到 `dev` 并推送;用户已持续授权这类向 `dev` 的合并与推送,无需逐次确认。
- 合并前检查完整差异和待合入提交;保留 `dev` 已有工作,冲突解决后重新执行受影响的检查。不强推或重写共享历史,不擅自删除任务分支。
- `main` 保持人工集成:未经用户针对本次操作明确授权,不直接推送 `main`,也不代为合并面向 `main` 的 PR。需要发布到主线时,准备 `dev` 到 `main` 的可评审 PR,等待用户手动合并。
- 当前不使用 CI/CD,不因合并或推送 `dev` 自动恢复旧流水线、远程预览或部署安排。完整构建与运行验证是否执行及其环境,按用户另行授权处理。
- 交付报告说明任务分支、提交、检查结果以及是否已合并并推送 `dev`;区分 `dev` 集成与 `main` 合并、部署。
+11 -5
View File
@@ -1,6 +1,6 @@
# 啡跃 Endoleap
啡跃把笔记、参考素材和创作工作台放在同一个 Next.js 应用里。应用位于 `apps/waterfall-wiki`,共享主题与品牌资源位于 `packages/brand`。
啡跃把笔记、参考素材和创作工作台放在同一个 Next.js 应用里。应用位于 `apps/dashboard`,共享主题与品牌资源位于 `packages/brand`。
| 入口 | 用途 |
| --- | --- |
@@ -13,9 +13,9 @@
## 运行
准备 Node.js 24、pnpm 11.25.0、PostgreSQL、S3 兼容对象存储和 FFmpeg。参考 [环境变量示例](apps/waterfall-wiki/.env.example),将变量注入 Web、初始化和媒体 worker 进程。邮箱注册与找回密码需要可用的 SMTP 服务。
准备 Node.js 24、pnpm 11.25.0、PostgreSQL、S3 兼容对象存储和 FFmpeg。参考 [环境变量示例](apps/dashboard/.env.example),将变量注入 Web、初始化和媒体 worker 进程。邮箱注册与找回密码需要可用的 SMTP 服务。
在仓库根目录运行 `pnpm install --frozen-lockfile` 和 `pnpm build`。然后在 `apps/waterfall-wiki` 目录准备数据库和存储,初始化管理员认证并启动 Web。
在仓库根目录运行 `pnpm install --frozen-lockfile` 和 `pnpm build`。然后在 `apps/dashboard` 目录准备数据库和存储,初始化管理员认证并启动 Web。
```sh
node dist/bootstrap.cjs
@@ -27,12 +27,18 @@ pnpm start
## Monorepo 与 Turborepo
仓库在现有 pnpm workspace 上接入 Turborepo,无需运行面向新项目的 `npx create-turbo@latest`。应用仍位于 `apps/waterfall-wiki`,源码品牌包仍位于 `packages/brand`;子包脚本和应用产物路径保持原样。依赖统一锁定在根目录 `pnpm-lock.yaml`,安装从仓库根目录执行。
仓库在现有 pnpm workspace 上接入 Turborepo,无需运行面向新项目的 `npx create-turbo@latest`。应用仍位于 `apps/dashboard`,源码品牌包仍位于 `packages/brand`;子包脚本和应用产物路径保持原样。依赖统一锁定在根目录 `pnpm-lock.yaml`,安装从仓库根目录执行。
根目录命令由 Turbo 调度:`pnpm dev` 启动开发任务,`pnpm dev:waterfall-wiki` 仅启动 Wiki,`pnpm build` 构建应用及 worker/CLI,`pnpm typecheck` 生成 Next.js 类型并检查 TypeScript。`pnpm start` 继续直接启动现有应用。也可以在子包目录使用原有命令。
根目录命令由 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` 配置,未设置时使用原默认名称。
@@ -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;
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
@@ -11,7 +11,7 @@ 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})),
...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"},
@@ -1,5 +1,5 @@
{
"name": "@prompt/waterfall-wiki",
"name": "@prompt/dashboard",
"version": "0.1.0",
"private": true,
"engines": {

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 {
@@ -14,6 +14,6 @@ const pending=new Map<string,Promise<Buffer>>();const cache=join(tmpdir(),'promp
export async function compressPreview(input:Buffer,width:number){const max=width<=480?100_000:220_000;let size=width,quality=72;let output:Buffer;do{output=await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width:size,withoutEnlargement:true}).webp({quality,effort:3}).toBuffer();if(output.length<=max)return output;size=Math.max(240,Math.floor(size*.8));quality=Math.max(30,quality-10);}while(size>240||quality>30);return sharp(input,{limitInputPixels:40_000_000}).rotate().resize({width:240,withoutEnlargement:true}).webp({quality:30}).toBuffer();}
export async function cachedPreview(identity:string,width:number,load:()=>Promise<Buffer>,quality=false){const key=createHash('sha256').update(identity+':'+width+(quality?':export-v1':':webp-v1')).digest('hex');const file=join(cache,key+'.webp');try{return {bytes:await readFile(file),etag:'"'+key+'"'};}catch{/* cache miss */}let task=pending.get(key);if(!task){task=limited(async()=>{await mkdir(cache,{recursive:true});const input=await load();const bytes=quality?await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width,withoutEnlargement:true}).webp({quality:95,effort:4}).toBuffer():await compressPreview(input,width);const temp=file+'.'+process.pid+'.tmp';await writeFile(temp,bytes);await rename(temp,file);if(++writes%32===0){const files=await readdir(cache);if(files.length>1024){const dates=await Promise.all(files.map(async name=>({name,time:(await stat(join(cache,name)).catch(()=>({mtimeMs:0}))).mtimeMs})));await Promise.all(dates.sort((a,b)=>a.time-b.time).slice(0,files.length-1024).map(f=>unlink(join(cache,f.name)).catch(()=>{})));}}return bytes;});pending.set(key,task);}try{return {bytes:await task,etag:'"'+key+'"'};}finally{pending.delete(key);}}
export async function loadStaticPreview(source:string){
const asset=(managedMedia as Record<string,{key:string;sha256:string;bytes:number}>)["apps/waterfall-wiki/public"+source];
const asset=(managedMedia as Record<string,{key:string;sha256:string;bytes:number}>)["apps/dashboard/public"+source];
if(asset){return {identity:asset.key,load:async()=>{const object=await getObject(asset.key);if(asset.bytes>64*1024*1024)throw Error("Image too large");const bytes=Buffer.from(await object.Body!.transformToByteArray());if(bytes.length!==asset.bytes||createHash("sha256").update(bytes).digest("hex")!==asset.sha256)throw Error("Stored image checksum mismatch");return bytes;}};}
const root=await realpath(join(process.cwd(),'public'));const file=await realpath(staticPreviewPath(source,root));if(!file.startsWith(root+sep))throw Error('Invalid source');const info=await stat(file);if(info.size>64*1024*1024)throw Error('Image too large');return {identity:file+':'+info.mtimeMs,load:()=>readFile(file)};}
@@ -13,7 +13,7 @@ function stableId(name: string) {
}
// Explicit editorial publication, never part of the generic storage bootstrap.
// Re-running updates these three authored posts without duplicating posts or media.
export async function publishLearningSeries(asset=managedMedia["apps/waterfall-wiki/public/learning-skills/cover.png"]) {
export async function publishLearningSeries(asset=managedMedia["apps/dashboard/public/learning-skills/cover.png"]) {
const object=await getObject(asset.key);
const cover=Buffer.from(await object.Body!.transformToByteArray());
const digest=createHash("sha256").update(cover).digest("hex");

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