"use client"; import { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { useTheme } from "next-themes"; 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 = () => () => {}; function useMounted() { return useSyncExternalStore( emptySubscribe, () => true, () => false, ); } type LightPalette = "aged-parchment" | "tulasi-garden" | "yamuna-mist"; type DarkPalette = "ancient-manuscript" | "nilamani" | "vraja-twilight"; type ReadingPrefs = { serifFont: boolean; fontSize: string; justified: boolean; lightPalette: LightPalette; darkPalette: DarkPalette; }; const DEFAULT_PREFS: ReadingPrefs = { serifFont: false, fontSize: "base", justified: false, lightPalette: "aged-parchment", darkPalette: "ancient-manuscript", }; // Preview swatches use approximations of each palette's background // and primary colours — the real colours come from the CSS variables. const LIGHT_PALETTES: { id: LightPalette; name: string; bg: string; primary: string; }[] = [ // Warm cream + saffron — the original { id: "aged-parchment", name: "Aged Parchment", bg: "#f5ecd7", primary: "#b88940", }, // Pale celadon paper + deep peacock teal { id: "tulasi-garden", name: "Tulasī Garden", bg: "#eaf5f1", primary: "#006762", }, // Misty porcelain + navy indigo { id: "yamuna-mist", name: "Yamunā Mist", bg: "#edf2f7", primary: "#2b4c83", }, ]; const DARK_PALETTES: { id: DarkPalette; name: string; bg: string; primary: string; }[] = [ // Warm ink + burnished gold — the original, de-muddied { id: "ancient-manuscript", name: "Ancient Manuscript", bg: "#15120e", primary: "#d8b260", }, // Midnight navy ink + peacock-feather teal { id: "nilamani", name: "Nīlamaṇi", bg: "#08101c", primary: "#4cc3c2", }, // Deep wine + rose-red — distinctly RED/WARM { id: "vraja-twilight", name: "Vraja Twilight", bg: "#2b1217", primary: "#e07252", }, ]; const LIGHT_PALETTE_IDS = LIGHT_PALETTES.map((p) => p.id); const DARK_PALETTE_IDS = DARK_PALETTES.map((p) => p.id); const FONT_SIZES = [ { value: "xs", label: "XS" }, { value: "sm", label: "S" }, { value: "base", label: "M" }, { value: "lg", label: "L" }, { value: "xl", label: "XL" }, { value: "2xl", label: "XXL" }, ] as const; 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; return sanitizePrefs(JSON.parse(raw)); } catch { return DEFAULT_PREFS; } } function savePrefs(prefs: ReadingPrefs) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch { // localStorage might be unavailable } } function applyClasses(prefs: ReadingPrefs) { const root = document.documentElement; // Serif root.classList.toggle("reading-serif", prefs.serifFont); // Font size — remove all, add current for (const fs of FONT_SIZES) { root.classList.remove(`reading-fs-${fs.value}`); } root.classList.add(`reading-fs-${prefs.fontSize}`); // Justified root.classList.toggle("reading-justified", prefs.justified); // Palettes — always set both attributes so the appropriate one takes // effect when the user toggles light ↔ dark. root.setAttribute("data-light-palette", prefs.lightPalette); root.setAttribute("data-dark-palette", prefs.darkPalette); } function Toggle({ checked, onChange, label, }: { checked: boolean; onChange: (v: boolean) => void; label: string; }) { return ( ); } /** Optional render-prop trigger — lets sidebar/bottom-nav variants compose * their own row layout while reusing ReadingSettings' state + popover. */ type TriggerRenderer = (state: { open: boolean; toggle: () => void; buttonRef: React.RefObject; }) => React.ReactNode; /** Where the panel renders relative to its trigger. * * - "below-right" (default) — header-style: panel hangs below the * trigger, right-aligned. Works when the trigger sits near the top. * - "above-left" — sidebar-style: panel rises above the trigger and * extends to the right so a bottom-of-sidebar button doesn't push * the popover off the bottom of the viewport. */ type Placement = "below-right" | "above-left"; /** How the controls are presented. * * - "popover" (default) — the icon trigger + floating panel pattern * used in SiteHeader/sidebar. Centred triggers on narrow viewports * can push the panel off-screen, which is why auth users get the * dedicated settings page instead. * - "inline" — render the controls flat as part of normal page flow, * full-width up to a sensible cap, wrapped in a subtle card. * - "bare" — like "inline" but with no card chrome at all. The controls * blend into the surrounding page as a normal section. Used by the * top-level /settings screen where reading prefs are just one of * several stacked sections. */ type Variant = "popover" | "inline" | "bare"; export function ReadingSettings({ variant = "popover", renderTrigger, placement = "below-right", onOpenChange, }: { variant?: Variant; renderTrigger?: TriggerRenderer; placement?: Placement; /** Fires every time the popover opens or closes. Used by the sidebar * to keep the rail expanded for as long as the popover is visible. */ onOpenChange?: (open: boolean) => void; } = {}) { const [open, setOpen] = useState(false); const [prefs, setPrefs] = useState(() => loadPrefs()); const panelRef = useRef(null); const buttonRef = useRef(null); const { theme, setTheme, resolvedTheme } = useTheme(); const mounted = useMounted(); // When the panel sits next to a sidebar, we render it through a portal // with viewport-fixed coordinates so it (a) escapes the sidebar's // bounds, (b) doesn't shift when the sidebar collapses on mouseleave, // and (c) stays a comfortable gap to the right of the rail. const [portalPos, setPortalPos] = useState<{ left: number; bottom: number; } | null>(null); useEffect(() => { if (!open || placement !== "above-left") { setPortalPos(null); return; } function recompute() { const btn = buttonRef.current; if (!btn) return; const rect = btn.getBoundingClientRect(); // Anchor by `bottom` (distance from viewport bottom) so the panel // grows upward from the trigger. `left` clears the rail by 16px. const left = rect.right + 16; const bottom = Math.max(8, window.innerHeight - rect.bottom); setPortalPos({ left, bottom }); } recompute(); // The sidebar width transitions when the rail expands — observe the // trigger so the popover slides along with it instead of staying at // the collapsed-rail x-coordinate. const btn = buttonRef.current; let observer: ResizeObserver | null = null; if (btn && typeof ResizeObserver !== "undefined") { observer = new ResizeObserver(() => recompute()); observer.observe(btn); } window.addEventListener("resize", recompute); return () => { observer?.disconnect(); window.removeEventListener("resize", recompute); }; }, [open, placement]); // Notify caller whenever the popover open state changes. We can't fire // this synchronously inside setOpen because some consumers (e.g. the // sidebar) feed it back into their own state — using useEffect breaks // the render cycle cleanly. useEffect(() => { onOpenChange?.(open); }, [open, onOpenChange]); // Apply classes on mount (prefs already loaded via lazy initializer) useEffect(() => { applyClasses(prefs); // 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; function handleClickOutside(e: MouseEvent) { if ( panelRef.current && !panelRef.current.contains(e.target as Node) && buttonRef.current && !buttonRef.current.contains(e.target as Node) ) { setOpen(false); } } function handleEscape(e: KeyboardEvent) { if (e.key === "Escape") { setOpen(false); } } document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [open]); 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"; // Show palettes matching the currently visible mode. Falls back to light if // resolvedTheme hasn't been determined yet (e.g. first render). const isDark = resolvedTheme === "dark"; const visiblePalettes = isDark ? DARK_PALETTES : LIGHT_PALETTES; const activePaletteId = isDark ? prefs.darkPalette : prefs.lightPalette; const activePaletteName = visiblePalettes.find((p) => p.id === activePaletteId)?.name ?? ""; if (!mounted) { // Inline / bare variants: render nothing until mounted so we don't // flash a misaligned skeleton on the dedicated settings page. if (variant === "inline" || variant === "bare") return null; return ( ); } const panelInner = ( <> {/* Theme selector */}
Theme
{( [ { value: "light", icon: Sun, label: "Light" }, { value: "dark", icon: Moon, label: "Dark" }, { value: "system", icon: Monitor, label: "System" }, ] as const ).map(({ value, icon: Icon, label }) => ( ))}
{/* Palette selector — shows light or dark palettes depending on the currently resolved theme. */}
Palette {activePaletteName}
{visiblePalettes.map((p) => { const isActive = activePaletteId === p.id; return ( ); })}
Reading {/* Serif font toggle */} updatePrefs({ serifFont: v })} label="Serif Font" /> {/* Justify text toggle */} updatePrefs({ justified: v })} label="Justify Text" /> {/* Font size slider */}
Font Size {currentFontSizeLabel}
A { const idx = parseInt(e.target.value, 10); updatePrefs({ fontSize: FONT_SIZES[idx].value }); }} className="flex-1 h-1.5 accent-primary cursor-pointer" /> A
); // ── Inline variant ────────────────────────────────────────────────── // Renders the same controls as a flush, full-width card. No popover // positioning, so it can't drift off-viewport on a narrow screen. if (variant === "inline") { return (
{panelInner}
); } // ── Bare variant ──────────────────────────────────────────────────── // No card, no border — controls render flush with the surrounding page // so the section reads as part of the main flow rather than a callout. // Used by /settings, where reading prefs sit alongside several other // unstyled sections. if (variant === "bare") { return
{panelInner}
; } // ── Popover variant (default) ─────────────────────────────────────── const toggle = () => setOpen((v) => !v); return (
{renderTrigger ? ( renderTrigger({ open, toggle, buttonRef }) ) : ( )} {open && (
{panelInner}
)}
); } /** Conditionally render `children` through a React portal to document.body. * When `portal` is false (or SSR), children render in place. We use this * to escape stacking + overflow contexts (e.g. inside the sidebar) without * duplicating the panel JSX. */ function MaybePortal({ portal, children, }: { portal: boolean; children: React.ReactNode; }) { if (portal && typeof document !== "undefined") { return createPortal(children, document.body); } return <>{children}; }