From f7cde325cac142337618831be969c844b7aa55d2 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sun, 27 Jul 2025 13:17:13 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=8D=20Separate=20My=20Collections=20&?= =?UTF-8?q?=20Community=20Collections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ Collection Organization Restructure: - /collections now shows only user's own collections, collaborations, and shared collections - /community/collections shows all public collections for discovery - Updated navigation to include 'Community Collections' link - Added 'Discover Community' button on My Collections page 🔧 API Changes: - Modified /api/collections to exclude public collections from other users - Created /api/community/collections for public collection discovery - Proper authentication and permission handling for both endpoints 🎯 User Experience Improvements: - Clear separation between personal and community spaces - 'My Collection' sidebar item now accurately reflects content - Community discovery is intentional and separate - Better organization matches user mental models 📱 UI Enhancements: - Updated page titles and descriptions - Added community discovery button with globe icon - Consistent styling across both collection views - Same thumbnail and layout system for both pages This properly separates personal collection management from community discovery! 🚀 --- components/Layout.js | 1 + pages/api/collections.js | 5 +- pages/api/community/collections.js | 74 ++++++ pages/collections.js | 43 ++-- pages/community/collections.js | 375 +++++++++++++++++++++++++++++ 5 files changed, 479 insertions(+), 19 deletions(-) create mode 100644 pages/api/community/collections.js create mode 100644 pages/community/collections.js diff --git a/components/Layout.js b/components/Layout.js index 507c576..ecfe5dc 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -135,6 +135,7 @@ export default function Layout({ children, user = { email: 'me@randallstillwell. { name: 'Decks', href: '/decks', icon: 'deck', active: router.pathname === '/decks', badge: '12' }, { name: 'Analytics', href: '/analytics', icon: 'analytics', active: router.pathname === '/analytics' }, { name: 'Community', href: '/community', icon: 'community', active: router.pathname === '/community' }, + { name: 'Community Collections', href: '/community/collections', icon: 'community', active: router.pathname === '/community/collections' }, { name: 'Settings', href: '/settings', icon: 'settings', active: router.pathname === '/settings' }, ...(user?.role === 'admin' ? [ { name: 'Admin Tools', href: '/admin/card-editor', icon: 'admin', active: router.pathname.startsWith('/admin'), badge: 'ADMIN' } diff --git a/pages/api/collections.js b/pages/api/collections.js index c062e11..17f026d 100644 --- a/pages/api/collections.js +++ b/pages/api/collections.js @@ -24,7 +24,7 @@ export default async function handler(req, res) { const currentUserId = user.userId; - // Get collections based on ownership, collaboration, or public visibility + // Get collections based on ownership, collaboration, or shared access (no public discovery) const result = await sql` SELECT DISTINCT c.*, @@ -44,8 +44,7 @@ export default async function handler(req, res) { LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${currentUserId} AND cp.status = 'active' WHERE c.user_id = ${currentUserId} OR - cp.id IS NOT NULL OR - (c.is_public = true) + cp.id IS NOT NULL GROUP BY c.id, u.email, cp.role ORDER BY c.updated_at DESC `; diff --git a/pages/api/community/collections.js b/pages/api/community/collections.js new file mode 100644 index 0000000..d3869f4 --- /dev/null +++ b/pages/api/community/collections.js @@ -0,0 +1,74 @@ +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 currentUserId = user.userId; + + // Get all public collections for community discovery + const result = await sql` + SELECT DISTINCT + c.*, + u.email as creator_email, + COUNT(cc.card_id) as card_count, + COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value, + cp.role as user_role, + CASE + WHEN c.user_id = ${currentUserId} THEN 'owner' + WHEN cp.role IS NOT NULL THEN cp.role + ELSE NULL + END as effective_role + FROM collections c + LEFT JOIN users u ON c.user_id = u.id + LEFT JOIN collection_cards cc ON c.id = cc.collection_id + LEFT JOIN cards ON cc.card_id = cards.id + LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${currentUserId} AND cp.status = 'active' + WHERE c.is_public = true + GROUP BY c.id, u.email, cp.role + ORDER BY c.updated_at DESC + `; + + const collections = result.rows.map(collection => ({ + id: collection.id, + slug: collection.slug, + name: collection.name, + description: collection.description, + tcg: collection.tcg || 'MTG', + cardCount: parseInt(collection.card_count) || 0, + value: parseFloat(collection.total_value) || 0, + lastViewed: collection.updated_at, + createdAt: collection.created_at, + isPublic: collection.is_public || false, + tags: collection.tags ? collection.tags.split(',') : [], + creator: collection.creator_email, + userRole: collection.effective_role + })); + + res.status(200).json(collections); + + } catch (error) { + console.error('Error fetching community collections:', 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 7d170c7..de93bdd 100644 --- a/pages/collections.js +++ b/pages/collections.js @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import PermissionIndicator from '../components/PermissionIndicator'; import { useAuth } from '../lib/use-auth'; +import Link from 'next/link'; export default function Collections() { const router = useRouter(); @@ -359,26 +360,36 @@ export default function Collections() {
-

+

My Collections

-

- Organize and manage your card collections +

+ Collections you own, collaborate on, or have been shared with you

- +
+ + + + +
diff --git a/pages/community/collections.js b/pages/community/collections.js new file mode 100644 index 0000000..5d68878 --- /dev/null +++ b/pages/community/collections.js @@ -0,0 +1,375 @@ +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import Link from 'next/link'; +import Layout from '../../components/Layout'; +import PermissionIndicator from '../../components/PermissionIndicator'; +import { useAuth } from '../../lib/use-auth'; + +export default function CommunityCollections() { + const router = useRouter(); + const { user, loading: authLoading } = useAuth(); + + const [collections, setCollections] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('name'); + + // Redirect to login if not authenticated + useEffect(() => { + if (!authLoading && !user) { + router.push('/login'); + } + }, [authLoading, user, router]); + + useEffect(() => { + if (user) { + fetchPublicCollections(); + } + }, [user]); + + const fetchPublicCollections = async () => { + try { + const token = localStorage.getItem('auth_token'); + const headers = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch('/api/community/collections', { headers }); + + if (response.ok) { + const data = await response.json(); + + // Fetch thumbnails for each collection + const collectionsWithThumbnails = await Promise.all( + data.map(async (collection) => { + try { + const identifier = collection.slug || collection.id; + const thumbnailResponse = await fetch(`/api/collections/${identifier}/thumbnails`, { headers }); + if (thumbnailResponse.ok) { + const thumbnailData = await thumbnailResponse.json(); + return { ...collection, thumbnails: thumbnailData.thumbnails }; + } + 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 public collections'); + } + } catch (error) { + console.error('Error fetching public collections:', error); + } finally { + setLoading(false); + } + }; + + 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: 'Recently Created' } + ]; + + const sortCollections = (collections, sortBy) => { + return [...collections].sort((a, b) => { + switch (sortBy) { + case 'name': + return a.name.localeCompare(b.name); + case 'value': + return (b.value || 0) - (a.value || 0); + case 'cardCount': + return (b.cardCount || 0) - (a.cardCount || 0); + 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()); + return matchesSearch; + }); + + const sortedCollections = sortCollections(filteredCollections, sortBy); + + const formatCurrency = (amount) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amount); + }; + + const formatDate = (dateString) => { + return new Date(dateString).toLocaleDateString(); + }; + + // Collection thumbnail component (same as in regular collections) + 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}

+
+
+ ) : ( +
+
🃏
+
+ )} +
+ + {/* 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 */} +
+
+ ) : ( +
+
🃏
+
+ )} +
+ ); + })} +
+
+
+ ); + }; + + // Show loading spinner while auth is loading or data is loading + if (authLoading || loading) { + return ( + +
+
+
+
+ ); + } + + // Redirect to login if not authenticated (handled by useEffect, but this is a fallback) + if (!user) { + return null; + } + + return ( + + {/* Header */} +
+
+
+

