feat(engine): merge cloudflare-migration — paradigm D engine, BYOK proxy, story persistence (#95)

Squash-merge the cloudflare-migration branch (7 commits by Kai ki) into
staging with conflict resolution, feature integration, and bug fixes.

Engine:
- Paradigm D: single-stream Writer replacing dual-phase Plan/Beats
- Delete Architect agent; story bible generated via Writer <plan> tag
- Modular prompt architecture (segments/registry/builder)
- StreamRouter for tagged stream splitting (<plan>/<story>/<choices>)

Infrastructure:
- Cloudflare Workers deployment (wrangler.jsonc, OpenNext adapter)
- D1 database schema + Drizzle ORM (scaffolded, not yet active)
- R2 storage helpers (scaffolded, not yet active)
- Story persistence API routes + client-side persistence

BYOK (Bring Your Own Key):
- /api/llm/user-proxy with SSRF-protected LLM proxy (+ requireUser auth)
- CORS-aware fetch in ai-client: auto-detect CORS failure, fallback to
  server proxy transparently via OpenAI SDK custom fetch
- BYO config support added to classify-freeform and vision routes
- SettingsModal CORS privacy notice (keys never logged/stored)

SSE streaming:
- engineClient.ts: fetchSSE helper for progressive scene events
- startSession/requestScene accept optional emit callback
- Fix SSE error event field name (error → message) in scene/start routes

i18n integration:
- Wire buildLanguageDirective into paradigm D's prompt builder
- Update corsNotice i18n keys (zh-CN/en/ja) with CORS proxy privacy text
- Preserve Session.language + LanguageSwitcher from i18n commit

Co-authored-by: Kai ki <155355644+zbf1009@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zonghao Yuan
2026-06-18 18:05:38 +08:00
committed by GitHub
parent 05bd7e229c
commit 0e4c2ebef4
78 changed files with 7396 additions and 919 deletions
+5 -3
View File
@@ -1,7 +1,7 @@
import { classifyFreeform } from "@infiplot/engine";
import type { FreeformClassifyRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { loadEngineConfig, buildByoEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
@@ -25,11 +25,13 @@ export async function POST(req: Request) {
}
try {
const config = loadEngineConfig();
const official = loadEngineConfig();
const config = body.byo ? buildByoEngineConfig(body.byo, official) : official;
const result = await classifyFreeform(config, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
const status = message.includes("Invalid BYO") || message.includes("Missing BYO") ? 400 : 500;
return NextResponse.json({ error: message }, { status });
}
}
-1
View File
@@ -26,7 +26,6 @@ export async function POST(req: Request) {
try {
const base = loadEngineConfig();
// See StartRequest.clientTts — BYO clients synth in-browser, so drop server TTS.
const config = body.clientTts === true ? { ...base, tts: undefined } : base;
const result = await requestInsertBeat(config, body);
return NextResponse.json({
+43
View File
@@ -0,0 +1,43 @@
import { proxyLLM, type ProxyLLMParams } from "@/lib/byoProxy";
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
export async function POST(req: Request): Promise<Response> {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let parsed: Partial<ProxyLLMParams>;
try {
parsed = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Validate required fields
const { provider, apiKey, baseUrl, body } = parsed;
if (!provider || !apiKey || !baseUrl || !body) {
return NextResponse.json(
{ error: "Missing required fields: provider, apiKey, baseUrl, body" },
{ status: 400 },
);
}
// Validate provider
if (!["openai", "claude", "gemini"].includes(provider)) {
return NextResponse.json(
{ error: `Unsupported provider: ${provider}` },
{ status: 400 },
);
}
// Forward to proxy core
return proxyLLM({
provider: provider as "openai" | "claude" | "gemini",
apiKey,
baseUrl,
body,
model: parsed.model,
stream: parsed.stream,
});
}
+55 -6
View File
@@ -1,5 +1,5 @@
import { requestScene } from "@infiplot/engine";
import type { Character, SceneRequest } from "@infiplot/types";
import type { Character, SceneRequest, SceneStreamEvent } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
@@ -13,6 +13,10 @@ function stripKnownVoices(
);
}
function formatSSE(event: SceneStreamEvent | { type: string; [k: string]: unknown }): string {
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
}
export const runtime = "nodejs";
export async function POST(req: Request) {
@@ -30,17 +34,62 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "session is required" }, { status: 400 });
}
const acceptsSSE = req.headers.get("accept")?.includes("text/event-stream");
try {
const base = loadEngineConfig();
// See StartRequest.clientTts — BYO clients synth in-browser, so drop server TTS.
const config = body.clientTts === true ? { ...base, tts: undefined } : base;
const result = await requestScene(config, body);
if (!acceptsSSE) {
const result = await requestScene(config, body);
const knownNames = new Set(
(body.session.characters ?? []).map((c) => c.name),
);
return NextResponse.json({
...result,
characters: stripKnownVoices(result.characters, knownNames),
});
}
const encoder = new TextEncoder();
const knownNames = new Set(
(body.session.characters ?? []).map((c) => c.name),
);
return NextResponse.json({
...result,
characters: stripKnownVoices(result.characters, knownNames),
const stream = new ReadableStream({
async start(controller) {
try {
const result = await requestScene(config, body, (event) => {
controller.enqueue(encoder.encode(formatSSE(event)));
});
controller.enqueue(
encoder.encode(
formatSSE({
type: "done",
response: {
...result,
characters: stripKnownVoices(result.characters, knownNames),
},
}),
),
);
controller.close();
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
controller.enqueue(
encoder.encode(formatSSE({ type: "error", message })),
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
+43 -6
View File
@@ -1,9 +1,13 @@
import { startSession } from "@infiplot/engine";
import type { StartRequest } from "@infiplot/types";
import type { SceneStreamEvent, StartRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
function formatSSE(event: SceneStreamEvent | { type: string; [k: string]: unknown }): string {
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
}
export const runtime = "nodejs";
// Matches /api/vision and /api/parse-style-image — the user's resized 512px
@@ -43,14 +47,47 @@ export async function POST(req: Request) {
}
}
const acceptsSSE = req.headers.get("accept")?.includes("text/event-stream");
try {
const base = loadEngineConfig();
// BYO key: the browser provisions + synths voices directly against Xiaomi
// (key never reaches us), so strip server-side TTS so the engine skips all
// provisioning + synth. See StartRequest.clientTts.
const config = body.clientTts === true ? { ...base, tts: undefined } : base;
const result = await startSession(config, body);
return NextResponse.json(result);
if (!acceptsSSE) {
const result = await startSession(config, body);
return NextResponse.json(result);
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const result = await startSession(config, body, (event) => {
controller.enqueue(encoder.encode(formatSSE(event)));
});
controller.enqueue(
encoder.encode(
formatSSE({ type: "done", response: result }),
),
);
controller.close();
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
controller.enqueue(
encoder.encode(formatSSE({ type: "error", message })),
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
/**
* GET/DELETE /api/stories/[id] — TEMPORARILY DISABLED (2026-06-09)
*
* D1 persistence disabled until authentication integration.
* Returns 404 so client handles gracefully (localStorage is the source of truth).
*
* To re-enable: Restore original implementation after auth integration.
*/
export async function GET(
_req: Request,
_context: { params: Promise<{ id: string }> },
) {
return NextResponse.json(
{ error: "Server persistence temporarily disabled" },
{ status: 404 },
);
}
export async function DELETE(
_req: Request,
_context: { params: Promise<{ id: string }> },
) {
return NextResponse.json(
{ error: "Server persistence temporarily disabled" },
{ status: 404 },
);
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { getDb } from "@/lib/db/client";
import { FeaturedRepository } from "@/lib/db/repositories/featuredRepo";
export const runtime = "nodejs";
/**
* GET /api/stories/featured?gender=male
*
* List active featured stories for homepage display.
* Fallback: D1 query fails → return empty array (homepage shows no cards, gracefully degrades).
*
* Query Params:
* gender: "male" | "female" (required)
*
* Response: { stories: FeaturedStory[] }
* Errors: 400 (invalid gender), 500 (should not reach user - caught and degraded)
*/
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const genderParam = searchParams.get("gender");
// Validate gender
if (!genderParam || !["male", "female"].includes(genderParam)) {
return NextResponse.json(
{ error: "gender query parameter must be 'male' or 'female'" },
{ status: 400 },
);
}
const gender = genderParam as "male" | "female";
try {
const db = getDb();
const repo = new FeaturedRepository(db);
const stories = await repo.listByGender(gender);
return NextResponse.json({ stories });
} catch (err) {
// D1 unavailable or query failed - degrade to empty array
// (homepage will show no cards but remain functional)
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[stories/featured] D1 query failed, returning empty array:", message);
return NextResponse.json({ stories: [] });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
/**
* GET /api/stories/list — TEMPORARILY DISABLED (2026-06-09)
*
* D1 persistence disabled until authentication integration.
* Returns empty list so client falls back to localStorage-only mode.
*
* To re-enable: Restore original implementation after auth integration.
*/
export async function GET(_req: Request) {
return NextResponse.json({ stories: [] });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
/**
* POST /api/stories/save — TEMPORARILY DISABLED (2026-06-09)
*
* D1 persistence is disabled until an authentication system (better-auth) is
* integrated. Without auth, anonymous writes to D1 have no rate limiting,
* per-user quota, or ownership verification — an abuse/DoS risk on a public,
* registration-less site. The client (lib/clientStoryPersistence.ts) now
* persists stories to localStorage only; this 503 keeps the contract intact
* for any caller that still hits the endpoint.
*
* The full D1 implementation lives in StoryRepository (lib/db/repositories/
* storyRepo.ts), which is untouched. To re-enable after auth integration:
* restore the handler to validate input + call `repo.save(...)` (see the
* task-10 implementation log) and gate it behind an authenticated session.
*
* See: ARCHITECTURE_DESIGN.md Phase 2, memory tech_d1_anonymous_write_risk
*/
export async function POST(_req: Request) {
return NextResponse.json(
{ error: "Server persistence temporarily disabled - using local storage" },
{ status: 503 },
);
}
+5 -3
View File
@@ -1,7 +1,7 @@
import { visionDecide } from "@infiplot/engine";
import type { VisionRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { loadEngineConfig, buildByoEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
@@ -45,11 +45,13 @@ export async function POST(req: Request) {
}
try {
const config = loadEngineConfig();
const official = loadEngineConfig();
const config = body.byo ? buildByoEngineConfig(body.byo, official) : official;
const result = await visionDecide(config, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
const status = message.includes("Invalid BYO") || message.includes("Missing BYO") ? 400 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+95 -16
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { track } from "@/lib/analytics";
@@ -121,6 +122,31 @@ const OPTS: Opt[] = [
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
// 首页卡片的统一渲染形态——无论来自 D1 featured API 还是硬编码 STORIES 降级,
// 都归一到这个形状后只走一条渲染路径。
type FeaturedCard = {
id: string; // e.g. "m0" / "f12",用于 ?card= 与封面拼接
title: string;
outline: string;
coverPath: string; // e.g. "/home/m0.webp"
};
// D1 featured API 的响应行(与 lib/db/schema.ts FeaturedStory 对应的线上子集)。
type FeaturedStoryRow = {
id: string;
gender: string;
title: string;
outline: string;
style: string;
tags: string; // JSON 字符串
coverPath: string;
firstactPath: string;
firstscenePath?: string | null;
sortOrder: number;
isActive: number;
clickCount: number;
};
import { STYLE_MAP } from "@/lib/options";
/* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。
@@ -771,6 +797,22 @@ const DISPLAY_ORDER: Record<Gender, number[]> = {
],
};
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(featured API 故障/空时的降级源,
// 同时作为首屏即时渲染的初始值,避免等 fetch 期间卡片区空白)。
function buildFallbackCards(g: Gender): FeaturedCard[] {
const imgPrefix = g === "女性向" ? "f" : "m";
const localStories = STORIES[g];
return DISPLAY_ORDER[g].map((origIdx) => {
const c = localStories[origIdx]!;
return {
id: `${imgPrefix}${origIdx}`,
title: c.title,
outline: c.outline,
coverPath: `/home/${imgPrefix}${origIdx}.webp`,
};
});
}
/* ---------- typewriter ---------- */
// 父组件持有当前 phrase 的索引(这样 start() 不输入时能用当前闪动的那句
@@ -1462,6 +1504,39 @@ export default function HomePage() {
return () => clearTimeout(t);
}, [gender, galleryGender]);
// Featured stories 动态加载(从 /api/stories/featured),降级用硬编码 STORIES。
// 惰性初始化确保首屏即有卡片内容(SSR + hydration 一致),fetch 成功后无缝替换。
const [featuredCards, setFeaturedCards] = useState<FeaturedCard[]>(() =>
buildFallbackCards(galleryGender),
);
useEffect(() => {
const apiGender = galleryGender === "女性向" ? "female" : "male";
fetch(`/api/stories/featured?gender=${apiGender}`)
.then((r) => r.json())
.then((data: { stories: FeaturedStoryRow[] }) => {
// API 已按 sortOrder 排序且仅返回 isActive=1 的记录。
// D1 故障时 featured route 返回 { stories: [] }HTTP 200),
// 空数组也必须降级到常量,否则首页白屏。
const rows = data.stories ?? [];
if (rows.length === 0) {
setFeaturedCards(buildFallbackCards(galleryGender));
return;
}
setFeaturedCards(
rows.map((s) => ({
id: s.id,
title: s.title,
outline: s.outline,
coverPath: s.coverPath,
})),
);
})
.catch(() => {
// 网络故障 / JSON 解析失败 → 降级到常量
setFeaturedCards(buildFallbackCards(galleryGender));
});
}, [galleryGender]);
/* close any open dropdown on outside click */
useEffect(() => {
const h = (e: MouseEvent) => {
@@ -1735,7 +1810,7 @@ export default function HomePage() {
// 「语音配音」选项仍然生效:把 audioEnabled 经 sessionStorage 传给 /play。
// 其余选项(剧情风格 / 内容节奏)在预烘焙时已锁成「多线转折 / 紧凑爽快」
// 的红果默认基调,对精选卡不再生效。
const onCardClick = (idx: number, _card: StoryContent) => {
const onCardClick = (cardId: string) => {
const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!;
const audioEnabled = voice === "开启";
sessionStorage.setItem(
@@ -1746,9 +1821,9 @@ export default function HomePage() {
source: "curated",
gender: galleryGender,
tts: audioEnabled,
card: `${imgPrefix}${idx}`,
card: cardId as `${"m" | "f"}${number}`,
});
router.push(`/play?card=${imgPrefix}${idx}`);
router.push(`/play?card=${cardId}`);
};
// overflow-x-hidden 在 wrapper 层兜底:body 的 overflow-x-hidden 在移动端会因
@@ -1762,6 +1837,14 @@ export default function HomePage() {
</span>
<div className="flex items-center gap-4 md:gap-5">
<LanguageSwitcher variant="compact" />
<Link
href="/stories"
aria-label="我的剧情"
title="我的剧情"
className="text-base text-clay-500 hover:text-ember-500 transition-colors cursor-pointer"
>
<i className="fa-solid fa-book-bookmark" />
</Link>
<button
type="button"
onClick={() => {
@@ -1935,19 +2018,15 @@ export default function HomePage() {
}
>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-5">
{DISPLAY_ORDER[galleryGender].map((origIdx) => {
const c = stories[origIdx];
if (!c) return null;
return (
<StoryCard
key={`${imgPrefix}-${origIdx}`}
title={c.title}
outline={c.outline}
image={`/home/${imgPrefix}${origIdx}.webp`}
onClick={() => onCardClick(origIdx, c)}
/>
);
})}
{featuredCards.map((card) => (
<StoryCard
key={card.id}
title={card.title}
outline={card.outline}
image={card.coverPath}
onClick={() => onCardClick(card.id)}
/>
))}
</div>
</div>
</section>
+36 -15
View File
@@ -21,6 +21,7 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co
import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
import { collectBeatAudioForExport } from "@/lib/exportAudio";
import { loadFromLocalStorage } from "@/lib/clientStoryPersistence";
import { PRESETS } from "@/lib/presets";
import {
STORY_SHARE_STORAGE_KEY,
@@ -807,10 +808,6 @@ function PlayInner() {
const replayActiveRef = useRef(false);
const exportingStoryRef = useRef(false);
const exportingGalleryRef = useRef(false);
// Audio carried in from a `.infiplot` share file, keyed by `${sceneId}:${beatId}`.
// Survives scene swaps so a player who re-exports a replayed game keeps the
// baked voices that the original creator already paid to synth — they're
// free to embed back into the new gallery / share file.
const prebakedAudioRef = useRef<Record<string, string>>({});
// Original (CDN) URL of the currently-rendered scene image. Used as the key
// to revoke its blob: URL when the scene swaps. We track the ORIGINAL URL,
@@ -1192,8 +1189,6 @@ function PlayInner() {
setVisionClickEnabled(settings.visionClickEnabled);
const nextPlayerName = settings.playerName || undefined;
setSession((prev) => prev ? { ...prev, playerName: nextPlayerName } : prev);
// Refresh the BYO TTS config so a key entered mid-session takes effect
// immediately — byoTtsRef is otherwise only read once at mount.
const cfg = settings.ttsConfigured ? loadClientTtsConfig() : null;
byoTtsRef.current = cfg;
setByoTtsConfig(cfg);
@@ -1587,10 +1582,12 @@ function PlayInner() {
// ?custom=1 → 用户自定义 promptsessionStorage 取 ws/sg
// 后走 /api/start 现场生成
// ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放
// ?storyId=<uuid> → 加载已保存的剧情(从 localStorage
const cardName = params.get("card");
const presetId = params.get("preset");
const isCustom = params.get("custom") === "1";
const isShare = params.get("share") === "1";
const storyId = params.get("storyId");
if (isShare) {
(async () => {
@@ -1629,11 +1626,6 @@ function PlayInner() {
replayIndexRef.current = 0;
replayActiveRef.current = imported.history.length > 1;
visitedBeatsRef.current = [first.scene.entryBeatId];
// Stash pre-baked audio (from doc.audioByBeatId) so it survives scene
// swaps and re-exports. Keyed by `${sceneId}:${beatId}`. Also seed the
// current beatAudioMap for the first scene so audio plays right away
// — the scene-change effect normally clears the map on transition,
// and bare beat ids "b1/b2/..." would otherwise miss prebaked entries.
if (doc.audioByBeatId) {
prebakedAudioRef.current = { ...doc.audioByBeatId };
const seed: Record<string, string> = {};
@@ -1710,11 +1702,43 @@ function PlayInner() {
// be tagged onto the local Session build for /api/scene calls).
const sessionLanguage: string = locale;
if (!cardName && !livePayload) {
if (!cardName && !livePayload && !storyId) {
router.replace("/");
return;
}
// ── Load saved story path ──
if (storyId) {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
const loadedSession = loadFromLocalStorage(storyId);
if (!loadedSession) {
setError("找不到保存的剧情");
return;
}
const firstScene = loadedSession.history[0]?.scene;
if (!firstScene) {
setError("剧情数据损坏");
return;
}
(async () => {
try {
const blobUrl = await getOrCreateBlobUrl(firstScene.imageUrl ?? "");
lastImageOriginalUrlRef.current = firstScene.imageUrl ?? "";
setSession(loadedSession);
setCurrentScene(firstScene);
setCurrentBeatId(firstScene.entryBeatId);
setImageUrl(blobUrl);
visitedBeatsRef.current = [firstScene.entryBeatId];
setOrientation(loadedSession.orientation ?? "landscape");
setPhase("ready");
track("scene_reached", { scene_index: loadedSession.history.length });
} catch (e) {
setError(String(e));
}
})();
return;
}
type PrebakedFirstAct = StartResponse & {
worldSetting: string;
styleGuide: string;
@@ -1766,9 +1790,6 @@ function PlayInner() {
fetchStart
.then(async (data) => {
// Resolve to a paintable src before committing to state. Proxy path:
// a fully-local blob: URL the browser paints atomically (no row-by-row
// "层层加载"). Direct path (default): the preloaded original URL.
const blobUrl = await getOrCreateBlobUrl(data.imageUrl);
lastImageOriginalUrlRef.current = data.imageUrl;
+157
View File
@@ -0,0 +1,157 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { loadStoryList, deleteStory } from "@/lib/clientStoryPersistence";
import type { StoryMeta } from "@/lib/db/repositories/storyRepo";
export default function StoriesPage() {
const [stories, setStories] = useState<StoryMeta[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
loadStoryList()
.then(setStories)
.catch(() => setStories([]))
.finally(() => setLoading(false));
}, []);
const handleDelete = async (storyId: string) => {
if (!confirm("确认删除这个剧情?此操作无法撤销。")) return;
setDeletingId(storyId);
const success = await deleteStory(storyId);
if (success) {
setStories((prev) => prev.filter((s) => s.id !== storyId));
} else {
alert("删除失败,请稍后重试");
}
setDeletingId(null);
};
// D1 timestamps arrive as ISO strings over the JSON API boundary (the
// server-side Date is serialized by NextResponse.json), so coerce before use.
const formatDate = (value: Date | string | number) => {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "今天";
if (days === 1) return "昨天";
if (days < 7) return `${days} 天前`;
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit"
});
};
return (
<div className="min-h-screen flex flex-col">
{/* ================== HEADER ================== */}
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
<Link
href="/"
className="text-[10px] smallcaps text-clay-700 hover:text-clay-900 transition-colors flex items-center gap-2 cursor-pointer"
>
<i className="fa-solid fa-arrow-left text-[9px]" />
InfiPlot
</Link>
<span className="text-[10px] smallcaps text-clay-500">
· · ·
</span>
</header>
{/* ================== CONTENT ================== */}
<section className="px-6 md:px-16 pt-16 md:pt-24 pb-20 md:pb-24 flex-1">
{loading ? (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
· ·
</p>
</div>
) : stories.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<i className="fa-solid fa-book-open text-4xl text-clay-300 mb-6" />
<p className="font-serif italic text-lg text-clay-500 mb-4">
</p>
<Link
href="/"
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors cursor-pointer"
>
</Link>
</div>
) : (
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{stories.map((story) => (
<div
key={story.id}
className="bg-cream-100 border border-clay-900/10 rounded-sm p-6 transition-all duration-200 hover:shadow-md hover:border-clay-900/20 relative group"
>
<Link
href={`/play?storyId=${encodeURIComponent(story.id)}`}
className="block cursor-pointer"
>
<div className="mb-4">
<h3 className="font-serif text-lg text-clay-900 leading-tight mb-2 line-clamp-2">
{story.worldSetting.slice(0, 60)}
{story.worldSetting.length > 60 ? "..." : ""}
</h3>
<p className="text-sm text-clay-600 line-clamp-1">
{story.styleGuide}
</p>
</div>
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500">
<span className="flex items-center gap-1">
<i className="fa-solid fa-photo-film text-[9px]" />
{story.sceneCount}
</span>
<span className="flex items-center gap-1">
<i className="fa-solid fa-clock text-[9px]" />
{formatDate(story.updatedAt)}
</span>
</div>
</Link>
{/* Delete button */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
handleDelete(story.id);
}}
disabled={deletingId === story.id}
aria-label="删除"
className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-clay-400 hover:text-ember-500 disabled:opacity-50 cursor-pointer"
>
<i className={deletingId === story.id ? "fa-solid fa-spinner fa-spin" : "fa-solid fa-trash-can"} />
</button>
</div>
))}
</div>
</div>
)}
</section>
{/* ================== FOOTER ================== */}
<footer className="px-6 md:px-16 pb-8">
<div className="hairline-full w-full mb-4" />
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
<span>MMXXVI</span>
<span className="num">{stories.length} </span>
</div>
</footer>
</div>
);
}