Save current image generation workspace

This commit is contained in:
2026-06-22 23:36:52 +08:00
parent d2a1968d53
commit 36e6bedf1a
62 changed files with 13107 additions and 6948 deletions
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import {
deleteCanvasProject,
getCanvasProjectById,
updateCanvasProject,
} from "@/lib/server/repositories/canvas-project-repository";
import { isCanvasProjectData } from "@/lib/canvas/types";
export const runtime = "nodejs";
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params;
const project = getCanvasProjectById(id);
if (!project) {
return NextResponse.json(
{ error: "Canvas project not found" },
{ status: 404 },
);
}
return NextResponse.json({ item: project });
}
export async function PATCH(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params;
const payload = await request.json().catch(() => ({}));
const patch: {
title?: string;
data?: unknown;
} = {};
if (typeof payload.title === "string") {
patch.title = payload.title;
}
if (isCanvasProjectData(payload.data)) {
patch.data = payload.data;
}
const project = updateCanvasProject(id, {
title: patch.title,
data: isCanvasProjectData(patch.data) ? patch.data : undefined,
});
if (!project) {
return NextResponse.json(
{ error: "Canvas project not found" },
{ status: 404 },
);
}
return NextResponse.json({ item: project });
}
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params;
const removed = deleteCanvasProject(id);
if (!removed) {
return NextResponse.json(
{ error: "Canvas project not found" },
{ status: 404 },
);
}
return NextResponse.json({ ok: true });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import {
createCanvasProject,
listCanvasProjects,
} from "@/lib/server/repositories/canvas-project-repository";
import { defaultCanvasProjectData, isCanvasProjectData } from "@/lib/canvas/types";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({ items: listCanvasProjects() });
}
export async function POST(request: Request) {
const payload = await request.json().catch(() => ({}));
const title =
typeof payload.title === "string" && payload.title.trim()
? payload.title
: "未命名画布";
const data = isCanvasProjectData(payload.data)
? payload.data
: defaultCanvasProjectData;
const project = createCanvasProject({ title, data });
return NextResponse.json({ item: project }, { status: 201 });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { persistUploadedImageFile } from "@/lib/server/storage/image-file-storage";
export const runtime = "nodejs";
export async function POST(request: Request) {
const formData = await request.formData();
const image = formData.get("image");
if (!(image instanceof File)) {
return NextResponse.json({ error: "缺少图片文件" }, { status: 400 });
}
const buffer = Buffer.from(await image.arrayBuffer());
const id = crypto.randomUUID();
const persisted = persistUploadedImageFile(id, image.name || "canvas-upload.png", buffer);
return NextResponse.json({
item: {
id,
imageUrl: persisted.imageUrl,
filePath: persisted.filePath,
fileName: persisted.fileName,
},
});
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { listHistory } from "@/lib/server/repositories/history-repository";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({ items: listHistory() });
}
+15
View File
@@ -0,0 +1,15 @@
import {
getImageJobResponse,
submitImageJobRequest,
} from "@/lib/server/services/image-job-service";
export const runtime = "nodejs";
export const maxDuration = 300;
export async function POST(request: Request) {
return submitImageJobRequest(request);
}
export async function GET(request: Request) {
return getImageJobResponse(request);
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import {
cleanupExpiredHistory,
getRetentionDays,
setRetentionDays,
} from "@/lib/server/repositories/history-repository";
export const runtime = "nodejs";
export async function GET() {
cleanupExpiredHistory();
return NextResponse.json({ retentionDays: getRetentionDays() });
}
export async function POST(request: Request) {
const payload = await request.json().catch(() => ({}));
const retentionDays = setRetentionDays(Number(payload.retentionDays));
return NextResponse.json({ retentionDays });
}
+10
View File
@@ -0,0 +1,10 @@
import { CanvasEditorClient } from "@/components/canvas/canvas-editor-client";
export default async function CanvasProjectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <CanvasEditorClient projectId={id} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { HomeShell } from "@/components/direct/home-shell";
export default function CanvasProjectsPage() {
return <HomeShell initialView="canvas" />;
}
+15 -9
View File
@@ -1,22 +1,21 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f4f4f5;
--foreground: #18181b;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: Arial, Helvetica, sans-serif;
--font-mono: "Cascadia Code", Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
@@ -24,3 +23,10 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
button,
input,
textarea,
select {
letter-spacing: 0;
}
+5 -16
View File
@@ -1,20 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Image Studio Admin",
description: "A shadcn-admin style image generation workspace",
};
export default function RootLayout({
@@ -24,10 +13,10 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="zh-CN"
className="h-full antialiased"
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="h-full overflow-hidden">{children}</body>
</html>
);
}
+13 -63
View File
@@ -1,65 +1,15 @@
import Image from "next/image";
import { HomeShell, type ActiveView } from "@/components/direct/home-shell";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
function normalizeView(value: string | string[] | undefined): ActiveView {
const candidate = Array.isArray(value) ? value[0] : value;
if (candidate === "history" || candidate === "settings" || candidate === "canvas") {
return candidate;
}
return "studio";
}
export default async function Home(props: PageProps<"/">) {
const searchParams = await props.searchParams;
const view = normalizeView(searchParams.view);
return <HomeShell key={`home-${view}`} initialView={view} />;
}