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>
|
||||
|
||||
+20
-33
@@ -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,25 +1479,21 @@ 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.
|
||||
// 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 () => {
|
||||
try {
|
||||
const sb = createSupabaseClient();
|
||||
const { data } = await sb.auth.getUser();
|
||||
if (!data.user) {
|
||||
// OAuth failed/not signed in → fall back to normal bootstrap.
|
||||
const snap = await consumeResumeSnapshot<PlayResumeSnapshot>(
|
||||
PLAY_RESUME_KEY,
|
||||
);
|
||||
if (!snap) {
|
||||
startedRef.current = false;
|
||||
setRetryBootstrap((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
await restorePlayResume(
|
||||
JSON.parse(resumeRaw) as PlayResumeSnapshot,
|
||||
);
|
||||
try {
|
||||
await restorePlayResume(snap);
|
||||
} catch {
|
||||
// Corrupt snapshot / network — relinquish and bootstrap normally.
|
||||
startedRef.current = false;
|
||||
@@ -1514,7 +1502,6 @@ function PlayInner() {
|
||||
})();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 三条进入路径:
|
||||
// ?card=<m0..f31> → 首页精选卡,直接从 /home/firstact/{name}.json
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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<boolean> {
|
||||
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<T>(
|
||||
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.
|
||||
export async function consumeResumeSnapshot<T>(key: string): Promise<T | null> {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(key);
|
||||
if (!(await isAuthed())) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null; // corrupt snapshot — ignore
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user