- Redesigned card display with 2.5:3.5 aspect ratio and image-only view - Added infinite scroll to replace pagination - Implemented authentic card back placeholders for MTG, Pokemon, and Lorcana - Added rarity-based particle effects with tiered intensity (mythic/enchanted/rare/uncommon) - Enhanced hover details panel with structured card information - Fixed search functionality with debouncing and Enter key support - Improved filter system with working TCG, rarity, set, and price filters - Added favorite system for cards in both hover and detail views - Updated card detail page with comprehensive metadata and actions - Fixed API filtering with proper Vercel Postgres implementation - Added particle animations and rarity glow effects - Improved overall UX with better visual hierarchy and interactions
524 lines
No EOL
20 KiB
JavaScript
524 lines
No EOL
20 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/router';
|
|
import Layout from '../components/Layout';
|
|
|
|
export default function Collections() {
|
|
const router = useRouter();
|
|
|
|
// Mock user data for now
|
|
const user = {
|
|
email: 'me@randallstillwell.com',
|
|
role: 'user'
|
|
};
|
|
|
|
const [collections, setCollections] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [editingCollection, setEditingCollection] = useState(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedTCG, setSelectedTCG] = useState('all');
|
|
|
|
// Sample data - replace with API calls
|
|
const [sampleCollections] = useState([
|
|
{
|
|
id: 1,
|
|
name: "Modern Masters 2021",
|
|
description: "Complete set of Modern Masters 2021",
|
|
tcg: "MTG",
|
|
cardCount: 254,
|
|
value: 2847.50,
|
|
lastViewed: "2024-01-15",
|
|
createdAt: "2024-01-10",
|
|
isPublic: true,
|
|
tags: ["modern", "masters", "complete"]
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Pokemon Base Set",
|
|
description: "Original Pokemon base set collection",
|
|
tcg: "Pokemon",
|
|
cardCount: 102,
|
|
value: 1250.00,
|
|
lastViewed: "2024-01-14",
|
|
createdAt: "2024-01-05",
|
|
isPublic: false,
|
|
tags: ["base", "original", "holographic"]
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "Lorcana First Chapter",
|
|
description: "Disney Lorcana First Chapter collection",
|
|
tcg: "Lorcana",
|
|
cardCount: 204,
|
|
value: 890.00,
|
|
lastViewed: "2024-01-13",
|
|
createdAt: "2024-01-08",
|
|
isPublic: true,
|
|
tags: ["disney", "first-chapter", "enchanted"]
|
|
},
|
|
{
|
|
id: 4,
|
|
name: "Commander Staples",
|
|
description: "Essential cards for Commander format",
|
|
tcg: "MTG",
|
|
cardCount: 156,
|
|
value: 2100.00,
|
|
lastViewed: "2024-01-12",
|
|
createdAt: "2024-01-03",
|
|
isPublic: true,
|
|
tags: ["commander", "staples", "multiplayer"]
|
|
},
|
|
{
|
|
id: 5,
|
|
name: "Vintage Pokemon",
|
|
description: "Rare vintage Pokemon cards",
|
|
tcg: "Pokemon",
|
|
cardCount: 45,
|
|
value: 3500.00,
|
|
lastViewed: "2024-01-11",
|
|
createdAt: "2024-01-01",
|
|
isPublic: false,
|
|
tags: ["vintage", "rare", "holographic"]
|
|
}
|
|
]);
|
|
|
|
const [newCollection, setNewCollection] = useState({
|
|
name: '',
|
|
description: '',
|
|
tcg: 'MTG',
|
|
isPublic: false,
|
|
tags: []
|
|
});
|
|
|
|
useEffect(() => {
|
|
setCollections(sampleCollections);
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
const tcgOptions = [
|
|
{ value: 'MTG', label: 'Magic: The Gathering', color: 'purple' },
|
|
{ value: 'Pokemon', label: 'Pokemon', color: 'blue' },
|
|
{ value: 'Lorcana', label: 'Disney Lorcana', color: 'pink' },
|
|
{ value: 'YuGiOh', label: 'Yu-Gi-Oh!', color: 'yellow' },
|
|
{ value: 'Digimon', label: 'Digimon', color: 'orange' }
|
|
];
|
|
|
|
const filteredCollections = collections.filter(collection => {
|
|
const matchesSearch = collection.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
collection.description.toLowerCase().includes(searchQuery.toLowerCase());
|
|
const matchesTCG = selectedTCG === 'all' || collection.tcg === selectedTCG;
|
|
return matchesSearch && matchesTCG;
|
|
});
|
|
|
|
const groupedCollections = filteredCollections.reduce((acc, collection) => {
|
|
if (!acc[collection.tcg]) {
|
|
acc[collection.tcg] = [];
|
|
}
|
|
acc[collection.tcg].push(collection);
|
|
return acc;
|
|
}, {});
|
|
|
|
const handleCreateCollection = () => {
|
|
const newId = Math.max(...collections.map(c => c.id)) + 1;
|
|
const collection = {
|
|
...newCollection,
|
|
id: newId,
|
|
cardCount: 0,
|
|
value: 0,
|
|
lastViewed: new Date().toISOString().split('T')[0],
|
|
createdAt: new Date().toISOString().split('T')[0]
|
|
};
|
|
setCollections([collection, ...collections]);
|
|
setNewCollection({ name: '', description: '', tcg: 'MTG', isPublic: false, tags: [] });
|
|
setShowCreateModal(false);
|
|
};
|
|
|
|
const handleUpdateCollection = () => {
|
|
setCollections(collections.map(c =>
|
|
c.id === editingCollection.id ? editingCollection : c
|
|
));
|
|
setEditingCollection(null);
|
|
};
|
|
|
|
const handleDeleteCollection = (id) => {
|
|
if (confirm('Are you sure you want to delete this collection?')) {
|
|
setCollections(collections.filter(c => c.id !== id));
|
|
}
|
|
};
|
|
|
|
const getTCGColor = (tcg) => {
|
|
const tcgOption = tcgOptions.find(option => option.value === tcg);
|
|
return tcgOption ? tcgOption.color : 'gray';
|
|
};
|
|
|
|
const formatCurrency = (amount) => {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD'
|
|
}).format(amount);
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
return new Date(dateString).toLocaleDateString();
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Layout user={user}>
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--text-accent-light)' }}></div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout user={user}>
|
|
{/* Header */}
|
|
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary-light)', borderBottom: '1px solid var(--border-light)' }}>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
|
My Collections
|
|
</h1>
|
|
<p className="text-lg" style={{ color: 'var(--text-secondary-light)' }}>
|
|
Organize and manage your card collections
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="action-btn-primary flex items-center space-x-2"
|
|
>
|
|
<svg className="h-5 w-5" 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>Create Collection</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters and Search */}
|
|
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary-light)' }}>
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|
<div className="flex-1">
|
|
<input
|
|
type="text"
|
|
placeholder="Search collections..."
|
|
className="search-bar"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<select
|
|
value={selectedTCG}
|
|
onChange={(e) => setSelectedTCG(e.target.value)}
|
|
className="input-field w-48"
|
|
>
|
|
<option value="all">All TCGs</option>
|
|
{tcgOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collections Grid */}
|
|
<div className="p-6">
|
|
{Object.keys(groupedCollections).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-light)' }}>
|
|
No collections found
|
|
</h3>
|
|
<p className="mb-6" style={{ color: 'var(--text-secondary-light)' }}>
|
|
Create your first collection to get started
|
|
</p>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="action-btn-primary"
|
|
>
|
|
Create Collection
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-8">
|
|
{Object.entries(groupedCollections).map(([tcg, collections]) => (
|
|
<div key={tcg} className="space-y-4">
|
|
<div className="flex items-center space-x-3">
|
|
<div
|
|
className={`w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold text-sm bg-${getTCGColor(tcg)}-500`}
|
|
>
|
|
{tcg}
|
|
</div>
|
|
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary-light)' }}>
|
|
{tcgOptions.find(opt => opt.value === tcg)?.label || tcg}
|
|
</h2>
|
|
<span className="text-sm px-3 py-1 rounded-full" style={{
|
|
backgroundColor: 'var(--bg-tertiary-light)',
|
|
color: 'var(--text-secondary-light)'
|
|
}}>
|
|
{collections.length} collection{collections.length !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{collections.map(collection => (
|
|
<div key={collection.id} className="card hover:shadow-xl transition-all duration-300">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div className="flex-1">
|
|
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
|
{collection.name}
|
|
</h3>
|
|
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary-light)' }}>
|
|
{collection.description}
|
|
</p>
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={() => setEditingCollection(collection)}
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary-light)',
|
|
color: 'var(--text-secondary-light)'
|
|
}}
|
|
>
|
|
<svg className="h-4 w-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>
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteCollection(collection.id)}
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary-light)',
|
|
color: '#ef4444'
|
|
}}
|
|
>
|
|
<svg className="h-4 w-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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
<div className="text-center p-3 rounded-lg" style={{ backgroundColor: 'var(--bg-tertiary-light)' }}>
|
|
<div className="text-2xl font-bold gradient-text-blue">{collection.cardCount}</div>
|
|
<div className="text-xs" style={{ color: 'var(--text-secondary-light)' }}>Cards</div>
|
|
</div>
|
|
<div className="text-center p-3 rounded-lg" style={{ backgroundColor: 'var(--bg-tertiary-light)' }}>
|
|
<div className="text-2xl font-bold gradient-text-purple">{formatCurrency(collection.value)}</div>
|
|
<div className="text-xs" style={{ color: 'var(--text-secondary-light)' }}>Value</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-xs" style={{ color: 'var(--text-secondary-light)' }}>
|
|
<span>Created {formatDate(collection.createdAt)}</span>
|
|
<span>Viewed {formatDate(collection.lastViewed)}</span>
|
|
</div>
|
|
|
|
{collection.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-3">
|
|
{collection.tags.slice(0, 3).map(tag => (
|
|
<span
|
|
key={tag}
|
|
className="px-2 py-1 text-xs rounded-full"
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary-light)',
|
|
color: 'var(--text-secondary-light)'
|
|
}}
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{collection.tags.length > 3 && (
|
|
<span className="px-2 py-1 text-xs rounded-full" style={{
|
|
backgroundColor: 'var(--bg-tertiary-light)',
|
|
color: 'var(--text-secondary-light)'
|
|
}}>
|
|
+{collection.tags.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create Collection Modal */}
|
|
{showCreateModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="card max-w-md w-full mx-4">
|
|
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary-light)' }}>
|
|
Create New Collection
|
|
</h2>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
|
Collection Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="input-field"
|
|
value={newCollection.name}
|
|
onChange={(e) => setNewCollection({...newCollection, name: e.target.value})}
|
|
placeholder="Enter collection name"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
|
Description
|
|
</label>
|
|
<textarea
|
|
className="input-field"
|
|
rows="3"
|
|
value={newCollection.description}
|
|
onChange={(e) => setNewCollection({...newCollection, description: e.target.value})}
|
|
placeholder="Describe your collection"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
|
Trading Card Game
|
|
</label>
|
|
<select
|
|
className="input-field"
|
|
value={newCollection.tcg}
|
|
onChange={(e) => setNewCollection({...newCollection, tcg: e.target.value})}
|
|
>
|
|
{tcgOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
id="isPublic"
|
|
checked={newCollection.isPublic}
|
|
onChange={(e) => setNewCollection({...newCollection, isPublic: e.target.checked})}
|
|
className="mr-2"
|
|
/>
|
|
<label htmlFor="isPublic" className="text-sm" style={{ color: 'var(--text-primary-light)' }}>
|
|
Make collection public
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="flex space-x-3 mt-6">
|
|
<button
|
|
onClick={() => setShowCreateModal(false)}
|
|
className="flex-1 py-2 px-4 rounded-2xl border transition-colors"
|
|
style={{
|
|
borderColor: 'var(--border-light)',
|
|
color: 'var(--text-secondary-light)'
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleCreateCollection}
|
|
disabled={!newCollection.name.trim()}
|
|
className="flex-1 action-btn-primary disabled:opacity-50"
|
|
>
|
|
Create Collection
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Collection Modal */}
|
|
{editingCollection && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="card max-w-md w-full mx-4">
|
|
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary-light)' }}>
|
|
Edit Collection
|
|
</h2>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
|
Collection Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="input-field"
|
|
value={editingCollection.name}
|
|
onChange={(e) => setEditingCollection({...editingCollection, name: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
|
Description
|
|
</label>
|
|
<textarea
|
|
className="input-field"
|
|
rows="3"
|
|
value={editingCollection.description}
|
|
onChange={(e) => setEditingCollection({...editingCollection, description: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
|
Trading Card Game
|
|
</label>
|
|
<select
|
|
className="input-field"
|
|
value={editingCollection.tcg}
|
|
onChange={(e) => setEditingCollection({...editingCollection, tcg: e.target.value})}
|
|
>
|
|
{tcgOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
id="editIsPublic"
|
|
checked={editingCollection.isPublic}
|
|
onChange={(e) => setEditingCollection({...editingCollection, isPublic: e.target.checked})}
|
|
className="mr-2"
|
|
/>
|
|
<label htmlFor="editIsPublic" className="text-sm" style={{ color: 'var(--text-primary-light)' }}>
|
|
Make collection public
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="flex space-x-3 mt-6">
|
|
<button
|
|
onClick={() => setEditingCollection(null)}
|
|
className="flex-1 py-2 px-4 rounded-2xl border transition-colors"
|
|
style={{
|
|
borderColor: 'var(--border-light)',
|
|
color: 'var(--text-secondary-light)'
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleUpdateCollection}
|
|
disabled={!editingCollection.name.trim()}
|
|
className="flex-1 action-btn-primary disabled:opacity-50"
|
|
>
|
|
Update Collection
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Layout>
|
|
);
|
|
}
|