"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"; 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", }, // Sage green background + deep emerald — distinctly GREEN { id: "tulasi-garden", name: "Tulasī Garden", bg: "#c4dcc1", primary: "#207537", }, // Soft blue background + cerulean — distinctly BLUE { id: "yamuna-mist", name: "Yamunā Mist", bg: "#c2d7ec", primary: "#1f6fd6", }, ]; const DARK_PALETTES: { id: DarkPalette; name: string; bg: string; primary: string; }[] = [ // Warm brown + saffron — the original { id: "ancient-manuscript", name: "Ancient Manuscript", bg: "#2a2519", primary: "#d0a04e", }, // Deep navy + sky-blue — distinctly BLUE { id: "nilamani", name: "Nīlamaṇi", bg: "#101a36", primary: "#4a9fff", }, // 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"; 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, }; } 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"; export function ReadingSettings({ renderTrigger, placement = "below-right", onOpenChange, }: { 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 }, []); // 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); return next; }); }, []); 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) { return ( ); } const toggle = () => setOpen((v) => !v); return (
{renderTrigger ? ( renderTrigger({ open, toggle, buttonRef }) ) : ( )} {open && (
{/* 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
)}
); } /** 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}; }