import { NavLink, Outlet, useLocation } from "react-router-dom"; import { LuSettings, LuMouse, LuKeyboard, LuVideo, LuCpu, LuShieldCheck, LuWrench, LuArrowLeft, LuPalette, LuCommand, LuNetwork, } from "react-icons/lu"; import React, { useEffect, useRef, useState } from "react"; import { useResizeObserver } from "usehooks-ts"; import Card from "@/components/Card"; import { LinkButton } from "@/components/Button"; import LoadingSpinner from "@/components/LoadingSpinner"; import { useUiStore } from "@/hooks/stores"; import useKeyboard from "@/hooks/useKeyboard"; import { FeatureFlag } from "../components/FeatureFlag"; import { cx } from "../cva.config"; /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { const location = useLocation(); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); const { sendKeyboardEvent } = useKeyboard(); const scrollContainerRef = useRef<HTMLDivElement>(null); const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false); const { width = 0 } = useResizeObserver({ ref: scrollContainerRef as React.RefObject<HTMLDivElement> }); // Handle scroll position to show/hide gradients const handleScroll = () => { if (scrollContainerRef.current) { const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current; // Show left gradient only if scrolled to the right setShowLeftGradient(scrollLeft > 0); // Show right gradient only if there's more content to scroll to the right setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); // -1 for rounding errors } }; useEffect(() => { // Check initial scroll position handleScroll(); // Add scroll event listener to the container const scrollContainer = scrollContainerRef.current; if (scrollContainer) { scrollContainer.addEventListener("scroll", handleScroll); } return () => { // Clean up event listener if (scrollContainer) { scrollContainer.removeEventListener("scroll", handleScroll); } }; }, [width]); useEffect(() => { // disable focus trap setTimeout(() => { // Reset keyboard state. Incase the user is pressing a key while enabling the sidebar sendKeyboardEvent([], []); setDisableVideoFocusTrap(true); // For some reason, the focus trap is not disabled immediately // so we need to blur the active element (document.activeElement as HTMLElement)?.blur(); console.log("Just disabled focus trap"); }, 300); return () => { setDisableVideoFocusTrap(false); }; }, [setDisableVideoFocusTrap, sendKeyboardEvent]); return ( <div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white"> <div className="h-full"> <div className="w-full gap-x-8 gap-y-4 space-y-4 md:grid md:grid-cols-8 md:space-y-0"> <div className="w-full select-none space-y-4 md:col-span-2"> <Card className="flex w-full gap-x-4 overflow-hidden p-2 md:flex-col dark:bg-slate-800"> <div className="md:hidden"> <LinkButton to=".." size="SM" theme="blank" text="Back to KVM" LeadingIcon={LuArrowLeft} textAlign="left" /> </div> <div className="hidden md:block"> <LinkButton to=".." size="SM" theme="blank" text="Back to KVM" LeadingIcon={LuArrowLeft} textAlign="left" fullWidth /> </div> </Card> <Card className="relative overflow-hidden"> {/* Gradient overlay for left side - only visible on mobile when scrolled */} <div className={cx( "pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-linear-to-r from-white to-transparent transition-opacity duration-300 ease-in-out md:hidden dark:from-slate-900", { "opacity-0": !showLeftGradient, "opacity-100": showLeftGradient, }, )} ></div> {/* Gradient overlay for right side - only visible on mobile when there's more content */} <div className={cx( "pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-linear-to-l from-white to-transparent transition duration-300 ease-in-out md:hidden dark:from-slate-900", { "opacity-0": !showRightGradient, "opacity-100": showRightGradient, }, )} ></div> <div ref={scrollContainerRef} className="hide-scrollbar relative flex w-full gap-x-4 overflow-x-auto whitespace-nowrap p-2 md:flex-col md:overflow-visible md:whitespace-normal dark:bg-slate-800" > <div className="shrink-0"> <NavLink to="general" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuSettings className="h-4 w-4 shrink-0" /> <h1>General</h1> </div> </NavLink> </div> <div className="shrink-0"> <NavLink to="mouse" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuMouse className="h-4 w-4 shrink-0" /> <h1>Mouse</h1> </div> </NavLink> </div> <FeatureFlag minAppVersion="0.4.0" name="Paste text"> <div className="shrink-0"> <NavLink to="keyboard" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent"> <LuKeyboard className="h-4 w-4 shrink-0" /> <h1>Keyboard</h1> </div> </NavLink> </div> </FeatureFlag> <div className="shrink-0"> <NavLink to="video" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuVideo className="h-4 w-4 shrink-0" /> <h1>Video</h1> </div> </NavLink> </div> <div className="shrink-0"> <NavLink to="hardware" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuCpu className="h-4 w-4 shrink-0" /> <h1>Hardware</h1> </div> </NavLink> </div> <div className="shrink-0"> <NavLink to="access" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuShieldCheck className="h-4 w-4 shrink-0" /> <h1>Access</h1> </div> </NavLink> </div> <div className="shrink-0"> <NavLink to="appearance" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuPalette className="h-4 w-4 shrink-0" /> <h1>Appearance</h1> </div> </NavLink> </div> <div className="shrink-0"> <NavLink to="macros" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuCommand className="h-4 w-4 shrink-0" /> <h1>Keyboard Macros</h1> </div> </NavLink> </div> <div className="shrink-0"> <NavLink to="network" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuNetwork className="h-4 w-4 shrink-0" /> <h1>Network</h1> </div> </NavLink> </div> <div className="shrink-0"> <NavLink to="advanced" className={({ isActive }) => (isActive ? "active" : "")} > <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <LuWrench className="h-4 w-4 shrink-0" /> <h1>Advanced</h1> </div> </NavLink> </div> </div> </Card> </div> <div className="w-full md:col-span-6"> {/* <AutoHeight> */} <Card className="dark:bg-slate-800"> <div className="space-y-4 px-8 py-6" style={{ animationDuration: "0.7s" }} key={location.pathname} // This is a workaround to force the animation to run when the route changes > <Outlet /> </div> </Card> {/* </AutoHeight> */} </div> </div> </div> </div> ); } interface SettingsItemProps { readonly title: string; readonly description: string | React.ReactNode; readonly badge?: string; readonly className?: string; readonly loading?: boolean; readonly children?: React.ReactNode; } export function SettingsItem(props: SettingsItemProps) { const { title, description, badge, children, className, loading } = props; return ( <label className={cx( "flex select-none items-center justify-between gap-x-8 rounded", className, )} > <div className="space-y-0.5"> <div className="flex items-center gap-x-2"> <div className="flex items-center text-base font-semibold text-black dark:text-white"> {title} {badge && ( <span className="ml-2 rounded-full bg-red-500 px-2 py-1 text-[10px] font-medium leading-none text-white dark:border dark:border-red-700 dark:bg-red-800 dark:text-red-50"> {badge} </span> )} </div> {loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />} </div> <div className="text-sm text-slate-700 dark:text-slate-300">{description}</div> </div> {children ? <div>{children}</div> : null} </label> ); }