Skip to content

Commit 45688ec

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: dashboard home page with stats, storage bar, activity feed, starred/top structures
1 parent 4e51446 commit 45688ec

2 files changed

Lines changed: 364 additions & 2 deletions

File tree

src/App.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { StudioDrafts } from './components/dashboard/StudioDrafts';
77
import { AuthPage } from './components/auth/AuthPage';
88
import { AccountSettings } from './components/dashboard/AccountSettings';
99
import { ActivityTimeline } from './components/dashboard/ActivityTimeline';
10+
import { DashboardHome } from './components/dashboard/DashboardHome';
1011

1112
// Protected Route Wrapper
1213
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -43,8 +44,8 @@ function App() {
4344
</ProtectedRoute>
4445
}
4546
>
46-
{/* Redirect /dashboard to /dashboard/structures */}
47-
<Route index element={<Navigate to="structures" replace />} />
47+
{/* Dashboard home — overview with stats */}
48+
<Route index element={<DashboardHome />} />
4849

4950
<Route path="structures" element={<MyStructures />} />
5051
<Route path="drafts" element={<StudioDrafts />} />
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import { useState, useEffect } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import {
4+
Database, Eye, Upload, FolderOpen, Star,
5+
Activity, TrendingUp, Loader2, Plus,
6+
ArrowRight, Share2, Clock, Dna, Atom, Zap
7+
} from 'lucide-react';
8+
import { useAuth } from '../../lib/AuthContext';
9+
import { listStructures, getActivityLog, type Structure, type ActivityLog, type ActivityAction } from '../../lib/structuresService';
10+
11+
// ─── Helpers ──────────────────────────────────────────────────────
12+
13+
function formatBytes(b: number | null) {
14+
if (!b) return '0 B';
15+
if (b < 1024) return `${b} B`;
16+
if (b < 1048576) return `${(b / 1024).toFixed(1)} KB`;
17+
return `${(b / 1048576).toFixed(1)} MB`;
18+
}
19+
20+
function timeAgo(ts: string) {
21+
const m = Math.floor((Date.now() - new Date(ts).getTime()) / 60000);
22+
if (m < 1) return 'just now';
23+
if (m < 60) return `${m}m ago`;
24+
const h = Math.floor(m / 60);
25+
if (h < 24) return `${h}h ago`;
26+
return `${Math.floor(h / 24)}d ago`;
27+
}
28+
29+
const ACTION_CONFIG: Record<ActivityAction, { label: string; icon: React.ElementType; color: string }> = {
30+
upload: { label: 'Uploaded', icon: Upload, color: 'text-blue-400' },
31+
open: { label: 'Opened', icon: Eye, color: 'text-emerald-400' },
32+
share: { label: 'Shared', icon: Share2, color: 'text-violet-400' },
33+
delete: { label: 'Deleted', icon: Database, color: 'text-red-400' },
34+
import_rcsb: { label: 'Imported', icon: Dna, color: 'text-amber-400' },
35+
duplicate: { label: 'Duplicated', icon: FolderOpen, color: 'text-pink-400' },
36+
};
37+
38+
// ─── Stat Card ────────────────────────────────────────────────────
39+
40+
function StatCard({ label, value, sub, icon: Icon, gradient }: {
41+
label: string; value: string | number; sub?: string;
42+
icon: React.ElementType; gradient: string;
43+
}) {
44+
return (
45+
<div className={`relative overflow-hidden rounded-2xl border border-white/5 p-6 flex flex-col gap-4 ${gradient}`}>
46+
<div className="flex items-center justify-between">
47+
<span className="text-xs font-bold uppercase tracking-widest text-white/50">{label}</span>
48+
<div className="w-8 h-8 rounded-xl bg-white/10 flex items-center justify-center">
49+
<Icon className="w-4 h-4 text-white/80" />
50+
</div>
51+
</div>
52+
<div>
53+
<div className="text-4xl font-bold text-white tabular-nums tracking-tight">{value}</div>
54+
{sub && <div className="text-xs text-white/40 mt-1">{sub}</div>}
55+
</div>
56+
</div>
57+
);
58+
}
59+
60+
// ─── Storage Bar ──────────────────────────────────────────────────
61+
62+
const STORAGE_LIMIT_MB = 500;
63+
64+
function StorageBar({ totalBytes }: { totalBytes: number }) {
65+
const usedMB = totalBytes / 1048576;
66+
const pct = Math.min((usedMB / STORAGE_LIMIT_MB) * 100, 100);
67+
const color = pct > 85 ? 'bg-red-500' : pct > 65 ? 'bg-amber-500' : 'bg-blue-500';
68+
69+
return (
70+
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 space-y-4">
71+
<div className="flex items-center gap-2">
72+
<Database className="w-4 h-4 text-blue-400" />
73+
<h3 className="text-sm font-semibold text-white">Storage</h3>
74+
<span className="ml-auto text-xs text-neutral-500">{STORAGE_LIMIT_MB} MB limit</span>
75+
</div>
76+
<div className="space-y-2">
77+
<div className="flex justify-between text-xs">
78+
<span className="text-neutral-400">{formatBytes(totalBytes)} used</span>
79+
<span className="text-neutral-600">{pct.toFixed(1)}%</span>
80+
</div>
81+
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
82+
<div
83+
className={`h-full rounded-full transition-all duration-700 ${color}`}
84+
style={{ width: `${pct}%` }}
85+
/>
86+
</div>
87+
<div className="text-xs text-neutral-600">
88+
{(STORAGE_LIMIT_MB - usedMB).toFixed(1)} MB remaining
89+
</div>
90+
</div>
91+
</div>
92+
);
93+
}
94+
95+
// ─── Quick Actions ────────────────────────────────────────────────
96+
97+
function QuickActions() {
98+
const navigate = useNavigate();
99+
return (
100+
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 space-y-3">
101+
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
102+
<Zap className="w-4 h-4 text-yellow-400" /> Quick Actions
103+
</h3>
104+
<div className="space-y-2">
105+
{[
106+
{ label: 'Open 3D Viewer', icon: Atom, action: () => navigate('/'), accent: 'hover:border-blue-500/40 hover:bg-blue-500/5 hover:text-blue-400' },
107+
{ label: 'My Structures', icon: FolderOpen, action: () => navigate('/dashboard/structures'), accent: 'hover:border-emerald-500/40 hover:bg-emerald-500/5 hover:text-emerald-400' },
108+
{ label: 'Activity Log', icon: Activity, action: () => navigate('/dashboard/activity'), accent: 'hover:border-violet-500/40 hover:bg-violet-500/5 hover:text-violet-400' },
109+
].map(({ label, icon: Icon, action, accent }) => (
110+
<button key={label} onClick={action}
111+
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-neutral-800 text-neutral-400 transition-all text-sm font-medium group ${accent}`}>
112+
<Icon className="w-4 h-4 shrink-0 transition-colors" />
113+
{label}
114+
<ArrowRight className="w-3.5 h-3.5 ml-auto opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0 transition-all" />
115+
</button>
116+
))}
117+
</div>
118+
</div>
119+
);
120+
}
121+
122+
// ─── Top Structures ────────────────────────────────────────────────
123+
124+
function TopStructures({ structures }: { structures: Structure[] }) {
125+
const navigate = useNavigate();
126+
const top = [...structures]
127+
.filter(s => (s.view_count ?? 0) > 0)
128+
.sort((a, b) => (b.view_count ?? 0) - (a.view_count ?? 0))
129+
.slice(0, 5);
130+
131+
if (top.length === 0) return null;
132+
133+
return (
134+
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 space-y-4">
135+
<div className="flex items-center justify-between">
136+
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
137+
<TrendingUp className="w-4 h-4 text-emerald-400" /> Most Viewed
138+
</h3>
139+
<Link to="/dashboard/structures" className="text-xs text-neutral-500 hover:text-white transition-colors">
140+
All structures →
141+
</Link>
142+
</div>
143+
<div className="space-y-2">
144+
{top.map((s, i) => (
145+
<div key={s.id} className="flex items-center gap-3 group">
146+
<span className="text-lg font-bold text-neutral-800 tabular-nums w-5 text-right shrink-0">
147+
{i + 1}
148+
</span>
149+
<div className="flex-1 min-w-0">
150+
<div className="text-sm text-neutral-300 font-medium truncate group-hover:text-white transition-colors">
151+
{s.name}
152+
</div>
153+
<div className="text-xs text-neutral-600">{s.file_type.toUpperCase()}</div>
154+
</div>
155+
<div className="flex items-center gap-1 text-xs text-neutral-600 shrink-0">
156+
<Eye className="w-3 h-3" />
157+
<span className="tabular-nums">{s.view_count}</span>
158+
</div>
159+
</div>
160+
))}
161+
</div>
162+
</div>
163+
);
164+
}
165+
166+
// ─── Recent Activity Feed ─────────────────────────────────────────
167+
168+
function RecentFeed({ logs }: { logs: ActivityLog[] }) {
169+
const recent = logs.slice(0, 7);
170+
if (recent.length === 0) return null;
171+
172+
return (
173+
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 space-y-4">
174+
<div className="flex items-center justify-between">
175+
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
176+
<Clock className="w-4 h-4 text-neutral-400" /> Recent Activity
177+
</h3>
178+
<Link to="/dashboard/activity" className="text-xs text-neutral-500 hover:text-white transition-colors">
179+
Full log →
180+
</Link>
181+
</div>
182+
<div className="space-y-1">
183+
{recent.map(log => {
184+
const cfg = ACTION_CONFIG[log.action] ?? ACTION_CONFIG.open;
185+
const Icon = cfg.icon;
186+
return (
187+
<div key={log.id} className="flex items-center gap-2.5 py-1.5 group">
188+
<Icon className={`w-3.5 h-3.5 shrink-0 ${cfg.color}`} />
189+
<div className="flex-1 min-w-0 flex items-baseline gap-1.5">
190+
<span className="text-xs text-neutral-500">{cfg.label}</span>
191+
{log.structure_name && (
192+
<span className="text-xs text-neutral-300 font-medium truncate">{log.structure_name}</span>
193+
)}
194+
</div>
195+
<span className="text-xs text-neutral-700 shrink-0">{timeAgo(log.created_at)}</span>
196+
</div>
197+
);
198+
})}
199+
</div>
200+
</div>
201+
);
202+
}
203+
204+
// ─── Starred Structures ───────────────────────────────────────────
205+
206+
function StarredRow({ structures }: { structures: Structure[] }) {
207+
const starred = structures.filter(s => s.starred).slice(0, 4);
208+
if (starred.length === 0) return null;
209+
210+
return (
211+
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 space-y-4">
212+
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
213+
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" /> Starred
214+
</h3>
215+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
216+
{starred.map(s => (
217+
<div key={s.id}
218+
className="p-3 rounded-xl border border-neutral-800 hover:border-yellow-500/30 hover:bg-yellow-500/5 transition-all cursor-pointer group">
219+
<div className="w-8 h-8 rounded-lg bg-neutral-800 flex items-center justify-center mb-2">
220+
<Dna className="w-4 h-4 text-neutral-500 group-hover:text-yellow-400 transition-colors" />
221+
</div>
222+
<p className="text-xs font-medium text-neutral-300 truncate">{s.name}</p>
223+
<p className="text-[10px] text-neutral-600 mt-0.5">{s.file_type.toUpperCase()}</p>
224+
</div>
225+
))}
226+
</div>
227+
</div>
228+
);
229+
}
230+
231+
// ─── Main DashboardHome ───────────────────────────────────────────
232+
233+
export const DashboardHome = () => {
234+
const { user } = useAuth();
235+
const [structures, setStructures] = useState<Structure[]>([]);
236+
const [logs, setLogs] = useState<ActivityLog[]>([]);
237+
const [loading, setLoading] = useState(true);
238+
239+
useEffect(() => {
240+
if (!user) return;
241+
Promise.all([
242+
listStructures(user.id),
243+
getActivityLog(user.id, 50),
244+
]).then(([s, l]) => {
245+
setStructures(s);
246+
setLogs(l);
247+
}).catch(console.error).finally(() => setLoading(false));
248+
}, [user]);
249+
250+
const totalBytes = structures.reduce((acc, s) => acc + (s.file_size ?? 0), 0);
251+
const totalOpens = structures.reduce((acc, s) => acc + (s.view_count ?? 0), 0);
252+
const weekOpens = logs.filter(l => {
253+
const d = new Date(l.created_at);
254+
return l.action === 'open' && Date.now() - d.getTime() < 7 * 86400000;
255+
}).length;
256+
257+
const greeting = () => {
258+
const h = new Date().getHours();
259+
if (h < 12) return 'Good morning';
260+
if (h < 18) return 'Good afternoon';
261+
return 'Good evening';
262+
};
263+
264+
const displayName = user?.user_metadata?.full_name || user?.email?.split('@')[0] || 'Scientist';
265+
266+
if (loading) {
267+
return (
268+
<div className="flex items-center justify-center h-64 text-neutral-600">
269+
<Loader2 className="w-6 h-6 animate-spin" />
270+
</div>
271+
);
272+
}
273+
274+
return (
275+
<div className="space-y-8 max-w-5xl">
276+
{/* Header */}
277+
<div className="flex items-end justify-between gap-4">
278+
<div>
279+
<p className="text-sm text-neutral-500">{greeting()},</p>
280+
<h1 className="text-3xl font-bold text-white tracking-tight">{displayName} 👋</h1>
281+
<p className="text-sm text-neutral-500 mt-1">
282+
Here's a snapshot of your structure library.
283+
</p>
284+
</div>
285+
<Link
286+
to="/dashboard/structures"
287+
className="hidden sm:flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-xl transition-colors shrink-0"
288+
>
289+
<Plus className="w-4 h-4" /> New Structure
290+
</Link>
291+
</div>
292+
293+
{/* Stats Row */}
294+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
295+
<StatCard
296+
label="Structures"
297+
value={structures.length}
298+
sub={`${structures.filter(s => s.starred).length} starred`}
299+
icon={Database}
300+
gradient="bg-gradient-to-br from-blue-600/30 to-blue-900/20"
301+
/>
302+
<StatCard
303+
label="Storage Used"
304+
value={formatBytes(totalBytes)}
305+
sub={`of ${STORAGE_LIMIT_MB} MB`}
306+
icon={TrendingUp}
307+
gradient="bg-gradient-to-br from-emerald-600/30 to-emerald-900/20"
308+
/>
309+
<StatCard
310+
label="Total Opens"
311+
value={totalOpens}
312+
sub="all time"
313+
icon={Eye}
314+
gradient="bg-gradient-to-br from-violet-600/30 to-violet-900/20"
315+
/>
316+
<StatCard
317+
label="This Week"
318+
value={weekOpens}
319+
sub="opens in last 7 days"
320+
icon={Activity}
321+
gradient="bg-gradient-to-br from-amber-600/30 to-amber-900/20"
322+
/>
323+
</div>
324+
325+
{/* Main Grid */}
326+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
327+
{/* Left column — 2/3 width */}
328+
<div className="lg:col-span-2 space-y-4">
329+
<StarredRow structures={structures} />
330+
<TopStructures structures={structures} />
331+
<RecentFeed logs={logs} />
332+
333+
{/* Empty state */}
334+
{structures.length === 0 && (
335+
<div className="bg-neutral-900 border border-neutral-800 border-dashed rounded-2xl p-12 text-center space-y-4">
336+
<div className="w-16 h-16 rounded-2xl bg-neutral-800 flex items-center justify-center mx-auto">
337+
<Dna className="w-8 h-8 text-neutral-600" />
338+
</div>
339+
<div>
340+
<p className="text-neutral-300 font-medium">No structures yet</p>
341+
<p className="text-sm text-neutral-600 mt-1">Upload your first structure or import from RCSB PDB.</p>
342+
</div>
343+
<Link
344+
to="/dashboard/structures"
345+
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors"
346+
>
347+
<Plus className="w-4 h-4" /> Get Started
348+
</Link>
349+
</div>
350+
)}
351+
</div>
352+
353+
{/* Right column — 1/3 width */}
354+
<div className="space-y-4">
355+
<StorageBar totalBytes={totalBytes} />
356+
<QuickActions />
357+
</div>
358+
</div>
359+
</div>
360+
);
361+
};

0 commit comments

Comments
 (0)