Flatten authenticated sidebar IA, move admin to the profile menu, right-align TopSearchBar actions, and replace placeholder dashboard panels with data from /api/user/stats and /api/user-cards. Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
7.5 KiB
JavaScript
226 lines
7.5 KiB
JavaScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { useRouter } from 'next/router';
|
|
import Link from 'next/link';
|
|
import Layout from '../components/Layout';
|
|
import DashboardFeaturedCollection from '../components/DashboardFeaturedCollection';
|
|
import DashboardRecentActivity from '../components/DashboardRecentActivity';
|
|
import DashboardCardSpotlight from '../components/DashboardCardSpotlight';
|
|
import { Button, StatCard } from '../components/ui';
|
|
import { useAuth } from '../lib/use-auth';
|
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
|
import { formatRelativeTime } from '../lib/format-relative-time.js';
|
|
|
|
function authHeaders() {
|
|
const token =
|
|
typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
return headers;
|
|
}
|
|
|
|
function mapRecentActivityRows(recentActivity) {
|
|
if (!Array.isArray(recentActivity)) return [];
|
|
return recentActivity.slice(0, 5).map((row, index) => ({
|
|
id: `${row.name}-${row.addedAt}-${index}`,
|
|
actor: { name: 'You', initial: 'Y' },
|
|
action: `added to ${VOCAB.MY_COLLECTION}`,
|
|
subject: [row.name, row.game].filter(Boolean).join(' · '),
|
|
time: formatRelativeTime(row.addedAt),
|
|
}));
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const router = useRouter();
|
|
const { user, loading: authLoading } = useAuth();
|
|
|
|
const [stats, setStats] = useState(null);
|
|
const [recentCards, setRecentCards] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
router.push('/login');
|
|
}
|
|
}, [authLoading, user, router]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
let cancelled = false;
|
|
|
|
const fetchAll = async () => {
|
|
const headers = authHeaders();
|
|
try {
|
|
const [statsRes, cardsRes] = await Promise.all([
|
|
fetch('/api/user/stats', { headers }),
|
|
fetch('/api/user-cards', { headers }),
|
|
]);
|
|
|
|
if (!cancelled) {
|
|
if (statsRes.ok) {
|
|
setStats(await statsRes.json());
|
|
} else {
|
|
setStats(null);
|
|
}
|
|
if (cardsRes.ok) {
|
|
const data = await cardsRes.json();
|
|
setRecentCards(Array.isArray(data) ? data.slice(0, 8) : []);
|
|
} else {
|
|
setRecentCards([]);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[dashboard] fetch error:', error);
|
|
if (!cancelled) {
|
|
setStats(null);
|
|
setRecentCards([]);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchAll();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [user]);
|
|
|
|
const activityRows = useMemo(
|
|
() => mapRecentActivityRows(stats?.recentActivity),
|
|
[stats?.recentActivity]
|
|
);
|
|
|
|
const spotlightCard = recentCards[0] ?? null;
|
|
const totalCards = stats?.totalCards ?? 0;
|
|
const collectionValue = stats?.totalValue ?? 0;
|
|
const totalDecks = stats?.totalDecks ?? 0;
|
|
const statsLoaded = stats !== null;
|
|
const isEmptyCollection =
|
|
statsLoaded && totalCards === 0 && recentCards.length === 0;
|
|
|
|
return (
|
|
<Layout user={user}>
|
|
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
|
{isEmptyCollection && (
|
|
<div className="glass-panel rounded-2xl p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1
|
|
className="text-2xl font-bold mb-1"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
Welcome{user?.username ? `, ${user.username}` : ''}
|
|
</h1>
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
Scan or browse cards to start {VOCAB.MY_COLLECTION}.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Link href="/scanner">
|
|
<Button variant="secondary" size="sm">
|
|
Scan card
|
|
</Button>
|
|
</Link>
|
|
<Link href="/cards">
|
|
<Button variant="primary" size="sm">
|
|
Browse catalog
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div
|
|
className="motion-essential animate-spin rounded-full h-12 w-12 border-b-2"
|
|
style={{ borderColor: 'var(--accent-ember)' }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<StatCard
|
|
accent="blue"
|
|
label="Total cards"
|
|
value={totalCards.toLocaleString()}
|
|
icon={
|
|
<svg
|
|
className="h-6 w-6"
|
|
fill="none"
|
|
stroke="rgb(255, 255, 255)"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
|
/>
|
|
</svg>
|
|
}
|
|
/>
|
|
<StatCard
|
|
accent="gold"
|
|
label="Collection value"
|
|
value={`$${collectionValue.toLocaleString(undefined, {
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
})}`}
|
|
icon={
|
|
<svg
|
|
className="h-6 w-6"
|
|
fill="none"
|
|
stroke="rgb(255, 255, 255)"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
|
/>
|
|
</svg>
|
|
}
|
|
/>
|
|
<StatCard
|
|
accent="purple"
|
|
label="Decks"
|
|
value={totalDecks.toLocaleString()}
|
|
icon={
|
|
<svg
|
|
className="h-6 w-6"
|
|
fill="none"
|
|
stroke="rgb(255, 255, 255)"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
/>
|
|
</svg>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<DashboardFeaturedCollection cards={recentCards} loading={false} />
|
|
<DashboardRecentActivity
|
|
activities={activityRows}
|
|
loading={false}
|
|
/>
|
|
</div>
|
|
<div className="lg:col-span-1">
|
|
<DashboardCardSpotlight card={spotlightCard} loading={false} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|