From b95d972e95dc0bc38d0f09a2ff047a248f92d768 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sat, 26 Jul 2025 21:51:58 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Redesign=20Collections=20Page=20?= =?UTF-8?q?with=20Card=20Thumbnails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 📱 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! 🔥✨ --- pages/api/collections/[id]/thumbnails.js | 113 ++++ pages/collections.js | 639 +++++++++++++---------- styles/globals.css | 12 + 3 files changed, 479 insertions(+), 285 deletions(-) create mode 100644 pages/api/collections/[id]/thumbnails.js diff --git a/pages/api/collections/[id]/thumbnails.js b/pages/api/collections/[id]/thumbnails.js new file mode 100644 index 0000000..37280e3 --- /dev/null +++ b/pages/api/collections/[id]/thumbnails.js @@ -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' }); + } +} \ No newline at end of file diff --git a/pages/collections.js b/pages/collections.js index a267757..42917f6 100644 --- a/pages/collections.js +++ b/pages/collections.js @@ -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 ( +
+ {collection.name} +
+ ); + } + + // If no thumbnails available, show placeholder + if (!thumbnails || thumbnails.length === 0) { + return ( +
+
+
📦
+

No cards yet

+
+
+ ); + } + + // 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 ( +
+ {/* Main card (rarest) - takes up 2/3 of the space */} +
+ {mainCard ? ( +
+ {mainCard.name} + {/* Rarity glow effect */} +
+ {/* Card name overlay */} +
+

{mainCard.name}

+

{mainCard.rarity}

+
+
+ ) : ( +
+ No image +
+ )} +
+ + {/* Grid of 4 other cards - takes up 1/3 of the space */} +
+
+ {Array.from({ length: 4 }).map((_, index) => { + const card = gridCards[index]; + return ( +
+ {card ? ( +
+ {card.name} + {/* Subtle rarity glow for grid cards */} +
+
+ ) : ( +
+ + +
+ )} +
+ ); + })} +
+
+
+ ); + }; + if (loading) { return (
-
+
); @@ -223,19 +265,23 @@ export default function Collections() { return ( {/* Header */} -
+
-

+

My Collections

-

+

Organize and manage your card collections

{/* Filters and Search */} -
+
setSearchQuery(e.target.value)} />
setNewCollection({...newCollection, name: e.target.value})} placeholder="Enter collection name" />
-