diff --git a/.changeset/tasty-peaches-relax.md b/.changeset/tasty-peaches-relax.md new file mode 100644 index 0000000..bf3c2cf --- /dev/null +++ b/.changeset/tasty-peaches-relax.md @@ -0,0 +1,5 @@ +--- +"pinorama-studio": minor +--- + +keyboard shortcut system and ui enhancements diff --git a/packages/pinorama-studio/package.json b/packages/pinorama-studio/package.json index 6a51d2d..e83ddbf 100644 --- a/packages/pinorama-studio/package.json +++ b/packages/pinorama-studio/package.json @@ -29,12 +29,14 @@ "@fastify/static": "^7.0.4", "@hookform/resolvers": "^3.9.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-query": "^5.50.1", @@ -58,7 +60,9 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.52.1", + "react-hotkeys-hook": "^4.5.0", "react-intl": "^6.6.8", + "react-json-view-lite": "^1.4.0", "react-resizable-panels": "^2.0.20", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", diff --git a/packages/pinorama-studio/src/components/clipboard-button/clipboard-button.tsx b/packages/pinorama-studio/src/components/clipboard-button/clipboard-button.tsx new file mode 100644 index 0000000..ca3f465 --- /dev/null +++ b/packages/pinorama-studio/src/components/clipboard-button/clipboard-button.tsx @@ -0,0 +1,58 @@ +import { CheckIcon, CopyIcon } from "lucide-react" +import { forwardRef, useCallback, useImperativeHandle, useState } from "react" +import { useIntl } from "react-intl" +import { IconButton } from "../icon-button/icon-button" + +type ClipboardButtonProps = { + textToCopy: string + keystroke?: string +} + +export type ImperativeClipboardButtonHandle = { + copyToClipboard: () => void +} + +export const ClipboardButton = forwardRef< + ImperativeClipboardButtonHandle, + ClipboardButtonProps +>(function ClipboardButton(props, ref) { + const intl = useIntl() + const [isCopied, setIsCopied] = useState(false) + + const handleClick = useCallback(async () => { + if (isCopied || !props.textToCopy) { + return + } + + try { + await navigator.clipboard.writeText(props.textToCopy) + setIsCopied(true) + + setTimeout(() => { + setIsCopied(false) + }, 1500) + } catch (err) { + console.error("Failed to copy to clipboard", err) + } + }, [props.textToCopy, isCopied]) + + useImperativeHandle( + ref, + () => ({ + copyToClipboard: handleClick + }), + [handleClick] + ) + + return ( + + ) +}) diff --git a/packages/pinorama-studio/src/components/clipboard-button/index.ts b/packages/pinorama-studio/src/components/clipboard-button/index.ts new file mode 100644 index 0000000..b4ed813 --- /dev/null +++ b/packages/pinorama-studio/src/components/clipboard-button/index.ts @@ -0,0 +1 @@ +export * from "./clipboard-button" diff --git a/packages/pinorama-studio/src/components/icon-button/icon-button.tsx b/packages/pinorama-studio/src/components/icon-button/icon-button.tsx new file mode 100644 index 0000000..d51b651 --- /dev/null +++ b/packages/pinorama-studio/src/components/icon-button/icon-button.tsx @@ -0,0 +1,57 @@ +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipTrigger +} from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { LoaderIcon, type LucideIcon } from "lucide-react" +import type { ComponentProps } from "react" +import { Kbd } from "../kbd/kbd" + +type IconButtonProps = ComponentProps & { + icon: LucideIcon + tooltip?: string + loading?: boolean + keystroke?: string +} + +export function IconButton({ + variant = "outline2", + icon, + tooltip, + keystroke, + loading, + ...props +}: IconButtonProps) { + const Icon = loading ? LoaderIcon : icon + + const Component = ( + + ) + + return tooltip || keystroke + ? withTooltip(Component, tooltip, keystroke) + : Component +} + +function withTooltip( + WrappedComponent: React.ReactNode, + tooltip?: string, + keystroke?: string +) { + return ( + + {WrappedComponent} + + +
{tooltip}
+ {keystroke ? {keystroke} : null} +
+
+
+ ) +} diff --git a/packages/pinorama-studio/src/components/icon-button/index.ts b/packages/pinorama-studio/src/components/icon-button/index.ts new file mode 100644 index 0000000..46f00cb --- /dev/null +++ b/packages/pinorama-studio/src/components/icon-button/index.ts @@ -0,0 +1 @@ +export * from "./icon-button" diff --git a/packages/pinorama-studio/src/components/kbd/index.ts b/packages/pinorama-studio/src/components/kbd/index.ts new file mode 100644 index 0000000..3151d29 --- /dev/null +++ b/packages/pinorama-studio/src/components/kbd/index.ts @@ -0,0 +1 @@ +export * from "./kbd" diff --git a/packages/pinorama-studio/src/components/kbd/kbd.tsx b/packages/pinorama-studio/src/components/kbd/kbd.tsx new file mode 100644 index 0000000..85b0e90 --- /dev/null +++ b/packages/pinorama-studio/src/components/kbd/kbd.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils" + +export function Kbd({ + children, + className +}: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ) +} diff --git a/packages/pinorama-studio/src/components/search-input/search-input.tsx b/packages/pinorama-studio/src/components/search-input/search-input.tsx index 19e7043..17fdaa7 100644 --- a/packages/pinorama-studio/src/components/search-input/search-input.tsx +++ b/packages/pinorama-studio/src/components/search-input/search-input.tsx @@ -1,5 +1,6 @@ import { SearchIcon, XIcon } from "lucide-react" import { forwardRef } from "react" +import { Kbd } from "../kbd" import { Button } from "../ui/button" import { Input } from "../ui/input" @@ -7,12 +8,15 @@ type SearchInputProps = { value: string onChange: (text: string) => void placeholder: string + keystroke?: string } export const SearchInput = forwardRef(function SearchInput( props: SearchInputProps, ref: React.Ref ) { + const hasValue = props.value.length > 0 + return (
@@ -20,21 +24,29 @@ export const SearchInput = forwardRef(function SearchInput( ref={ref} type="text" placeholder={props.placeholder} - className="pl-9" + className="pl-9 pr-16" value={props.value} onChange={(e) => props.onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape" && e.target instanceof HTMLInputElement) { + e.target.blur() + } + }} /> - {props.value.length > 0 ? ( + {hasValue ? ( ) : null} + {props.keystroke ? ( + {props.keystroke} + ) : null}
) }) diff --git a/packages/pinorama-studio/src/components/title-bar/components/hotkeys-button.tsx b/packages/pinorama-studio/src/components/title-bar/components/hotkeys-button.tsx new file mode 100644 index 0000000..eea3f0a --- /dev/null +++ b/packages/pinorama-studio/src/components/title-bar/components/hotkeys-button.tsx @@ -0,0 +1,77 @@ +import { Kbd } from "@/components/kbd/kbd" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipTrigger +} from "@/components/ui/tooltip" +import { useAllModuleHotkeys } from "@/hooks/use-module-hotkeys" +import { KeyboardIcon } from "lucide-react" +import { FormattedMessage } from "react-intl" + +export function HotkeysButton() { + const hotkeys = useAllModuleHotkeys() + + const handleClick = () => {} + + return ( + + + + + + + + + + + + + + + + + + + + + + + {Object.entries(hotkeys).map(([module, hotkeys]) => ( +
+
{module}
+
    + {hotkeys.map((hotkey) => ( +
  • + + {hotkey.description} + + {hotkey.keystroke} +
  • + ))} +
+
+ ))} +
+
+ ) +} diff --git a/packages/pinorama-studio/src/components/title-bar/title-bar.tsx b/packages/pinorama-studio/src/components/title-bar/title-bar.tsx index a2b5f08..bcdb154 100644 --- a/packages/pinorama-studio/src/components/title-bar/title-bar.tsx +++ b/packages/pinorama-studio/src/components/title-bar/title-bar.tsx @@ -1,5 +1,6 @@ import { ConnectionStatusButton } from "./components/connection-status-button" import { ConnectionToggleButton } from "./components/connection-toggle-button" +import { HotkeysButton } from "./components/hotkeys-button" import { PinoramaLogo } from "./components/pinorama-logo" import { ThemeToggleButton } from "./components/theme-toggle-button" @@ -19,6 +20,7 @@ export function TitleBar() { {/* Right */}
+
diff --git a/packages/pinorama-studio/src/components/ui/dialog.tsx b/packages/pinorama-studio/src/components/ui/dialog.tsx new file mode 100644 index 0000000..ab139f9 --- /dev/null +++ b/packages/pinorama-studio/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription +} diff --git a/packages/pinorama-studio/src/features/index.tsx b/packages/pinorama-studio/src/features/index.tsx deleted file mode 100644 index 5fc54ed..0000000 --- a/packages/pinorama-studio/src/features/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Feature } from "@/types" -import LogExplorerFeature from "./log-explorer" - -export default [ - LogExplorerFeature, - { - routePath: "/feature2", - component: () => ( -
- Welcome to Feature 2! 🎉 -
- ) - } -] as Feature[] diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-details/index.tsx b/packages/pinorama-studio/src/features/log-explorer/components/log-details/index.tsx deleted file mode 100644 index 0e41b3d..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-details/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -type LogDetailsProps = { - data: any -} - -export function LogDetails(props: LogDetailsProps) { - return ( -
-
{JSON.stringify(props.data, null, 2)}
-
- ) -} diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-explorer.tsx b/packages/pinorama-studio/src/features/log-explorer/components/log-explorer.tsx deleted file mode 100644 index 2c39eaf..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-explorer.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react" - -import { usePinoramaConnection } from "@/hooks" -import { UnplugIcon } from "lucide-react" -import { useIntl } from "react-intl" - -import { EmptyStateBlock } from "@/components/empty-state" -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup -} from "@/components/ui/resizable" -import { LogDetails } from "./log-details" -import { LogFilters } from "./log-filters" -import { LogViewer } from "./log-viewer" - -import { useAppConfig } from "@/contexts" -import type { ImperativePanelHandle } from "react-resizable-panels" -import type { SearchFilters } from "./log-filters/types" - -const PANEL_SIZES = { - filters: { base: 20, min: 10 }, - details: { base: 20, min: 10 } -} - -export function LogExplorer() { - const intl = useIntl() - const appConfig = useAppConfig() - - const { isConnected, toggleConnection, introspection } = - usePinoramaConnection() - - const [liveMode, setLiveMode] = useState(null) - const [filters, setFilters] = useState({}) - const [searchText, setSearchText] = useState("") - const [selectedRow, setSelectedRow] = useState(null) - - const [detailsPanelCollapsed, setDetailsPanelCollapsed] = useState(true) - const [filtersPanelCollapsed, setFiltersPanelCollapsed] = useState(true) - - const filtersPanelRef = useRef(null) - const detailsPanelRef = useRef(null) - - const isLiveModeEnabled = liveMode ?? appConfig?.config.liveMode ?? false - - const toggleFiltersPanel = useCallback(() => { - const panel = filtersPanelRef.current - - if (panel?.isCollapsed()) { - panel?.expand(PANEL_SIZES.filters.base) - } else { - panel?.collapse() - } - }, []) - - const clearFilters = useCallback(() => { - setSearchText("") - setFilters({}) - }, []) - - useEffect(() => { - const panel = detailsPanelRef.current - selectedRow ? panel?.expand(PANEL_SIZES.details.base) : panel?.collapse() - }, [selectedRow]) - - if (!isConnected) { - return ( - - ) - } - - if (!introspection) { - return null - } - - return ( - - {/* Filters */} - setFiltersPanelCollapsed(true)} - onExpand={() => setFiltersPanelCollapsed(false)} - > - - - - - {/* Viewer */} - - - - - - {/* Details */} - setDetailsPanelCollapsed(true)} - onExpand={() => setDetailsPanelCollapsed(false)} - > - - - - ) -} diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/clear-filters-button.tsx b/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/clear-filters-button.tsx deleted file mode 100644 index b2fe15f..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/clear-filters-button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Button } from "@/components/ui/button" -import { - Tooltip, - TooltipContent, - TooltipPortal, - TooltipTrigger -} from "@/components/ui/tooltip" -import { FilterXIcon } from "lucide-react" -import { useIntl } from "react-intl" - -type ClearFiltersButtonProps = { - onClick: () => void -} - -export function ClearFiltersButton(props: ClearFiltersButtonProps) { - const intl = useIntl() - const label = intl.formatMessage({ id: "logExplorer.clearFilters" }) - return ( - - - - - - {label} - - - ) -} diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/header.tsx b/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/header.tsx deleted file mode 100644 index 082d713..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/header.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { SearchInput } from "@/components/search-input" -import { useIntl } from "react-intl" -import { ClearFiltersButton } from "./clear-filters-button" -import { ToggleColumnsButton } from "./toggle-columns-button" -import { ToggleFiltersButton } from "./toggle-filters-button" -import { ToggleLiveButton } from "./toggle-live-button" - -import type { AnySchema } from "@orama/orama" -import type { Table } from "@tanstack/react-table" -import type { PinoramaIntrospection } from "pinorama-types" -import { RefreshDataButton } from "./refresh-data-button" - -type LogViewerHeaderProps = { - introspection: PinoramaIntrospection - table: Table - searchText: string - showClearFiltersButton: boolean - liveMode: boolean - isLoading: boolean - onSearchTextChange: (text: string) => void - onToggleFiltersButtonClick: () => void - onClearFiltersButtonClick: () => void - onToggleLiveButtonClick: (live: boolean) => void - onRefreshButtonClick: () => void -} - -export function LogViewerHeader(props: LogViewerHeaderProps) { - const intl = useIntl() - - return ( -
- - - - - {props.showClearFiltersButton ? ( - - ) : null} - -
- ) -} diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/refresh-data-button.tsx b/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/refresh-data-button.tsx deleted file mode 100644 index 6898e3f..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/refresh-data-button.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Button } from "@/components/ui/button" -import { - Tooltip, - TooltipContent, - TooltipPortal, - TooltipTrigger -} from "@/components/ui/tooltip" -import { cn } from "@/lib/utils" -import { LoaderIcon, RefreshCwIcon } from "lucide-react" -import { useIntl } from "react-intl" - -type RefreshDataButtonProps = { - onClick: () => void - loading?: boolean -} - -export function RefreshDataButton(props: RefreshDataButtonProps) { - const intl = useIntl() - const label = intl.formatMessage({ id: "logExplorer.refreshData" }) - - const Icon = props.loading ? LoaderIcon : RefreshCwIcon - - return ( - - - - - - {label} - - - ) -} diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-filters-button.tsx b/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-filters-button.tsx deleted file mode 100644 index 7d314b9..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-filters-button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Button } from "@/components/ui/button" -import { - Tooltip, - TooltipContent, - TooltipPortal, - TooltipTrigger -} from "@/components/ui/tooltip" -import { FilterIcon } from "lucide-react" -import { useIntl } from "react-intl" - -type ToggleFiltersButtonProps = { - onClick: () => void -} - -export function ToggleFiltersButton(props: ToggleFiltersButtonProps) { - const intl = useIntl() - const label = intl.formatMessage({ id: "logExplorer.showOrHideFilters" }) - return ( - - - - - - {label} - - - ) -} diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-live-button.tsx b/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-live-button.tsx deleted file mode 100644 index fd1dd3c..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-live-button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Toggle } from "@/components/ui/toggle" -import { cn } from "@/lib/utils" -import { PlayCircleIcon, StopCircleIcon } from "lucide-react" -import { useIntl } from "react-intl" - -type ToggleLiveButtonProps = { - pressed?: boolean - onPressedChange?: (pressed: boolean) => void -} - -export function ToggleLiveButton(props: ToggleLiveButtonProps) { - const intl = useIntl() - const label = intl.formatMessage({ id: "logExplorer.liveMode" }) - const Icon = props.pressed ? StopCircleIcon : PlayCircleIcon - - return ( - -
- -
{label}
-
-
- ) -} diff --git a/packages/pinorama-studio/src/features/log-explorer/index.ts b/packages/pinorama-studio/src/features/log-explorer/index.ts deleted file mode 100644 index 1349e7f..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Feature } from "@/types" -import { LogExplorer } from "./components/log-explorer" - -export default { - routePath: "/", - component: LogExplorer, - messages: { - en: () => import("./messages/en.json"), - it: () => import("./messages/it.json") - } -} as Feature diff --git a/packages/pinorama-studio/src/features/log-explorer/messages/en.json b/packages/pinorama-studio/src/features/log-explorer/messages/en.json deleted file mode 100644 index d6c663a..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/messages/en.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "logExplorer.searchLogs": "Search logs...", - "logExplorer.noLogsFound": "No logs found. Please check filters.", - "logExplorer.showOrHideFilters": "Show or hide filters", - "logExplorer.liveMode": "Live Mode", - "logExplorer.refreshData": "Refresh Data", - "logExplorer.clearFilters": "Clear search text and filters", - "logExplorer.columns": "Columns", - "logExplorer.columnsVisibility": "Columns visibility", - "logExplorer.resetColumns": "Reset columns", - "logExplorer.notConnected.title": "Not Connected", - "logExplorer.notConnected.message": "You are currently disconnected. Please connect to view logs.", - "logExplorer.notConnected.action": "Connect" -} diff --git a/packages/pinorama-studio/src/features/log-explorer/messages/it.json b/packages/pinorama-studio/src/features/log-explorer/messages/it.json deleted file mode 100644 index 954ddb5..0000000 --- a/packages/pinorama-studio/src/features/log-explorer/messages/it.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "logExplorer.searchLogs": "Cerca nei log...", - "logExplorer.noLogsFound": "Nessun log trovato. Si prega di controllare i filtri.", - "logExplorer.showOrHideFilters": "Mostra o nascondi filtri", - "logExplorer.liveMode": "Modalità Live", - "logExplorer.refreshData": "Aggiorna Dati", - "logExplorer.clearFilters": "Cancella testo di ricerca e filtri", - "logExplorer.columns": "Colonne", - "logExplorer.columnsVisibility": "Visibilità delle colonne", - "logExplorer.resetColumns": "Reimposta colonne", - "logExplorer.notConnected.title": "Non Connesso", - "logExplorer.notConnected.message": "Sei attualmente disconnesso. Connettiti per visualizzare i log.", - "logExplorer.notConnected.action": "Connetti" -} diff --git a/packages/pinorama-studio/src/hooks/use-module-hotkeys.ts b/packages/pinorama-studio/src/hooks/use-module-hotkeys.ts new file mode 100644 index 0000000..7f84207 --- /dev/null +++ b/packages/pinorama-studio/src/hooks/use-module-hotkeys.ts @@ -0,0 +1,64 @@ +import type { Module } from "@/lib/modules" +import modules from "@/modules" +import { useMemo } from "react" +import type { ComponentType } from "react" +import { useIntl } from "react-intl" + +type ModuleHotkey = { + method: string + keystroke: string + description: string +} + +type ModuleMethod> = keyof NonNullable< + M["hotkeys"] +> + +export function useModuleHotkeys>(module: M) { + const intl = useIntl() + + const hotkeysMap = useMemo(() => { + const hotkeys: Partial, ModuleHotkey>> = {} + + const mod = modules.find((m) => m.id === module.id) + if (!mod || !mod.hotkeys) return hotkeys + + for (const [method, key] of Object.entries(mod.hotkeys)) { + hotkeys[method as ModuleMethod] = { + method, + keystroke: key as string, + description: intl.formatMessage({ id: `${mod.id}.hotkeys.${method}` }) + } + } + + return hotkeys + }, [intl, module.id]) + + const getHotkey = (method: ModuleMethod) => hotkeysMap[method] + + return { hotkeys: hotkeysMap, getHotkey } +} + +export function useAllModuleHotkeys() { + const intl = useIntl() + + return useMemo(() => { + const hotkeys: Record = {} + + for (const mod of modules) { + if (!mod.hotkeys) continue + + const moduleTitle = intl.formatMessage({ id: `${mod.id}.title` }) + + hotkeys[moduleTitle] = Object.entries(mod.hotkeys).map( + ([method, key]) => ({ + method, + keystroke: key as string, + description: intl.formatMessage({ id: `${mod.id}.hotkeys.${method}` }) + }) + ) + } + + return hotkeys + }, [intl]) +} diff --git a/packages/pinorama-studio/src/i18n/index.ts b/packages/pinorama-studio/src/i18n/index.ts index e0ad9e4..8f9ae57 100644 --- a/packages/pinorama-studio/src/i18n/index.ts +++ b/packages/pinorama-studio/src/i18n/index.ts @@ -1,4 +1,4 @@ -import features from "@/features" +import modules from "@/modules" const appMessages: ImportMessages = { en: () => import("./messages/en.json"), @@ -26,16 +26,16 @@ export const getMessages = async (locale: Locale) => { console.warn(`i18n: could not load app messages for "${locale}"`) } - // Feature Messages - for (const feature of features) { - const translationImport = feature.messages?.[locale] + // Module Messages + for (const mod of modules) { + const translationImport = mod.messages?.[locale] if (translationImport) { try { const module = await translationImport() messages = { ...messages, ...module.default } } catch (error) { console.warn( - `i18n: could not load "${feature.id}" messages for "${locale}"` + `i18n: could not load "${mod.id}" messages for "${locale}"` ) } } diff --git a/packages/pinorama-studio/src/i18n/messages/en.json b/packages/pinorama-studio/src/i18n/messages/en.json index dde1f8e..d135691 100644 --- a/packages/pinorama-studio/src/i18n/messages/en.json +++ b/packages/pinorama-studio/src/i18n/messages/en.json @@ -16,5 +16,8 @@ "connection.status.unknown": "Unknown", "labels.inlineError": "Error:", "labels.loading": "Loading...", - "labels.noResultFound": "No result found" + "labels.noResultFound": "No result found", + "labels.copyToClipboard": "Copy to clipboard", + "labels.keyboardShortcuts": "Keyboard Shortcuts", + "labels.allShortcutsSeparatedByModule": "All shortcuts are separated by module" } diff --git a/packages/pinorama-studio/src/i18n/messages/it.json b/packages/pinorama-studio/src/i18n/messages/it.json index 4ba9eca..6ea12af 100644 --- a/packages/pinorama-studio/src/i18n/messages/it.json +++ b/packages/pinorama-studio/src/i18n/messages/it.json @@ -16,5 +16,8 @@ "connection.status.unknown": "Sconosciuto", "labels.inlineError": "Errore:", "labels.loading": "Caricamento in corso...", - "labels.noResultFound": "Nessun risultato trovato" + "labels.noResultFound": "Nessun risultato trovato", + "labels.copyToClipboard": "Copia negli appunti", + "labels.keyboardShortcuts": "Scorciatoie da tastiera", + "labels.allShortcutsSeparatedByModule": "Tutte le scorciatoie sono separate per modulo" } diff --git a/packages/pinorama-studio/src/lib/modules.tsx b/packages/pinorama-studio/src/lib/modules.tsx new file mode 100644 index 0000000..ab0f62c --- /dev/null +++ b/packages/pinorama-studio/src/lib/modules.tsx @@ -0,0 +1,84 @@ +import type { ImportMessages } from "@/i18n" +import { + type ComponentRef, + type ComponentType, + useEffect, + useRef, + useState +} from "react" +import { useHotkeys } from "react-hotkeys-hook" + +export type MethodKeys = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never +}[keyof T] + +export type Hotkeys = Record, string> + +export type Module = { + id: string + routePath: string + component: T + messages?: ImportMessages + hotkeys?: Hotkeys> +} + +export function createModule( + mod: Module +): Module { + return mod.hotkeys + ? { ...mod, component: withHotkeys(mod.component, mod.hotkeys) } + : mod +} + +function withHotkeys( + WrappedComponent: T, + hotkeys: Hotkeys> +): T { + function ComponentWithHotkeys(props: any) { + const ref = useRef>(null) + const [hotkeyEnabled, setHotkeyEnabled] = useState(true) + + useEffect(() => { + const attrName = "data-scroll-locked" + + const checkDataAttribute = () => { + const bodyDataAttribute = document.body.getAttribute(attrName) + setHotkeyEnabled(bodyDataAttribute !== "1") + } + + checkDataAttribute() + + const observer = new MutationObserver(checkDataAttribute) + observer.observe(document.body, { + attributes: true, + attributeFilter: [attrName] + }) + + return () => observer.disconnect() + }, []) + + for (const [method, key] of Object.entries(hotkeys)) { + useHotkeys( + key as string, + (event) => { + event.preventDefault() + if (ref.current) { + const func = ref.current[method as keyof ComponentRef] + if (typeof func === "function") { + func.call(ref.current) + } else { + console.warn(`Method '${method}' not found or not a function`) + } + } + }, + { + enabled: hotkeyEnabled + } + ) + } + + return + } + + return ComponentWithHotkeys as T +} diff --git a/packages/pinorama-studio/src/lib/utils.ts b/packages/pinorama-studio/src/lib/utils.tsx similarity index 100% rename from packages/pinorama-studio/src/lib/utils.ts rename to packages/pinorama-studio/src/lib/utils.tsx diff --git a/packages/pinorama-studio/src/modules/index.tsx b/packages/pinorama-studio/src/modules/index.tsx new file mode 100644 index 0000000..1d6b182 --- /dev/null +++ b/packages/pinorama-studio/src/modules/index.tsx @@ -0,0 +1,17 @@ +import type { Module } from "@/lib/modules" +import LogExplorerModule from "./log-explorer" + +const modules: Module[] = [ + LogExplorerModule, + { + id: "feature-fake", + routePath: "/feature2", + component: () => ( +
+ Welcome to Feature 2! 🎉 +
+ ) + } +] + +export default modules diff --git a/packages/pinorama-studio/src/modules/log-explorer/components/log-details/index.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-details/index.tsx new file mode 100644 index 0000000..51d5dd2 --- /dev/null +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-details/index.tsx @@ -0,0 +1,155 @@ +import { EmptyStateBlock } from "@/components/empty-state" +import { + ArrowDownIcon, + ArrowUpIcon, + Maximize2Icon, + MousePointerClickIcon, + XIcon +} from "lucide-react" +import { JsonView } from "react-json-view-lite" + +import type { AnyOrama } from "@orama/orama" +import type { PinoramaDocument } from "pinorama-types" + +import "./styles/json-viewer.css" +import { + ClipboardButton, + type ImperativeClipboardButtonHandle +} from "@/components/clipboard-button/clipboard-button" +import { IconButton } from "@/components/icon-button/icon-button" +import { Separator } from "@/components/ui/separator" +import { useModuleHotkeys } from "@/hooks/use-module-hotkeys" +import LogExplorerModule from "@/modules/log-explorer" +import { type Ref, forwardRef, useImperativeHandle, useRef } from "react" +import { useIntl } from "react-intl" + +const style = { + container: "json-view-container", + basicChildStyle: "basic-element-style", + label: "label", + clickableLabel: "clickable-label", + nullValue: "value-null", + undefinedValue: "value-undefined", + stringValue: "value-string", + booleanValue: "value-boolean", + numberValue: "value-number", + otherValue: "value-other", + punctuation: "punctuation", + collapseIcon: "collapse-icon", + expandIcon: "expand-icon", + collapsedContent: "collapsed-content", + noQuotesForStringValues: false +} + +type LogDetailsProps = { + data: PinoramaDocument | null + onClose: () => void + onNext: () => void + onPrevious: () => void + onMaximize: () => void + canNext?: boolean + canPrevious?: boolean +} + +export type ImperativeLogDetailsHandle = { + copyToClipboard: () => void +} + +export const LogDetails = forwardRef(function LogDetails( + props: LogDetailsProps, + ref: Ref +) { + const intl = useIntl() + const moduleHotkeys = useModuleHotkeys(LogExplorerModule) + + const copyButtonRef = useRef(null) + + const hotkeys = { + maximizeDetails: moduleHotkeys.getHotkey("maximizeDetails"), + copyToClipboard: moduleHotkeys.getHotkey("copyToClipboard"), + selectNextRow: moduleHotkeys.getHotkey("selectNextRow"), + selectPreviousRow: moduleHotkeys.getHotkey("selectPreviousRow"), + showDetails: moduleHotkeys.getHotkey("showDetails") + } + + useImperativeHandle( + ref, + () => ({ + copyToClipboard: () => { + copyButtonRef.current?.copyToClipboard() + } + }), + [] + ) + + return ( +
+ {/* Toolbar */} +
+
+ +
+
+ + + +
+ + +
+
+
+ + {/* JSON Viewer */} +
+ {!props.data ? ( + + ) : ( + + )} +
+
+ ) +}) diff --git a/packages/pinorama-studio/src/modules/log-explorer/components/log-details/styles/json-viewer.css b/packages/pinorama-studio/src/modules/log-explorer/components/log-details/styles/json-viewer.css new file mode 100644 index 0000000..818506c --- /dev/null +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-details/styles/json-viewer.css @@ -0,0 +1,51 @@ +.json-view-container { + @apply whitespace-pre-wrap font-mono text-sm pl-1 +} + +.punctuation { + @apply text-muted-foreground +} + +.expander { + @apply mr-1 +} + +.expand-icon, .collapse-icon { + @apply inline-flex w-[14px] ml-[-14px] text-lg leading-none text-muted-foreground/40 +} + +.expand-icon::after { + content: 'â–¸'; +} + +.collapse-icon::after { + content: 'â–¾'; +} + +.collapsed-content { + @apply mx-1 +} + +.collapsed-content::after { + content: '...'; +} + +.basic-element-style { + @apply my-1 mx-4 p-0 +} + +.label, .clickable-label { + @apply mr-2 font-semibold +} + +.value-string { + @apply text-sky-400 +} + +.value-number, .value-boolean, .value-null, .value-undefined, .value-other { + @apply text-rose-400 +} + +.collapse-icon {} +.expand-icon {} + diff --git a/packages/pinorama-studio/src/modules/log-explorer/components/log-explorer.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-explorer.tsx new file mode 100644 index 0000000..cc0434e --- /dev/null +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-explorer.tsx @@ -0,0 +1,265 @@ +import { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState +} from "react" + +import { usePinoramaConnection } from "@/hooks" +import { UnplugIcon } from "lucide-react" +import { useIntl } from "react-intl" + +import { EmptyStateBlock } from "@/components/empty-state" +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup +} from "@/components/ui/resizable" +import { type ImperativeLogDetailsHandle, LogDetails } from "./log-details" +import { LogFilters } from "./log-filters" +import { type ImperativeLogViewerHandle, LogViewer } from "./log-viewer" + +import { useAppConfig } from "@/contexts" +import type { AnyOrama } from "@orama/orama" +import type { PinoramaDocument } from "pinorama-types" +import type { ImperativePanelHandle } from "react-resizable-panels" +import type { SearchFilters } from "./log-filters/types" + +const PANEL_SIZES = { + filters: { base: 20, min: 10 }, + details: { base: 25, min: 25 } +} + +export type ImperativeLogExplorerHandle = { + showFilters: () => void + showDetails: () => void + maximizeDetails: () => void + clearFilters: () => void + liveMode: () => void + refresh: () => void + focusSearch: () => void + selectNextRow: () => void + selectPreviousRow: () => void + copyToClipboard: () => void + clearSelection: () => void + incrementFiltersSize: () => void + decrementFiltersSize: () => void + incrementDetailsSize: () => void + decrementDetailsSize: () => void +} + +export const LogExplorer = forwardRef( + function LogExplorer(_props, ref) { + const intl = useIntl() + const appConfig = useAppConfig() + + const { isConnected, toggleConnection, introspection } = + usePinoramaConnection() + + const [liveMode, setLiveMode] = useState(null) + const [filters, setFilters] = useState({}) + const [searchText, setSearchText] = useState("") + const [selectedRow, setSelectedRow] = + useState | null>(null) + + const [detailsPanelCollapsed, setDetailsPanelCollapsed] = useState(true) + const [filtersPanelCollapsed, setFiltersPanelCollapsed] = useState(true) + + const filtersPanelRef = useRef(null) + const detailsPanelRef = useRef(null) + + const viewerRef = useRef(null) + const detailsRef = useRef(null) + + const isLiveModeEnabled = liveMode ?? appConfig?.config.liveMode ?? false + + const showFilters = useCallback(() => { + const panel = filtersPanelRef.current + + if (panel?.isCollapsed()) { + panel?.expand(PANEL_SIZES.filters.base) + } else { + panel?.collapse() + } + }, []) + + const showDetails = useCallback(() => { + const panel = detailsPanelRef.current + + if (panel?.isCollapsed()) { + panel?.expand(PANEL_SIZES.details.base) + } else { + panel?.collapse() + } + }, []) + + const maximizeDetails = useCallback(() => { + if (detailsPanelRef.current?.getSize() !== 100) { + detailsPanelRef.current?.resize(100) + } else { + detailsPanelRef.current?.resize(PANEL_SIZES.details.base) + } + }, []) + + const incrementDetailsSize = useCallback(() => { + detailsPanelRef.current?.resize(detailsPanelRef.current?.getSize() + 1) + }, []) + + const decrementDetailsSize = useCallback(() => { + detailsPanelRef.current?.resize(detailsPanelRef.current?.getSize() - 1) + }, []) + + const incrementFiltersSize = useCallback(() => { + filtersPanelRef.current?.resize(filtersPanelRef.current?.getSize() + 1) + }, []) + + const decrementFiltersSize = useCallback(() => { + filtersPanelRef.current?.resize(filtersPanelRef.current?.getSize() - 1) + }, []) + + const clearFilters = useCallback(() => { + setSearchText("") + setFilters({}) + }, []) + + const changeSelectedRow = useCallback( + (row: PinoramaDocument | null) => { + setSelectedRow(row) + if (!selectedRow && row) { + detailsPanelRef.current?.expand(PANEL_SIZES.details.base) + } + }, + [selectedRow] + ) + + useImperativeHandle( + ref, + () => ({ + showFilters, + showDetails, + maximizeDetails, + clearFilters, + copyToClipboard: () => { + detailsRef.current?.copyToClipboard() + }, + liveMode: () => setLiveMode((prev) => !prev), + refresh: () => viewerRef.current?.refresh(), + focusSearch: () => viewerRef.current?.focusSearch(), + selectNextRow: () => viewerRef.current?.selectNextRow(), + selectPreviousRow: () => viewerRef.current?.selectPreviousRow(), + incrementFiltersSize, + decrementFiltersSize, + incrementDetailsSize, + decrementDetailsSize, + clearSelection: () => { + viewerRef.current?.clearSelection() + } + }), + [ + showFilters, + showDetails, + maximizeDetails, + clearFilters, + incrementFiltersSize, + decrementFiltersSize, + incrementDetailsSize, + decrementDetailsSize + ] + ) + + if (!isConnected) { + return ( + + ) + } + + if (!introspection) { + return null + } + + return ( + + {/* Filters */} + setFiltersPanelCollapsed(true)} + onExpand={() => setFiltersPanelCollapsed(false)} + > + + + + + {/* Viewer */} + + + + + + {/* Details */} + setDetailsPanelCollapsed(true)} + onExpand={() => setDetailsPanelCollapsed(false)} + > + viewerRef.current?.selectNextRow()} + onPrevious={() => viewerRef.current?.selectPreviousRow()} + onClose={() => detailsPanelRef.current?.collapse()} + canNext={viewerRef.current?.canSelectNextRow()} + canPrevious={viewerRef.current?.canSelectPreviousRow()} + /> + + + ) + } +) diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet-body.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet-body.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet-body.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet-body.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet-factory-input.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet-factory-input.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet-factory-input.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet-factory-input.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet-header.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet-header.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet-header.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet-header.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet-item.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet-item.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet-item.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet-item.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/components/facet.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/components/facet.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/hooks/use-facet.ts b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/hooks/use-facet.ts similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/hooks/use-facet.ts rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/hooks/use-facet.ts diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/index.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/index.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/index.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/index.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/lib/operations.ts b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/lib/operations.ts similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/lib/operations.ts rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/lib/operations.ts diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/lib/utils.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/lib/utils.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/lib/utils.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/lib/utils.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-filters/types.ts b/packages/pinorama-studio/src/modules/log-explorer/components/log-filters/types.ts similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-filters/types.ts rename to packages/pinorama-studio/src/modules/log-explorer/components/log-filters/types.ts diff --git a/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/header.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/header.tsx new file mode 100644 index 0000000..3146b77 --- /dev/null +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/header.tsx @@ -0,0 +1,96 @@ +import { SearchInput } from "@/components/search-input" +import { useIntl } from "react-intl" +import { ToggleLiveButton } from "./toggle-live-button" + +import { IconButton } from "@/components/icon-button/icon-button" +import { useModuleHotkeys } from "@/hooks/use-module-hotkeys" +import LogExplorerModule from "@/modules/log-explorer" +import type { AnySchema } from "@orama/orama" +import type { Table } from "@tanstack/react-table" +import { + FilterIcon, + FilterXIcon, + PanelRightIcon, + RefreshCwIcon +} from "lucide-react" +import type { PinoramaIntrospection } from "pinorama-types" +import { ToggleColumnsButton } from "./toggle-columns-button" + +type LogViewerHeaderProps = { + searchInputRef: React.RefObject + introspection: PinoramaIntrospection + table: Table + searchText: string + showClearFiltersButton: boolean + liveMode: boolean + isLoading: boolean + onSearchTextChange: (text: string) => void + onToggleFiltersButtonClick: () => void + onClearFiltersButtonClick: () => void + onToggleLiveButtonClick: (live: boolean) => void + onRefreshButtonClick: () => void + onToggleDetailsButtonClick: () => void +} + +export function LogViewerHeader(props: LogViewerHeaderProps) { + const intl = useIntl() + const moduleHotkeys = useModuleHotkeys(LogExplorerModule) + + const hotkeys = { + showFilters: moduleHotkeys.getHotkey("showFilters"), + refresh: moduleHotkeys.getHotkey("refresh"), + clearFilters: moduleHotkeys.getHotkey("clearFilters"), + showDetails: moduleHotkeys.getHotkey("showDetails") + } + + return ( +
+ + + + + {props.showClearFiltersButton ? ( + + ) : null} + + +
+ ) +} diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-columns-button.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/toggle-columns-button.tsx similarity index 68% rename from packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-columns-button.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/toggle-columns-button.tsx index a1bcd77..75ccdca 100644 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/header/toggle-columns-button.tsx +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/toggle-columns-button.tsx @@ -1,40 +1,31 @@ -import { Button } from "@/components/ui/button" +import { IconButton } from "@/components/icon-button/icon-button" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { createField } from "@/lib/introspection" import type { AnySchema } from "@orama/orama" import type { Table } from "@tanstack/react-table" -import { EllipsisVerticalIcon } from "lucide-react" +import { ListChecksIcon } from "lucide-react" import type { PinoramaIntrospection } from "pinorama-types" -import { FormattedMessage, useIntl } from "react-intl" +import { FormattedMessage } from "react-intl" -type ColumnsVisibilityButtonProps = { +type ToggleColumnsButtonProps = { introspection: PinoramaIntrospection table: Table } -export function ToggleColumnsButton(props: ColumnsVisibilityButtonProps) { - const intl = useIntl() - const label = intl.formatMessage({ id: "logExplorer.columnsVisibility" }) +export function ToggleColumnsButton(props: ToggleColumnsButtonProps) { return ( - - + + - - - - {props.table .getAllColumns() .filter( diff --git a/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/toggle-live-button.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/toggle-live-button.tsx new file mode 100644 index 0000000..94b1f0f --- /dev/null +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/header/toggle-live-button.tsx @@ -0,0 +1,56 @@ +import { Kbd } from "@/components/kbd/kbd" +import { Toggle } from "@/components/ui/toggle" +import { TooltipContent } from "@/components/ui/tooltip" +import { useModuleHotkeys } from "@/hooks/use-module-hotkeys" +import { cn } from "@/lib/utils" +import LogExplorerModule from "@/modules/log-explorer" +import { Tooltip, TooltipPortal, TooltipTrigger } from "@radix-ui/react-tooltip" +import { PlayCircleIcon, StopCircleIcon } from "lucide-react" +import { useIntl } from "react-intl" + +type ToggleLiveButtonProps = { + pressed?: boolean + onPressedChange?: (pressed: boolean) => void +} + +export function ToggleLiveButton(props: ToggleLiveButtonProps) { + const intl = useIntl() + const moduleHotkeys = useModuleHotkeys(LogExplorerModule) + + const label = intl.formatMessage({ id: "logExplorer.liveMode" }) + const hotkey = moduleHotkeys.getHotkey("liveMode") + const Icon = props.pressed ? StopCircleIcon : PlayCircleIcon + + return ( + + + +
+ +
{label}
+
+
+
+ + +
{hotkey?.description}
+ {hotkey?.keystroke} +
+
+
+ ) +} diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/tbody.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/tbody.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/tbody.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/tbody.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/thead.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/thead.tsx similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/components/log-viewer/components/thead.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/components/thead.tsx diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/hooks/use-live-logs.ts b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/hooks/use-live-logs.ts similarity index 97% rename from packages/pinorama-studio/src/features/log-explorer/components/log-viewer/hooks/use-live-logs.ts rename to packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/hooks/use-live-logs.ts index 338d8ef..f2fed41 100644 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/hooks/use-live-logs.ts +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/hooks/use-live-logs.ts @@ -1,5 +1,5 @@ import { usePinoramaClient } from "@/contexts" -import { buildPayload } from "@/features/log-explorer/utils" +import { buildPayload } from "@/modules/log-explorer/utils" import { useInfiniteQuery } from "@tanstack/react-query" import { useCallback, useEffect, useMemo, useRef } from "react" diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/hooks/use-static-logs.ts b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/hooks/use-static-logs.ts similarity index 92% rename from packages/pinorama-studio/src/features/log-explorer/components/log-viewer/hooks/use-static-logs.ts rename to packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/hooks/use-static-logs.ts index b67325b..16b4db6 100644 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/hooks/use-static-logs.ts +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/hooks/use-static-logs.ts @@ -1,5 +1,5 @@ import { usePinoramaClient } from "@/contexts" -import { buildPayload } from "@/features/log-explorer/utils" +import { buildPayload } from "@/modules/log-explorer/utils" import { useQuery } from "@tanstack/react-query" import type { SearchFilters } from "../../log-filters/types" diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/index.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/index.tsx similarity index 75% rename from packages/pinorama-studio/src/features/log-explorer/components/log-viewer/index.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/index.tsx index 943cc2a..b462ae5 100644 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/index.tsx +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/index.tsx @@ -1,4 +1,13 @@ -import { forwardRef, useEffect, useMemo, useRef, useState } from "react" +import { + type Ref, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from "react" import { EmptyStateInline } from "@/components/empty-state/empty-state" import { ErrorState } from "@/components/error-state/error-state" @@ -30,9 +39,23 @@ type LogViewerProps = { onToggleFiltersButtonClick: () => void onClearFiltersButtonClick: () => void onToggleLiveButtonClick: (live: boolean) => void + onToggleDetailsButtonClick: () => void +} + +export type ImperativeLogViewerHandle = { + refresh: () => void + focusSearch: () => void + selectNextRow: () => void + selectPreviousRow: () => void + clearSelection: () => void + canSelectNextRow: () => boolean + canSelectPreviousRow: () => boolean } -export const LogViewer = forwardRef(function LogViewer(props: LogViewerProps) { +export const LogViewer = forwardRef(function LogViewer( + props: LogViewerProps, + ref: Ref +) { const intl = useIntl() const staticLogsQuery = useStaticLogs( @@ -49,6 +72,7 @@ export const LogViewer = forwardRef(function LogViewer(props: LogViewerProps) { const [rowSelection, setRowSelection] = useState({}) const tableContainerRef = useRef(null) + const searchInputRef = useRef(null) const logsQuery = props.liveMode ? liveLogsQuery : staticLogsQuery const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]) @@ -88,9 +112,33 @@ export const LogViewer = forwardRef(function LogViewer(props: LogViewerProps) { // biome-ignore lint: I need to reset row selection on filters change useEffect(() => { - table.setRowSelection({}) + clearSelection() }, [props.filters, props.searchText]) + const clearSelection = useCallback(() => { + table.setRowSelection({}) + }, [table]) + + useImperativeHandle( + ref, + () => ({ + refresh: () => logsQuery.refetch(), + focusSearch: () => searchInputRef.current?.focus(), + selectNextRow: () => { + const currentIndex = utils.getCurrentRowIndex(table) + utils.selectRowByIndex(currentIndex + 1, table) + }, + selectPreviousRow: () => { + const currentIndex = utils.getCurrentRowIndex(table) + utils.selectRowByIndex(currentIndex - 1, table) + }, + clearSelection, + canSelectNextRow: () => utils.canSelectNextRow(table), + canSelectPreviousRow: () => utils.canSelectPreviousRow(table) + }), + [logsQuery.refetch, clearSelection, table] + ) + const { rows } = table.getRowModel() const virtualizer = useVirtualizer({ @@ -107,6 +155,7 @@ export const LogViewer = forwardRef(function LogViewer(props: LogViewerProps) { return (
diff --git a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/utils/index.tsx b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/utils/index.tsx similarity index 62% rename from packages/pinorama-studio/src/features/log-explorer/components/log-viewer/utils/index.tsx rename to packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/utils/index.tsx index 0a905ba..0f5fe03 100644 --- a/packages/pinorama-studio/src/features/log-explorer/components/log-viewer/utils/index.tsx +++ b/packages/pinorama-studio/src/modules/log-explorer/components/log-viewer/utils/index.tsx @@ -1,7 +1,7 @@ import { createField } from "@/lib/introspection" import { cn } from "@/lib/utils" import type { AnySchema } from "@orama/orama" -import type { ColumnDef } from "@tanstack/react-table" +import type { ColumnDef, Table } from "@tanstack/react-table" import type { PinoramaIntrospection } from "pinorama-types" const DEFAULT_COLUMN_SIZE = 150 @@ -58,3 +58,31 @@ export const getColumnsConfig = ( definition } } + +export const selectRowByIndex = (index: number, table: Table) => { + const totalRows = table.getRowModel().rows.length + const validIndex = Math.max(0, Math.min(index, totalRows - 1)) + + table.setRowSelection({ [validIndex]: true }) + + const row = document.querySelector(`[data-index="${validIndex}"]`) + row?.scrollIntoView({ + block: "center", + behavior: "smooth" + }) +} + +export const getCurrentRowIndex = (table: Table) => { + const selectedKeys = table.getSelectedRowModel() + return selectedKeys.rows[0]?.index ?? -1 +} + +export const canSelectNextRow = (table: Table) => { + const currentRowIndex = getCurrentRowIndex(table) + return currentRowIndex < table.getRowModel().rows.length - 1 +} + +export const canSelectPreviousRow = (table: Table) => { + const currentRowIndex = getCurrentRowIndex(table) + return currentRowIndex > 0 +} diff --git a/packages/pinorama-studio/src/modules/log-explorer/index.ts b/packages/pinorama-studio/src/modules/log-explorer/index.ts new file mode 100644 index 0000000..bf5fbee --- /dev/null +++ b/packages/pinorama-studio/src/modules/log-explorer/index.ts @@ -0,0 +1,29 @@ +import { createModule } from "@/lib/modules" +import { LogExplorer } from "./components/log-explorer" + +export default createModule({ + id: "logExplorer", + component: LogExplorer, + routePath: "/", + messages: { + en: () => import("./messages/en.json"), + it: () => import("./messages/it.json") + }, + hotkeys: { + focusSearch: "/", + showFilters: "f", + showDetails: "o", + maximizeDetails: "m", + liveMode: "l", + refresh: "r", + clearFilters: "x", + selectNextRow: "j, down", + selectPreviousRow: "k, up", + copyToClipboard: "y, c", + incrementFiltersSize: "shift+f", + decrementFiltersSize: "shift+d", + incrementDetailsSize: "shift+j", + decrementDetailsSize: "shift+k", + clearSelection: "esc" + } +} as const) diff --git a/packages/pinorama-studio/src/modules/log-explorer/messages/en.json b/packages/pinorama-studio/src/modules/log-explorer/messages/en.json new file mode 100644 index 0000000..2d75820 --- /dev/null +++ b/packages/pinorama-studio/src/modules/log-explorer/messages/en.json @@ -0,0 +1,28 @@ +{ + "logExplorer.title": "Log Explorer", + "logExplorer.searchLogs": "Search logs...", + "logExplorer.noLogsFound": "No logs found. Please check filters.", + "logExplorer.liveMode": "Live Mode", + "logExplorer.columns": "Columns", + "logExplorer.resetColumns": "Reset columns", + "logExplorer.notConnected.title": "Not Connected", + "logExplorer.notConnected.message": "You are currently disconnected. Please connect to view logs.", + "logExplorer.notConnected.action": "Connect", + "logExplorer.noDataSelected.title": "No data selected", + "logExplorer.noDataSelected.message": "Select a row to view details.", + "logExplorer.hotkeys.focusSearch": "Focus search bar", + "logExplorer.hotkeys.showFilters": "Show or hide filters", + "logExplorer.hotkeys.showDetails": "Show or hide details", + "logExplorer.hotkeys.maximizeDetails": "Maximize details", + "logExplorer.hotkeys.liveMode": "Enable or disable Live Mode", + "logExplorer.hotkeys.refresh": "Refresh data", + "logExplorer.hotkeys.clearFilters": "Clear all filters", + "logExplorer.hotkeys.selectNextRow": "Select next row", + "logExplorer.hotkeys.selectPreviousRow": "Select previous row", + "logExplorer.hotkeys.copyToClipboard": "Copy to clipboard", + "logExplorer.hotkeys.incrementFiltersSize": "Increase filters area", + "logExplorer.hotkeys.decrementFiltersSize": "Decrease filters area", + "logExplorer.hotkeys.incrementDetailsSize": "Increase details area", + "logExplorer.hotkeys.decrementDetailsSize": "Decrease details area", + "logExplorer.hotkeys.clearSelection": "Clear selection" +} diff --git a/packages/pinorama-studio/src/modules/log-explorer/messages/it.json b/packages/pinorama-studio/src/modules/log-explorer/messages/it.json new file mode 100644 index 0000000..c6c5cea --- /dev/null +++ b/packages/pinorama-studio/src/modules/log-explorer/messages/it.json @@ -0,0 +1,28 @@ +{ + "logExplorer.title": "Log Explorer", + "logExplorer.searchLogs": "Cerca nei log...", + "logExplorer.noLogsFound": "Nessun log trovato. Si prega di controllare i filtri.", + "logExplorer.liveMode": "Modalità Live", + "logExplorer.columns": "Colonne", + "logExplorer.resetColumns": "Reimposta colonne", + "logExplorer.notConnected.title": "Non Connesso", + "logExplorer.notConnected.message": "Sei attualmente disconnesso. Connettiti per visualizzare i log.", + "logExplorer.notConnected.action": "Connetti", + "logExplorer.noDataSelected.title": "Nessun dato selezionato", + "logExplorer.noDataSelected.message": "Seleziona una riga per visualizzarne i dettagli.", + "logExplorer.hotkeys.focusSearch": "Focus sulla barra di ricerca", + "logExplorer.hotkeys.showFilters": "Mostra o nascondi i filtri", + "logExplorer.hotkeys.showDetails": "Mostra o nascondi i dettagli", + "logExplorer.hotkeys.maximizeDetails": "Massimizza i dettagli", + "logExplorer.hotkeys.liveMode": "Abilita o disabilita la modalità Live", + "logExplorer.hotkeys.refresh": "Aggiorna i dati", + "logExplorer.hotkeys.clearFilters": "Cancella tutti i filtri", + "logExplorer.hotkeys.selectNextRow": "Seleziona la riga successiva", + "logExplorer.hotkeys.selectPreviousRow": "Seleziona la riga precedente", + "logExplorer.hotkeys.copyToClipboard": "Copia negli appunti", + "logExplorer.hotkeys.incrementFiltersSize": "Aumenta l'area dei filtri", + "logExplorer.hotkeys.decrementFiltersSize": "Diminuisci l'area dei filtri", + "logExplorer.hotkeys.incrementDetailsSize": "Aumenta l'area dei dettagli", + "logExplorer.hotkeys.decrementDetailsSize": "Diminuisci l'area dei dettagli", + "logExplorer.hotkeys.clearSelection": "Cancella la selezione" +} diff --git a/packages/pinorama-studio/src/features/log-explorer/utils/index.ts b/packages/pinorama-studio/src/modules/log-explorer/utils/index.ts similarity index 100% rename from packages/pinorama-studio/src/features/log-explorer/utils/index.ts rename to packages/pinorama-studio/src/modules/log-explorer/utils/index.ts diff --git a/packages/pinorama-studio/src/root.tsx b/packages/pinorama-studio/src/root.tsx index 377d155..489e3ad 100644 --- a/packages/pinorama-studio/src/root.tsx +++ b/packages/pinorama-studio/src/root.tsx @@ -2,13 +2,12 @@ import "./styles/globals.css" import { RouterProvider, - type SyncRouteComponent, createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router" -import { type ComponentType, StrictMode } from "react" +import { StrictMode } from "react" import { TooltipProvider } from "@/components/ui/tooltip" import { @@ -18,17 +17,17 @@ import { ThemeProvider } from "@/contexts" import App from "./app" -import features from "./features" +import modules from "./modules" const rootRoute = createRootRoute({ component: App }) -const routes = features.map((feature) => +const routes = modules.map((mod) => createRoute({ getParentRoute: () => rootRoute, - path: feature.routePath, - component: feature.component as SyncRouteComponent + path: mod.routePath, + component: mod.component }) ) diff --git a/packages/pinorama-studio/src/types/index.ts b/packages/pinorama-studio/src/types/index.ts deleted file mode 100644 index cb583a7..0000000 --- a/packages/pinorama-studio/src/types/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ImportMessages } from "@/i18n" -import type React from "react" - -export type Feature = { - routePath: string - component: React.ComponentType - messages?: ImportMessages -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abf5213..4e5b748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.1.1 version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -216,6 +219,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-toast': + specifier: ^1.2.1 + version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-toggle': specifier: ^1.1.0 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -285,9 +291,15 @@ importers: react-hook-form: specifier: ^7.52.1 version: 7.52.1(react@18.3.1) + react-hotkeys-hook: + specifier: ^4.5.0 + version: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-intl: specifier: ^6.6.8 version: 6.6.8(react@18.3.1)(typescript@5.5.3) + react-json-view-lite: + specifier: ^1.4.0 + version: 1.4.0(react@18.3.1) react-resizable-panels: specifier: ^2.0.20 version: 2.0.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1132,6 +1144,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.1': + resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.0': resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} peerDependencies: @@ -1337,6 +1362,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-toast@1.2.1': + resolution: {integrity: sha512-5trl7piMXcZiCq7MW6r8YYmu0bK5qDpTWz+FdEPdKyft2UixkspheYbjbrLXVN5NGKHFbOP7lm8eD0biiSqZqg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toggle@1.1.0': resolution: {integrity: sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==} peerDependencies: @@ -3195,6 +3233,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-hotkeys-hook@4.5.0: + resolution: {integrity: sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==} + peerDependencies: + react: '>=16.8.1' + react-dom: '>=16.8.1' + react-intl@6.6.8: resolution: {integrity: sha512-M0pkhzcgV31h++2901BiRXWl69hp2zPyLxRrSwRjd1ErXbNoubz/f4M6DrRTd4OiSUrT4ajRQzrmtS5plG4FtA==} peerDependencies: @@ -3210,6 +3254,12 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-json-view-lite@1.4.0: + resolution: {integrity: sha512-wh6F6uJyYAmQ4fK0e8dSQMEWuvTs2Wr3el3sLD9bambX1+pSWUVXIz1RFaoy3TI1mZ0FqdpKq9YgbgTTgyrmXA==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -4767,6 +4817,28 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-dialog@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.7(@types/react@18.3.3)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: react: 18.3.1 @@ -4992,6 +5064,26 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-toast@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-toggle@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -6775,6 +6867,11 @@ snapshots: dependencies: react: 18.3.1 + react-hotkeys-hook@4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-intl@6.6.8(react@18.3.1)(typescript@5.5.3): dependencies: '@formatjs/ecma402-abstract': 2.0.0 @@ -6795,6 +6892,10 @@ snapshots: react-is@18.3.1: {} + react-json-view-lite@1.4.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-refresh@0.14.2: {} react-remove-scroll-bar@2.3.6(@types/react@18.3.3)(react@18.3.1):