deckhearth/pages/collections.js
Randall Stillwell af7593d884 Enhanced Collections UI with API Integration
🎯 Collections Page Improvements:
- Removed TCG selection from creation modal
- Added image URL field for collection hero images
- Changed public checkbox to visibility dropdown (Private/Invite-Only/Public)
- Added success modal with navigation to created collection
- Integrated real API calls for creating and fetching collections
- Added Permission indicators throughout the interface

🃏 Collection Detail Page Enhancements:
- Created comprehensive empty state for new collections
- Added 'Browse Cards to Add' call-to-action button
- Included quick add search functionality
- Improved filtered results empty state with clear filters option
- Integrated API calls for real collection data
- Distinguished between empty collection vs no search results

🗄️ Database & API Updates:
- Added image column to collections table
- Updated collections API to handle image field
- Enhanced API to return proper collection data structure
- Added fallback to mock data for development

🎨 User Experience:
- Beautiful success confirmation after collection creation
- Direct navigation to newly created collection
- Clear visual distinction between different empty states
- Intuitive call-to-action buttons for collection building
- Permission badges visible on collection cards

Ready for users to create collections with images and start building their card collections! 🚀
2025-07-25 10:06:39 -05:00

620 lines
No EOL
24 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator';
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: '',
visibility: 'private',
image: '',
tags: []
});
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [createdCollection, setCreatedCollection] = useState(null);
const fetchCollections = async () => {
try {
const response = await fetch('/api/collections');
if (response.ok) {
const data = await response.json();
setCollections(data);
} else {
console.error('Failed to fetch collections');
setCollections(sampleCollections); // Fallback to sample data
}
} catch (error) {
console.error('Error fetching collections:', error);
setCollections(sampleCollections); // Fallback to sample data
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCollections();
}, []);
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 = async () => {
try {
const response = await fetch('/api/collections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newCollection.name,
description: newCollection.description,
visibility: newCollection.visibility,
image: newCollection.image,
tags: newCollection.tags
})
});
if (response.ok) {
const createdCollection = await response.json();
setCreatedCollection(createdCollection);
setShowCreateModal(false);
setShowSuccessModal(true);
// Reset form
setNewCollection({
name: '',
description: '',
visibility: 'private',
image: '',
tags: []
});
// Refresh collections list
fetchCollections();
} else {
const error = await response.json();
alert(error.error || 'Failed to create collection');
}
} catch (error) {
console.error('Error creating collection:', error);
alert('Network error. Please try again.');
}
};
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 cursor-pointer"
onClick={() => router.push(`/collection/${collection.id}`)}
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xl font-semibold" style={{ color: 'var(--text-primary-light)' }}>
{collection.name}
</h3>
<PermissionIndicator
userRole={collection.userRole}
visibility={collection.visibility}
showTooltip={false}
/>
</div>
<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)' }}>
Collection Image (Optional)
</label>
<input
type="url"
className="input-field"
value={newCollection.image}
onChange={(e) => setNewCollection({...newCollection, image: e.target.value})}
placeholder="Enter image URL"
/>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary-light)' }}>
Add a hero image for your collection
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
Visibility
</label>
<select
className="input-field"
value={newCollection.visibility}
onChange={(e) => setNewCollection({...newCollection, visibility: e.target.value})}
>
<option value="private">🔒 Private - Only you can access</option>
<option value="invite-only">👥 Invite Only - Controlled collaboration</option>
<option value="public">🌍 Public - Anyone can view</option>
</select>
</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>
)}
{/* Success Modal */}
{showSuccessModal && createdCollection && (
<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 text-center">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary-light)' }}>
Collection Created!
</h2>
<p className="mb-6" style={{ color: 'var(--text-secondary-light)' }}>
Your collection "{createdCollection.name}" has been created successfully.
</p>
<div className="space-y-3">
<button
onClick={() => {
setShowSuccessModal(false);
router.push(`/collection/${createdCollection.id}`);
}}
className="w-full action-btn-primary"
>
View Collection
</button>
<button
onClick={() => setShowSuccessModal(false)}
className="w-full py-2 px-4 rounded-2xl border transition-colors"
style={{
borderColor: 'var(--border-light)',
color: 'var(--text-secondary-light)'
}}
>
Stay on Collections Page
</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>
);
}