695 lines
24 KiB
TypeScript
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}</>;
|
|
}
|