feat: add image project dashboard and canvas editor
Verify and deploy production / verify-and-deploy (push) Successful in 2m34s
Verify and deploy production / verify-and-deploy (push) Successful in 2m34s
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
type Settings = {
|
||||
fit: "contain" | "cover";
|
||||
scale: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
rotation: number;
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
borderRadius: number;
|
||||
};
|
||||
|
||||
type Slide = { id: string; fileName: string; originalName: string; mimeType: string; settings: Settings };
|
||||
type Project = { id: string; name: string; canvas: { width: number; height: number; background: string }; slides: Slide[]; createdAt: string; updatedAt: string };
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || `请求失败:${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function imageUrl(projectId: string, fileName: string) { return `/api/image-projects/${projectId}/images/${fileName}`; }
|
||||
|
||||
export default function ImageEditor({ projectId }: { projectId: string }) {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [message, setMessage] = useState("正在载入项目…");
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const drag = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${projectId}`));
|
||||
setProject(data.project);
|
||||
setSelectedId((current) => current || data.project.slides[0]?.id || "");
|
||||
setMessage("项目已载入");
|
||||
} catch (error) { setMessage((error as Error).message); }
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
const selected = project?.slides.find((slide) => slide.id === selectedId) || project?.slides[0];
|
||||
|
||||
function updateSettings(patch: Partial<Settings>) {
|
||||
if (!project || !selected) return;
|
||||
setProject({ ...project, slides: project.slides.map((slide) => slide.id === selected.id ? { ...slide, settings: { ...slide.settings, ...patch } } : slide) });
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!project) return;
|
||||
setBusy("save");
|
||||
try {
|
||||
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${project.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(project) }));
|
||||
setProject(data.project);
|
||||
setMessage("所有修改已保存");
|
||||
} catch (error) { setMessage((error as Error).message); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function addImages(files: FileList | null) {
|
||||
if (!project || !files?.length) return;
|
||||
setBusy("upload");
|
||||
try {
|
||||
const form = new FormData();
|
||||
Array.from(files).forEach((file) => form.append("images", file));
|
||||
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${project.id}/images`, { method: "POST", body: form }));
|
||||
setProject(data.project);
|
||||
setSelectedId(data.project.slides.at(-1)?.id || selectedId);
|
||||
setMessage(`已添加 ${files.length} 张图片`);
|
||||
} catch (error) { setMessage((error as Error).message); }
|
||||
finally { setBusy(""); if (fileInput.current) fileInput.current.value = ""; }
|
||||
}
|
||||
|
||||
function setCanvas(width: number, height: number) {
|
||||
if (project) setProject({ ...project, canvas: { ...project.canvas, width, height } });
|
||||
}
|
||||
|
||||
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!selected) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drag.current = { x: event.clientX, y: event.clientY, offsetX: selected.settings.offsetX, offsetY: selected.settings.offsetY };
|
||||
}
|
||||
|
||||
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!drag.current || !selected) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
updateSettings({ offsetX: Math.max(-100, Math.min(100, drag.current.offsetX + (event.clientX - drag.current.x) / rect.width * 100)), offsetY: Math.max(-100, Math.min(100, drag.current.offsetY + (event.clientY - drag.current.y) / rect.height * 100)) });
|
||||
}
|
||||
|
||||
function onPointerUp(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
drag.current = null;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
if (!project) return <div className="editor-loading"><span className="editor-spinner" /><p>{message}</p><a href="/">返回首页</a></div>;
|
||||
if (!selected) return <div className="editor-loading"><p>项目中没有图片</p><a href="/">返回首页</a></div>;
|
||||
|
||||
const style = {
|
||||
transform: `translate(${selected.settings.offsetX}%, ${selected.settings.offsetY}%) scale(${selected.settings.scale / 100}) rotate(${selected.settings.rotation}deg)`,
|
||||
filter: `brightness(${selected.settings.brightness}%) contrast(${selected.settings.contrast}%) saturate(${selected.settings.saturation}%)`,
|
||||
objectFit: selected.settings.fit,
|
||||
borderRadius: `${selected.settings.borderRadius}px`,
|
||||
} as CSSProperties;
|
||||
const ratioStyle = { "--canvas-ratio": `${project.canvas.width} / ${project.canvas.height}`, background: project.canvas.background } as CSSProperties;
|
||||
|
||||
return <main className="image-editor-shell">
|
||||
<header className="editor-topbar">
|
||||
<div className="editor-brand"><a href="/" className="editor-back">←</a><span className="editor-logo">小红书</span><i /><input value={project.name} onChange={(event) => setProject({ ...project, name: event.target.value })} /></div>
|
||||
<div className="editor-status"><span className="status-dot" />{message}</div>
|
||||
<div className="editor-actions"><button className="ghost-control" onClick={() => fileInput.current?.click()}>+ 添加图片</button><button className="save-control" onClick={save} disabled={Boolean(busy)}>{busy === "save" ? "保存中…" : "保存项目"}</button></div>
|
||||
<input ref={fileInput} hidden type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => void addImages(event.target.files)} />
|
||||
</header>
|
||||
|
||||
<aside className="editor-filmstrip">
|
||||
<div className="filmstrip-title"><span>页面</span><small>{project.slides.length}</small></div>
|
||||
<div className="filmstrip-list">{project.slides.map((slide, index) => <button key={slide.id} className={`filmstrip-item ${selected.id === slide.id ? "active" : ""}`} onClick={() => setSelectedId(slide.id)}><span>{index + 1}</span><img src={imageUrl(project.id, slide.fileName)} alt={slide.originalName} /></button>)}</div>
|
||||
<button className="filmstrip-add" onClick={() => fileInput.current?.click()}>+</button>
|
||||
</aside>
|
||||
|
||||
<section className="editor-workspace">
|
||||
<div className="canvas-toolbar"><button>↖ 选择</button><button>✋ 移动</button><i /><span>{project.canvas.width} × {project.canvas.height}</span><button onClick={() => updateSettings({ scale: 100, offsetX: 0, offsetY: 0, rotation: 0 })}>重置视图</button></div>
|
||||
<div className="canvas-viewport">
|
||||
<div className="canvas-page" style={ratioStyle} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}>
|
||||
<img draggable={false} src={imageUrl(project.id, selected.fileName)} alt={selected.originalName} style={style} />
|
||||
<div className="canvas-selection"><i className="handle nw"/><i className="handle ne"/><i className="handle sw"/><i className="handle se"/></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-pagination"><button disabled={project.slides[0].id === selected.id} onClick={() => setSelectedId(project.slides[Math.max(0, project.slides.findIndex((item) => item.id === selected.id) - 1)].id)}>‹</button><span>{project.slides.findIndex((item) => item.id === selected.id) + 1} / {project.slides.length}</span><button disabled={project.slides.at(-1)?.id === selected.id} onClick={() => setSelectedId(project.slides[Math.min(project.slides.length - 1, project.slides.findIndex((item) => item.id === selected.id) + 1)].id)}>›</button></div>
|
||||
</section>
|
||||
|
||||
<aside className="editor-inspector">
|
||||
<div className="inspector-tabs"><button className="active">画面</button><button>文字</button><button>素材</button></div>
|
||||
<InspectorSection title="画布">
|
||||
<div className="ratio-buttons"><button className={project.canvas.width === project.canvas.height ? "active" : ""} onClick={() => setCanvas(1080, 1080)}>1:1</button><button className={project.canvas.width / project.canvas.height === .75 ? "active" : ""} onClick={() => setCanvas(1080, 1440)}>3:4</button><button className={project.canvas.width / project.canvas.height === .8 ? "active" : ""} onClick={() => setCanvas(1080, 1350)}>4:5</button></div>
|
||||
<label className="color-field"><span>背景</span><input type="color" value={project.canvas.background} onChange={(event) => setProject({ ...project, canvas: { ...project.canvas, background: event.target.value } })} /><code>{project.canvas.background.toUpperCase()}</code></label>
|
||||
</InspectorSection>
|
||||
<InspectorSection title="图片适配">
|
||||
<div className="segmented-control"><button className={selected.settings.fit === "cover" ? "active" : ""} onClick={() => updateSettings({ fit: "cover" })}>填充</button><button className={selected.settings.fit === "contain" ? "active" : ""} onClick={() => updateSettings({ fit: "contain" })}>完整</button></div>
|
||||
<Range label="缩放" value={selected.settings.scale} min={20} max={300} suffix="%" onChange={(scale) => updateSettings({ scale })} />
|
||||
<Range label="旋转" value={selected.settings.rotation} min={-180} max={180} suffix="°" onChange={(rotation) => updateSettings({ rotation })} />
|
||||
<Range label="圆角" value={selected.settings.borderRadius} min={0} max={200} suffix="" onChange={(borderRadius) => updateSettings({ borderRadius })} />
|
||||
</InspectorSection>
|
||||
<InspectorSection title="画面调整">
|
||||
<Range label="亮度" value={selected.settings.brightness} min={0} max={200} suffix="%" onChange={(brightness) => updateSettings({ brightness })} />
|
||||
<Range label="对比度" value={selected.settings.contrast} min={0} max={200} suffix="%" onChange={(contrast) => updateSettings({ contrast })} />
|
||||
<Range label="饱和度" value={selected.settings.saturation} min={0} max={200} suffix="%" onChange={(saturation) => updateSettings({ saturation })} />
|
||||
</InspectorSection>
|
||||
</aside>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function InspectorSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return <section className="inspector-section"><h3>{title}<button>⌃</button></h3>{children}</section>;
|
||||
}
|
||||
|
||||
function Range({ label, value, min, max, suffix, onChange }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (value: number) => void }) {
|
||||
return <label className="range-field"><div><span>{label}</span><code>{Math.round(value)}{suffix}</code></div><input type="range" min={min} max={max} value={value} onChange={(event) => onChange(Number(event.target.value))} /></label>;
|
||||
}
|
||||
+98
-53
@@ -1,76 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type IconName = "home" | "note" | "code" | "chart" | "flag" | "bulb" | "school" | "book" | "draft" | "plus" | "chevron" | "upload";
|
||||
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 10 8-6 8 6v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M9 14h6"/></>,
|
||||
note: <><rect x="5" y="3" width="14" height="18" rx="3"/><path d="M9 8h6M9 12h5"/></>,
|
||||
code: <><rect x="3" y="4" width="18" height="16" rx="3"/><path d="m9 9-3 3 3 3m6-6 3 3-3 3m-2-7-2 8"/></>,
|
||||
chart: <><rect x="3" y="4" width="18" height="16" rx="3"/><path d="m7 15 3-3 3 2 4-5"/></>,
|
||||
flag: <><path d="M6 21V5m0 1c4-3 7 3 12 0v9c-5 3-8-3-12 0"/></>,
|
||||
bulb: <><path d="M9 18h6m-5 3h4"/><path d="M8.2 15.5a7 7 0 1 1 7.6 0c-.8.5-.8 1.5-.8 2.5H9c0-1 0-2-.8-2.5Z"/></>,
|
||||
school: <><path d="m3 9 9-5 9 5-9 5Z"/><path d="M7 12v5c3 2 7 2 10 0v-5m4-3v6"/></>,
|
||||
book: <><path d="M6 3h11a2 2 0 0 1 2 2v16H7a3 3 0 0 1-3-3V5a2 2 0 0 1 2-2Z"/><path d="M7 15h12M9 7h6"/></>,
|
||||
draft: <><path d="M5 5h14v15H5z"/><path d="M9 3h6v4H9zM8 11h8m-8 4h6"/></>,
|
||||
plus: <><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M12 9v6m-3-3h6"/></>,
|
||||
chevron: <path d="m8 10 4 4 4-4"/>,
|
||||
upload: <><path d="M7 18a5 5 0 0 1-.5-10A7 7 0 0 1 20 10.5 4 4 0 0 1 19 18"/><path d="m9 12 3-3 3 3m-3-3v10"/></>,
|
||||
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 = 19, strokeWidth = 1.55 }: { name: IconName; size?: number; strokeWidth?: number }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
|
||||
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>;
|
||||
}
|
||||
|
||||
const navItems: Array<{ icon: IconName; label: string; expandable?: boolean }> = [
|
||||
{ icon: "home", label: "首页" },
|
||||
{ icon: "note", label: "笔记管理" },
|
||||
{ icon: "code", label: "Builder hub", expandable: true },
|
||||
{ icon: "chart", label: "数据看板", expandable: true },
|
||||
{ icon: "flag", label: "活动中心" },
|
||||
{ icon: "bulb", label: "笔记灵感" },
|
||||
{ icon: "school", label: "创作学院" },
|
||||
{ icon: "book", label: "创作百科" },
|
||||
];
|
||||
|
||||
const tabs = ["上传视频", "上传图文", "写长文", "发播客"];
|
||||
function imageUrl(project: Project) { return project.slides[0] ? `/api/image-projects/${project.id}/images/${project.slides[0].fileName}` : ""; }
|
||||
|
||||
export default function Studio() {
|
||||
const [activeTab, setActiveTab] = useState("上传视频");
|
||||
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);
|
||||
|
||||
return <main className="creator-shell">
|
||||
<header className="creator-header">
|
||||
<div className="creator-brand"><span className="xiaohongshu-logo">小红书</span><strong>创作服务平台</strong></div>
|
||||
<button className="creator-account"><span className="account-photo">创</span><span>内容工作室</span><Icon name="chevron" size={14} /></button>
|
||||
</header>
|
||||
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));
|
||||
}, []);
|
||||
|
||||
<aside className="creator-sidebar">
|
||||
<button className="publish-button"><Icon name="plus" size={19} strokeWidth={1.8} /><span>发布笔记</span><Icon name="chevron" size={18} strokeWidth={2} /></button>
|
||||
<nav className="creator-nav">
|
||||
{navItems.map((item) => <button className="creator-nav-item" key={item.label}><Icon name={item.icon} /><span>{item.label}</span>{item.expandable && <Icon name="chevron" size={17} />}</button>)}
|
||||
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="creator-main">
|
||||
<div className="creator-toolbar">
|
||||
<div className="creator-tabs">{tabs.map((tab) => <button key={tab} className={activeTab === tab ? "active" : ""} onClick={() => setActiveTab(tab)}>{tab}</button>)}</div>
|
||||
<button className="draft-button"><Icon name="draft" size={18} /><span>草稿箱(0)</span></button>
|
||||
<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="upload-panel">
|
||||
<div className="upload-center">
|
||||
<div className="cloud-upload"><Icon name="upload" size={104} strokeWidth={1.15} /></div>
|
||||
<p>{activeTab === "上传视频" ? "拖拽视频到此或点击上传" : activeTab === "上传图文" ? "拖拽图片到此或点击上传" : activeTab === "写长文" ? "点击开始创作长文" : "拖拽音频到此或点击上传"}</p>
|
||||
<button className="upload-action">{activeTab === "上传视频" ? "上传视频" : activeTab === "上传图文" ? "上传图文" : activeTab === "写长文" ? "开始写作" : "上传音频"}</button>
|
||||
<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>
|
||||
|
||||
<div className="upload-guides">
|
||||
<div><strong>{activeTab === "上传视频" ? "视频大小" : "文件大小"}</strong><p>{activeTab === "上传视频" ? <>支持时长4小时以内,<br />最大20GB的视频文件</> : <>单个文件不超过20GB,<br />支持批量选择上传</>}</p></div>
|
||||
<div><strong>{activeTab === "上传视频" ? "视频格式" : "支持格式"}</strong><p>{activeTab === "上传视频" ? <>支持常用视频格式,<br />推荐使用mp4、mov</> : <>支持平台常用格式,<br />上传后可继续编辑</>}</p></div>
|
||||
<div><strong>{activeTab === "上传视频" ? "视频分辨率" : "内容质量"}</strong><p>{activeTab === "上传视频" ? <>超过1080P的视频用网页端上传画质更清晰</> : <>建议上传清晰、完整的原创内容</>}</p></div>
|
||||
</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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user