chore: import existing Endoleap Pages Router project
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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] || "操作未完成,请稍后重试。";
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" } } };
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../login";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user