"use client"; import { useState, useRef, useEffect, useCallback } from "react"; 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 ReadingPrefs = { serifFont: boolean; fontSize: string; justified: boolean; }; const DEFAULT_PREFS: ReadingPrefs = { serifFont: false, fontSize: "base", justified: false, }; 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, }; } 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); } function Toggle({ checked, onChange, label, }: { checked: boolean; onChange: (v: boolean) => void; label: string; }) { return ( onChange(!checked)} className="flex items-center justify-between w-full py-2 cursor-pointer" > {label} ); } export function ReadingSettings() { const [open, setOpen] = useState(false); const [prefs, setPrefs] = useState(() => loadPrefs()); const panelRef = useRef(null); const buttonRef = useRef(null); const { theme, setTheme } = useTheme(); const mounted = useMounted(); // 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"; if (!mounted) { return ( ); } return ( } aria-label="Reading settings" aria-expanded={open} onClick={() => setOpen((v) => !v)} className={cn( "size-9 inline-flex items-center justify-center rounded-lg", "text-muted-foreground hover:text-foreground hover:bg-muted", "transition-colors outline-none", "focus-visible:ring-2 focus-visible:ring-ring", )} > {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 }) => ( setTheme(value)} className={cn( "flex-1 flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer", theme === value ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground", )} > {label} ))} 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 )} ); }