feat: add user centric features

This commit is contained in:
gosangam
2026-05-30 23:44:08 +05:30
parent 7bd2952caa
commit dcdd2383c6
63 changed files with 16783 additions and 1735 deletions
+134 -16
View File
@@ -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}</>;
}