deckhearth/pages/community/collections.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

345 lines
No EOL
13 KiB
JavaScript

/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../../components/Layout';
import PermissionIndicator from '../../components/PermissionIndicator';
import { Button } from '../../components/ui';
import { useAuth } from '../../lib/use-auth';
import { VOCAB } from '../../lib/collection-vocabulary.js';
export default function CommunityCollections() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [collections, setCollections] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
// Fetch collections on mount, regardless of auth status
const fetchPublicCollections = async () => {
try {
// Use public API endpoint that doesn't require authentication
const response = await fetch('/api/public/collections?limit=50');
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`);
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 public collections');
}
} catch (error) {
console.error('Error fetching public collections:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount fetch; setLoading runs inside async loader
fetchPublicCollections();
}, []);
const sortOptions = [
{ value: 'name', label: 'Name (A-Z)' },
{ value: 'value', label: 'Value (High to Low)' },
{ value: 'cardCount', label: 'Card Count (High to Low)' },
{ value: 'createdAt', label: 'Recently Created' }
];
const sortCollections = (collections, sortBy) => {
return [...collections].sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'value':
return (b.value || 0) - (a.value || 0);
case 'cardCount':
return (b.cardCount || 0) - (a.cardCount || 0);
case 'createdAt':
return new Date(b.createdAt) - new Date(a.createdAt);
default:
return 0;
}
});
};
const filteredCollections = collections.filter(collection => {
const matchesSearch = collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
collection.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
});
const sortedCollections = sortCollections(filteredCollections, sortBy);
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString();
};
// Collection thumbnail component (same as in regular collections)
const CollectionThumbnail = ({ collection }) => {
const { thumbnails = [], image } = collection;
// If there's a custom image, show it
if (image) {
return (
<div className="w-full h-48 rounded-xl overflow-hidden mb-4">
<img
src={image}
alt={collection.name}
className="w-full h-full object-cover"
/>
</div>
);
}
// If no cards, show crying emoji
if (!thumbnails || thumbnails.length === 0) {
return (
<div className="w-full h-48 rounded-xl mb-4 flex items-center justify-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="text-center">
<div className="text-6xl mb-2">😢</div>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>No cards yet</p>
</div>
</div>
);
}
const mainCard = thumbnails[0];
const gridCards = thumbnails.slice(1, 5); // Get up to 4 cards for the 2x2 grid
return (
<div className="w-full h-48 rounded-xl overflow-hidden mb-4 p-3 flex gap-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
{/* Main card (larger, left side) */}
<div className="flex-2 h-full">
{mainCard ? (
<div className="w-full h-full bg-white rounded-lg overflow-hidden shadow-sm border" style={{ borderColor: 'var(--border)' }}>
<img
src={mainCard.image_url || mainCard.stock_image_url}
alt={mainCard.name}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-full h-full bg-white rounded-lg border" style={{ borderColor: 'var(--border)' }}></div>
)}
</div>
{/* Grid of 4 smaller cards (right side) */}
<div className="flex-1 h-full">
<div className="grid grid-cols-2 gap-2 h-full">
{Array.from({ length: 4 }).map((_, index) => {
const card = gridCards[index];
return (
<div key={index} className="relative">
{card ? (
<div className="w-full h-full bg-white rounded-md overflow-hidden shadow-sm border" style={{ borderColor: 'var(--border)' }}>
<img
src={card.image_url || card.stock_image_url}
alt={card.name}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-full h-full bg-white rounded-md border" style={{ borderColor: 'var(--border)' }}></div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};
// Show loading spinner while auth is loading or data is loading
if (authLoading || loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
</div>
</Layout>
);
}
// Redirect to login if not authenticated (handled by useEffect, but this is a fallback)
if (!user) {
return null;
}
return (
<Layout user={user}>
{/* Header */}
<div className="px-6 pt-6 pb-2">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}>
Community Lists
</h1>
<p className="mt-2" style={{ color: 'var(--text-secondary)' }}>
Discover public lists shared by the community
</p>
</div>
</div>
{/* Search and Sort */}
<div className="flex items-center justify-between mt-6">
<div className="flex-1 max-w-md">
<input
type="text"
placeholder="Search lists..."
className="input-field w-full"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-4">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="input-field w-48"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Collections Grid */}
<div className="p-6">
{sortedCollections.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🌍</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{searchQuery ? 'No lists found' : 'No public lists yet'}
</h3>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
{searchQuery
? 'Try adjusting your search terms'
: 'Be the first to share a public list with the community!'
}
</p>
{!searchQuery && (
<Link href="/collections">
<Button variant="primary" size="lg">
Go to My Lists
</Button>
</Link>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{sortedCollections.map(collection => (
<Link key={collection.id} href={`/collection/${collection.slug || collection.id}`}>
<div className="card group cursor-pointer hover:shadow-lg transition-all duration-200">
<CollectionThumbnail collection={collection} />
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate" style={{ color: 'var(--text-primary)' }}>
{collection.name}
</h3>
{collection.description && (
<p className="text-sm mt-1 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{collection.description}
</p>
)}
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
by {collection.creator}
</p>
</div>
<div className="flex items-center space-x-1 ml-2">
<PermissionIndicator
userRole={collection.userRole}
isPublic={collection.isPublic}
showTooltip={false}
/>
</div>
</div>
{/* Compact Stats */}
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center space-x-4">
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{collection.cardCount} cards
</span>
<span className="font-medium" style={{ color: 'var(--accent-ember)' }}>
{formatCurrency(collection.value)}
</span>
</div>
<span className="text-xs">
{formatDate(collection.createdAt)}
</span>
</div>
{/* Tags */}
{collection.tags && collection.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{collection.tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="px-2 py-1 rounded-md text-xs font-medium"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)'
}}
>
{tag}
</span>
))}
{collection.tags.length > 2 && (
<span
className="px-2 py-1 rounded-md text-xs font-medium"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)'
}}
>
+{collection.tags.length - 2}
</span>
)}
</div>
)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
</Layout>
);
}