deckhearth/components/CollectionPageView.js
Randall Stillwell 1769d4576a refactor(design): site-wide sweep — broken Tailwind tokens, rounded corners, SearchBar primitive (#117)
Comprehensive design sweep across the rest of the app following the
shipped Liquid Glass + corner-border-light system (#116).

## Three classes of finding

### 1. Broken Tailwind token classes (HIGH — pages were unstyled)

The decks / deck-builder / deck-detail cluster relied on Tailwind
classes that don't exist in `tailwind.config.js` (no `bg-bg-*`,
`text-text-*`, `border-border`, `bg-accent-ember`,
`focus:ring-accent-ember`, `hover:bg-accent-ember-dark`). Those classes
produced ZERO CSS — backgrounds were transparent, borders invisible,
hover states absent.

Rewrote with inline `style={{ ... CSS vars ... }}` + the `<Button>` /
`<SearchBar>` primitives + `glass-panel` surfaces:

- `pages/decks.js` (full page)
- `pages/deck/[id].js` (header, stats sidebar, group-by controls,
  card list)
- `pages/deck-builder.js` (loading spinner)
- `components/DeckBuilderView.js` (toolbar + main panel)
- `components/DeckBuilderCardBrowser.js` (full rewrite; integrated
  `<SearchBar>` for the card-picker input)
- `components/DeckBuilderDeckList.js` (full rewrite)
- `components/DeckBuilderStatsBar.js`
- `components/ManaSymbolSettings.js`
- `components/ManaSymbols.js` (single `text-text-secondary`)
- `pages/admin/card-editor.js` cluster was already clean

### 2. Duplicative / stale page searches

Replaced raw `<input>` search controls with the `<SearchBar>` primitive
(adds clear button, ember focus ring, system-consistent rounded
corners). Kept page-specific filter searches (they filter the visible
list — distinct from the global TopSearchBar command palette):

- `pages/my-cards.js`
- `pages/community/collections.js`
- `components/CardsPageView.js`
- `components/CollectionPageView.js`
- `components/DeckBuilderCardBrowser.js`

`pages/my-cards.js` filter wrapper also lifted into a `glass-panel`
chip instead of a solid `var(--bg-primary)` band.

### 3. Square corners + stale palette in shared views

- `components/CollectionPageView.js`: 10 action buttons (`rounded-lg`
  + `hover:bg-gray-50`) → `rounded-xl` + `nav-item-hover`; 4 filter
  selects (`focus:ring-purple-500 rounded-lg`) → `.input-field`;
  view-mode toggle (`bg-white text-gray-900` — invisible in dark mode)
  → tokenised; SYSTEM badge gradient (`from-blue-500 to-purple-600`)
  → ember↔flame; tooltip (`bg-gray-900`) → `glass-panel-strong`;
  search-results dropdown (`bg-white border-gray-200` — invisible in
  dark mode) → `glass-panel-strong`; Activity / game-count /
  TCG-game badges palette-aligned.
- `components/CardsPageView.js`: "Load More Cards" button
  (`bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg`) →
  `<Button variant="primary" size="lg">`.
- `components/CollectionsPageView.js`: matching SYSTEM badge +
  tooltip cleanup.
- `components/ShareModal.js`: user-search dropdown
  (`border-gray-200 hover:bg-gray-50`) and email-invite card moved
  onto `glass-panel` + `nav-item-hover`; social-share buttons
  `rounded-lg hover:bg-gray-50` → `rounded-xl nav-item-hover`.
- `components/Layout.js`: profile-menu dropdown row
  (`hover:bg-gray-50 dark:hover:bg-gray-700`) → `nav-item-hover`.
- `components/CardItem.js`: bulk-select checkbox
  `focus:ring-purple-500` → ember.

### 4. `dark:` modifier classes (broken with `[data-theme]` theming)

This app uses `[data-theme="dark"]` CSS selector theming, not
Tailwind's `class` strategy, so `dark:bg-green-900/20` etc. produced
no CSS in dark mode. Affected alerts on `pages/settings.js` and
`pages/profile.js` — replaced with `glass-panel` + semantic border
colour (flame for success, #dc2626 for error).

`pages/settings.js` sidebar nav also moved off its hardcoded full-ember
fill onto the system `nav-item` / `nav-item-active` / `nav-item-hover`
pattern for consistency with the global sidebar.

## Verification

- `npm run build` — green (Next 16 + Turbopack)
- `npm run lint` — 0 errors, 1 unrelated pre-existing warning
- `npm run test:run` — 113/113 pass (no test changes needed)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:55:29 -05:00

543 lines
22 KiB
JavaScript

/* eslint-disable @next/next/no-img-element -- Binder/card images use external URLs; next/image migration is out of scope. */
import Link from 'next/link';
import UploadImageModal from './UploadImageModal';
import ShareModal from './ShareModal';
import CollaboratorFacepile from './CollaboratorFacepile';
import CardItem from './CardItem';
import LoginCTA from './LoginCTA';
import { ManaCost, ColorFilterSymbol } from './ManaSymbols';
import ManaSymbolSettings from './ManaSymbolSettings';
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
import CollectionEditModal from './CollectionEditModal';
import CollectionDeleteModal from './CollectionDeleteModal';
import { Button, SearchBar } from './ui';
export default function CollectionPageView(props) {
const {
cards,
collection,
copySuccess,
editForm,
favoritedCards,
gameStats,
groupBy,
groupedCards,
handleAddCard,
handleAddToCollection,
handleAddToDeck,
handleDeleteCollection,
handleDownloadCSV,
handleEditCollection,
handleImageUpload,
handleSearchCards,
handleToggleFavorite,
handleToggleSelect,
identifier,
isFavorited,
loading,
router,
searchCards,
searchQuery,
searchResults,
selectedCards,
selectedRarity,
selectedTCG,
selectedType,
setCards,
setCollection,
setCopySuccess,
setEditForm,
setFavoritedCards,
setGroupBy,
setIsFavorited,
setLoading,
setSearchCards,
setSearchQuery,
setSearchResults,
setSelectedCards,
setSelectedRarity,
setSelectedTCG,
setSelectedType,
setShowDeleteModal,
setShowEditModal,
setShowSearchResults,
setShowShareModal,
setShowUploadModal,
setSortBy,
setViewMode,
showDeleteModal,
showEditModal,
showInitialLoading,
showSearchResults,
showShareModal,
showUploadModal,
sortBy,
user,
viewMode
} = props;
return (
<>
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
{/* Header */}
<div className="px-6 pt-6 pb-2">
<div className="flex items-center justify-between mb-6">
{/* Back button */}
<div className="flex items-center">
<Link href="/collections">
<button className="flex items-center text-sm font-medium hover:underline" style={{ color: 'var(--text-secondary)' }}>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to collections
</button>
</Link>
</div>
{/* Action buttons */}
<div className="flex items-center space-x-3">
{/* Edit and Delete buttons - only show for owner and non-system collections */}
{collection.userRole === 'owner' && !collection.isSystemCollection && (
<>
<button
onClick={() => setShowEditModal(true)}
className="px-4 py-2 text-sm font-medium border rounded-xl nav-item-hover flex items-center space-x-2"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit</span>
</button>
<button
onClick={() => setShowDeleteModal(true)}
className="px-4 py-2 text-sm font-medium border rounded-xl nav-item-hover flex items-center space-x-2"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</button>
</>
)}
<button
onClick={() => setShowUploadModal(true)}
className="px-4 py-2 text-sm font-medium border rounded-xl nav-item-hover"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
Upload Image
</button>
<button
className="px-4 py-2 text-sm font-medium border rounded-xl nav-item-hover"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
🪄 Generate AI Image
</button>
</div>
</div>
{/* Collection Info */}
<div className="mb-6">
<div className="flex items-center space-x-3 mb-2">
<h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}>
{collectionDisplayName(collection)}
</h1>
{/* System collection indicator */}
{collection.isSystemCollection && (
<div className="flex items-center space-x-1">
<span
className="px-2.5 py-1 text-xs font-bold rounded-full text-white shadow-sm border-2"
style={{
background:
'linear-gradient(135deg, var(--accent-ember) 0%, var(--accent-flame) 100%)',
borderColor: 'var(--accent-flame)',
}}
>
🔒 SYSTEM
</span>
<div className="group relative">
<svg
className="h-4 w-4 cursor-help"
style={{ color: 'var(--accent-ember)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div
className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-xs rounded-xl shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap glass-panel-strong"
style={{ color: 'var(--text-primary)' }}
>
{VOCAB.SYSTEM_COLLECTION_SYNC_HINT}
</div>
</div>
</div>
)}
</div>
<p className="text-lg mb-4" style={{ color: 'var(--text-secondary)' }}>
{collection.description}
</p>
{/* TCG Tags */}
<div className="flex items-center space-x-2 mb-4">
{Object.entries(gameStats).map(([game, count]) =>
count > 0 ? (
<span
key={game}
className="px-3 py-1 text-xs font-medium rounded-full"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
{game}
</span>
) : null
)}
</div>
{/* Creator and Stats */}
<div className="flex items-center space-x-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
{collection.creator ? (
<CollaboratorFacepile
collectionId={identifier}
creatorEmail={collection.creator}
/>
) : (
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Crafted by Unknown User
</span>
)}
<div>Cards: <span className="font-medium">{cards.length}</span></div>
<div>Cost: <span className="font-medium">${collection.value || '0'}</span></div>
</div>
<div className="flex items-center space-x-4 mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>Created {new Date(collection.createdAt).toLocaleDateString()}</span>
<span>Last updated {new Date(collection.lastViewed).toLocaleDateString()}</span>
</div>
</div>
{/* Action Bar */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => setShowShareModal(true)}
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium border rounded-xl nav-item-hover"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z" />
</svg>
<span>Share</span>
</button>
<button
onClick={toggleFavorite}
className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium border rounded-xl ${
isFavorited ? '' : 'nav-item-hover'
}`}
style={
isFavorited
? {
borderColor: 'var(--accent-ember)',
color: 'var(--accent-ember)',
backgroundColor: 'transparent',
}
: { borderColor: 'var(--border)', color: 'var(--text-primary)' }
}
>
<svg className="w-4 h-4" fill={isFavorited ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span>Favorite</span>
</button>
<button
onClick={handleDownloadCSV}
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium border rounded-xl nav-item-hover"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>Download List</span>
</button>
</div>
<div className="flex items-center space-x-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>Activity</span>
<span
className="px-2 py-1 rounded-full text-xs font-medium"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
123
</span>
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
{/* Search and Filters */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-4">
<div className="w-64">
<SearchBar
value={searchCards}
onChange={(e) => {
setSearchCards(e.target.value);
handleSearchCards(e.target.value);
}}
onClear={() => {
setSearchCards('');
handleSearchCards('');
}}
placeholder="Search cards…"
/>
</div>
<select
value={selectedRarity}
onChange={(e) => setSelectedRarity(e.target.value)}
className="input-field"
>
<option>All Rarities</option>
<option>Common</option>
<option>Uncommon</option>
<option>Rare</option>
<option>Mythic</option>
<option>Legendary</option>
</select>
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="input-field"
>
<option>All Types</option>
<option>Creature</option>
<option>Instant</option>
<option>Sorcery</option>
<option>Artifact</option>
<option>Enchantment</option>
</select>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="input-field"
>
<option>Group by Game</option>
<option>Group by Rarity</option>
<option>Group by Type</option>
</select>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="input-field"
>
<option>Sort by Name</option>
<option>Sort by Price</option>
<option>Sort by Rarity</option>
<option>Sort by Date Added</option>
</select>
</div>
<div className="flex items-center space-x-3">
<div
className="flex rounded-xl border"
style={{ borderColor: 'var(--border)' }}
>
<button
onClick={() => setViewMode('grid')}
className="p-2 rounded-l-xl transition-colors"
style={{
backgroundColor:
viewMode === 'grid' ? 'var(--bg-tertiary)' : 'transparent',
color:
viewMode === 'grid'
? 'var(--text-primary)'
: 'var(--text-secondary)',
}}
aria-label="Grid view"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className="p-2 rounded-r-xl transition-colors"
style={{
backgroundColor:
viewMode === 'list' ? 'var(--bg-tertiary)' : 'transparent',
color:
viewMode === 'list'
? 'var(--text-primary)'
: 'var(--text-secondary)',
}}
aria-label="List view"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
<Button
variant="primary"
onClick={() => router.push('/cards')}
leadingIcon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
}
>
Add Cards
</Button>
<button
className="px-4 py-2 border rounded-xl nav-item-hover flex items-center space-x-2"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Scan Cards</span>
</button>
</div>
</div>
{/* Game Sections */}
{Object.entries(groupedCards).map(([game, gameCards]) => (
<div key={game} className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold flex items-center space-x-3" style={{ color: 'var(--text-primary)' }}>
<span>{game === 'MTG' ? 'Magic The Gathering' : game}</span>
<span
className="px-2 py-1 rounded-xl text-sm font-medium"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)',
}}
>
{gameCards.length}
</span>
</h2>
</div>
<div className="card-grid-container grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-3 sm:gap-4 lg:gap-6">
{gameCards.map((card, index) => (
<CardItem
key={card.id || index}
card={card}
viewMode={viewMode}
isSelected={selectedCards.some(c => c.id === card.id)}
onToggleSelect={handleToggleSelect}
onAddToCollection={handleAddToCollection}
onAddToDeck={handleAddToDeck}
onToggleFavorite={handleToggleFavorite}
isFavorited={favoritedCards.has(card.id)}
/>
))}
</div>
</div>
))}
{cards.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)' }}>
Start Building Your List
</h3>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Add cards to get started with your collection
</p>
<Button variant="primary" onClick={() => router.push('/cards')}>
Browse Cards to Add
</Button>
</div>
)}
</div>
{/* Search Results Dropdown */}
{showSearchResults && searchResults.length > 0 && (
<div
className="absolute top-full left-0 right-0 glass-panel-strong rounded-xl max-h-64 overflow-y-auto z-50"
>
{searchResults.map(card => (
<div
key={card.id}
onClick={() => handleAddCard(card)}
className="flex items-center p-3 cursor-pointer nav-item-hover"
>
<img
src={card.image_url}
alt={card.name}
className="w-12 h-16 object-cover rounded mr-3"
onError={(e) => {
e.target.src = 'https://via.placeholder.com/48x64/6366f1/ffffff?text=No+Image';
}}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>
{card.name}
</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{card.set_name} ${card.market_price}
</div>
</div>
</div>
))}
</div>
)}
<CollectionEditModal
isOpen={showEditModal}
editForm={editForm}
setEditForm={setEditForm}
onClose={() => setShowEditModal(false)}
onSave={handleEditCollection}
/>
<CollectionDeleteModal
isOpen={showDeleteModal}
collection={collection}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDeleteCollection}
/>
{/* Upload Image Modal */}
<UploadImageModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
onUpload={handleImageUpload}
currentImage={collection.image}
/>
{/* Share Modal */}
<ShareModal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
collectionId={identifier}
isPublic={collection.isPublic}
onTogglePublic={togglePublic}
onInviteUser={(email) => console.log('Invited:', email)}
/>
</div>
{/* Show login CTA for non-authenticated users */}
{!user && collection?.isPublic && (
<LoginCTA message="Sign up to create your own lists and collaborate with others!" />
)}
</>
);
}