feat: add user centric features
This commit is contained in:
+134
-16
@@ -1,6 +1,7 @@
|
||||
"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";
|
||||
@@ -204,13 +205,88 @@ function Toggle({
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadingSettings() {
|
||||
/** 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";
|
||||
|
||||
export function ReadingSettings({
|
||||
renderTrigger,
|
||||
placement = "below-right",
|
||||
onOpenChange,
|
||||
}: {
|
||||
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(() => {
|
||||
@@ -275,27 +351,51 @@ export function ReadingSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const toggle = () => setOpen((v) => !v);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef as React.Ref<HTMLButtonElement>}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Settings className="size-5 md:size-4" />
|
||||
</button>
|
||||
{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}
|
||||
className="absolute right-0 top-full mt-2 w-72 rounded-lg border border-border bg-card shadow-lg z-50 p-4"
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{/* Theme selector */}
|
||||
<div className="mb-4">
|
||||
@@ -424,7 +524,25 @@ export function ReadingSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</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}</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user