diff --git a/frontend/src/components/ui/BrowsePage/FileViewer.tsx b/frontend/src/components/ui/BrowsePage/FileViewer.tsx index a4c16ebf..8c94a4e9 100644 --- a/frontend/src/components/ui/BrowsePage/FileViewer.tsx +++ b/frontend/src/components/ui/BrowsePage/FileViewer.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { Typography } from '@material-tailwind/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { @@ -10,6 +9,7 @@ import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { formatFileSize, formatUnixTimestamp } from '@/utils'; import type { FileOrFolder } from '@/shared.types'; import { useFileContentQuery } from '@/queries/fileContentQueries'; +import useDarkMode from '@/hooks/useDarkMode'; type FileViewerProps = { readonly file: FileOrFolder; @@ -76,27 +76,9 @@ const getLanguageFromExtension = (filename: string): string => { export default function FileViewer({ file }: FileViewerProps) { const { fspName } = useFileBrowserContext(); - - const [isDarkMode, setIsDarkMode] = useState(false); - + const isDarkMode = useDarkMode(); const contentQuery = useFileContentQuery(fspName, file.path); - // 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 (contentQuery.isLoading) { return ( @@ -121,11 +103,25 @@ export default function FileViewer({ file }: FileViewerProps) { const language = getLanguageFromExtension(file.name); const content = contentQuery.data ?? ''; + // Get the theme's code styles and merge with padding bottom for scrollbar + const theme = isDarkMode ? materialDark : coy; + const themeCodeStyles = theme['code[class*="language-"]'] || {}; + const mergedCodeTagProps = { + style: { + ...themeCodeStyles, + paddingBottom: '1em' + } + }; + return ( - ) => { - 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..deba746d 100644 --- a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx +++ b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx @@ -5,6 +5,8 @@ import type { UseQueryResult } from '@tanstack/react-query'; import zarrLogo from '@/assets/zarr.jpg'; import ZarrMetadataTable from '@/components/ui/BrowsePage/ZarrMetadataTable'; import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; +import DataLinkUsageDialog from '@/components/ui/Dialogs/DataLinkUsageDialog'; +import TextDialogBtn from '@/components/ui/buttons/DialogTextBtn'; import DataToolLinks from './DataToolLinks'; import type { OpenWithToolUrls, @@ -91,12 +93,25 @@ export default function ZarrPreview({ {openWithToolUrls ? ( - + <> + + {openWithToolUrls.copy ? ( + + {closeDialog => ( + + )} + + ) : null} + ) : null} {showDataLinkDialog ? ( diff --git a/frontend/src/components/ui/Dialogs/DataLinkUsageDialog.tsx b/frontend/src/components/ui/Dialogs/DataLinkUsageDialog.tsx new file mode 100644 index 00000000..5ef23393 --- /dev/null +++ b/frontend/src/components/ui/Dialogs/DataLinkUsageDialog.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react'; +import { Typography, Tabs } from '@material-tailwind/react'; +import { HiOutlineClipboardCopy } from 'react-icons/hi'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { + materialDark, + coy +} from 'react-syntax-highlighter/dist/esm/styles/prism'; + +import FgDialog from './FgDialog'; +import useDarkMode from '@/hooks/useDarkMode'; +import CopyTooltip from '@/components/ui/widgets/CopyTooltip'; + +type CodeBlockProps = { + readonly code: string; + readonly language?: string; + readonly showLineNumbers?: boolean; + readonly wrapLines?: boolean; + readonly wrapLongLines?: boolean; + readonly copyable?: boolean; + readonly copyLabel?: string; + readonly customStyle?: React.CSSProperties; +}; + +const TOOLTIP_TRIGGER_CLASSES = + 'text-foreground/50 hover:text-foreground py-1 px-2'; + +function CodeBlock({ + code, + language = 'text', + showLineNumbers = false, + wrapLines = true, + wrapLongLines = true, + copyable = false, + copyLabel = 'Copy code', + customStyle = { + margin: 0, + marginTop: 0, + marginRight: 0, + marginBottom: 0, + marginLeft: 0, + paddingTop: '3em', + paddingRight: '1em', + paddingBottom: '0', + paddingLeft: '1em', + fontSize: '14px', + lineHeight: '1.5' + } +}: CodeBlockProps) { + const isDarkMode = useDarkMode(); + + // Get the theme's code styles and merge with custom codeTagProps + const theme = isDarkMode ? materialDark : coy; + const themeCodeStyles = theme['code[class*="language-"]'] || {}; + const mergedCodeTagProps = { + style: { + ...themeCodeStyles, + paddingBottom: '1em' + } + }; + + return ( +
+ + {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 open: boolean; + readonly onClose: () => void; +}; + +export default function DataLinkUsageDialog({ + dataLinkUrl, + open, + onClose +}: DataLinkUsageDialogProps) { + const [activeTab, setActiveTab] = useState('napari'); + + const tabs = [ + { + id: 'napari', + label: 'Napari', + content: ( + + ) + }, + { + id: 'python', + label: 'Python', + content: ( + <> + + + + ) + }, + { + id: 'java', + label: 'Java', + content: ( + + ) + } + ]; + + const TAB_TRIGGER_CLASSES = '!text-foreground h-full'; + const PANEL_CLASSES = + 'flex-1 flex flex-col gap-4 max-w-full p-4 rounded-b-lg border border-t-0 border-surface bg-surface-light'; + + return ( + +
+
+ + How to use your data link + + + + +
+ + + {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 570d32db..c139ac9d 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -17,6 +17,8 @@ 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 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'; @@ -276,15 +278,45 @@ export default function PropertiesDrawer({ {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 c0ca1ab9..d9b7c7f9 100644 --- a/frontend/src/components/ui/Table/linksColumns.tsx +++ b/frontend/src/components/ui/Table/linksColumns.tsx @@ -4,6 +4,7 @@ import { Typography } from '@material-tailwind/react'; import type { ColumnDef } from '@tanstack/react-table'; import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; +import DataLinkUsageDialog from '@/components/ui/Dialogs/DataLinkUsageDialog'; import DataLinksActionsMenu from '@/components/ui/Menus/DataLinksActions'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; @@ -37,6 +38,7 @@ type ProxiedPathRowActionProps = { handleCopyPath: (path: string) => Promise; handleCopyUrl: (item: ProxiedPath) => Promise; handleUnshare: () => void; + handleViewDataLinkUsage: () => void; item: ProxiedPath; displayPath: string; pathFsp: FileSharePath | undefined; @@ -94,6 +96,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 +118,10 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { item.path ); + const handleViewDataLinkUsage = () => { + setShowDataLinkUsageDialog(true); + }; + const menuItems: MenuItem[] = [ { name: 'Copy path', @@ -127,6 +135,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 +151,7 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { handleCopyPath, handleCopyUrl, handleUnshare, + handleViewDataLinkUsage, item, displayPath, pathFsp @@ -169,6 +183,14 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { showDataLinkDialog={showDataLinkDialog} /> ) : null} + {/* Code snippets dialog */} + {showDataLinkUsageDialog ? ( + setShowDataLinkUsageDialog(false)} + open={showDataLinkUsageDialog} + /> + ) : null} ); } 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; +}