Skip to content

Commit d089565

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: quick save button, recent structures in landing overlay, localStorage tracking
1 parent c846022 commit d089565

4 files changed

Lines changed: 151 additions & 8 deletions

File tree

src/ViewerApp.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { StudioLayout } from './components/StudioLayout';
6666
import { useAuth } from './lib/AuthContext';
6767
import { Link } from 'react-router-dom';
6868
import { uploadStructure } from './lib/structuresService';
69+
import { addRecentStructure } from './lib/recentStructures';
6970

7071
const deepEqual = (a: any, b: any): boolean => {
7172
if (a === b) return true;
@@ -1603,6 +1604,53 @@ function App() {
16031604
}
16041605
};
16051606

1607+
// ── Quick Save to My Structures ────────────────────────────────
1608+
const [quickSaving, setQuickSaving] = useState(false);
1609+
const handleQuickSave = async () => {
1610+
if (!user?.id) { error("Sign in to save structures to your library."); return; }
1611+
const ctrl = controllers[0];
1612+
setQuickSaving(true);
1613+
try {
1614+
if (ctrl.file) {
1615+
await uploadStructure(ctrl.file, user.id);
1616+
success(`"${ctrl.file.name.replace(/\.[^/.]+$/, '')}" saved to My Structures ✓`);
1617+
} else if (ctrl.pdbId) {
1618+
const res = await fetch(`https://files.rcsb.org/download/${ctrl.pdbId.toUpperCase()}.pdb`);
1619+
if (!res.ok) throw new Error('Could not fetch from RCSB');
1620+
const blob = await res.blob();
1621+
const file = new File([blob], `${ctrl.pdbId.toUpperCase()}.pdb`, { type: 'chemical/x-pdb' });
1622+
await uploadStructure(file, user.id);
1623+
success(`"${ctrl.pdbId}" saved to My Structures ✓`);
1624+
} else {
1625+
error("No structure loaded to save.");
1626+
}
1627+
} catch (e: any) {
1628+
error(e?.message ?? "Quick save failed");
1629+
} finally {
1630+
setQuickSaving(false);
1631+
}
1632+
};
1633+
1634+
// ── Track recent structures in localStorage ────────────────────
1635+
useEffect(() => {
1636+
const ctrl = controllers[0];
1637+
if (ctrl.pdbId && ctrl.pdbId.length >= 4) {
1638+
addRecentStructure({
1639+
id: ctrl.pdbId.toUpperCase(),
1640+
name: proteinTitle || ctrl.pdbId.toUpperCase(),
1641+
pdbId: ctrl.pdbId.toUpperCase(),
1642+
fileType: ctrl.dataSource === 'pubchem' ? 'sdf' : 'pdb',
1643+
});
1644+
} else if (ctrl.file) {
1645+
addRecentStructure({
1646+
id: ctrl.file.name,
1647+
name: ctrl.file.name.replace(/\.[^/.]+$/, ''),
1648+
fileType: ctrl.file.name.split('.').pop()?.toLowerCase(),
1649+
});
1650+
}
1651+
// eslint-disable-next-line react-hooks/exhaustive-deps
1652+
}, [controllers[0].pdbId, controllers[0].file]);
1653+
16061654
const handleExportVideo = async () => {
16071655
const viewer = viewerRef.current || viewerRefs[0].current;
16081656
if (!viewer || !recorder.session) return;
@@ -2890,6 +2938,8 @@ function App() {
28902938
setIsRocking={setIsRocking}
28912939

28922940
onSaveSession={() => handleToolAction('save')}
2941+
onQuickSave={handleQuickSave}
2942+
quickSaving={quickSaving}
28932943
onLoadSession={handleLoadSession}
28942944
onDownloadPDB={handleDownloadPDB}
28952945
onDownloadSequence={handleDownloadSequence}

src/components/Controls.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,8 @@ interface ControlsProps {
345345
onSaveSession: () => void;
346346
onLoadSession: (file: File) => void;
347347
onToggleContactMap: () => void;
348+
onQuickSave?: () => void;
349+
quickSaving?: boolean;
348350
movies: Movie[];
349351
colorPalette: ColorPalette;
350352
setColorPalette: (palette: ColorPalette) => void;
@@ -502,7 +504,9 @@ export const Controls: React.FC<ControlsProps> = ({
502504
setVisualizerEngine,
503505
onResetCamera,
504506
isRocking,
505-
setIsRocking
507+
setIsRocking,
508+
onQuickSave,
509+
quickSaving = false,
506510
}) => {
507511
// Motif Search State
508512
const [searchPattern, setSearchPattern] = useState('');
@@ -1010,6 +1014,21 @@ export const Controls: React.FC<ControlsProps> = ({
10101014

10111015

10121016
</div>
1017+
1018+
{onQuickSave && (
1019+
<button
1020+
onClick={onQuickSave}
1021+
disabled={quickSaving}
1022+
className={`w-full flex items-center justify-center gap-2 border py-2 rounded-lg transition-all group ${cardBg} hover:opacity-80 disabled:opacity-50`}
1023+
title="Save current structure to My Structures library"
1024+
>
1025+
{quickSaving
1026+
? <svg className="animate-spin w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" /></svg>
1027+
: <svg className="w-3.5 h-3.5 group-hover:text-blue-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z" /><polyline points="17,21 17,13 7,13 7,21" /><polyline points="7,3 7,8 15,8" /></svg>
1028+
}
1029+
<span className="text-xs font-medium">{quickSaving ? 'Saving…' : 'Save to Library'}</span>
1030+
</button>
1031+
)}
10131032
</div>
10141033

10151034
{/* 2. STRUCTURE INFO (Always Visible if loaded) */}

src/components/LandingOverlay.tsx

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React, { useEffect, useState, useMemo } from 'react';
2-
import { ArrowRight, Upload, Play, BookOpen, Dna, Activity, Shuffle } from 'lucide-react';
2+
import { ArrowRight, Upload, Play, BookOpen, Dna, Activity, Shuffle, Clock, ChevronRight } from 'lucide-react';
33
import clsx from 'clsx';
44
import { FEATURED_MOLECULES } from '../data/featuredMolecules';
5+
import { getRecentStructures, type RecentStructure } from '../lib/recentStructures';
56

67
interface LandingOverlayProps {
78
isVisible: boolean;
@@ -14,6 +15,12 @@ interface LandingOverlayProps {
1415
export const LandingOverlay: React.FC<LandingOverlayProps> = ({ isVisible, onDismiss, onUpload, onStartTour, onLoadPdb }) => {
1516
const [shouldRender, setShouldRender] = useState(isVisible);
1617
const [isFadingOut, setIsFadingOut] = useState(false);
18+
const [recentStructures, setRecentStructures] = useState<RecentStructure[]>([]);
19+
20+
// Load recent structures from localStorage each time the overlay opens
21+
useEffect(() => {
22+
if (isVisible) setRecentStructures(getRecentStructures());
23+
}, [isVisible]);
1724

1825
// Day of year logic for consistent daily rotation
1926
const dailyIndex = useMemo(() => {
@@ -32,14 +39,12 @@ export const LandingOverlay: React.FC<LandingOverlayProps> = ({ isVisible, onDis
3239
const handleShuffle = (e: React.MouseEvent) => {
3340
e.stopPropagation();
3441
setIsShuffling(true);
35-
// Animate shuffle
3642
let count = 0;
3743
const interval = setInterval(() => {
3844
setSelectedIndex(prev => (prev + 1) % FEATURED_MOLECULES.length);
3945
count++;
4046
if (count > 5) {
4147
clearInterval(interval);
42-
// Pick a new random index that is different from current
4348
let newIndex = Math.floor(Math.random() * FEATURED_MOLECULES.length);
4449
while (newIndex === selectedIndex && FEATURED_MOLECULES.length > 1) {
4550
newIndex = Math.floor(Math.random() * FEATURED_MOLECULES.length);
@@ -57,20 +62,29 @@ export const LandingOverlay: React.FC<LandingOverlayProps> = ({ isVisible, onDis
5762
setIsFadingOut(false);
5863
} else {
5964
setIsFadingOut(true);
60-
const timer = setTimeout(() => setShouldRender(false), 500); // Match CSS transition
65+
const timer = setTimeout(() => setShouldRender(false), 500);
6166
return () => clearTimeout(timer);
6267
}
6368
}, [isVisible]);
6469

6570
if (!shouldRender) return null;
6671

72+
const timeAgo = (ts: number) => {
73+
const m = Math.floor((Date.now() - ts) / 60000);
74+
if (m < 1) return 'just now';
75+
if (m < 60) return `${m}m ago`;
76+
const h = Math.floor(m / 60);
77+
if (h < 24) return `${h}h ago`;
78+
return `${Math.floor(h / 24)}d ago`;
79+
};
80+
6781
return (
6882
<div className={clsx(
6983
"fixed inset-0 z-[100] flex flex-col md:flex-row items-center justify-center md:justify-between p-6 md:p-12 transition-opacity duration-500",
7084
isFadingOut ? "opacity-0 pointer-events-none" : "opacity-100",
7185
"bg-gradient-to-br from-black/90 via-black/80 to-transparent backdrop-blur-sm"
7286
)}>
73-
{/* Background Interaction Layer (Click to dismiss if clicking empty space) */}
87+
{/* Background Interaction Layer */}
7488
<div className="absolute inset-0 z-0" onClick={onDismiss} />
7589

7690
{/* Logo */}
@@ -111,7 +125,7 @@ export const LandingOverlay: React.FC<LandingOverlayProps> = ({ isVisible, onDis
111125
</button>
112126
</div>
113127

114-
<div className="pt-8 flex items-center justify-center md:justify-start gap-6 text-sm text-gray-500 animate-in fade-in slide-in-from-bottom-8 duration-700 delay-500">
128+
<div className="pt-4 flex items-center justify-center md:justify-start gap-6 text-sm text-gray-500 animate-in fade-in slide-in-from-bottom-8 duration-700 delay-500">
115129
<button onClick={onUpload} className="hover:text-white transition-colors flex items-center gap-2">
116130
<Upload size={16} /> Upload File
117131
</button>
@@ -120,11 +134,34 @@ export const LandingOverlay: React.FC<LandingOverlayProps> = ({ isVisible, onDis
120134
<Dna size={16} /> Load Example (2B3P)
121135
</button>
122136
</div>
137+
138+
{/* ── Recently Viewed ── */}
139+
{recentStructures.length > 0 && (
140+
<div className="pt-2 animate-in fade-in slide-in-from-bottom-8 duration-700 delay-700">
141+
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
142+
<Clock size={12} />
143+
<span className="font-semibold uppercase tracking-wider">Recently Viewed</span>
144+
</div>
145+
<div className="flex flex-wrap gap-2">
146+
{recentStructures.map(r => (
147+
<button
148+
key={r.id}
149+
onClick={() => { onLoadPdb(r.pdbId ?? r.id); onDismiss(); }}
150+
className="group flex items-center gap-2 px-3 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-full text-xs text-gray-300 hover:text-white transition-all"
151+
>
152+
<Dna size={11} className="text-blue-400 opacity-70 group-hover:opacity-100" />
153+
<span className="font-medium truncate max-w-[120px]">{r.name}</span>
154+
<span className="text-gray-600 shrink-0">{timeAgo(r.timestamp)}</span>
155+
<ChevronRight size={10} className="text-gray-600 group-hover:text-gray-400 group-hover:translate-x-0.5 transition-transform" />
156+
</button>
157+
))}
158+
</div>
159+
</div>
160+
)}
123161
</div>
124162

125163
{/* FEATURED CARD (Right) */}
126164
<div className="relative pointer-events-auto w-full max-w-md animate-in fade-in slide-in-from-right-8 duration-1000 delay-500 hidden md:block">
127-
{/* Glass Card - Enlarged */}
128165
<div className="bg-black/40 backdrop-blur-xl border border-white/10 p-8 rounded-3xl shadow-2xl relative overflow-hidden group hover:border-white/20 transition-colors transform hover:-translate-y-2 duration-500">
129166
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity scale-150">
130167
<Activity size={180} />

src/lib/recentStructures.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Manages a "recently viewed structures" list in localStorage.
3+
* Stores up to MAX_RECENT entries with name, pdbId/url, and timestamp.
4+
*/
5+
6+
const KEY = 'quercus_recent_structures';
7+
const MAX_RECENT = 5;
8+
9+
export interface RecentStructure {
10+
id: string; // unique key (pdbId or uuid)
11+
name: string; // display name
12+
pdbId?: string; // if loaded by PDB ID
13+
fileType?: string; // 'pdb', 'cif', etc.
14+
timestamp: number; // ms since epoch
15+
}
16+
17+
export function getRecentStructures(): RecentStructure[] {
18+
try {
19+
const raw = localStorage.getItem(KEY);
20+
if (!raw) return [];
21+
return JSON.parse(raw) as RecentStructure[];
22+
} catch {
23+
return [];
24+
}
25+
}
26+
27+
export function addRecentStructure(entry: Omit<RecentStructure, 'timestamp'>): void {
28+
try {
29+
const existing = getRecentStructures().filter(r => r.id !== entry.id);
30+
const updated = [{ ...entry, timestamp: Date.now() }, ...existing].slice(0, MAX_RECENT);
31+
localStorage.setItem(KEY, JSON.stringify(updated));
32+
} catch { /* ignore storage errors */ }
33+
}
34+
35+
export function clearRecentStructures(): void {
36+
localStorage.removeItem(KEY);
37+
}

0 commit comments

Comments
 (0)