Extract useCollectionsPage hook and CollectionsPageView (Brief 3).
Moves list index logic into a hook and view; CollectionsThumbnail is a shared presentational component. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8e2c0e47a8
commit
2a8e674b38
4 changed files with 733 additions and 633 deletions
335
components/CollectionsPageView.js
Normal file
335
components/CollectionsPageView.js
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
|
||||
import Link from 'next/link';
|
||||
import PermissionIndicator from './PermissionIndicator';
|
||||
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
||||
import CollectionsCreateModal from './CollectionsCreateModal';
|
||||
import CollectionsEditModal from './CollectionsEditModal';
|
||||
import CollectionsSuccessModal from './CollectionsSuccessModal';
|
||||
import CollectionsThumbnail from './CollectionsThumbnail';
|
||||
|
||||
export default function CollectionsPageView(props) {
|
||||
const {
|
||||
collections,
|
||||
createdCollection,
|
||||
editTagInput,
|
||||
editingCollection,
|
||||
filteredCollections,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
handleCreateCollection,
|
||||
handleDeleteCollection,
|
||||
handleUpdateCollection,
|
||||
loading,
|
||||
newCollection,
|
||||
router,
|
||||
searchQuery,
|
||||
setCollections,
|
||||
setCreatedCollection,
|
||||
setEditTagInput,
|
||||
setEditingCollection,
|
||||
setLoading,
|
||||
setNewCollection,
|
||||
setSearchQuery,
|
||||
setShowCreateModal,
|
||||
setShowSuccessModal,
|
||||
setSortBy,
|
||||
setTagInput,
|
||||
showCreateModal,
|
||||
showSuccessModal,
|
||||
sortBy,
|
||||
sortCollections,
|
||||
sortOptions,
|
||||
sortedCollections,
|
||||
tagInput,
|
||||
user
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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)' }}>
|
||||
{VOCAB.LISTS}
|
||||
</h1>
|
||||
<p className="mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Lists 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-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
+ Create List
|
||||
</button>
|
||||
</div>
|
||||
</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 lists..."
|
||||
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 lists found' : 'No lists yet'}
|
||||
</h3>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{searchQuery
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Create your first list 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 List
|
||||
</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 */}
|
||||
<CollectionsThumbnail collection={collection} />
|
||||
|
||||
{/* Collection Info */}
|
||||
<div className="space-y-3">
|
||||
{/* Header with name and description - more space */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-semibold leading-tight" style={{ color: 'var(--text-primary)' }}>
|
||||
{collectionDisplayName(collection)}
|
||||
</h3>
|
||||
{/* System collection indicator */}
|
||||
{collection.isSystemCollection && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="px-2.5 py-1 text-xs font-bold rounded-full bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-sm border-2 border-blue-200">
|
||||
🔒 SYSTEM
|
||||
</span>
|
||||
<div className="group relative">
|
||||
<svg className="h-4 w-4 text-blue-500 cursor-help" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-xs bg-gray-900 text-white rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap">
|
||||
{VOCAB.SYSTEM_COLLECTION_SYNC_HINT}
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Edit/Delete buttons - hidden for system collections */}
|
||||
{!collection.isSystemCollection && (
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex space-x-1 ml-2 flex-shrink-0">
|
||||
<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>
|
||||
{collection.description && (
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Creator/Facepile and View Button Row */}
|
||||
<div className="flex items-center justify-between pt-2 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Creator info or facepile */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-r from-orange-400 to-pink-400 flex items-center justify-center text-xs font-bold text-white">
|
||||
{collection.creator ? collection.creator.charAt(0).toUpperCase() : 'A'}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{collection.creator ? collection.creator.split('@')[0] : 'alice'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Additional collaborators could go here as overlapping avatars */}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/collection/${collection.slug || collection.id}`);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)'
|
||||
}}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CollectionsCreateModal
|
||||
isOpen={showCreateModal}
|
||||
newCollection={newCollection}
|
||||
setNewCollection={setNewCollection}
|
||||
tagInput={tagInput}
|
||||
setTagInput={setTagInput}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
setTagInput('');
|
||||
}}
|
||||
onCreate={handleCreateCollection}
|
||||
/>
|
||||
|
||||
<CollectionsSuccessModal
|
||||
isOpen={showSuccessModal}
|
||||
createdCollection={createdCollection}
|
||||
onViewList={() => {
|
||||
setShowSuccessModal(false);
|
||||
router.push(`/collection/${createdCollection.slug || createdCollection.id}`);
|
||||
}}
|
||||
onStay={() => setShowSuccessModal(false)}
|
||||
/>
|
||||
|
||||
<CollectionsEditModal
|
||||
editingCollection={editingCollection}
|
||||
setEditingCollection={setEditingCollection}
|
||||
editTagInput={editTagInput}
|
||||
setEditTagInput={setEditTagInput}
|
||||
onClose={() => {
|
||||
setEditingCollection(null);
|
||||
setEditTagInput('');
|
||||
}}
|
||||
onUpdate={handleUpdateCollection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
components/CollectionsThumbnail.js
Normal file
119
components/CollectionsThumbnail.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/* eslint-disable @next/next/no-img-element -- Collection thumbnail images; next/image migration is out of scope. */
|
||||
export default function CollectionsThumbnail({ collection }) {
|
||||
const { thumbnails = [], image } = collection;
|
||||
|
||||
if (image) {
|
||||
return (
|
||||
<div className="w-full h-48 rounded-xl overflow-hidden mb-4 relative">
|
||||
<img src={image} alt={collection.name} className="w-full h-full object-cover" />
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
{collection.userRole === 'owner' && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 backdrop-blur-sm bg-opacity-90">
|
||||
👑 Owner
|
||||
</span>
|
||||
)}
|
||||
{collection.isPublic && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 backdrop-blur-sm bg-opacity-90">
|
||||
🌍 Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!thumbnails || thumbnails.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-48 rounded-xl mb-4 flex items-center justify-center relative"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-2">😢</div>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
No cards yet
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
{collection.userRole === 'owner' && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 backdrop-blur-sm bg-opacity-90">
|
||||
👑 Owner
|
||||
</span>
|
||||
)}
|
||||
{collection.isPublic && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 backdrop-blur-sm bg-opacity-90">
|
||||
🌍 Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mainCard = thumbnails[0];
|
||||
const gridCards = thumbnails.slice(1, 5);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-48 rounded-xl overflow-hidden mb-4 p-3 flex gap-2 relative"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<div className="flex-2 h-full">
|
||||
{mainCard ? (
|
||||
<div
|
||||
className="w-full h-full bg-white rounded-lg overflow-hidden shadow-sm border"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<img
|
||||
src={mainCard.image_url || mainCard.stock_image_url}
|
||||
alt={mainCard.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-white rounded-lg border" style={{ borderColor: 'var(--border)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 h-full">
|
||||
<div className="grid grid-cols-2 gap-2 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 bg-white rounded-md overflow-hidden shadow-sm border"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<img
|
||||
src={card.image_url || card.stock_image_url}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full bg-white rounded-md border"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
{collection.userRole === 'owner' && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 backdrop-blur-sm bg-opacity-90">
|
||||
👑 Owner
|
||||
</span>
|
||||
)}
|
||||
{collection.isPublic && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 backdrop-blur-sm bg-opacity-90">
|
||||
🌍 Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
lib/use-collections-page.js
Normal file
272
lib/use-collections-page.js
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { VOCAB, collectionDisplayName } from './collection-vocabulary.js';
|
||||
|
||||
/**
|
||||
* Card/collection page state and handlers (god-component split).
|
||||
*/
|
||||
export function useCollectionsPage({ user, authLoading }) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
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);
|
||||
const [tagInput, setTagInput] = useState(''); // For creating new collections
|
||||
const [editTagInput, setEditTagInput] = useState(''); // For editing collections
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
const fetchCollections = 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/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 collections');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- load lists when user is available
|
||||
fetchCollections();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
;
|
||||
|
||||
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: []
|
||||
});
|
||||
setTagInput(''); // Clear tag input
|
||||
|
||||
// Refresh collections list
|
||||
fetchCollections();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create list');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCollection = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${editingCollection.slug || editingCollection.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: editingCollection.name,
|
||||
description: editingCollection.description,
|
||||
isPublic: editingCollection.isPublic,
|
||||
image: editingCollection.image,
|
||||
tags: editingCollection.tags
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh collections after update
|
||||
fetchCollections();
|
||||
setEditingCollection(null);
|
||||
setEditTagInput(''); // Clear the tag input
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to update list');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating collection:', error);
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCollection = async (collectionId) => {
|
||||
if (confirm('Are you sure you want to delete this list? This action cannot be undone.')) {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${collectionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchCollections(); // Refresh the list
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to delete list');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting collection:', error);
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const showInitialLoading = authLoading || loading;
|
||||
|
||||
return {
|
||||
collections,
|
||||
createdCollection,
|
||||
editTagInput,
|
||||
editingCollection,
|
||||
filteredCollections,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
handleCreateCollection,
|
||||
handleDeleteCollection,
|
||||
handleUpdateCollection,
|
||||
loading,
|
||||
newCollection,
|
||||
router,
|
||||
searchQuery,
|
||||
setCollections,
|
||||
setCreatedCollection,
|
||||
setEditTagInput,
|
||||
setEditingCollection,
|
||||
setLoading,
|
||||
setNewCollection,
|
||||
setSearchQuery,
|
||||
setShowCreateModal,
|
||||
setShowSuccessModal,
|
||||
setSortBy,
|
||||
setTagInput,
|
||||
showCreateModal,
|
||||
showInitialLoading,
|
||||
showSuccessModal,
|
||||
sortBy,
|
||||
sortCollections,
|
||||
sortOptions,
|
||||
sortedCollections,
|
||||
tagInput
|
||||
};
|
||||
}
|
||||
|
|
@ -1,655 +1,29 @@
|
|||
/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Layout from '../components/Layout';
|
||||
import PermissionIndicator from '../components/PermissionIndicator';
|
||||
import CollectionsPageView from '../components/CollectionsPageView';
|
||||
import { useAuth } from '../lib/use-auth';
|
||||
import Link from 'next/link';
|
||||
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
||||
import CollectionsCreateModal from '../components/CollectionsCreateModal';
|
||||
import CollectionsEditModal from '../components/CollectionsEditModal';
|
||||
import CollectionsSuccessModal from '../components/CollectionsSuccessModal';
|
||||
import { useCollectionsPage } from '../lib/use-collections-page.js';
|
||||
|
||||
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 collectionsPage = useCollectionsPage({ user, authLoading });
|
||||
|
||||
const [newCollection, setNewCollection] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
image: '',
|
||||
tags: []
|
||||
});
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [createdCollection, setCreatedCollection] = useState(null);
|
||||
const [tagInput, setTagInput] = useState(''); // For creating new collections
|
||||
const [editTagInput, setEditTagInput] = useState(''); // For editing collections
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
const fetchCollections = 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/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 collections');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- load lists when user is available
|
||||
fetchCollections();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
;
|
||||
|
||||
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: []
|
||||
});
|
||||
setTagInput(''); // Clear tag input
|
||||
|
||||
// Refresh collections list
|
||||
fetchCollections();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create list');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCollection = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${editingCollection.slug || editingCollection.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: editingCollection.name,
|
||||
description: editingCollection.description,
|
||||
isPublic: editingCollection.isPublic,
|
||||
image: editingCollection.image,
|
||||
tags: editingCollection.tags
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh collections after update
|
||||
fetchCollections();
|
||||
setEditingCollection(null);
|
||||
setEditTagInput(''); // Clear the tag input
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to update list');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating collection:', error);
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCollection = async (collectionId) => {
|
||||
if (confirm('Are you sure you want to delete this list? This action cannot be undone.')) {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${collectionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchCollections(); // Refresh the list
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to delete list');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting collection:', error);
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 - Updated design with card images
|
||||
const CollectionThumbnail = ({ collection }) => {
|
||||
const { thumbnails = [], image } = collection;
|
||||
|
||||
// If there's a custom image, show it
|
||||
if (image) {
|
||||
return (
|
||||
<div className="w-full h-48 rounded-xl overflow-hidden mb-4 relative">
|
||||
<img
|
||||
src={image}
|
||||
alt={collection.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{/* Floating badges over custom image */}
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
{collection.userRole === 'owner' && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 backdrop-blur-sm bg-opacity-90">
|
||||
👑 Owner
|
||||
</span>
|
||||
)}
|
||||
{collection.isPublic && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 backdrop-blur-sm bg-opacity-90">
|
||||
🌍 Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If no cards, show crying emoji
|
||||
if (!thumbnails || thumbnails.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-48 rounded-xl mb-4 flex items-center justify-center relative" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-2">😢</div>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>No cards yet</p>
|
||||
</div>
|
||||
{/* Floating badges over empty state */}
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
{collection.userRole === 'owner' && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 backdrop-blur-sm bg-opacity-90">
|
||||
👑 Owner
|
||||
</span>
|
||||
)}
|
||||
{collection.isPublic && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 backdrop-blur-sm bg-opacity-90">
|
||||
🌍 Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mainCard = thumbnails[0];
|
||||
const gridCards = thumbnails.slice(1, 5); // Get up to 4 cards for the 2x2 grid
|
||||
|
||||
return (
|
||||
<div className="w-full h-48 rounded-xl overflow-hidden mb-4 p-3 flex gap-2 relative" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
||||
{/* Main card (larger, left side) */}
|
||||
<div className="flex-2 h-full">
|
||||
{mainCard ? (
|
||||
<div className="w-full h-full bg-white rounded-lg overflow-hidden shadow-sm border" style={{ borderColor: 'var(--border)' }}>
|
||||
<img
|
||||
src={mainCard.image_url || mainCard.stock_image_url}
|
||||
alt={mainCard.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-white rounded-lg border" style={{ borderColor: 'var(--border)' }}></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid of 4 smaller cards (right side) */}
|
||||
<div className="flex-1 h-full">
|
||||
<div className="grid grid-cols-2 gap-2 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 bg-white rounded-md overflow-hidden shadow-sm border" style={{ borderColor: 'var(--border)' }}>
|
||||
<img
|
||||
src={card.image_url || card.stock_image_url}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-white rounded-md border" style={{ borderColor: 'var(--border)' }}></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating badges over card layout */}
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
{collection.userRole === 'owner' && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 backdrop-blur-sm bg-opacity-90">
|
||||
👑 Owner
|
||||
</span>
|
||||
)}
|
||||
{collection.isPublic && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 backdrop-blur-sm bg-opacity-90">
|
||||
🌍 Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Show loading spinner while auth is loading or data is loading
|
||||
if (authLoading || loading) {
|
||||
if (collectionsPage.showInitialLoading) {
|
||||
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 className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
|
||||
</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)' }}>
|
||||
{VOCAB.LISTS}
|
||||
</h1>
|
||||
<p className="mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Lists 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-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
+ Create List
|
||||
</button>
|
||||
</div>
|
||||
</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 lists..."
|
||||
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 lists found' : 'No lists yet'}
|
||||
</h3>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{searchQuery
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Create your first list 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 List
|
||||
</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 description - more space */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-semibold leading-tight" style={{ color: 'var(--text-primary)' }}>
|
||||
{collectionDisplayName(collection)}
|
||||
</h3>
|
||||
{/* System collection indicator */}
|
||||
{collection.isSystemCollection && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="px-2.5 py-1 text-xs font-bold rounded-full bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-sm border-2 border-blue-200">
|
||||
🔒 SYSTEM
|
||||
</span>
|
||||
<div className="group relative">
|
||||
<svg className="h-4 w-4 text-blue-500 cursor-help" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-xs bg-gray-900 text-white rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap">
|
||||
{VOCAB.SYSTEM_COLLECTION_SYNC_HINT}
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Edit/Delete buttons - hidden for system collections */}
|
||||
{!collection.isSystemCollection && (
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex space-x-1 ml-2 flex-shrink-0">
|
||||
<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>
|
||||
{collection.description && (
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Creator/Facepile and View Button Row */}
|
||||
<div className="flex items-center justify-between pt-2 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Creator info or facepile */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-r from-orange-400 to-pink-400 flex items-center justify-center text-xs font-bold text-white">
|
||||
{collection.creator ? collection.creator.charAt(0).toUpperCase() : 'A'}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{collection.creator ? collection.creator.split('@')[0] : 'alice'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Additional collaborators could go here as overlapping avatars */}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/collection/${collection.slug || collection.id}`);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)'
|
||||
}}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CollectionsCreateModal
|
||||
isOpen={showCreateModal}
|
||||
newCollection={newCollection}
|
||||
setNewCollection={setNewCollection}
|
||||
tagInput={tagInput}
|
||||
setTagInput={setTagInput}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
setTagInput('');
|
||||
}}
|
||||
onCreate={handleCreateCollection}
|
||||
/>
|
||||
|
||||
<CollectionsSuccessModal
|
||||
isOpen={showSuccessModal}
|
||||
createdCollection={createdCollection}
|
||||
onViewList={() => {
|
||||
setShowSuccessModal(false);
|
||||
router.push(`/collection/${createdCollection.slug || createdCollection.id}`);
|
||||
}}
|
||||
onStay={() => setShowSuccessModal(false)}
|
||||
/>
|
||||
|
||||
<CollectionsEditModal
|
||||
editingCollection={editingCollection}
|
||||
setEditingCollection={setEditingCollection}
|
||||
editTagInput={editTagInput}
|
||||
setEditTagInput={setEditTagInput}
|
||||
onClose={() => {
|
||||
setEditingCollection(null);
|
||||
setEditTagInput('');
|
||||
}}
|
||||
onUpdate={handleUpdateCollection}
|
||||
/>
|
||||
<CollectionsPageView {...collectionsPage} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue