diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index b4e34d0c..87e4cbb4 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -1,155 +1,185 @@ -import { Button, ButtonGroup, Typography } from '@material-tailwind/react'; +import { Typography } from '@material-tailwind/react'; import { Link } from 'react-router'; +import { HiOutlineClipboardCopy } from 'react-icons/hi'; +import { HiOutlineEllipsisHorizontalCircle } from 'react-icons/hi2'; import neuroglancer_logo from '@/assets/neuroglancer.png'; import validator_logo from '@/assets/ome-ngff-validator.png'; import volE_logo from '@/assets/aics_website-3d-cell-viewer.png'; import avivator_logo from '@/assets/vizarr_logo.png'; -import copy_logo from '@/assets/copy-link-64.png'; import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; +import DialogIconBtn from '@/components/ui/buttons/DialogIconBtn'; +import DataLinkUsageDialog from '@/components/ui/Dialogs/DataLinkUsageDialog'; +import type { DataLinkType } from '@/components/ui/Dialogs/DataLinkUsageDialog'; + +const CIRCLE_CLASSES = + 'rounded-full bg-surface-light dark:bg-surface-light hover:bg-surface dark:hover:bg-surface w-12 h-12 flex items-center justify-center cursor-pointer transform active:scale-90 transition-all duration-75'; + +const LABEL_CLASSES = 'text-xs text-center text-foreground mt-1'; export default function DataToolLinks({ + compact = false, + dataLinkUrl, + dataType, onToolClick, showCopiedTooltip, title, urls }: { + readonly compact?: boolean; + readonly dataLinkUrl?: string; + readonly dataType?: DataLinkType; readonly onToolClick: (toolKey: PendingToolKey) => Promise; readonly showCopiedTooltip: boolean; readonly title: string; readonly urls: OpenWithToolUrls | null; }) { - const tooltipTriggerClasses = - 'rounded-sm m-0 p-0 transform active:scale-90 transition-transform duration-75'; - if (!urls) { return null; } return (
- + {title} - +
{urls.neuroglancer !== null ? ( - - { - e.preventDefault(); - await onToolClick('neuroglancer'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.neuroglancer} +
+ - Neuroglancer logo - - + { + e.preventDefault(); + await onToolClick('neuroglancer'); + }} + rel="noopener noreferrer" + target="_blank" + to={urls.neuroglancer} + > + Neuroglancer logo + + + Neuroglancer +
) : null} {urls.vole !== null ? ( - - { - e.preventDefault(); - await onToolClick('vole'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.vole} - > - Vol-E logo - - +
+ + { + e.preventDefault(); + await onToolClick('vole'); + }} + rel="noopener noreferrer" + target="_blank" + to={urls.vole} + > + Vol-E logo + + + Vol-E +
) : null} {urls.avivator !== null ? ( - - { - e.preventDefault(); - await onToolClick('avivator'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.avivator} - > - Avivator logo - - +
+ + { + e.preventDefault(); + await onToolClick('avivator'); + }} + rel="noopener noreferrer" + target="_blank" + to={urls.avivator} + > + Avivator logo + + + Avivator +
) : null} {urls.validator !== null ? ( +
+ + { + e.preventDefault(); + await onToolClick('validator'); + }} + rel="noopener noreferrer" + target="_blank" + to={urls.validator} + > + OME-Zarr Validator logo + + + Validator +
+ ) : null} + +
{ + await onToolClick('copy'); + }} + openCondition={showCopiedTooltip ? true : undefined} + triggerClasses={CIRCLE_CLASSES} > - { - e.preventDefault(); - await onToolClick('validator'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.validator} - > - OME-Zarr Validator logo - + - ) : null} + Copy +
- { - await onToolClick('copy'); - }} - openCondition={showCopiedTooltip ? true : undefined} - triggerClasses={tooltipTriggerClasses} - variant="ghost" - > - Copy URL icon - - + {dataLinkUrl && dataType ? ( +
+ + {closeDialog => ( + + )} + + More... +
+ ) : null} +
); } diff --git a/frontend/src/components/ui/BrowsePage/FileViewer.tsx b/frontend/src/components/ui/BrowsePage/FileViewer.tsx index cfb3a8d5..50d89c01 100644 --- a/frontend/src/components/ui/BrowsePage/FileViewer.tsx +++ b/frontend/src/components/ui/BrowsePage/FileViewer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Switch, Typography } from '@material-tailwind/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { @@ -13,6 +13,7 @@ import { useFileContentQuery, useFileMetadataQuery } from '@/queries/fileContentQueries'; +import useDarkMode from '@/hooks/useDarkMode'; type FileViewerProps = { readonly file: FileOrFolder; @@ -79,8 +80,7 @@ const getLanguageFromExtension = (filename: string): string => { export default function FileViewer({ file }: FileViewerProps) { const { fspName } = useFileBrowserContext(); - - const [isDarkMode, setIsDarkMode] = useState(false); + const isDarkMode = useDarkMode(); const [formatJson, setFormatJson] = useState(true); // First, fetch metadata to check if file is binary @@ -97,22 +97,6 @@ export default function FileViewer({ file }: FileViewerProps) { const language = getLanguageFromExtension(file.name); const isJsonFile = language === 'json'; - // Detect dark mode from document - useEffect(() => { - const checkDarkMode = () => { - setIsDarkMode(document.documentElement.classList.contains('dark')); - }; - - checkDarkMode(); - const observer = new MutationObserver(checkDarkMode); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['class'] - }); - - return () => observer.disconnect(); - }, []); - const renderViewer = () => { if (metadataQuery.isLoading) { return ( @@ -184,7 +168,10 @@ export default function FileViewer({ file }: FileViewerProps) { codeTagProps={mergedCodeTagProps} customStyle={{ margin: 0, - padding: '1rem', + paddingTop: '1em', + paddingRight: '1em', + paddingBottom: '0', + paddingLeft: '1em', fontSize: '14px', lineHeight: '1.5', overflow: 'visible', diff --git a/frontend/src/components/ui/BrowsePage/N5Preview.tsx b/frontend/src/components/ui/BrowsePage/N5Preview.tsx index ec1f5c55..9e598ca8 100644 --- a/frontend/src/components/ui/BrowsePage/N5Preview.tsx +++ b/frontend/src/components/ui/BrowsePage/N5Preview.tsx @@ -55,6 +55,9 @@ export default function N5Preview({ {openWithToolUrls ? ( - ) => { - setShowNavigationDialog(true); - }} - triggerClasses={triggerClasses} - /> - {showNavigationDialog ? ( - setShowNavigationDialog(false)} - open={showNavigationDialog} - > - - - ) : null} - + + {closeDialog => ( + + )} + ); } diff --git a/frontend/src/components/ui/BrowsePage/NewFolderButton.tsx b/frontend/src/components/ui/BrowsePage/NewFolderButton.tsx index 401f6be1..0130fff8 100644 --- a/frontend/src/components/ui/BrowsePage/NewFolderButton.tsx +++ b/frontend/src/components/ui/BrowsePage/NewFolderButton.tsx @@ -1,11 +1,9 @@ -import { useState } from 'react'; -import type { ChangeEvent, MouseEvent } from 'react'; +import type { ChangeEvent } from 'react'; import { Button, Typography } from '@material-tailwind/react'; import { HiFolderAdd } from 'react-icons/hi'; import toast from 'react-hot-toast'; -import FgTooltip from '@/components/ui/widgets/FgTooltip'; -import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import DialogIconBtn from '@/components/ui/buttons/DialogIconBtn'; import { Spinner } from '@/components/ui/widgets/Loaders'; import useNewFolderDialog from '@/hooks/useNewFolderDialog'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; @@ -17,7 +15,6 @@ type NewFolderButtonProps = { export default function NewFolderButton({ triggerClasses }: NewFolderButtonProps) { - const [showNewFolderDialog, setShowNewFolderDialog] = useState(false); const { fspName, mutations } = useFileBrowserContext(); const { handleNewFolderSubmit, newName, setNewName, isDuplicateName } = useNewFolderDialog(); @@ -25,7 +22,10 @@ export default function NewFolderButton({ const isSubmitDisabled = !newName.trim() || isDuplicateName || mutations.createFolder.isPending; - const formSubmit = async (event: React.FormEvent) => { + const formSubmit = async ( + event: React.FormEvent, + closeDialog: () => void + ) => { event.preventDefault(); const result = await handleNewFolderSubmit(); if (result.success) { @@ -34,74 +34,62 @@ export default function NewFolderButton({ } else { toast.error(`Error creating folder: ${result.error}`); } - setShowNewFolderDialog(false); - }; - - const handleClose = () => { - setNewName(''); - setShowNewFolderDialog(false); + closeDialog(); }; return ( - <> - ) => { - setShowNewFolderDialog(true); - }} - triggerClasses={triggerClasses} - /> - {showNewFolderDialog ? ( - -
-
- - Create a New Folder + + {closeDialog => ( + formSubmit(e, closeDialog)}> +
+ + Create a New Folder + + ) => { + setNewName(event.target.value); + }} + placeholder="Folder name ..." + type="text" + value={newName} + /> +
+
+ + {!newName.trim() ? ( + + Please enter a folder name + + ) : newName.trim() && isDuplicateName ? ( + + A file or folder with this name already exists - ) => { - setNewName(event.target.value); - }} - placeholder="Folder name ..." - type="text" - value={newName} - /> -
-
- - {!newName.trim() ? ( - - Please enter a folder name - - ) : newName.trim() && isDuplicateName ? ( - - A file or folder with this name already exists - - ) : null} -
- - - ) : null} - + ) : null} +
+ + )} + ); } diff --git a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx index 0b917349..0d952418 100644 --- a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx +++ b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx @@ -92,6 +92,9 @@ export default function ZarrPreview({ {openWithToolUrls ? ( + + {code} + + {copyable ? ( +
+ + + +
+ ) : null} + + ); +} + +type InstructionBlockProps = { + readonly steps: string[]; +}; + +function InstructionBlock({ steps }: InstructionBlockProps) { + return ( +
    + {steps.map((step, index) => ( +
  1. + + {index + 1} + + {step} +
  2. + ))} +
+ ); +} + +type DataLinkUsageDialogProps = { + readonly dataLinkUrl: string; + readonly dataType: DataLinkType; + readonly open: boolean; + readonly onClose: () => void; +}; + +function getNapariZarrTab(dataLinkUrl: string) { + return { + id: 'napari', + label: 'Napari', + content: ( +
    +
  1. + + 1 + + +
  2. +
  3. + + 2 + +
    + + Install the napari-ome-zarr plugin. Assuming you are in the conda + environment, from the command line: + + +
    +
  4. +
  5. + + 3 + +
    + + Launch napari and open the data link using the napari-ome-zarr + plugin. From the command line: + + + Or in Python: + +
    +
  6. +
+ ) + }; +} + +function getFijiTab() { + return { + id: 'fiji', + label: 'Fiji', + content: ( + + ) + }; +} + +function getTabsForDataType(dataType: DataLinkType, dataLinkUrl: string) { + if (dataType === 'directory') { + return [ + { + id: 'java', + label: 'Java', + content: ( + response = client.send(request, + HttpResponse.BodyHandlers.ofString()); + + Document doc = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(new ByteArrayInputStream( + response.body().getBytes())); + + // Print files + NodeList contents = doc.getElementsByTagName("Contents"); + for (int i = 0; i < contents.getLength(); i++) { + Element el = (Element) contents.item(i); + String key = el.getElementsByTagName("Key") + .item(0).getTextContent(); + String size = el.getElementsByTagName("Size") + .item(0).getTextContent(); + System.out.println(key + " (" + size + " bytes)"); + } + + // Print subdirectories + NodeList prefixes = doc.getElementsByTagName("CommonPrefixes"); + for (int i = 0; i < prefixes.getLength(); i++) { + Element el = (Element) prefixes.item(i); + String prefix = el.getElementsByTagName("Prefix") + .item(0).getTextContent(); + System.out.println(prefix + " (directory)"); + } + } +}`} + copyLabel="Copy code" + copyable={true} + language="java" + /> + ) + }, + { + id: 'python', + label: 'Python', + content: ( + <> + + + + ) + } + ]; + } + + if (dataType === 'zarr') { + return [ + getFijiTab(), + { + id: 'java', + label: 'Java', + content: ( + block = reader.readBlock( + dataset, attrs, blockPosition); + if (block != null) { + Object data = block.getData(); + if (data instanceof short[]) { + short[] arr = (short[]) data; + System.out.println("First 10 voxels: " + Arrays.toString(Arrays.copyOf(arr, Math.min(10, arr.length)))); + } else if (data instanceof float[]) { + float[] arr = (float[]) data; + System.out.println("First 10 voxels: " + Arrays.toString(Arrays.copyOf(arr, Math.min(10, arr.length)))); + } else if (data instanceof byte[]) { + byte[] arr = (byte[]) data; + System.out.println("First 10 voxels: " + Arrays.toString(Arrays.copyOf(arr, Math.min(10, arr.length)))); + } + } + + reader.close(); + } +}`} + copyLabel="Copy code" + copyable={true} + language="java" + /> + ) + }, + getNapariZarrTab(dataLinkUrl), + { + id: 'python', + label: 'Python', + content: ( + <> + + + + ) + } + ]; + } + + // dataType === 'n5' + return [ + getFijiTab(), + { + id: 'java', + label: 'Java', + content: ( + block = reader.readBlock( + dataset, attrs, blockPosition); + if (block != null) { + Object data = block.getData(); + if (data instanceof short[]) { + short[] arr = (short[]) data; + System.out.println("First 10 voxels: " + Arrays.toString(Arrays.copyOf(arr, Math.min(10, arr.length)))); + } else if (data instanceof float[]) { + float[] arr = (float[]) data; + System.out.println("First 10 voxels: " + Arrays.toString(Arrays.copyOf(arr, Math.min(10, arr.length)))); + } else if (data instanceof byte[]) { + byte[] arr = (byte[]) data; + System.out.println("First 10 voxels: " + Arrays.toString(Arrays.copyOf(arr, Math.min(10, arr.length)))); + } + } + + reader.close(); + } +}`} + copyLabel="Copy code" + copyable={true} + language="java" + /> + ) + }, + { + id: 'napari', + label: 'Napari', + content: ( + + ) + }, + { + id: 'python', + label: 'Python', + content: ( + <> + + + + ) + } + ]; +} + +export default function DataLinkUsageDialog({ + dataLinkUrl, + dataType, + open, + onClose +}: DataLinkUsageDialogProps) { + const tabs = getTabsForDataType(dataType, dataLinkUrl); + const [activeTab, setActiveTab] = useState(tabs[0]?.id ?? ''); + + const TAB_TRIGGER_CLASSES = '!text-foreground h-full'; + const PANEL_CLASSES = + 'flex flex-col gap-4 max-w-full max-h-[65vh] p-4 rounded-b-lg border border-t-0 border-surface dark:border-foreground/30 bg-surface-light dark:bg-surface overflow-y-auto overflow-x-hidden'; + + return ( + +
+ + How to use your data link + +
+ + {dataLinkUrl} + + + + +
+ + + {tabs.map(tab => ( + + {tab.label} + + ))} + + + + {tabs.map(tab => ( + + {tab.content} + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index f14e8515..f8f936df 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -8,7 +8,12 @@ import { Tabs } from '@material-tailwind/react'; import toast from 'react-hot-toast'; -import { HiOutlineDocument, HiOutlineDuplicate, HiX } from 'react-icons/hi'; +import { + HiExternalLink, + HiOutlineDocument, + HiOutlineDuplicate, + HiX +} from 'react-icons/hi'; import { HiOutlineFolder } from 'react-icons/hi2'; import { useLocation } from 'react-router'; @@ -17,6 +22,10 @@ import OverviewTable from '@/components/ui/PropertiesDrawer/OverviewTable'; import TicketDetails from '@/components/ui/PropertiesDrawer/TicketDetails'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; +import DataLinkUsageDialog, { + inferDataType +} from '@/components/ui/Dialogs/DataLinkUsageDialog'; +import TextDialogBtn from '@/components/ui/buttons/DialogTextBtn'; import { getPreferredPathForDisplay } from '@/utils'; import { copyToClipboard } from '@/utils/copyText'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; @@ -307,17 +316,67 @@ export default function PropertiesDrawer({ ? 'Deleting the data link will remove data access for collaborators with the link.' : 'Creating a data link allows you to share the data at this path with internal collaborators or use tools to view the data.'} + {!externalDataUrlQuery.data && + !proxiedPathByFspAndPathQuery.data ? ( + + + Learn more about data links + + + + ) : null} {externalDataUrlQuery.data ? ( - + <> + + + {closeDialog => ( + + )} + + ) : proxiedPathByFspAndPathQuery.data?.url ? ( - + <> + + + {closeDialog => ( + + )} + + ) : null} ) : null} diff --git a/frontend/src/components/ui/Table/linksColumns.tsx b/frontend/src/components/ui/Table/linksColumns.tsx index 222848eb..238cd1ec 100644 --- a/frontend/src/components/ui/Table/linksColumns.tsx +++ b/frontend/src/components/ui/Table/linksColumns.tsx @@ -4,6 +4,9 @@ import { Typography } from '@material-tailwind/react'; import type { ColumnDef } from '@tanstack/react-table'; import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; +import DataLinkUsageDialog, { + inferDataType +} from '@/components/ui/Dialogs/DataLinkUsageDialog'; import DataLinksActionsMenu from '@/components/ui/Menus/DataLinksActions'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; @@ -37,6 +40,7 @@ type ProxiedPathRowActionProps = { handleCopyPath: (path: string) => Promise; handleCopyUrl: (item: ProxiedPath) => Promise; handleUnshare: () => void; + handleViewDataLinkUsage: () => void; item: ProxiedPath; displayPath: string; pathFsp: FileSharePath | undefined; @@ -94,6 +98,8 @@ function PathCell({ function ActionsCell({ item }: { readonly item: ProxiedPath }) { const [showDataLinkDialog, setShowDataLinkDialog] = useState(false); + const [showDataLinkUsageDialog, setShowDataLinkUsageDialog] = + useState(false); const { handleDeleteDataLink } = useDataToolLinks(setShowDataLinkDialog); const { pathPreference } = usePreferencesContext(); const { zonesAndFspQuery } = useZoneAndFspMapContext(); @@ -114,6 +120,10 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { item.path ); + const handleViewDataLinkUsage = () => { + setShowDataLinkUsageDialog(true); + }; + const menuItems: MenuItem[] = [ { name: 'Copy path', @@ -127,6 +137,11 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { await props.handleCopyUrl(props.item); } }, + { + name: 'Example code snippets', + action: (props: ProxiedPathRowActionProps) => + props.handleViewDataLinkUsage() + }, { name: 'Unshare', action: (props: ProxiedPathRowActionProps) => props.handleUnshare(), @@ -138,6 +153,7 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { handleCopyPath, handleCopyUrl, handleUnshare, + handleViewDataLinkUsage, item, displayPath, pathFsp @@ -169,6 +185,15 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { showDataLinkDialog={showDataLinkDialog} /> ) : null} + {/* Code snippets dialog */} + {showDataLinkUsageDialog ? ( + setShowDataLinkUsageDialog(false)} + open={showDataLinkUsageDialog} + /> + ) : null} ); } @@ -241,12 +266,15 @@ export function useLinksColumns(): ColumnDef[] { enableSorting: true, meta: { // Allow searching by URL and all path formats (linux, mac, windows) - getSearchableValues: (value: PathCellValue, row: ProxiedPath) => [ - row.url, - value.pathMap.mac_path, - value.pathMap.linux_path, - value.pathMap.windows_path - ] + getSearchableValues: (value: unknown, row: ProxiedPath) => { + const pathValue = value as PathCellValue; + return [ + row.url, + pathValue.pathMap.mac_path, + pathValue.pathMap.linux_path, + pathValue.pathMap.windows_path + ]; + } } }, { diff --git a/frontend/src/components/ui/buttons/DialogIconBtn.tsx b/frontend/src/components/ui/buttons/DialogIconBtn.tsx new file mode 100644 index 00000000..2f81af6a --- /dev/null +++ b/frontend/src/components/ui/buttons/DialogIconBtn.tsx @@ -0,0 +1,46 @@ +import { useState, type ReactNode, type MouseEvent } from 'react'; +import type { IconType } from 'react-icons'; + +import FgTooltip from '@/components/ui/widgets/FgTooltip'; +import FgDialog from '@/components/ui/Dialogs/FgDialog'; + +type DialogIconBtnProps = { + readonly icon: IconType; + readonly label: string; + readonly triggerClasses: string; + readonly disabled?: boolean; + readonly children: ReactNode | ((closeDialog: () => void) => ReactNode); +}; + +export default function DialogIconBtn({ + icon, + label, + triggerClasses, + disabled = false, + children +}: DialogIconBtnProps) { + const [showDialog, setShowDialog] = useState(false); + + const closeDialog = () => setShowDialog(false); + + return ( + <> + ) => { + setShowDialog(true); + e.currentTarget.blur(); + }} + triggerClasses={triggerClasses} + /> + {showDialog ? ( + + {typeof children === 'function' ? children(closeDialog) : children} + + ) : null} + + ); +} diff --git a/frontend/src/components/ui/buttons/DialogTextBtn.tsx b/frontend/src/components/ui/buttons/DialogTextBtn.tsx new file mode 100644 index 00000000..29071a35 --- /dev/null +++ b/frontend/src/components/ui/buttons/DialogTextBtn.tsx @@ -0,0 +1,45 @@ +import { useState, type ReactNode, type MouseEvent } from 'react'; +import { Button } from '@material-tailwind/react'; + +import FgDialog from '@/components/ui/Dialogs/FgDialog'; + +type TextDialogBtnProps = { + readonly label: string; + readonly variant?: 'solid' | 'outline' | 'ghost' | 'gradient' | undefined; + readonly className?: string; + readonly disabled?: boolean; + readonly children: ReactNode | ((closeDialog: () => void) => ReactNode); +}; + +export default function TextDialogBtn({ + label, + variant = 'outline', + className = '!rounded-md w-fit', + disabled = false, + children +}: TextDialogBtnProps) { + const [showDialog, setShowDialog] = useState(false); + + const closeDialog = () => setShowDialog(false); + + return ( + <> + + {showDialog ? ( + + {typeof children === 'function' ? children(closeDialog) : children} + + ) : null} + + ); +} diff --git a/frontend/src/components/ui/widgets/FgTooltip.tsx b/frontend/src/components/ui/widgets/FgTooltip.tsx index 49be7b7b..5432495b 100644 --- a/frontend/src/components/ui/widgets/FgTooltip.tsx +++ b/frontend/src/components/ui/widgets/FgTooltip.tsx @@ -46,7 +46,7 @@ export default function FgTooltip({ > {Icon ? : null} {children} - + {interactiveLabel ? interactiveLabel : label} diff --git a/frontend/src/hooks/useDarkMode.ts b/frontend/src/hooks/useDarkMode.ts new file mode 100644 index 00000000..83187a9e --- /dev/null +++ b/frontend/src/hooks/useDarkMode.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +/** + * Hook to detect dark mode from the document element's class list. + * Observes changes to the document element's class attribute. + * @returns boolean indicating if dark mode is active + */ +export default function useDarkMode(): boolean { + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + const checkDarkMode = () => { + setIsDarkMode(document.documentElement.classList.contains('dark')); + }; + + checkDarkMode(); + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }); + + return () => observer.disconnect(); + }, []); + + return isDarkMode; +}