refactor: migrate pages and API routes to App Router

This commit is contained in:
2026-11-05 23:20:34 +08:00
parent 982fa188e2
commit 6c4d6100b7
18 changed files with 287 additions and 174 deletions
+122
View File
@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from "next/server";
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
const bodyLimit = 1024 * 1024;
// Route Handlers do not have the Pages Router bodyParser size limit.
async function readBody(req: NextRequest) {
const reader = req.body?.getReader();
if (!reader) return {};
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > bodyLimit) {
await reader.cancel();
throw new Error("body_too_large");
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
if (!size) return {};
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
async function handler(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
const json = (body: unknown, status: number) =>
NextResponse.json(body, { status, headers: responseHeaders });
const requestOrigin = req.headers.get("origin");
if (req.method !== "GET" && requestOrigin) {
try {
if (new URL(requestOrigin).host !== req.headers.get("host"))
return json({ error: "invalid_origin" }, 403);
} catch {
return json({ error: "invalid_origin" }, 403);
}
}
const path = (await params).path.join("/");
if (!allowed.test(path)) return json({ error: "not_found" }, 404);
let requestBody: unknown;
if (req.method !== "GET") {
try {
requestBody = await readBody(req);
} catch (error) {
const tooLarge = error instanceof Error && error.message === "body_too_large";
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
}
}
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
const headers: Record<string, string> = { "Content-Type": "application/json" };
const session = req.cookies.get("foundry_session")?.value;
if (session) headers.Authorization = "Bearer " + session;
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) {
const value = req.headers.get(h);
if (value !== null) headers[h] = value;
}
const query = new URLSearchParams();
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
const values = req.nextUrl.searchParams.getAll(key);
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
}
const started = performance.now();
try {
const upstream = await fetch(
origin + "/projects/" + encodeURIComponent(project) + "/" + path +
(query.size ? "?" + query.toString() : ""),
{
method: req.method,
headers,
body: req.method === "GET" ? undefined : JSON.stringify(requestBody || {}),
signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
redirect: "manual",
cache: "no-store",
},
);
const headersReceived = performance.now();
const body = upstream.status === 204 ? null : await upstream.json();
responseHeaders.set(
"Server-Timing",
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
);
if (body?.access_token) {
responseHeaders.set(
"Set-Cookie",
`foundry_session=${encodeURIComponent(body.access_token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https" ? "; Secure" : ""}`,
);
delete body.access_token;
}
if (path === "auth/logout" && upstream.ok)
responseHeaders.set(
"Set-Cookie",
"foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
);
if (upstream.status === 204)
return new NextResponse(null, { status: 204, headers: responseHeaders });
return json(body || { error: "upstream_unavailable" }, upstream.status);
} catch (error) {
responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`);
const timeout = error instanceof Error && error.name === "TimeoutError";
return json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" }, timeout ? 504 : 502);
}
}
function methodNotAllowed() {
return NextResponse.json({ error: "method_not_allowed" }, {
status: 405,
headers: { "Cache-Control": "no-store", Allow: "GET, POST, PATCH, DELETE" },
});
}
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS };
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> },
) {
const { provider } = await params;
const challenges = req.nextUrl.searchParams.getAll("challenge");
const challenge = challenges[0];
if (
!["google", "slack"].includes(provider) ||
challenges.length !== 1 ||
!/^[-_a-zA-Z0-9]{43}$/.test(challenge)
)
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
return NextResponse.redirect(
origin + "/projects/" + encodeURIComponent(project) + "/auth/" + provider + "?challenge=" + challenge,
{ status: 302, headers: { "Cache-Control": "no-store" } },
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../login/page";
+935
View File
@@ -0,0 +1,935 @@
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
ArrowUpRight,
Plus,
Search,
Folder,
FileText,
LayoutGrid,
LogOut,
ChevronDown,
PanelLeftClose,
PanelLeftOpen,
Check,
MoreHorizontal,
X,
ArrowLeft,
Sun,
Moon,
} from "lucide-react";
import { Brand } from "@/components/brand";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { api, errorText, type Profile } from "@/lib/api";
import {
createBoard,
loadWorkspaceHome,
type Workspace,
} from "@/lib/workspace";
type Board = {
id: string;
title: string;
updated: string;
style: number;
text: string;
};
const initialBoards: Board[] = [
{
id: "morning",
title: "城市清晨",
updated: "刚刚",
style: 0,
text: "让阳光穿过叶片,落在安静的房间里。",
},
{
id: "ideas",
title: "灵感收集",
updated: "昨天",
style: 1,
text: "把一闪而过的念头,留给下一次创作。",
},
{
id: "journey",
title: "下一站,去哪里",
updated: "2 天前",
style: 2,
text: "为一段还未开始的旅程,收集一些可能。",
},
];
export default function Dashboard() {
const router = useRouter();
const demo = useSearchParams().get("demo") === "1";
const mutationVersion = useRef(0);
const [boardsLoading, setBoardsLoading] = useState(false);
const [spacesLoaded, setSpacesLoaded] = useState(false);
const [ready, setReady] = useState(false),
[profile, setProfile] = useState<Profile | null>(null),
[workspaces, setWorkspaces] = useState<Workspace[]>([]),
[workspace, setWorkspace] = useState(""),
[boards, setBoards] = useState<Board[]>([]),
[query, setQuery] = useState(""),
[view, setView] = useState("全部文件"),
[canvas, setCanvas] = useState<Board | null>(null),
[sidebar, setSidebar] = useState(true),
[dialog, setDialog] = useState<"board" | "workspace" | null>(null),
[title, setTitle] = useState(""),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[workspaceMenu, setWorkspaceMenu] = useState(false),
[notice, setNotice] = useState(""),
[dark, setDark] = useState(false),
[checked, setChecked] = useState(false),
[zoom, setZoom] = useState(100),
[nodeText, setNodeText] = useState(""),
[note, setNote] = useState("");
useEffect(() => {
let active = true;
if (demo) {
setProfile({
id: "demo",
username: "creator",
display_name: "创作者",
email: null,
});
setWorkspaces([{ id: "demo", name: "个人空间" }]);
setWorkspace("demo");
setSpacesLoaded(true);
let stored: Board[] | null = null;
try {
stored = JSON.parse(
localStorage.getItem("endoleap_demo_boards_demo") || "null",
);
} catch {}
setBoards(stored || initialBoards);
setReady(true);
return;
}
const controller = new AbortController();
loadWorkspaceHome(api, controller.signal)
.then(({ profile, workspaces: list }) => {
if (!active) return;
setProfile(profile);
setWorkspaces(list);
setSpacesLoaded(true);
setWorkspace(list[0]?.id || "");
setReady(true);
})
.catch((e) => {
if (!active) return;
if (e.message === "invalid_session") router.replace("/login");
else {
setError(errorText(e));
setReady(true);
}
});
return () => {
active = false;
controller.abort();
};
}, [demo, router]);
useEffect(() => {
if (demo && ready)
localStorage.setItem(
"endoleap_demo_boards_" + workspace,
JSON.stringify(boards),
);
}, [boards, demo, ready, workspace]);
useEffect(() => {
if (!workspace || demo) {
setBoardsLoading(false);
return;
}
let active = true;
const controller = new AbortController();
const version = mutationVersion.current;
setBoardsLoading(true);
setBoards([]);
api<{ items: { id: string; title: string }[] }>(
"workspaces/" +
workspace +
"/materials?kind=board&sort=modified&limit=100",
{ signal: controller.signal },
)
.then((r) => {
if (active && version === mutationVersion.current)
setBoards(
r.items.map((b, i) => ({
id: b.id,
title: b.title,
updated: "最近",
style: i % 3,
text: "",
})),
);
})
.catch((e) => {
if (active) setError(errorText(e));
})
.finally(() => {
if (active) setBoardsLoading(false);
});
return () => {
active = false;
controller.abort();
};
}, [workspace, demo]);
useEffect(() => {
if (notice) {
const timer = setTimeout(() => setNotice(""), 3500);
return () => clearTimeout(timer);
}
}, [notice]);
async function create(e: FormEvent) {
e.preventDefault();
if (!title.trim()) return;
setBusy(true);
setError("");
try {
if (dialog === "workspace") {
const w = demo
? { id: crypto.randomUUID(), name: title.trim() }
: await api<Workspace>("workspaces", {
method: "POST",
body: JSON.stringify({ name: title.trim() }),
});
setWorkspaces([...workspaces, w]);
setWorkspace(w.id);
setBoards([]);
} else {
if (!workspace && !spacesLoaded)
throw new Error("workspace_not_loaded");
const id = demo
? crypto.randomUUID()
: (
await createBoard(api, title, workspace, (w) => {
setWorkspaces((previous) =>
previous.some((x) => x.id === w.id)
? previous
: [...previous, w],
);
setWorkspace(w.id);
})
).id;
mutationVersion.current += 1;
setBoards((previous) => [
{
id,
title: title.trim(),
updated: "刚刚",
style: boards.length % 3,
text: "一张空白画布,等待你的第一个念头。",
},
...previous,
]);
}
setDialog(null);
setTitle("");
setNotice("已创建");
} catch (e) {
setError(errorText(e));
} finally {
setBusy(false);
}
}
async function logout() {
try {
if (!demo) await api("auth/logout", { method: "POST" });
router.push("/login");
} catch (e) {
setError(errorText(e));
}
}
function open(b: Board) {
setCanvas(b);
setNote(b.text);
setNodeText("");
if (!demo) setNotice("当前为画布界面预览;服务端已保存画布名称。");
}
function saveNote() {
if (!canvas) return;
if (demo) {
setBoards(
boards.map((b) => (b.id === canvas.id ? { ...b, text: note } : b)),
);
setNotice("已保存到当前浏览器");
} else setNotice("画布文字编辑将在布局接口接入后保存。");
}
const names =
workspaces.find((w) => w.id === workspace)?.name || "创建你的空间";
const filtered = boards.filter((b) =>
b.title.toLowerCase().includes(query.toLowerCase()),
);
if (!ready)
return (
<div className="loading-page">
<Brand />
<p>正在打开你的创作空间…</p>
</div>
);
return (
<div className={"dashboard " + (dark ? "dark-workspace" : "")}>
<aside className="icon-rail">
<Link href="/" aria-label="首页">
<img
src="/assets/dashboardcontext-imgFrame.svg"
width="25"
height="25"
alt="啡跃"
/>
</Link>
<div className="rail-nav">
{[
["LayoutDashboard", "工作台"],
["Layers", "图层"],
["Search", "搜索"],
["ImagePlus", "素材"],
["Bot", "Agent"],
].map(([icon, label]) => (
<button
title={label}
aria-label={label}
className={view === label ? "active" : ""}
key={label}
onClick={() => {
if (label === "搜索") {
setCanvas(null);
setTimeout(
() => document.getElementById("board-search")?.focus(),
0,
);
} else if (label === "图层" && canvas) setSidebar(!sidebar);
else if (label === "工作台") {
setCanvas(null);
setView("全部文件");
} else setNotice(label + "将在后续版本开放。");
}}
>
<Icon name={icon} size={17} />
</button>
))}
</div>
<div className="rail-bottom">
<button aria-label="切换亮暗主题" onClick={() => setDark(!dark)}>
{dark ? <Sun size={17} /> : <Moon size={17} />}
</button>
<button aria-label="退出登录" title="退出登录" onClick={logout}>
<LogOut size={17} />
</button>
</div>
</aside>
{sidebar && (
<aside className="workspace-sidebar">
<div className="workspace-switch">
<button
onClick={() => setWorkspaceMenu(!workspaceMenu)}
aria-expanded={workspaceMenu}
>
<span className="workspace-avatar">↑</span>
<span>{names}</span>
<ChevronDown size={13} />
</button>
{workspaceMenu && (
<div className="workspace-menu">
{workspaces.map((w) => (
<button
key={w.id}
onClick={() => {
setWorkspace(w.id);
setWorkspaceMenu(false);
if (!demo) setBoards([]);
else {
let stored: Board[] = [];
try {
stored = JSON.parse(
localStorage.getItem(
"endoleap_demo_boards_" + w.id,
) || "[]",
);
} catch {}
setBoards(stored);
}
}}
>
{w.name}
{workspace === w.id && <Check size={14} />}
</button>
))}
<button
onClick={() => {
setDialog("workspace");
setWorkspaceMenu(false);
}}
>
<Plus size={14} /> 新建工作空间
</button>
</div>
)}
</div>
<button
className="profile-row"
onClick={() => setNotice(profile?.email || "当前为本地演示账户")}
>
<span className="user-avatar">
{(profile?.display_name || profile?.username || "U")
.slice(0, 1)
.toUpperCase()}
</span>
<span>
{profile?.display_name || profile?.username || "创作者"}
</span>
<ChevronDown size={12} />
</button>
<div className="sidebar-label">
{canvas ? "当前画布" : "工作空间"}
</div>
{canvas ? (
<>
<button
className="side-item selected"
onClick={() => {
setCanvas(null);
setView("全部文件");
}}
>
<ArrowLeft size={15} />
返回全部文件
</button>
<div className="layer-label">
<Icon name="Layers" size={15} /> 图层 <Plus size={14} />
</div>
{[
"灵感片段",
"镜头研究",
"清晨光线",
"镜头节奏",
"光的形状",
"环境声音",
"拍摄待办",
"色彩参考",
].map((n, i) => (
<button
key={n}
className={"side-item layer " + (i === 1 ? "selected" : "")}
onClick={() => {
document.getElementById("canvas-note")?.focus();
setNotice("已选择「" + n + "」");
}}
>
<Icon
name={i === 1 ? "Scan1" : i === 4 ? "Square" : "Type"}
size={14}
/>
{n}
</button>
))}
</>
) : (
<>
{[
["全部文件", LayoutGrid],
["最近打开", FileText],
["我的收藏", Folder],
].map(([label, Comp]) => {
const C = Comp as typeof Folder;
return (
<button
key={label as string}
className={
"side-item " + (view === label ? "selected" : "")
}
onClick={() => setView(label as string)}
>
<C size={16} />
{label as string}
</button>
);
})}
<div className="sidebar-label sidebar-label-flex">
画布{" "}
<button
aria-label="新建画布"
onClick={() => setDialog("board")}
>
<Plus size={15} />
</button>
</div>
{boards.map((b) => (
<button
key={b.id}
className="side-item"
onClick={() => open(b)}
>
<Icon name="Scan1" size={15} />
{b.title}
</button>
))}
<button
className="side-item muted"
onClick={() => setDialog("board")}
>
<Plus size={15} />
新建画布
</button>
</>
)}
<div className="sidebar-bottom">
<p>给想法留白,给创作空间。</p>
<div className="sidebar-bottom-line">
<Brand />
{demo && <span className="demo-badge">演示</span>}
</div>
</div>
</aside>
)}
<main className={"workspace-main " + (canvas ? "canvas-main" : "")}>
<header className="workspace-header">
<button
className="icon-button"
aria-label={sidebar ? "收起侧栏" : "展开侧栏"}
onClick={() => setSidebar(!sidebar)}
>
{sidebar ? (
<PanelLeftClose size={17} />
) : (
<PanelLeftOpen size={17} />
)}
</button>
<span>{names}</span>
<span className="breadcrumb">/</span>
<span>{canvas ? canvas.title : "工作台"}</span>
<div className="header-right">
{demo && (
<span className="demo-label">
本地演示 · 内容保存在此浏览器{" "}
<button
onClick={() => {
setBoards(initialBoards);
setWorkspace("demo");
setWorkspaces([{ id: "demo", name: "个人空间" }]);
}}
>
重置示例
</button>
</span>
)}
<button
className="icon-button"
aria-label="切换主题"
onClick={() => setDark(!dark)}
>
{dark ? <Sun size={17} /> : <Moon size={17} />}
</button>
</div>
</header>
{error && (
<div className="workspace-error" role="alert">
{error}
<button onClick={() => setError("")} aria-label="关闭错误">
<X size={16} />
</button>
</div>
)}
{canvas ? (
<div className="canvas-viewport">
<div className="canvas-floating-title">
<Icon name="SunMoon" size={17} />
{canvas.title}
<span>已打开</span>
</div>
<div className="canvas-zoom">
<button onClick={() => setZoom(Math.max(50, zoom - 10))}>
−
</button>
{zoom}%
<button onClick={() => setZoom(Math.min(150, zoom + 10))}>
+
</button>
<Button
size="sm"
onClick={() => setNotice("分享需要后端空间权限与链接配置。")}
>
分享 <ArrowUpRight size={12} />
</Button>
</div>
<div
className="canvas-stage"
style={{ transform: "scale(" + zoom / 100 + ")" }}
>
<div className="loose-note">
<small>灵感片段</small>
<p>
把日常,
<br />
拍成一场电影。
</p>
<span>一闪而过的想法。</span>
</div>
<section className="study-board">
<div className="board-caption">
镜头研究 <span>子画布 · 740 × 600</span>
</div>
<div className="study-text">
<h3>清晨光线</h3>
<textarea
id="canvas-note"
aria-label="清晨光线笔记"
value={note}
onChange={(e) => setNote(e.target.value)}
onBlur={saveNote}
/>
</div>
<div className="study-text">
<h3>镜头节奏</h3>
<p>
缓慢推进,保留停顿。
<br />
35mm · 温暖柔和的光线。
</p>
</div>
<div className="study-sound">
<h3>环境声音</h3>
<p>
窗外车流、杯子落桌、翻书。
<br />
保留房间里自然的声音。
</p>
</div>
<div className="study-art">
<h3>光的形状</h3>
<div className="light-shape" />
</div>
<div className="study-checks">
<h3>拍摄待办</h3>
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>{" "}
窗边植物
</label>
<label>
<input type="checkbox" /> 城市空镜
</label>
</div>
<div className="study-palette">
<small>色彩参考</small>
<div>
{[
"#49372e",
"#a47764",
"#d1aa92",
"#ede9d0",
"#fff6ef",
].map((c) => (
<span key={c} style={{ background: c }} />
))}
</div>
</div>
</section>
{nodeText && (
<div className="new-canvas-node">
<textarea
aria-label="新文字节点"
value={nodeText}
onChange={(e) => setNodeText(e.target.value)}
/>
<button
aria-label="移除临时节点"
onClick={() => setNodeText("")}
>
<X size={13} />
</button>
</div>
)}
</div>
<div className="canvas-toolbar">
{[
["MousePointer2", "选择"],
["Hand", "平移"],
["Type2", "文字节点"],
["FileText1", "文章节点"],
["ImagePlus", "图片节点"],
["Square1", "图形节点"],
["Link2", "连接"],
].map(([icon, label]) => (
<button
key={icon}
aria-label={label}
title={label}
onClick={() => {
if (label === "文字节点")
setNodeText("在这里写下新的念头…");
else setNotice("已选择" + label + "工具");
}}
>
<Icon name={icon} />
</button>
))}
<button aria-label="返回文件" onClick={() => setCanvas(null)}>
<LayoutGrid size={18} />
</button>
</div>
</div>
) : (
<div className="workspace-content">
<div className="welcome-top">
<div className="eyebrow">
A LITTLE SPACE, A LOT OF POSSIBILITY
</div>
<span className="welcome-spark">✳</span>
<h1>让灵感,从这里开始。</h1>
<p>
欢迎回来,
{profile?.display_name || profile?.username || "创作者"}
。今天有什么新想法?
</p>
</div>
<div className="quick-create">
<button onClick={() => setDialog("board")}>
<span className="quick-icon peach">
<Icon name="LayoutDashboard" size={24} />
</span>
<div>
<h3>空白画布</h3>
<p>让思路,自由生长。</p>
</div>
<Plus size={18} />
</button>
<button
onClick={() => {
setDialog("board");
setTitle("未命名灵感");
}}
>
<span className="quick-icon olive">
<Icon name="FileText" size={24} />
</span>
<div>
<h3>记录灵感</h3>
<p>先留下,再慢慢展开。</p>
</div>
<Plus size={18} />
</button>
</div>
<section className="file-section">
<div className="files-heading">
<h2>{view}</h2>
<div>
<div className="board-search">
<Search size={15} />
<input
id="board-search"
placeholder="搜索画布…"
aria-label="搜索画布"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setDialog("board")}
>
<Plus size={14} />
新建
</Button>
</div>
</div>
<div className="file-tabs">
{["全部文件", "最近打开", "我的收藏"].map((t) => (
<button
key={t}
className={view === t ? "active" : ""}
onClick={() => setView(t)}
>
{t}
</button>
))}
</div>
{boardsLoading ? (
<div className="empty-state" role="status">
<p>正在读取画布…</p>
</div>
) : view === "我的收藏" ? (
<div className="empty-state">
<Folder size={32} />
<h3>给喜欢的想法,留个位置。</h3>
<p>收藏功能将在后续版本接入。</p>
</div>
) : filtered.length ? (
<div className="board-grid">
{filtered.map((b) => (
<button
className="board-card"
key={b.id}
onClick={() => open(b)}
>
<div className={"board-cover cover-" + b.style}>
{b.style === 0 ? (
<>
<span className="cover-title">光的形状</span>
<div className="light-shape" />
<span className="cover-small">
城市清晨 / A MORNING STUDY
</span>
</>
) : b.style === 1 ? (
<>
<span className="cover-serif">
a little
<br />
room for
<br />
<i>ideas.</i>
</span>
<b>✳</b>
</>
) : (
<>
<div className="cover-circles">
<span />
<span />
<span />
</div>
<span className="cover-serif">
去看看
<br />
不一样的日常。
</span>
</>
)}
</div>
<div className="board-card-info">
<Icon name="Scan1" size={18} />
<div>
<h3>{b.title}</h3>
<p>{b.updated}编辑</p>
</div>
<MoreHorizontal size={17} />
</div>
</button>
))}
</div>
) : (
<div className="empty-state">
<LayoutGrid size={32} />
<h3>{query ? "没有找到相关画布" : "这里还留着一片空白。"}</h3>
<p>
{query
? "换个关键词试试。"
: "从一个小小的念头开始,创建第一张画布。"}
</p>
{!query && (
<Button
onClick={() =>
setDialog(workspace ? "board" : "workspace")
}
>
<Plus size={15} />
创建{workspace ? "画布" : "空间"}
</Button>
)}
</div>
)}
</section>
<div className="workspace-tip">
<span>✦</span>
<p>
不用想好全部。
<br />
<strong>带着一点好奇,先开始。</strong>
</p>
<img
src="/assets/landingcontext-imgFrame3.svg"
width="100"
height="110"
alt="记下突然出现的灵感"
/>
</div>
</div>
)}
</main>
{notice && (
<div className="toast" role="status">
<Check size={16} />
{notice}
</div>
)}
<Dialog
open={dialog !== null}
onOpenChange={(o) => {
if (!o) {
setDialog(null);
setTitle("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle className="dialog-title">
{dialog === "workspace"
? "给你的空间起个名字"
: "一个新想法,从这里开始。"}
</DialogTitle>
<DialogDescription className="dialog-description">
{dialog === "workspace"
? "把相关的创作和资料放在同一个空间。"
: "创建一张空白画布,慢慢让灵感成形。"}
</DialogDescription>
</DialogHeader>
<form onSubmit={create}>
<label htmlFor="new-title">
{dialog === "workspace" ? "空间名称" : "画布名称"}
</label>
<input
id="new-title"
className="dialog-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={
dialog === "workspace"
? "例如:我的创作空间"
: "例如:周末的灵感"
}
maxLength={80}
autoFocus
required
/>
{error && (
<p className="form-error" role="alert">
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialog(null)}
>
取消
</Button>
<Button type="submit" disabled={busy || !title.trim()}>
{busy
? "创建中…"
: "创建" + (dialog === "workspace" ? "空间" : "画布")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Suspense } from "react";
import { Brand } from "@/components/brand";
import Dashboard from "./dashboard";
export default function DashboardPage() {
return (
<Suspense fallback={
<div className="loading-page">
<Brand />
<p>正在打开你的创作空间…</p>
</div>
}>
<Dashboard />
</Suspense>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "@/styles/globals.css";
export const metadata: Metadata = {
title: "啡跃 Endoleap · 让灵感有形",
description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Eye,
EyeOff,
ArrowUpRight,
LoaderCircle,
} from "lucide-react";
import { Brand } from "@/components/brand";
import { Button } from "@/components/ui/button";
import { api, errorText, startOAuth } from "@/lib/api";
export default function Login() {
const router = useRouter();
const [register, setRegister] = useState(false),
[email, setEmail] = useState(""),
[password, setPassword] = useState(""),
[show, setShow] = useState(false),
[busy, setBusy] = useState(false),
[message, setMessage] = useState(""),
[error, setError] = useState("");
useEffect(() => {
const hash = new URLSearchParams(location.hash.slice(1));
const code = hash.get("code"),
verify = hash.get("verify_email"),
failure = hash.get("error");
if (!code && !verify && !failure) return;
history.replaceState(null, "", location.pathname);
if (failure) {
setError("第三方登录未完成,请重新尝试。");
return;
}
setBusy(true);
const verifier = sessionStorage.getItem("foundry_verifier");
sessionStorage.removeItem("foundry_verifier");
(verify
? api("auth/verify-email", {
method: "POST",
body: JSON.stringify({ token: verify }),
})
: verifier
? api("auth/exchange", {
method: "POST",
headers: {
"X-Foundry-Code": code!,
"X-Foundry-Verifier": verifier,
},
})
: Promise.reject(new Error("invalid_session"))
)
.then(() => router.replace("/dashboard"))
.catch((e) => setError(errorText(e)))
.finally(() => setBusy(false));
}, [router]);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
setMessage("");
try {
await api("auth/" + (register ? "register" : "login"), {
method: "POST",
body: JSON.stringify({ email, password }),
});
if (register)
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
else router.push("/dashboard");
} catch (e) {
setError(errorText(e));
} finally {
setBusy(false);
}
}
async function oauth(p: "google" | "slack") {
setError("");
setMessage("正在前往 " + (p === "google" ? "Google" : "Slack") + "…");
setBusy(true);
try {
await startOAuth(p);
} catch {
setBusy(false);
setMessage("");
setError("无法启动登录,请重新尝试。");
}
}
return (
<div className="auth-page">
<aside className="auth-story">
<Brand light />
<div className="auth-story-copy">
<div className="eyebrow">A SPACE FOR YOUR NEXT IDEA</div>
<h1>
给想法留白,
<br />
让灵感有形。
</h1>
<p>
收集一闪而过的灵感,把有用的知识连接起来。
<br />
下一件作品,从这里开始。
</p>
</div>
<div className="auth-collage">
<div className="auth-orbit" />
<article className="auth-note">
<small>01 / THE IDEA</small>
<h2>
留住清晨
<br />
的一束光。
</h2>
<p>灵感片段 · 随手记下</p>
</article>
<article className="auth-art">
<small>02 / IN THE MAKING</small>
<div className="light-shape" />
<h3>光的形状</h3>
<p>让一个念头,慢慢成形。</p>
</article>
</div>
<span className="auth-caption">COLLECT. CONNECT. CREATE.</span>
<footer>啡跃 Endoleap 为下一次创作,留一个起点。</footer>
</aside>
<main className="auth-main">
<Link href="/" className="auth-back">
返回首页 <ArrowUpRight size={14} />
</Link>
<div className="auth-form-wrap">
<div className="eyebrow">WELCOME TO YOUR CREATIVE SPACE</div>
<h1>{register ? "创建你的账户" : "欢迎回来"}</h1>
<p>
{register ? "为下一次创作,留一个起点。" : "登录,继续你的创作。"}
</p>
<div className="auth-tabs" role="tablist" aria-label="登录方式">
<button
role="tab"
aria-selected={!register}
onClick={() => {
setRegister(false);
setError("");
setMessage("");
}}
>
登录
</button>
<button
role="tab"
aria-selected={register}
onClick={() => {
setRegister(true);
setError("");
setMessage("");
}}
>
创建账户
</button>
</div>
<form onSubmit={submit}>
<label htmlFor="email">邮箱地址</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">密码</label>
<div className="password-field">
<input
id="password"
type={show ? "text" : "password"}
autoComplete={register ? "new-password" : "current-password"}
placeholder={register ? "至少 12 个字符" : "输入你的密码"}
required
minLength={register ? 12 : 1}
maxLength={72}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
aria-label={show ? "隐藏密码" : "显示密码"}
onClick={() => setShow(!show)}
>
{show ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{error && (
<p role="alert" className="form-error">
{error}
</p>
)}
{message && (
<p role="status" className="form-success">
{message}
</p>
)}
<Button className="auth-submit" disabled={busy} type="submit">
{busy ? (
<LoaderCircle className="animate-spin" size={18} />
) : (
<>
{register ? "创建账户" : "登录"}
<ArrowRight size={17} />
</>
)}
</Button>
</form>
<div className="auth-divider">
<span>或使用以下方式继续</span>
</div>
<div className="social-buttons">
<Button
variant="outline"
onClick={() => oauth("google")}
disabled={busy}
>
<strong className="google-mark">G</strong> Google
</Button>
<Button
variant="outline"
onClick={() => oauth("slack")}
disabled={busy}
>
<strong className="slack-mark">#</strong> Slack
</Button>
</div>
<p className="auth-notice">
首次注册需要验证邮箱。
<br />
验证成功后,即可进入你的创作空间。
</p>
<Link href="/dashboard?demo=1" className="demo-link">
先看看工作台 <ArrowUpRight size={13} />
</Link>
</div>
</main>
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ArrowUpRight, ArrowRight, Plus, Minus } from "lucide-react";
import { Brand } from "@/components/brand";
import { Button } from "@/components/ui/button";
const scenes = [
"收藏生活的切片",
"遇见新的工作方法",
"记下突然出现的灵感",
"给想法一点自由",
"做一件属于你的作品",
];
export default function Home() {
const [faq, setFaq] = useState<number | null>(0);
return (
<div className="landing">
<header className="site-nav">
<Brand />
<nav aria-label="主导航">
<a href="#tools">创作工具</a>
<a href="#ideas">知识与灵感</a>
<a href="#workflow">工作方式</a>
</nav>
<div className="nav-actions">
<Link href="/login">登录</Link>
<Button asChild>
<Link href="/login">
进入工作台 <ArrowUpRight size={15} />
</Link>
</Button>
</div>
</header>
<main>
<section className="hero container">
<div className="eyebrow">
<span className="status-dot" /> A SPACE FOR HUMAN IDEAS & AGENTS
</div>
<h1>
想法不该停在对话里。
<br />
让灵感,<span>有形。</span>
<svg
className="hero-stroke"
viewBox="0 0 230 18"
aria-hidden="true"
>
<path
d="M5 13 Q100 0 225 9"
fill="none"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
/>
</svg>
</h1>
<p className="hero-copy">
从一闪而过的念头,到值得分享的作品。
<br />
在一张画布上,把你的创作继续下去。
</p>
<div className="hero-actions">
<Button asChild size="lg">
<Link href="/login">
开始创作 <ArrowUpRight size={18} />
</Link>
</Button>
<a className="text-link" href="#space">
探索你的创作空间 <ArrowRight size={16} />
</a>
</div>
<div className="people-row">
{scenes.map((s, i) => (
<figure key={s}>
<div className="person-slot">
<img
src={"/assets/landingcontext-imgFrame" + (i + 1) + ".svg"}
width="200"
height="220"
alt={s}
/>
</div>
<figcaption>
<span>0{i + 1}</span>
{s}
</figcaption>
</figure>
))}
</div>
<div className="hero-footnote">
<span>COLLECT. CONNECT. CREATE.</span>
<span>给好奇心一点空间 ↓</span>
</div>
</section>
<section id="space" className="space-section">
<div className="container">
<div className="section-heading">
<div>
<div className="eyebrow">YOUR IDEAS, ALL TOGETHER</div>
<h2>
一个念头,
<br />
可以去很远。
</h2>
</div>
<p>
笔记、参考、还没想好的方向。
<br />
放在一起,看见它们之间的新可能。
</p>
</div>
<div className="product-preview">
<div className="preview-sidebar">
<Brand />
<p>
个人空间 <span>⌄</span>
</p>
<div className="preview-active">▦ 城市清晨</div>
<div>▧ 灵感收集</div>
<div>+ 新建画布</div>
<small>你的下一件作品,从这里开始。</small>
</div>
<div className="preview-canvas">
<div className="preview-bar">
☀ 城市清晨 <span>↗ 分享</span>
</div>
<div className="preview-note">
<small>01 / THE IDEA</small>
<h3>
把日常,
<br />
拍成一场电影。
</h3>
<p>
一束光、一段声音、
<br />
一个值得留下的瞬间。
</p>
</div>
<div className="preview-art">
<div className="light-shape" />
<span>光的形状</span>
</div>
<div className="preview-sticky">
慢一点。
<br />
让生活里的细节
<br />
成为下一次灵感。<span>✳</span>
</div>
<svg
viewBox="0 0 800 500"
preserveAspectRatio="none"
className="preview-connect"
aria-hidden="true"
>
<path
d="M290 235 C420 235 310 360 480 360"
fill="none"
stroke="#a98b72"
strokeWidth="1.5"
strokeDasharray="5 5"
/>
</svg>
<div className="preview-tools">↖ ✋ T ▧ □ ↗ +</div>
</div>
</div>
</div>
</section>
<section id="tools" className="tools-section container">
<div className="section-heading">
<div>
<div className="eyebrow">TWO TOOLS. ONE CREATIVE FLOW.</div>
<h2>
给不同的想法,
<br />
恰好的空间。
</h2>
</div>
<p>
从整理思路,到亲手完成作品。
<br />
每一步,都有你的空间。
</p>
</div>
<div className="tool-grid">
<article className="tool-card">
<div className="tool-meta">
01 / GRAPHIC <ArrowUpRight size={20} />
</div>
<div className="type-art">
MAKE
<br />
ROOM<span>for ideas.</span>
<i>✳</i>
</div>
<h3>把灵感排成作品。</h3>
<p>
让文字、图形与图片自由编排。打磨每个细节,留下属于你的表达。
</p>
<Link href="/login" className="text-link">
打开图形编辑器 <ArrowRight size={16} />
</Link>
</article>
<article className="tool-card">
<div className="tool-meta">
02 / INFINITE CANVAS <ArrowUpRight size={20} />
</div>
<div className="canvas-art">
<span>一个念头</span>
<i>⤳</i>
<span>一张参考</span>
<b>新的可能 ✧</b>
</div>
<h3>让思路,自由生长。</h3>
<p>
把笔记、参考图与提示词连成线索。在无限画布上,找到下一次创作的方向。
</p>
<Link href="/login" className="text-link">
打开无限画布 <ArrowRight size={16} />
</Link>
</article>
</div>
</section>
<section id="ideas" className="ideas-section">
<div className="container">
<div className="eyebrow">GOOD IDEAS DESERVE GOOD COMPANY</div>
<h2>
不必每一次,
<br />
都从空白开始。
</h2>
<div className="idea-grid">
{[
["留住一闪而过", "一句念头、一张参考图。先留下,再慢慢展开。"],
["让线索相遇", "把不同的灵感放上画布,用连接看见新的可能。"],
["把创作继续下去", "用熟悉的方法探索,再亲手调整细节。"],
].map(([title, copy], i) => (
<article key={title}>
<img
src={"/assets/landingcontext-imgFrame" + (i + 6) + ".svg"}
width="140"
height="110"
alt=""
/>
<h3>{title}</h3>
<p>{copy}</p>
</article>
))}
</div>
</div>
</section>
<section id="workflow" className="workflow container">
<div>
<div className="eyebrow">YOUR AGENT. YOUR WORKFLOW.</div>
<h2>
熟悉的工作方式。
<br />
新的创作可能。
</h2>
<p>
从一个小小的起点出发,让工具跟上你的想法。
<br />
整理、连接、亲手打磨,把创作继续下去。
</p>
<Link href="/login" className="text-link">
进入创作空间 <ArrowUpRight size={17} />
</Link>
</div>
<ol>
{[
["找到起点", "留住一个念头,或者从一张空白画布开始。"],
["连接工作方式", "把相关的笔记与参考放在一起。"],
["亲手打磨,保存作品", "调整细节,让想法成为自己的表达。"],
].map(([t, c], i) => (
<li key={t}>
<span>0{i + 1}</span>
<div>
<h3>{t}</h3>
<p>{c}</p>
</div>
</li>
))}
</ol>
</section>
<section className="faq container">
<div className="eyebrow">A FEW THINGS TO KNOW</div>
<h2>开始之前,你也许想知道。</h2>
{[
[
"我可以在这里做什么?",
"收集灵感、整理笔记、在画布上连接想法,逐步把零散的线索变成作品。",
],
[
"需要先想好完整的作品吗?",
"不用。可以从一段文字、一张参考或一个还没有想清楚的念头开始。",
],
[
"我的内容会自动公开吗?",
"你的工作空间需要登录访问。分享内容前,请确认工作空间的成员与权限设置。",
],
].map(([q, a], i) => (
<div className="faq-item" key={q}>
<button
aria-expanded={faq === i}
onClick={() => setFaq(faq === i ? null : i)}
>
{q}
{faq === i ? <Minus size={17} /> : <Plus size={17} />}
</button>
{faq === i && <p>{a}</p>}
</div>
))}
</section>
<section className="closing">
<div className="eyebrow">YOUR NEXT IDEA STARTS HERE</div>
<h2>
给下一个念头,
<br />
留一个起点。
</h2>
<p>不用想好全部。带着一点好奇,先开始。</p>
<Button asChild size="lg">
<Link href="/login">
进入你的工作台 <ArrowUpRight size={18} />
</Link>
</Button>
<span className="closing-star">✳</span>
</section>
</main>
<footer className="site-footer">
<Brand />
<p>给想法留白,给创作空间。</p>
<a href="#workflow">
工作方式 <ArrowUpRight size={14} />
</a>
<span>© {new Date().getFullYear()} Endoleap</span>
</footer>
</div>
);
}