+ Community Collections +

+

+ Discover public collections shared by the community +

+
+
+ + {/* Search and Sort */} +
+
+ setSearchQuery(e.target.value)} + /> +
+
+ +
+
+
+ + {/* Collections Grid */} +
+ {sortedCollections.length === 0 ? ( +
+
🌍
+

+ {searchQuery ? 'No collections found' : 'No public collections yet'} +

+

+ {searchQuery + ? 'Try adjusting your search terms' + : 'Be the first to share a public collection with the community!' + } +

+ {!searchQuery && ( + + + + )} +
+ ) : ( +
+ {sortedCollections.map(collection => ( + +
+ + +
+
+
+

+ {collection.name} +

+ {collection.description && ( +

+ {collection.description} +

+ )} +

+ by {collection.creator} +

+
+
+ +
+
+ + {/* Compact Stats */} +
+
+ + {collection.cardCount} cards + + + {formatCurrency(collection.value)} + +
+ + {formatDate(collection.createdAt)} + +
+ + {/* Tags */} + {collection.tags && collection.tags.length > 0 && ( +
+ {collection.tags.slice(0, 2).map((tag, index) => ( + + {tag} + + ))} + {collection.tags.length > 2 && ( + + +{collection.tags.length - 2} + + )} +
+ )} +
+
+ + ))} +
+ )} +
+
+ ); +} \ No newline at end of file