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

695 lines
24 KiB
TypeScript

"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",
},
// 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";
/** 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<Record<keyof ReadingPrefs, unknown>>;
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 (
<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>
);
}
/** 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<HTMLButtonElement | null>;
}) => 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. Used by /u/me/settings. */
type Variant = "popover" | "inline";
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<ReadingPrefs>(() => loadPrefs());
const panelRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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<string | null>(null);
const pushTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>)
.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<string, unknown>)
.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<ReadingPrefs>) => {
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 variant: render nothing until mounted so we don't flash a
// misaligned skeleton on the dedicated settings page.
if (variant === "inline") return null;
return (
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
<Settings className="size-4" />
</Button>
);
}
const panelInner = (
<>
{/* 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>
{/* Palette selector — shows light or dark palettes depending on
the currently resolved theme. */}
<div className="mb-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Palette
</span>
<span className="text-xs text-muted-foreground">
{activePaletteName}
</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-2">
{visiblePalettes.map((p) => {
const isActive = activePaletteId === p.id;
return (
<button
key={p.id}
type="button"
onClick={() =>
updatePrefs(
isDark
? { darkPalette: p.id as DarkPalette }
: { lightPalette: p.id as LightPalette },
)
}
aria-label={`Use ${p.name} palette`}
aria-pressed={isActive}
title={p.name}
className={cn(
"group relative aspect-square rounded-md transition-all cursor-pointer overflow-hidden",
"border-2 outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary ring-2 ring-primary/30"
: "border-border hover:border-foreground/40",
)}
style={{ backgroundColor: p.bg }}
>
{/* Primary-color dot in the corner */}
<span
className="absolute bottom-1.5 right-1.5 size-3 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: p.primary }}
/>
</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>
</>
);
// ── 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. Used
// by /u/me/settings.
if (variant === "inline") {
return (
<div className="w-full max-w-xl rounded-lg border border-border bg-card p-4 sm:p-5">
{panelInner}
</div>
);
}
// ── Popover variant (default) ───────────────────────────────────────
const toggle = () => setOpen((v) => !v);
return (
<div className="relative">
{renderTrigger ? (
renderTrigger({ open, toggle, buttonRef })
) : (
<button
ref={buttonRef as React.Ref<HTMLButtonElement>}
aria-label="Reading settings"
aria-expanded={open}
onClick={toggle}
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",
)}
>
<Settings className="size-5 md:size-4" />
</button>
)}
{open && (
<MaybePortal portal={placement === "above-left" && portalPos !== null}>
<div
ref={panelRef}
style={
placement === "above-left" && portalPos
? {
position: "fixed",
left: portalPos.left,
bottom: portalPos.bottom,
}
: undefined
}
className={cn(
"w-72 rounded-lg border border-border bg-card shadow-lg p-4",
"max-h-[80vh] overflow-y-auto",
placement === "above-left" && portalPos
? "z-[100]"
: placement === "above-left"
? "absolute bottom-full left-0 mb-2 z-50"
: "absolute top-full right-0 mt-2 z-50",
)}
>
{panelInner}
</div>
</MaybePortal>
)}
</div>
);
}
/** 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}</>;
}