deckhearth/pages/dashboard.js
varutasu e6e778080a
feat(design-system): redesign v2 #3 — TopSearchBar + Cmd+K + sweep page-header-glass (#105)
Sub-convoy #3 from .convoys/redesign-v2-from-mockups.md (umbrella
§ 7.3 — locked: sweep to ALL authenticated pages this convoy).

What ships:

- components/ui/TopSearchBar.js — the top horizontal chrome strip
  from the mockup. Layout: prominent search input on left (with
  magnifier icon + Cmd+K/Ctrl+K hint pill that adapts to platform)
  + notification bell with red badge (hidden when count=0) + mail
  icon + compact user-menu chip (gradient-tile avatar + display name
  + chevron). Avatar reads user.username with a fallback initial.
  Renders null for unauthenticated visitors (public marketing pages
  use their own header).

- components/ui/CommandPaletteModal.js — the surface that opens on
  ⌘K / Ctrl+K. Single search input, auto-focused. Enter submits to
  /cards?q=<query>. 3 quick-action buttons (Dashboard / Cards /
  Scanner) below the input. Eschews live-result preview, recent-
  search storage, and federated-search ranking; those are deferred
  to a follow-up convoy per umbrella § 7.2.

- components/Layout.js: TopSearchBar mounted in the main-content
  column ABOVE <main> for authenticated users (drops the legacy
  showSearch prop dependency — the prop stays for back-compat but
  no longer drives the header's visibility). Global keydown listener
  attached at Layout scope, toggles the CommandPaletteModal on
  ⌘K/Ctrl+K (preventDefault on the shortcut so the browser's native
  bookmark/search shortcut doesn't fire). The legacy <header>
  block that rendered an inline search input is removed; that
  surface is replaced by TopSearchBar + CommandPaletteModal.

- page-header-glass call-site sweep (umbrella § 7.3 contract:
  "no call site references it after this convoy"):
  - pages/dashboard.js
  - pages/my-cards.js
  - pages/community/collections.js
  - components/CollectionsPageView.js
  - components/CollectionPageView.js
  - components/CardsPageView.js
  Each `page-header-glass p-4 sm:p-6` is replaced with plain content
  padding (`px-4 sm:px-6 pt-6 pb-2`). Page titles + actions stay
  exactly where they were inside the content area; the glass chrome
  that previously framed them is now provided by TopSearchBar above.
  The .page-header-glass utility class stays in styles/globals.css
  (a downstream sweep convoy can remove it once the unused-CSS lint
  catches it).

- components/ui/index.js: barrel export updated with TopSearchBar +
  CommandPaletteModal.

Lint fix:
- CommandPaletteModal initially used useEffect(setQuery(''), [open])
  to reset the input on open; that hits the react-hooks/set-state-
  in-effect rule (we added the rule in fix-auth-bypass Brief 5). Use
  the "during render with previous-state tracking" pattern that
  NavigationContent uses (lines 168-178 of components/Layout.js)
  for the same purpose. No useEffect required.

Tests:
- npm run test:run: 113/113 (was 110; +3 new — implicit Layout
  tree-render coverage of the new TopSearchBar mount paths).
- npm run lint: clean (1 pre-existing unused-disable warning).
- npm run build: green.

Next: sub-convoy #6 (card-grid outer-glow), #7 (dashboard layout
rebuild), #8 (right-rail Card Spotlight).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 11:14:50 -05:00

293 lines
No EOL
12 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator';
import { Button, StatCard } from '../components/ui';
import { useAuth } from '../lib/use-auth';
import Link from 'next/link';
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
export default function Dashboard() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [collections, setCollections] = useState([]);
const [loading, setLoading] = useState(true);
// Redirect to login if not authenticated
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
const fetchCollections = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch('/api/collections', { headers });
if (response.ok) {
const data = await response.json();
// Fetch thumbnails for each collection
const collectionsWithThumbnails = await Promise.all(
data.map(async (collection) => {
try {
const identifier = collection.slug || collection.id;
const thumbnailResponse = await fetch(`/api/collections/${identifier}/thumbnails`, { headers });
if (thumbnailResponse.ok) {
const thumbnailData = await thumbnailResponse.json();
return { ...collection, thumbnails: thumbnailData.thumbnails };
}
return { ...collection, thumbnails: [] };
} catch (error) {
console.error(`Error fetching thumbnails for collection ${collection.id}:`, error);
return { ...collection, thumbnails: [] };
}
})
);
setCollections(collectionsWithThumbnails);
} else {
console.error('Failed to fetch collections');
setCollections([]);
}
} catch (error) {
console.error('Error fetching collections:', error);
setCollections([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (user) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- load dashboard lists when user is available
fetchCollections();
}
}, [user]);
;
return (
<Layout user={user}>
{/* Header */}
<div className="px-4 sm:px-6 pt-6 pb-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{VOCAB.MY_COLLECTION}
</h1>
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
Overview of your lists and owned cards
</p>
</div>
<div className="flex space-x-2 sm:space-x-4">
<Link href="/collections">
<Button
variant="primary"
size="sm"
leadingIcon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
}
>
Create List
</Button>
</Link>
</div>
</div>
</div>
{/* Content */}
<div className="p-4 sm:p-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
</div>
) : (
<div className="max-w-7xl mx-auto">
{/* Stats Cards — 4-up grid from operator mockup
(.convoys/redesign-v2-from-mockups.md § 7.1).
Metrics: Total Cards / Rare Cards / Collection Value /
Wishlist Items. Real data where available; placeholders
with TODO comments where the concept doesn't exist yet. */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard
accent="blue"
label="Total Cards"
value={collections
.reduce((total, col) => total + (col.cardCount || 0), 0)
.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>
}
/>
{/* TODO(rarity-aggregation convoy): swap placeholder 0
for a real count once the user_cards.rarity column is
populated by the import jobs. Operator-approved
placeholder per .convoys/redesign-v2-from-mockups.md
§ 7.1 ("placeholders for what we don't have"). */}
<StatCard
accent="purple"
label="Rare Cards"
value="0"
subtitle="Coming soon"
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 2l3 7h7l-5.5 4.5L18 21l-6-4-6 4 1.5-7.5L2 9h7l3-7z"
/>
</svg>
}
/>
<StatCard
accent="gold"
label="Collection Value"
value={`$${collections
.reduce((total, col) => total + (col.value || 0), 0)
.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="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>
}
/>
{/* TODO(wishlist-feature convoy): real wishlist count
ships when the wishlist table + API land. Operator-
approved placeholder for now. */}
<StatCard
accent="red"
label="Wishlist Items"
value="0"
subtitle="Coming soon"
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="M4.318 6.318a4.5 4.5 0 016.364 0L12 7.636l1.318-1.318a4.5 4.5 0 116.364 6.364L12 20.364l-7.682-7.682a4.5 4.5 0 010-6.364z"
/>
</svg>
}
/>
</div>
{/* Collections Grid */}
<div className="mb-6">
<h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>Recent Lists</h2>
{collections.length === 0 ? (
<div className="text-center py-12">
<div className="glass-panel w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
<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 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No Lists Yet</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Create your first list to start organizing your cards
</p>
<Link href="/collections">
<Button variant="primary" size="lg">
Create Your First List
</Button>
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{collections.slice(0, 6).map((collection) => (
<Link key={collection.id} href={`/collection/${collection.slug || collection.id}`}>
<div className="glass-panel rounded-2xl p-6 transition-all duration-200 hover:scale-105 cursor-pointer">
<div className="flex items-center mb-4">
<div className="w-12 h-12 rounded-xl mr-4 flex items-center justify-center gradient-bg-ember">
<span className="text-white font-bold">
{collection.name?.charAt(0)?.toUpperCase() || 'C'}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{collectionDisplayName(collection)}
</h3>
<p className="text-sm truncate" style={{ color: 'var(--text-secondary)' }}>
{collection.cardCount || 0} cards
</p>
</div>
</div>
{collection.description && (
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{collection.description}
</p>
)}
<div className="flex items-center justify-between">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
${(collection.value || 0).toLocaleString()}
</span>
<PermissionIndicator isPublic={collection.isPublic} />
</div>
</div>
</Link>
))}
</div>
)}
</div>
{collections.length > 6 && (
<div className="text-center">
<Link href="/collections">
<Button variant="secondary" size="lg">
View All Lists
</Button>
</Link>
</div>
)}
</div>
)}
</div>
</Layout>
);
}