refactor: migrate pages and API routes to App Router
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user