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
+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>
);
}