From 03b8f85e067857b249d90943b838e4be91cec42e Mon Sep 17 00:00:00 2001
From: gosangam
Date: Sun, 31 May 2026 07:07:50 +0530
Subject: [PATCH] feat: add reading settings sync
---
components/profile/profile-header.tsx | 42 +++++--
components/reading-settings.tsx | 168 +++++++++++++++++++++-----
components/site-header.tsx | 26 ++--
lib/reading-prefs.ts | 53 ++++++++
4 files changed, 245 insertions(+), 44 deletions(-)
create mode 100644 lib/reading-prefs.ts
diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx
index 90a340cc..3e60ed66 100644
--- a/components/profile/profile-header.tsx
+++ b/components/profile/profile-header.tsx
@@ -3,13 +3,24 @@
/**
* ProfileHeader — Instagram-style centered header used at the top of
* /u/me and /u/[handle]. Avatar, display name, @handle, a single stat
- * line, and an optional action slot (Edit profile button on owner view).
+ * line, and an action row.
*
- * Replaces the older ProfileViewHeader (left-aligned avatar + sidebar).
+ * The action row hosts:
+ * • "Edit profile" link — only on the owner's own profile (via the
+ * `editHref` prop set by /u/me layout).
+ * • ReadingSettings gear — for every signed-in viewer, regardless of
+ * whose profile they're on. SiteHeader drops its own mobile gear
+ * for auth users (see comment there) so this is the single source
+ * of truth for that affordance once you're signed in.
+ *
+ * Anonymous visitors don't see this row at all — they read SiteHeader's
+ * gear instead.
*/
import Image from "next/image";
import Link from "next/link";
+import { ReadingSettings } from "@/components/reading-settings";
+import { useAuth } from "@/components/auth/auth-provider";
export function ProfileHeader({
avatarURL,
@@ -27,6 +38,14 @@ export function ProfileHeader({
/** When provided, shows an "Edit profile" CTA below the handle. */
editHref?: string;
}) {
+ const { user, loading } = useAuth();
+ // ReadingSettings appears for every authenticated viewer (own profile
+ // OR someone else's), so an auth user reading a friend's profile can
+ // still reach theme/palette/font controls. Anonymous viewers fall back
+ // to SiteHeader's mobile gear.
+ const showReadingSettings = !loading && !!user;
+ const showActions = !!editHref || showReadingSettings;
+
return (
@@ -48,13 +67,18 @@ export function ProfileHeader({
) : null}
);
diff --git a/components/reading-settings.tsx b/components/reading-settings.tsx
index 5ecbf7a1..36593bf7 100644
--- a/components/reading-settings.tsx
+++ b/components/reading-settings.tsx
@@ -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>;
+ 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(null);
+ const pushTimerRef = useRef | 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)
+ .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)
+ .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) => {
- setPrefs((prev) => {
- const next = { ...prev, ...update };
- savePrefs(next);
- applyClasses(next);
- return next;
- });
- }, []);
+ const updatePrefs = useCallback(
+ (update: Partial) => {
+ 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";
diff --git a/components/site-header.tsx b/components/site-header.tsx
index e3455ea8..00fe8b0f 100644
--- a/components/site-header.tsx
+++ b/components/site-header.tsx
@@ -5,11 +5,14 @@
* centred. Navigation lives in SiteSidebar (desktop) and SiteBottomNav
* (mobile), so the header's only job here is to anchor the brand.
*
- * Mobile also gets a Reading-settings affordance pinned to the right —
- * the sidebar rail is hidden on mobile, so this is the only easy entry
- * point to the theme + palette + font controls.
+ * Mobile gets a Reading-settings affordance pinned to the right ONLY for
+ * anonymous visitors — auth users access the same controls from the
+ * gear next to Edit Profile in ProfileHeader (and from the desktop
+ * sidebar rail). The split avoids two icons doing the same job for
+ * signed-in users while keeping the affordance reachable for guests who
+ * have no profile.
*
- * Layout (mobile):
+ * Layout (mobile, anonymous):
*
* ┌─────────────────────────────────────────────────────┐
* │ Prabhupāda ⚙ │
@@ -21,8 +24,15 @@
import Link from "next/link";
import { ReadingSettings } from "@/components/reading-settings";
+import { useAuth } from "@/components/auth/auth-provider";
export function SiteHeader() {
+ const { user, loading } = useAuth();
+ // Only show the mobile gear for anonymous viewers. During the initial
+ // auth check we treat `loading` as anonymous so the gear doesn't flash
+ // in and out — once auth resolves, it hides for signed-in users.
+ const showMobileGear = !loading && !user;
+
return (
@@ -38,9 +48,11 @@ export function SiteHeader() {
Says
-
-
-
+ {showMobileGear ? (
+
+
+
+ ) : null}
);
diff --git a/lib/reading-prefs.ts b/lib/reading-prefs.ts
new file mode 100644
index 00000000..f601d93b
--- /dev/null
+++ b/lib/reading-prefs.ts
@@ -0,0 +1,53 @@
+/**
+ * Browser-side client for the user's roaming reading preferences.
+ *
+ * The BFF stores prefs as opaque JSONB keyed off the user. This module
+ * just round-trips that blob — it makes no claim about its shape (that
+ * contract is owned by components/reading-settings.tsx). All calls run
+ * with `credentials: "include"` so the auth cookie comes along.
+ */
+
+const BFF_BASE =
+ process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
+
+type Envelope = {
+ success?: boolean;
+ data?: T;
+ error?: { code: number; message: string };
+};
+
+async function call(path: string, init?: RequestInit): Promise {
+ const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
+ credentials: "include",
+ headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
+ ...init,
+ });
+ if (res.status === 401 || res.status === 403) return null;
+ if (!res.ok) {
+ throw new Error(`reading-prefs ${path}: ${res.status}`);
+ }
+ const body = (await res.json().catch(() => ({}))) as Envelope;
+ return (body.data ?? null) as T | null;
+}
+
+export const readingPrefsApi = {
+ /** Fetches the caller's reading prefs blob. Returns `null` if the
+ * request is unauthenticated (treat as "no remote prefs"). The blob
+ * is the raw JSON object the frontend wrote last; an empty object
+ * on first load. */
+ async get(): Promise | null> {
+ const data = await call>("/users/me/reading-prefs");
+ return data ?? null;
+ },
+
+ /** Overwrites the caller's reading prefs blob. The server stores the
+ * payload verbatim (after canonicalising via re-marshal) — schema is
+ * client-owned. Errors are swallowed by the caller; reading prefs
+ * should never block a UI interaction. */
+ async put(prefs: Record): Promise {
+ await call>("/users/me/reading-prefs", {
+ method: "PUT",
+ body: JSON.stringify(prefs),
+ });
+ },
+};