chore: import existing Endoleap Pages Router project

This commit is contained in:
2026-11-05 23:18:09 +08:00
commit 982fa188e2
84 changed files with 8374 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
export function Brand({ light = false }: { light?: boolean }) {
return (
<Link
href="/"
className={"brand " + (light ? "brand-light" : "")}
aria-label="啡跃 Endoleap 首页"
>
<img
src="/assets/landingcontext-imgFrame.svg"
alt=""
width="36"
height="36"
/>
<span>
啡跃 <small>Endoleap</small>
</span>
</Link>
);
}
+10
View File
@@ -0,0 +1,10 @@
export function Icon({ name, size = 18 }: { name: string; size?: number }) {
return (
<img
className="ui-icon"
src={"/assets/dashboardcontext-imgIcon" + name + ".svg"}
alt=""
style={{ width: size, height: size }}
/>
);
}
+58
View File
@@ -0,0 +1,58 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+122
View File
@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+56
View File
@@ -0,0 +1,56 @@
export type Profile = {
id: string;
username: string;
display_name: string | null;
email: string | null;
};
export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch("/api/foundry/" + path, {
...options,
headers: { "Content-Type": "application/json", ...options.headers },
});
const body = await res.json().catch(() => null);
if (!res.ok)
throw new Error(body?.error?.code || body?.error || "request_failed");
return body as T;
}
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
export async function startOAuth(provider: "google" | "slack") {
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
sessionStorage.setItem("foundry_verifier", verifier);
window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge);
}
export const errorText = (error: unknown) => {
const code = error instanceof Error ? error.message : "";
const messages: Record<string, string> = {
invalid_credentials: "邮箱或密码不正确,或邮箱尚未验证。",
rate_limited: "请求太频繁,请稍后再试。",
invalid_session: "登录已过期,请重新登录。",
workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。",
workspace_required: "请先创建或选择一个工作空间。",
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
invalid_reference: "工作空间不存在或当前账户无权访问,请刷新空间列表。",
upstream_timeout: "连接服务超时,请稍后再试。",
upstream_unavailable: "暂时无法连接服务,请稍后重试。",
not_found: "接口暂未上线,请稍后重试。",
invalid_verification: "验证链接无效或已过期,请重新注册以获取邮件。",
slack_not_configured: "Slack 登录尚未配置完成。",
};
return messages[code] || "操作未完成,请稍后重试。";
};
+5
View File
@@ -0,0 +1,5 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+43
View File
@@ -0,0 +1,43 @@
import type { Profile } from "./api";
export type Workspace = { id: string; name: string };
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
export async function loadWorkspaceHome(
request: Request,
signal?: AbortSignal,
) {
const [profile, result] = await Promise.all([
request<Profile>("profile", { signal }),
request<Workspace[] | { items: Workspace[] }>("workspaces", { signal }),
]);
return {
profile,
workspaces: Array.isArray(result) ? result : result.items || [],
};
}
export async function createBoard(
request: Request,
title: string,
workspaceId: string,
onWorkspaceCreated: (workspace: Workspace) => void,
) {
let id = workspaceId;
if (!id) {
const workspace = await request<Workspace>("workspaces", {
method: "POST",
body: JSON.stringify({ name: "个人空间" }),
});
id = workspace.id;
onWorkspaceCreated(workspace);
}
return request<{ id: string }>("workspaces/" + id + "/materials", {
method: "POST",
body: JSON.stringify({
kind: "board",
title: title.trim(),
content: { schema_version: 1 },
}),
});
}
+18
View File
@@ -0,0 +1,18 @@
import type { AppProps } from "next/app";
import Head from "next/head";
import "@/styles/globals.css";
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>啡跃 Endoleap · 让灵感有形</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。"
/>
</Head>
<Component {...pageProps} />
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="zh-CN">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { NextApiRequest, NextApiResponse } from "next";
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
res.setHeader("Cache-Control", "no-store");
if (!["GET", "POST", "PATCH", "DELETE"].includes(req.method || ""))
return res.status(405).json({ error: "method_not_allowed" });
if (req.method !== "GET" && req.headers.origin) {
try {
if (new URL(req.headers.origin).host !== req.headers.host)
return res.status(403).json({ error: "invalid_origin" });
} catch {
return res.status(403).json({ error: "invalid_origin" });
}
}
const path = (Array.isArray(req.query.path) ? req.query.path : []).join("/");
if (!allowed.test(path)) return res.status(404).json({ error: "not_found" });
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",
};
if (req.cookies.foundry_session)
headers.Authorization = "Bearer " + req.cookies.foundry_session;
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"])
if (typeof req.headers[h] === "string")
headers[h] = req.headers[h] as string;
const query = new URLSearchParams();
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
const v = req.query[key];
if (typeof v === "string" && v.length < 300) query.set(key, v);
}
const started = performance.now();
const disconnected = new AbortController();
const onClose = () => {
if (!res.writableEnded) disconnected.abort();
};
res.once("close", onClose);
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(req.body || {}),
signal: AbortSignal.any([
AbortSignal.timeout(15000),
disconnected.signal,
]),
redirect: "manual",
},
);
const headersReceived = performance.now();
const body = upstream.status === 204 ? null : await upstream.json();
res.setHeader(
"Server-Timing",
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
);
const session = body?.access_token;
if (session) {
res.setHeader(
"Set-Cookie",
`foundry_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers["x-forwarded-proto"] === "https" ? "; Secure" : ""}`,
);
delete body.access_token;
}
if (path === "auth/logout" && upstream.ok)
res.setHeader(
"Set-Cookie",
"foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
);
if (upstream.status === 204) return res.status(204).end();
return res
.status(upstream.status)
.json(body || { error: "upstream_unavailable" });
} catch (error) {
if (res.destroyed) return;
res.setHeader(
"Server-Timing",
`total;dur=${(performance.now() - started).toFixed(1)}`,
);
const timeout = error instanceof Error && error.name === "TimeoutError";
return res
.status(timeout ? 504 : 502)
.json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" });
} finally {
res.off("close", onClose);
}
}
export const config = { api: { bodyParser: { sizeLimit: "1mb" } } };
+26
View File
@@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const p = req.query.provider,
c = req.query.challenge;
if (
req.method !== "GET" ||
!["google", "slack"].includes(String(p)) ||
typeof c !== "string" ||
!/^[-_a-zA-Z0-9]{43}$/.test(c)
)
return res.status(400).json({ error: "invalid_request" });
const origin =
process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
res.setHeader("Cache-Control", "no-store");
res.redirect(
302,
origin +
"/projects/" +
encodeURIComponent(project) +
"/auth/" +
p +
"?challenge=" +
c,
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../login";
+934
View File
@@ -0,0 +1,934 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useRouter } from "next/router";
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 = router.query.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(() => {
if (!router.isReady) return;
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();
};
}, [router.isReady, 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" });
await 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>
);
}
+340
View File
@@ -0,0 +1,340 @@
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>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
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 await 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>
);
}
File diff suppressed because it is too large Load Diff