feat(persistence): local-first story persistence (IndexedDB + Supabase skeleton)
Remove Cloudflare D1 entirely (4 API routes, lib/db/, Drizzle config/migrations, drizzle-orm/drizzle-kit deps, wrangler D1/R2/KV bindings) and replace with browser-local-first architecture: Open-source build (IndexedDB, no auth): - lib/persistence/ 5-file module: types, idb adapter (zero-dep, fault-tolerant, post-open invalidation retry), localStore (CRUD + sync-reserved metadata + slim/rebuild + retention-cap eviction with tombstone reap + sync-state protection + last-resort bounded fallback), sessionSlim (voice strip + styleRef absent-delete), cloudStore (Supabase skeleton, server-only) - Autosave: persistence fingerprint (history.length:lastBeatCount:playerName), serial saveChain, failure rollback retry, replaySourceRef guard (prevents replayed shared stories from clobbering user saves) - clientStoryPersistence.ts: thin facade (SaveResult discriminated union) - Stories page: /[locale]/stories with 3-language i18n (zh-CN/en/ja) - Homepage: book icon entry point in header Commercial build (Supabase, skeleton only): - Single table public.stories (JSONB + RLS 4 policies on auth.uid()=user_id) - supabase/migrations/ DDL (idempotent) - cloudStore.ts server-only repository, AUTH_ENABLED short-circuit - Not wired to client this phase Featured stories: pure fallback (buildFallbackCards + localizeCards), no D1 Includes fixes from 3 rounds of subagent code-review (tasks 16-30): - CR1: autosave restructure, coerceOrientation, D1 comment cleanup - CR2: fingerprint+serial+rollback+replay guard, idb post-open retry, enforceRetentionCap latent defense, sessionSlim absent invariant - CR3: single-scene share guard (replaySourceRef), insert-beat fingerprint (beats.length), pass3 overflow double-count fix, detach gate unification
This commit is contained in:
+22
-52
@@ -123,8 +123,8 @@ const OPTS: Opt[] = [
|
||||
|
||||
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
||||
|
||||
// 首页卡片的统一渲染形态——无论来自 D1 featured API 还是硬编码 STORIES 降级,
|
||||
// 都归一到这个形状后只走一条渲染路径。
|
||||
// 首页卡片的统一渲染形态。卡片来自硬编码 STORIES(buildFallbackCards),
|
||||
// 按当前 locale 本地化后渲染。
|
||||
type FeaturedCard = {
|
||||
id: string; // e.g. "m0" / "f12",用于 ?card= 与封面拼接
|
||||
title: string;
|
||||
@@ -132,22 +132,6 @@ type FeaturedCard = {
|
||||
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 按索引一一对应)。
|
||||
@@ -798,8 +782,8 @@ const DISPLAY_ORDER: Record<Gender, number[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(featured API 故障/空时的降级源,
|
||||
// 同时作为首屏即时渲染的初始值,避免等 fetch 期间卡片区空白)。
|
||||
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(卡片的唯一数据源,经
|
||||
// localizeCards 三语本地化;惰性初始化为首屏即时渲染初始值,无需任何 fetch)。
|
||||
function buildFallbackCards(g: Gender): FeaturedCard[] {
|
||||
const imgPrefix = g === "女性向" ? "f" : "m";
|
||||
const localStories = STORIES[g];
|
||||
@@ -1532,8 +1516,8 @@ export default function HomePage() {
|
||||
return () => clearTimeout(t);
|
||||
}, [gender, galleryGender]);
|
||||
|
||||
// Featured stories 动态加载(从 /api/stories/featured),降级用硬编码 STORIES。
|
||||
// 惰性初始化确保首屏即有卡片内容(SSR + hydration 一致),fetch 成功后无缝替换。
|
||||
// Featured 卡片来自硬编码 STORIES 的本地化结果。惰性初始化确保首屏即有内容
|
||||
//(SSR + hydration 一致),locale/gender 变化时重算本地化。
|
||||
const storiesI18nRef = useRef<{ locale: string; data: StoriesI18n | null }>({ locale: "", data: null });
|
||||
const [featuredCards, setFeaturedCards] = useState<FeaturedCard[]>(() =>
|
||||
buildFallbackCards(galleryGender),
|
||||
@@ -1546,34 +1530,11 @@ export default function HomePage() {
|
||||
}
|
||||
const i18n = storiesI18nRef.current.data;
|
||||
if (cancelled) return;
|
||||
|
||||
const apiGender = galleryGender === "女性向" ? "female" : "male";
|
||||
try {
|
||||
const r = await fetch(`/api/stories/featured?gender=${apiGender}`);
|
||||
const data: { stories: FeaturedStoryRow[] } = await r.json();
|
||||
// API 已按 sortOrder 排序且仅返回 isActive=1 的记录。
|
||||
// D1 故障时 featured route 返回 { stories: [] }(HTTP 200),
|
||||
// 空数组也必须降级到常量,否则首页白屏。
|
||||
const rows = data.stories ?? [];
|
||||
if (cancelled) return;
|
||||
if (rows.length === 0) {
|
||||
setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
|
||||
return;
|
||||
}
|
||||
setFeaturedCards(
|
||||
localizeCards(
|
||||
rows.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
outline: s.outline,
|
||||
coverPath: s.coverPath,
|
||||
})),
|
||||
i18n,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
|
||||
}
|
||||
// Featured cards come from the hardcoded fallback set (buildFallbackCards),
|
||||
// localized for the active locale. The D1 /api/stories/featured route was
|
||||
// removed — it had no real data and always degraded to this fallback, so
|
||||
// this is behavior-identical with one fewer network round-trip.
|
||||
setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [galleryGender, locale]);
|
||||
@@ -1878,8 +1839,17 @@ export default function HomePage() {
|
||||
</span>
|
||||
<div className="flex items-center gap-4 md:gap-5">
|
||||
<LanguageSwitcher variant="compact" />
|
||||
{/* Story persistence UI hidden until auth integration is ready.
|
||||
Code in app/stories/, app/api/stories/, lib/db/ is retained. */}
|
||||
{/* "我的剧情" — entry to the browser-local story list (IndexedDB).
|
||||
Needs no auth; the /[locale]/stories page reads the local store. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(lp("/stories"))}
|
||||
aria-label={t("home.ui.myStories")}
|
||||
title={t("home.ui.myStories")}
|
||||
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-book" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
+107
-23
@@ -21,7 +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 { saveStory, loadStorySession } from "@/lib/clientStoryPersistence";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
import {
|
||||
STORY_SHARE_STORAGE_KEY,
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
TtsConfig,
|
||||
TtsProvider,
|
||||
} from "@infiplot/types";
|
||||
import { coerceOrientation } from "@infiplot/types";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||
import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume";
|
||||
@@ -480,7 +481,7 @@ function prefetchScenePath(
|
||||
source: "prefetch" as const,
|
||||
kind,
|
||||
http_status,
|
||||
orientation: baseSession.orientation ?? "landscape",
|
||||
orientation: coerceOrientation(baseSession.orientation),
|
||||
connection: getConnectionType(),
|
||||
was_hidden: typeof document !== "undefined" && document.visibilityState === "hidden",
|
||||
scene_index: baseSession.history.length,
|
||||
@@ -711,7 +712,7 @@ function PlayInner() {
|
||||
session: sess,
|
||||
beatId: beat.id,
|
||||
visitedBeats: [...visitedBeatsRef.current],
|
||||
orientation: sess.orientation ?? "landscape",
|
||||
orientation: coerceOrientation(sess.orientation),
|
||||
imageOriginalUrl,
|
||||
pendingAction: pendingResumeActionRef.current ?? undefined,
|
||||
};
|
||||
@@ -851,6 +852,61 @@ function PlayInner() {
|
||||
useEffect(() => {
|
||||
sessionRef.current = session;
|
||||
}, [session]);
|
||||
// Autosave bookkeeping. We persist on a stable FINGERPRINT of the durable,
|
||||
// session-level state — committed-scene count + playerName — not the raw
|
||||
// `session` reference, which churns on every beat advance (visitedBeatIds).
|
||||
// - lastSavedFingerprintRef holds the fingerprint of the last SUCCESSFUL save.
|
||||
// On failure it's cleared so the next session change retries: a
|
||||
// fire-and-forget that silently failed (IndexedDB transiently unavailable)
|
||||
// must not strand the scene unsaved.
|
||||
// - saveChainRef serializes writes so a slow save for scene N can't land after
|
||||
// a faster save for N+1 and persist a stale, shorter session.
|
||||
const lastSavedFingerprintRef = useRef("");
|
||||
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
// Persist to the browser-local store when the durable state changes (Req 2.1).
|
||||
// Fingerprint = committed-scene count + last-scene beat count + playerName:
|
||||
// - scene count grows on a normal scene commit;
|
||||
// - last-scene beat count grows on an insert-beat (freeform / background-click
|
||||
// appends a beat to the current scene WITHOUT changing history.length), which
|
||||
// is real generated narrative that must persist — keying on length alone
|
||||
// would silently drop it;
|
||||
// - playerName captures a late rename.
|
||||
// Within-scene *visited* progress (visitedBeatIds) is deliberately NOT in the
|
||||
// fingerprint, so merely advancing through existing beats doesn't re-save. The
|
||||
// resume path primes the fingerprint so loading a story stays a pure read (no
|
||||
// re-save / rev bump / list reorder). No debounce — the write is issued on the
|
||||
// committing render, so navigating home right after a change can't drop it (the
|
||||
// IndexedDB put is already in flight, serialized, not cancelled by unmount).
|
||||
// Fire-and-forget: never blocks.
|
||||
useEffect(() => {
|
||||
// Never persist a replayed shared story into the user's own library — it
|
||||
// isn't theirs and its id can collide with (and clobber) a real local save.
|
||||
// Guard on replaySourceRef (set unconditionally on import, cleared by
|
||||
// detachRecordedReplay when the user takes over) — NOT replayActiveRef, which
|
||||
// means "more recorded scenes remain" and is false for a single-scene share,
|
||||
// so that share would otherwise slip through and overwrite a real save.
|
||||
if (!session || replaySourceRef.current) return;
|
||||
const history = session.history ?? [];
|
||||
if (history.length < 1) return;
|
||||
const lastBeatCount = history[history.length - 1]?.scene?.beats?.length ?? 0;
|
||||
const fingerprint = `${history.length}:${lastBeatCount}:${session.playerName ?? ""}`;
|
||||
if (fingerprint === lastSavedFingerprintRef.current) return;
|
||||
lastSavedFingerprintRef.current = fingerprint; // optimistic; rolled back on failure
|
||||
const snapshot = session;
|
||||
saveChainRef.current = saveChainRef.current
|
||||
.then(async () => {
|
||||
const r = await saveStory(snapshot);
|
||||
// Roll back only if no newer save has superseded us, so the next session
|
||||
// change retries this content instead of the failure being permanent.
|
||||
if (!r.ok && lastSavedFingerprintRef.current === fingerprint) {
|
||||
lastSavedFingerprintRef.current = "";
|
||||
}
|
||||
})
|
||||
// Defensive: saveStory is contracted never to throw, but if a future edit
|
||||
// to this callback ever does, an unhandled rejection here would poison the
|
||||
// chain and freeze ALL subsequent saves. Swallow to keep the chain alive.
|
||||
.catch(() => {});
|
||||
}, [session]);
|
||||
useEffect(() => {
|
||||
currentSceneRef.current = currentScene;
|
||||
}, [currentScene]);
|
||||
@@ -1382,7 +1438,7 @@ function PlayInner() {
|
||||
v: audioByBeatId && Object.keys(audioByBeatId).length > 0 ? 3 : 2,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
orientation: s.orientation ?? "landscape",
|
||||
orientation: coerceOrientation(s.orientation),
|
||||
scenes,
|
||||
alternates,
|
||||
characters,
|
||||
@@ -1712,27 +1768,50 @@ function PlayInner() {
|
||||
|
||||
// ── Load saved story path ──
|
||||
if (storyId) {
|
||||
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
|
||||
const loadedSession = loadFromLocalStorage(storyId);
|
||||
if (!loadedSession) {
|
||||
setError(t("play.savedStoryNotFound"));
|
||||
return;
|
||||
}
|
||||
const firstScene = loadedSession.history[0]?.scene;
|
||||
if (!firstScene) {
|
||||
setError(t("play.savedStoryCorrupted"));
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
// Browser-local store (IndexedDB) is async; load inside the IIFE.
|
||||
const loadedSession = await loadStorySession(storyId);
|
||||
if (!loadedSession) {
|
||||
setError(t("play.savedStoryNotFound"));
|
||||
return;
|
||||
}
|
||||
// Resume at the player's last position. Walk from the newest scene back
|
||||
// to the first and resume at the latest one that actually has a rendered
|
||||
// image: the final scene → correct position; if the very last scene
|
||||
// failed to image (committed without one), a small rewind beats a blank
|
||||
// canvas (Req 3.3). If NO scene has an image the story can't render —
|
||||
// surface savedStoryCorrupted instead of landing on getOrCreateBlobUrl("").
|
||||
const history = loadedSession.history;
|
||||
let resumeEntry = history[history.length - 1];
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i]?.scene?.imageUrl) {
|
||||
resumeEntry = history[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const resumeScene = resumeEntry?.scene;
|
||||
if (!resumeScene?.imageUrl) {
|
||||
setError(t("play.savedStoryCorrupted"));
|
||||
return;
|
||||
}
|
||||
// Pure read: prime the autosave fingerprint so loading doesn't re-save /
|
||||
// bump rev / reorder the list. Must match the effect's fingerprint shape
|
||||
// exactly (scene count + last-scene beat count + playerName) or the first
|
||||
// render would re-persist.
|
||||
{
|
||||
const lastBeatCount =
|
||||
history[history.length - 1]?.scene?.beats?.length ?? 0;
|
||||
lastSavedFingerprintRef.current = `${history.length}:${lastBeatCount}:${loadedSession.playerName ?? ""}`;
|
||||
}
|
||||
try {
|
||||
const blobUrl = await getOrCreateBlobUrl(firstScene.imageUrl ?? "");
|
||||
lastImageOriginalUrlRef.current = firstScene.imageUrl ?? "";
|
||||
const blobUrl = await getOrCreateBlobUrl(resumeScene.imageUrl);
|
||||
lastImageOriginalUrlRef.current = resumeScene.imageUrl;
|
||||
setSession(loadedSession);
|
||||
setCurrentScene(firstScene);
|
||||
setCurrentBeatId(firstScene.entryBeatId);
|
||||
setCurrentScene(resumeScene);
|
||||
setCurrentBeatId(resumeScene.entryBeatId);
|
||||
setImageUrl(blobUrl);
|
||||
visitedBeatsRef.current = [firstScene.entryBeatId];
|
||||
setOrientation(loadedSession.orientation ?? "landscape");
|
||||
visitedBeatsRef.current = [resumeScene.entryBeatId];
|
||||
setOrientation(coerceOrientation(loadedSession.orientation));
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: loadedSession.history.length });
|
||||
} catch (e) {
|
||||
@@ -2238,7 +2317,10 @@ function PlayInner() {
|
||||
|
||||
async function onFreeformInput(text: string) {
|
||||
if (phase !== "ready" || !session || !currentScene) return;
|
||||
if (replayActiveRef.current) detachRecordedReplay();
|
||||
// Detach if we're still replaying a shared story (gate on replaySourceRef,
|
||||
// not replayActiveRef — the latter is false for a single-scene share, which
|
||||
// would otherwise leave us "stuck" in replay and block autosave forever).
|
||||
if (replaySourceRef.current) detachRecordedReplay();
|
||||
|
||||
track("freeform_input", {
|
||||
scene_index: session.history.length,
|
||||
@@ -2295,7 +2377,9 @@ function PlayInner() {
|
||||
|
||||
async function onBackgroundClick(click: { x: number; y: number }) {
|
||||
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
|
||||
if (replayActiveRef.current) detachRecordedReplay();
|
||||
// Gate on replaySourceRef, not replayActiveRef (false for a single-scene
|
||||
// share) — see onFreeformInput for the rationale.
|
||||
if (replaySourceRef.current) detachRecordedReplay();
|
||||
const visionT0 = Date.now();
|
||||
setPhase("vision-thinking");
|
||||
setPendingClick(click);
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadStoryList, deleteStory } from "@/lib/clientStoryPersistence";
|
||||
import type { StoryMeta } from "@/lib/db/repositories/storyRepo";
|
||||
import type { StoryMeta } from "@/lib/persistence/types";
|
||||
import { coerceEpoch } from "@/lib/persistence/types";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
import { useI18n } from "@/lib/i18n/client";
|
||||
|
||||
export default function StoriesPage() {
|
||||
const lp = useLocalePath();
|
||||
const { t, locale } = useI18n();
|
||||
const [stories, setStories] = useState<StoryMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
@@ -20,7 +23,7 @@ export default function StoriesPage() {
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (storyId: string) => {
|
||||
if (!confirm("确认删除这个剧情?此操作无法撤销。")) return;
|
||||
if (!confirm(t("stories.deleteConfirm"))) return;
|
||||
|
||||
setDeletingId(storyId);
|
||||
const success = await deleteStory(storyId);
|
||||
@@ -28,30 +31,31 @@ export default function StoriesPage() {
|
||||
if (success) {
|
||||
setStories((prev) => prev.filter((s) => s.id !== storyId));
|
||||
} else {
|
||||
alert("删除失败,请稍后重试");
|
||||
alert(t("stories.deleteFailed"));
|
||||
}
|
||||
|
||||
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.
|
||||
// Story timestamps cross the storage boundary as epoch ms (the local store
|
||||
// coerces them); coerceEpoch is the shared guard for any legacy string/Date.
|
||||
const formatDate = (value: Date | string | number) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const ms = coerceEpoch(value, NaN);
|
||||
if (Number.isNaN(ms)) return "";
|
||||
const date = new Date(ms);
|
||||
|
||||
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} 天前`;
|
||||
if (days === 0) return t("stories.today");
|
||||
if (days === 1) return t("stories.yesterday");
|
||||
if (days < 7) return t("stories.daysAgo", { days });
|
||||
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
return date.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -67,7 +71,7 @@ export default function StoriesPage() {
|
||||
InfiPlot
|
||||
</Link>
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
我 · 的 · 剧 · 情
|
||||
{t("stories.title")}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
@@ -76,20 +80,20 @@ export default function StoriesPage() {
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[40vh]">
|
||||
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||||
载 · 入 · 中
|
||||
{t("stories.loading")}
|
||||
</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">
|
||||
还没有保存的剧情
|
||||
{t("stories.emptyTitle")}
|
||||
</p>
|
||||
<Link
|
||||
href={lp("/")}
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors cursor-pointer"
|
||||
>
|
||||
回到首页开始新的故事
|
||||
{t("stories.emptyBack")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -117,7 +121,7 @@ export default function StoriesPage() {
|
||||
<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} 幕
|
||||
{t("stories.scenes", { count: story.sceneCount })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-clock text-[9px]" />
|
||||
@@ -134,7 +138,7 @@ export default function StoriesPage() {
|
||||
handleDelete(story.id);
|
||||
}}
|
||||
disabled={deletingId === story.id}
|
||||
aria-label="删除"
|
||||
aria-label={t("stories.deleteLabel")}
|
||||
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"} />
|
||||
@@ -151,7 +155,7 @@ export default function StoriesPage() {
|
||||
<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>
|
||||
<span className="num">{t("stories.storiesCount", { count: stories.length })}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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: [] });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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: [] });
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user