feat: add reading settings sync
This commit is contained in:
@@ -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
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user