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
+33 -9
View File
@@ -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 (
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
<Avatar src={avatarURL} name={name} />
@@ -48,13 +67,18 @@ export function ProfileHeader({
</p>
) : null}
</div>
{editHref ? (
<Link
href={editHref}
className="inline-flex items-center justify-center h-9 px-5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Edit profile
</Link>
{showActions ? (
<div className="flex items-center gap-2">
{editHref ? (
<Link
href={editHref}
className="inline-flex items-center justify-center h-9 px-5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Edit profile
</Link>
) : null}
{showReadingSettings ? <ReadingSettings /> : null}
</div>
) : null}
</header>
);
+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";
+19 -7
View File
@@ -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 (
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
<div className="relative h-12 sm:h-14 flex items-center justify-center px-4">
@@ -38,9 +48,11 @@ export function SiteHeader() {
Says
</span>
</Link>
<div className="md:hidden absolute right-2 top-1/2 -translate-y-1/2">
<ReadingSettings />
</div>
{showMobileGear ? (
<div className="md:hidden absolute right-2 top-1/2 -translate-y-1/2">
<ReadingSettings />
</div>
) : null}
</div>
</header>
);
+53
View File
@@ -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<T> = {
success?: boolean;
data?: T;
error?: { code: number; message: string };
};
async function call<T>(path: string, init?: RequestInit): Promise<T | null> {
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<T>;
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<Record<string, unknown> | null> {
const data = await call<Record<string, unknown>>("/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<string, unknown>): Promise<void> {
await call<Record<string, unknown>>("/users/me/reading-prefs", {
method: "PUT",
body: JSON.stringify(prefs),
});
},
};