deckhearth/pages/dashboard.js
varutasu b9e840ff3a
Realign dashboard home nav and wire honest ownership stats (#164)
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>
2026-08-15 16:47:59 -05:00

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>
);
}