✨ 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! 🚀
This commit is contained in:
parent
23d995102f
commit
af7593d884
4 changed files with 249 additions and 49 deletions
|
|
@ -61,7 +61,7 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
} else if (req.method === 'POST') {
|
} else if (req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const { name, description, tcg = 'MTG', visibility = 'private', tags = [] } = req.body;
|
const { name, description, tcg = 'MTG', visibility = 'private', image = '', tags = [] } = req.body;
|
||||||
|
|
||||||
if (!name || !description) {
|
if (!name || !description) {
|
||||||
return res.status(400).json({ error: 'Name and description are required' });
|
return res.status(400).json({ error: 'Name and description are required' });
|
||||||
|
|
@ -75,8 +75,8 @@ export default async function handler(req, res) {
|
||||||
const userId = 1;
|
const userId = 1;
|
||||||
|
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
INSERT INTO collections (name, description, tcg, visibility, tags, user_id)
|
INSERT INTO collections (name, description, tcg, visibility, image, tags, user_id)
|
||||||
VALUES (${name}, ${description}, ${tcg}, ${visibility}, ${tags.join(',')}, ${userId})
|
VALUES (${name}, ${description}, ${tcg}, ${visibility}, ${image}, ${tags.join(',')}, ${userId})
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,15 +114,33 @@ export default function CollectionView() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
// Simulate API call
|
fetchCollectionData();
|
||||||
setTimeout(() => {
|
|
||||||
setCollection(mockCollection);
|
|
||||||
setCards(mockCards);
|
|
||||||
setLoading(false);
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchCollectionData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCollection(data.collection);
|
||||||
|
setCards(data.cards || []);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch collection');
|
||||||
|
// Fallback to mock data for now
|
||||||
|
setCollection(mockCollection);
|
||||||
|
setCards(mockCards);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching collection:', error);
|
||||||
|
// Fallback to mock data for now
|
||||||
|
setCollection(mockCollection);
|
||||||
|
setCards(mockCards);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
|
@ -514,17 +532,86 @@ export default function CollectionView() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedCards.length === 0 && (
|
{cards.length === 0 ? (
|
||||||
|
// Empty collection state
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="text-8xl mb-6">🃏</div>
|
||||||
|
<h3 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Start Building Your Collection
|
||||||
|
</h3>
|
||||||
|
<p className="text-lg mb-8 max-w-md mx-auto" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
This collection is empty. Add your first cards to get started!
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/cards')}
|
||||||
|
className="inline-flex items-center space-x-2 px-8 py-4 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-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>Browse Cards to Add</span>
|
||||||
|
</button>
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Or search for specific cards to add to your collection
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick add section */}
|
||||||
|
<div className="mt-12 p-6 rounded-2xl max-w-md mx-auto" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||||
|
<h4 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Quick Add
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for a card to add..."
|
||||||
|
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-primary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
color: 'var(--text-primary)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="w-full px-4 py-2 rounded-lg border transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-primary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
color: 'var(--text-primary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Card
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : sortedCards.length === 0 ? (
|
||||||
|
// No results for current filters
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-6xl mb-4">🔍</div>
|
<div className="text-6xl mb-4">🔍</div>
|
||||||
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
No cards found
|
No cards match your filters
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
Try adjusting your search or filter criteria
|
Try adjusting your search or filter criteria
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedRarity('all');
|
||||||
|
setSelectedType('all');
|
||||||
|
}}
|
||||||
|
className="mt-4 px-4 py-2 rounded-lg border transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-primary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
color: 'var(--text-primary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear All Filters
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Share Modal */}
|
{/* Share Modal */}
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,33 @@ export default function Collections() {
|
||||||
const [newCollection, setNewCollection] = useState({
|
const [newCollection, setNewCollection] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
tcg: 'MTG',
|
visibility: 'private',
|
||||||
isPublic: false,
|
image: '',
|
||||||
tags: []
|
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(() => {
|
useEffect(() => {
|
||||||
setCollections(sampleCollections);
|
fetchCollections();
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tcgOptions = [
|
const tcgOptions = [
|
||||||
|
|
@ -119,19 +138,47 @@ export default function Collections() {
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const handleCreateCollection = () => {
|
const handleCreateCollection = async () => {
|
||||||
const newId = Math.max(...collections.map(c => c.id)) + 1;
|
try {
|
||||||
const collection = {
|
const response = await fetch('/api/collections', {
|
||||||
...newCollection,
|
method: 'POST',
|
||||||
id: newId,
|
headers: {
|
||||||
cardCount: 0,
|
'Content-Type': 'application/json',
|
||||||
value: 0,
|
},
|
||||||
lastViewed: new Date().toISOString().split('T')[0],
|
body: JSON.stringify({
|
||||||
createdAt: new Date().toISOString().split('T')[0]
|
name: newCollection.name,
|
||||||
};
|
description: newCollection.description,
|
||||||
setCollections([collection, ...collections]);
|
visibility: newCollection.visibility,
|
||||||
setNewCollection({ name: '', description: '', tcg: 'MTG', isPublic: false, tags: [] });
|
image: newCollection.image,
|
||||||
setShowCreateModal(false);
|
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 = () => {
|
const handleUpdateCollection = () => {
|
||||||
|
|
@ -400,32 +447,33 @@ export default function Collections() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
||||||
Trading Card Game
|
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>
|
</label>
|
||||||
<select
|
<select
|
||||||
className="input-field"
|
className="input-field"
|
||||||
value={newCollection.tcg}
|
value={newCollection.visibility}
|
||||||
onChange={(e) => setNewCollection({...newCollection, tcg: e.target.value})}
|
onChange={(e) => setNewCollection({...newCollection, visibility: e.target.value})}
|
||||||
>
|
>
|
||||||
{tcgOptions.map(option => (
|
<option value="private">🔒 Private - Only you can access</option>
|
||||||
<option key={option.value} value={option.value}>
|
<option value="invite-only">👥 Invite Only - Controlled collaboration</option>
|
||||||
{option.label}
|
<option value="public">🌍 Public - Anyone can view</option>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex space-x-3 mt-6">
|
<div className="flex space-x-3 mt-6">
|
||||||
<button
|
<button
|
||||||
|
|
@ -450,6 +498,42 @@ export default function Collections() {
|
||||||
</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 */}
|
{/* Edit Collection Modal */}
|
||||||
{editingCollection && (
|
{editingCollection && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
|
|
||||||
29
scripts/add-image-column.js
Executable file
29
scripts/add-image-column.js
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { sql } from '@vercel/postgres';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
config({ path: '.env.local' });
|
||||||
|
|
||||||
|
async function addImageColumn() {
|
||||||
|
try {
|
||||||
|
console.log('🖼️ Adding image column to collections table...\n');
|
||||||
|
|
||||||
|
// Add image column to collections table
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE collections
|
||||||
|
ADD COLUMN IF NOT EXISTS image TEXT
|
||||||
|
`;
|
||||||
|
console.log('✅ Added image column to collections table');
|
||||||
|
|
||||||
|
console.log('\n🎉 Image column added successfully!');
|
||||||
|
console.log('\n📋 Collections can now have hero images!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Migration failed:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addImageColumn();
|
||||||
Loading…
Reference in a new issue