deckhearth/pages/collections.js
Randall Stillwell 7c9368a739 🔐 Fix Authentication Issues in Collection Pages
🐛 Fixed Authentication Problems:
- Removed hardcoded mock admin user from collection detail page
- Removed hardcoded mock user from collections page
- Created proper useAuth hook to get current authenticated user
- Added proper authentication checks and redirects

🔧 Authentication Flow Fixes:
- Collection detail page now uses actual logged-in user (Alice, Bob, etc.)
- Proper permission checks based on real user identity
- Edit/Delete buttons now show correctly based on actual ownership
- Authentication loading states handled properly

🛠️ Technical Improvements:
- Created lib/use-auth.js hook for consistent auth handling
- Added auth loading states to prevent flash of wrong content
- Proper redirects to login page when not authenticated
- Fixed token retrieval from localStorage ('auth_token')

 User Experience:
- Alice and Bob now see their own collections correctly
- Edit/Delete permissions work based on actual collection ownership
- No more authentication errors when editing owned collections
- Consistent user identity across all pages

The authentication system now works correctly with the demo users! 🎯
2025-07-26 22:23:31 -05:00

706 lines
No EOL
28 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator';
import { useAuth } from '../lib/use-auth';
export default function Collections() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [collections, setCollections] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingCollection, setEditingCollection] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name'); // name, value, cardCount, createdAt
const [newCollection, setNewCollection] = useState({
name: '',
description: '',
isPublic: false,
image: '',
tags: []
});
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [createdCollection, setCreatedCollection] = useState(null);
// Redirect to login if not authenticated
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
if (user) {
fetchCollections();
}
}, [user]);
const fetchCollections = async () => {
try {
const response = await fetch('/api/collections');
if (response.ok) {
const data = await response.json();
// Fetch thumbnail cards 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`);
const thumbnails = thumbnailResponse.ok ? await thumbnailResponse.json() : [];
return { ...collection, thumbnails };
} catch (error) {
console.error(`Error fetching thumbnails for collection ${collection.slug || collection.id}:`, error);
return { ...collection, thumbnails: [] };
}
})
);
setCollections(collectionsWithThumbnails);
} else {
console.error('Failed to fetch collections');
setCollections([]); // Empty array on error
}
} catch (error) {
console.error('Error fetching collections:', error);
setCollections([]); // Empty array on 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: '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());
return matchesSearch;
});
const sortedCollections = sortCollections(filteredCollections, sortBy);
const handleCreateCollection = async () => {
try {
const response = await fetch('/api/collections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newCollection.name,
description: newCollection.description,
isPublic: newCollection.isPublic,
image: newCollection.image,
tags: newCollection.tags
})
});
if (response.ok) {
const createdCollection = await response.json();
setCreatedCollection(createdCollection);
setShowCreateModal(false);
setShowSuccessModal(true);
// Reset form
setNewCollection({
name: '',
description: '',
isPublic: false,
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 = () => {
setCollections(collections.map(c =>
c.id === editingCollection.id ? editingCollection : c
));
setEditingCollection(null);
};
const handleDeleteCollection = (id) => {
if (confirm('Are you sure you want to delete this collection?')) {
setCollections(collections.filter(c => c.id !== id));
}
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const formatDate = (dateString) => {
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>
);
};
// 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 mb-2" style={{ color: 'var(--text-primary)' }}>
My Collections
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Organize and manage your card collections
</p>
</div>
<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"
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>
</button>
</div>
</div>
{/* Filters and Search */}
<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="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 collections yet'}
</h3>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
{searchQuery
? 'Try adjusting your search terms'
: 'Create your first collection to get started'
}
</p>
{!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="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.slug || collection.id}`)}
>
{/* Thumbnail Section */}
<CollectionThumbnail collection={collection} />
{/* 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>
))}
</div>
)}
</div>
{/* Create Collection Modal */}
{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)' }}>
Create New Collection
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Collection Name
</label>
<input
type="text"
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)' }}>
Description
</label>
<textarea
className="input-field w-full"
rows="3"
value={newCollection.description}
onChange={(e) => setNewCollection({...newCollection, description: e.target.value})}
placeholder="Describe your collection"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Hero Image (Optional)
</label>
<input
type="url"
className="input-field w-full"
value={newCollection.image}
onChange={(e) => setNewCollection({...newCollection, image: e.target.value})}
placeholder="Enter image URL or upload later"
/>
<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)' }}>
<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={() => 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'
}`}
/>
</button>
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
onClick={() => setShowCreateModal(false)}
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)'
}}
>
Cancel
</button>
<button
onClick={handleCreateCollection}
disabled={!newCollection.name.trim()}
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>
</div>
</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)' }}>
Collection Created!
</h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Your collection "{createdCollection.name}" has been created successfully.
</p>
<div className="space-y-3">
<button
onClick={() => {
setShowSuccessModal(false);
router.push(`/collection/${createdCollection.slug || createdCollection.id}`);
}}
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-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)'
}}
>
Stay on Collections Page
</button>
</div>
</div>
</div>
)}
{/* Edit Collection Modal */}
{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)' }}>
Edit Collection
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Collection Name
</label>
<input
type="text"
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)' }}>
Description
</label>
<textarea
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)' }}>
Hero Image
</label>
<input
type="url"
className="input-field w-full"
value={editingCollection.image || ''}
onChange={(e) => setEditingCollection({...editingCollection, image: e.target.value})}
placeholder="Enter image URL"
/>
</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-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)'
}}
>
Cancel
</button>
<button
onClick={handleUpdateCollection}
disabled={!editingCollection.name.trim()}
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>
</div>
</div>
</div>
)}
</Layout>
);
}