122 lines
8.6 KiB
TypeScript
122 lines
8.6 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
|
||
type IconName = "home" | "create" | "assets" | "projects" | "canvas" | "tools" | "bell" | "api" | "plus" | "image" | "layers" | "magic" | "resize" | "grid" | "search" | "arrow";
|
||
type Project = { id: string; name: string; slides: Array<{ fileName: string }>; canvas: { width: number; height: number }; updatedAt: string };
|
||
|
||
const paths: Record<IconName, React.ReactNode> = {
|
||
home: <path d="M4 11 12 4l8 7v9H5v-9"/>,
|
||
create: <><circle cx="12" cy="12" r="8"/><path d="M12 8v8m-4-4h8"/></>,
|
||
assets: <><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 4-4 3 3 2-2 2 3"/></>,
|
||
projects: <><path d="M4 7h6l2 2h8v10H4z"/><path d="M4 7V5h6l2 2"/></>,
|
||
canvas: <><rect x="5" y="5" width="14" height="14" rx="2"/><path d="M2 8V3h5M17 3h5v5M22 16v5h-5M7 21H2v-5"/></>,
|
||
tools: <><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></>,
|
||
bell: <><path d="M6 17h12l-2-3V9a4 4 0 0 0-8 0v5Z"/><path d="M10 20h4"/></>,
|
||
api: <><path d="M8 8 4 12l4 4m8-8 4 4-4 4M14 5l-4 14"/></>,
|
||
plus: <path d="M12 5v14M5 12h14"/>,
|
||
image: <><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m5 18 5-5 3 3 2-2 4 4"/></>,
|
||
layers: <><path d="m12 3 9 5-9 5-9-5Z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/></>,
|
||
magic: <><path d="m15 4 5 5L8 21l-5-5Z"/><path d="m13 6 5 5M7 3v3M5.5 4.5h3M20 15v4M18 17h4"/></>,
|
||
resize: <><path d="M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5"/><path d="m3 8 6-6m12 6-6-6M3 16l6 6m12-6-6 6"/></>,
|
||
grid: <><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></>,
|
||
search: <><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></>,
|
||
arrow: <path d="M5 12h14m-4-4 4 4-4 4"/>,
|
||
};
|
||
|
||
function Icon({ name, size = 20 }: { name: IconName; size?: number }) {
|
||
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
|
||
}
|
||
|
||
function imageUrl(project: Project) { return project.slides[0] ? `/api/image-projects/${project.id}/images/${project.slides[0].fileName}` : ""; }
|
||
|
||
export default function Studio() {
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [filter, setFilter] = useState("项目");
|
||
const [query, setQuery] = useState("");
|
||
const [uploading, setUploading] = useState(false);
|
||
const [message, setMessage] = useState("");
|
||
const input = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/image-projects").then(async (response) => {
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || "项目载入失败");
|
||
setProjects(data.projects || []);
|
||
}).catch((error) => setMessage((error as Error).message));
|
||
}, []);
|
||
|
||
async function createProject(files: FileList | null) {
|
||
if (!files?.length) return;
|
||
setUploading(true);
|
||
setMessage(`正在创建项目并上传 ${files.length} 张图片…`);
|
||
try {
|
||
const form = new FormData();
|
||
Array.from(files).forEach((file) => form.append("images", file));
|
||
const response = await fetch("/api/image-projects", { method: "POST", body: form });
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || "上传失败");
|
||
window.location.assign(`/projects/${data.project.id}/editor`);
|
||
} catch (error) { setMessage((error as Error).message); setUploading(false); if (input.current) input.current.value = ""; }
|
||
}
|
||
|
||
const visibleProjects = projects.filter((project) => project.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||
|
||
return <main className="kling-shell">
|
||
<header className="kling-windowbar"><strong>首页</strong><button aria-label="新建"><Icon name="plus" size={18} /></button></header>
|
||
<aside className="kling-sidebar">
|
||
<div className="kling-logo">R</div>
|
||
<nav>
|
||
<SideItem icon="create" label="创作" active />
|
||
<SideItem icon="assets" label="资产" />
|
||
<i />
|
||
<SideItem icon="projects" label="项目" />
|
||
<SideItem icon="canvas" label="灵动画布" />
|
||
<SideItem icon="tools" label="全部工具" />
|
||
</nav>
|
||
<div className="side-footer"><SideItem icon="bell" label="通知" /><SideItem icon="api" label="API" /><div className="side-avatar">HX</div></div>
|
||
</aside>
|
||
|
||
<section className="kling-main">
|
||
<div className="tool-heroes">
|
||
<button className="hero-card primary-hero" onClick={() => input.current?.click()}><div><span className="new-badge">NEW</span><h2><b>RED</b> 开始创作 <Icon name="arrow" /></h2><p>上传图片,开启你的图文项目</p></div><div className="hero-wave"><i/><i/><i/></div></button>
|
||
<button className="hero-card image-hero" onClick={() => input.current?.click()}><div><h2><Icon name="image" /> 图片创作 <Icon name="arrow" /></h2><p>多图编辑、尺寸适配、画面调整</p></div><div className="stacked-pictures"><i/><i/><i/></div></button>
|
||
<button className="hero-card batch-hero" onClick={() => input.current?.click()}><div><h2><Icon name="layers" /> 批量图文 <Icon name="arrow" /></h2><p>一次导入多张图片,逐页精细编辑</p></div><div className="batch-art"><i/><i/></div></button>
|
||
</div>
|
||
|
||
<div className="quick-tools">
|
||
<ToolCard icon="canvas" title="灵动画布" subtitle="专业画布编辑器" onClick={() => input.current?.click()} />
|
||
<ToolCard icon="magic" title="封面生成" subtitle="快速制作小红书封面" />
|
||
<ToolCard icon="layers" title="批量排版" subtitle="统一多页视觉样式" />
|
||
<ToolCard icon="resize" title="尺寸适配" subtitle="一键切换常用比例" />
|
||
<ToolCard icon="grid" title="全部" subtitle="探索更多创作工具" />
|
||
</div>
|
||
|
||
<section className="project-feed">
|
||
<div className="project-toolbar">
|
||
<div className="feed-tabs">{["项目", "最近", "收藏"].map((tab) => <button key={tab} className={filter === tab ? "active" : ""} onClick={() => setFilter(tab)}>{tab}</button>)}</div>
|
||
<i />
|
||
<label className="project-search"><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目" /><Icon name="search" size={18} /></label>
|
||
<button className="create-project-button" onClick={() => input.current?.click()}>+ 新建图文项目</button>
|
||
</div>
|
||
|
||
<div className="project-categories"><button className="active">全部</button><button>小红书图文</button><button>封面设计</button><button>方形图片</button><button>竖版长图</button><button>最近编辑</button></div>
|
||
|
||
{visibleProjects.length ? <div className="project-masonry">{visibleProjects.map((project, index) => <a className={`project-tile tile-${index % 5}`} href={`/projects/${project.id}/editor`} key={project.id}><div className="project-cover" style={{ aspectRatio: `${project.canvas.width}/${project.canvas.height}` }}>{imageUrl(project) && <img src={imageUrl(project)} alt="" />}<span>{project.slides.length} 页</span></div><div className="project-caption"><strong>{project.name}</strong><small>{new Date(project.updatedAt).toLocaleDateString("zh-CN")} 编辑</small></div></a>)}</div> : <div className="project-empty"><div className="empty-art"><span/><span/><span/></div><h3>开始第一个图文项目</h3><p>上传 1–20 张图片,进入专业画布逐页编辑</p><button onClick={() => input.current?.click()}>上传图片创建项目</button></div>}
|
||
</section>
|
||
</section>
|
||
|
||
<input ref={input} hidden type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => void createProject(event.target.files)} />
|
||
{uploading && <div className="upload-overlay"><span className="upload-spinner"/><strong>{message}</strong><small>请不要关闭页面</small></div>}
|
||
{!uploading && message && <div className="dashboard-toast">{message}<button onClick={() => setMessage("")}>×</button></div>}
|
||
</main>;
|
||
}
|
||
|
||
function SideItem({ icon, label, active }: { icon: IconName; label: string; active?: boolean }) {
|
||
return <button className={`side-item ${active ? "active" : ""}`}><Icon name={icon} size={20} /><span>{label}</span></button>;
|
||
}
|
||
|
||
function ToolCard({ icon, title, subtitle, onClick }: { icon: IconName; title: string; subtitle: string; onClick?: () => void }) {
|
||
return <button className="quick-tool" onClick={onClick}><Icon name={icon} /><div><strong>{title}</strong><small>{subtitle}</small></div><Icon name="arrow" size={17} /></button>;
|
||
}
|