diff --git a/Dockerfile b/Dockerfile index 7fa8540..7c5aec8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ # Production-ready Dockerfile for Next.js application -FROM node:18-alpine AS base +FROM node:20-alpine AS base # Install dependencies only when needed FROM base AS deps +RUN apk add --no-cache libc6-compat WORKDIR /app # Copy package files @@ -12,6 +13,7 @@ RUN npm install --production=false --legacy-peer-deps # Build the source code FROM base AS builder +RUN apk add --no-cache libc6-compat WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . diff --git a/src/app/components/InspectorSidebar.tsx b/src/app/components/InspectorSidebar.tsx index a7e0b35..6196f56 100644 --- a/src/app/components/InspectorSidebar.tsx +++ b/src/app/components/InspectorSidebar.tsx @@ -65,6 +65,10 @@ const InspectorSidebar = ({ if (items.length > 0) { setItem(items[0]) setIsOpen(true) + } else { + // Item was deleted — clear inspector + setItem(null) + setFocusedItem(undefined) } } else { setItem(null) @@ -172,6 +176,11 @@ const InspectorSidebar = ({ flattenedEntries.push([key, value]), ) + // Inject folder name for molecules + if ((item as ExtendedFolder).dtype === 'molecule') { + flattenedEntries.push(['name', item.name]) + } + // Add extended_metadata fields if they exist if ( metadata.extended_metadata && diff --git a/src/app/components/Workspace.tsx b/src/app/components/Workspace.tsx index 4798274..9978f52 100644 --- a/src/app/components/Workspace.tsx +++ b/src/app/components/Workspace.tsx @@ -25,6 +25,8 @@ import { ExportFilesText } from './workspace/ExportFilesText' import { Toolbar } from './workspace/Toolbar' import { UploadFilesText } from './workspace/UploadFilesText' import { UploadedFiles } from './workspace/UploadedFiles' +import RenamePopup from './tree-view/RenamePopup' +import DeletePopup from './tree-view/DeletePopup' type Database = { assignedFiles: ExtendedFile[] @@ -106,6 +108,18 @@ const Workspace = ({ ExtendedFile | ExtendedFolder >() + const [renamingItem, setRenamingItem] = useState<{ + item: ExtendedFile | ExtendedFolder + x: number + y: number + } | null>(null) + + const [deletingItem, setDeletingItem] = useState<{ + item: ExtendedFile | ExtendedFolder + x: number + y: number + } | null>(null) + if (!db) return (
@@ -223,6 +237,24 @@ const Workspace = ({ const assignmentTreeContextMenuClose = () => setAssignmentTreeContextMenu(initialContextMenu) + const fetchItem = async (fullPath: string) => { + const file = await filesDB.files.get({ fullPath }) + const folder = await filesDB.folders.get({ fullPath }) + return file || folder + } + + const handleRenameClick = async (fullPath: string, x: number, y: number) => { + const item = await fetchItem(fullPath) + if (!item) return + setRenamingItem({ item, x, y }) + } + + const handleDeleteClick = async (fullPath: string, x: number, y: number) => { + const item = await fetchItem(fullPath) + if (!item) return + setDeletingItem({ item, x, y }) + } + return ( )} - renderItem={createRenderItem(tree)} + renderItem={createRenderItem(tree, setExpandedItems)} rootItem={inputTreeRoot} treeId="inputTree" treeLabel="Input Tree" @@ -295,7 +327,10 @@ const Workspace = ({ {children}
)} - renderItem={createRenderItem(tree)} + renderItem={createRenderItem(tree, setExpandedItems, { + onRenameClick: handleRenameClick, + onDeleteClick: handleDeleteClick, + })} rootItem={assignmentTreeRoot} treeId="assignmentTree" treeLabel="Assignment Tree" @@ -321,6 +356,24 @@ const Workspace = ({ y={assignmentTreeContextMenu.y} /> )} + {renamingItem && ( + setRenamingItem(null)} + /> + )} + {deletingItem && ( + setDeletingItem(null)} + /> + )} {children} diff --git a/src/app/components/context-menu/AssignmentTreeContextMenu.tsx b/src/app/components/context-menu/AssignmentTreeContextMenu.tsx index ad54182..e6df5e7 100644 --- a/src/app/components/context-menu/AssignmentTreeContextMenu.tsx +++ b/src/app/components/context-menu/AssignmentTreeContextMenu.tsx @@ -39,6 +39,7 @@ const ClickOnAnalysesContextMenu = ({ targetItem={targetItem} tree={tree} hideDeleteOption={true} + hideRenameOption={true} /> ) diff --git a/src/app/components/context-menu/DefaultContextMenu.tsx b/src/app/components/context-menu/DefaultContextMenu.tsx index 1d22411..292282d 100644 --- a/src/app/components/context-menu/DefaultContextMenu.tsx +++ b/src/app/components/context-menu/DefaultContextMenu.tsx @@ -10,18 +10,22 @@ const DefaultContextMenu = ({ targetItem, tree, hideDeleteOption = false, + hideRenameOption = false, }: { closeContextMenu: () => void targetItem: ExtendedFile | ExtendedFolder tree: Record hideDeleteOption?: boolean + hideRenameOption?: boolean }) => ( <> - + {!hideRenameOption && ( + + )} {!hideDeleteOption && ( = ({ tree, }) => { const popupRef = useRef(null) - const [showConfirmation, setShowConfirmation] = useState(false) useOnClickOutside(popupRef, () => setShowConfirmation(false)) - const handleDelete = (e: React.FormEvent) => { - e.preventDefault() - - if (!item.isFolder) deleteFile(item as ExtendedFile) - else deleteFolder(item as ExtendedFolder, tree) - - close() - } - - const handleCancel = (e: { stopPropagation: () => void }) => { - e.stopPropagation() - setShowConfirmation(false) - } - return (
  • = ({ className="absolute left-full top-[-5px] z-10 ml-2 origin-left-center animate-emerge-from-lamp rounded-lg border border-gray-300 bg-white p-1 shadow-lg" ref={popupRef} > -
    - - Delete{' '} - - {item.name} - - ? - -
    - - -
    -
    + setShowConfirmation(false)} + /> )}
  • diff --git a/src/app/components/context-menu/context-menu-items/RenameContextMenuItem.tsx b/src/app/components/context-menu/context-menu-items/RenameContextMenuItem.tsx index 142a7b7..294d58a 100644 --- a/src/app/components/context-menu/context-menu-items/RenameContextMenuItem.tsx +++ b/src/app/components/context-menu/context-menu-items/RenameContextMenuItem.tsx @@ -3,9 +3,7 @@ import { FileNode } from '@/helper/types' import { useOnClickOutside } from '@/hooks/useOnClickOutside' import { FC, useRef, useState } from 'react' import { FaEdit } from 'react-icons/fa' - -import renameFile from '../renameFile' -import renameFolder from '../renameFolder' +import RenameForm from '../../shared/RenameForm' interface RenameProps { className?: string @@ -21,45 +19,10 @@ const RenameContextMenuItem: FC = ({ tree, }) => { const popupRef = useRef(null) - - const [newName, setNewName] = useState(item.name) const [showInput, setShowInput] = useState(false) - const [newFullPathAvailable, setNewFullPathAvailable] = useState(false) useOnClickOutside(popupRef, () => showInput && setShowInput(false)) - const validateNewName = (userInput: string) => { - setNewName(userInput) - const parentPath = - item.fullPath.split('/').slice(0, -1).join('/') || 'inputTreeRoot' - - const validInput = userInput.trim().length > 0 && !userInput.includes('/') - - const newPath = - parentPath === 'inputTreeRoot' ? userInput : parentPath + '/' + userInput - - const nameAvailable = !tree[parentPath].children.includes(newPath) - - setNewFullPathAvailable(validInput && nameAvailable) - } - - const handleCancel = (e: { stopPropagation: () => void }) => { - e.stopPropagation() - setShowInput(false) - setNewName(item.name) - } - - const handleRename = (e: React.FormEvent) => { - e.preventDefault() - - tree[item.fullPath].data = newName - - if (!item.isFolder) renameFile(item as ExtendedFile, newName) - else renameFolder(item as ExtendedFolder, tree, newName) - - close() - } - return (
  • = ({ className="absolute left-full top-[-5px] z-10 ml-2 origin-left-center animate-emerge-from-lamp rounded-lg border border-gray-300 bg-white p-1 shadow-lg" ref={popupRef} > -
    - validateNewName(e.target.value)} - placeholder="Enter new folder name" - value={newName} - /> -
    - - -
    -
    + setShowInput(false)} + /> )}
  • diff --git a/src/app/components/hooks/useMetadataHandlers.ts b/src/app/components/hooks/useMetadataHandlers.ts index be4773c..671f28e 100644 --- a/src/app/components/hooks/useMetadataHandlers.ts +++ b/src/app/components/hooks/useMetadataHandlers.ts @@ -95,7 +95,10 @@ export const useMetadataHandlers = ({ // Only update tree/state for the currently selected item if (fullPath === item.fullPath) { - if (key === 'name') { + if ( + key === 'name' && + (item as ExtendedFolder).dtype !== 'molecule' + ) { renameFolder(item as ExtendedFolder, tree, newValue as string) } diff --git a/src/app/components/input-components/OntologyTreeSelect.tsx b/src/app/components/input-components/OntologyTreeSelect.tsx index 12c6890..01c17b0 100644 --- a/src/app/components/input-components/OntologyTreeSelect.tsx +++ b/src/app/components/input-components/OntologyTreeSelect.tsx @@ -8,11 +8,7 @@ interface OntologyItem { is_enabled?: boolean selectable?: boolean id?: number - children: OntologyItem[] -} - -interface OntologyData { - ols_terms: OntologyItem[] + children?: OntologyItem[] } interface TreeSelectFieldProps { @@ -24,67 +20,109 @@ interface TreeSelectFieldProps { name?: string } -// Cache for ontology data -const ontologyCache = new Map() +const RECENTLY_SELECTED_MAX = 10 +const RECENT_PREFIX = '__recent__' +const ONTOLOGY_LABELS: Record = { + Kind: 'Type (Chemicals Method Ontology - CHMO)', + Rxno: 'Type (Name Reaction Ontology - RXNO)', +} + +// Raw JSON cache — fetched once per type, never invalidated +const rawDataCache = new Map() + +const getRecentlySelected = (ontologyType: string): OntologyItem[] => { + try { + const stored = localStorage.getItem(`ontology_recent_${ontologyType}`) + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } +} + +const addRecentlySelected = (ontologyType: string, item: OntologyItem) => { + const current = getRecentlySelected(ontologyType) + if (current.find((i) => i.value === item.value)) return + const updated = [item, ...current].slice(0, RECENTLY_SELECTED_MAX) + try { + localStorage.setItem( + `ontology_recent_${ontologyType}`, + JSON.stringify(updated), + ) + } catch {} +} -// Transform ontology data (simplified) -const transformOntologyData = (data: OntologyItem[]): any[] => { +const transformOntologyData = ( + data: OntologyItem[], + ontologyType: string, +): any[] => { const usedKeys = new Set() - const transform = (items: OntologyItem[]): any[] => { - return items - .filter((item) => item.selectable !== false) + const transform = (items: OntologyItem[]): any[] => + items .map((item) => { - // Create unique key for duplicates + if (item.selectable === false) { + if (item.title !== '-- Recently selected --') return null + const recent = getRecentlySelected(ontologyType) + if (recent.length === 0) return null + return { + title: item.title, + value: item.value, + key: '__recently_selected__', + children: recent.map((r) => ({ + title: r.title, + value: `${RECENT_PREFIX}${r.value}`, + key: `${RECENT_PREFIX}${r.value}`, + })), + } + } + let uniqueKey = item.value let counter = 1 - while (usedKeys.has(uniqueKey)) { - uniqueKey = `${item.value}__${counter}` - counter++ - } + while (usedKeys.has(uniqueKey)) + uniqueKey = `${item.value}__${counter++}` usedKeys.add(uniqueKey) return { title: item.title, value: item.value, key: uniqueKey, - children: - item.children?.length > 0 ? transform(item.children) : undefined, + children: item.children?.length + ? transform(item.children) + : undefined, disabled: item.is_enabled === false, } }) - } + .filter(Boolean) return transform(data) } -// Load ontology data const loadOntologyData = async ( ontologyType: 'reaction' | 'analysis', ): Promise => { - if (ontologyCache.has(ontologyType)) { - return ontologyCache.get(ontologyType)! + if (!rawDataCache.has(ontologyType)) { + const fileName = ontologyType === 'reaction' ? 'rxno.json' : 'chmo.json' + const response = await fetch(`/data/ontologies/${fileName}`) + if (!response.ok) throw new Error(`Failed to load ${fileName}`) + const { ols_terms } = await response.json() + rawDataCache.set(ontologyType, ols_terms) } + return transformOntologyData(rawDataCache.get(ontologyType)!, ontologyType) +} - const fileName = ontologyType === 'reaction' ? 'rxno.json' : 'chmo.json' - const response = await fetch(`/data/ontologies/${fileName}`) - - if (!response.ok) { - throw new Error(`Failed to load ${fileName}`) +const findInTree = (nodes: any[], value: string): any => { + for (const node of nodes) { + if (node.value === value) return node + if (node.children) { + const found = findInTree(node.children, value) + if (found) return found + } } - - const data: OntologyData = await response.json() - const transformedData = transformOntologyData(data.ols_terms) - ontologyCache.set(ontologyType, transformedData) - return transformedData + return null } -// Simple filter function -const filterTreeNode = (input: string, child: any): boolean => { - const searchText = - child.title?.toLowerCase() || child.value?.toLowerCase() || '' - return searchText.includes(input?.toLowerCase() || '') -} +const filterTreeNode = (input: string, child: any): boolean => + (child.title?.toLowerCase() ?? '').includes(input?.toLowerCase() ?? '') const OntologyTreeSelect: React.FC = ({ value, @@ -100,38 +138,44 @@ const OntologyTreeSelect: React.FC = ({ useEffect(() => { let mounted = true - - const loadData = async () => { - setLoading(true) - setError(null) - - try { - const data = await loadOntologyData(ontologyType) - if (mounted) { - setTreeData(data) - } - } catch (err) { - if (mounted) { + setLoading(true) + setError(null) + loadOntologyData(ontologyType) + .then((data) => { + if (mounted) setTreeData(data) + }) + .catch((err) => { + if (mounted) setError( err instanceof Error ? err.message : 'Failed to load ontology data', ) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - loadData() + }) + .finally(() => { + if (mounted) setLoading(false) + }) return () => { mounted = false } }, [ontologyType]) const handleChange = (selectedValue: string) => { - if (onChange && !readonly) { - onChange(selectedValue || '') + if (readonly) return + const cleanValue = selectedValue?.startsWith(RECENT_PREFIX) + ? selectedValue.slice(RECENT_PREFIX.length) + : selectedValue + if (!cleanValue || cleanValue === 'recently selected') return + + onChange?.(cleanValue) + + const fromRecent = getRecentlySelected(ontologyType).find( + (r) => r.value === cleanValue, + ) + const title = fromRecent?.title ?? findInTree(treeData, cleanValue)?.title + if (title) { + addRecentlySelected(ontologyType, { title, value: cleanValue }) + setTreeData( + transformOntologyData(rawDataCache.get(ontologyType)!, ontologyType), + ) } } @@ -152,17 +196,11 @@ const OntologyTreeSelect: React.FC = ({ ) } - const onthologyLabel = name - ? formatLabel(name) === 'Kind' - ? 'Type (Chemicals Method Ontology - CHMO)' - : formatLabel(name) === 'Rxno' - ? 'Type (Name Reaction Ontology - RXNO)' - : undefined - : undefined + const ontologyLabel = name ? ONTOLOGY_LABELS[formatLabel(name)] : undefined return (