🌍 Separate My Collections & Community Collections
✨ 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! 🚀
This commit is contained in:
parent
f11a7fef36
commit
f7cde325ca
5 changed files with 479 additions and 19 deletions
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`;
|
||||
|
|
|
|||
74
pages/api/community/collections.js
Normal file
74
pages/api/community/collections.js
Normal file
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,28 +360,38 @@ export default function Collections() {
|
|||
<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)' }}>
|
||||
<h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
My Collections
|
||||
</h1>
|
||||
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||||
Organize and manage your card collections
|
||||
<p className="mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Collections you own, collaborate on, or have been shared with you
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/community/collections">
|
||||
<button className="px-4 py-2 rounded-xl border transition-colors hover:shadow-md"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
🌍 Discover Community
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md flex items-center space-x-2"
|
||||
className="px-6 py-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
||||
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" />
|
||||
</svg>
|
||||
<span>Create Collection</span>
|
||||
+ Create Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
|
|
|
|||
375
pages/community/collections.js
Normal file
375
pages/community/collections.js
Normal file
|
|
@ -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 (
|
||||
<div className="w-full h-48 rounded-xl overflow-hidden mb-4">
|
||||
<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="w-full h-full relative">
|
||||
<img
|
||||
src={mainCard.image_url || mainCard.stock_image_url}
|
||||
alt={mainCard.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{/* Rarity glow effect */}
|
||||
<div className={`absolute inset-0 rarity-glow-${mainCard.rarity?.toLowerCase()}`}></div>
|
||||
{/* Card name overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2">
|
||||
<p className="text-white text-xs font-medium truncate">{mainCard.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
||||
<div className="text-2xl">🃏</div>
|
||||
</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">
|
||||
{card ? (
|
||||
<div className="w-full h-full relative">
|
||||
<img
|
||||
src={card.image_url || card.stock_image_url}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
/>
|
||||
{/* Subtle rarity glow */}
|
||||
<div className={`absolute inset-0 rarity-glow-${card.rarity?.toLowerCase()} opacity-50 rounded`}></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
||||
<div className="text-lg opacity-50">🃏</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Show loading spinner while auth is loading or data is loading
|
||||
if (authLoading || 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(--accent-ember)' }}></div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated (handled by useEffect, but this is a fallback)
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
{/* Header */}
|
||||
<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" style={{ color: 'var(--text-primary)' }}>
|
||||
Community Collections
|
||||
</h1>
|
||||
<p className="mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Discover public collections shared by the community
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Sort */}
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<div className="flex-1 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search collections..."
|
||||
className="input-field w-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="input-field w-48"
|
||||
>
|
||||
{sortOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collections Grid */}
|
||||
<div className="p-6">
|
||||
{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)' }}>
|
||||
{searchQuery ? 'No collections found' : 'No public collections yet'}
|
||||
</h3>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{searchQuery
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Be the first to share a public collection with the community!'
|
||||
}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Link href="/collections">
|
||||
<button className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Go to My Collections
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{sortedCollections.map(collection => (
|
||||
<Link key={collection.id} href={`/collection/${collection.slug || collection.id}`}>
|
||||
<div className="card group cursor-pointer hover:shadow-lg transition-all duration-200">
|
||||
<CollectionThumbnail collection={collection} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg 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>
|
||||
)}
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
by {collection.creator}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 ml-2">
|
||||
<PermissionIndicator
|
||||
userRole={collection.userRole}
|
||||
isPublic={collection.isPublic}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</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, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 rounded-md text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{collection.tags.length > 2 && (
|
||||
<span
|
||||
className="px-2 py-1 rounded-md text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
+{collection.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue