Files
spsays-training/components/reading-settings.tsx
T

290 lines
8.3 KiB
TypeScript

"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 (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex items-center justify-between w-full py-2 cursor-pointer"
>
<span className="text-sm text-foreground">{label}</span>
<div
className={cn(
"w-10 h-5 rounded-full transition-colors relative shrink-0 ml-3",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<div
className={cn(
"absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm",
checked ? "translate-x-5" : "translate-x-0.5",
)}
/>
</div>
</button>
);
}
export function ReadingSettings() {
const [open, setOpen] = useState(false);
const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs());
const panelRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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<ReadingPrefs>) => {
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 (
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
<Settings className="size-4" />
</Button>
);
}
return (
<div className="relative">
<Button
ref={buttonRef as React.Ref<HTMLButtonElement>}
variant="ghost"
size="icon-sm"
aria-label="Reading settings"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Settings className="size-4" />
</Button>
{open && (
<div
ref={panelRef}
className="absolute right-0 top-full mt-2 w-72 rounded-lg border border-border bg-card shadow-lg z-50 p-4"
>
{/* Theme selector */}
<div className="mb-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Theme
</span>
<div className="flex gap-1 mt-2">
{(
[
{ 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 }) => (
<button
key={value}
type="button"
onClick={() => 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",
)}
>
<Icon className="size-3.5" />
{label}
</button>
))}
</div>
</div>
<div className="border-t border-border pt-3 space-y-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Reading
</span>
{/* Serif font toggle */}
<Toggle
checked={prefs.serifFont}
onChange={(v) => updatePrefs({ serifFont: v })}
label="Serif Font"
/>
{/* Justify text toggle */}
<Toggle
checked={prefs.justified}
onChange={(v) => updatePrefs({ justified: v })}
label="Justify Text"
/>
{/* Font size slider */}
<div className="pt-1 pb-1">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-foreground">Font Size</span>
<span className="text-xs font-medium text-muted-foreground bg-muted px-2 py-0.5 rounded">
{currentFontSizeLabel}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">A</span>
<input
type="range"
min={0}
max={FONT_SIZES.length - 1}
step={1}
value={fontSizeIndex}
onChange={(e) => {
const idx = parseInt(e.target.value, 10);
updatePrefs({ fontSize: FONT_SIZES[idx].value });
}}
className="flex-1 h-1.5 accent-primary cursor-pointer"
/>
<span className="text-base text-muted-foreground font-medium">
A
</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}