feat: add reading settings sync

This commit is contained in:
gosangam
2026-05-31 07:07:50 +05:30
parent 0ff8005df2
commit 03b8f85e06
4 changed files with 245 additions and 44 deletions
+140 -28
View File
@@ -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";