feat(persistence): local-first story persistence (IndexedDB + Supabase skeleton)
Remove Cloudflare D1 entirely (4 API routes, lib/db/, Drizzle config/migrations, drizzle-orm/drizzle-kit deps, wrangler D1/R2/KV bindings) and replace with browser-local-first architecture: Open-source build (IndexedDB, no auth): - lib/persistence/ 5-file module: types, idb adapter (zero-dep, fault-tolerant, post-open invalidation retry), localStore (CRUD + sync-reserved metadata + slim/rebuild + retention-cap eviction with tombstone reap + sync-state protection + last-resort bounded fallback), sessionSlim (voice strip + styleRef absent-delete), cloudStore (Supabase skeleton, server-only) - Autosave: persistence fingerprint (history.length:lastBeatCount:playerName), serial saveChain, failure rollback retry, replaySourceRef guard (prevents replayed shared stories from clobbering user saves) - clientStoryPersistence.ts: thin facade (SaveResult discriminated union) - Stories page: /[locale]/stories with 3-language i18n (zh-CN/en/ja) - Homepage: book icon entry point in header Commercial build (Supabase, skeleton only): - Single table public.stories (JSONB + RLS 4 policies on auth.uid()=user_id) - supabase/migrations/ DDL (idempotent) - cloudStore.ts server-only repository, AUTH_ENABLED short-circuit - Not wired to client this phase Featured stories: pure fallback (buildFallbackCards + localizeCards), no D1 Includes fixes from 3 rounds of subagent code-review (tasks 16-30): - CR1: autosave restructure, coerceOrientation, D1 comment cleanup - CR2: fingerprint+serial+rollback+replay guard, idb post-open retry, enforceRetentionCap latent defense, sessionSlim absent invariant - CR3: single-scene share guard (replaySourceRef), insert-beat fingerprint (beats.length), pass3 overflow double-count fix, detach gate unification
This commit is contained in:
+22
-52
@@ -123,8 +123,8 @@ const OPTS: Opt[] = [
|
||||
|
||||
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
||||
|
||||
// 首页卡片的统一渲染形态——无论来自 D1 featured API 还是硬编码 STORIES 降级,
|
||||
// 都归一到这个形状后只走一条渲染路径。
|
||||
// 首页卡片的统一渲染形态。卡片来自硬编码 STORIES(buildFallbackCards),
|
||||
// 按当前 locale 本地化后渲染。
|
||||
type FeaturedCard = {
|
||||
id: string; // e.g. "m0" / "f12",用于 ?card= 与封面拼接
|
||||
title: string;
|
||||
@@ -132,22 +132,6 @@ type FeaturedCard = {
|
||||
coverPath: string; // e.g. "/home/m0.webp"
|
||||
};
|
||||
|
||||
// D1 featured API 的响应行(与 lib/db/schema.ts FeaturedStory 对应的线上子集)。
|
||||
type FeaturedStoryRow = {
|
||||
id: string;
|
||||
gender: string;
|
||||
title: string;
|
||||
outline: string;
|
||||
style: string;
|
||||
tags: string; // JSON 字符串
|
||||
coverPath: string;
|
||||
firstactPath: string;
|
||||
firstscenePath?: string | null;
|
||||
sortOrder: number;
|
||||
isActive: number;
|
||||
clickCount: number;
|
||||
};
|
||||
|
||||
import { STYLE_MAP } from "@/lib/options";
|
||||
|
||||
/* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。
|
||||
@@ -798,8 +782,8 @@ const DISPLAY_ORDER: Record<Gender, number[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(featured API 故障/空时的降级源,
|
||||
// 同时作为首屏即时渲染的初始值,避免等 fetch 期间卡片区空白)。
|
||||
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(卡片的唯一数据源,经
|
||||
// localizeCards 三语本地化;惰性初始化为首屏即时渲染初始值,无需任何 fetch)。
|
||||
function buildFallbackCards(g: Gender): FeaturedCard[] {
|
||||
const imgPrefix = g === "女性向" ? "f" : "m";
|
||||
const localStories = STORIES[g];
|
||||
@@ -1532,8 +1516,8 @@ export default function HomePage() {
|
||||
return () => clearTimeout(t);
|
||||
}, [gender, galleryGender]);
|
||||
|
||||
// Featured stories 动态加载(从 /api/stories/featured),降级用硬编码 STORIES。
|
||||
// 惰性初始化确保首屏即有卡片内容(SSR + hydration 一致),fetch 成功后无缝替换。
|
||||
// Featured 卡片来自硬编码 STORIES 的本地化结果。惰性初始化确保首屏即有内容
|
||||
//(SSR + hydration 一致),locale/gender 变化时重算本地化。
|
||||
const storiesI18nRef = useRef<{ locale: string; data: StoriesI18n | null }>({ locale: "", data: null });
|
||||
const [featuredCards, setFeaturedCards] = useState<FeaturedCard[]>(() =>
|
||||
buildFallbackCards(galleryGender),
|
||||
@@ -1546,34 +1530,11 @@ export default function HomePage() {
|
||||
}
|
||||
const i18n = storiesI18nRef.current.data;
|
||||
if (cancelled) return;
|
||||
|
||||
const apiGender = galleryGender === "女性向" ? "female" : "male";
|
||||
try {
|
||||
const r = await fetch(`/api/stories/featured?gender=${apiGender}`);
|
||||
const data: { stories: FeaturedStoryRow[] } = await r.json();
|
||||
// API 已按 sortOrder 排序且仅返回 isActive=1 的记录。
|
||||
// D1 故障时 featured route 返回 { stories: [] }(HTTP 200),
|
||||
// 空数组也必须降级到常量,否则首页白屏。
|
||||
const rows = data.stories ?? [];
|
||||
if (cancelled) return;
|
||||
if (rows.length === 0) {
|
||||
setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
|
||||
return;
|
||||
}
|
||||
setFeaturedCards(
|
||||
localizeCards(
|
||||
rows.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
outline: s.outline,
|
||||
coverPath: s.coverPath,
|
||||
})),
|
||||
i18n,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
|
||||
}
|
||||
// Featured cards come from the hardcoded fallback set (buildFallbackCards),
|
||||
// localized for the active locale. The D1 /api/stories/featured route was
|
||||
// removed — it had no real data and always degraded to this fallback, so
|
||||
// this is behavior-identical with one fewer network round-trip.
|
||||
setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [galleryGender, locale]);
|
||||
@@ -1878,8 +1839,17 @@ export default function HomePage() {
|
||||
</span>
|
||||
<div className="flex items-center gap-4 md:gap-5">
|
||||
<LanguageSwitcher variant="compact" />
|
||||
{/* Story persistence UI hidden until auth integration is ready.
|
||||
Code in app/stories/, app/api/stories/, lib/db/ is retained. */}
|
||||
{/* "我的剧情" — entry to the browser-local story list (IndexedDB).
|
||||
Needs no auth; the /[locale]/stories page reads the local store. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(lp("/stories"))}
|
||||
aria-label={t("home.ui.myStories")}
|
||||
title={t("home.ui.myStories")}
|
||||
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-book" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
+107
-23
@@ -21,7 +21,7 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co
|
||||
import { annotateClick } from "@/lib/annotateClient";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { collectBeatAudioForExport } from "@/lib/exportAudio";
|
||||
import { loadFromLocalStorage } from "@/lib/clientStoryPersistence";
|
||||
import { saveStory, loadStorySession } from "@/lib/clientStoryPersistence";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
import {
|
||||
STORY_SHARE_STORAGE_KEY,
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
TtsConfig,
|
||||
TtsProvider,
|
||||
} from "@infiplot/types";
|
||||
import { coerceOrientation } from "@infiplot/types";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||
import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume";
|
||||
@@ -480,7 +481,7 @@ function prefetchScenePath(
|
||||
source: "prefetch" as const,
|
||||
kind,
|
||||
http_status,
|
||||
orientation: baseSession.orientation ?? "landscape",
|
||||
orientation: coerceOrientation(baseSession.orientation),
|
||||
connection: getConnectionType(),
|
||||
was_hidden: typeof document !== "undefined" && document.visibilityState === "hidden",
|
||||
scene_index: baseSession.history.length,
|
||||
@@ -711,7 +712,7 @@ function PlayInner() {
|
||||
session: sess,
|
||||
beatId: beat.id,
|
||||
visitedBeats: [...visitedBeatsRef.current],
|
||||
orientation: sess.orientation ?? "landscape",
|
||||
orientation: coerceOrientation(sess.orientation),
|
||||
imageOriginalUrl,
|
||||
pendingAction: pendingResumeActionRef.current ?? undefined,
|
||||
};
|
||||
@@ -851,6 +852,61 @@ function PlayInner() {
|
||||
useEffect(() => {
|
||||
sessionRef.current = session;
|
||||
}, [session]);
|
||||
// Autosave bookkeeping. We persist on a stable FINGERPRINT of the durable,
|
||||
// session-level state — committed-scene count + playerName — not the raw
|
||||
// `session` reference, which churns on every beat advance (visitedBeatIds).
|
||||
// - lastSavedFingerprintRef holds the fingerprint of the last SUCCESSFUL save.
|
||||
// On failure it's cleared so the next session change retries: a
|
||||
// fire-and-forget that silently failed (IndexedDB transiently unavailable)
|
||||
// must not strand the scene unsaved.
|
||||
// - saveChainRef serializes writes so a slow save for scene N can't land after
|
||||
// a faster save for N+1 and persist a stale, shorter session.
|
||||
const lastSavedFingerprintRef = useRef("");
|
||||
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
// Persist to the browser-local store when the durable state changes (Req 2.1).
|
||||
// Fingerprint = committed-scene count + last-scene beat count + playerName:
|
||||
// - scene count grows on a normal scene commit;
|
||||
// - last-scene beat count grows on an insert-beat (freeform / background-click
|
||||
// appends a beat to the current scene WITHOUT changing history.length), which
|
||||
// is real generated narrative that must persist — keying on length alone
|
||||
// would silently drop it;
|
||||
// - playerName captures a late rename.
|
||||
// Within-scene *visited* progress (visitedBeatIds) is deliberately NOT in the
|
||||
// fingerprint, so merely advancing through existing beats doesn't re-save. The
|
||||
// resume path primes the fingerprint so loading a story stays a pure read (no
|
||||
// re-save / rev bump / list reorder). No debounce — the write is issued on the
|
||||
// committing render, so navigating home right after a change can't drop it (the
|
||||
// IndexedDB put is already in flight, serialized, not cancelled by unmount).
|
||||
// Fire-and-forget: never blocks.
|
||||
useEffect(() => {
|
||||
// Never persist a replayed shared story into the user's own library — it
|
||||
// isn't theirs and its id can collide with (and clobber) a real local save.
|
||||
// Guard on replaySourceRef (set unconditionally on import, cleared by
|
||||
// detachRecordedReplay when the user takes over) — NOT replayActiveRef, which
|
||||
// means "more recorded scenes remain" and is false for a single-scene share,
|
||||
// so that share would otherwise slip through and overwrite a real save.
|
||||
if (!session || replaySourceRef.current) return;
|
||||
const history = session.history ?? [];
|
||||
if (history.length < 1) return;
|
||||
const lastBeatCount = history[history.length - 1]?.scene?.beats?.length ?? 0;
|
||||
const fingerprint = `${history.length}:${lastBeatCount}:${session.playerName ?? ""}`;
|
||||
if (fingerprint === lastSavedFingerprintRef.current) return;
|
||||
lastSavedFingerprintRef.current = fingerprint; // optimistic; rolled back on failure
|
||||
const snapshot = session;
|
||||
saveChainRef.current = saveChainRef.current
|
||||
.then(async () => {
|
||||
const r = await saveStory(snapshot);
|
||||
// Roll back only if no newer save has superseded us, so the next session
|
||||
// change retries this content instead of the failure being permanent.
|
||||
if (!r.ok && lastSavedFingerprintRef.current === fingerprint) {
|
||||
lastSavedFingerprintRef.current = "";
|
||||
}
|
||||
})
|
||||
// Defensive: saveStory is contracted never to throw, but if a future edit
|
||||
// to this callback ever does, an unhandled rejection here would poison the
|
||||
// chain and freeze ALL subsequent saves. Swallow to keep the chain alive.
|
||||
.catch(() => {});
|
||||
}, [session]);
|
||||
useEffect(() => {
|
||||
currentSceneRef.current = currentScene;
|
||||
}, [currentScene]);
|
||||
@@ -1382,7 +1438,7 @@ function PlayInner() {
|
||||
v: audioByBeatId && Object.keys(audioByBeatId).length > 0 ? 3 : 2,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
orientation: s.orientation ?? "landscape",
|
||||
orientation: coerceOrientation(s.orientation),
|
||||
scenes,
|
||||
alternates,
|
||||
characters,
|
||||
@@ -1712,27 +1768,50 @@ function PlayInner() {
|
||||
|
||||
// ── Load saved story path ──
|
||||
if (storyId) {
|
||||
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
|
||||
const loadedSession = loadFromLocalStorage(storyId);
|
||||
if (!loadedSession) {
|
||||
setError(t("play.savedStoryNotFound"));
|
||||
return;
|
||||
}
|
||||
const firstScene = loadedSession.history[0]?.scene;
|
||||
if (!firstScene) {
|
||||
setError(t("play.savedStoryCorrupted"));
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
// Browser-local store (IndexedDB) is async; load inside the IIFE.
|
||||
const loadedSession = await loadStorySession(storyId);
|
||||
if (!loadedSession) {
|
||||
setError(t("play.savedStoryNotFound"));
|
||||
return;
|
||||
}
|
||||
// Resume at the player's last position. Walk from the newest scene back
|
||||
// to the first and resume at the latest one that actually has a rendered
|
||||
// image: the final scene → correct position; if the very last scene
|
||||
// failed to image (committed without one), a small rewind beats a blank
|
||||
// canvas (Req 3.3). If NO scene has an image the story can't render —
|
||||
// surface savedStoryCorrupted instead of landing on getOrCreateBlobUrl("").
|
||||
const history = loadedSession.history;
|
||||
let resumeEntry = history[history.length - 1];
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i]?.scene?.imageUrl) {
|
||||
resumeEntry = history[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const resumeScene = resumeEntry?.scene;
|
||||
if (!resumeScene?.imageUrl) {
|
||||
setError(t("play.savedStoryCorrupted"));
|
||||
return;
|
||||
}
|
||||
// Pure read: prime the autosave fingerprint so loading doesn't re-save /
|
||||
// bump rev / reorder the list. Must match the effect's fingerprint shape
|
||||
// exactly (scene count + last-scene beat count + playerName) or the first
|
||||
// render would re-persist.
|
||||
{
|
||||
const lastBeatCount =
|
||||
history[history.length - 1]?.scene?.beats?.length ?? 0;
|
||||
lastSavedFingerprintRef.current = `${history.length}:${lastBeatCount}:${loadedSession.playerName ?? ""}`;
|
||||
}
|
||||
try {
|
||||
const blobUrl = await getOrCreateBlobUrl(firstScene.imageUrl ?? "");
|
||||
lastImageOriginalUrlRef.current = firstScene.imageUrl ?? "";
|
||||
const blobUrl = await getOrCreateBlobUrl(resumeScene.imageUrl);
|
||||
lastImageOriginalUrlRef.current = resumeScene.imageUrl;
|
||||
setSession(loadedSession);
|
||||
setCurrentScene(firstScene);
|
||||
setCurrentBeatId(firstScene.entryBeatId);
|
||||
setCurrentScene(resumeScene);
|
||||
setCurrentBeatId(resumeScene.entryBeatId);
|
||||
setImageUrl(blobUrl);
|
||||
visitedBeatsRef.current = [firstScene.entryBeatId];
|
||||
setOrientation(loadedSession.orientation ?? "landscape");
|
||||
visitedBeatsRef.current = [resumeScene.entryBeatId];
|
||||
setOrientation(coerceOrientation(loadedSession.orientation));
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: loadedSession.history.length });
|
||||
} catch (e) {
|
||||
@@ -2238,7 +2317,10 @@ function PlayInner() {
|
||||
|
||||
async function onFreeformInput(text: string) {
|
||||
if (phase !== "ready" || !session || !currentScene) return;
|
||||
if (replayActiveRef.current) detachRecordedReplay();
|
||||
// Detach if we're still replaying a shared story (gate on replaySourceRef,
|
||||
// not replayActiveRef — the latter is false for a single-scene share, which
|
||||
// would otherwise leave us "stuck" in replay and block autosave forever).
|
||||
if (replaySourceRef.current) detachRecordedReplay();
|
||||
|
||||
track("freeform_input", {
|
||||
scene_index: session.history.length,
|
||||
@@ -2295,7 +2377,9 @@ function PlayInner() {
|
||||
|
||||
async function onBackgroundClick(click: { x: number; y: number }) {
|
||||
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
|
||||
if (replayActiveRef.current) detachRecordedReplay();
|
||||
// Gate on replaySourceRef, not replayActiveRef (false for a single-scene
|
||||
// share) — see onFreeformInput for the rationale.
|
||||
if (replaySourceRef.current) detachRecordedReplay();
|
||||
const visionT0 = Date.now();
|
||||
setPhase("vision-thinking");
|
||||
setPendingClick(click);
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadStoryList, deleteStory } from "@/lib/clientStoryPersistence";
|
||||
import type { StoryMeta } from "@/lib/db/repositories/storyRepo";
|
||||
import type { StoryMeta } from "@/lib/persistence/types";
|
||||
import { coerceEpoch } from "@/lib/persistence/types";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
import { useI18n } from "@/lib/i18n/client";
|
||||
|
||||
export default function StoriesPage() {
|
||||
const lp = useLocalePath();
|
||||
const { t, locale } = useI18n();
|
||||
const [stories, setStories] = useState<StoryMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
@@ -20,7 +23,7 @@ export default function StoriesPage() {
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (storyId: string) => {
|
||||
if (!confirm("确认删除这个剧情?此操作无法撤销。")) return;
|
||||
if (!confirm(t("stories.deleteConfirm"))) return;
|
||||
|
||||
setDeletingId(storyId);
|
||||
const success = await deleteStory(storyId);
|
||||
@@ -28,30 +31,31 @@ export default function StoriesPage() {
|
||||
if (success) {
|
||||
setStories((prev) => prev.filter((s) => s.id !== storyId));
|
||||
} else {
|
||||
alert("删除失败,请稍后重试");
|
||||
alert(t("stories.deleteFailed"));
|
||||
}
|
||||
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
// D1 timestamps arrive as ISO strings over the JSON API boundary (the
|
||||
// server-side Date is serialized by NextResponse.json), so coerce before use.
|
||||
// Story timestamps cross the storage boundary as epoch ms (the local store
|
||||
// coerces them); coerceEpoch is the shared guard for any legacy string/Date.
|
||||
const formatDate = (value: Date | string | number) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const ms = coerceEpoch(value, NaN);
|
||||
if (Number.isNaN(ms)) return "";
|
||||
const date = new Date(ms);
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return "今天";
|
||||
if (days === 1) return "昨天";
|
||||
if (days < 7) return `${days} 天前`;
|
||||
if (days === 0) return t("stories.today");
|
||||
if (days === 1) return t("stories.yesterday");
|
||||
if (days < 7) return t("stories.daysAgo", { days });
|
||||
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
return date.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -67,7 +71,7 @@ export default function StoriesPage() {
|
||||
InfiPlot
|
||||
</Link>
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
我 · 的 · 剧 · 情
|
||||
{t("stories.title")}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
@@ -76,20 +80,20 @@ export default function StoriesPage() {
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[40vh]">
|
||||
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||||
载 · 入 · 中
|
||||
{t("stories.loading")}
|
||||
</p>
|
||||
</div>
|
||||
) : stories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
|
||||
<i className="fa-solid fa-book-open text-4xl text-clay-300 mb-6" />
|
||||
<p className="font-serif italic text-lg text-clay-500 mb-4">
|
||||
还没有保存的剧情
|
||||
{t("stories.emptyTitle")}
|
||||
</p>
|
||||
<Link
|
||||
href={lp("/")}
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors cursor-pointer"
|
||||
>
|
||||
回到首页开始新的故事
|
||||
{t("stories.emptyBack")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -117,7 +121,7 @@ export default function StoriesPage() {
|
||||
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-photo-film text-[9px]" />
|
||||
{story.sceneCount} 幕
|
||||
{t("stories.scenes", { count: story.sceneCount })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-clock text-[9px]" />
|
||||
@@ -134,7 +138,7 @@ export default function StoriesPage() {
|
||||
handleDelete(story.id);
|
||||
}}
|
||||
disabled={deletingId === story.id}
|
||||
aria-label="删除"
|
||||
aria-label={t("stories.deleteLabel")}
|
||||
className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-clay-400 hover:text-ember-500 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<i className={deletingId === story.id ? "fa-solid fa-spinner fa-spin" : "fa-solid fa-trash-can"} />
|
||||
@@ -151,7 +155,7 @@ export default function StoriesPage() {
|
||||
<div className="hairline-full w-full mb-4" />
|
||||
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
|
||||
<span>MMXXVI</span>
|
||||
<span className="num">{stories.length} 个剧情</span>
|
||||
<span className="num">{t("stories.storiesCount", { count: stories.length })}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET/DELETE /api/stories/[id] — TEMPORARILY DISABLED (2026-06-09)
|
||||
*
|
||||
* D1 persistence disabled until authentication integration.
|
||||
* Returns 404 so client handles gracefully (localStorage is the source of truth).
|
||||
*
|
||||
* To re-enable: Restore original implementation after auth integration.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
_context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Server persistence temporarily disabled" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
_context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Server persistence temporarily disabled" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { FeaturedRepository } from "@/lib/db/repositories/featuredRepo";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/stories/featured?gender=male
|
||||
*
|
||||
* List active featured stories for homepage display.
|
||||
* Fallback: D1 query fails → return empty array (homepage shows no cards, gracefully degrades).
|
||||
*
|
||||
* Query Params:
|
||||
* gender: "male" | "female" (required)
|
||||
*
|
||||
* Response: { stories: FeaturedStory[] }
|
||||
* Errors: 400 (invalid gender), 500 (should not reach user - caught and degraded)
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const genderParam = searchParams.get("gender");
|
||||
|
||||
// Validate gender
|
||||
if (!genderParam || !["male", "female"].includes(genderParam)) {
|
||||
return NextResponse.json(
|
||||
{ error: "gender query parameter must be 'male' or 'female'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const gender = genderParam as "male" | "female";
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const repo = new FeaturedRepository(db);
|
||||
|
||||
const stories = await repo.listByGender(gender);
|
||||
|
||||
return NextResponse.json({ stories });
|
||||
} catch (err) {
|
||||
// D1 unavailable or query failed - degrade to empty array
|
||||
// (homepage will show no cards but remain functional)
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[stories/featured] D1 query failed, returning empty array:", message);
|
||||
|
||||
return NextResponse.json({ stories: [] });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/stories/list — TEMPORARILY DISABLED (2026-06-09)
|
||||
*
|
||||
* D1 persistence disabled until authentication integration.
|
||||
* Returns empty list so client falls back to localStorage-only mode.
|
||||
*
|
||||
* To re-enable: Restore original implementation after auth integration.
|
||||
*/
|
||||
export async function GET(_req: Request) {
|
||||
return NextResponse.json({ stories: [] });
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/stories/save — TEMPORARILY DISABLED (2026-06-09)
|
||||
*
|
||||
* D1 persistence is disabled until an authentication system (better-auth) is
|
||||
* integrated. Without auth, anonymous writes to D1 have no rate limiting,
|
||||
* per-user quota, or ownership verification — an abuse/DoS risk on a public,
|
||||
* registration-less site. The client (lib/clientStoryPersistence.ts) now
|
||||
* persists stories to localStorage only; this 503 keeps the contract intact
|
||||
* for any caller that still hits the endpoint.
|
||||
*
|
||||
* The full D1 implementation lives in StoryRepository (lib/db/repositories/
|
||||
* storyRepo.ts), which is untouched. To re-enable after auth integration:
|
||||
* restore the handler to validate input + call `repo.save(...)` (see the
|
||||
* task-10 implementation log) and gate it behind an authenticated session.
|
||||
*
|
||||
* See: ARCHITECTURE_DESIGN.md Phase 2, memory tech_d1_anonymous_write_risk
|
||||
*/
|
||||
export async function POST(_req: Request) {
|
||||
return NextResponse.json(
|
||||
{ error: "Server persistence temporarily disabled - using local storage" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
@@ -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 || "",
|
||||
},
|
||||
});
|
||||
@@ -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`);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1780820306927,
|
||||
"tag": "0000_early_paladin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -1,299 +1,44 @@
|
||||
// Client-side story persistence helpers.
|
||||
// Client-side story persistence facade.
|
||||
//
|
||||
// Provides: anonymous user ID management, save/load functions that call
|
||||
// /api/stories/* and fallback to localStorage when D1 is unavailable.
|
||||
// Thin wrapper over the browser-local IndexedDB store (lib/persistence/localStore).
|
||||
// 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 { StorySaveInput, SceneSaveInput, CharacterSaveInput, StoryMeta, StoryLoadResult } from "@/lib/db/repositories/storyRepo";
|
||||
|
||||
const USER_ID_KEY = "infiplot:userId";
|
||||
const SAVE_FALLBACK_KEY = "infiplot:savedStories";
|
||||
|
||||
// ── Anonymous User ID ────────────────────────────────────────────────────
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
import type { Session } from "@infiplot/types";
|
||||
import type { StoryMeta } from "@/lib/persistence/types";
|
||||
import {
|
||||
saveStorySession,
|
||||
listStories,
|
||||
loadStorySession as loadSession,
|
||||
softDeleteStory,
|
||||
} from "@/lib/persistence/localStore";
|
||||
|
||||
export type SaveResult =
|
||||
| { ok: true; storyId: string; source: "server" }
|
||||
| { ok: true; storyId: string; source: "localStorage" }
|
||||
| { ok: true; storyId: 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> {
|
||||
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration).
|
||||
// Anonymous D1 writes lack rate limiting / quota / ownership checks — an
|
||||
// abuse risk on a public registration-less site. Persist locally instead.
|
||||
return saveToLocalStorage(session);
|
||||
|
||||
/* 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
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
} catch {
|
||||
// D1 unavailable or network error - fallback to localStorage
|
||||
return saveToLocalStorage(session);
|
||||
}
|
||||
*/
|
||||
const rec = await saveStorySession(session);
|
||||
return rec
|
||||
? { ok: true, storyId: rec.id }
|
||||
: { ok: false, error: "无法保存到本地存储" };
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List saved stories for the "我的剧情" page (newest first). */
|
||||
export async function loadStoryList(): Promise<StoryMeta[]> {
|
||||
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
|
||||
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 [];
|
||||
}
|
||||
*/
|
||||
return listStories();
|
||||
}
|
||||
|
||||
export async function loadStory(storyId: string): Promise<StoryLoadResult | null> {
|
||||
// TEMPORARY: localStorage-only mode — unused in current code (play page uses
|
||||
// loadFromLocalStorage directly). Returns null to maintain type compatibility.
|
||||
// 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;
|
||||
}
|
||||
*/
|
||||
/** Load the full (slim) Session for a saved story, or null if absent/deleted. */
|
||||
export async function loadStorySession(id: string): Promise<Session | null> {
|
||||
return loadSession(id);
|
||||
}
|
||||
|
||||
/** Delete a saved story (soft-delete). Returns false if not found. */
|
||||
export async function deleteStory(storyId: string): Promise<boolean> {
|
||||
// TEMPORARY: localStorage-only mode
|
||||
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,
|
||||
})),
|
||||
};
|
||||
return softDeleteStory(storyId);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -10,6 +10,7 @@ import {
|
||||
resolveEngineConfig,
|
||||
} from "@/lib/clientModelConfig";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { stripSessionVoices } from "@/lib/persistence/sessionSlim";
|
||||
import type {
|
||||
Character,
|
||||
FreeformClassifyRequest,
|
||||
@@ -78,16 +79,11 @@ async function getJson<T>(path: string): Promise<T> {
|
||||
// data is bulky (~160KB/character via referenceAudioBase64) and the
|
||||
// scene-generation / vision / classify pipelines never need it — voices
|
||||
// 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 {
|
||||
return {
|
||||
...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),
|
||||
};
|
||||
return stripSessionVoices(session);
|
||||
}
|
||||
|
||||
// The server strips voice from already-known characters before responding
|
||||
|
||||
@@ -112,6 +112,7 @@ export const en = {
|
||||
start: "Start",
|
||||
loadStory: "Load Story",
|
||||
settings: "Settings",
|
||||
myStories: "My Stories",
|
||||
searchPlaceholder: "Search styles…",
|
||||
noMatchingStyle: "No matching styles",
|
||||
close: "Close",
|
||||
@@ -388,6 +389,22 @@ Dreamy watercolor style with soft tones and nostalgic atmosphere
|
||||
current: "Current 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;
|
||||
|
||||
export type EnTranslations = typeof en;
|
||||
|
||||
@@ -123,6 +123,7 @@ export const ja = {
|
||||
start: "スタート",
|
||||
loadStory: "シナリオ読み込み",
|
||||
settings: "設定",
|
||||
myStories: "マイストーリー",
|
||||
searchPlaceholder: "スタイルを検索…",
|
||||
noMatchingStyle: "一致するスタイルがありません",
|
||||
close: "閉じる",
|
||||
@@ -428,6 +429,22 @@ export const ja = {
|
||||
current: "現在の言語",
|
||||
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;
|
||||
|
||||
export type JaTranslations = typeof ja;
|
||||
|
||||
@@ -123,6 +123,7 @@ export const zhCN = {
|
||||
start: "开始",
|
||||
loadStory: "载入剧情",
|
||||
settings: "设置",
|
||||
myStories: "我的剧情",
|
||||
searchPlaceholder: "搜索风格…",
|
||||
noMatchingStyle: "没有匹配的风格",
|
||||
close: "关闭",
|
||||
@@ -428,6 +429,22 @@ export const zhCN = {
|
||||
current: "当前语言",
|
||||
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;
|
||||
|
||||
export type ZhCNTranslations = typeof zhCN;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -24,7 +24,6 @@
|
||||
"@fortawesome/fontawesome-free": "6.5.1",
|
||||
"@supabase/ssr": "^0.12",
|
||||
"@supabase/supabase-js": "^2.108",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "^16.0.0",
|
||||
@@ -41,7 +40,6 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^10.1.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"postcss": "^8.4.49",
|
||||
"sharp": "^0.33.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
|
||||
Generated
+36
-377
@@ -17,9 +17,6 @@ importers:
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.108
|
||||
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:
|
||||
specifier: ^3.14.0
|
||||
version: 3.14.0
|
||||
@@ -63,9 +60,6 @@ importers:
|
||||
cross-env:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
drizzle-kit:
|
||||
specifier: ^0.31.10
|
||||
version: 0.31.10
|
||||
postcss:
|
||||
specifier: ^8.4.49
|
||||
version: 8.5.15
|
||||
@@ -108,24 +102,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@ast-grep/napi-linux-arm64-musl@0.40.5':
|
||||
resolution: {integrity: sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@ast-grep/napi-linux-x64-gnu@0.40.5':
|
||||
resolution: {integrity: sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@ast-grep/napi-linux-x64-musl@0.40.5':
|
||||
resolution: {integrity: sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@ast-grep/napi-win32-arm64-msvc@0.40.5':
|
||||
resolution: {integrity: sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug==}
|
||||
@@ -389,9 +387,6 @@ packages:
|
||||
resolution: {integrity: sha512-GeDxvtjiRuoyWVU9nQneId879zIyNdL05bS7RKiqMkfBSKpHMWHLoRyRqjYWLaXmX/llKO1hTlqHDmatkQAjPA==}
|
||||
hasBin: true
|
||||
|
||||
'@drizzle-team/brocli@0.10.2':
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
'@ecies/ciphers@0.2.6':
|
||||
resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==}
|
||||
engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'}
|
||||
@@ -404,14 +399,6 @@ packages:
|
||||
'@epic-web/invariant@1.0.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -430,12 +417,6 @@ packages:
|
||||
cpu: [ppc64]
|
||||
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':
|
||||
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -454,12 +435,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -478,12 +453,6 @@ packages:
|
||||
cpu: [arm]
|
||||
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':
|
||||
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -502,12 +471,6 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -526,12 +489,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -550,12 +507,6 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -574,12 +525,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -598,12 +543,6 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -622,12 +561,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -646,12 +579,6 @@ packages:
|
||||
cpu: [arm]
|
||||
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':
|
||||
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -670,12 +597,6 @@ packages:
|
||||
cpu: [ia32]
|
||||
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':
|
||||
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -694,12 +615,6 @@ packages:
|
||||
cpu: [loong64]
|
||||
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':
|
||||
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -718,12 +633,6 @@ packages:
|
||||
cpu: [mips64el]
|
||||
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':
|
||||
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -742,12 +651,6 @@ packages:
|
||||
cpu: [ppc64]
|
||||
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':
|
||||
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -766,12 +669,6 @@ packages:
|
||||
cpu: [riscv64]
|
||||
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':
|
||||
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -790,12 +687,6 @@ packages:
|
||||
cpu: [s390x]
|
||||
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':
|
||||
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -832,12 +723,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -874,12 +759,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -910,12 +789,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -934,12 +807,6 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -958,12 +825,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -982,12 +843,6 @@ packages:
|
||||
cpu: [ia32]
|
||||
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':
|
||||
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1062,155 +917,183 @@ packages:
|
||||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.0.5':
|
||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.0.4':
|
||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.0.4':
|
||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.33.5':
|
||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.33.5':
|
||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.33.5':
|
||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.33.5':
|
||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.33.5':
|
||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||
@@ -1295,24 +1178,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.7':
|
||||
resolution: {integrity: sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.7':
|
||||
resolution: {integrity: sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.7':
|
||||
resolution: {integrity: sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.7':
|
||||
resolution: {integrity: sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==}
|
||||
@@ -1858,102 +1745,6 @@ packages:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
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:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2001,11 +1792,6 @@ packages:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.18.20:
|
||||
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
esbuild@0.25.4:
|
||||
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2137,9 +1923,6 @@ packages:
|
||||
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
get-tsconfig@4.14.0:
|
||||
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2611,9 +2394,6 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
resolve@1.22.12:
|
||||
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3580,8 +3360,6 @@ snapshots:
|
||||
picomatch: 4.0.4
|
||||
which: 4.0.0
|
||||
|
||||
'@drizzle-team/brocli@0.10.2': {}
|
||||
|
||||
'@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
|
||||
dependencies:
|
||||
'@noble/ciphers': 1.3.0
|
||||
@@ -3593,16 +3371,6 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
optional: true
|
||||
|
||||
@@ -3612,9 +3380,6 @@ snapshots:
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3624,9 +3389,6 @@ snapshots:
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3636,9 +3398,6 @@ snapshots:
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3648,9 +3407,6 @@ snapshots:
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3660,9 +3416,6 @@ snapshots:
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3672,9 +3425,6 @@ snapshots:
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3684,9 +3434,6 @@ snapshots:
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3696,9 +3443,6 @@ snapshots:
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3708,9 +3452,6 @@ snapshots:
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3720,9 +3461,6 @@ snapshots:
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3732,9 +3470,6 @@ snapshots:
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3744,9 +3479,6 @@ snapshots:
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3756,9 +3488,6 @@ snapshots:
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3768,9 +3497,6 @@ snapshots:
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3780,9 +3506,6 @@ snapshots:
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3792,9 +3515,6 @@ snapshots:
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3813,9 +3533,6 @@ snapshots:
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3834,9 +3551,6 @@ snapshots:
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3852,9 +3566,6 @@ snapshots:
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3864,9 +3575,6 @@ snapshots:
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3876,9 +3584,6 @@ snapshots:
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -3888,9 +3593,6 @@ snapshots:
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
@@ -4703,18 +4405,6 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -4760,31 +4450,6 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
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:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.4
|
||||
@@ -5026,10 +4691,6 @@ snapshots:
|
||||
|
||||
get-stream@6.0.1: {}
|
||||
|
||||
get-tsconfig@4.14.0:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -5422,8 +5083,6 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
resolve@1.22.12:
|
||||
dependencies:
|
||||
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
@@ -59,34 +59,8 @@
|
||||
//
|
||||
// See .dev.vars.example for a full reference of all variables.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Cloudflare D1 database (story persistence — optional) ───────────
|
||||
// Not required for core gameplay. Uncomment and fill in your ID if needed:
|
||||
// wrangler d1 create infiplot-db
|
||||
// "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>"
|
||||
// }
|
||||
// ]
|
||||
//
|
||||
// Story persistence is browser-local (IndexedDB) in the open-source build and
|
||||
// Supabase (Postgres) in the commercial build — no Cloudflare D1/R2/KV
|
||||
// bindings are used.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user