Files
hexin-codex-bot 7e7e26d869
Verify and deploy production / verify-and-deploy (push) Successful in 2m50s
ui: center canvas and refine project layout
2026-10-05 15:36:39 +08:00

121 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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">
<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>;
}