diff --git a/app/page.tsx b/app/page.tsx index 1d42bd0..2833abf 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,7 +17,7 @@ import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelCon import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction"; import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare"; import { AUTH_ENABLED } from "@/lib/supabase/config"; -import { createClient as createSupabaseClient } from "@/lib/supabase/client"; +import { isAuthed, writeResumeSnapshot } from "@/lib/authResume"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; @@ -854,15 +854,6 @@ function CategorySelect({ const PENDING_START_KEY = "infiplot:pending-start"; const PENDING_PARSE_KEY = "infiplot:pending-parse"; -// True when auth is disabled (self-host with blank Supabase env) or the visitor -// already has a session. Gates the vision call behind login. -async function isAuthed(): Promise { - if (!AUTH_ENABLED) return true; - const sb = createSupabaseClient(); - const { data } = await sb.auth.getUser(); - return !!data.user; -} - // Shared by the StyleModal uploader and the post-login resume path: turns a // resized data URL into an English style prompt, via the browser engine when a // BYO model config is present, otherwise the server route. @@ -1399,19 +1390,11 @@ export default function HomePage() { const persistPendingStart = () => { const snap = { prompt, sel, customStyleGuide, customStyleRefImage, playerName }; - try { - sessionStorage.setItem(PENDING_START_KEY, JSON.stringify(snap)); - } catch { - // Quota is usually blown by the data-URL style ref; drop it, keep text. - try { - sessionStorage.setItem( - PENDING_START_KEY, - JSON.stringify({ ...snap, customStyleRefImage: "" }), - ); - } catch { - /* still too big — give up on resume, the form just clears */ - } - } + // Quota fallback: the data-URL style ref (~100KB) is the usual culprit — + // drop it first; text-only form still resumes the start. + writeResumeSnapshot(PENDING_START_KEY, snap, [ + { ...snap, customStyleRefImage: "" }, + ]); }; const resumePendingParse = async () => { @@ -1461,12 +1444,15 @@ export default function HomePage() { // is now signed in, restore and continue; otherwise clear stale snapshots. useEffect(() => { if (!AUTH_ENABLED) return; - const hasPending = - sessionStorage.getItem(PENDING_START_KEY) !== null || - sessionStorage.getItem(PENDING_PARSE_KEY) !== null; - if (!hasPending) return; + const hasStart = sessionStorage.getItem(PENDING_START_KEY) !== null; + const hasParse = sessionStorage.getItem(PENDING_PARSE_KEY) !== null; + if (!hasStart && !hasParse) return; let cancelled = false; void (async () => { + // Gate BOTH snapshots on auth: a stale leftover from an abandoned login + // must not resurrect a half-flow. The parse key stores a raw data URL + // with its own restore path (resumePendingParse), so both are gated + // manually here rather than via consumeResumeSnapshot. if (!(await isAuthed())) { sessionStorage.removeItem(PENDING_START_KEY); sessionStorage.removeItem(PENDING_PARSE_KEY); @@ -1492,10 +1478,11 @@ export default function HomePage() { const start = async () => { if (AUTH_ENABLED) { - const sb = createSupabaseClient(); - const { data } = await sb.auth.getUser(); - if (!data.user) { - persistPendingStart(); + if (!(await isAuthed())) { + // Don't snapshot here — persistPendingStart fires via + // AuthModal.onBeforeOAuth at redirect time, so the form is captured + // for BOTH OAuth and (harmlessly) OTP paths at the single source of + // truth. OTP's onSuccess resumes in-place without needing the snapshot. setPendingAction("start"); setAuthModalOpen(true); return; @@ -1992,7 +1979,8 @@ export default function HomePage() { onSuccess={() => { setAuthModalOpen(false); // Email-OTP stays on the page, so resume inline: parse first (it - // reads its own snapshot), then the pending start. + // reads its own snapshot), then the pending start. OTP never + // triggers onBeforeOAuth, so no PENDING_START snapshot was written. void resumePendingParse(); if (pendingAction === "start") { setPendingAction(null); @@ -2004,6 +1992,16 @@ export default function HomePage() { start(); } }} + // + // Only snapshot when the user is mid-start: the OAuth redirect also + // fires for bare logins (UserChip / StyleModal onRequireAuth), where + // the user just wants to sign in — not kick off a game. Guarding on + // pendingAction keeps bare logins from auto-starting a session on + // return. (start() sets pendingAction="start" right before opening + // this modal.) + onBeforeOAuth={() => { + if (pendingAction === "start") persistPendingStart(); + }} /> )} diff --git a/app/play/page.tsx b/app/play/page.tsx index 06ef63c..c2db30c 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -52,10 +52,39 @@ import type { } from "@infiplot/types"; import { track } from "@/lib/analytics"; import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; const MUTED_STORAGE_KEY = "infiplot:muted"; +// One-shot snapshot of in-progress game state, written just before an OAuth +// full-page redirect (Google/GitHub) so the play page can resume the exact +// scene/beat after the round-trip. The redirect unmounts the app and destroys +// the in-memory Session (the server is stateless), so without this the play +// page re-bootstraps from `?card=…` and restarts the story. OTP login keeps +// state in-memory (no redirect) and never writes this. Consumed once on mount. +const PLAY_RESUME_KEY = "infiplot:play-resume"; + +// Serializable form of the action intercepted by a 401. `persistPlayResume` +// stashes whichever one is pending into sessionStorage; the deferred-replay +// effect re-dispatches it after `restorePlayResume` commits the restored state. +type PendingResumeAction = + | { kind: "choice"; choice: BeatChoice } + | { kind: "freeform"; text: string } + | { kind: "background-click"; x: number; y: number }; + +// Shape written to sessionStorage[PLAY_RESUME_KEY]. `imageOriginalUrl` is the +// remote CDN URL (never the blob: URL — those are revoked on unmount and won't +// survive the full-page reload); restorePlayResume re-resolves it to a fresh +// blob via getOrCreateBlobUrl. +type PlayResumeSnapshot = { + session: Session; + beatId: string; + visitedBeats: string[]; + orientation: Orientation; + imageOriginalUrl: string; + pendingAction?: PendingResumeAction; +}; // Consecutive silent (no-audio) beats before we surface the BYO-key nudge to a // non-BYO, unmuted player. Set high enough that one transient miss won't trip @@ -618,6 +647,22 @@ function PlayInner() { const [visionClickEnabled, setVisionClickEnabled] = useState(true); const [authModalOpen, setAuthModalOpen] = useState(false); const authResolveRef = useRef<(() => void) | null>(null); + // Serializable description of the action that hit the 401 (choice / freeform + // text / background-click coords), captured alongside the retry closure. An + // OAuth round-trip destroys the closure, but this survives in sessionStorage + // so the exact action can be replayed after game state is restored. + const pendingResumeActionRef = useRef(null); + // Set by restorePlayResume when a snapshot carries a pending action; a + // dedicated effect dispatches it once the restored state has committed + // (phase "ready", session + scene present), then clears it. Mirrors the + // homepage's autoStartPending resume pattern. + const [pendingReplayAction, setPendingReplayAction] = + useState(null); + // Bumped by the OAuth-resume fallback to retrigger the bootstrap effect after + // relinquishing its `startedRef` slot (snapshot consumed but user not signed + // in → run normal card/preset/custom bootstrap instead of leaving a blank + // loading screen). + const [retryBootstrap, setRetryBootstrap] = useState(0); // Top-of-screen progress toast for the gallery / story export pipeline. // null when idle; { done, total, label } while collecting beat audio. const [exportProgress, setExportProgress] = useState< @@ -627,10 +672,18 @@ function PlayInner() { // `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess // after the user signs in. Omitted by callers whose path can't actually 401 // (initial load already gated on the homepage, recorded replay is local). + // `action` is the serializable twin of `retry`: same intent, but survives an + // OAuth full-page redirect via sessionStorage so it can be replayed after + // game state is restored (the retry closure itself is destroyed on unmount). const handleAuthError = useCallback( - (e: unknown, retry?: () => void): boolean => { + ( + e: unknown, + retry?: () => void, + action?: PendingResumeAction, + ): boolean => { if (e instanceof AuthRequiredError) { authResolveRef.current = retry ?? null; + pendingResumeActionRef.current = action ?? null; setAuthModalOpen(true); return true; } @@ -639,6 +692,64 @@ function PlayInner() { [], ); + // Snapshot the in-progress game just before an OAuth full-page redirect so + // the play page can resume the exact scene/beat on return. Reads only refs + // (stable across renders), so an empty dep list is safe. Mirrors the + // homepage's persistPendingStart + quota-fallback degradation. + const persistPlayResume = useCallback((): void => { + const sess = sessionRef.current; + const beat = currentBeatRef.current; + const imageOriginalUrl = lastImageOriginalUrlRef.current; + if (!sess || !beat || !imageOriginalUrl) return; + const snap: PlayResumeSnapshot = { + session: sess, + beatId: beat.id, + visitedBeats: [...visitedBeatsRef.current], + orientation: sess.orientation ?? "landscape", + imageOriginalUrl, + pendingAction: pendingResumeActionRef.current ?? undefined, + }; + // Quota-safe write: the only heavy field is the user-uploaded style ref + // (~100KB data URL), which only affects the Painter on FUTURE scenes, not + // the resumed scene — so stripping it degrades gracefully. Voices are + // deliberately kept (continuity > rare quota miss; a typical session of + // remote-image URLs + a few ~160KB voice refs fits under the 5MB cap). + writeResumeSnapshot(PLAY_RESUME_KEY, snap, [ + // Fallback: drop the style-reference data URL from the session. + { ...snap, session: { ...sess, styleReferenceImage: undefined } }, + ]); + }, []); + + // Restore an in-progress game from a PLAY_RESUME_KEY snapshot after an OAuth + // round-trip. Re-resolves the remote image URL to a fresh blob (the old blob + // was revoked on unmount), repopulates the runtime refs the handlers read, + // and hands any pending action to the deferred-replay effect. Throws on a + // corrupt snapshot so the caller can fall back to normal bootstrap. + const restorePlayResume = useCallback( + async (snap: PlayResumeSnapshot): Promise => { + const last = snap.session.history[snap.session.history.length - 1]; + if (!last?.scene) throw new Error("resume snapshot missing current scene"); + + setOrientation(snap.orientation); + visitedBeatsRef.current = [...snap.visitedBeats]; + lastImageOriginalUrlRef.current = snap.imageOriginalUrl; + + setSession(snap.session); + setCurrentScene(last.scene); + setCurrentBeatId(snap.beatId); + + const blobUrl = await getOrCreateBlobUrl(snap.imageOriginalUrl); + const ready = waitForImageReady(); + setImageUrl(blobUrl); + await ready; + setPhase("ready"); + track("scene_reached", { scene_index: snap.session.history.length }); + + if (snap.pendingAction) setPendingReplayAction(snap.pendingAction); + }, + [], + ); + const startedRef = useRef(false); const poolRef = useRef>(new Map()); // Accumulator for resolved prefetches across the whole session — every @@ -1359,6 +1470,47 @@ function PlayInner() { if (startedRef.current) return; startedRef.current = true; + // ── OAuth resume ──────────────────────────────────────────────── + // Returning from a Google/GitHub round-trip? The full-page redirect + // destroyed the in-memory Session; if we stashed a snapshot just before + // navigating away (persistPlayResume via AuthModal.onBeforeOAuth) and the + // user is now signed in, restore the exact scene/beat instead of + // re-bootstrapping from `?card=…` (which would restart the story). OTP + // login never writes a snapshot — its onSuccess retry keeps state + // in-memory. + // + // Peek before awaiting: when there's no snapshot (the common case — + // normal card/preset/custom entry), fall straight through to the + // bootstrap below. Only when a snapshot exists do we enter the async + // gate, which itself removes the entry. This keeps the no-snapshot path + // off the retryBootstrap re-trigger loop entirely. + if ( + AUTH_ENABLED && + sessionStorage.getItem(PLAY_RESUME_KEY) !== null + ) { + void (async () => { + const snap = await consumeResumeSnapshot( + PLAY_RESUME_KEY, + ); + if (!snap) { + // Snapshot existed but user isn't signed in / payload corrupt → + // consumeResumeSnapshot already removed it. Relinquish the slot so + // the normal bootstrap below re-runs on the next effect cycle. + startedRef.current = false; + setRetryBootstrap((n) => n + 1); + return; + } + try { + await restorePlayResume(snap); + } catch { + // Corrupt snapshot / network — relinquish and bootstrap normally. + startedRef.current = false; + setRetryBootstrap((n) => n + 1); + } + })(); + return; + } + // 三条进入路径: // ?card= → 首页精选卡,直接从 /home/firstact/{name}.json // 静态文件加载(已在构建期 prebake,免一切引擎调用) @@ -1579,7 +1731,30 @@ function PlayInner() { setError(String(e)); } }); - }, [params, router]); + }, [params, router, retryBootstrap, restorePlayResume]); + + // ── Deferred replay of the action that hit 401 (OAuth resume) ───────── + // After restorePlayResume commits the restored session/scene/beat, dispatch + // the pending action so the player lands exactly where they were headed + // (seamless continuation). Runs once the restored state is interactive, + // then clears the slot. Mirrors the homepage's autoStartPending pattern. + useEffect(() => { + if (!pendingReplayAction) return; + if (phase !== "ready" || !session || !currentScene) return; + const action = pendingReplayAction; + setPendingReplayAction(null); + if (action.kind === "choice") { + onSelectChoice(action.choice); + } else if (action.kind === "freeform") { + void onFreeformInput(action.text); + } else { + void onBackgroundClick({ x: action.x, y: action.y }); + } + // onSelectChoice/onFreeformInput/onBackgroundClick are stable inner + // functions keyed off the restored state; listing them would re-fire on + // every render, so we intentionally scope deps to the readiness gate. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingReplayAction, phase, session, currentScene]); // ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ────── useEffect(() => { @@ -1647,6 +1822,7 @@ function PlayInner() { visitedForCurrent: string[], exitLabel: string, retry?: () => void, + action?: PendingResumeAction, ) { const sceneT0 = Date.now(); setPhase("transitioning"); @@ -1706,7 +1882,7 @@ function PlayInner() { setPhase("ready"); return; } - if (!handleAuthError(e, retry)) { + if (!handleAuthError(e, retry, action)) { trackPlayError("scene", e, sceneT0); setError(String(e)); } @@ -1903,8 +2079,13 @@ function PlayInner() { const cached = consumeChoice(poolRef.current, choice.id); if (cached) { - void performSceneTransition(cached, exit, visited, choice.label, () => - onSelectChoice(choice), + void performSceneTransition( + cached, + exit, + visited, + choice.label, + () => onSelectChoice(choice), + { kind: "choice", choice }, ); return; } @@ -1930,8 +2111,13 @@ function PlayInner() { return data; })(); - void performSceneTransition(promise, exit, visited, choice.label, () => - onSelectChoice(choice), + void performSceneTransition( + promise, + exit, + visited, + choice.label, + () => onSelectChoice(choice), + { kind: "choice", choice }, ); } @@ -2037,9 +2223,10 @@ function PlayInner() { visited, decision.freeformAction, () => onFreeformInput(text), + { kind: "freeform", text }, ); } catch (e) { - if (!handleAuthError(e, () => onFreeformInput(text))) { + if (!handleAuthError(e, () => onFreeformInput(text), { kind: "freeform", text })) { trackPlayError("freeform", e, freeformT0); setError(String(e)); } @@ -2146,10 +2333,11 @@ function PlayInner() { visited, decision.intent.freeformAction, () => onBackgroundClick(click), + { kind: "background-click", x: click.x, y: click.y }, ); } } catch (e) { - if (!handleAuthError(e, () => onBackgroundClick(click))) { + if (!handleAuthError(e, () => onBackgroundClick(click), { kind: "background-click", x: click.x, y: click.y })) { trackPlayError("vision", e, visionT0); setError(String(e)); } @@ -2259,13 +2447,16 @@ function PlayInner() { setAuthModalOpen(false); // User dismissed login — drop the retry, don't re-run the action. authResolveRef.current = null; + pendingResumeActionRef.current = null; }} onSuccess={() => { setAuthModalOpen(false); const retry = authResolveRef.current; authResolveRef.current = null; + pendingResumeActionRef.current = null; retry?.(); }} + onBeforeOAuth={persistPlayResume} /> )} @@ -2457,13 +2648,16 @@ function PlayInner() { setAuthModalOpen(false); // User dismissed login — drop the retry, don't re-run the action. authResolveRef.current = null; + pendingResumeActionRef.current = null; }} onSuccess={() => { setAuthModalOpen(false); const retry = authResolveRef.current; authResolveRef.current = null; + pendingResumeActionRef.current = null; retry?.(); }} + onBeforeOAuth={persistPlayResume} /> )} diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx index 8f1cd9b..3ea4c48 100644 --- a/components/AuthModal.tsx +++ b/components/AuthModal.tsx @@ -9,9 +9,16 @@ type AuthStep = "pick" | "email-input" | "otp-verify"; export function AuthModal({ onClose, onSuccess, + onBeforeOAuth, }: { onClose: () => void; onSuccess: () => void; + // Fires synchronously before the OAuth full-page redirect (signInWithOAuth + // navigates the browser away, unmounting the whole React tree). Hosts that + // need to survive the round-trip (e.g. play page carrying in-memory game + // state) snapshot into sessionStorage here — sessionStorage.setItem is + // synchronous, so it completes before the navigation begins. + onBeforeOAuth?: () => void; }) { const [step, setStep] = useState("pick"); const [email, setEmail] = useState(""); @@ -31,6 +38,16 @@ export function AuthModal({ async (provider: "google" | "github") => { setLoading(true); setError(""); + // Snapshot before navigating away — the redirect below unmounts the app, + // so any host state must be persisted to sessionStorage *now*. + // Non-fatal: if the snapshot fails (e.g. sessionStorage is blocked in + // privacy mode), the OAuth flow still proceeds — the user just won't + // have their in-progress state restored on return. + try { + onBeforeOAuth?.(); + } catch { + /* snapshot failure is non-fatal */ + } const supabase = createClient(); const { error: oauthError } = await supabase.auth.signInWithOAuth({ provider, @@ -43,7 +60,7 @@ export function AuthModal({ setLoading(false); } }, - [], + [onBeforeOAuth], ); const handleSendOtp = useCallback(async () => { diff --git a/lib/authResume.ts b/lib/authResume.ts new file mode 100644 index 0000000..b2358eb --- /dev/null +++ b/lib/authResume.ts @@ -0,0 +1,89 @@ +// Shared primitives for surviving an OAuth full-page round-trip. +// +// Google / GitHub OAuth is a full-page redirect: it unmounts the React tree +// and discards all in-memory state (the server is stateless, so the client +// carries everything). To resume where the user left off after the redirect, +// a page snapshots its domain state into sessionStorage just before navigating +// away, then consumes the snapshot on the next mount — but only if the user is +// now actually signed in. +// +// Email-OTP login never redirects (it resolves in-page), so it bypasses this +// machinery entirely and resumes synchronously via AuthModal.onSuccess. +// +// This module holds the three page-agnostic pieces: the login check, a +// quota-safe sessionStorage write (heavy data-URL fields are stripped on +// QuotaExceededError), and the consume-once resume gate. Each page keeps its +// own snapshot shape and restore side effects — only the plumbing is shared. + +import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { createClient as createSupabaseClient } from "@/lib/supabase/client"; + +// True when auth is disabled (self-host with blank Supabase env) or the visitor +// already has a session. Gates any auth-required action (and the resume path). +export async function isAuthed(): Promise { + if (!AUTH_ENABLED) return true; + const sb = createSupabaseClient(); + const { data } = await sb.auth.getUser(); + return !!data.user; +} + +// Write a resume snapshot to sessionStorage with a quota-safe fallback. +// `fallbacks` is an ordered list of progressively-lighter payloads to try if +// the primary write fails (typically QuotaExceededError from a data-URL image). +// Each fallback drops some non-essential heavy field while keeping the data +// needed to resume. A dropped field only affects *future* generation (e.g. the +// painter on later scenes), never the scene being resumed, so degrading is +// graceful. Returns true if any write succeeded. +export function writeResumeSnapshot( + key: string, + primary: T, + fallbacks: readonly T[] = [], +): boolean { + const tryWrite = (candidate: T): boolean => { + try { + sessionStorage.setItem(key, JSON.stringify(candidate)); + return true; + } catch { + return false; // QuotaExceededError (or disabled storage) + } + }; + if (tryWrite(primary)) return true; + for (const fb of fallbacks) { + if (tryWrite(fb)) return true; + } + return false; +} + +// Consume-once resume gate. Returns the parsed snapshot if one exists at `key` +// AND the user is now signed in (so a stale snapshot from a failed/abandoned +// login doesn't resurrect a half-flow). Always removes the entry — either it's +// consumed here, or it's stale and must not linger. Returns null when there's +// nothing to resume, the user isn't signed in, or the payload is corrupt. +// +// `removeItem` intentionally runs before `isAuthed()` so that a network error +// during the auth check does not leave a zombie snapshot behind. Without this +// ordering, callers that guard on the snapshot's presence (play-page bootstrap) +// would re-enter this path on every effect cycle, producing an infinite retry +// loop. Dropping the snapshot on a transient network glitch is an acceptable +// trade-off — the worst case is the user lands on the first scene instead of +// resuming mid-story, which is the same experience as before this feature. +export async function consumeResumeSnapshot(key: string): Promise { + const raw = sessionStorage.getItem(key); + if (!raw) return null; + sessionStorage.removeItem(key); + let authed: boolean; + try { + authed = await isAuthed(); + } catch { + // Network / unexpected error during auth check. Snapshot already removed + // (prevents the caller's retry loop); return null so callers fall back to + // their default path (normal bootstrap). + return null; + } + if (!authed) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; // corrupt snapshot — ignore + } +}