From 41c91178ee27e192579cb48ccb0a55e0d30688cc Mon Sep 17 00:00:00 2001 From: DJ Leamen Date: Tue, 30 Jun 2026 15:09:56 -0400 Subject: [PATCH 1/2] feat: add history and favourites system - Add MoleculeEntry type to src/types/molecule.ts - Create src/utils/storage.ts with localStorage helpers and display name logic - Create src/hooks/useMoleculeHistory.ts (max 50 entries, deduplication, addEntry/removeEntry/clearHistory) - Create src/hooks/useFavourites.ts (toggleFavourite, isFavourite, clearFavourites) - Create src/components/HistoryFavouritesPanel.tsx with restore, star, and remove actions - Update App.tsx: tabbed panel (Search / History / Favourites), wire hooks, save history on successful lookup - Update InputForm.tsx: favourite star button in header when molecule is loaded Resolves #9 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.tsx | 93 +++++++++++++++++--- src/components/HistoryFavouritesPanel.tsx | 102 ++++++++++++++++++++++ src/components/InputForm.tsx | 48 +++++++--- src/hooks/useFavourites.ts | 43 +++++++++ src/hooks/useMoleculeHistory.ts | 44 ++++++++++ src/types/molecule.ts | 7 ++ src/utils/storage.ts | 53 +++++++++++ 7 files changed, 366 insertions(+), 24 deletions(-) create mode 100644 src/components/HistoryFavouritesPanel.tsx create mode 100644 src/hooks/useFavourites.ts create mode 100644 src/hooks/useMoleculeHistory.ts create mode 100644 src/utils/storage.ts diff --git a/src/App.tsx b/src/App.tsx index aebabaa..d990f7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,11 @@ 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 { 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 +26,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 +111,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 +126,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 +205,72 @@ function App() { {/* Dropdown Panel */} -
+ {/* Tab bar */} +
+ {(['search', 'history', 'favourites'] as PanelTab[]).map(tab => ( + + ))} +
+
- + {activeTab === 'search' && ( + toggleFavourite(identifiers)} + /> + )} + {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..ddfa65f --- /dev/null +++ b/src/components/HistoryFavouritesPanel.tsx @@ -0,0 +1,102 @@ +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..12ec7a2 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,35 @@ 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..001b582 --- /dev/null +++ b/src/hooks/useFavourites.ts @@ -0,0 +1,43 @@ +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); + setFavourites(prev => { + let next: MoleculeEntry[]; + if (key && prev.some(e => getMoleculeKey(e.identifiers) === key)) { + next = prev.filter(e => getMoleculeKey(e.identifiers) !== key); + } else { + const entry: MoleculeEntry = { + id: key || crypto.randomUUID(), + 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 || ''; +} From c4d5d4f02fb0e5856bc15ec535f736bc3f6e47bd Mon Sep 17 00:00:00 2001 From: DJ Leamen Date: Tue, 30 Jun 2026 15:24:02 -0400 Subject: [PATCH 2/2] fix: address Copilot review comments - useFavourites: guard toggleFavourite against empty key to prevent unstable favourites - App.tsx: only pass onToggleFavourite when a stable key exists and not loading - InputForm.tsx: add aria-label to favourite star button - HistoryFavouritesPanel.tsx: add aria-label to star and remove buttons; make remove visible on keyboard focus Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.tsx | 3 ++- src/components/HistoryFavouritesPanel.tsx | 4 +++- src/components/InputForm.tsx | 1 + src/hooks/useFavourites.ts | 6 ++++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d990f7b..7e1d128 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ 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'; @@ -243,7 +244,7 @@ function App() { lookupError={lookupError} lookupSource={lookupSource} isFavourite={isFavourite(identifiers)} - onToggleFavourite={() => toggleFavourite(identifiers)} + onToggleFavourite={!isLookingUp && getMoleculeKey(identifiers) ? () => toggleFavourite(identifiers) : undefined} /> )} {activeTab === 'history' && ( diff --git a/src/components/HistoryFavouritesPanel.tsx b/src/components/HistoryFavouritesPanel.tsx index ddfa65f..96caceb 100644 --- a/src/components/HistoryFavouritesPanel.tsx +++ b/src/components/HistoryFavouritesPanel.tsx @@ -76,6 +76,7 @@ const HistoryFavouritesPanel = ({ : 'text-gray-300 hover:text-yellow-400' }`} title={isFavourite(entry.identifiers) ? 'Remove from favourites' : 'Add to favourites'} + aria-label={isFavourite(entry.identifiers) ? 'Remove from favourites' : 'Add to favourites'} > @@ -85,8 +86,9 @@ const HistoryFavouritesPanel = ({ {/* Remove button */}