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} - {editHref ? ( - - Edit profile - + {showActions ? ( +
+ {editHref ? ( + + Edit profile + + ) : null} + {showReadingSettings ? : null} +
) : 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), + }); + }, +};