refactor(auth): share OAuth-resume plumbing between home and play pages
Extract the page-agnostic resume primitives into lib/authResume.ts: - isAuthed() — single login check (was duplicated in app/page.tsx) - writeResumeSnapshot(key, primary, fallbacks) — quota-safe sessionStorage write with ordered lighter-payload fallbacks (was hand-rolledTry/catch in both pages) - consumeResumeSnapshot<T>(key) — consume-once resume gate that verifies the user is signed in before returning the snapshot, else clears it Both pages now share this plumbing while keeping their own snapshot shapes and restore side effects (home: form fields + start(); play: Session + restorePlayResume + deferred action replay). Unify the persist trigger: home previously snapshotted eagerly inside start() before opening the modal, while play snapshotted in AuthModal.onBeforeOAuth at redirect time. Move home to the same onBeforeOAuth trigger so both pages persist at the single OAuth-redirect instant — the eager-snapshot special case is gone, and OTP (no redirect) keeps its in-place onSuccess resume on both pages. Net: -21 lines. Behavior preserved for OTP; OAuth resume now consistent.
This commit is contained in:
+24
-32
@@ -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<boolean> {
|
||||
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,10 @@ export default function HomePage() {
|
||||
start();
|
||||
}
|
||||
}}
|
||||
// Snapshot the form the instant the OAuth redirect begins — the
|
||||
// round-trip unmounts the page and discards in-memory state. Fires
|
||||
// only for Google/GitHub (signInWithOAuth), not OTP.
|
||||
onBeforeOAuth={persistPendingStart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+32
-45
@@ -52,7 +52,7 @@ import type {
|
||||
} from "@infiplot/types";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||
import { createClient as createSupabaseClient } from "@/lib/supabase/client";
|
||||
import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume";
|
||||
import { AuthModal } from "@/components/AuthModal";
|
||||
import { UserChip } from "@/components/UserChip";
|
||||
|
||||
@@ -709,23 +709,15 @@ function PlayInner() {
|
||||
imageOriginalUrl,
|
||||
pendingAction: pendingResumeActionRef.current ?? undefined,
|
||||
};
|
||||
const tryWrite = (payload: PlayResumeSnapshot): boolean => {
|
||||
try {
|
||||
sessionStorage.setItem(PLAY_RESUME_KEY, JSON.stringify(payload));
|
||||
return true;
|
||||
} catch {
|
||||
return false; // QuotaExceededError — try a lighter payload below
|
||||
}
|
||||
};
|
||||
if (tryWrite(snap)) return;
|
||||
// Drop the heaviest data-URL field (user-uploaded style ref, ~100KB) and
|
||||
// retry. It only affects the Painter on FUTURE scenes, not the resumed
|
||||
// scene, so losing it degrades gracefully. Voices are deliberately kept —
|
||||
// preserving voice continuity matters more than the rare quota miss, and a
|
||||
// typical session (remote-image URLs + a few ~160KB voice refs) fits well
|
||||
// under sessionStorage's 5MB cap. If still too big, give up: resume is
|
||||
// disabled for this trip and the page falls back to normal bootstrap.
|
||||
tryWrite({ ...snap, session: { ...sess, styleReferenceImage: 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
|
||||
@@ -1487,33 +1479,28 @@ function PlayInner() {
|
||||
// login never writes a snapshot — its onSuccess retry keeps state
|
||||
// in-memory.
|
||||
if (AUTH_ENABLED) {
|
||||
const resumeRaw = sessionStorage.getItem(PLAY_RESUME_KEY);
|
||||
if (resumeRaw) {
|
||||
sessionStorage.removeItem(PLAY_RESUME_KEY);
|
||||
// Let the async resume run; on failure it relinquishes the slot so the
|
||||
// normal bootstrap below re-runs. Either way return here — the sync
|
||||
// body must not run while the OAuth return is being reconciled.
|
||||
void (async () => {
|
||||
try {
|
||||
const sb = createSupabaseClient();
|
||||
const { data } = await sb.auth.getUser();
|
||||
if (!data.user) {
|
||||
// OAuth failed/not signed in → fall back to normal bootstrap.
|
||||
startedRef.current = false;
|
||||
setRetryBootstrap((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
await restorePlayResume(
|
||||
JSON.parse(resumeRaw) as PlayResumeSnapshot,
|
||||
);
|
||||
} catch {
|
||||
// Corrupt snapshot / network — relinquish and bootstrap normally.
|
||||
startedRef.current = false;
|
||||
setRetryBootstrap((n) => n + 1);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
// Let the async resume run; on failure (no snapshot / not signed in /
|
||||
// corrupt) it relinquishes the slot so the normal bootstrap below
|
||||
// re-runs. Either way return here — the sync body must not run while the
|
||||
// OAuth return is being reconciled.
|
||||
void (async () => {
|
||||
const snap = await consumeResumeSnapshot<PlayResumeSnapshot>(
|
||||
PLAY_RESUME_KEY,
|
||||
);
|
||||
if (!snap) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 三条进入路径:
|
||||
|
||||
Reference in New Issue
Block a user