Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71e6a07054 | |||
| 61d80f352f | |||
| 4ec83f2603 | |||
| 195e8d6deb | |||
| cf0bc49732 | |||
| 7e104cd660 | |||
| b6718f9981 | |||
| 25f6f5921b | |||
| 48c7ae0e98 | |||
| 2bc44c6a9e | |||
| f020551d02 | |||
| 8d37fabe58 | |||
| 95df8df552 | |||
| 5a5a2934da | |||
| 484cc0ba84 | |||
| b1df6077cd | |||
| 896c8bb9e5 | |||
| 1df5790d23 | |||
| fe26f03cf4 | |||
| 19e179dd20 | |||
| 7f88ebba71 | |||
| 0a77d03220 | |||
| 6654a1456b | |||
| 80d23f0e3d |
+3
-3
@@ -27,6 +27,6 @@ __pycache__/
|
||||
/apps/studio/public/brand/
|
||||
/apps/studio/public/favicon.ico
|
||||
/apps/studio/public/apple-touch-icon.png
|
||||
/apps/waterfall-wiki/public/brand/
|
||||
/apps/waterfall-wiki/public/favicon.ico
|
||||
/apps/waterfall-wiki/public/apple-touch-icon.png
|
||||
/apps/dashboard/public/brand/
|
||||
/apps/dashboard/public/favicon.ico
|
||||
/apps/dashboard/public/apple-touch-icon.png
|
||||
|
||||
@@ -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` 合并、部署。
|
||||
|
||||
@@ -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` 配置,未设置时使用原默认名称。
|
||||
|
||||
@@ -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 |
+2
-2
@@ -1,4 +1,4 @@
|
||||
/** The approved mark is copied from @endoleap/brand during the remote build. */
|
||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
||||
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
|
||||
export function Brand({ compact = false, markOnly = false }: { compact?: boolean; markOnly?: boolean }) {
|
||||
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/>{!markOnly && <span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span>}</span>;
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import Head from 'next/head';
|
||||
import {useEffect, useRef, useState, type ReactNode} from 'react';
|
||||
import {ArrowLeft, ArrowUpRight, BookOpen, Bot, Check, ChevronDown, ChevronRight, Layers, LogOut, User, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
|
||||
import {Brand} from './Brand';
|
||||
import {ThemeToggle} from './ThemeToggle';
|
||||
import {parseCanvasDocument, type CanvasNode} from '@/lib/canvas-document';
|
||||
import s from '@/styles/workspace.module.css';
|
||||
|
||||
const tools = [
|
||||
{label: '无限画布', detail: '收集灵感,连接文字与参考素材', href: '/infinite_canvas', icon: Workflow},
|
||||
{label: '图形设计', detail: '编辑文字、图像与版式', href: '/graphic', icon: Palette},
|
||||
{label: '知识与教程', detail: '查找指南和创作方法', href: '/documents', icon: BookOpen},
|
||||
{label: 'Agent 接入', detail: '了解如何连接创作工具与 API', href: '/studio/api', icon: Bot},
|
||||
];
|
||||
const purposes = [
|
||||
{label: '工作与团队', detail: '整理项目和工作资料。', icon: LayoutGrid},
|
||||
{label: '个人创作', detail: '收集灵感,让想法成形。', icon: House},
|
||||
{label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen},
|
||||
];
|
||||
type Space = {id?: string; name: string; purpose: string; owner?: boolean};
|
||||
const spaceKey = (space: Space) => space.id ?? space.name;
|
||||
export type Subcanvas = {id: string; name: string; nodes: string[]; editedAt?: string};
|
||||
const personal = {name: '个人空间', purpose: ''};
|
||||
|
||||
type WorkspaceShellProps = {
|
||||
editor?: (subset: Subcanvas | undefined) => ReactNode;
|
||||
liveNodes?: CanvasNode[];
|
||||
};
|
||||
|
||||
export function WorkspaceShell({editor, liveNodes}: WorkspaceShellProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [state, setState] = useState('loading');
|
||||
const [sidebar, setSidebar] = useState(true);
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
const [active, setActive] = useState(personal);
|
||||
const [query, setQuery] = useState('');
|
||||
const [step, setStep] = useState<'choice' | 'join' | 'purpose' | 'name' | 'invite'>('choice');
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [savedNodes, setNodes] = useState<CanvasNode[]>([]);
|
||||
const nodes = liveNodes ?? savedNodes;
|
||||
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
|
||||
const [selectedCanvas, setSelectedCanvas] = useState(editor ? '' : 'history');
|
||||
const [draftEditedAt, setDraftEditedAt] = useState<string>();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [subsetName, setSubsetName] = useState('');
|
||||
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
|
||||
const subsetDialog = useRef<HTMLDialogElement>(null);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const switcher = useRef<HTMLDetailsElement>(null);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebar(window.matchMedia('(min-width: 761px)').matches);
|
||||
const request = new AbortController();
|
||||
fetch('/api/account/session', {signal: request.signal}).then(async response => {
|
||||
if (!response.ok) throw Error();
|
||||
const data = await response.json();
|
||||
if (!data.user) {
|
||||
if (editor) setState('ready');
|
||||
else window.location.replace('/account?next=/workspace');
|
||||
return;
|
||||
}
|
||||
setEmail(data.user.email);
|
||||
let local: Space[] = [];
|
||||
let activeKey = '';
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('studio.workspace:' + data.user.email) || 'null');
|
||||
if (Array.isArray(saved?.spaces)) local = saved.spaces.filter((space: Space) => !space.id);
|
||||
activeKey = saved?.active || '';
|
||||
} catch {}
|
||||
let shared: Space[] = [];
|
||||
try {
|
||||
const response = await fetch('/api/account/workspaces', {signal: request.signal});
|
||||
if (response.ok) shared = (await response.json()).spaces;
|
||||
} catch {}
|
||||
if (request.signal.aborted) return;
|
||||
const available = [...local, ...shared];
|
||||
setSpaces(available);
|
||||
setActive(available.find(space => spaceKey(space) === activeKey) || personal);
|
||||
setState('ready');
|
||||
}).catch(() => {
|
||||
if (!request.signal.aborted) setState('error');
|
||||
});
|
||||
return () => request.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timeout = window.setTimeout(() => setNotice(''), 4500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [notice]);
|
||||
|
||||
function selectSpace(space: Space, next = spaces) {
|
||||
localStorage.setItem('studio.workspace:' + email, JSON.stringify({spaces: next, active: spaceKey(space)}));
|
||||
setSpaces(next);
|
||||
setActive(space);
|
||||
switcher.current?.removeAttribute('open');
|
||||
}
|
||||
|
||||
const subsetKey = 'studio.subcanvases:' + email + ':' + spaceKey(active);
|
||||
useEffect(() => {
|
||||
if (!email) return;
|
||||
setSelectedCanvas(editor ? '' : 'history');
|
||||
setQuery('');
|
||||
try {
|
||||
const draft = localStorage.getItem('studio.infinite-canvas.v1');
|
||||
setDraftEditedAt(localStorage.getItem('studio.infinite-canvas.v1.editedAt') || undefined);
|
||||
setNodes(draft ? parseCanvasDocument(draft).cards : []);
|
||||
} catch {
|
||||
setNodes([]);
|
||||
}
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(subsetKey) || '[]');
|
||||
const list: Subcanvas[] = Array.isArray(saved) ? saved : [];
|
||||
setSubcanvases(list);
|
||||
if (editor) {
|
||||
const id = new URLSearchParams(window.location.search).get('subset');
|
||||
if (list.some(item => item.id === id)) setSelectedCanvas(id!);
|
||||
}
|
||||
} catch {
|
||||
setSubcanvases([]);
|
||||
}
|
||||
}, [email, subsetKey]);
|
||||
|
||||
function openSubsetDialog(edit?: Subcanvas) {
|
||||
setError('');
|
||||
setSubsetName(edit?.name ?? '');
|
||||
setSubsetNodes(edit?.nodes ?? []);
|
||||
subsetDialog.current?.showModal();
|
||||
}
|
||||
|
||||
function saveSubset(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const name = subsetName.trim();
|
||||
if (!name || !subsetNodes.length) return setError('请输入名称并选择至少一个节点。');
|
||||
const id = selectedCanvas || window.crypto?.randomUUID?.() || String(Date.now());
|
||||
const subset = {id, name, nodes: subsetNodes, editedAt: new Date().toISOString()};
|
||||
const next = [...subcanvases.filter(item => item.id !== id), subset];
|
||||
try {
|
||||
localStorage.setItem(subsetKey, JSON.stringify(next));
|
||||
setSubcanvases(next);
|
||||
setSelectedCanvas(id);
|
||||
setExpanded(true);
|
||||
subsetDialog.current?.close();
|
||||
} catch {
|
||||
setError('浏览器无法保存子画布。');
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
const response = await fetch('/api/account/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
|
||||
if (!response.ok) throw Error();
|
||||
window.location.assign('/account?next=/workspace');
|
||||
} catch {
|
||||
setNotice('暂时无法退出登录,请重试。');
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
setStep('choice');
|
||||
setCode('');
|
||||
setPurpose('');
|
||||
setName('');
|
||||
setError('');
|
||||
dialog.current?.showModal();
|
||||
}
|
||||
|
||||
async function workspaceRequest(payload: object) {
|
||||
const response = await fetch('/api/account/workspaces', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || '空间操作失败');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function submitWorkspace(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const clean = name.trim();
|
||||
if (step === 'name' && !clean) return setError('请填写 Workspace 名称。');
|
||||
if (step === 'join' && !code.trim()) return setError('请输入邀请码。');
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const {space} = await workspaceRequest(step === 'join' ? {action:'join', code:code.trim()} : {action:'create', name:clean, purpose});
|
||||
selectSpace(space, [...spaces.filter(item => spaceKey(item) !== spaceKey(space)), space]);
|
||||
dialog.current?.close();
|
||||
setNotice((step === 'join' ? '已加入' : '已创建') + '「' + space.name + '」');
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : '空间操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteMember() {
|
||||
if (busy || !active.id) return;
|
||||
switcher.current?.removeAttribute('open');
|
||||
setCode('');
|
||||
setError('');
|
||||
setStep('invite');
|
||||
dialog.current?.showModal();
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await workspaceRequest({action:'invite', workspaceId:active.id});
|
||||
setCode(result.code);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : '无法生成邀请码');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const subset = subcanvases.find(item => item.id === selectedCanvas);
|
||||
const visibleNodes = subset ? nodes.filter(node => subset.nodes.includes(node.id)) : nodes;
|
||||
const visibleSubsets = subcanvases.filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
const canvasName = '无限画布';
|
||||
const showHistory = selectedCanvas === 'history';
|
||||
const dates = new Intl.DateTimeFormat('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'});
|
||||
const times = new Intl.DateTimeFormat('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false});
|
||||
const entries = [
|
||||
...(nodes.length || draftEditedAt ? [{id: '', name: canvasName, editedAt: draftEditedAt, summary: nodes[0]?.text || nodes[0]?.title || '本机草稿'}] : []),
|
||||
...subcanvases.map(item => ({...item, summary: nodes.find(node => item.nodes.includes(node.id))?.text || '子画布'})),
|
||||
].filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
entries.sort((a, b) => (b.editedAt ?? '').localeCompare(a.editedAt ?? ''));
|
||||
const history = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const day = entry.editedAt && Number.isFinite(Date.parse(entry.editedAt)) ? dates.format(new Date(entry.editedAt)) : '编辑时间未记录';
|
||||
const group = history.get(day) ?? [];
|
||||
group.push(entry);
|
||||
history.set(day, group);
|
||||
}
|
||||
return <div className={s.app}>
|
||||
{!editor && <Head><title>工作台 · 啡跃</title></Head>}
|
||||
<nav className={s.rail} aria-label="全局导航">
|
||||
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand markOnly/></a>
|
||||
<button type="button" aria-label="切换工作台导航" title="切换工作台导航" aria-current="page" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><LayoutGrid size={18}/></button>
|
||||
<a href="/infinite_canvas" aria-label="无限画布" title="无限画布"><Workflow size={18}/></a>
|
||||
<button type="button" aria-label="搜索画布" title="搜索画布" disabled={state !== 'ready'} onClick={() => {setSidebar(true); requestAnimationFrame(() => search.current?.focus());}}><Search size={18}/></button>
|
||||
<a href="/graphic" aria-label="图形设计" title="图形设计"><Palette size={18}/></a>
|
||||
<a href="/studio/api" aria-label="Agent 接入" title="Agent 接入"><Bot size={18}/></a>
|
||||
<a className={s.settings} href="/account" aria-label="账户设置" title="账户设置"><Settings size={18}/></a>
|
||||
</nav>
|
||||
{sidebar && <aside className={s.panel} id="workspace-navigation">
|
||||
<div className={s.identity}>
|
||||
<details ref={switcher} className={s.switcher}>
|
||||
<summary><span className={s.avatar}>{Array.from(active.name)[0]}</span><strong>{active.name}</strong><ChevronDown size={14}/></summary>
|
||||
{state === 'ready' && <div className={s.menu}>
|
||||
<div className={s.menuIdentity}><span className={s.avatar}>{Array.from(active.name)[0]}</span><div><strong>{active.name}</strong><small>{active.id ? '当前空间 · 共享空间' : '当前空间 · 保存在此浏览器'}</small></div></div>
|
||||
{email && <div className={s.menuSection}>
|
||||
{active.owner && <button type="button" onClick={() => void inviteMember()}><Plus size={16}/>邀请成员</button>}<small>{email}</small>
|
||||
{[personal, ...spaces].map(space => <button type="button" key={spaceKey(space)} aria-pressed={spaceKey(space) === spaceKey(active)} onClick={() => {
|
||||
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
|
||||
}}><span className={s.avatar}>{Array.from(space.name)[0]}</span><span>{space.name}</span>{spaceKey(space) === spaceKey(active) && <Check size={16}/>}</button>)}
|
||||
<button className={s.menuAdd} type="button" onClick={() => {switcher.current?.removeAttribute('open'); openDialog();}}><Plus size={16}/>添加 Workspace</button>
|
||||
</div>}
|
||||
<div className={s.menuSection}><a href="/account"><User size={16}/>账户管理</a>{email && <button type="button" onClick={() => void signOut()}><LogOut size={16}/>退出登录</button>}</div>
|
||||
</div>}
|
||||
</details>
|
||||
<a className={s.avatar} href="/account" aria-label="账户设置" title={email || '账户设置'}>{email ? email.charAt(0).toUpperCase() : <Settings size={15}/>}</a>
|
||||
</div>
|
||||
<div className={s.panelTitle}><strong>画布</strong><button type="button" disabled={!email || !nodes.length} onClick={() => {setSelectedCanvas(''); openSubsetDialog();}} aria-label="新建子画布" title="新建子画布"><Plus size={18}/></button></div>
|
||||
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索画布…" aria-label="搜索画布名称"/></label>
|
||||
{state === 'ready' && <nav className={s.links} aria-label="画布树">
|
||||
<button className={s.historyLink} type="button" aria-current={showHistory ? 'page' : undefined} onClick={() => editor ? window.location.assign('/workspace') : setSelectedCanvas('history')}>编辑历史</button>
|
||||
<p>本机画布</p>
|
||||
<div className={s.treeRoot}>
|
||||
<button type="button" aria-label="展开或折叠子画布" aria-expanded={expanded || !!query} disabled={!!query} onClick={() => setExpanded(!expanded)}>{expanded || query ? <ChevronDown size={15}/> : <ChevronRight size={15}/>}</button>
|
||||
<button type="button" aria-current={!subset && !showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
|
||||
</div>
|
||||
{(expanded || query) && <ul className={s.children}>{visibleSubsets.map(item => <li key={item.id}><button type="button" aria-current={item.id === selectedCanvas ? 'page' : undefined} onClick={() => setSelectedCanvas(item.id)}><LayoutGrid size={15}/>{item.name}</button></li>)}</ul>}
|
||||
{query && !visibleSubsets.length && !canvasName.includes(query.trim()) && <p role="status">没有匹配的画布。</p>}
|
||||
</nav>}
|
||||
<small className={s.local}>空间设置保存在此浏览器</small>
|
||||
</aside>}
|
||||
<main className={editor ? s.editorMain : s.main}>
|
||||
{editor ? <>
|
||||
<button className={s.editorToggle} type="button" aria-label="切换画布树" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button>
|
||||
{editor(subset)}
|
||||
</> : <>
|
||||
<header className={s.top}>
|
||||
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}</span></div>
|
||||
<ThemeToggle/>
|
||||
</header>
|
||||
{state === 'loading' ? <p className={s.status} role="status">正在打开工作台…</p> : state === 'error' ? <p className={s.status} role="alert">暂时无法读取账户。 <a href="/account?next=/workspace">重新登录</a></p> :
|
||||
showHistory ? <section className={s.history} aria-labelledby="history-heading">
|
||||
<header><div><h1 id="history-heading">编辑历史</h1><p>按最近编辑日期排列</p></div><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></header>
|
||||
{Array.from(history, ([day, items]) => <section className={s.historyGroup} key={day}>
|
||||
<h2>{day} · {items.length}</h2>
|
||||
<ul>{items.map(item => <li key={item.id}><button type="button" onClick={() => setSelectedCanvas(item.id)}>
|
||||
{item.id ? <LayoutGrid size={18}/> : <Layers size={18}/>}
|
||||
<span className={s.historyItem}><strong>{item.name}</strong><small>{item.summary}</small></span>
|
||||
{item.editedAt && Number.isFinite(Date.parse(item.editedAt)) && <time dateTime={item.editedAt}>{times.format(new Date(item.editedAt))}</time>}
|
||||
<ChevronRight size={16}/>
|
||||
</button></li>)}</ul>
|
||||
</section>)}
|
||||
{!entries.length && <p className={s.historyEmpty}>{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}</p>}
|
||||
</section> : <div className={s.content}>
|
||||
<section className={s.intro}>
|
||||
<Layers size={20}/>
|
||||
<h1>{subset?.name ?? canvasName}</h1>
|
||||
<p>{subset ? `子画布 · ${visibleNodes.length} 个节点 · 属于无限画布` : `${active.name} · ${nodes.length} 个节点`}</p>
|
||||
{subset ? <><button className={s.primary} type="button" onClick={() => openSubsetDialog(subset)}>编辑子画布</button><a className={s.openSubset} href={'/infinite_canvas?subset=' + encodeURIComponent(subset.id)}>在编辑器打开 <ArrowUpRight size={16}/></a></> : <a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a>}
|
||||
</section>
|
||||
<section aria-labelledby="workspace-nodes">
|
||||
<h2 id="workspace-nodes">画布内容</h2>
|
||||
{visibleNodes.length ? <div className={s.tools}>{visibleNodes.map(node => <article className={s.node} key={node.id}><strong>{node.title || '未命名节点'}</strong>{node.image && <img src={node.image} alt=""/>}{node.text && <p>{node.text}</p>}</article>)}</div> : <p>{subset ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}</p>}
|
||||
</section>
|
||||
<nav className={s.shortcuts} aria-label="其他创作工具">{tools.slice(1).map(({label, href}) => <a key={href} href={href}>{label} <ArrowUpRight size={14}/></a>)}</nav>
|
||||
</div>}
|
||||
</>}
|
||||
</main>
|
||||
<dialog className={s.dialog} ref={subsetDialog} aria-labelledby="subcanvas-title">
|
||||
<div className={s.dialogTop}><h2 id="subcanvas-title">{subset ? '编辑子画布' : '新建子画布'}</h2><button type="button" aria-label="关闭" onClick={() => subsetDialog.current?.close()}><X size={20}/></button></div>
|
||||
<form onSubmit={saveSubset}>
|
||||
<p>从无限画布选择节点。原画布会保留这些节点。</p>
|
||||
<label htmlFor="subcanvas-name">子画布名称</label>
|
||||
<input id="subcanvas-name" value={subsetName} maxLength={40} onChange={event => setSubsetName(event.target.value)}/>
|
||||
<fieldset className={s.members}><legend>包含的节点</legend>{nodes.map(node => <label key={node.id}><input type="checkbox" checked={subsetNodes.includes(node.id)} onChange={event => setSubsetNodes(event.target.checked ? [...subsetNodes, node.id] : subsetNodes.filter(id => id !== node.id))}/>{node.title || '未命名节点'}</label>)}</fieldset>
|
||||
{error && <p className={s.error} role="alert">{error}</p>}
|
||||
<button className={s.primary} type="submit">保存子画布</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog className={s.dialog} ref={dialog} aria-labelledby="workspace-dialog-title">
|
||||
<div className={s.dialogTop}>
|
||||
{step !== 'choice' && step !== 'invite' && <button type="button" aria-label="返回上一步" onClick={() => {setError(''); setStep(step === 'name' ? 'purpose' : 'choice');}}><ArrowLeft size={20}/></button>}
|
||||
<h2 id="workspace-dialog-title">{{choice:'建立一个创作空间',join:'加入 Workspace',purpose:'这个空间用来做什么?',name:'给你的 Workspace 命名',invite:'邀请成员'}[step]}</h2>
|
||||
<button type="button" aria-label="关闭" onClick={() => dialog.current?.close()}><X size={20}/></button>
|
||||
</div>
|
||||
{step === 'choice' ? <>
|
||||
<p>加入已有空间,或创建自己的 Workspace。</p>
|
||||
<div className={s.options}>
|
||||
<button type="button" onClick={() => setStep('join')}><User size={22}/><span><strong>加入已有空间</strong><small>使用空间所有者提供的邀请码</small></span></button>
|
||||
<button type="button" onClick={() => setStep('purpose')}><Plus size={22}/><span><strong>创建 Workspace</strong><small>为新项目建立一个空间</small></span></button>
|
||||
</div>
|
||||
</> : step === 'join' ? <form onSubmit={submitWorkspace}>
|
||||
<label htmlFor="workspace-invite">邀请码</label>
|
||||
<input id="workspace-invite" value={code} maxLength={80} onChange={event => setCode(event.target.value)}/>
|
||||
{error && <p className={s.error} role="alert">{error}</p>}
|
||||
<button className={s.primary} type="submit" disabled={busy}>加入 Workspace</button>
|
||||
</form> : step === 'invite' ? <>
|
||||
<p>邀请码有效期为 24 小时。把它交给要加入的成员。</p>
|
||||
{busy && <p role="status">正在生成邀请码…</p>}
|
||||
{error && <p className={s.error} role="alert">{error}</p>}
|
||||
{code && <input aria-label="邀请码" readOnly value={code} onFocus={event => event.target.select()}/>}
|
||||
</> : step === 'purpose' ? <>
|
||||
<p>选择一个起点。</p>
|
||||
<div className={s.options}>{purposes.map(({label, detail, icon: Icon}) => <button key={label} type="button" aria-pressed={purpose === label} onClick={() => setPurpose(label)}><Icon size={23}/><span><strong>{label}</strong><small>{detail}</small></span></button>)}</div>
|
||||
<button className={s.primary} type="button" disabled={!purpose} onClick={() => setStep('name')}>继续 <ArrowUpRight size={16}/></button>
|
||||
</> : <form onSubmit={submitWorkspace}>
|
||||
<p>{purpose} · 一个空间,装下你的想法与作品。</p>
|
||||
<div className={s.iconPreview}><span className={s.avatar}>{Array.from(name.trim())[0] || '空'}</span>空间图标</div>
|
||||
<label htmlFor="workspace-name">Workspace 名称</label>
|
||||
<input id="workspace-name" autoFocus value={name} maxLength={40} onChange={e => {setName(e.target.value); setError('');}} aria-invalid={!!error} aria-describedby={error ? 'workspace-error' : undefined} placeholder="输入空间名称"/>
|
||||
{error && <p id="workspace-error" role="alert" className={s.error}>{error}</p>}
|
||||
<button className={s.primary} type="submit" disabled={busy}>创建 Workspace <ArrowUpRight size={16}/></button>
|
||||
</form>}
|
||||
</dialog>
|
||||
{notice && <div role="status" className={s.notice}>{notice}</div>}
|
||||
</div>;
|
||||
}
|
||||
+24
-24
@@ -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",
|
||||
+2
-1
@@ -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)};}
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user