refactor(collections): useCollectionsPage + view (Brief 3) #87

Merged
varutasu merged 2 commits from refactor/collections-page-split-brief-3 into main 2026-06-03 18:10:17 -04:00
4 changed files with 733 additions and 633 deletions
Showing only changes of commit 2a8e674b38 - Show all commits

View 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}
/>
</>
);
}

View 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
View 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
};
}

View file

@ -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>
);
}
}