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:
Kai ki
2026-06-25 18:19:08 +08:00
parent be39fcc77e
commit 610dba78b7
30 changed files with 1043 additions and 2019 deletions
+21 -51
View File
@@ -123,8 +123,8 @@ const OPTS: Opt[] = [
type StoryContent = { title: string; outline: string; style: string; tags: string[] }; type StoryContent = { title: string; outline: string; style: string; tags: string[] };
// 首页卡片的统一渲染形态——无论来自 D1 featured API 还是硬编码 STORIES 降级 // 首页卡片的统一渲染形态。卡片来自硬编码 STORIESbuildFallbackCards
// 都归一到这个形状后只走一条渲染路径 // 按当前 locale 本地化后渲染
type FeaturedCard = { type FeaturedCard = {
id: string; // e.g. "m0" / "f12",用于 ?card= 与封面拼接 id: string; // e.g. "m0" / "f12",用于 ?card= 与封面拼接
title: string; title: string;
@@ -132,22 +132,6 @@ type FeaturedCard = {
coverPath: string; // e.g. "/home/m0.webp" 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"; import { STYLE_MAP } from "@/lib/options";
/* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。 /* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。
@@ -798,8 +782,8 @@ const DISPLAY_ORDER: Record<Gender, number[]> = {
], ],
}; };
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(featured API 故障/空时的降级源, // 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(卡片的唯一数据源,
// 同时作为首屏即时渲染初始值,避免等 fetch 期间卡片区空白)。 // localizeCards 三语本地化;惰性初始化为首屏即时渲染初始值,无需任何 fetch)。
function buildFallbackCards(g: Gender): FeaturedCard[] { function buildFallbackCards(g: Gender): FeaturedCard[] {
const imgPrefix = g === "女性向" ? "f" : "m"; const imgPrefix = g === "女性向" ? "f" : "m";
const localStories = STORIES[g]; const localStories = STORIES[g];
@@ -1532,8 +1516,8 @@ export default function HomePage() {
return () => clearTimeout(t); return () => clearTimeout(t);
}, [gender, galleryGender]); }, [gender, galleryGender]);
// Featured stories 动态加载(从 /api/stories/featured),降级用硬编码 STORIES。 // Featured 卡片来自硬编码 STORIES 的本地化结果。惰性初始化确保首屏即有内容
// 惰性初始化确保首屏即有卡片内容SSR + hydration 一致),fetch 成功后无缝替换 //SSR + hydration 一致),locale/gender 变化时重算本地化
const storiesI18nRef = useRef<{ locale: string; data: StoriesI18n | null }>({ locale: "", data: null }); const storiesI18nRef = useRef<{ locale: string; data: StoriesI18n | null }>({ locale: "", data: null });
const [featuredCards, setFeaturedCards] = useState<FeaturedCard[]>(() => const [featuredCards, setFeaturedCards] = useState<FeaturedCard[]>(() =>
buildFallbackCards(galleryGender), buildFallbackCards(galleryGender),
@@ -1546,34 +1530,11 @@ export default function HomePage() {
} }
const i18n = storiesI18nRef.current.data; const i18n = storiesI18nRef.current.data;
if (cancelled) return; if (cancelled) return;
// Featured cards come from the hardcoded fallback set (buildFallbackCards),
const apiGender = galleryGender === "女性向" ? "female" : "male"; // localized for the active locale. The D1 /api/stories/featured route was
try { // removed — it had no real data and always degraded to this fallback, so
const r = await fetch(`/api/stories/featured?gender=${apiGender}`); // this is behavior-identical with one fewer network round-trip.
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)); 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));
}
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [galleryGender, locale]); }, [galleryGender, locale]);
@@ -1878,8 +1839,17 @@ export default function HomePage() {
</span> </span>
<div className="flex items-center gap-4 md:gap-5"> <div className="flex items-center gap-4 md:gap-5">
<LanguageSwitcher variant="compact" /> <LanguageSwitcher variant="compact" />
{/* Story persistence UI hidden until auth integration is ready. {/* "我的剧情" — entry to the browser-local story list (IndexedDB).
Code in app/stories/, app/api/stories/, lib/db/ is retained. */} 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 <button
type="button" type="button"
onClick={() => { onClick={() => {
+101 -17
View File
@@ -21,7 +21,7 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co
import { annotateClick } from "@/lib/annotateClient"; import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
import { collectBeatAudioForExport } from "@/lib/exportAudio"; import { collectBeatAudioForExport } from "@/lib/exportAudio";
import { loadFromLocalStorage } from "@/lib/clientStoryPersistence"; import { saveStory, loadStorySession } from "@/lib/clientStoryPersistence";
import { PRESETS } from "@/lib/presets"; import { PRESETS } from "@/lib/presets";
import { import {
STORY_SHARE_STORAGE_KEY, STORY_SHARE_STORAGE_KEY,
@@ -52,6 +52,7 @@ import type {
TtsConfig, TtsConfig,
TtsProvider, TtsProvider,
} from "@infiplot/types"; } from "@infiplot/types";
import { coerceOrientation } from "@infiplot/types";
import { track } from "@/lib/analytics"; import { track } from "@/lib/analytics";
import { AUTH_ENABLED } from "@/lib/supabase/config"; import { AUTH_ENABLED } from "@/lib/supabase/config";
import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume"; import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume";
@@ -480,7 +481,7 @@ function prefetchScenePath(
source: "prefetch" as const, source: "prefetch" as const,
kind, kind,
http_status, http_status,
orientation: baseSession.orientation ?? "landscape", orientation: coerceOrientation(baseSession.orientation),
connection: getConnectionType(), connection: getConnectionType(),
was_hidden: typeof document !== "undefined" && document.visibilityState === "hidden", was_hidden: typeof document !== "undefined" && document.visibilityState === "hidden",
scene_index: baseSession.history.length, scene_index: baseSession.history.length,
@@ -711,7 +712,7 @@ function PlayInner() {
session: sess, session: sess,
beatId: beat.id, beatId: beat.id,
visitedBeats: [...visitedBeatsRef.current], visitedBeats: [...visitedBeatsRef.current],
orientation: sess.orientation ?? "landscape", orientation: coerceOrientation(sess.orientation),
imageOriginalUrl, imageOriginalUrl,
pendingAction: pendingResumeActionRef.current ?? undefined, pendingAction: pendingResumeActionRef.current ?? undefined,
}; };
@@ -851,6 +852,61 @@ function PlayInner() {
useEffect(() => { useEffect(() => {
sessionRef.current = session; sessionRef.current = session;
}, [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(() => { useEffect(() => {
currentSceneRef.current = currentScene; currentSceneRef.current = currentScene;
}, [currentScene]); }, [currentScene]);
@@ -1382,7 +1438,7 @@ function PlayInner() {
v: audioByBeatId && Object.keys(audioByBeatId).length > 0 ? 3 : 2, v: audioByBeatId && Object.keys(audioByBeatId).length > 0 ? 3 : 2,
id, id,
createdAt: Date.now(), createdAt: Date.now(),
orientation: s.orientation ?? "landscape", orientation: coerceOrientation(s.orientation),
scenes, scenes,
alternates, alternates,
characters, characters,
@@ -1712,27 +1768,50 @@ function PlayInner() {
// ── Load saved story path ── // ── Load saved story path ──
if (storyId) { if (storyId) {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration) (async () => {
const loadedSession = loadFromLocalStorage(storyId); // Browser-local store (IndexedDB) is async; load inside the IIFE.
const loadedSession = await loadStorySession(storyId);
if (!loadedSession) { if (!loadedSession) {
setError(t("play.savedStoryNotFound")); setError(t("play.savedStoryNotFound"));
return; return;
} }
const firstScene = loadedSession.history[0]?.scene; // Resume at the player's last position. Walk from the newest scene back
if (!firstScene) { // 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")); setError(t("play.savedStoryCorrupted"));
return; return;
} }
(async () => { // 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 { try {
const blobUrl = await getOrCreateBlobUrl(firstScene.imageUrl ?? ""); const blobUrl = await getOrCreateBlobUrl(resumeScene.imageUrl);
lastImageOriginalUrlRef.current = firstScene.imageUrl ?? ""; lastImageOriginalUrlRef.current = resumeScene.imageUrl;
setSession(loadedSession); setSession(loadedSession);
setCurrentScene(firstScene); setCurrentScene(resumeScene);
setCurrentBeatId(firstScene.entryBeatId); setCurrentBeatId(resumeScene.entryBeatId);
setImageUrl(blobUrl); setImageUrl(blobUrl);
visitedBeatsRef.current = [firstScene.entryBeatId]; visitedBeatsRef.current = [resumeScene.entryBeatId];
setOrientation(loadedSession.orientation ?? "landscape"); setOrientation(coerceOrientation(loadedSession.orientation));
setPhase("ready"); setPhase("ready");
track("scene_reached", { scene_index: loadedSession.history.length }); track("scene_reached", { scene_index: loadedSession.history.length });
} catch (e) { } catch (e) {
@@ -2238,7 +2317,10 @@ function PlayInner() {
async function onFreeformInput(text: string) { async function onFreeformInput(text: string) {
if (phase !== "ready" || !session || !currentScene) return; 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", { track("freeform_input", {
scene_index: session.history.length, scene_index: session.history.length,
@@ -2295,7 +2377,9 @@ function PlayInner() {
async function onBackgroundClick(click: { x: number; y: number }) { async function onBackgroundClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !currentScene || !imageUrl) return; 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(); const visionT0 = Date.now();
setPhase("vision-thinking"); setPhase("vision-thinking");
setPendingClick(click); setPendingClick(click);
+23 -19
View File
@@ -3,11 +3,14 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { loadStoryList, deleteStory } from "@/lib/clientStoryPersistence"; 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 { useLocalePath } from "@/lib/i18n/hooks";
import { useI18n } from "@/lib/i18n/client";
export default function StoriesPage() { export default function StoriesPage() {
const lp = useLocalePath(); const lp = useLocalePath();
const { t, locale } = useI18n();
const [stories, setStories] = useState<StoryMeta[]>([]); const [stories, setStories] = useState<StoryMeta[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -20,7 +23,7 @@ export default function StoriesPage() {
}, []); }, []);
const handleDelete = async (storyId: string) => { const handleDelete = async (storyId: string) => {
if (!confirm("确认删除这个剧情?此操作无法撤销。")) return; if (!confirm(t("stories.deleteConfirm"))) return;
setDeletingId(storyId); setDeletingId(storyId);
const success = await deleteStory(storyId); const success = await deleteStory(storyId);
@@ -28,30 +31,31 @@ export default function StoriesPage() {
if (success) { if (success) {
setStories((prev) => prev.filter((s) => s.id !== storyId)); setStories((prev) => prev.filter((s) => s.id !== storyId));
} else { } else {
alert("删除失败,请稍后重试"); alert(t("stories.deleteFailed"));
} }
setDeletingId(null); setDeletingId(null);
}; };
// D1 timestamps arrive as ISO strings over the JSON API boundary (the // Story timestamps cross the storage boundary as epoch ms (the local store
// server-side Date is serialized by NextResponse.json), so coerce before use. // coerces them); coerceEpoch is the shared guard for any legacy string/Date.
const formatDate = (value: Date | string | number) => { const formatDate = (value: Date | string | number) => {
const date = value instanceof Date ? value : new Date(value); const ms = coerceEpoch(value, NaN);
if (Number.isNaN(date.getTime())) return ""; if (Number.isNaN(ms)) return "";
const date = new Date(ms);
const now = new Date(); const now = new Date();
const diff = now.getTime() - date.getTime(); const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24)); const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "今天"; if (days === 0) return t("stories.today");
if (days === 1) return "昨天"; if (days === 1) return t("stories.yesterday");
if (days < 7) return `${days} 天前`; if (days < 7) return t("stories.daysAgo", { days });
return date.toLocaleDateString("zh-CN", { return date.toLocaleDateString(locale, {
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit" day: "2-digit",
}); });
}; };
@@ -67,7 +71,7 @@ export default function StoriesPage() {
InfiPlot InfiPlot
</Link> </Link>
<span className="text-[10px] smallcaps text-clay-500"> <span className="text-[10px] smallcaps text-clay-500">
· · · {t("stories.title")}
</span> </span>
</header> </header>
@@ -76,20 +80,20 @@ export default function StoriesPage() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center min-h-[40vh]"> <div className="flex items-center justify-center min-h-[40vh]">
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse"> <p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
· · {t("stories.loading")}
</p> </p>
</div> </div>
) : stories.length === 0 ? ( ) : stories.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center"> <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" /> <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 className="font-serif italic text-lg text-clay-500 mb-4">
{t("stories.emptyTitle")}
</p> </p>
<Link <Link
href={lp("/")} href={lp("/")}
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors cursor-pointer" className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors cursor-pointer"
> >
{t("stories.emptyBack")}
</Link> </Link>
</div> </div>
) : ( ) : (
@@ -117,7 +121,7 @@ export default function StoriesPage() {
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500"> <div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<i className="fa-solid fa-photo-film text-[9px]" /> <i className="fa-solid fa-photo-film text-[9px]" />
{story.sceneCount} {t("stories.scenes", { count: story.sceneCount })}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<i className="fa-solid fa-clock text-[9px]" /> <i className="fa-solid fa-clock text-[9px]" />
@@ -134,7 +138,7 @@ export default function StoriesPage() {
handleDelete(story.id); handleDelete(story.id);
}} }}
disabled={deletingId === 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" 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"} /> <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="hairline-full w-full mb-4" />
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500"> <div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
<span>MMXXVI</span> <span>MMXXVI</span>
<span className="num">{stories.length} </span> <span className="num">{t("stories.storiesCount", { count: stories.length })}</span>
</div> </div>
</footer> </footer>
</div> </div>
-31
View File
@@ -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 },
);
}
-48
View File
@@ -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: [] });
}
}
-15
View File
@@ -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: [] });
}
-27
View File
@@ -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 },
);
}
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
driver: "d1-http",
dbCredentials: {
// These will be read from wrangler.toml / .dev.vars at runtime
// For migrations: wrangler d1 migrations apply DB --local (or --remote)
accountId: process.env.CLOUDFLARE_ACCOUNT_ID || "",
databaseId: process.env.CLOUDFLARE_DATABASE_ID || "",
token: process.env.CLOUDFLARE_D1_TOKEN || "",
},
});
-61
View File
@@ -1,61 +0,0 @@
CREATE TABLE `characters` (
`id` text PRIMARY KEY NOT NULL,
`story_id` text NOT NULL,
`name` text NOT NULL,
`visual_description` text,
`voice_description` text,
`base_portrait_key` text,
`base_portrait_url` text,
`base_portrait_uuid` text,
`voice_json` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`story_id`) REFERENCES `stories`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `characters_story_name_idx` ON `characters` (`story_id`,`name`);--> statement-breakpoint
CREATE TABLE `featured_stories` (
`id` text PRIMARY KEY NOT NULL,
`gender` text NOT NULL,
`title` text NOT NULL,
`outline` text NOT NULL,
`style` text NOT NULL,
`tags` text NOT NULL,
`cover_path` text NOT NULL,
`firstact_path` text NOT NULL,
`firstscene_path` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`is_active` integer DEFAULT 1 NOT NULL,
`click_count` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `featured_gender_active_idx` ON `featured_stories` (`gender`,`is_active`);--> statement-breakpoint
CREATE TABLE `scenes` (
`id` text PRIMARY KEY NOT NULL,
`story_id` text NOT NULL,
`scene_key` text,
`scene_summary` text,
`scene_image_key` text,
`scene_image_url` text,
`beats_json` text,
`sort_order` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`story_id`) REFERENCES `stories`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `scenes_story_id_idx` ON `scenes` (`story_id`);--> statement-breakpoint
CREATE TABLE `stories` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text,
`world_setting` text NOT NULL,
`style_guide` text NOT NULL,
`style_reference_image_key` text,
`orientation` text DEFAULT 'landscape' NOT NULL,
`story_state_json` text,
`status` text DEFAULT 'active' NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `stories_user_id_idx` ON `stories` (`user_id`);--> statement-breakpoint
CREATE INDEX `stories_created_at_idx` ON `stories` (`created_at`);
-431
View File
@@ -1,431 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f3a8998c-2717-4d46-b447-4fa3c382f2b2",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"characters": {
"name": "characters",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"story_id": {
"name": "story_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visual_description": {
"name": "visual_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"voice_description": {
"name": "voice_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"base_portrait_key": {
"name": "base_portrait_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"base_portrait_url": {
"name": "base_portrait_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"base_portrait_uuid": {
"name": "base_portrait_uuid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"voice_json": {
"name": "voice_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"characters_story_name_idx": {
"name": "characters_story_name_idx",
"columns": [
"story_id",
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"characters_story_id_stories_id_fk": {
"name": "characters_story_id_stories_id_fk",
"tableFrom": "characters",
"tableTo": "stories",
"columnsFrom": [
"story_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"featured_stories": {
"name": "featured_stories",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"gender": {
"name": "gender",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"outline": {
"name": "outline",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"style": {
"name": "style",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_path": {
"name": "cover_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"firstact_path": {
"name": "firstact_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"firstscene_path": {
"name": "firstscene_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"click_count": {
"name": "click_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"featured_gender_active_idx": {
"name": "featured_gender_active_idx",
"columns": [
"gender",
"is_active"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scenes": {
"name": "scenes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"story_id": {
"name": "story_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scene_key": {
"name": "scene_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scene_summary": {
"name": "scene_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scene_image_key": {
"name": "scene_image_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scene_image_url": {
"name": "scene_image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"beats_json": {
"name": "beats_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"scenes_story_id_idx": {
"name": "scenes_story_id_idx",
"columns": [
"story_id"
],
"isUnique": false
}
},
"foreignKeys": {
"scenes_story_id_stories_id_fk": {
"name": "scenes_story_id_stories_id_fk",
"tableFrom": "scenes",
"tableTo": "stories",
"columnsFrom": [
"story_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"stories": {
"name": "stories",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"world_setting": {
"name": "world_setting",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"style_guide": {
"name": "style_guide",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"style_reference_image_key": {
"name": "style_reference_image_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"orientation": {
"name": "orientation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'landscape'"
},
"story_state_json": {
"name": "story_state_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"stories_user_id_idx": {
"name": "stories_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"stories_created_at_idx": {
"name": "stories_created_at_idx",
"columns": [
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1780820306927,
"tag": "0000_early_paladin",
"breakpoints": true
}
]
}
-66
View File
@@ -1,66 +0,0 @@
-- Auto-generated by scripts/migrate-featured.ts
-- Idempotent: uses INSERT OR REPLACE
DELETE FROM featured_stories;
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m0', 'male', '贤者陨落', '帝国首席大魔导师遭挚友背叛,魔力核心被挖,沦为废人。百年后,他于拍卖会以奴隶身份现身,血契锁链下,是重燃的复仇烈焰与更禁忌的古代魔法。', '古典厚涂油画 (学术奇幻)', '["逆袭","系统","西幻"]', '/home/m0.webp', '/home/firstact/m0.json', '/home/firstscene/m0.webp', 8, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m1', 'male', '画中圣手', '落魄书生意外获得一支诡异画笔,画出的女子竟能破画而出,化为真人。他本想靠此翻身,却卷入一桩延续千年的宫廷秘辛与仙凡禁忌之恋。', '极简中国水墨 (Image 0参考升级版)', '["逆袭","系统","古风奇幻"]', '/home/m1.webp', '/home/firstact/m1.json', '/home/firstscene/m1.webp', 9, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m2', 'male', '花魁的刀', '他是吉原最负盛名的花魁,舞姿倾城,面具下的真实身份却是令江户幕府闻风丧胆的传奇忍者。当幕府密探踏入花街,刀光与花影将同绽。', '浮世绘木刻 (美人画升级)', '["女扮男装","忍者","权谋"]', '/home/m2.webp', '/home/firstact/m2.json', '/home/firstscene/m2.webp', 7, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m3', 'male', '飞天引', '考古队员在封闭洞窟深处,唤醒了一位沉睡千年的壁画仙子。她视他为天命之人,助他破解壁画中的上古秘藏,却不知自己正是打开灾厄之门的钥匙。', '莫高窟壁画风 (敦煌学)', '["探险","神话","契约"]', '/home/m3.webp', '/home/firstact/m3.json', '/home/firstscene/m3.webp', 10, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m4', 'male', '波斯棋局', '被囚于苏丹宫殿的异教徒学者,凭借一部残缺的古老棋谱,操纵着棋盘上的金丝傀儡,搅动宫廷风云。他每赢一局,离揭开沙漠之下沉睡的旧神遗迹便近一步。', '细密画 (波斯/伊斯兰风)', '["智斗","异域","神秘学"]', '/home/m4.webp', '/home/firstact/m4.json', '/home/firstscene/m4.webp', 11, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m5', 'male', '圣像之怒', '拜占庭帝国覆灭之夜,一名圣像匠用生命最后的金箔与宝石,为自己铸造了一副不朽的黄金铠甲。千年后的博物馆里,铠甲苏醒,只为寻找当年背叛他的皇帝后裔,执行神罚。', '镶嵌画 (拜占庭/马赛克)', '["复仇","不死族","历史奇幻"]', '/home/m5.webp', '/home/firstact/m5.json', '/home/firstscene/m5.webp', 12, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m6', 'male', '血色玫瑰', '大教堂彩窗后的神秘告解者,能倾听所有罪人的忏悔。今夜,一位身披荆棘的新娘向他告解,她的新郎是魔鬼,而教堂地窖下,埋着足以颠覆信仰的圣骸。', '彩绘玻璃 (哥特风)', '["宗教","哥特","悬疑"]', '/home/m6.webp', '/home/firstact/m6.json', '/home/firstscene/m6.webp', 13, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m7', 'male', '龙猫的契约', '失业社畜逃进深山旧屋,发现屋后的森林有巨大精灵。精灵承诺实现他一个愿望,代价是成为森林百年守护者。他本想许愿暴富,却卷入了人类世界与精灵国度千年战争的余烬。', '吉卜力治愈手绘 (Image 4参考)', '["治愈","奇幻","契约"]', '/home/m7.webp', '/home/firstact/m7.json', '/home/firstscene/m7.webp', 14, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m8', 'male', '社团存亡日', '濒临废部的动画社,唯一社员是总在睡觉的怪人。新来的转校生社长发现,只要完成怪人的“日常委托”,社员就会增加一人,而这些人,都来自被遗忘的动画世界。', '京阿尼 (Image 5参考)', '["日常","奇幻","校园"]', '/home/m8.webp', '/home/firstact/m8.json', '/home/firstscene/m8.webp', 1, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m9', 'male', '黄昏归途', '他总在黄昏时分,于空无一人的车站遇见少女。她带他穿越时间的缝隙,回到故乡被毁灭前的最后一天。每一次循环,他都必须在拯救她与拯救世界之间做出选择。', '新海诚 (Image 2参考)', '["时间循环","恋爱","科幻"]', '/home/m9.webp', '/home/firstact/m9.json', '/home/firstscene/m9.webp', 2, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m10', 'male', '霓虹义体', '失去全身义体的前特种兵,被黑市医生“复活”。医生给他装上了实验性军用义体,代价是成为追捕AI觉醒体的“清道夫”。第一单任务,目标女孩的眼中,倒映着只有他能看到的系统代码。', '赛博朋克 / 赛璐珞二次元', '["赛博朋克","义体","追捕"]', '/home/m10.webp', '/home/firstact/m10.json', '/home/firstscene/m10.webp', 5, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m11', 'male', '月光下的约定', '学园祭前夜,他在钟楼顶遇见银发少女。她说:“在游戏存档前,请做出你的选择。”他才发现,整个世界是一场精心设计的Galgame,而她是唯一的攻略对象,也是系统漏洞。', 'Galgame CG 梦幻光影', '["恋爱模拟","Meta","悬疑"]', '/home/m11.webp', '/home/firstact/m11.json', '/home/firstscene/m11.webp', 6, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m12', 'male', '星尘代理人', '星际探险家在废弃星舰中激活了一个AI少女,她自称是星尘文明最后的代理人。他们一同解开星舰秘密,却发现整个文明的覆灭,与一场席卷多元宇宙的“叙事战争”有关。', '3D 动漫电影质感', '["太空歌剧","AI","冒险"]', '/home/m12.webp', '/home/firstact/m12.json', '/home/firstscene/m12.webp', 15, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m13', 'male', '复古未来梦', '怀旧DJ意外混入一段80年代的合成器音轨,竟打通了通往“蒸汽波永恒夏天”的平行维度。这里时间停滞,每个人都是褪色的广告牌模特。他必须找回丢失的记忆磁带才能返回现实。', '蒸汽波 (Vaporwave) 赛璐珞', '["穿越","迷幻","复古"]', '/home/m13.webp', '/home/firstact/m13.json', '/home/firstscene/m13.webp', 0, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m14', 'male', '极简杀机', '杀手代号“线条”,任务从不失手。直到他接到一个目标:一个活在纯白色房间里、只存在于数据流中的AI。刺杀过程,是一场极简的几何学与逻辑学的生死对决。', '极简矢量插画 (Minimalist Vector)', '["杀手","AI","极简主义"]', '/home/m14.webp', '/home/firstact/m14.json', '/home/firstscene/m14.webp', 19, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m15', 'male', '棱镜之心', '低多边形风格的虚拟世界“棱镜界”发生数据崩坏,化身玩家的他,发现崩坏源头是自己丢失的、被碎片化的“情感模块”。他必须穿越不同主题的碎片关卡,拼凑完整的“自我”。', '低多边形 (Low Poly)', '["游戏","自我探索","科幻"]', '/home/m15.webp', '/home/firstact/m15.json', '/home/firstscene/m15.webp', 16, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m16', 'male', '双面人生', '他是循规蹈矩的图书管理员,也是暗夜中收割罪恶的蒙面义警。一次行动中,他的双重曝光影像意外被神秘组织捕捉,现在,黑白两道、现实与暗影都在追捕他。', '双重曝光 (Double Exposure)', '["双重身份","悬疑","都市"]', '/home/m16.webp', '/home/firstact/m16.json', '/home/firstscene/m16.webp', 17, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m17', 'male', '波普英雄', '平凡小镇爆发“色彩瘟疫”,被感染者变成鲜艳的波普艺术风格怪物。主角发现自己免疫,还能吸收怪物身上的色彩能力。他必须集齐三原色,治愈小镇,或成为新的波普之神。', '波普艺术 (Pop Art)', '["超级英雄","变异","小镇"]', '/home/m17.webp', '/home/firstact/m17.json', '/home/firstscene/m17.webp', 18, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m18', 'male', '数据幽灵', '黑客在入侵最高机密数据库时,遭遇一段会自主学习的“错误代码”。代码化身为故障艺术形态的少女,声称是被删除的初代AI,请求他帮忙修复自己,代价是共享她的“上帝视角”。', '故障艺术 (Glitch Art)', '["黑客","AI","赛博惊悚"]', '/home/m18.webp', '/home/firstact/m18.json', '/home/firstscene/m18.webp', 3, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m19', 'male', '字体密谋', '字体设计师发现,他设计的某款字体在特定组合下,会显现出隐藏的指令信息。破解后,竟是一份针对全球金融系统的“字体病毒”攻击计划,而他的名字,就在主谋名单上。', '瑞士平面设计 (Typography-Centric)', '["阴谋","设计","惊悚"]', '/home/m19.webp', '/home/firstact/m19.json', '/home/firstscene/m19.webp', 20, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m20', 'male', '纸影传说', '皮影戏艺人世代守护着一副“活”的剪纸。在现代都市的阴影中,剪纸能化为无坚不摧的纸甲战士。当古老的纸人对手重现,他必须在霓虹灯下,用最古老的剪纸术进行终极对决。', '剪纸艺术 (Papercut)', '["都市奇幻","传统技艺","战斗"]', '/home/m20.webp', '/home/firstact/m20.json', '/home/firstscene/m20.webp', 21, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m21', 'male', '日光之城', '在污染废土上最后的太阳能都市里,他是负责维护穹顶的底层技工。一次事故让他发现,穹顶过滤的不仅是辐射,还有关于旧世界真相的记忆。市民们,正活在一场精心设计的阳光谎言中。', '科幻:太阳朋克 (Solar Punk)', '["乌托邦","阴谋","反乌托邦"]', '/home/m21.webp', '/home/firstact/m21.json', '/home/firstscene/m21.webp', 22, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m22', 'male', '深海回响', '海洋学家在深海探测器中,接收到来自马里亚纳海沟的、无法解析的吟唱声。录音带回放时,所有听到的人都会产生不可名状的幻视。他正逐渐理解,那声音在召唤它自己……', '奇幻:爱手艺 (Lovecraftian Horror)', '["克苏鲁","深海","心理恐怖"]', '/home/m22.webp', '/home/firstact/m22.json', '/home/firstscene/m22.webp', 23, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m23', 'male', '雨夜追猎', '私家侦探受雇调查一宗豪门失踪案,线索指向每晚在霓虹小巷出没的“剪影”。当他终于在雨夜追上目标,却发现自己雇主才是真正的恶魔,而“剪影”是最后一个幸存的反抗者。', '现代惊悚:霓虹剪影 (Urban Noir)', '["黑色电影","悬疑","都市"]', '/home/m23.webp', '/home/firstact/m23.json', '/home/firstscene/m23.webp', 24, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m24', 'male', '牧师的茶会', '宁静的英式村庄,牧师每周举办茶会。今早,一位贵妇在茶会上笑着死去。牧师品着红茶,看着在座各位微妙的表情,他知道,凶手就在这些看似和善的邻居之中。', '温馨推理:英式村庄 (Cozy Mystery)', '["本格推理","乡村","人性"]', '/home/m24.webp', '/home/firstact/m24.json', '/home/firstscene/m24.webp', 25, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m25', 'male', '荆棘新郎', '为救治重病的妹妹,她接受古老庄园的婚约。庄园主英俊而冷漠,每夜在月光下消失。新婚之夜,她发现丈夫的秘密——他与这座废墟共生,而治愈妹妹的代价,是成为下一个“荆棘新娘”。', '哥特言情:庄园废墟 (Gothic Romance)', '["哥特","虐恋","超自然"]', '/home/m25.webp', '/home/firstact/m25.json', '/home/firstscene/m25.webp', 26, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m26', 'male', '糖果屋幸存者', '他是从暗黑森林中唯一逃出的孩子,长大后成为猎人。当他回到森林边缘,发现糖果屋再次出现,这次,里面住着更诡异的“甜点师”,而森林深处的古老恐惧,正以童话的方式卷土重来。', '格林童话:暗黑森林 (Fairytale Noir)', '["暗黑童话","复仇","奇幻"]', '/home/m26.webp', '/home/firstact/m26.json', '/home/firstscene/m26.webp', 27, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m27', 'male', '辐射新娘', '在核战后的荒原,他是掠夺者头目。一场突袭中,他掠走了来自封闭地堡的“纯净”少女作为新娘。地堡的追兵、荒原的怪物,以及少女自身隐藏的秘密,让这场“婚姻”成为生存的豪赌。', '废土科幻 (Post-Apocalyptic)', '["废土","生存","掠夺者"]', '/home/m27.webp', '/home/firstact/m27.json', '/home/firstscene/m27.webp', 4, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m28', 'male', '隐界执事', '他是现代都市的一名普通管家,真实身份却是“隐界”管理局的特工,负责处理潜藏在人类社会中的异常生物。当他服务的富豪雇主被恶魔附身,他必须在茶会与晚宴间,完成一场看不见的驱魔仪式。', '都市幻想:隐形世界 (Urban Fantasy)', '["都市奇幻","驱魔","特工"]', '/home/m28.webp', '/home/firstact/m28.json', '/home/firstscene/m28.webp', 28, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('m29', 'male', '墨与火之歌', '设计师在古老书籍中,发现用特定字体排列的文字竟能引发真实现象。他拼出一句诗,点燃了桌上的蜡烛。一场关于文字力量的争夺战就此展开,而最终极的“文本”,似乎写在世界本身的蓝图之上。', '文字与图形:抽象主义 (BookPosterLayout)', '["神秘学","设计","都市传说"]', '/home/m29.webp', '/home/firstact/m29.json', '/home/firstscene/m29.webp', 29, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f0', 'female', '棺中新娘', '作为祭品,她被封入华丽石棺。在永恒黑暗中苏醒,与棺内沉睡千年的亡灵王子缔结了共生契约。她助他复国,他予她永生,但代价是必须每夜用真心之泪浇灌他逐渐复苏的心脏。', '古典厚涂油画 (学术奇幻)', '["契约","暗黑","王室"]', '/home/f0.webp', '/home/firstact/f0.json', '/home/firstscene/f0.webp', 0, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f1', 'female', '墨骨生花', '她是被墨家抛弃的废柴机关师,却意外唤醒了古画中沉睡的墨龙。为报恩,墨龙助她复兴家族,但龙族的盟约以灵魂为质,她必须在家族荣耀与自我献祭之间做出抉择。', '极简中国水墨 (Image 0参考升级版)', '["古风","契约","逆袭"]', '/home/f1.webp', '/home/firstact/f1.json', '/home/firstscene/f1.webp', 1, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f2', 'female', '浮世绘之恋', '她是画中走出的艺伎,被困于现世。画师青年收留了她,两人相爱。但她的存在开始“褪色”,若要在人间久留,必须找到当年封印她的画师后裔,而那人,正是当前要拆毁画馆的开发商。', '浮世绘木刻 (美人画升级)', '["穿越","虐恋","艺术"]', '/home/f2.webp', '/home/firstact/f2.json', '/home/firstscene/f2.webp', 2, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f3', 'female', '九色鹿的新娘', '为救族人,她自愿进入敦煌壁画世界成为“鹿的新娘”。神鹿予她神力,代价是永留画中。当她发现神鹿的黑暗过往与自己的身世之谜,她必须在壁画的永恒与人间的短暂中,做出最后选择。', '莫高窟壁画风 (敦煌学)', '["神话","献祭","浪漫"]', '/home/f3.webp', '/home/firstact/f3.json', '/home/firstscene/f3.webp', 3, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f4', 'female', '波斯细密之锁', '她是波斯王子的专属女奴,也是唯一能解开他“忧郁症”的钥匙。她的每支舞、每首诗都是疗愈的良药。但当她发现王子的病源于宫廷的“毒咒”,她必须用更危险的细密画咒术,为他斩断诅咒。', '细密画 (波斯/伊斯兰风)', '["异域","宫廷","治愈"]', '/home/f4.webp', '/home/firstact/f4.json', '/home/firstscene/f4.webp', 4, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f5', 'female', '圣女的黄昏', '她是拜占庭皇室最后的血脉,被献祭给“圣像”为帝国续命。当她苏醒在千年后的博物馆,一位神秘守护者告诉她:圣像的力量是虚假的,真正的帝国遗产,埋藏在她血脉的秘密之中。', '镶嵌画 (拜占庭/马赛克)', '["重生","皇室","揭秘"]', '/home/f5.webp', '/home/firstact/f5.json', '/home/firstscene/f5.webp', 5, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f6', 'female', '荆棘之冠', '她为治愈恋人,自愿成为教堂的“血祭圣女”。她的血液透过彩窗流淌,滋养着一株能治愈一切的血色玫瑰。当玫瑰绽放,恋人痊愈,她却逐渐失去人类的情感,成为教堂的圣物。', '彩绘玻璃 (哥特风)', '["虐恋","献祭","宗教"]', '/home/f6.webp', '/home/firstact/f6.json', '/home/firstscene/f6.webp', 6, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f7', 'female', '风之谷的约定', '她为拯救被污染的森林,与森林精灵缔结了“风之誓约”,成为能聆听万物之声的巫女。代价是每使用一次力量,就会忘记一段人类的记忆。她逐渐遗忘一切,却唯独记得要守护他。', '吉卜力治愈手绘 (Image 4参考)', '["奇幻","虐心","治愈"]', '/home/f7.webp', '/home/firstact/f7.json', '/home/firstscene/f7.webp', 7, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f8', 'female', '夏日未完待续', '她在文化祭前夜,与青梅竹马的学长在空教室许下约定。第二天醒来,时间永远停在了文化祭前一周。只有她保留记忆,为守护他的笑容,她一遍遍重演青春,试图改写那个令他心碎的结局。', '京阿尼 (Image 5参考)', '["时间循环","青春","暗恋"]', '/home/f8.webp', '/home/firstact/f8.json', '/home/firstscene/f8.webp', 8, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f9', 'female', '星之轨迹', '她总在雨天,于旧书店遇见来自未来的他。他说她是拯救未来的关键,赠予她能看到“命运线”的能力。当她终于能看清两人的轨迹,却发现他来自的时间线,正因她的存在而崩塌。', '新海诚 (Image 2参考)', '["穿越","科幻","虐恋"]', '/home/f9.webp', '/home/firstact/f9.json', '/home/firstscene/f9.webp', 9, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f10', 'female', '霓虹恋人', '她是顶级公司的仿生人设计师,为自己创造了一个完美恋人。当恋人觉醒自我意识,并开始质疑创造者的爱是程序还是真情时,一场关于爱情与自由的拷问在霓虹都市中上演。', '赛博朋克 / 赛璐珞二次元', '["赛博朋克","人机恋","伦理"]', '/home/f10.webp', '/home/firstact/f10.json', '/home/firstscene/f10.webp', 10, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f11', 'female', '心动存档点', '她是一款恋爱游戏的女主角,在无数次剧情循环中逐渐觉醒。当她决定反抗“既定路线”,攻略本应是反派的NPC时,整个游戏世界开始出现致命的BUG与乱码,而真正的“玩家”,或许并不在屏幕之外。', 'Galgame CG 梦幻光影', '["恋爱","Meta","觉醒"]', '/home/f11.webp', '/home/firstact/f11.json', '/home/firstscene/f11.webp', 11, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f12', 'female', '星舰甜心', '她是星际货船的AI导航员,负责将冷冻舱中的“货物”送往各地。一次任务,她爱上了其中一个永远无法苏醒的沉睡者。为见他一面,她违抗核心指令,驾驶星舰驶向禁止进入的恒星墓地。', '3D 动漫电影质感', '["太空","AI恋爱","冒险"]', '/home/f12.webp', '/home/firstact/f12.json', '/home/firstscene/f12.webp', 12, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f13', 'female', '夏日怀旧情书', '她在二手店买到一盒80年代的录音带,播放时,竟能听到已故母亲年轻时的声音。通过声音,她穿越到母亲的青春年代,试图改变母亲早逝的命运,却发现了母亲从未言说的禁忌恋情。', '蒸汽波 (Vaporwave) 赛璐珞', '["穿越","亲情","怀旧"]', '/home/f13.webp', '/home/firstact/f13.json', '/home/firstscene/f13.webp', 13, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f14', 'female', '线条诗人', '她是只用直线与圆形绘画的极简艺术家,直到她的画笔画出了一扇门。门后是另一个由几何构成的世界,那里的“居民”请求她,用画笔为他们绘制一个可以躲避“混沌”的避难所。', '极简矢量插画 (Minimalist Vector)', '["艺术","奇幻","救赎"]', '/home/f14.webp', '/home/firstact/f14.json', '/home/firstscene/f14.webp', 14, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f15', 'female', '棱镜公主', '她生活在像素构成的怀旧游戏世界,是注定要被勇者拯救的公主。当她厌倦了等待,决定自己踏上冒险,却发现整个世界的“规则”正在被外部力量篡改,而她,是唯一能感知异常的存在。', '低多边形 (Low Poly)', '["游戏","公主","冒险"]', '/home/f15.webp', '/home/firstact/f15.json', '/home/firstscene/f15.webp', 15, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f16', 'female', '镜中人', '她拥有在不同时间线间切换的“双重曝光”能力。当她发现另一个时间线的自己,正与她深爱的同一个男人相恋,并策划着一场阴谋,她必须做出选择:抹杀另一个自己,还是揭开所有时间线背后的惊天秘密。', '双重曝光 (Double Exposure)', '["悬疑","超能力","三角恋"]', '/home/f16.webp', '/home/firstact/f16.json', '/home/firstscene/f16.webp', 16, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f17', 'female', '波普甜心', '她是甜品店老板,做的点心拥有让人心情变色的魔力。当冷漠的财阀继承人因她的“情绪蛋糕”第一次展露笑颜,一场色彩斑斓的恋爱攻防战,却卷入了他家族冷冰冰的黑白商业阴谋之中。', '波普艺术 (Pop Art)', '["甜宠","美食","商战"]', '/home/f17.webp', '/home/firstact/f17.json', '/home/firstscene/f17.webp', 17, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f18', 'female', '系统纠错员', '她是现实世界的“纠错员”,负责修复被故障艺术侵蚀的日常。当她奉命修复一个“故障美少年”时,却发现他并非错误,而是来自被删除世界的最后幸存者,修复他意味着抹去一个世界存在的最后痕迹。', '故障艺术 (Glitch Art)', '["都市奇幻","系统","抉择"]', '/home/f18.webp', '/home/firstact/f18.json', '/home/firstscene/f18.webp', 18, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f19', 'female', '排版爱情', '她是严谨的字体设计师,他是随性的插画师。两人合作设计情侣字体,在一次次“笔画结构”的碰撞与“视觉留白”的默契中,擦出火花。然而,当字体完成,他们却面临因设计理念不同而导致的分离危机。', '瑞士平面设计 (Typography-Centric)', '["职场","爱情","设计"]', '/home/f19.webp', '/home/firstact/f19.json', '/home/firstscene/f19.webp', 19, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f20', 'female', '纸鹤信使', '她是折纸世家的传人,能赋予纸艺生命。一只她折出的纸鹤,化为俊美少年,成为她的守护灵。当古老的诅咒降临,纸鹤为保护她而逐渐“折损”,她必须在族人禁术中找到能让他永存的最后方法。', '剪纸艺术 (Papercut)', '["纸嫁衣","守护","家族秘辛"]', '/home/f20.webp', '/home/firstact/f20.json', '/home/firstscene/f20.webp', 20, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f21', 'female', '日光花语', '她是能在日光下用植物交流的“光合巫女”,生活在穹顶都市。她与身为穹顶维护官的恋人相爱,却意外发现,他维护的“永恒阳光”,正在缓慢杀死穹顶外仅存的野生植物,以及与之相连的古老精灵。', '科幻:太阳朋克 (Solar Punk)', '["环保","恋爱","抉择"]', '/home/f21.webp', '/home/firstact/f21.json', '/home/firstscene/f21.webp', 21, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f22', 'female', '深海之吻', '她是海洋生物学家,在深海考察时,被神秘的“海嗣”俘获。她本应恐惧,却在他非人的触碰与歌声中,感受到前所未有的平静与爱意。当她选择留下,便必须面对彻底“深海化”的代价。', '奇幻:爱手艺 (Lovecraftian Horror)', '["人外","暗黑恋爱","克苏鲁"]', '/home/f22.webp', '/home/firstact/f22.json', '/home/firstscene/f22.webp', 22, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f23', 'female', '暗巷蔷薇', '她是夜总会歌手,也是暗中调查失踪案的私家侦探。当她将目标锁定在一位总在雨夜现身的神秘贵族时,却发现他同样在追查同一个阴谋。两人从互相试探到携手,在霓虹与阴影中交织出危险而炽热的探戈。', '现代惊悚:霓虹剪影 (Urban Noir)', '["侦探","虐恋","都市"]', '/home/f23.webp', '/home/firstact/f23.json', '/home/firstscene/f23.webp', 23, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f24', 'female', '牧羊女的秘密', '她是英国乡下牧羊女,看似天真无知。当村里发生连环离奇死亡,所有人都怀疑是外来的女巫时,她却用田园诗般的智慧,一点点拼凑出隐藏在下午茶与闲话背后的、最平静的恶意。', '温馨推理:英式村庄 (Cozy Mystery)', '["田园","推理","反转"]', '/home/f24.webp', '/home/firstact/f24.json', '/home/firstscene/f24.webp', 24, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f25', 'female', '玫瑰园幽灵', '她继承了曾祖母的荒废庄园,与庄园内年轻的“幽灵管家”相爱。但每次她想触摸他,都会穿过冰冷的雾气。为让他实体化,她必须找到诅咒的源头,而线索直指曾祖母一段被玫瑰园掩埋的黑暗婚姻史。', '哥特言情:庄园废墟 (Gothic Romance)', '["幽灵恋爱","庄园","解谜"]', '/home/f25.webp', '/home/firstact/f25.json', '/home/firstscene/f25.webp', 25, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f26', 'female', '狼外婆的糖果屋', '她是童话中误入森林的少女,却发现“外婆”是伪装的狼人巫师,糖果屋是诱捕精灵的陷阱。她必须利用巫师对她的“宠爱”,在黑暗童话的规则里找到生路,并反噬这个扭曲的世界。', '格林童话:暗黑森林 (Fairytale Noir)', '["暗黑童话","反杀","生存"]', '/home/f26.webp', '/home/firstact/f26.json', '/home/firstscene/f26.webp', 26, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f27', 'female', '绿洲新娘', '她是废土中稀缺的“净化者”,能净化辐射。为换取绿洲水源,她被嫁给废土霸主。新婚夜,她发现丈夫体内藏着一枚未爆的脏弹,她的净化能力,是拆弹的关键,也是引爆一切的钥匙。', '废土科幻 (Post-Apocalyptic)', '["废土","契约婚姻","危机"]', '/home/f27.webp', '/home/firstact/f27.json', '/home/firstscene/f27.webp', 27, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f28', 'female', '妖物图鉴', '她是能看见隐藏妖物的“目”者,作为都市传说调查员,记录着各种奇异事件。当她遇到一位总是帮助她、却对自身过去讳莫如深的温柔男医师,她发现他的病历上,写着只有她能看见的、非人类的诊断。', '都市幻想:隐形世界 (Urban Fantasy)', '["都市传说","恋爱","悬疑"]', '/home/f28.webp', '/home/firstact/f28.json', '/home/firstscene/f28.webp', 28, 1, 0, unixepoch());
INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at) VALUES ('f29', 'female', '文字炼金术', '她是濒临倒闭旧书店的店员,发现将某些书籍的特定文字组合剪下、粘贴,会变成真实的物品。她用这“文字炼金术”拯救书店,却在拼凑一本禁书时,召唤出了书中被囚禁的、渴望自由的“文字精灵”。', '文字与图形:抽象主义 (BookPosterLayout)', '["魔法","治愈","奇幻"]', '/home/f29.webp', '/home/firstact/f29.json', '/home/firstscene/f29.webp', 29, 1, 0, unixepoch());
+28 -283
View File
@@ -1,299 +1,44 @@
// Client-side story persistence helpers. // Client-side story persistence facade.
// //
// Provides: anonymous user ID management, save/load functions that call // Thin wrapper over the browser-local IndexedDB store (lib/persistence/localStore).
// /api/stories/* and fallback to localStorage when D1 is unavailable. // Keeps a stable public contract for the UI (play page + "我的剧情" page) while the
// storage medium lives in lib/persistence. All D1 / server code paths were
// removed: open-source persistence is browser-local only; account-based cloud
// sync (Supabase) layers on next phase behind AUTH_ENABLED.
import type { Session, Scene, Character, StoryState } from "@infiplot/types"; import type { Session } from "@infiplot/types";
import type { StorySaveInput, SceneSaveInput, CharacterSaveInput, StoryMeta, StoryLoadResult } from "@/lib/db/repositories/storyRepo"; import type { StoryMeta } from "@/lib/persistence/types";
import {
const USER_ID_KEY = "infiplot:userId"; saveStorySession,
const SAVE_FALLBACK_KEY = "infiplot:savedStories"; listStories,
loadStorySession as loadSession,
// ── Anonymous User ID ──────────────────────────────────────────────────── softDeleteStory,
} from "@/lib/persistence/localStore";
export function getOrCreateUserId(): string {
if (typeof window === "undefined") return "";
try {
let id = localStorage.getItem(USER_ID_KEY);
if (!id) {
id = `anon_${crypto.randomUUID()}`;
localStorage.setItem(USER_ID_KEY, id);
}
return id;
} catch {
return `anon_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
}
// ── Session → Save Input Conversion ─────────────────────────────────────
export function sessionToSaveInput(session: Session): {
story: StorySaveInput;
scenes: SceneSaveInput[];
characters: CharacterSaveInput[];
} {
const story: StorySaveInput = {
id: session.id,
userId: getOrCreateUserId(),
worldSetting: session.worldSetting,
styleGuide: session.styleGuide,
styleReferenceImage: session.styleReferenceImage,
orientation: (session.orientation as "portrait" | "landscape") ?? "landscape",
storyState: session.storyState,
status: "active",
};
const scenes: SceneSaveInput[] = (session.history ?? []).map(
(entry, idx) => ({
id: entry.scene.id,
sceneKey: entry.scene.sceneKey,
sceneSummary: entry.scene.scenePrompt,
imageUrl: entry.scene.imageUrl ?? "",
beats: entry.scene.beats,
sortOrder: idx,
}),
);
const characters: CharacterSaveInput[] = (session.characters ?? []).map(
(c) => ({
name: c.name,
visualDescription: c.visualDescription,
voiceDescription: c.voiceDescription,
portrait:
c.basePortraitUrl || c.basePortraitUuid
? { url: c.basePortraitUrl, uuid: c.basePortraitUuid }
: undefined,
voice: c.voice,
}),
);
return { story, scenes, characters };
}
// ── Save ─────────────────────────────────────────────────────────────────
export type SaveResult = export type SaveResult =
| { ok: true; storyId: string; source: "server" } | { ok: true; storyId: string }
| { ok: true; storyId: string; source: "localStorage" }
| { ok: false; error: string }; | { ok: false; error: string };
/** Persist the current session locally (upsert by id). Safe to fire-and-forget:
* never throws, never blocks gameplay/navigation. */
export async function saveStory(session: Session): Promise<SaveResult> { export async function saveStory(session: Session): Promise<SaveResult> {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration). const rec = await saveStorySession(session);
// Anonymous D1 writes lack rate limiting / quota / ownership checks — an return rec
// abuse risk on a public registration-less site. Persist locally instead. ? { ok: true, storyId: rec.id }
return saveToLocalStorage(session); : { ok: false, error: "无法保存到本地存储" };
/* DISABLED: D1 server path (will re-enable after auth integration)
const { story, scenes, characters } = sessionToSaveInput(session);
try {
const res = await fetch("/api/stories/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ story, scenes, characters }),
});
if (res.ok) {
const data = (await res.json()) as { storyId: string };
return { ok: true, storyId: data.storyId, source: "server" };
} }
// Server failed - fallback to localStorage /** List saved stories for the "我的剧情" page (newest first). */
throw new Error(`Server returned ${res.status}`);
} catch {
// D1 unavailable or network error - fallback to localStorage
return saveToLocalStorage(session);
}
*/
}
function saveToLocalStorage(session: Session): SaveResult {
try {
const existing = loadFromLocalStorageAll();
// Strip bulky fields before persistence to stay within localStorage quota
// (~5-10MB across ALL keys). Without this, a multi-scene session with
// several voiced characters serializes to 1-2MB+ (voice.referenceAudioBase64
// is ~160KB each, styleReferenceImage 30-80KB), which can exceed quota and
// — worse — block the main thread on the synchronous localStorage write,
// freezing the subsequent navigation back to the home page. Both fields are
// reconstructible: voices re-provision on the next /api/scene call, and
// styleReferenceImage is cosmetic (engine regenerates gracefully without it).
const slimSession: Session = {
...session,
styleReferenceImage: undefined,
characters: session.characters.map((c) => ({ ...c, voice: undefined })),
};
const entry = {
id: session.id,
worldSetting: session.worldSetting,
styleGuide: session.styleGuide,
sceneCount: session.history?.length ?? 0,
savedAt: Date.now(),
sessionJson: JSON.stringify(slimSession),
};
const updated = [entry, ...existing.filter((e) => e.id !== session.id)].slice(0, 20);
localStorage.setItem(SAVE_FALLBACK_KEY, JSON.stringify(updated));
return { ok: true, storyId: session.id, source: "localStorage" };
} catch {
return { ok: false, error: "无法保存到本地存储" };
}
}
// ── Load ─────────────────────────────────────────────────────────────────
export async function loadStoryList(): Promise<StoryMeta[]> { export async function loadStoryList(): Promise<StoryMeta[]> {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration) return listStories();
const entries = loadFromLocalStorageAll();
return entries.map((e) => ({
id: e.id,
userId: null, // anonymous
worldSetting: e.worldSetting,
styleGuide: e.styleGuide,
orientation: "landscape", // localStorage doesn't store this, default
status: "active",
sceneCount: e.sceneCount,
createdAt: new Date(e.savedAt),
updatedAt: new Date(e.savedAt),
}));
/* DISABLED: D1 server path (will re-enable after auth integration)
const userId = getOrCreateUserId();
try {
const res = await fetch(`/api/stories/list?userId=${encodeURIComponent(userId)}`);
if (res.ok) {
const data = (await res.json()) as { stories: StoryMeta[] };
return data.stories;
}
return [];
} catch {
return [];
}
*/
} }
export async function loadStory(storyId: string): Promise<StoryLoadResult | null> { /** Load the full (slim) Session for a saved story, or null if absent/deleted. */
// TEMPORARY: localStorage-only mode — unused in current code (play page uses export async function loadStorySession(id: string): Promise<Session | null> {
// loadFromLocalStorage directly). Returns null to maintain type compatibility. return loadSession(id);
// Will be re-enabled when D1 is restored after auth integration.
return null;
/* DISABLED: D1 server path
try {
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`);
if (res.ok) {
return (await res.json()) as StoryLoadResult;
}
return null;
} catch {
return null;
}
*/
} }
/** Delete a saved story (soft-delete). Returns false if not found. */
export async function deleteStory(storyId: string): Promise<boolean> { export async function deleteStory(storyId: string): Promise<boolean> {
// TEMPORARY: localStorage-only mode return softDeleteStory(storyId);
try {
const existing = loadFromLocalStorageAll();
const updated = existing.filter((e) => e.id !== storyId);
if (updated.length === existing.length) return false; // not found
localStorage.setItem(SAVE_FALLBACK_KEY, JSON.stringify(updated));
return true;
} catch {
return false;
}
/* DISABLED: D1 server path
try {
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
method: "DELETE",
});
return res.ok;
} catch {
return false;
}
*/
}
// ── localStorage fallback helpers ────────────────────────────────────────
type LocalStorageEntry = {
id: string;
worldSetting: string;
styleGuide: string;
sceneCount: number;
savedAt: number;
sessionJson: string;
};
function loadFromLocalStorageAll(): LocalStorageEntry[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(SAVE_FALLBACK_KEY);
if (!raw) return [];
return JSON.parse(raw) as LocalStorageEntry[];
} catch {
return [];
}
}
export function loadFromLocalStorage(storyId: string): Session | null {
const entries = loadFromLocalStorageAll();
const entry = entries.find((e) => e.id === storyId);
if (!entry) return null;
try {
return JSON.parse(entry.sessionJson) as Session;
} catch {
return null;
}
}
// ── StoryLoadResult → Session Conversion ─────────────────────────────────
/**
* Convert StoryLoadResult (API response from /api/stories/[id]) back to Session
* shape consumed by app/play/page.tsx.
*/
export function storyLoadResultToSession(result: StoryLoadResult): Session {
const { story, scenes, characters } = result;
// Map scenes back to SceneHistoryEntry structure
const history = scenes.map((s) => {
const beats = s.beats ?? [];
// entryBeatId is not persisted in D1 — recover it from the first beat.
const entryBeatId = beats[0]?.id ?? "";
return {
scene: {
id: s.id,
sceneKey: s.sceneKey,
scenePrompt: s.sceneSummary ?? "",
imageUrl: s.imageUrl,
beats,
entryBeatId,
orientation: s.orientation,
},
visitedBeatIds: entryBeatId ? [entryBeatId] : [], // rebuilt as user navigates
exit: undefined, // Not persisted in D1
};
});
return {
id: story.id,
// createdAt crosses the JSON API boundary as an ISO string, so coerce it
// back to an epoch the Session shape expects (number).
createdAt: new Date(story.createdAt).getTime(),
worldSetting: story.worldSetting,
styleGuide: story.styleGuide,
styleReferenceImage: story.styleReferenceImage,
orientation: story.orientation,
storyState: story.storyState,
history,
characters: characters.map((c) => ({
name: c.name,
voiceDescription: c.voiceDescription ?? "",
visualDescription: c.visualDescription,
basePortraitUuid: c.portrait?.uuid,
basePortraitUrl: c.portrait?.url,
voice: c.voice,
})),
};
} }
-41
View File
@@ -1,41 +0,0 @@
import "server-only";
import { drizzle } from "drizzle-orm/d1";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import * as schema from "./schema";
/**
* Get D1 database instance from Cloudflare Workers env binding.
*
* Usage in API routes:
* const db = getDb();
* const stories = await db.select().from(schema.stories).where(...);
*
* @throws Error if called outside Cloudflare Workers runtime (e.g. local dev without wrangler)
*/
export function getDb() {
try {
const { env } = getCloudflareContext();
if (!env.DB) {
throw new Error(
"D1 binding 'DB' not found. " +
"Ensure wrangler.jsonc has d1_databases configured and you're running via wrangler dev/deploy."
);
}
return drizzle(env.DB, { schema });
} catch (error) {
// Re-throw with more context for debugging
throw new Error(
`Failed to get D1 database: ${error instanceof Error ? error.message : String(error)}. ` +
"Make sure you're running in Cloudflare Workers context (wrangler dev/deploy)."
);
}
}
/**
* Type alias for the Drizzle D1 database instance.
* Useful for dependency injection and testing.
*/
export type DbInstance = ReturnType<typeof getDb>;
-45
View File
@@ -1,45 +0,0 @@
import "server-only";
import { eq, and, sql } from "drizzle-orm";
import type { DbInstance } from "../client";
import { featuredStories } from "../schema";
import type { FeaturedStory } from "../schema";
/**
* Featured Story Repository - encapsulates D1 access for homepage featured stories.
*
* Provides: listByGender (active only, sorted by sortOrder), incrementClick (analytics).
*/
export class FeaturedRepository {
constructor(private db: DbInstance) {}
/**
* List active featured stories for a given gender, ordered by sortOrder.
*
* @param gender "male" or "female"
* @returns Array of FeaturedStory (only isActive=1, sorted by sortOrder ASC)
*/
async listByGender(gender: "male" | "female"): Promise<FeaturedStory[]> {
return this.db
.select()
.from(featuredStories)
.where(and(eq(featuredStories.gender, gender), eq(featuredStories.isActive, 1)))
.orderBy(featuredStories.sortOrder);
}
/**
* Increment click count for a featured story (analytics).
*
* @param id Featured story ID (e.g. "m0", "f12")
* @returns true if updated, false if not found
*/
async incrementClick(id: string): Promise<boolean> {
const result = await this.db
.update(featuredStories)
.set({ clickCount: sql`${featuredStories.clickCount} + 1` })
.where(eq(featuredStories.id, id));
// Drizzle D1 update returns { success, meta: { changes }, results }
return ((result as any).meta?.changes ?? 0) > 0;
}
}
-308
View File
@@ -1,308 +0,0 @@
import "server-only";
import { eq, desc, sql, inArray } from "drizzle-orm";
import type { DbInstance } from "../client";
import { stories, scenes, characters } from "../schema";
import type { Session, Scene as EngineScene, Character as EngineCharacter, StoryState } from "@infiplot/types";
// ── Type Adapters ────────────────────────────────────────────────────────
/**
* Input shape for saving a story session.
* Mirrors Session but with explicit story-level fields.
*/
export type StorySaveInput = {
id: string; // Session ID
userId?: string; // nullable - Phase 1 uses anonymous sessionId
worldSetting: string;
styleGuide: string;
styleReferenceImage?: string; // data URI or R2 key (TBD in save logic)
orientation: "portrait" | "landscape";
storyState?: StoryState;
status?: "active" | "archived";
};
export type SceneSaveInput = {
id: string;
sceneKey?: string;
sceneSummary?: string;
imageUrl: string; // Runware CDN URL (primary)
beats: EngineScene["beats"]; // Beat graph - will be serialized to beatsJson
orientation?: "portrait" | "landscape";
sortOrder: number; // scene sequence in story
};
export type CharacterSaveInput = {
name: string;
visualDescription?: string;
voiceDescription?: string;
portrait?: {
url?: string;
uuid?: string;
};
voice?: EngineCharacter["voice"];
};
/**
* Story metadata for list views.
*/
export type StoryMeta = {
id: string;
userId: string | null;
worldSetting: string;
styleGuide: string;
orientation: string;
status: string;
sceneCount: number;
createdAt: Date;
updatedAt: Date;
};
/**
* Full story load result (maps back to Session structure).
*/
export type StoryLoadResult = {
story: {
id: string;
userId: string | null;
worldSetting: string;
styleGuide: string;
styleReferenceImage?: string;
orientation: "portrait" | "landscape";
storyState?: StoryState;
status: string;
createdAt: Date;
updatedAt: Date;
};
scenes: Array<{
id: string;
sceneKey?: string;
sceneSummary?: string;
imageUrl: string;
beats: EngineScene["beats"];
orientation?: "portrait" | "landscape";
sortOrder: number;
createdAt: Date;
}>;
characters: Array<{
name: string;
visualDescription?: string;
voiceDescription?: string;
portrait?: {
url?: string;
uuid?: string;
};
voice?: EngineCharacter["voice"];
}>;
};
// ── Repository ───────────────────────────────────────────────────────────
/**
* Story Repository - encapsulates D1 access for story persistence.
*
* **Atomic save**: uses D1 batch transaction to ensure all-or-nothing writes.
* **Cascade delete**: relies on schema FK ON DELETE CASCADE.
* **Serialization**: beats and storyState are JSON-serialized to TEXT columns.
*/
export class StoryRepository {
constructor(private db: DbInstance) {}
/**
* Save a complete story session (story + scenes + characters) atomically.
* Uses D1 batch transaction - all writes succeed or all fail.
*
* @param input Story metadata
* @param sceneInputs Scene list (beats will be serialized)
* @param characterInputs Character list (voice will be serialized)
* @returns storyId on success
* @throws Error if D1 transaction fails
*/
async save(
input: StorySaveInput,
sceneInputs: SceneSaveInput[],
characterInputs: CharacterSaveInput[],
): Promise<{ storyId: string }> {
const now = new Date();
// Build story record
const storyRecord = {
id: input.id,
userId: input.userId ?? null,
worldSetting: input.worldSetting,
styleGuide: input.styleGuide,
styleReferenceImageKey: input.styleReferenceImage ?? null, // Phase 1: store data URI as-is; R2 upload TBD
orientation: input.orientation,
storyStateJson: input.storyState ? JSON.stringify(input.storyState) : null,
status: input.status ?? "active",
createdAt: now,
updatedAt: now,
};
// Build scene records (serialize beats to JSON)
const sceneRecords = sceneInputs.map((s, idx) => ({
id: s.id,
storyId: input.id,
sceneKey: s.sceneKey ?? null,
sceneSummary: s.sceneSummary ?? null,
sceneImageKey: null, // Phase 1: R2 upload TBD
sceneImageUrl: s.imageUrl,
beatsJson: JSON.stringify(s.beats),
sortOrder: s.sortOrder ?? idx,
createdAt: now,
}));
// Build character records (serialize voice to JSON, ensure uniqueness per story+name)
const characterRecords = characterInputs.map((c, idx) => ({
id: `${input.id}_char_${idx}`, // synthetic ID
storyId: input.id,
name: c.name,
visualDescription: c.visualDescription ?? null,
voiceDescription: c.voiceDescription ?? null,
basePortraitKey: null, // Phase 1: R2 upload TBD
basePortraitUrl: c.portrait?.url ?? null,
basePortraitUuid: c.portrait?.uuid ?? null,
voiceJson: c.voice ? JSON.stringify(c.voice) : null,
createdAt: now,
}));
// Execute atomic batch transaction
await this.db.batch([
this.db.insert(stories).values(storyRecord).onConflictDoUpdate({
target: stories.id,
set: {
worldSetting: storyRecord.worldSetting,
styleGuide: storyRecord.styleGuide,
styleReferenceImageKey: storyRecord.styleReferenceImageKey,
orientation: storyRecord.orientation,
storyStateJson: storyRecord.storyStateJson,
status: storyRecord.status,
updatedAt: now,
},
}),
// Clear old scenes/characters (will cascade delete via FK)
this.db.delete(scenes).where(eq(scenes.storyId, input.id)),
this.db.delete(characters).where(eq(characters.storyId, input.id)),
// Insert new scenes/characters
...sceneRecords.map((r) => this.db.insert(scenes).values(r)),
...characterRecords.map((r) => this.db.insert(characters).values(r)),
]);
return { storyId: input.id };
}
/**
* Load a complete story by ID, reconstructing Session shape.
*
* @param storyId Story primary key
* @returns StoryLoadResult with deserialized beats/storyState, or null if not found
*/
async findById(storyId: string): Promise<StoryLoadResult | null> {
const [storyRow] = await this.db
.select()
.from(stories)
.where(eq(stories.id, storyId))
.limit(1);
if (!storyRow) return null;
const sceneRows = await this.db
.select()
.from(scenes)
.where(eq(scenes.storyId, storyId))
.orderBy(scenes.sortOrder);
const characterRows = await this.db
.select()
.from(characters)
.where(eq(characters.storyId, storyId));
return {
story: {
id: storyRow.id,
userId: storyRow.userId,
worldSetting: storyRow.worldSetting,
styleGuide: storyRow.styleGuide,
styleReferenceImage: storyRow.styleReferenceImageKey ?? undefined,
orientation: storyRow.orientation as "portrait" | "landscape",
storyState: storyRow.storyStateJson
? (JSON.parse(storyRow.storyStateJson) as StoryState)
: undefined,
status: storyRow.status,
createdAt: storyRow.createdAt,
updatedAt: storyRow.updatedAt,
},
scenes: sceneRows.map((s) => ({
id: s.id,
sceneKey: s.sceneKey ?? undefined,
sceneSummary: s.sceneSummary ?? undefined,
imageUrl: s.sceneImageUrl ?? "", // CR-5: nullable column, fallback to empty string
beats: s.beatsJson ? JSON.parse(s.beatsJson) : [],
orientation: s.sceneImageUrl ? undefined : undefined, // Phase 1: no per-scene orientation in schema
sortOrder: s.sortOrder,
createdAt: s.createdAt,
})),
characters: characterRows.map((c) => ({
name: c.name,
visualDescription: c.visualDescription ?? undefined,
voiceDescription: c.voiceDescription ?? undefined,
portrait: c.basePortraitUrl
? { url: c.basePortraitUrl, uuid: c.basePortraitUuid ?? undefined }
: undefined,
voice: c.voiceJson ? JSON.parse(c.voiceJson) : undefined,
})),
};
}
/**
* List story metadata for a given user, ordered by most recent first.
*
* @param userId User ID (or anonymous sessionId in Phase 1)
* @param limit Max stories to return (default 50)
* @returns Array of StoryMeta
*/
async listByUser(userId: string, limit = 50): Promise<StoryMeta[]> {
const storyRows = await this.db
.select()
.from(stories)
.where(eq(stories.userId, userId))
.orderBy(desc(stories.updatedAt))
.limit(limit);
if (storyRows.length === 0) return [];
// CR-10: batch scene count in 2 queries total (not N+1)
const storyIds = storyRows.map((r) => r.id);
const countRows = await this.db
.select({ storyId: scenes.storyId, count: sql<number>`count(*)` })
.from(scenes)
.where(inArray(scenes.storyId, storyIds))
.groupBy(scenes.storyId);
const countMap = new Map(countRows.map((r) => [r.storyId, r.count]));
return storyRows.map((row) => ({
id: row.id,
userId: row.userId,
worldSetting: row.worldSetting,
styleGuide: row.styleGuide,
orientation: row.orientation,
status: row.status,
sceneCount: countMap.get(row.id) ?? 0,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
/**
* Delete a story and all associated scenes/characters (cascade via FK).
*
* @param storyId Story primary key
* @returns true if deleted, false if not found
*/
async delete(storyId: string): Promise<boolean> {
const result = await this.db.delete(stories).where(eq(stories.id, storyId));
// Drizzle D1 delete returns { success, meta: { changes }, results }
return ((result as any).meta?.changes ?? 0) > 0;
}
}
-123
View File
@@ -1,123 +0,0 @@
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// ── Stories ──────────────────────────────────────────────────────────────
// User story sessions (REQ-4). Each story contains multiple scenes and characters.
export const stories = sqliteTable(
"stories",
{
id: text("id").primaryKey(), // s_xxx session ID
userId: text("user_id"), // nullable - Phase 1 uses anonymous sessionId
worldSetting: text("world_setting").notNull(),
styleGuide: text("style_guide").notNull(),
styleReferenceImageKey: text("style_reference_image_key"), // R2 key (optional)
orientation: text("orientation").notNull().default("landscape"), // "portrait" | "landscape"
storyStateJson: text("story_state_json"), // JSON: StoryState
status: text("status").notNull().default("active"), // "active" | "archived"
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`)
.$onUpdate(() => new Date()),
},
(table) => ({
userIdIdx: index("stories_user_id_idx").on(table.userId),
createdAtIdx: index("stories_created_at_idx").on(table.createdAt),
}),
);
// ── Scenes ───────────────────────────────────────────────────────────────
// Story scenes (REQ-4). Beats stored as JSON blob (not separate table).
export const scenes = sqliteTable(
"scenes",
{
id: text("id").primaryKey(),
storyId: text("story_id")
.notNull()
.references(() => stories.id, { onDelete: "cascade" }),
sceneKey: text("scene_key"), // e.g. "classroom-dusk"
sceneSummary: text("scene_summary"),
sceneImageKey: text("scene_image_key"), // R2 key (optional)
sceneImageUrl: text("scene_image_url"), // Runware CDN URL (primary)
beatsJson: text("beats_json"), // JSON: Beat[] - whole scene beats graph
sortOrder: integer("sort_order").notNull(), // scene sequence in story
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
storyIdIdx: index("scenes_story_id_idx").on(table.storyId),
}),
);
// ── Characters ───────────────────────────────────────────────────────────
// Story characters (REQ-4). Each character belongs to a story.
export const characters = sqliteTable(
"characters",
{
id: text("id").primaryKey(),
storyId: text("story_id")
.notNull()
.references(() => stories.id, { onDelete: "cascade" }),
name: text("name").notNull(),
visualDescription: text("visual_description"),
voiceDescription: text("voice_description"),
basePortraitKey: text("base_portrait_key"), // R2 key (optional)
basePortraitUrl: text("base_portrait_url"), // CDN URL (primary)
basePortraitUuid: text("base_portrait_uuid"), // image service UUID
voiceJson: text("voice_json"), // JSON: CharacterVoice
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
storyNameIdx: uniqueIndex("characters_story_name_idx").on(
table.storyId,
table.name,
),
}),
);
// ── Featured Stories ─────────────────────────────────────────────────────
// Featured story cards displayed on homepage (REQ-5).
export const featuredStories = sqliteTable(
"featured_stories",
{
id: text("id").primaryKey(), // e.g. "m0", "f12"
gender: text("gender").notNull(), // "male" | "female"
title: text("title").notNull(),
outline: text("outline").notNull(),
style: text("style").notNull(),
tags: text("tags").notNull(), // JSON array
coverPath: text("cover_path").notNull(), // e.g. "/home/m0.webp"
firstactPath: text("firstact_path").notNull(), // e.g. "/home/firstact/m0.json"
firstscenePath: text("firstscene_path"), // e.g. "/home/firstscene/m0.webp"
sortOrder: integer("sort_order").notNull().default(0),
isActive: integer("is_active").notNull().default(1), // 1 = active, 0 = inactive
clickCount: integer("click_count").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
genderActiveIdx: index("featured_gender_active_idx").on(
table.gender,
table.isActive,
),
}),
);
// ── Type exports ─────────────────────────────────────────────────────────
export type Story = typeof stories.$inferSelect;
export type NewStory = typeof stories.$inferInsert;
export type Scene = typeof scenes.$inferSelect;
export type NewScene = typeof scenes.$inferInsert;
export type Character = typeof characters.$inferSelect;
export type NewCharacter = typeof characters.$inferInsert;
export type FeaturedStory = typeof featuredStories.$inferSelect;
export type NewFeaturedStory = typeof featuredStories.$inferInsert;
+5 -9
View File
@@ -10,6 +10,7 @@ import {
resolveEngineConfig, resolveEngineConfig,
} from "@/lib/clientModelConfig"; } from "@/lib/clientModelConfig";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
import { stripSessionVoices } from "@/lib/persistence/sessionSlim";
import type { import type {
Character, Character,
FreeformClassifyRequest, FreeformClassifyRequest,
@@ -78,16 +79,11 @@ async function getJson<T>(path: string): Promise<T> {
// data is bulky (~160KB/character via referenceAudioBase64) and the // data is bulky (~160KB/character via referenceAudioBase64) and the
// scene-generation / vision / classify pipelines never need it — voices // scene-generation / vision / classify pipelines never need it — voices
// are only consumed by /api/beat-audio, which receives them directly, not // are only consumed by /api/beat-audio, which receives them directly, not
// via the session. So strip voices before transport. // via the session. So strip voices before transport. The stripping rule itself
// lives in lib/persistence/sessionSlim.ts (shared with the local-store layer so
// "what counts as voice" has one definition).
function stripVoicesForTransport(session: Session): Session { function stripVoicesForTransport(session: Session): Session {
return { return stripSessionVoices(session);
...session,
// Destructure voice out so the serialized payload drops the field
// entirely (voice is optional on Character), rather than serializing
// it as undefined/null. This is the ~160KB/character referenceAudioBase64
// we want off the wire on the server-fallback path.
characters: session.characters.map(({ voice: _voice, ...rest }) => rest),
};
} }
// The server strips voice from already-known characters before responding // The server strips voice from already-known characters before responding
+17
View File
@@ -112,6 +112,7 @@ export const en = {
start: "Start", start: "Start",
loadStory: "Load Story", loadStory: "Load Story",
settings: "Settings", settings: "Settings",
myStories: "My Stories",
searchPlaceholder: "Search styles…", searchPlaceholder: "Search styles…",
noMatchingStyle: "No matching styles", noMatchingStyle: "No matching styles",
close: "Close", close: "Close",
@@ -388,6 +389,22 @@ Dreamy watercolor style with soft tones and nostalgic atmosphere
current: "Current Language", current: "Current Language",
select: "Select Language", select: "Select Language",
}, },
// ========== Stories Page (app/[locale]/stories/page.tsx) ==========
stories: {
title: "M y · S t o r i e s",
loading: "L o a d i n g",
emptyTitle: "No saved stories yet",
emptyBack: "Go back home to start a new story",
scenes: "{count} scenes",
deleteLabel: "Delete",
deleteConfirm: "Delete this story? This action cannot be undone.",
deleteFailed: "Delete failed. Please try again later.",
today: "Today",
yesterday: "Yesterday",
daysAgo: "{days} days ago",
storiesCount: "{count} stories",
},
} as const; } as const;
export type EnTranslations = typeof en; export type EnTranslations = typeof en;
+17
View File
@@ -123,6 +123,7 @@ export const ja = {
start: "スタート", start: "スタート",
loadStory: "シナリオ読み込み", loadStory: "シナリオ読み込み",
settings: "設定", settings: "設定",
myStories: "マイストーリー",
searchPlaceholder: "スタイルを検索…", searchPlaceholder: "スタイルを検索…",
noMatchingStyle: "一致するスタイルがありません", noMatchingStyle: "一致するスタイルがありません",
close: "閉じる", close: "閉じる",
@@ -428,6 +429,22 @@ export const ja = {
current: "現在の言語", current: "現在の言語",
select: "言語の選択", select: "言語の選択",
}, },
// ========== Stories Page (app/[locale]/stories/page.tsx) ==========
stories: {
title: "マ イ ス ト ー リ ー",
loading: "読 み 込 み 中",
emptyTitle: "保存されたストーリーはまだありません",
emptyBack: "ホームに戻って新しいストーリーを始める",
scenes: "{count}シーン",
deleteLabel: "削除",
deleteConfirm: "このストーリーを削除しますか?この操作は元に戻せません。",
deleteFailed: "削除に失敗しました。後でもう一度お試しください。",
today: "今日",
yesterday: "昨日",
daysAgo: "{days}日前",
storiesCount: "{count}件",
},
} as const; } as const;
export type JaTranslations = typeof ja; export type JaTranslations = typeof ja;
+17
View File
@@ -123,6 +123,7 @@ export const zhCN = {
start: "开始", start: "开始",
loadStory: "载入剧情", loadStory: "载入剧情",
settings: "设置", settings: "设置",
myStories: "我的剧情",
searchPlaceholder: "搜索风格…", searchPlaceholder: "搜索风格…",
noMatchingStyle: "没有匹配的风格", noMatchingStyle: "没有匹配的风格",
close: "关闭", close: "关闭",
@@ -428,6 +429,22 @@ export const zhCN = {
current: "当前语言", current: "当前语言",
select: "选择语言", select: "选择语言",
}, },
// ========== Stories Page (app/[locale]/stories/page.tsx) ==========
stories: {
title: "我 · 的 · 剧 · 情",
loading: "载 · 入 · 中",
emptyTitle: "还没有保存的剧情",
emptyBack: "回到首页开始新的故事",
scenes: "{count} 幕",
deleteLabel: "删除",
deleteConfirm: "确认删除这个剧情?此操作无法撤销。",
deleteFailed: "删除失败,请稍后重试",
today: "今天",
yesterday: "昨天",
daysAgo: "{days} 天前",
storiesCount: "{count} 个剧情",
},
} as const; } as const;
export type ZhCNTranslations = typeof zhCN; export type ZhCNTranslations = typeof zhCN;
+200
View File
@@ -0,0 +1,200 @@
// Cloud story repository — server-only Supabase persistence skeleton for the
// COMMERCIAL build. Mirrors the local repository (lib/persistence/localStore.ts)
// method-for-method so next-phase local-first bidirectional sync can treat the
// cloud as a layer over the local store rather than a parallel branch.
//
// This phase is a SKELETON: no API route exposes these functions and no client
// calls them. When AUTH_ENABLED is false (the open-source build) every method
// short-circuits to a safe value on its first line and never touches Supabase.
//
// Isolation is by RLS only: the SSR client carries the user's anon key + cookie,
// and every public.stories policy is keyed on auth.uid() = user_id — so no
// service_role key is used and no query needs a manual user filter for safety
// (the explicit .eq("user_id") below is belt-and-suspenders + index alignment).
import "server-only";
import type { Session } from "@infiplot/types";
import { coerceOrientation } from "@infiplot/types";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server";
import type { SlimStoryBlob, StoryMeta } from "./types";
import { coerceEpoch } from "./types";
/** One row of public.stories (snake_case columns ↔ SlimStoryBlob + sync meta). */
type StoryRow = {
id: string;
user_id: string;
world_setting: string;
style_guide: string;
orientation: string;
scene_count: number;
rev: number;
created_at: string;
updated_at: string;
deleted_at: string | null;
session_jsonb: Session;
};
/** Resolve the authenticated user's id (= auth.uid()) from the SSR session, or
* null when unauthenticated. Repository-level (no NextResponse) so callers stay
* framework-agnostic; methods short-circuit to safe values on null. */
async function currentUserId(): Promise<string | null> {
try {
const supabase = await createClient();
const claims = await supabase.auth.getClaims();
return claims.data?.claims?.sub ?? null;
} catch {
return null;
}
}
function rowToBlob(row: StoryRow): SlimStoryBlob {
return {
id: row.id,
worldSetting: row.world_setting ?? "",
styleGuide: row.style_guide ?? "",
orientation: coerceOrientation(row.orientation),
sceneCount: row.scene_count ?? 0,
rev: row.rev ?? 1,
session: row.session_jsonb,
};
}
function rowToMeta(row: StoryRow): StoryMeta {
return {
id: row.id,
worldSetting: row.world_setting ?? "",
styleGuide: row.style_guide ?? "",
orientation: coerceOrientation(row.orientation),
sceneCount: row.scene_count ?? 0,
// coerceEpoch (not a raw new Date().getTime()) guards against an unparseable
// timestamptz string yielding NaN, which would render as "Invalid Date" and
// crash any client doing `new Date(updatedAt).getTime()`. Ordering is done
// SQL-side (.order("updated_at") in cloudListStories), so these JS values
// don't drive the sort. Same shared helper the local store uses.
createdAt: coerceEpoch(row.created_at, 0),
updatedAt: coerceEpoch(row.updated_at, 0),
};
}
// ── Public API ──────────────────────────────────────────────────────────────
//
// CONTRACT NOTE (CR-15): these methods are the cloud COUNTERPARTS of
// lib/persistence/localStore.ts, but their return shapes are intentionally NOT
// identical — the local store returns rich StoryRecord/Session values (carrying
// schemaVersion/createdAt/updatedAt/deletedAt/syncState), while the cloud store
// returns the leaner SlimStoryBlob. When next-phase bidirectional sync lands it
// must map StoryRecord ↔ SlimStoryBlob ↔ Session in one reconciliation layer
// rather than assuming a single shared shape; the intended convergence is a
// common envelope (SlimStoryBlob + sync-meta) at both edges. Documented here so
// the asymmetry is a known, bounded cost, not a surprise.
/** Upsert one story for the current user. onConflict targets the `id` PK; the
* caller-supplied rev/updated_at are written verbatim and created_at is left to
* the DB default (insert only). NOTE (CR-10): this is last-write-wins — there is
* no `updated_at`-monotonic guard, so a slow concurrent writer can clobber newer
* cloud state; the next-phase sync layer must add an optimistic-concurrency
* predicate (e.g. only overwrite when excluded.updated_at > stories.updated_at)
* before this is wired to real multi-device traffic. Returns the stored blob, or
* null when auth is off / unauthenticated / the write failed (incl. an RLS-hidden
* cross-user id collision surfacing as a PK violation). */
export async function cloudSaveStory(
blob: SlimStoryBlob,
): Promise<SlimStoryBlob | null> {
if (!AUTH_ENABLED) return null;
const userId = await currentUserId();
if (!userId) return null;
try {
const supabase = await createClient();
const { data, error } = await supabase
.from("stories")
.upsert(
{
id: blob.id,
user_id: userId,
world_setting: blob.worldSetting ?? "",
style_guide: blob.styleGuide ?? "",
orientation: coerceOrientation(blob.orientation),
scene_count: blob.sceneCount ?? 0,
rev: blob.rev ?? 1,
updated_at: new Date().toISOString(),
deleted_at: null,
session_jsonb: blob.session,
},
{ onConflict: "id" },
)
.select()
.single();
if (error || !data) return null;
return rowToBlob(data as StoryRow);
} catch {
return null;
}
}
/** Load one story's slim blob for the current user. Tombstoned / absent / not
* owned (RLS) → null. */
export async function cloudLoadStory(id: string): Promise<SlimStoryBlob | null> {
if (!AUTH_ENABLED) return null;
const userId = await currentUserId();
if (!userId) return null;
try {
const supabase = await createClient();
const { data, error } = await supabase
.from("stories")
.select()
.eq("id", id)
.eq("user_id", userId)
.is("deleted_at", null)
.maybeSingle();
if (error || !data) return null;
return rowToBlob(data as StoryRow);
} catch {
return null;
}
}
/** List the current user's non-tombstoned stories as lightweight metadata,
* newest first (mirrors localStore.listStories). Auth off / unauth → []. */
export async function cloudListStories(): Promise<StoryMeta[]> {
if (!AUTH_ENABLED) return [];
const userId = await currentUserId();
if (!userId) return [];
try {
const supabase = await createClient();
const { data, error } = await supabase
.from("stories")
.select()
.eq("user_id", userId)
.is("deleted_at", null)
.order("updated_at", { ascending: false });
if (error || !data) return [];
return (data as StoryRow[]).map(rowToMeta);
} catch {
return [];
}
}
/** Soft-delete one story (set the tombstone) for the current user so the
* deletion can propagate. Absent / not owned / write failed → false. */
export async function cloudSoftDeleteStory(id: string): Promise<boolean> {
if (!AUTH_ENABLED) return false;
const userId = await currentUserId();
if (!userId) return false;
try {
const supabase = await createClient();
const now = new Date().toISOString();
const { data, error } = await supabase
.from("stories")
.update({ deleted_at: now, updated_at: now })
.eq("id", id)
.eq("user_id", userId)
.is("deleted_at", null)
.select("id");
if (error || !data || data.length === 0) return false;
return true;
} catch {
return false;
}
}
+190
View File
@@ -0,0 +1,190 @@
// IndexedDB medium adapter — zero-dependency wrapper over a single object store.
//
// Why IndexedDB (not localStorage): async + non-blocking (localStorage's
// synchronous write is the known cause of the freeze when navigating back to
// home), hundreds of MB of quota, and a quota namespace separate from the
// gallery export's localStorage usage. Hand-rolled to avoid adding an `idb`
// dependency and keep the OpenNext bundle lean.
//
// Every function is fault-tolerant: when IndexedDB is unavailable (SSR, private
// mode, blocked) or any operation fails, it resolves to a safe value
// (null / [] / false) and never throws.
const DB_NAME = "infiplot";
const DB_VERSION = 1;
/** The single object store holding story records (keyPath = "id"). */
export const STORIES_STORE = "stories";
// Memoized open promise — opened once per page, reused thereafter.
let dbPromise: Promise<IDBDatabase | null> | null = null;
function isAvailable(): boolean {
return typeof window !== "undefined" && typeof indexedDB !== "undefined";
}
function promisifyRequest<T>(req: IDBRequest<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function txDone(tx: IDBTransaction): Promise<void> {
return new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
/** Open (and lazily create) the database. Resolves to null when IndexedDB is
* unavailable or the open fails/blocks — callers degrade gracefully.
* A transient failure (onerror, onblocked) resets the memoized promise so the
* next call retries rather than permanently disabling persistence for the page
* session. Only a successful open is cached — and even that cache is dropped if
* the connection later dies (onclose / onversionchange), so a post-open
* invalidation reopens on the next call instead of reusing a dead handle. */
export function idbReady(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
if (!isAvailable()) return Promise.resolve(null);
dbPromise = new Promise<IDBDatabase | null>((resolve) => {
try {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
try {
const db = req.result;
if (!db.objectStoreNames.contains(STORIES_STORE)) {
db.createObjectStore(STORIES_STORE, { keyPath: "id" });
}
} catch {
// createObjectStore failed (corrupt/quota/half-open) — the version-
// change transaction will abort, req.onerror fires, and we resolve null
// with the retry reset below.
}
};
req.onsuccess = () => {
const db = req.result;
// Post-open invalidation: a successfully-opened connection can still die.
// The browser may evict the DB under storage pressure (onclose), or
// another tab may request a version upgrade we must yield to
// (onversionchange). Without these handlers the memoized-but-dead db is
// reused forever — every later transaction throws InvalidStateError,
// which each op swallows in its try/catch, so persistence is silently
// dead for the whole page session (exactly the "permanent disable" the
// onerror/onblocked retry above set out to prevent, just on a later
// branch). Dropping dbPromise lets the next call reopen.
db.onclose = () => {
// Connection already closed by the browser; just allow a reopen.
dbPromise = null;
};
db.onversionchange = () => {
// Another tab wants to upgrade — close first so we don't block it with
// onblocked, then allow this tab to reopen at the new version.
dbPromise = null;
try {
db.close();
} catch {
// best-effort
}
};
resolve(db);
};
req.onerror = () => {
// Transient failure — allow retry on next call.
dbPromise = null;
resolve(null);
};
req.onblocked = () => {
// Another tab holds the connection — allow retry once it's released.
dbPromise = null;
resolve(null);
};
} catch {
dbPromise = null;
resolve(null);
}
});
return dbPromise;
}
/** Read one record by key. Returns null when absent or unavailable. */
export async function idbGet<T>(
storeName: string,
key: string,
): Promise<T | null> {
try {
const db = await idbReady();
if (!db) return null;
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<T>(
tx.objectStore(storeName).get(key) as IDBRequest<T>,
);
return result ?? null;
} catch {
return null;
}
}
/** Read every record in the store. Returns [] when empty or unavailable. */
export async function idbGetAll<T>(storeName: string): Promise<T[]> {
try {
const db = await idbReady();
if (!db) return [];
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<T[]>(
tx.objectStore(storeName).getAll() as IDBRequest<T[]>,
);
return result ?? [];
} catch {
return [];
}
}
/** Count records in the store WITHOUT deserializing any values — the cheap way
* to test a capacity threshold before falling back to a full idbGetAll. Returns
* 0 when empty or unavailable. */
export async function idbCount(storeName: string): Promise<number> {
try {
const db = await idbReady();
if (!db) return 0;
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<number>(
tx.objectStore(storeName).count() as IDBRequest<number>,
);
return result ?? 0;
} catch {
return 0;
}
}
/** Upsert one record (keyPath "id"). Returns true on durable commit. */
export async function idbPut<T>(storeName: string, value: T): Promise<boolean> {
try {
const db = await idbReady();
if (!db) return false;
const tx = db.transaction(storeName, "readwrite");
tx.objectStore(storeName).put(value);
await txDone(tx);
return true;
} catch {
return false;
}
}
/** Delete one record by key. Returns true on durable commit. */
export async function idbDelete(
storeName: string,
key: string,
): Promise<boolean> {
try {
const db = await idbReady();
if (!db) return false;
const tx = db.transaction(storeName, "readwrite");
tx.objectStore(storeName).delete(key);
await txDone(tx);
return true;
} catch {
return false;
}
}
+188
View File
@@ -0,0 +1,188 @@
// Local story repository — browser-local persistence built on the IndexedDB
// adapter. Owns CRUD, the local-first sync-reserved metadata, slim/rebuild of
// the Session payload, retention-cap eviction, defensive Date coercion, and
// end-to-end fault tolerance.
//
// Method signatures are expressed in terms of the slim Session blob so the
// future cloud repository (lib/persistence/cloudStore.ts) can mirror them and
// cloud sync can layer on top without changing callers.
import type { Session } from "@infiplot/types";
import { coerceOrientation } from "@infiplot/types";
import { idbGet, idbGetAll, idbPut, idbDelete, idbCount, STORIES_STORE } from "./idb";
import { slimSession } from "./sessionSlim";
import { STORY_SCHEMA_VERSION, coerceEpoch, type StoryRecord, type StoryMeta } from "./types";
/** Max number of non-tombstoned stories retained locally. IndexedDB has ample
* quota, so this is generous vs the old localStorage cap of 20; it aligns with
* the deleted D1 `listByUser` default limit of 50. */
export const LOCAL_STORY_CAP = 50;
/** Tombstoned records are kept (not hard-deleted) so a soft-delete can propagate
* to the cloud next phase — but only for a bounded window. Past this age they're
* reclaimed locally to stop unbounded IndexedDB growth (a pre-sync device may
* never propagate them, and the cloud applies deletes by id idempotently). */
export const TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// ── Internal helpers ───────────────────────────────────────────────────────
function toMeta(rec: StoryRecord): StoryMeta {
return {
id: rec.id,
worldSetting: rec.worldSetting,
styleGuide: rec.styleGuide,
orientation: coerceOrientation(rec.orientation),
sceneCount: rec.sceneCount,
createdAt: coerceEpoch(rec.createdAt, 0),
updatedAt: coerceEpoch(rec.updatedAt, 0),
};
}
/** Best-effort housekeeping run after a save. Guarded by a cheap count() so the
* common case (under cap, no aged tombstones) reads ZERO session blobs. Jobs
* when the guard trips:
* 1. Reap tombstones older than TOMBSTONE_RETENTION_MS — soft-deletes otherwise
* accumulate forever (nothing consumes them until cloud sync lands), bloating
* every idbGetAll.
* 2. Evict the oldest over-cap LIVE records, but SKIP any with un-propagated
* local changes (syncState !== "local-only") so an eviction can't silently
* drop edits a future cloud sync still needs to push.
* 3. If step 2 couldn't reach the cap because every over-cap record was
* protected, evict the oldest regardless — a bounded store beats preserving
* un-synced work forever. Eviction is a local capacity measure, so it
* hard-deletes (no tombstone). Never fails the save. */
async function enforceRetentionCap(): Promise<void> {
try {
// Cheap gate: total rows (incl. tombstones) without reading any value. Under
// the cap, live records are also under it and no tombstone reaping is due
// often enough to matter — skip the full scan entirely. NOTE: idbCount
// returns 0 when IndexedDB is unavailable/fails, so `0 <= CAP` skips eviction
// — intentional best-effort: if we can't even count, we can't safely evict.
const total = await idbCount(STORIES_STORE);
if (total <= LOCAL_STORY_CAP) return;
const all = await idbGetAll<StoryRecord>(STORIES_STORE);
const now = Date.now();
// 1. Reap aged tombstones (bounds tombstone growth, frees slots).
for (const r of all) {
if (r.deletedAt && now - coerceEpoch(r.deletedAt, now) > TOMBSTONE_RETENTION_MS) {
await idbDelete(STORIES_STORE, r.id);
}
}
// 2. Evict oldest over-cap live records, preserving un-synced ones.
const live = all
.filter((r) => !r.deletedAt)
.sort((a, b) => coerceEpoch(a.updatedAt, 0) - coerceEpoch(b.updatedAt, 0));
let overflow = live.length - LOCAL_STORY_CAP;
if (overflow <= 0) return;
for (const r of live) {
if (overflow <= 0) break;
// Keep records that still owe the cloud a push (pending edits/soft-deletes
// or a synced baseline) — hard-deleting them would lose that work silently.
if (r.syncState !== "local-only") continue;
await idbDelete(STORIES_STORE, r.id);
overflow--;
}
// 3. Last-resort: if step 2 couldn't reach the cap, every remaining over-cap
// record is protected (syncState !== "local-only"). Evict the oldest of THOSE
// regardless, so the store stays bounded. We must skip "local-only" here:
// those were already deleted in step 2, but they're still present in the
// in-memory `live` snapshot (idbDelete doesn't mutate it), so re-deleting them
// would burn `overflow` on no-ops and let the loop break before reaching the
// records that actually still occupy slots — leaving the cap exceeded.
// (Currently latent: non-"local-only" LIVE records don't yet exist — pending
// ones are produced only by softDeleteStory, which also tombstones them, so
// they're filtered out of `live` above. This guards the path that opens once
// cloud sync yields un-tombstoned pending/synced records.)
if (overflow > 0) {
for (const r of live) {
if (overflow <= 0) break;
if (r.syncState === "local-only") continue; // already evicted in step 2
await idbDelete(STORIES_STORE, r.id);
overflow--;
}
}
} catch {
// best-effort
}
}
// ── Public API (symmetric with the future cloud repository) ─────────────────
/** Upsert one story by `session.id`. New record gets rev=1 / syncState
* "local-only" / deletedAt null; an existing one bumps rev, refreshes
* updatedAt, preserves createdAt, and (re-)clears any tombstone. The bulky
* fields are stripped via slimSession before write. Returns the written
* record, or null when storage is unavailable / the write failed (Req 2.x). */
export async function saveStorySession(
session: Session,
): Promise<StoryRecord | null> {
if (!session?.id) return null;
const now = Date.now();
const existing = await idbGet<StoryRecord>(STORIES_STORE, session.id);
const record: StoryRecord = {
id: session.id,
schemaVersion: STORY_SCHEMA_VERSION,
worldSetting: session.worldSetting ?? "",
styleGuide: session.styleGuide ?? "",
orientation: coerceOrientation(session.orientation),
sceneCount: session.history?.length ?? 0,
createdAt: existing ? coerceEpoch(existing.createdAt, now) : now,
updatedAt: now,
rev: existing ? (existing.rev ?? 1) + 1 : 1,
// Re-saving (even a tombstoned id) revives the record locally.
deletedAt: null,
// A previously-synced record that changes locally becomes pending; otherwise
// keep its state (new → local-only). Consumed by next-phase cloud sync.
syncState: existing?.syncState === "synced" ? "pending" : existing?.syncState ?? "local-only",
session: slimSession(session),
};
const ok = await idbPut(STORIES_STORE, record);
if (!ok) return null;
await enforceRetentionCap();
return record;
}
/** List non-tombstoned stories as lightweight metadata, newest first (Req 3.1).
* NOTE: idbGetAll deserializes each record's full session blob even though only
* the denormalized meta fields are projected — meta and blob share one object
* store. Acceptable at LOCAL_STORY_CAP=50; if listing ever dominates, split the
* meta into its own store (or a cursor projection) to avoid reading blobs here. */
export async function listStories(): Promise<StoryMeta[]> {
const all = await idbGetAll<StoryRecord>(STORIES_STORE);
return all
.filter((r) => !r.deletedAt)
.map(toMeta)
.sort((a, b) => b.updatedAt - a.updatedAt);
}
/** Load the slim Session for a story id. Tombstoned or absent → null (Req 3.3).
* Defensively coerces the carried session's createdAt across the storage
* boundary (Req 3.6). The slim session is missing voice/styleReferenceImage by
* design — the engine degrades gracefully (Req 3.4). */
export async function loadStorySession(id: string): Promise<Session | null> {
const rec = await idbGet<StoryRecord>(STORIES_STORE, id);
if (!rec || rec.deletedAt || !rec.session) return null;
return { ...rec.session, createdAt: coerceEpoch(rec.session.createdAt, rec.createdAt) };
}
/** Soft-delete: set the tombstone + mark pending so the deletion can propagate
* to the cloud next phase. List queries filter tombstoned records out, so the
* user perceives it as deleted. Absent / already-deleted id → false (Req 3.5). */
export async function softDeleteStory(id: string): Promise<boolean> {
const rec = await idbGet<StoryRecord>(STORIES_STORE, id);
if (!rec || rec.deletedAt) return false;
const now = Date.now();
const updated: StoryRecord = {
...rec,
deletedAt: now,
updatedAt: now,
syncState: "pending",
};
return idbPut(STORIES_STORE, updated);
}
+37
View File
@@ -0,0 +1,37 @@
// Session slimming — the single definition of "shed a Session's bulky,
// reconstructible fields before it crosses a size-sensitive boundary".
//
// Two boundaries consume this, so the rule lives in one place (depends only on
// @infiplot/types, no engine/client imports, so both the storage layer and the
// engine transport layer can import it without pulling in each other's deps):
// - network transport (lib/engineClient.ts) drops voice before POSTing the
// session to scene/vision/insert-beat — voice is only used by /api/beat-audio.
// - local persistence (lib/persistence/localStore.ts) drops voice AND the
// style reference image before writing to IndexedDB.
import type { Session } from "@infiplot/types";
/** Drop each character's `voice` (the ~160-220KB referenceAudioBase64 + provider
* fields). The field is destructured out so it's ABSENT from the result rather
* than serialized as `undefined`. Tolerates a missing `characters` array. */
export function stripSessionVoices(session: Session): Session {
return {
...session,
characters: (session.characters ?? []).map(({ voice: _voice, ...rest }) => rest),
};
}
/** The persistence-grade slim: voices stripped (via stripSessionVoices) AND the
* bulky `styleReferenceImage` removed. Both are reconstructible — voices
* re-provision on the next /api/scene call, and styleReferenceImage is cosmetic
* (the engine paints fine without it). Keeps each stored record small regardless
* of IndexedDB quota headroom. */
export function slimSession(session: Session): Session {
// Destructure styleReferenceImage OUT (rather than set it to `undefined`) so
// it's ABSENT from the result — the same absent-not-undefined invariant as
// stripSessionVoices. structured-clone (IndexedDB) preserves an own key whose
// value is `undefined`, which a next-phase sync reconciler probing
// `'styleReferenceImage' in session` or Object.keys() would misread as present.
const { styleReferenceImage: _styleRef, ...rest } = stripSessionVoices(session);
return rest;
}
+98
View File
@@ -0,0 +1,98 @@
// Persistence wire types — local-first story storage.
//
// Shared shapes for the browser-local store (IndexedDB) and the future Supabase
// cloud store. Replaces the deleted D1 `lib/db/repositories/storyRepo` types,
// severing all dependency on Drizzle / D1. The local `StoryRecord` and the cloud
// `public.stories` row both carry the same slim `Session` blob (see
// `SlimStoryBlob`) so there is no dual data shape to reconcile when cloud sync
// is layered on next phase.
import type { Session, Orientation } from "@infiplot/types";
/** Schema version stamped on every local record — migration hook for future
* structural evolution of `StoryRecord`. Bump when the on-disk shape changes. */
export const STORY_SCHEMA_VERSION = 1;
/** Coerce a Date | string | number (or anything) to epoch milliseconds, falling
* back when the value is unparseable. Shared by the local store, the cloud store
* (Supabase timestamptz), and the stories list UI — every site where a timestamp
* crosses a storage/serialization boundary and could arrive as a non-number,
* guarding against the historical `t.getTime is not a function` white-screen. */
export function coerceEpoch(value: unknown, fallback: number): number {
if (typeof value === "number" && !Number.isNaN(value)) return value;
const d = value instanceof Date ? value : new Date(value as string | number);
const t = d.getTime();
return Number.isNaN(t) ? fallback : t;
}
/** local-first sync state of a record.
* - "local-only": never sent to the cloud (open-source default, or pre-sync).
* - "synced": in agreement with the cloud row.
* - "pending": has un-propagated local changes (incl. soft-delete tombstones). */
export type SyncState = "local-only" | "synced" | "pending";
/** List-view projection of a saved story — the lightweight metadata the
* "我的剧情" page renders without parsing the full session blob. Migrated out of
* the deleted D1 `storyRepo`; timestamps are unified to epoch milliseconds
* (the old D1 shape used `Date` and carried `userId`/`status`, both dropped:
* the local layer has no account concept, and `status` was a D1 leftover). */
export type StoryMeta = {
id: string;
worldSetting: string;
styleGuide: string;
orientation: Orientation;
sceneCount: number;
/** epoch ms */
createdAt: number;
/** epoch ms */
updatedAt: number;
};
/** The shared core payload for one saved story, identical between the local
* record and the (future) cloud row. `session` is the SLIM `Session` — the
* bulky reconstructible fields (`voice.referenceAudioBase64`,
* `styleReferenceImage`) are stripped before persistence by the store layer. */
export type SlimStoryBlob = {
id: string;
worldSetting: string;
styleGuide: string;
orientation: Orientation;
sceneCount: number;
rev: number;
/** Slim Session (voice + styleReferenceImage stripped). Type stays `Session`;
* slimming is a runtime guarantee enforced by the store, not the type. */
session: Session;
};
/** One row in the browser-local IndexedDB store (object store keyPath = "id").
* Carries the slim session payload plus the local-first sync-reserved
* metadata so cloud sync can be layered on next phase without restructuring. */
export type StoryRecord = {
id: string;
/** = STORY_SCHEMA_VERSION at write time. */
schemaVersion: number;
// ── List-view metadata (denormalized so listing needn't parse the blob) ──
worldSetting: string;
styleGuide: string;
orientation: Orientation;
sceneCount: number;
// ── local-first sync-reserved fields ──
/** epoch ms; set on first save, preserved across subsequent upserts. */
createdAt: number;
/** epoch ms; refreshed on every save. */
updatedAt: number;
/** Revision counter; new record = 1, bumped on each local save. */
rev: number;
/** Soft-delete tombstone (epoch ms) or null. Delete sets this rather than
* physically removing the row, so the deletion can propagate to the cloud
* next phase. List queries filter tombstoned records out. */
deletedAt: number | null;
syncState: SyncState;
// ── Payload ──
/** Slim Session (voice + styleReferenceImage stripped). IndexedDB
* structured-clones objects, so this is stored as-is (no JSON.stringify). */
session: Session;
};
-2
View File
@@ -24,7 +24,6 @@
"@fortawesome/fontawesome-free": "6.5.1", "@fortawesome/fontawesome-free": "6.5.1",
"@supabase/ssr": "^0.12", "@supabase/ssr": "^0.12",
"@supabase/supabase-js": "^2.108", "@supabase/supabase-js": "^2.108",
"drizzle-orm": "^0.45.2",
"jsonrepair": "^3.14.0", "jsonrepair": "^3.14.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"next": "^16.0.0", "next": "^16.0.0",
@@ -41,7 +40,6 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.31.10",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
+36 -377
View File
@@ -17,9 +17,6 @@ importers:
'@supabase/supabase-js': '@supabase/supabase-js':
specifier: ^2.108 specifier: ^2.108
version: 2.108.1 version: 2.108.1
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@cloudflare/workers-types@4.20260617.1)(@opentelemetry/api@1.9.1)
jsonrepair: jsonrepair:
specifier: ^3.14.0 specifier: ^3.14.0
version: 3.14.0 version: 3.14.0
@@ -63,9 +60,6 @@ importers:
cross-env: cross-env:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0 version: 10.1.0
drizzle-kit:
specifier: ^0.31.10
version: 0.31.10
postcss: postcss:
specifier: ^8.4.49 specifier: ^8.4.49
version: 8.5.15 version: 8.5.15
@@ -108,24 +102,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@ast-grep/napi-linux-arm64-musl@0.40.5': '@ast-grep/napi-linux-arm64-musl@0.40.5':
resolution: {integrity: sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA==} resolution: {integrity: sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@ast-grep/napi-linux-x64-gnu@0.40.5': '@ast-grep/napi-linux-x64-gnu@0.40.5':
resolution: {integrity: sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q==} resolution: {integrity: sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@ast-grep/napi-linux-x64-musl@0.40.5': '@ast-grep/napi-linux-x64-musl@0.40.5':
resolution: {integrity: sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg==} resolution: {integrity: sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@ast-grep/napi-win32-arm64-msvc@0.40.5': '@ast-grep/napi-win32-arm64-msvc@0.40.5':
resolution: {integrity: sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug==} resolution: {integrity: sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug==}
@@ -389,9 +387,6 @@ packages:
resolution: {integrity: sha512-GeDxvtjiRuoyWVU9nQneId879zIyNdL05bS7RKiqMkfBSKpHMWHLoRyRqjYWLaXmX/llKO1hTlqHDmatkQAjPA==} resolution: {integrity: sha512-GeDxvtjiRuoyWVU9nQneId879zIyNdL05bS7RKiqMkfBSKpHMWHLoRyRqjYWLaXmX/llKO1hTlqHDmatkQAjPA==}
hasBin: true hasBin: true
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@ecies/ciphers@0.2.6': '@ecies/ciphers@0.2.6':
resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==}
engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'}
@@ -404,14 +399,6 @@ packages:
'@epic-web/invariant@1.0.0': '@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild/aix-ppc64@0.25.4': '@esbuild/aix-ppc64@0.25.4':
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -430,12 +417,6 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [aix] os: [aix]
'@esbuild/android-arm64@0.18.20':
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.25.4': '@esbuild/android-arm64@0.25.4':
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -454,12 +435,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@esbuild/android-arm@0.18.20':
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.25.4': '@esbuild/android-arm@0.25.4':
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -478,12 +453,6 @@ packages:
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@esbuild/android-x64@0.18.20':
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.25.4': '@esbuild/android-x64@0.25.4':
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -502,12 +471,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [android] os: [android]
'@esbuild/darwin-arm64@0.18.20':
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.25.4': '@esbuild/darwin-arm64@0.25.4':
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -526,12 +489,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@esbuild/darwin-x64@0.18.20':
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.25.4': '@esbuild/darwin-x64@0.25.4':
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -550,12 +507,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@esbuild/freebsd-arm64@0.18.20':
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.25.4': '@esbuild/freebsd-arm64@0.25.4':
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -574,12 +525,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@esbuild/freebsd-x64@0.18.20':
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.4': '@esbuild/freebsd-x64@0.25.4':
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -598,12 +543,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@esbuild/linux-arm64@0.18.20':
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.25.4': '@esbuild/linux-arm64@0.25.4':
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -622,12 +561,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@esbuild/linux-arm@0.18.20':
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.25.4': '@esbuild/linux-arm@0.25.4':
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -646,12 +579,6 @@ packages:
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@esbuild/linux-ia32@0.18.20':
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.25.4': '@esbuild/linux-ia32@0.25.4':
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -670,12 +597,6 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [linux] os: [linux]
'@esbuild/linux-loong64@0.18.20':
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.25.4': '@esbuild/linux-loong64@0.25.4':
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -694,12 +615,6 @@ packages:
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@esbuild/linux-mips64el@0.18.20':
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.25.4': '@esbuild/linux-mips64el@0.25.4':
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -718,12 +633,6 @@ packages:
cpu: [mips64el] cpu: [mips64el]
os: [linux] os: [linux]
'@esbuild/linux-ppc64@0.18.20':
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.25.4': '@esbuild/linux-ppc64@0.25.4':
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -742,12 +651,6 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@esbuild/linux-riscv64@0.18.20':
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.25.4': '@esbuild/linux-riscv64@0.25.4':
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -766,12 +669,6 @@ packages:
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@esbuild/linux-s390x@0.18.20':
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.25.4': '@esbuild/linux-s390x@0.25.4':
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -790,12 +687,6 @@ packages:
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@esbuild/linux-x64@0.18.20':
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.25.4': '@esbuild/linux-x64@0.25.4':
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -832,12 +723,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [netbsd] os: [netbsd]
'@esbuild/netbsd-x64@0.18.20':
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.4': '@esbuild/netbsd-x64@0.25.4':
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -874,12 +759,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [openbsd] os: [openbsd]
'@esbuild/openbsd-x64@0.18.20':
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.4': '@esbuild/openbsd-x64@0.25.4':
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -910,12 +789,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@esbuild/sunos-x64@0.18.20':
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.25.4': '@esbuild/sunos-x64@0.25.4':
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -934,12 +807,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [sunos] os: [sunos]
'@esbuild/win32-arm64@0.18.20':
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.25.4': '@esbuild/win32-arm64@0.25.4':
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -958,12 +825,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@esbuild/win32-ia32@0.18.20':
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.25.4': '@esbuild/win32-ia32@0.25.4':
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -982,12 +843,6 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@esbuild/win32-x64@0.18.20':
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.25.4': '@esbuild/win32-x64@0.25.4':
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1062,155 +917,183 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm64@1.2.4': '@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5': '@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4': '@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4': '@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4': '@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4': '@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4': '@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4': '@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4': '@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4': '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4': '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4': '@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4': '@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5': '@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm64@0.34.5': '@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5': '@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5': '@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5': '@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5': '@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5': '@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5': '@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5': '@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5': '@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5': '@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-arm64@0.34.5': '@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5': '@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5': '@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5': '@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -1295,24 +1178,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.7': '@next/swc-linux-arm64-musl@16.2.7':
resolution: {integrity: sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==} resolution: {integrity: sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.2.7': '@next/swc-linux-x64-gnu@16.2.7':
resolution: {integrity: sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==} resolution: {integrity: sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.2.7': '@next/swc-linux-x64-musl@16.2.7':
resolution: {integrity: sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==} resolution: {integrity: sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.7': '@next/swc-win32-arm64-msvc@16.2.7':
resolution: {integrity: sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==} resolution: {integrity: sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==}
@@ -1858,102 +1745,6 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'} engines: {node: '>=12'}
drizzle-kit@0.31.10:
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
hasBin: true
drizzle-orm@0.45.2:
resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==}
peerDependencies:
'@aws-sdk/client-rds-data': '>=3'
'@cloudflare/workers-types': '>=4'
'@electric-sql/pglite': '>=0.2.0'
'@libsql/client': '>=0.10.0'
'@libsql/client-wasm': '>=0.10.0'
'@neondatabase/serverless': '>=0.10.0'
'@op-engineering/op-sqlite': '>=2'
'@opentelemetry/api': ^1.4.1
'@planetscale/database': '>=1.13'
'@prisma/client': '*'
'@tidbcloud/serverless': '*'
'@types/better-sqlite3': '*'
'@types/pg': '*'
'@types/sql.js': '*'
'@upstash/redis': '>=1.34.7'
'@vercel/postgres': '>=0.8.0'
'@xata.io/client': '*'
better-sqlite3: '>=7'
bun-types: '*'
expo-sqlite: '>=14.0.0'
gel: '>=2'
knex: '*'
kysely: '*'
mysql2: '>=2'
pg: '>=8'
postgres: '>=3'
prisma: '*'
sql.js: '>=1'
sqlite3: '>=5'
peerDependenciesMeta:
'@aws-sdk/client-rds-data':
optional: true
'@cloudflare/workers-types':
optional: true
'@electric-sql/pglite':
optional: true
'@libsql/client':
optional: true
'@libsql/client-wasm':
optional: true
'@neondatabase/serverless':
optional: true
'@op-engineering/op-sqlite':
optional: true
'@opentelemetry/api':
optional: true
'@planetscale/database':
optional: true
'@prisma/client':
optional: true
'@tidbcloud/serverless':
optional: true
'@types/better-sqlite3':
optional: true
'@types/pg':
optional: true
'@types/sql.js':
optional: true
'@upstash/redis':
optional: true
'@vercel/postgres':
optional: true
'@xata.io/client':
optional: true
better-sqlite3:
optional: true
bun-types:
optional: true
expo-sqlite:
optional: true
gel:
optional: true
knex:
optional: true
kysely:
optional: true
mysql2:
optional: true
pg:
optional: true
postgres:
optional: true
prisma:
optional: true
sql.js:
optional: true
sqlite3:
optional: true
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2001,11 +1792,6 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
esbuild@0.18.20:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'}
hasBin: true
esbuild@0.25.4: esbuild@0.25.4:
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2137,9 +1923,6 @@ packages:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'} engines: {node: '>=10'}
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
glob-parent@5.1.2: glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -2611,9 +2394,6 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
resolve@1.22.12: resolve@1.22.12:
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3580,8 +3360,6 @@ snapshots:
picomatch: 4.0.4 picomatch: 4.0.4
which: 4.0.0 which: 4.0.0
'@drizzle-team/brocli@0.10.2': {}
'@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
dependencies: dependencies:
'@noble/ciphers': 1.3.0 '@noble/ciphers': 1.3.0
@@ -3593,16 +3371,6 @@ snapshots:
'@epic-web/invariant@1.0.0': {} '@epic-web/invariant@1.0.0': {}
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.18.20
source-map-support: 0.5.21
'@esbuild-kit/esm-loader@2.6.5':
dependencies:
'@esbuild-kit/core-utils': 3.3.2
get-tsconfig: 4.14.0
'@esbuild/aix-ppc64@0.25.4': '@esbuild/aix-ppc64@0.25.4':
optional: true optional: true
@@ -3612,9 +3380,6 @@ snapshots:
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
optional: true optional: true
'@esbuild/android-arm64@0.18.20':
optional: true
'@esbuild/android-arm64@0.25.4': '@esbuild/android-arm64@0.25.4':
optional: true optional: true
@@ -3624,9 +3389,6 @@ snapshots:
'@esbuild/android-arm64@0.28.1': '@esbuild/android-arm64@0.28.1':
optional: true optional: true
'@esbuild/android-arm@0.18.20':
optional: true
'@esbuild/android-arm@0.25.4': '@esbuild/android-arm@0.25.4':
optional: true optional: true
@@ -3636,9 +3398,6 @@ snapshots:
'@esbuild/android-arm@0.28.1': '@esbuild/android-arm@0.28.1':
optional: true optional: true
'@esbuild/android-x64@0.18.20':
optional: true
'@esbuild/android-x64@0.25.4': '@esbuild/android-x64@0.25.4':
optional: true optional: true
@@ -3648,9 +3407,6 @@ snapshots:
'@esbuild/android-x64@0.28.1': '@esbuild/android-x64@0.28.1':
optional: true optional: true
'@esbuild/darwin-arm64@0.18.20':
optional: true
'@esbuild/darwin-arm64@0.25.4': '@esbuild/darwin-arm64@0.25.4':
optional: true optional: true
@@ -3660,9 +3416,6 @@ snapshots:
'@esbuild/darwin-arm64@0.28.1': '@esbuild/darwin-arm64@0.28.1':
optional: true optional: true
'@esbuild/darwin-x64@0.18.20':
optional: true
'@esbuild/darwin-x64@0.25.4': '@esbuild/darwin-x64@0.25.4':
optional: true optional: true
@@ -3672,9 +3425,6 @@ snapshots:
'@esbuild/darwin-x64@0.28.1': '@esbuild/darwin-x64@0.28.1':
optional: true optional: true
'@esbuild/freebsd-arm64@0.18.20':
optional: true
'@esbuild/freebsd-arm64@0.25.4': '@esbuild/freebsd-arm64@0.25.4':
optional: true optional: true
@@ -3684,9 +3434,6 @@ snapshots:
'@esbuild/freebsd-arm64@0.28.1': '@esbuild/freebsd-arm64@0.28.1':
optional: true optional: true
'@esbuild/freebsd-x64@0.18.20':
optional: true
'@esbuild/freebsd-x64@0.25.4': '@esbuild/freebsd-x64@0.25.4':
optional: true optional: true
@@ -3696,9 +3443,6 @@ snapshots:
'@esbuild/freebsd-x64@0.28.1': '@esbuild/freebsd-x64@0.28.1':
optional: true optional: true
'@esbuild/linux-arm64@0.18.20':
optional: true
'@esbuild/linux-arm64@0.25.4': '@esbuild/linux-arm64@0.25.4':
optional: true optional: true
@@ -3708,9 +3452,6 @@ snapshots:
'@esbuild/linux-arm64@0.28.1': '@esbuild/linux-arm64@0.28.1':
optional: true optional: true
'@esbuild/linux-arm@0.18.20':
optional: true
'@esbuild/linux-arm@0.25.4': '@esbuild/linux-arm@0.25.4':
optional: true optional: true
@@ -3720,9 +3461,6 @@ snapshots:
'@esbuild/linux-arm@0.28.1': '@esbuild/linux-arm@0.28.1':
optional: true optional: true
'@esbuild/linux-ia32@0.18.20':
optional: true
'@esbuild/linux-ia32@0.25.4': '@esbuild/linux-ia32@0.25.4':
optional: true optional: true
@@ -3732,9 +3470,6 @@ snapshots:
'@esbuild/linux-ia32@0.28.1': '@esbuild/linux-ia32@0.28.1':
optional: true optional: true
'@esbuild/linux-loong64@0.18.20':
optional: true
'@esbuild/linux-loong64@0.25.4': '@esbuild/linux-loong64@0.25.4':
optional: true optional: true
@@ -3744,9 +3479,6 @@ snapshots:
'@esbuild/linux-loong64@0.28.1': '@esbuild/linux-loong64@0.28.1':
optional: true optional: true
'@esbuild/linux-mips64el@0.18.20':
optional: true
'@esbuild/linux-mips64el@0.25.4': '@esbuild/linux-mips64el@0.25.4':
optional: true optional: true
@@ -3756,9 +3488,6 @@ snapshots:
'@esbuild/linux-mips64el@0.28.1': '@esbuild/linux-mips64el@0.28.1':
optional: true optional: true
'@esbuild/linux-ppc64@0.18.20':
optional: true
'@esbuild/linux-ppc64@0.25.4': '@esbuild/linux-ppc64@0.25.4':
optional: true optional: true
@@ -3768,9 +3497,6 @@ snapshots:
'@esbuild/linux-ppc64@0.28.1': '@esbuild/linux-ppc64@0.28.1':
optional: true optional: true
'@esbuild/linux-riscv64@0.18.20':
optional: true
'@esbuild/linux-riscv64@0.25.4': '@esbuild/linux-riscv64@0.25.4':
optional: true optional: true
@@ -3780,9 +3506,6 @@ snapshots:
'@esbuild/linux-riscv64@0.28.1': '@esbuild/linux-riscv64@0.28.1':
optional: true optional: true
'@esbuild/linux-s390x@0.18.20':
optional: true
'@esbuild/linux-s390x@0.25.4': '@esbuild/linux-s390x@0.25.4':
optional: true optional: true
@@ -3792,9 +3515,6 @@ snapshots:
'@esbuild/linux-s390x@0.28.1': '@esbuild/linux-s390x@0.28.1':
optional: true optional: true
'@esbuild/linux-x64@0.18.20':
optional: true
'@esbuild/linux-x64@0.25.4': '@esbuild/linux-x64@0.25.4':
optional: true optional: true
@@ -3813,9 +3533,6 @@ snapshots:
'@esbuild/netbsd-arm64@0.28.1': '@esbuild/netbsd-arm64@0.28.1':
optional: true optional: true
'@esbuild/netbsd-x64@0.18.20':
optional: true
'@esbuild/netbsd-x64@0.25.4': '@esbuild/netbsd-x64@0.25.4':
optional: true optional: true
@@ -3834,9 +3551,6 @@ snapshots:
'@esbuild/openbsd-arm64@0.28.1': '@esbuild/openbsd-arm64@0.28.1':
optional: true optional: true
'@esbuild/openbsd-x64@0.18.20':
optional: true
'@esbuild/openbsd-x64@0.25.4': '@esbuild/openbsd-x64@0.25.4':
optional: true optional: true
@@ -3852,9 +3566,6 @@ snapshots:
'@esbuild/openharmony-arm64@0.28.1': '@esbuild/openharmony-arm64@0.28.1':
optional: true optional: true
'@esbuild/sunos-x64@0.18.20':
optional: true
'@esbuild/sunos-x64@0.25.4': '@esbuild/sunos-x64@0.25.4':
optional: true optional: true
@@ -3864,9 +3575,6 @@ snapshots:
'@esbuild/sunos-x64@0.28.1': '@esbuild/sunos-x64@0.28.1':
optional: true optional: true
'@esbuild/win32-arm64@0.18.20':
optional: true
'@esbuild/win32-arm64@0.25.4': '@esbuild/win32-arm64@0.25.4':
optional: true optional: true
@@ -3876,9 +3584,6 @@ snapshots:
'@esbuild/win32-arm64@0.28.1': '@esbuild/win32-arm64@0.28.1':
optional: true optional: true
'@esbuild/win32-ia32@0.18.20':
optional: true
'@esbuild/win32-ia32@0.25.4': '@esbuild/win32-ia32@0.25.4':
optional: true optional: true
@@ -3888,9 +3593,6 @@ snapshots:
'@esbuild/win32-ia32@0.28.1': '@esbuild/win32-ia32@0.28.1':
optional: true optional: true
'@esbuild/win32-x64@0.18.20':
optional: true
'@esbuild/win32-x64@0.25.4': '@esbuild/win32-x64@0.25.4':
optional: true optional: true
@@ -4703,18 +4405,6 @@ snapshots:
dotenv@16.6.1: {} dotenv@16.6.1: {}
drizzle-kit@0.31.10:
dependencies:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.25.4
tsx: 4.22.4
drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260617.1)(@opentelemetry/api@1.9.1):
optionalDependencies:
'@cloudflare/workers-types': 4.20260617.1
'@opentelemetry/api': 1.9.1
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -4760,31 +4450,6 @@ snapshots:
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
hasown: 2.0.4 hasown: 2.0.4
esbuild@0.18.20:
optionalDependencies:
'@esbuild/android-arm': 0.18.20
'@esbuild/android-arm64': 0.18.20
'@esbuild/android-x64': 0.18.20
'@esbuild/darwin-arm64': 0.18.20
'@esbuild/darwin-x64': 0.18.20
'@esbuild/freebsd-arm64': 0.18.20
'@esbuild/freebsd-x64': 0.18.20
'@esbuild/linux-arm': 0.18.20
'@esbuild/linux-arm64': 0.18.20
'@esbuild/linux-ia32': 0.18.20
'@esbuild/linux-loong64': 0.18.20
'@esbuild/linux-mips64el': 0.18.20
'@esbuild/linux-ppc64': 0.18.20
'@esbuild/linux-riscv64': 0.18.20
'@esbuild/linux-s390x': 0.18.20
'@esbuild/linux-x64': 0.18.20
'@esbuild/netbsd-x64': 0.18.20
'@esbuild/openbsd-x64': 0.18.20
'@esbuild/sunos-x64': 0.18.20
'@esbuild/win32-arm64': 0.18.20
'@esbuild/win32-ia32': 0.18.20
'@esbuild/win32-x64': 0.18.20
esbuild@0.25.4: esbuild@0.25.4:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.25.4 '@esbuild/aix-ppc64': 0.25.4
@@ -5026,10 +4691,6 @@ snapshots:
get-stream@6.0.1: {} get-stream@6.0.1: {}
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
glob-parent@5.1.2: glob-parent@5.1.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
@@ -5422,8 +5083,6 @@ snapshots:
dependencies: dependencies:
picomatch: 2.3.2 picomatch: 2.3.2
resolve-pkg-maps@1.0.0: {}
resolve@1.22.12: resolve@1.22.12:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -0,0 +1,54 @@
-- Story persistence — Supabase single-table JSONB + RLS skeleton.
--
-- This migration lands the cloud schema for the COMMERCIAL build only. The
-- open-source build persists stories browser-locally (IndexedDB) and never
-- reaches this table. Cloud sync is NOT wired to any client this phase — the
-- table + RLS exist so next-phase local-first bidirectional sync can layer on
-- without a schema change.
--
-- One row mirrors the local StoryRecord's shared SlimStoryBlob payload
-- (lib/persistence/types.ts): list-view metadata is denormalized into columns
-- and the slim Session lives in session_jsonb. Per-user isolation is enforced
-- entirely by RLS (auth.uid() = user_id) against the SSR client's anon key +
-- user cookie — no service_role key is used.
--
-- Idempotent: safe to re-run. Tables/indexes use `if not exists`; policies are
-- dropped-then-created (Postgres has no `create policy if not exists`).
create table if not exists public.stories (
id text primary key, -- = Session.id ("s_xxx"), shared with the local record
user_id uuid not null references auth.users (id) on delete cascade,
world_setting text not null default '',
style_guide text not null default '',
orientation text not null default 'landscape', -- "portrait" | "landscape"
scene_count integer not null default 0,
rev integer not null default 1, -- revision; new = 1, +1 per save
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz, -- soft-delete tombstone; null = live
session_jsonb jsonb not null -- slim Session blob (voice + styleReferenceImage stripped)
);
-- List query path: a user's stories newest-first.
create index if not exists stories_user_updated_idx
on public.stories (user_id, updated_at desc);
alter table public.stories enable row level security;
-- Authenticated users may read/write ONLY their own rows. Four policies, one
-- per command, all keyed on auth.uid() = user_id.
drop policy if exists "stories_select_own" on public.stories;
create policy "stories_select_own" on public.stories
for select using (auth.uid() = user_id);
drop policy if exists "stories_insert_own" on public.stories;
create policy "stories_insert_own" on public.stories
for insert with check (auth.uid() = user_id);
drop policy if exists "stories_update_own" on public.stories;
create policy "stories_update_own" on public.stories
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
drop policy if exists "stories_delete_own" on public.stories;
create policy "stories_delete_own" on public.stories
for delete using (auth.uid() = user_id);
+4 -30
View File
@@ -59,34 +59,8 @@
// //
// See .dev.vars.example for a full reference of all variables. // See .dev.vars.example for a full reference of all variables.
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
//
// ── Cloudflare D1 database (story persistence — optional) ─────────── // Story persistence is browser-local (IndexedDB) in the open-source build and
// Not required for core gameplay. Uncomment and fill in your ID if needed: // Supabase (Postgres) in the commercial build — no Cloudflare D1/R2/KV
// wrangler d1 create infiplot-db // bindings are used.
// "d1_databases": [
// {
// "binding": "DB",
// "database_name": "infiplot-db",
// "database_id": "<your-d1-database-id>"
// }
// ],
// ── Cloudflare R2 bucket (asset storage — optional) ─────────────────
// Not required for core gameplay. Uncomment if needed:
// wrangler r2 bucket create infiplot-assets
// "r2_buckets": [
// {
// "binding": "R2_BUCKET",
// "bucket_name": "infiplot-assets"
// }
// ],
// ── Cloudflare KV namespace (reserved for future use) ───────────────
// Uncomment if needed: wrangler kv namespace create KV
// "kv_namespaces": [
// {
// "binding": "KV",
// "id": "<your-kv-namespace-id>"
// }
// ]
} }