290 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|