Extract useCollectionView hook and CollectionPageView (Brief 3).
Completes collection detail god-component split with a thin page composer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8e2c0e47a8
commit
c3529163b3
3 changed files with 986 additions and 841 deletions
475
components/CollectionPageView.js
Normal file
475
components/CollectionPageView.js
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
/* 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';
|
||||||
|
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="p-6 border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
|
||||||
|
<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-lg hover:bg-gray-50 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-lg hover:bg-red-50 hover:border-red-200 hover:text-red-600 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-lg hover:bg-gray-50"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
Upload Image
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50" 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 bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-sm border-2 border-blue-200">
|
||||||
|
🔒 SYSTEM
|
||||||
|
</span>
|
||||||
|
<div className="group relative">
|
||||||
|
<svg className="h-4 w-4 text-blue-500 cursor-help" 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 bg-gray-900 text-white rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap">
|
||||||
|
{VOCAB.SYSTEM_COLLECTION_SYNC_HINT}
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||||
|
</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 bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{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-lg hover:bg-gray-50"
|
||||||
|
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-lg ${
|
||||||
|
isFavorited ? 'bg-red-50 border-red-200 text-red-600' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
style={!isFavorited ? { 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-lg hover:bg-gray-50"
|
||||||
|
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 bg-purple-100 text-purple-800 rounded-full text-xs font-medium">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="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search cards..."
|
||||||
|
value={searchCards}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchCards(e.target.value);
|
||||||
|
handleSearchCards(e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-64 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
||||||
|
/>
|
||||||
|
<svg className="absolute right-3 top-2.5 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedRarity}
|
||||||
|
onChange={(e) => setSelectedRarity(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
||||||
|
>
|
||||||
|
<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="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
||||||
|
>
|
||||||
|
<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="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
||||||
|
>
|
||||||
|
<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="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
||||||
|
>
|
||||||
|
<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-lg border" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-2 rounded-l-lg ${viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm' : 'bg-transparent hover:bg-gray-50'}`}
|
||||||
|
style={viewMode !== 'grid' ? { color: 'var(--text-secondary)' } : {}}
|
||||||
|
>
|
||||||
|
<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-lg ${viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'bg-transparent hover:bg-gray-50'}`}
|
||||||
|
style={viewMode !== 'list' ? { color: 'var(--text-secondary)' } : {}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
onClick={() => router.push('/cards')}
|
||||||
|
className="px-4 py-2 text-white rounded-lg flex items-center space-x-2"
|
||||||
|
style={{ backgroundColor: 'var(--accent-ember)' }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span>Add Cards</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="px-4 py-2 border rounded-lg hover:bg-gray-50 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 bg-gray-100 text-gray-600 rounded text-sm font-medium">
|
||||||
|
{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 className="px-6 py-3 text-white rounded-lg" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
||||||
|
Browse Cards to Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results Dropdown */}
|
||||||
|
{showSearchResults && searchResults.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg max-h-64 overflow-y-auto z-50">
|
||||||
|
{searchResults.map(card => (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
onClick={() => handleAddCard(card)}
|
||||||
|
className="flex items-center p-3 hover:bg-gray-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<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">{card.name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{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!" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
502
lib/use-collection-view.js
Normal file
502
lib/use-collection-view.js
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { VOCAB, collectionDisplayName } from './collection-vocabulary.js';
|
||||||
|
import { downloadCollectionCardsCsv } from './collection-cards-csv.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card/collection page state and handlers (god-component split).
|
||||||
|
*/
|
||||||
|
export function useCollectionView() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { identifier } = router.query;
|
||||||
|
|
||||||
|
const [collection, setCollection] = useState(null);
|
||||||
|
const [cards, setCards] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
const [selectedTCG, setSelectedTCG] = useState('MTG');
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
isPublic: false,
|
||||||
|
image: '',
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
||||||
|
const [selectedType, setSelectedType] = useState('All Types');
|
||||||
|
const [groupBy, setGroupBy] = useState('Group by Game');
|
||||||
|
const [sortBy, setSortBy] = useState('Sort by Name');
|
||||||
|
const [viewMode, setViewMode] = useState('grid');
|
||||||
|
const [searchCards, setSearchCards] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
|
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
|
||||||
|
// Card interaction states
|
||||||
|
const [selectedCards, setSelectedCards] = useState([]);
|
||||||
|
const [favoritedCards, setFavoritedCards] = useState(new Set());
|
||||||
|
|
||||||
|
const loadFavoritedCards = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/favorites?type=card', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const favoriteIds = new Set(data.favorites.map(fav => parseInt(fav.item_id)));
|
||||||
|
setFavoritedCards(favoriteIds);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading favorited cards:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkIfFavorited = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/favorites?type=collection`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const isFav = data.favorites.some(fav => fav.item_id === collection?.id);
|
||||||
|
setIsFavorited(isFav);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to check favorites:', response.status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking favorites:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCollectionData = async () => {
|
||||||
|
try {
|
||||||
|
// First try to fetch without authentication (for public collections)
|
||||||
|
let response = await fetch(`/api/collections/${identifier}`);
|
||||||
|
|
||||||
|
// If that fails and we have a user, try with authentication
|
||||||
|
if (!response.ok && user) {
|
||||||
|
response = await fetch(`/api/collections/${identifier}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Check if we accessed via numeric ID and need to redirect to slug
|
||||||
|
if (data.slug && identifier !== data.slug && !isNaN(parseInt(identifier))) {
|
||||||
|
// Redirect to slug URL
|
||||||
|
router.replace(`/collection/${data.slug}`, undefined, { shallow: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCollection(data);
|
||||||
|
setEditForm({
|
||||||
|
name: data.name || '',
|
||||||
|
description: data.description || '',
|
||||||
|
isPublic: data.isPublic || false,
|
||||||
|
image: data.image || '',
|
||||||
|
tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : [])
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch collection cards (try public first, then authenticated)
|
||||||
|
let cardsResponse = await fetch(`/api/collections/${identifier}/cards`);
|
||||||
|
|
||||||
|
if (!cardsResponse.ok && user) {
|
||||||
|
cardsResponse = await fetch(`/api/collections/${identifier}/cards`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardsResponse.ok) {
|
||||||
|
const cardsData = await cardsResponse.json();
|
||||||
|
setCards(cardsData.cards || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if collection is favorited
|
||||||
|
checkIfFavorited();
|
||||||
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
|
// Collection is private and user is not authenticated/authorized
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login');
|
||||||
|
} else {
|
||||||
|
// User is authenticated but doesn't have access
|
||||||
|
router.push('/collections');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching collection:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (identifier) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- load list when slug or auth changes
|
||||||
|
fetchCollectionData();
|
||||||
|
if (user) {
|
||||||
|
loadFavoritedCards();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug or auth changes
|
||||||
|
}, [identifier, user]);
|
||||||
|
|
||||||
|
const handleEditCollection = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${identifier}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: editForm.name,
|
||||||
|
description: editForm.description,
|
||||||
|
isPublic: editForm.isPublic,
|
||||||
|
image: editForm.image,
|
||||||
|
tags: editForm.tags
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const updatedCollection = await response.json();
|
||||||
|
|
||||||
|
// If the name changed and we got a new slug, redirect
|
||||||
|
if (updatedCollection.slug && updatedCollection.slug !== identifier) {
|
||||||
|
router.push(`/collection/${updatedCollection.slug}`);
|
||||||
|
} else {
|
||||||
|
// Just refresh the data
|
||||||
|
fetchCollectionData();
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowEditModal(false);
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Failed to update list');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating collection:', error);
|
||||||
|
alert('Network error. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCollection = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${identifier}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
router.push('/collections');
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Failed to delete list');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting collection:', error);
|
||||||
|
alert('Network error. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchCards = async (query) => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setShowSearchResults(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/cards/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSearchResults(data.cards || []);
|
||||||
|
setShowSearchResults(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching cards:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCard = async (card) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${identifier}/cards`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
cardId: card.id,
|
||||||
|
quantity: 1
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSearchCards('');
|
||||||
|
setShowSearchResults(false);
|
||||||
|
fetchCollectionData(); // Refresh the collection data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding card:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavorite = async () => {
|
||||||
|
try {
|
||||||
|
if (isFavorited) {
|
||||||
|
// Remove from favorites
|
||||||
|
const response = await fetch('/api/favorites', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemType: 'collection',
|
||||||
|
itemId: collection.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setIsFavorited(false);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to remove favorite:', response.status);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add to favorites
|
||||||
|
const response = await fetch('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemType: 'collection',
|
||||||
|
itemId: collection.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setIsFavorited(true);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to add favorite:', response.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling favorite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePublic = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${identifier}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
isPublic: !collection.isPublic
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setCollection(prev => ({
|
||||||
|
...prev,
|
||||||
|
isPublic: !prev.isPublic
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating collection:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (imageUrl) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${identifier}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
image: imageUrl
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setCollection(prev => ({
|
||||||
|
...prev,
|
||||||
|
image: imageUrl
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating collection image:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Card interaction handlers
|
||||||
|
const handleToggleSelect = (card) => {
|
||||||
|
setSelectedCards(prev => {
|
||||||
|
const isSelected = prev.some(c => c.id === card.id);
|
||||||
|
if (isSelected) {
|
||||||
|
return prev.filter(c => c.id !== card.id);
|
||||||
|
} else {
|
||||||
|
return [...prev, card];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFavorite = async (card) => {
|
||||||
|
try {
|
||||||
|
const isFavorited = favoritedCards.has(card.id);
|
||||||
|
const method = isFavorited ? 'DELETE' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch('/api/favorites', {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemType: 'card',
|
||||||
|
itemId: card.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setFavoritedCards(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (isFavorited) {
|
||||||
|
newSet.delete(card.id);
|
||||||
|
} else {
|
||||||
|
newSet.add(card.id);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling favorite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToCollection = (card) => {
|
||||||
|
// This would open a collection selection modal
|
||||||
|
console.log('Add to collection:', card);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToDeck = (card) => {
|
||||||
|
// This would open a deck selection modal
|
||||||
|
console.log('Add to deck:', card);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadCSV = () => {
|
||||||
|
downloadCollectionCardsCsv(cards, collection?.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group cards by game
|
||||||
|
const groupedCards = cards.reduce((acc, card) => {
|
||||||
|
const game = card.game || 'Other';
|
||||||
|
if (!acc[game]) acc[game] = [];
|
||||||
|
acc[game].push(card);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Get game display names and counts
|
||||||
|
const gameStats = {};
|
||||||
|
Object.keys(groupedCards).forEach(game => {
|
||||||
|
if (game && game !== 'Other') {
|
||||||
|
gameStats[game] = groupedCards[game].length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const showInitialLoading = loading;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
collection,
|
||||||
|
copySuccess,
|
||||||
|
editForm,
|
||||||
|
favoritedCards,
|
||||||
|
gameStats,
|
||||||
|
groupBy,
|
||||||
|
groupedCards,
|
||||||
|
handleAddCard,
|
||||||
|
handleAddToCollection,
|
||||||
|
handleAddToDeck,
|
||||||
|
handleDeleteCollection,
|
||||||
|
handleDownloadCSV,
|
||||||
|
handleEditCollection,
|
||||||
|
handleImageUpload,
|
||||||
|
handleSearchCards,
|
||||||
|
handleToggleFavorite,
|
||||||
|
handleToggleSelect,
|
||||||
|
id,
|
||||||
|
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,
|
||||||
|
viewMode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,463 +1,24 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- Binder/card images use external URLs; next/image migration is out of scope. */
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import UploadImageModal from '../../components/UploadImageModal';
|
|
||||||
import ShareModal from '../../components/ShareModal';
|
|
||||||
import CollaboratorFacepile from '../../components/CollaboratorFacepile';
|
|
||||||
import CardItem from '../../components/CardItem';
|
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import LoginCTA from '../../components/LoginCTA';
|
import CollectionPageView from '../../components/CollectionPageView';
|
||||||
import { ManaCost, ColorFilterSymbol } from '../../components/ManaSymbols';
|
|
||||||
import ManaSymbolSettings from '../../components/ManaSymbolSettings';
|
|
||||||
import { useAuth } from '../../lib/use-auth';
|
import { useAuth } from '../../lib/use-auth';
|
||||||
import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
|
import { useCollectionView } from '../../lib/use-collection-view.js';
|
||||||
import { downloadCollectionCardsCsv } from '../../lib/collection-cards-csv.js';
|
|
||||||
import CollectionEditModal from '../../components/CollectionEditModal';
|
|
||||||
import CollectionDeleteModal from '../../components/CollectionDeleteModal';
|
|
||||||
|
|
||||||
export default function CollectionView() {
|
export default function CollectionView() {
|
||||||
const router = useRouter();
|
|
||||||
const { identifier } = router.query;
|
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const collectionPage = useCollectionView();
|
||||||
|
|
||||||
const [collection, setCollection] = useState(null);
|
if (collectionPage.showInitialLoading) {
|
||||||
const [cards, setCards] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
|
||||||
const [showShareModal, setShowShareModal] = useState(false);
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
||||||
const [copySuccess, setCopySuccess] = useState(false);
|
|
||||||
const [selectedTCG, setSelectedTCG] = useState('MTG');
|
|
||||||
|
|
||||||
// Edit form state
|
|
||||||
const [editForm, setEditForm] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
isPublic: false,
|
|
||||||
image: '',
|
|
||||||
tags: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
|
||||||
const [selectedType, setSelectedType] = useState('All Types');
|
|
||||||
const [groupBy, setGroupBy] = useState('Group by Game');
|
|
||||||
const [sortBy, setSortBy] = useState('Sort by Name');
|
|
||||||
const [viewMode, setViewMode] = useState('grid');
|
|
||||||
const [searchCards, setSearchCards] = useState('');
|
|
||||||
const [searchResults, setSearchResults] = useState([]);
|
|
||||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
|
||||||
|
|
||||||
// Card interaction states
|
|
||||||
const [selectedCards, setSelectedCards] = useState([]);
|
|
||||||
const [favoritedCards, setFavoritedCards] = useState(new Set());
|
|
||||||
|
|
||||||
const loadFavoritedCards = async () => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const response = await fetch('/api/favorites?type=card', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const favoriteIds = new Set(data.favorites.map(fav => parseInt(fav.item_id)));
|
|
||||||
setFavoritedCards(favoriteIds);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading favorited cards:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkIfFavorited = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/favorites?type=collection`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const isFav = data.favorites.some(fav => fav.item_id === collection?.id);
|
|
||||||
setIsFavorited(isFav);
|
|
||||||
} else {
|
|
||||||
console.error('Failed to check favorites:', response.status);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking favorites:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchCollectionData = async () => {
|
|
||||||
try {
|
|
||||||
// First try to fetch without authentication (for public collections)
|
|
||||||
let response = await fetch(`/api/collections/${identifier}`);
|
|
||||||
|
|
||||||
// If that fails and we have a user, try with authentication
|
|
||||||
if (!response.ok && user) {
|
|
||||||
response = await fetch(`/api/collections/${identifier}`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Check if we accessed via numeric ID and need to redirect to slug
|
|
||||||
if (data.slug && identifier !== data.slug && !isNaN(parseInt(identifier))) {
|
|
||||||
// Redirect to slug URL
|
|
||||||
router.replace(`/collection/${data.slug}`, undefined, { shallow: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCollection(data);
|
|
||||||
setEditForm({
|
|
||||||
name: data.name || '',
|
|
||||||
description: data.description || '',
|
|
||||||
isPublic: data.isPublic || false,
|
|
||||||
image: data.image || '',
|
|
||||||
tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : [])
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch collection cards (try public first, then authenticated)
|
|
||||||
let cardsResponse = await fetch(`/api/collections/${identifier}/cards`);
|
|
||||||
|
|
||||||
if (!cardsResponse.ok && user) {
|
|
||||||
cardsResponse = await fetch(`/api/collections/${identifier}/cards`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardsResponse.ok) {
|
|
||||||
const cardsData = await cardsResponse.json();
|
|
||||||
setCards(cardsData.cards || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if collection is favorited
|
|
||||||
checkIfFavorited();
|
|
||||||
} else if (response.status === 401 || response.status === 403) {
|
|
||||||
// Collection is private and user is not authenticated/authorized
|
|
||||||
if (!user) {
|
|
||||||
router.push('/login');
|
|
||||||
} else {
|
|
||||||
// User is authenticated but doesn't have access
|
|
||||||
router.push('/collections');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching collection:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (identifier) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- load list when slug or auth changes
|
|
||||||
fetchCollectionData();
|
|
||||||
if (user) {
|
|
||||||
loadFavoritedCards();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug or auth changes
|
|
||||||
}, [identifier, user]);
|
|
||||||
|
|
||||||
const handleEditCollection = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/collections/${identifier}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: editForm.name,
|
|
||||||
description: editForm.description,
|
|
||||||
isPublic: editForm.isPublic,
|
|
||||||
image: editForm.image,
|
|
||||||
tags: editForm.tags
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const updatedCollection = await response.json();
|
|
||||||
|
|
||||||
// If the name changed and we got a new slug, redirect
|
|
||||||
if (updatedCollection.slug && updatedCollection.slug !== identifier) {
|
|
||||||
router.push(`/collection/${updatedCollection.slug}`);
|
|
||||||
} else {
|
|
||||||
// Just refresh the data
|
|
||||||
fetchCollectionData();
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowEditModal(false);
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.error || 'Failed to update list');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating collection:', error);
|
|
||||||
alert('Network error. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCollection = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/collections/${identifier}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
router.push('/collections');
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.error || 'Failed to delete list');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting collection:', error);
|
|
||||||
alert('Network error. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchCards = async (query) => {
|
|
||||||
if (query.length < 2) {
|
|
||||||
setSearchResults([]);
|
|
||||||
setShowSearchResults(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/cards/search?q=${encodeURIComponent(query)}&limit=10`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setSearchResults(data.cards || []);
|
|
||||||
setShowSearchResults(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching cards:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCard = async (card) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/collections/${identifier}/cards`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
cardId: card.id,
|
|
||||||
quantity: 1
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setSearchCards('');
|
|
||||||
setShowSearchResults(false);
|
|
||||||
fetchCollectionData(); // Refresh the collection data
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding card:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleFavorite = async () => {
|
|
||||||
try {
|
|
||||||
if (isFavorited) {
|
|
||||||
// Remove from favorites
|
|
||||||
const response = await fetch('/api/favorites', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
itemType: 'collection',
|
|
||||||
itemId: collection.id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setIsFavorited(false);
|
|
||||||
} else {
|
|
||||||
console.error('Failed to remove favorite:', response.status);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Add to favorites
|
|
||||||
const response = await fetch('/api/favorites', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
itemType: 'collection',
|
|
||||||
itemId: collection.id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setIsFavorited(true);
|
|
||||||
} else {
|
|
||||||
console.error('Failed to add favorite:', response.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling favorite:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePublic = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/collections/${identifier}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
isPublic: !collection.isPublic
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setCollection(prev => ({
|
|
||||||
...prev,
|
|
||||||
isPublic: !prev.isPublic
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating collection:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageUpload = async (imageUrl) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/collections/${identifier}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
image: imageUrl
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setCollection(prev => ({
|
|
||||||
...prev,
|
|
||||||
image: imageUrl
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating collection image:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Card interaction handlers
|
|
||||||
const handleToggleSelect = (card) => {
|
|
||||||
setSelectedCards(prev => {
|
|
||||||
const isSelected = prev.some(c => c.id === card.id);
|
|
||||||
if (isSelected) {
|
|
||||||
return prev.filter(c => c.id !== card.id);
|
|
||||||
} else {
|
|
||||||
return [...prev, card];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleFavorite = async (card) => {
|
|
||||||
try {
|
|
||||||
const isFavorited = favoritedCards.has(card.id);
|
|
||||||
const method = isFavorited ? 'DELETE' : 'POST';
|
|
||||||
|
|
||||||
const response = await fetch('/api/favorites', {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
itemType: 'card',
|
|
||||||
itemId: card.id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setFavoritedCards(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (isFavorited) {
|
|
||||||
newSet.delete(card.id);
|
|
||||||
} else {
|
|
||||||
newSet.add(card.id);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling favorite:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToCollection = (card) => {
|
|
||||||
// This would open a collection selection modal
|
|
||||||
console.log('Add to collection:', card);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToDeck = (card) => {
|
|
||||||
// This would open a deck selection modal
|
|
||||||
console.log('Add to deck:', card);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadCSV = () => {
|
|
||||||
downloadCollectionCardsCsv(cards, collection?.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group cards by game
|
|
||||||
const groupedCards = cards.reduce((acc, card) => {
|
|
||||||
const game = card.game || 'Other';
|
|
||||||
if (!acc[game]) acc[game] = [];
|
|
||||||
acc[game].push(card);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Get game display names and counts
|
|
||||||
const gameStats = {};
|
|
||||||
Object.keys(groupedCards).forEach(game => {
|
|
||||||
if (game && game !== 'Other') {
|
|
||||||
gameStats[game] = groupedCards[game].length;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show loading spinner while auth is loading or data is loading
|
|
||||||
if (authLoading || loading) {
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!collection) {
|
if (!collectionPage.collection) {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
|
@ -466,7 +27,7 @@ export default function CollectionView() {
|
||||||
List not found
|
List not found
|
||||||
</h2>
|
</h2>
|
||||||
<Link href="/collections">
|
<Link href="/collections">
|
||||||
<button className="px-4 py-2 rounded-lg text-white" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
<button type="button" className="px-4 py-2 rounded-lg text-white" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
||||||
Back to Lists
|
Back to Lists
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -478,400 +39,7 @@ export default function CollectionView() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
<CollectionPageView {...collectionPage} user={user} />
|
||||||
{/* Header */}
|
|
||||||
<div className="p-6 border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
|
|
||||||
<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-lg hover:bg-gray-50 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-lg hover:bg-red-50 hover:border-red-200 hover:text-red-600 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-lg hover:bg-gray-50"
|
|
||||||
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
Upload Image
|
|
||||||
</button>
|
|
||||||
<button className="px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50" 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 bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-sm border-2 border-blue-200">
|
|
||||||
🔒 SYSTEM
|
|
||||||
</span>
|
|
||||||
<div className="group relative">
|
|
||||||
<svg className="h-4 w-4 text-blue-500 cursor-help" 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 bg-gray-900 text-white rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap">
|
|
||||||
{VOCAB.SYSTEM_COLLECTION_SYNC_HINT}
|
|
||||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
|
||||||
</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 bg-blue-100 text-blue-800"
|
|
||||||
>
|
|
||||||
{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-lg hover:bg-gray-50"
|
|
||||||
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-lg ${
|
|
||||||
isFavorited ? 'bg-red-50 border-red-200 text-red-600' : 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
style={!isFavorited ? { 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-lg hover:bg-gray-50"
|
|
||||||
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 bg-purple-100 text-purple-800 rounded-full text-xs font-medium">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="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search cards..."
|
|
||||||
value={searchCards}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchCards(e.target.value);
|
|
||||||
handleSearchCards(e.target.value);
|
|
||||||
}}
|
|
||||||
className="w-64 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
|
||||||
/>
|
|
||||||
<svg className="absolute right-3 top-2.5 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={selectedRarity}
|
|
||||||
onChange={(e) => setSelectedRarity(e.target.value)}
|
|
||||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
|
||||||
>
|
|
||||||
<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="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
|
||||||
>
|
|
||||||
<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="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
|
||||||
>
|
|
||||||
<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="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-secondary)' }}
|
|
||||||
>
|
|
||||||
<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-lg border" style={{ borderColor: 'var(--border)' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
className={`p-2 rounded-l-lg ${viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm' : 'bg-transparent hover:bg-gray-50'}`}
|
|
||||||
style={viewMode !== 'grid' ? { color: 'var(--text-secondary)' } : {}}
|
|
||||||
>
|
|
||||||
<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-lg ${viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'bg-transparent hover:bg-gray-50'}`}
|
|
||||||
style={viewMode !== 'list' ? { color: 'var(--text-secondary)' } : {}}
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
onClick={() => router.push('/cards')}
|
|
||||||
className="px-4 py-2 text-white rounded-lg flex items-center space-x-2"
|
|
||||||
style={{ backgroundColor: 'var(--accent-ember)' }}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<span>Add Cards</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="px-4 py-2 border rounded-lg hover:bg-gray-50 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 bg-gray-100 text-gray-600 rounded text-sm font-medium">
|
|
||||||
{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 className="px-6 py-3 text-white rounded-lg" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
|
||||||
Browse Cards to Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Results Dropdown */}
|
|
||||||
{showSearchResults && searchResults.length > 0 && (
|
|
||||||
<div className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg max-h-64 overflow-y-auto z-50">
|
|
||||||
{searchResults.map(card => (
|
|
||||||
<div
|
|
||||||
key={card.id}
|
|
||||||
onClick={() => handleAddCard(card)}
|
|
||||||
className="flex items-center p-3 hover:bg-gray-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
<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">{card.name}</div>
|
|
||||||
<div className="text-sm text-gray-500">{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!" />
|
|
||||||
)}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue