🎨 Redesign Collections Page with Card Thumbnails
📱 Layout Improvements: - Removed TCG grouping for cleaner, unified view - Added responsive grid layout (1-4 columns based on screen size) - Implemented proper sorting options (name, value, card count, date) - Moved metadata below thumbnails for better visual hierarchy 🖼️ Beautiful Card Thumbnails: - Created CollectionThumbnail component with 2/3 + 1/3 layout - Main card (rarest) displayed prominently with rarity glow effects - Grid of 4 additional cards in smaller tiles - Card name and rarity overlays on main card - Fallback to hero image if user uploads custom thumbnail - Elegant placeholder for empty collections 🔧 Enhanced Functionality: - Smart thumbnail API fetches top 5 rarest cards by rarity priority - Rarity ordering: mythic > legendary > rare > uncommon > common - Secondary sorting by market price and name - Proper access control for collection thumbnails - Hover effects reveal edit/delete buttons 💅 Visual Polish: - Compact stats display (cards count + value + date) - Less prominent metadata positioning - Improved spacing and typography - Fire-themed color scheme throughout - Smooth hover transitions and interactions - Better mobile responsiveness 🎯 User Experience: - Intuitive sorting controls in header - Search functionality maintained - Quick access to collection actions - Visual feedback for empty states - Consistent with Deck Hearth branding The collections page now showcases beautiful card thumbnails that highlight the rarest cards in each collection! 🔥✨
This commit is contained in:
parent
a7ee884d02
commit
b95d972e95
3 changed files with 479 additions and 285 deletions
113
pages/api/collections/[id]/thumbnails.js
Normal file
113
pages/api/collections/[id]/thumbnails.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get authenticated user
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { id: collectionId } = req.query;
|
||||
|
||||
if (!collectionId) {
|
||||
return res.status(400).json({ error: 'Collection ID is required' });
|
||||
}
|
||||
|
||||
// Verify user has access to this collection
|
||||
const collectionResult = await sql`
|
||||
SELECT c.*, cp.role as user_role
|
||||
FROM collections c
|
||||
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active'
|
||||
WHERE c.id = ${collectionId}
|
||||
AND (
|
||||
c.user_id = ${user.userId} OR
|
||||
cp.id IS NOT NULL OR
|
||||
c.is_public = true
|
||||
)
|
||||
`;
|
||||
|
||||
if (collectionResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found or access denied' });
|
||||
}
|
||||
|
||||
// Define rarity priority order (highest to lowest value)
|
||||
const rarityOrder = {
|
||||
'mythic': 8,
|
||||
'legendary': 7,
|
||||
'rare': 6,
|
||||
'uncommon': 5,
|
||||
'common': 4,
|
||||
'special': 3,
|
||||
'promo': 2,
|
||||
'token': 1
|
||||
};
|
||||
|
||||
// Get the top 5 rarest cards from the collection
|
||||
const thumbnailsResult = await sql`
|
||||
SELECT DISTINCT
|
||||
cards.id,
|
||||
cards.name,
|
||||
cards.rarity,
|
||||
cards.image_url,
|
||||
cards.stock_image_url,
|
||||
cards.market_price,
|
||||
cards.game,
|
||||
cards.set_name,
|
||||
cc.quantity
|
||||
FROM collection_cards cc
|
||||
JOIN cards ON cc.card_id = cards.id
|
||||
WHERE cc.collection_id = ${collectionId}
|
||||
AND (cards.image_url IS NOT NULL OR cards.stock_image_url IS NOT NULL)
|
||||
ORDER BY
|
||||
CASE cards.rarity
|
||||
WHEN 'mythic' THEN 8
|
||||
WHEN 'legendary' THEN 7
|
||||
WHEN 'rare' THEN 6
|
||||
WHEN 'uncommon' THEN 5
|
||||
WHEN 'common' THEN 4
|
||||
WHEN 'special' THEN 3
|
||||
WHEN 'promo' THEN 2
|
||||
WHEN 'token' THEN 1
|
||||
ELSE 0
|
||||
END DESC,
|
||||
cards.market_price DESC NULLS LAST,
|
||||
cards.name ASC
|
||||
LIMIT 5
|
||||
`;
|
||||
|
||||
const thumbnails = thumbnailsResult.rows.map(card => ({
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
rarity: card.rarity,
|
||||
image_url: card.image_url,
|
||||
stock_image_url: card.stock_image_url,
|
||||
market_price: parseFloat(card.market_price) || 0,
|
||||
game: card.game,
|
||||
set_name: card.set_name,
|
||||
quantity: parseInt(card.quantity) || 1
|
||||
}));
|
||||
|
||||
res.status(200).json(thumbnails);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching collection thumbnails:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
|
@ -17,71 +17,7 @@ export default function Collections() {
|
|||
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 [sortBy, setSortBy] = useState('name'); // name, value, cardCount, createdAt
|
||||
|
||||
const [newCollection, setNewCollection] = useState({
|
||||
name: '',
|
||||
|
|
@ -98,14 +34,27 @@ export default function Collections() {
|
|||
const response = await fetch('/api/collections');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCollections(data);
|
||||
// Fetch thumbnail cards for each collection
|
||||
const collectionsWithThumbnails = await Promise.all(
|
||||
data.map(async (collection) => {
|
||||
try {
|
||||
const thumbnailResponse = await fetch(`/api/collections/${collection.id}/thumbnails`);
|
||||
const thumbnails = thumbnailResponse.ok ? await thumbnailResponse.json() : [];
|
||||
return { ...collection, thumbnails };
|
||||
} catch (error) {
|
||||
console.error(`Error fetching thumbnails for collection ${collection.id}:`, error);
|
||||
return { ...collection, thumbnails: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
setCollections(collectionsWithThumbnails);
|
||||
} else {
|
||||
console.error('Failed to fetch collections');
|
||||
setCollections(sampleCollections); // Fallback to sample data
|
||||
setCollections([]); // Empty array on error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
setCollections(sampleCollections); // Fallback to sample data
|
||||
setCollections([]); // Empty array on error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -115,28 +64,37 @@ export default function Collections() {
|
|||
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 sortOptions = [
|
||||
{ value: 'name', label: 'Name (A-Z)' },
|
||||
{ value: 'value', label: 'Value (High to Low)' },
|
||||
{ value: 'cardCount', label: 'Card Count (High to Low)' },
|
||||
{ value: 'createdAt', label: 'Date Created (Newest)' }
|
||||
];
|
||||
|
||||
const sortCollections = (collections, sortBy) => {
|
||||
return [...collections].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'value':
|
||||
return b.value - a.value;
|
||||
case 'cardCount':
|
||||
return b.cardCount - a.cardCount;
|
||||
case 'createdAt':
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const groupedCollections = filteredCollections.reduce((acc, collection) => {
|
||||
if (!acc[collection.tcg]) {
|
||||
acc[collection.tcg] = [];
|
||||
}
|
||||
acc[collection.tcg].push(collection);
|
||||
return acc;
|
||||
}, {});
|
||||
const sortedCollections = sortCollections(filteredCollections, sortBy);
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
try {
|
||||
|
|
@ -194,11 +152,6 @@ export default function Collections() {
|
|||
}
|
||||
};
|
||||
|
||||
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',
|
||||
|
|
@ -210,11 +163,100 @@ export default function Collections() {
|
|||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const CollectionThumbnail = ({ collection }) => {
|
||||
const { thumbnails = [], image } = collection;
|
||||
|
||||
// If collection has a custom hero image, use it
|
||||
if (image) {
|
||||
return (
|
||||
<div className="w-full h-48 rounded-xl overflow-hidden mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
||||
<img
|
||||
src={image}
|
||||
alt={collection.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If no thumbnails available, show placeholder
|
||||
if (!thumbnails || thumbnails.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-48 rounded-xl mb-4 flex items-center justify-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📦</div>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>No cards yet</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show main card (rarest) and grid of 4 others
|
||||
const mainCard = thumbnails[0]; // Rarest card
|
||||
const gridCards = thumbnails.slice(1, 5); // Next 4 cards
|
||||
|
||||
return (
|
||||
<div className="w-full h-48 rounded-xl overflow-hidden mb-4 flex gap-2">
|
||||
{/* Main card (rarest) - takes up 2/3 of the space */}
|
||||
<div className="flex-2 h-full relative group">
|
||||
{mainCard ? (
|
||||
<div className="relative h-full">
|
||||
<img
|
||||
src={mainCard.image_url || mainCard.stock_image_url || '/placeholder-card.png'}
|
||||
alt={mainCard.name}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
{/* Rarity glow effect */}
|
||||
<div className={`absolute inset-0 rounded-lg rarity-glow-${mainCard.rarity?.toLowerCase() || 'common'}`}></div>
|
||||
{/* Card name overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
|
||||
<p className="text-white text-xs font-medium truncate">{mainCard.name}</p>
|
||||
<p className="text-white/80 text-xs capitalize">{mainCard.rarity}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<span className="text-gray-400 text-xs">No image</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid of 4 other cards - takes up 1/3 of the space */}
|
||||
<div className="flex-1 h-full">
|
||||
<div className="grid grid-cols-2 gap-1 h-full">
|
||||
{Array.from({ length: 4 }).map((_, index) => {
|
||||
const card = gridCards[index];
|
||||
return (
|
||||
<div key={index} className="relative group">
|
||||
{card ? (
|
||||
<div className="relative h-full">
|
||||
<img
|
||||
src={card.image_url || card.stock_image_url || '/placeholder-card.png'}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
/>
|
||||
{/* Subtle rarity glow for grid cards */}
|
||||
<div className={`absolute inset-0 rounded rarity-glow-${card.rarity?.toLowerCase() || 'common'} opacity-50`}></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
|
||||
<span className="text-gray-300 text-xs">+</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
@ -223,19 +265,23 @@ export default function Collections() {
|
|||
return (
|
||||
<Layout user={user}>
|
||||
{/* Header */}
|
||||
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary-light)', borderBottom: '1px solid var(--border-light)' }}>
|
||||
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary)', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
||||
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
My Collections
|
||||
</h1>
|
||||
<p className="text-lg" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||||
Organize and manage your card collections
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="action-btn-primary flex items-center space-x-2"
|
||||
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md flex items-center space-x-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
|
|
@ -246,25 +292,24 @@ export default function Collections() {
|
|||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary-light)' }}>
|
||||
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search collections..."
|
||||
className="search-bar"
|
||||
className="input-field w-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={selectedTCG}
|
||||
onChange={(e) => setSelectedTCG(e.target.value)}
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="input-field w-48"
|
||||
>
|
||||
<option value="all">All TCGs</option>
|
||||
{tcgOptions.map(option => (
|
||||
{sortOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
|
|
@ -276,136 +321,137 @@ export default function Collections() {
|
|||
|
||||
{/* Collections Grid */}
|
||||
<div className="p-6">
|
||||
{Object.keys(groupedCollections).length === 0 ? (
|
||||
{sortedCollections.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 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{searchQuery ? 'No collections found' : 'No collections yet'}
|
||||
</h3>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Create your first collection to get started
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{searchQuery
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Create your first collection to get started'
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="action-btn-primary"
|
||||
>
|
||||
Create Collection
|
||||
</button>
|
||||
{!searchQuery && (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
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 xl:grid-cols-4 gap-6">
|
||||
{sortedCollections.map(collection => (
|
||||
<div
|
||||
key={collection.id}
|
||||
className="card hover:shadow-xl transition-all duration-300 cursor-pointer group"
|
||||
onClick={() => router.push(`/collection/${collection.id}`)}
|
||||
>
|
||||
{/* Thumbnail Section */}
|
||||
<CollectionThumbnail collection={collection} />
|
||||
|
||||
<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}
|
||||
isPublic={collection.isPublic}
|
||||
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>
|
||||
{/* Collection Info */}
|
||||
<div className="space-y-3">
|
||||
{/* Header with name and actions */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{collection.name}
|
||||
</h3>
|
||||
{collection.description && (
|
||||
<p className="text-sm mt-1 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center space-x-1 ml-2">
|
||||
<PermissionIndicator
|
||||
userRole={collection.userRole}
|
||||
isPublic={collection.isPublic}
|
||||
showTooltip={false}
|
||||
/>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex space-x-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingCollection(collection);
|
||||
}}
|
||||
className="p-1.5 rounded-lg transition-colors hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" 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={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteCollection(collection.id);
|
||||
}}
|
||||
className="p-1.5 rounded-lg transition-colors hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: '#ef4444'
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" 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>
|
||||
|
||||
{/* Compact Stats */}
|
||||
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{collection.cardCount} cards
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: 'var(--accent-ember)' }}>
|
||||
{formatCurrency(collection.value)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs">
|
||||
{formatDate(collection.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{collection.tags && collection.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{collection.tags.slice(0, 2).map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 text-xs rounded-full"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{collection.tags.length > 2 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full" style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
+{collection.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -417,28 +463,28 @@ export default function Collections() {
|
|||
{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)' }}>
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
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)' }}>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Collection Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
className="input-field w-full"
|
||||
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)' }}>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="input-field"
|
||||
className="input-field w-full"
|
||||
rows="3"
|
||||
value={newCollection.description}
|
||||
onChange={(e) => setNewCollection({...newCollection, description: e.target.value})}
|
||||
|
|
@ -446,47 +492,51 @@ export default function Collections() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
||||
Collection Image (Optional)
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Hero Image (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="input-field"
|
||||
className="input-field w-full"
|
||||
value={newCollection.image}
|
||||
onChange={(e) => setNewCollection({...newCollection, image: e.target.value})}
|
||||
placeholder="Enter image URL"
|
||||
placeholder="Enter image URL or upload later"
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Add a hero image for your collection
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Add a custom thumbnail image, or we'll use your rarest cards
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border-light)' }}>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary-light)' }}>
|
||||
Show in Community
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Public Collection
|
||||
</label>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make this collection discoverable by other users
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={newCollection.isPublic}
|
||||
onChange={(e) => setNewCollection({...newCollection, isPublic: e.target.checked})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewCollection({...newCollection, isPublic: !newCollection.isPublic})}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
newCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
newCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</button>
|
||||
</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"
|
||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-secondary-light)'
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -494,7 +544,11 @@ export default function Collections() {
|
|||
<button
|
||||
onClick={handleCreateCollection}
|
||||
disabled={!newCollection.name.trim()}
|
||||
className="flex-1 action-btn-primary disabled:opacity-50"
|
||||
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Create Collection
|
||||
</button>
|
||||
|
|
@ -508,10 +562,10 @@ export default function Collections() {
|
|||
<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)' }}>
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Collection Created!
|
||||
</h2>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Your collection "{createdCollection.name}" has been created successfully.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -520,16 +574,20 @@ export default function Collections() {
|
|||
setShowSuccessModal(false);
|
||||
router.push(`/collection/${createdCollection.id}`);
|
||||
}}
|
||||
className="w-full action-btn-primary"
|
||||
className="w-full py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
View Collection
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSuccessModal(false)}
|
||||
className="w-full py-2 px-4 rounded-2xl border transition-colors"
|
||||
className="w-full py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-secondary-light)'
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
Stay on Collections Page
|
||||
|
|
@ -543,68 +601,75 @@ export default function Collections() {
|
|||
{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)' }}>
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Edit Collection
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<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)' }}>
|
||||
Collection Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
className="input-field w-full"
|
||||
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)' }}>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="input-field"
|
||||
className="input-field w-full"
|
||||
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 className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Hero Image
|
||||
</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"
|
||||
type="url"
|
||||
className="input-field w-full"
|
||||
value={editingCollection.image || ''}
|
||||
onChange={(e) => setEditingCollection({...editingCollection, image: e.target.value})}
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
<label htmlFor="editIsPublic" className="text-sm" style={{ color: 'var(--text-primary-light)' }}>
|
||||
Make collection public
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Public Collection
|
||||
</label>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make this collection discoverable by other users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingCollection({...editingCollection, isPublic: !editingCollection.isPublic})}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
editingCollection.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editingCollection.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</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"
|
||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-secondary-light)'
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -612,7 +677,11 @@ export default function Collections() {
|
|||
<button
|
||||
onClick={handleUpdateCollection}
|
||||
disabled={!editingCollection.name.trim()}
|
||||
className="flex-1 action-btn-primary disabled:opacity-50"
|
||||
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Update Collection
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -641,3 +641,15 @@ body {
|
|||
0 0 70px rgba(244, 114, 182, 0.6),
|
||||
0 0 105px rgba(244, 114, 182, 0.5);
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.flex-2 {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
Loading…
Reference in a new issue