- add prototype upload, list, view, and delete api routes - add prototype manager ui with search, modal, and theme toggle - add filesystem-backed zip extraction and serving logic - switch project to pnpm with updated lockfile and tooling - add multi-stage dockerfile with standalone next.js output - add github actions to lint, build, and publish docker images
133 lines
4.9 KiB
TypeScript
133 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { AddPrototypeModal } from "@/components/AddPrototypeModal";
|
|
import { PrototypeCard } from "@/components/PrototypeCard";
|
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
|
import { PrototypeSummary } from "@/lib/prototype";
|
|
import dayjs from "@/lib/dayjs"
|
|
|
|
interface PrototypeManagerPageProps {
|
|
initialPrototypes: PrototypeSummary[];
|
|
}
|
|
|
|
export function PrototypeManagerPage({ initialPrototypes }: PrototypeManagerPageProps) {
|
|
const [prototypes, setPrototypes] = useState<PrototypeSummary[]>(initialPrototypes);
|
|
const [keyword, setKeyword] = useState("");
|
|
|
|
const onCreated = useCallback((item: PrototypeSummary) => {
|
|
setPrototypes((prev) => [item, ...prev.filter((current) => current.id !== item.id)]);
|
|
}, []);
|
|
|
|
const onDelete = useCallback(async (id: string) => {
|
|
const confirmed = window.confirm("确定删除这个原型吗?删除后不可恢复。");
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`/api/prototypes/${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const payload = await response.json().catch(() => ({ error: "删除失败" }));
|
|
throw new Error(payload.error || "删除失败");
|
|
}
|
|
|
|
setPrototypes((prev) => prev.filter((item) => item.id !== id));
|
|
}, []);
|
|
|
|
const filteredPrototypes = useMemo(() => {
|
|
const query = keyword.trim().toLowerCase();
|
|
|
|
if (!query) {
|
|
return prototypes;
|
|
}
|
|
|
|
return prototypes.filter((item) => item.name.toLowerCase().includes(query));
|
|
}, [keyword, prototypes]);
|
|
|
|
const content = useMemo(() => {
|
|
if (!prototypes.length) {
|
|
return (
|
|
<div className="rounded-xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center dark:border-slate-700 dark:bg-slate-900">
|
|
<p className="text-base text-slate-600 dark:text-slate-300">暂无原型,请点击右上角“添加原型”进行上传。</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!filteredPrototypes.length) {
|
|
return (
|
|
<div className="rounded-xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center dark:border-slate-700 dark:bg-slate-900">
|
|
<p className="text-base text-slate-600 dark:text-slate-300">
|
|
未找到名称包含“{keyword}”的原型。
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3">
|
|
{filteredPrototypes.map((item) => (
|
|
<PrototypeCard
|
|
key={item.id}
|
|
prototype={item}
|
|
onDelete={async (id) => {
|
|
try {
|
|
await onDelete(id);
|
|
} catch (deleteError) {
|
|
const message =
|
|
deleteError instanceof Error ? deleteError.message : "删除失败,请稍后重试";
|
|
window.alert(message);
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}, [filteredPrototypes, keyword, onDelete, prototypes]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 text-slate-800 dark:bg-slate-950 dark:text-slate-100">
|
|
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 py-8 md:px-8 md:py-10">
|
|
<header className="mb-10 flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 pb-8 dark:border-slate-800">
|
|
<div>
|
|
<h1 className="text-4xl font-bold tracking-tight text-sky-600 dark:text-sky-400">原型导航</h1>
|
|
<p className="mt-2 text-slate-600 dark:text-slate-300">Axure 原型集合 · Prototype Collection</p>
|
|
</div>
|
|
<AddPrototypeModal onCreated={onCreated} />
|
|
</header>
|
|
|
|
<main className="flex-1 space-y-6">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<input
|
|
type="text"
|
|
value={keyword}
|
|
onChange={(event) => setKeyword(event.target.value)}
|
|
placeholder="根据名称筛选原型..."
|
|
className="w-full max-w-md rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm outline-none transition focus:ring-2 focus:ring-sky-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
/>
|
|
{keyword ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setKeyword("")}
|
|
className="rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
>
|
|
清空
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{content}
|
|
</main>
|
|
|
|
<footer className="mt-16 border-t border-slate-200 pt-8 text-center text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
|
© {dayjs().format("YYYY")} 原型导航 | Powered by OnixByte
|
|
</footer>
|
|
</div>
|
|
|
|
<ThemeToggle />
|
|
</div>
|
|
);
|
|
}
|