Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 83 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChemicalIdentifiers>({
Expand All @@ -21,11 +27,15 @@ function App() {
});

const [isPanelOpen, setIsPanelOpen] = useState(true);
const [activeTab, setActiveTab] = useState<PanelTab>('search');
const [sourceField, setSourceField] = useState<keyof ChemicalIdentifiers | null>(null);
const [isLookingUp, setIsLookingUp] = useState(false);
const [lookupError, setLookupError] = useState<string | null>(null);
const [lookupSource, setLookupSource] = useState<string | null>(null);

const { history, addEntry: addHistoryEntry, removeEntry: removeHistoryEntry, clearHistory } = useMoleculeHistory();
const { favourites, isFavourite, toggleFavourite, clearFavourites } = useFavourites();

const clearIdentifiers = useCallback(() => {
setIdentifiers({
iupacName: '',
Expand Down Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -186,20 +206,72 @@ function App() {
</button>

{/* Dropdown Panel */}
<div className={`absolute top-16 right-4 z-20 w-80 max-h-96 transform transition-all duration-300 ease-in-out origin-top-right ${
<div className={`absolute top-16 right-4 z-20 w-80 transform transition-all duration-300 ease-in-out origin-top-right ${
isPanelOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0 pointer-events-none'
}`}>
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-2xl border border-white/20 overflow-hidden">
{/* Tab bar */}
<div className="flex border-b border-gray-200">
{(['search', 'history', 'favourites'] as PanelTab[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-2 text-xs font-medium capitalize transition-colors ${
activeTab === tab
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{tab}
{tab === 'history' && history.length > 0 && (
<span className="ml-1 text-gray-400">({history.length})</span>
)}
{tab === 'favourites' && favourites.length > 0 && (
<span className="ml-1 text-yellow-500">({favourites.length})</span>
)}
</button>
))}
</div>

<div className="max-h-96 overflow-y-auto">
<InputForm
identifiers={identifiers}
onInputChange={handleInputChange}
sourceField={sourceField}
isLookingUp={isLookingUp}
onClearAll={handleClearAll}
lookupError={lookupError}
lookupSource={lookupSource}
/>
{activeTab === 'search' && (
<InputForm
identifiers={identifiers}
onInputChange={handleInputChange}
sourceField={sourceField}
isLookingUp={isLookingUp}
onClearAll={handleClearAll}
lookupError={lookupError}
lookupSource={lookupSource}
isFavourite={isFavourite(identifiers)}
onToggleFavourite={!isLookingUp && getMoleculeKey(identifiers) ? () => toggleFavourite(identifiers) : undefined}
/>
)}
{activeTab === 'history' && (
<HistoryFavouritesPanel
entries={history}
emptyMessage="No history yet. Search for a molecule to get started."
onRestore={restoreEntry}
onRemove={removeHistoryEntry}
onClearAll={clearHistory}
isFavourite={isFavourite}
onToggleFavourite={toggleFavourite}
/>
)}
{activeTab === 'favourites' && (
<HistoryFavouritesPanel
entries={favourites}
emptyMessage="No favourites yet. Star a molecule to save it here."
onRestore={restoreEntry}
onRemove={(id) => {
const entry = favourites.find(e => e.id === id);
if (entry) toggleFavourite(entry.identifiers);
}}
onClearAll={clearFavourites}
isFavourite={isFavourite}
onToggleFavourite={toggleFavourite}
/>
)}
</div>
</div>
</div>
Expand Down
104 changes: 104 additions & 0 deletions src/components/HistoryFavouritesPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { MoleculeEntry } from '../types/molecule';

Check warning on line 1 in src/components/HistoryFavouritesPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'../types/molecule' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=djleamen_modelcules&issues=AZ8Z8OwxxcV1XoiWS3tc&open=AZ8Z8OwxxcV1XoiWS3tc&pullRequest=38
import { ChemicalIdentifiers } from '../types/molecule';

Check warning on line 2 in src/components/HistoryFavouritesPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'../types/molecule' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=djleamen_modelcules&issues=AZ8Z8OwxxcV1XoiWS3td&open=AZ8Z8OwxxcV1XoiWS3td&pullRequest=38

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 (
<div className="p-4 text-center text-sm text-gray-500">
{emptyMessage}
</div>
);
}

return (
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500">{entries.length} molecule{entries.length !== 1 ? 's' : ''}</span>

Check warning on line 46 in src/components/HistoryFavouritesPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=djleamen_modelcules&issues=AZ8Z8OwxxcV1XoiWS3te&open=AZ8Z8OwxxcV1XoiWS3te&pullRequest=38
<button
onClick={onClearAll}
className="text-xs text-red-500 hover:text-red-700 transition-colors"
>
Clear all
</button>
</div>
<ul className="space-y-1.5">
{entries.map(entry => (
<li
key={entry.id}
className="flex items-center gap-2 p-2 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors group"
>
{/* Restore button */}
<button
onClick={() => onRestore(entry.identifiers)}
className="flex-1 text-left min-w-0"
title="Load this molecule"
>
<p className="text-xs font-medium text-gray-800 truncate">{entry.displayName}</p>
<p className="text-xs text-gray-400">{formatTime(entry.timestamp)}</p>
</button>

{/* Favourite star */}
<button
onClick={() => onToggleFavourite(entry.identifiers)}
className={`flex-shrink-0 p-0.5 transition-colors ${
isFavourite(entry.identifiers)
? 'text-yellow-400 hover:text-yellow-500'
: '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'}
>
Comment thread
Copilot marked this conversation as resolved.
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>

{/* Remove button */}
<button
onClick={() => onRemove(entry.id)}
className="flex-shrink-0 p-0.5 text-gray-300 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove"
aria-label={`Remove ${entry.displayName}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
</div>
);
};

export default HistoryFavouritesPanel;
49 changes: 36 additions & 13 deletions src/components/InputForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ interface InputFormProps {
onClearAll?: () => void;
lookupError?: string | null;
lookupSource?: string | null;
isFavourite?: boolean;
onToggleFavourite?: () => void;
}

const inputFields = [
Expand All @@ -26,27 +28,48 @@ const InputForm = ({
isLookingUp = false,
onClearAll,
lookupError,
lookupSource
lookupSource,
isFavourite = false,
onToggleFavourite,
}: InputFormProps) => {
const hasAnyIdentifier = Object.values(identifiers).some(v => v.trim() !== '');
return (
<div className="p-4 h-full">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-1">Chemical Identifiers</h2>
<p className="text-xs text-gray-600">Enter any identifier to visualize in 3D</p>
</div>
{onClearAll && (
<button
onClick={onClearAll}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-800 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
title="Clear all fields"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Clear
</button>
)}
<div className="flex items-center gap-2">
{hasAnyIdentifier && onToggleFavourite && (
<button
onClick={onToggleFavourite}
className={`p-1.5 rounded-md transition-colors ${
isFavourite
? 'text-yellow-400 hover:text-yellow-500 bg-yellow-50'
: 'text-gray-300 hover:text-yellow-400 hover:bg-yellow-50'
}`}
title={isFavourite ? 'Remove from favourites' : 'Add to favourites'}
aria-label={isFavourite ? 'Remove from favourites' : 'Add to favourites'}
>
Comment thread
Copilot marked this conversation as resolved.
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
)}
{onClearAll && (
<button
onClick={onClearAll}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-800 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
title="Clear all fields"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Clear
</button>
)}
</div>
</div>

{isLookingUp && (
Expand Down
45 changes: 45 additions & 0 deletions src/hooks/useFavourites.ts
Original file line number Diff line number Diff line change
@@ -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<MoleculeEntry[]>(() => 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 };
}
Loading
Loading