diff --git a/src/App.tsx b/src/App.tsx index aebabaa..7e1d128 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,12 @@ import MoleculeViewer from './components/MoleculeViewer'; import InputForm from './components/InputForm'; import { ChemicalIdentifiers } from './types/molecule'; import { lookupChemicalIdentifiers, getLocalIdentifiers, cleanExpiredCache, validateIdentifier, normalizeIdentifier } from './utils/identifierLookup'; +import { getMoleculeKey } from './utils/storage'; +import { useMoleculeHistory } from './hooks/useMoleculeHistory'; +import { useFavourites } from './hooks/useFavourites'; +import HistoryFavouritesPanel from './components/HistoryFavouritesPanel'; + +type PanelTab = 'search' | 'history' | 'favourites'; function App() { const [identifiers, setIdentifiers] = useState({ @@ -21,11 +27,15 @@ function App() { }); const [isPanelOpen, setIsPanelOpen] = useState(true); + const [activeTab, setActiveTab] = useState('search'); const [sourceField, setSourceField] = useState(null); const [isLookingUp, setIsLookingUp] = useState(false); const [lookupError, setLookupError] = useState(null); const [lookupSource, setLookupSource] = useState(null); + const { history, addEntry: addHistoryEntry, removeEntry: removeHistoryEntry, clearHistory } = useMoleculeHistory(); + const { favourites, isFavourite, toggleFavourite, clearFavourites } = useFavourites(); + const clearIdentifiers = useCallback(() => { setIdentifiers({ iupacName: '', @@ -102,11 +112,13 @@ function App() { console.warn('Lookup failed:', result.error); } else if (result.identifiers) { setLookupSource(result.source || null); + const merged = { ...identifiers, ...result.identifiers, [field]: value }; setIdentifiers(prev => ({ ...prev, ...result.identifiers, [field]: value })); + addHistoryEntry(merged); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; @@ -115,7 +127,15 @@ function App() { } finally { setIsLookingUp(false); } - }, [sourceField, clearIdentifiers, performLookup]); + }, [sourceField, clearIdentifiers, performLookup, addHistoryEntry, identifiers]); + + const restoreEntry = useCallback((restoredIdentifiers: ChemicalIdentifiers) => { + setIdentifiers(restoredIdentifiers); + setSourceField(null); + setLookupError(null); + setLookupSource(null); + setActiveTab('search'); + }, []); const handleClearAll = useCallback(() => { setIdentifiers({ @@ -186,20 +206,72 @@ function App() { {/* Dropdown Panel */} -
+ {/* Tab bar */} +
+ {(['search', 'history', 'favourites'] as PanelTab[]).map(tab => ( + + ))} +
+
- + {activeTab === 'search' && ( + toggleFavourite(identifiers) : undefined} + /> + )} + {activeTab === 'history' && ( + + )} + {activeTab === 'favourites' && ( + { + const entry = favourites.find(e => e.id === id); + if (entry) toggleFavourite(entry.identifiers); + }} + onClearAll={clearFavourites} + isFavourite={isFavourite} + onToggleFavourite={toggleFavourite} + /> + )}
diff --git a/src/components/HistoryFavouritesPanel.tsx b/src/components/HistoryFavouritesPanel.tsx new file mode 100644 index 0000000..96caceb --- /dev/null +++ b/src/components/HistoryFavouritesPanel.tsx @@ -0,0 +1,104 @@ +import { MoleculeEntry } from '../types/molecule'; +import { ChemicalIdentifiers } from '../types/molecule'; + +interface HistoryFavouritesPanelProps { + entries: MoleculeEntry[]; + emptyMessage: string; + onRestore: (identifiers: ChemicalIdentifiers) => void; + onRemove: (id: string) => void; + onClearAll: () => void; + isFavourite: (identifiers: ChemicalIdentifiers) => boolean; + onToggleFavourite: (identifiers: ChemicalIdentifiers) => void; +} + +function formatTime(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + return date.toLocaleDateString(); +} + +const HistoryFavouritesPanel = ({ + entries, + emptyMessage, + onRestore, + onRemove, + onClearAll, + isFavourite, + onToggleFavourite, +}: HistoryFavouritesPanelProps) => { + if (entries.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+
+ {entries.length} molecule{entries.length !== 1 ? 's' : ''} + +
+
    + {entries.map(entry => ( +
  • + {/* Restore button */} + + + {/* Favourite star */} + + + {/* Remove button */} + +
  • + ))} +
+
+ ); +}; + +export default HistoryFavouritesPanel; diff --git a/src/components/InputForm.tsx b/src/components/InputForm.tsx index 77525ae..816ad0b 100644 --- a/src/components/InputForm.tsx +++ b/src/components/InputForm.tsx @@ -8,6 +8,8 @@ interface InputFormProps { onClearAll?: () => void; lookupError?: string | null; lookupSource?: string | null; + isFavourite?: boolean; + onToggleFavourite?: () => void; } const inputFields = [ @@ -26,8 +28,11 @@ const InputForm = ({ isLookingUp = false, onClearAll, lookupError, - lookupSource + lookupSource, + isFavourite = false, + onToggleFavourite, }: InputFormProps) => { + const hasAnyIdentifier = Object.values(identifiers).some(v => v.trim() !== ''); return (
@@ -35,18 +40,36 @@ const InputForm = ({

Chemical Identifiers

Enter any identifier to visualize in 3D

- {onClearAll && ( - - )} +
+ {hasAnyIdentifier && onToggleFavourite && ( + + )} + {onClearAll && ( + + )} +
{isLookingUp && ( diff --git a/src/hooks/useFavourites.ts b/src/hooks/useFavourites.ts new file mode 100644 index 0000000..8e0270d --- /dev/null +++ b/src/hooks/useFavourites.ts @@ -0,0 +1,45 @@ +import { useState, useCallback } from 'react'; +import { ChemicalIdentifiers, MoleculeEntry } from '../types/molecule'; +import { loadFavourites, saveFavourites, getDisplayName, getMoleculeKey } from '../utils/storage'; + +export function useFavourites() { + const [favourites, setFavourites] = useState(() => loadFavourites()); + + const isFavourite = useCallback( + (identifiers: ChemicalIdentifiers): boolean => { + const key = getMoleculeKey(identifiers); + if (!key) return false; + return favourites.some(e => getMoleculeKey(e.identifiers) === key); + }, + [favourites] + ); + + const toggleFavourite = useCallback((identifiers: ChemicalIdentifiers) => { + const key = getMoleculeKey(identifiers); + // Refuse to toggle when there's no stable key — we'd have no way to unstar it + if (!key) return; + setFavourites(prev => { + let next: MoleculeEntry[]; + if (prev.some(e => getMoleculeKey(e.identifiers) === key)) { + next = prev.filter(e => getMoleculeKey(e.identifiers) !== key); + } else { + const entry: MoleculeEntry = { + id: key, + displayName: getDisplayName(identifiers), + identifiers, + timestamp: Date.now(), + }; + next = [entry, ...prev]; + } + saveFavourites(next); + return next; + }); + }, []); + + const clearFavourites = useCallback(() => { + setFavourites([]); + saveFavourites([]); + }, []); + + return { favourites, isFavourite, toggleFavourite, clearFavourites }; +} diff --git a/src/hooks/useMoleculeHistory.ts b/src/hooks/useMoleculeHistory.ts new file mode 100644 index 0000000..40c172a --- /dev/null +++ b/src/hooks/useMoleculeHistory.ts @@ -0,0 +1,44 @@ +import { useState, useCallback } from 'react'; +import { ChemicalIdentifiers, MoleculeEntry } from '../types/molecule'; +import { loadHistory, saveHistory, getDisplayName, getMoleculeKey } from '../utils/storage'; + +const MAX_HISTORY = 50; + +export function useMoleculeHistory() { + const [history, setHistory] = useState(() => loadHistory()); + + const addEntry = useCallback((identifiers: ChemicalIdentifiers) => { + const key = getMoleculeKey(identifiers); + const entry: MoleculeEntry = { + id: key || crypto.randomUUID(), + displayName: getDisplayName(identifiers), + identifiers, + timestamp: Date.now(), + }; + + setHistory(prev => { + // Remove duplicate (same key) if it exists + const filtered = key + ? prev.filter(e => getMoleculeKey(e.identifiers) !== key) + : prev; + const next = [entry, ...filtered].slice(0, MAX_HISTORY); + saveHistory(next); + return next; + }); + }, []); + + const removeEntry = useCallback((id: string) => { + setHistory(prev => { + const next = prev.filter(e => e.id !== id); + saveHistory(next); + return next; + }); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + saveHistory([]); + }, []); + + return { history, addEntry, removeEntry, clearHistory }; +} diff --git a/src/types/molecule.ts b/src/types/molecule.ts index 5631f3e..012501a 100644 --- a/src/types/molecule.ts +++ b/src/types/molecule.ts @@ -18,6 +18,13 @@ export interface MoleculeData { value: string; } +export interface MoleculeEntry { + id: string; + displayName: string; + identifiers: ChemicalIdentifiers; + timestamp: number; +} + export interface Atom { element: string; x: number; diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..3f86d30 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,53 @@ +import { ChemicalIdentifiers, MoleculeEntry } from '../types/molecule'; + +const HISTORY_KEY = 'modelcules_history'; +const FAVOURITES_KEY = 'modelcules_favourites'; + +export function getDisplayName(identifiers: ChemicalIdentifiers): string { + return ( + identifiers.iupacName || + (identifiers.casNumber ? `CAS: ${identifiers.casNumber}` : '') || + (identifiers.pubchemCID ? `PubChem: ${identifiers.pubchemCID}` : '') || + (identifiers.smiles ? `SMILES: ${identifiers.smiles.substring(0, 20)}` : '') || + (identifiers.inchi ? `InChI: ${identifiers.inchi.substring(0, 20)}` : '') || + 'Unknown Molecule' + ); +} + +export function loadHistory(): MoleculeEntry[] { + try { + const raw = localStorage.getItem(HISTORY_KEY); + return raw ? (JSON.parse(raw) as MoleculeEntry[]) : []; + } catch { + return []; + } +} + +export function saveHistory(entries: MoleculeEntry[]): void { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(entries)); + } catch { + // localStorage unavailable or full — silently ignore + } +} + +export function loadFavourites(): MoleculeEntry[] { + try { + const raw = localStorage.getItem(FAVOURITES_KEY); + return raw ? (JSON.parse(raw) as MoleculeEntry[]) : []; + } catch { + return []; + } +} + +export function saveFavourites(entries: MoleculeEntry[]): void { + try { + localStorage.setItem(FAVOURITES_KEY, JSON.stringify(entries)); + } catch { + // localStorage unavailable or full — silently ignore + } +} + +export function getMoleculeKey(identifiers: ChemicalIdentifiers): string { + return identifiers.smiles || identifiers.inchi || identifiers.casNumber || identifiers.pubchemCID || ''; +}