feat: add reading settings sync
This commit is contained in:
+140
-28
@@ -7,6 +7,8 @@ import { Settings, Sun, Moon, Monitor } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { readingPrefsApi } from "@/lib/reading-prefs";
|
||||
|
||||
const emptySubscribe = () => () => {};
|
||||
|
||||
@@ -111,31 +113,42 @@ const FONT_SIZES = [
|
||||
|
||||
const STORAGE_KEY = "spsays_reading_prefs";
|
||||
|
||||
/** Coerce an unknown blob into a valid ReadingPrefs, falling back to
|
||||
* defaults for any missing / invalid field. Used by both the localStorage
|
||||
* loader and the remote-sync hydration so the same validation runs
|
||||
* against either source. */
|
||||
function sanitizePrefs(raw: unknown, fallback: ReadingPrefs = DEFAULT_PREFS): ReadingPrefs {
|
||||
if (!raw || typeof raw !== "object") return fallback;
|
||||
const p = raw as Partial<Record<keyof ReadingPrefs, unknown>>;
|
||||
return {
|
||||
serifFont:
|
||||
typeof p.serifFont === "boolean" ? p.serifFont : fallback.serifFont,
|
||||
fontSize:
|
||||
typeof p.fontSize === "string" &&
|
||||
FONT_SIZES.some((f) => f.value === p.fontSize)
|
||||
? p.fontSize
|
||||
: fallback.fontSize,
|
||||
justified:
|
||||
typeof p.justified === "boolean" ? p.justified : fallback.justified,
|
||||
lightPalette:
|
||||
typeof p.lightPalette === "string" &&
|
||||
LIGHT_PALETTE_IDS.includes(p.lightPalette as LightPalette)
|
||||
? (p.lightPalette as LightPalette)
|
||||
: fallback.lightPalette,
|
||||
darkPalette:
|
||||
typeof p.darkPalette === "string" &&
|
||||
DARK_PALETTE_IDS.includes(p.darkPalette as DarkPalette)
|
||||
? (p.darkPalette as DarkPalette)
|
||||
: fallback.darkPalette,
|
||||
};
|
||||
}
|
||||
|
||||
function loadPrefs(): ReadingPrefs {
|
||||
if (typeof window === "undefined") return DEFAULT_PREFS;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_PREFS;
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
serifFont:
|
||||
typeof parsed.serifFont === "boolean"
|
||||
? parsed.serifFont
|
||||
: DEFAULT_PREFS.serifFont,
|
||||
fontSize: FONT_SIZES.some((f) => f.value === parsed.fontSize)
|
||||
? parsed.fontSize
|
||||
: DEFAULT_PREFS.fontSize,
|
||||
justified:
|
||||
typeof parsed.justified === "boolean"
|
||||
? parsed.justified
|
||||
: DEFAULT_PREFS.justified,
|
||||
lightPalette: LIGHT_PALETTE_IDS.includes(parsed.lightPalette)
|
||||
? parsed.lightPalette
|
||||
: DEFAULT_PREFS.lightPalette,
|
||||
darkPalette: DARK_PALETTE_IDS.includes(parsed.darkPalette)
|
||||
? parsed.darkPalette
|
||||
: DEFAULT_PREFS.darkPalette,
|
||||
};
|
||||
return sanitizePrefs(JSON.parse(raw));
|
||||
} catch {
|
||||
return DEFAULT_PREFS;
|
||||
}
|
||||
@@ -294,6 +307,99 @@ export function ReadingSettings({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Account-level sync ────────────────────────────────────────────────
|
||||
//
|
||||
// Reading prefs roam with the user account: changing the palette on one
|
||||
// device should show up on another after sign-in. The localStorage copy
|
||||
// is still authoritative for anonymous sessions and as an offline cache.
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. On sign-in (user.id transitions from null/different → set), GET
|
||||
// remote prefs and hydrate.
|
||||
// - Empty/missing remote → upload the device's local prefs so
|
||||
// the account is seeded with whatever the user has been using.
|
||||
// - Non-empty remote → server wins on overlap (last device's
|
||||
// tuning beats this device's stale local copy).
|
||||
// 2. While signed in, every updatePrefs schedules a debounced PUT.
|
||||
// 3. On sign-out, we just stop syncing — the local copy stays so the
|
||||
// next anonymous reading session looks unchanged.
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const userId = user?.id ?? null;
|
||||
const hydratedForUserRef = useRef<string | null>(null);
|
||||
const pushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || !userId) return;
|
||||
if (hydratedForUserRef.current === userId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const remote = await readingPrefsApi.get();
|
||||
if (cancelled) return;
|
||||
hydratedForUserRef.current = userId;
|
||||
const hasRemote = !!remote && Object.keys(remote).length > 0;
|
||||
if (hasRemote) {
|
||||
// Merge: server keys override local, anything the server hasn't
|
||||
// seen still wins from local. sanitizePrefs drops garbage.
|
||||
setPrefs((prev) => {
|
||||
const merged = sanitizePrefs({ ...prev, ...remote }, prev);
|
||||
savePrefs(merged);
|
||||
applyClasses(merged);
|
||||
return merged;
|
||||
});
|
||||
} else {
|
||||
// Account has no prefs yet — seed it with this device's local
|
||||
// copy so the user doesn't lose their tuning on first sign-in.
|
||||
// Fire-and-forget; failure just leaves the account empty and
|
||||
// we'll retry next sign-in.
|
||||
readingPrefsApi
|
||||
.put(prefs as unknown as Record<string, unknown>)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Network/auth failure — silent. Reading prefs should never gate
|
||||
// the UI; the local copy already drives the page.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `prefs` is intentionally NOT a dep — re-running on every prefs
|
||||
// change would defeat the "hydrate once per sign-in" guard. Reading
|
||||
// it here always sees the latest value via closure-on-effect-start.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, authLoading]);
|
||||
|
||||
// Reset the hydration latch on sign-out so the next sign-in re-fetches.
|
||||
useEffect(() => {
|
||||
if (!userId) hydratedForUserRef.current = null;
|
||||
}, [userId]);
|
||||
|
||||
// Debounced server push. Called from updatePrefs whenever the user
|
||||
// changes a setting while signed in. 500 ms is long enough to coalesce
|
||||
// a font-size slider drag into one round-trip but short enough that a
|
||||
// tab close right after a change still lands the update.
|
||||
const queueServerPush = useCallback(
|
||||
(next: ReadingPrefs) => {
|
||||
if (!userId) return;
|
||||
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
||||
pushTimerRef.current = setTimeout(() => {
|
||||
readingPrefsApi
|
||||
.put(next as unknown as Record<string, unknown>)
|
||||
.catch(() => {
|
||||
// Drop silently — local is the source of truth for this device.
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
[userId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -323,14 +429,20 @@ export function ReadingSettings({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const updatePrefs = useCallback((update: Partial<ReadingPrefs>) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, ...update };
|
||||
savePrefs(next);
|
||||
applyClasses(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const updatePrefs = useCallback(
|
||||
(update: Partial<ReadingPrefs>) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, ...update };
|
||||
savePrefs(next);
|
||||
applyClasses(next);
|
||||
// Mirror to the user's account if they're signed in. Anonymous
|
||||
// users get localStorage only (above), which is exactly right.
|
||||
queueServerPush(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[queueServerPush],
|
||||
);
|
||||
|
||||
const fontSizeIndex = FONT_SIZES.findIndex((f) => f.value === prefs.fontSize);
|
||||
const currentFontSizeLabel = FONT_SIZES[fontSizeIndex]?.label ?? "M";
|
||||
|
||||
Reference in New Issue
Block a user