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 (