From e9f6001066374ea280d73f8394cc58a3ddbddafc Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:10:17 -0500 Subject: [PATCH] refactor(collections): useCollectionsPage + view (Brief 3) (#87) * 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 * Default hook params for prerender safety Co-authored-by: Cursor --------- Co-authored-by: Cursor --- components/CollectionsPageView.js | 335 +++++++++++++++ components/CollectionsThumbnail.js | 119 ++++++ lib/use-collections-page.js | 272 ++++++++++++ pages/collections.js | 640 +---------------------------- 4 files changed, 733 insertions(+), 633 deletions(-) create mode 100644 components/CollectionsPageView.js create mode 100644 components/CollectionsThumbnail.js create mode 100644 lib/use-collections-page.js diff --git a/components/CollectionsPageView.js b/components/CollectionsPageView.js new file mode 100644 index 0000000..f2ff4b7 --- /dev/null +++ b/components/CollectionsPageView.js @@ -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 */} +
+
+
+

+ {VOCAB.LISTS} +

+

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

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

+ {searchQuery ? 'No lists found' : 'No lists yet'} +

+

+ {searchQuery + ? 'Try adjusting your search terms' + : 'Create your first list to get started' + } +

+ {!searchQuery && ( + + )} +
+ ) : ( +
+ {sortedCollections.map(collection => ( +
router.push(`/collection/${collection.slug || collection.id}`)} + > + {/* Thumbnail Section */} + + + {/* Collection Info */} +
+ {/* Header with name and description - more space */} +
+
+
+

+ {collectionDisplayName(collection)} +

+ {/* System collection indicator */} + {collection.isSystemCollection && ( +
+ + 🔒 SYSTEM + +
+ + + +
+ {VOCAB.SYSTEM_COLLECTION_SYNC_HINT} +
+
+
+
+ )} +
+ {/* Edit/Delete buttons - hidden for system collections */} + {!collection.isSystemCollection && ( +
+ + +
+ )} +
+ {collection.description && ( +

+ {collection.description} +

+ )} +
+ + {/* Compact Stats */} +
+
+ + {collection.cardCount} cards + + + {formatCurrency(collection.value)} + +
+ + {formatDate(collection.createdAt)} + +
+ + {/* Tags */} + {collection.tags && collection.tags.length > 0 && ( +
+ {collection.tags.slice(0, 2).map(tag => ( + + {tag} + + ))} + {collection.tags.length > 2 && ( + + +{collection.tags.length - 2} + + )} +
+ )} + + {/* Creator/Facepile and View Button Row */} +
+
+ {/* Creator info or facepile */} +
+
+ {collection.creator ? collection.creator.charAt(0).toUpperCase() : 'A'} +
+ + {collection.creator ? collection.creator.split('@')[0] : 'alice'} + +
+ {/* Additional collaborators could go here as overlapping avatars */} +
+ +
+
+
+ ))} +
+ )} +
+ + { + setShowCreateModal(false); + setTagInput(''); + }} + onCreate={handleCreateCollection} + /> + + { + setShowSuccessModal(false); + router.push(`/collection/${createdCollection.slug || createdCollection.id}`); + }} + onStay={() => setShowSuccessModal(false)} + /> + + { + setEditingCollection(null); + setEditTagInput(''); + }} + onUpdate={handleUpdateCollection} + /> + + ); +} diff --git a/components/CollectionsThumbnail.js b/components/CollectionsThumbnail.js new file mode 100644 index 0000000..3684911 --- /dev/null +++ b/components/CollectionsThumbnail.js @@ -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 ( +
+ {collection.name} +
+ {collection.userRole === 'owner' && ( + + 👑 Owner + + )} + {collection.isPublic && ( + + 🌍 Public + + )} +
+
+ ); + } + + if (!thumbnails || thumbnails.length === 0) { + return ( +
+
+
😢
+

+ No cards yet +

+
+
+ {collection.userRole === 'owner' && ( + + 👑 Owner + + )} + {collection.isPublic && ( + + 🌍 Public + + )} +
+
+ ); + } + + const mainCard = thumbnails[0]; + const gridCards = thumbnails.slice(1, 5); + + return ( +
+
+ {mainCard ? ( +
+ {mainCard.name} +
+ ) : ( +
+ )} +
+
+
+ {Array.from({ length: 4 }).map((_, index) => { + const card = gridCards[index]; + return ( +
+ {card ? ( +
+ {card.name} +
+ ) : ( +
+ )} +
+ ); + })} +
+
+
+ {collection.userRole === 'owner' && ( + + 👑 Owner + + )} + {collection.isPublic && ( + + 🌍 Public + + )} +
+
+ ); +} diff --git a/lib/use-collections-page.js b/lib/use-collections-page.js new file mode 100644 index 0000000..e2b24f1 --- /dev/null +++ b/lib/use-collections-page.js @@ -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 = null, authLoading = true } = {}) { + 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 + }; +} diff --git a/pages/collections.js b/pages/collections.js index efc8872..bc6c94e 100644 --- a/pages/collections.js +++ b/pages/collections.js @@ -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 ( -
- {collection.name} - {/* Floating badges over custom image */} -
- {collection.userRole === 'owner' && ( - - 👑 Owner - - )} - {collection.isPublic && ( - - 🌍 Public - - )} -
-
- ); - } - - // If no cards, show crying emoji - if (!thumbnails || thumbnails.length === 0) { - return ( -
-
-
😢
-

No cards yet

-
- {/* Floating badges over empty state */} -
- {collection.userRole === 'owner' && ( - - 👑 Owner - - )} - {collection.isPublic && ( - - 🌍 Public - - )} -
-
- ); - } - - const mainCard = thumbnails[0]; - const gridCards = thumbnails.slice(1, 5); // Get up to 4 cards for the 2x2 grid - - return ( -
- {/* Main card (larger, left side) */} -
- {mainCard ? ( -
- {mainCard.name} -
- ) : ( -
- )} -
- - {/* Grid of 4 smaller cards (right side) */} -
-
- {Array.from({ length: 4 }).map((_, index) => { - const card = gridCards[index]; - return ( -
- {card ? ( -
- {card.name} -
- ) : ( -
- )} -
- ); - })} -
-
- - {/* Floating badges over card layout */} -
- {collection.userRole === 'owner' && ( - - 👑 Owner - - )} - {collection.isPublic && ( - - 🌍 Public - - )} -
-
- ); - }; - - // Show loading spinner while auth is loading or data is loading - if (authLoading || loading) { + if (collectionsPage.showInitialLoading) { return (
-
+
); } - // Redirect to login if not authenticated (handled by useEffect, but this is a fallback) if (!user) { return null; } return ( - {/* Header */} -
-
-
-

- {VOCAB.LISTS} -

-

- Lists you own, collaborate on, or have been shared with you -

-
-
- - - - -
-
-
- - {/* Filters and Search */} -
-
-
- setSearchQuery(e.target.value)} - /> -
-
- -
-
-
- - {/* Collections Grid */} -
- {sortedCollections.length === 0 ? ( -
-
📦
-

- {searchQuery ? 'No lists found' : 'No lists yet'} -

-

- {searchQuery - ? 'Try adjusting your search terms' - : 'Create your first list to get started' - } -

- {!searchQuery && ( - - )} -
- ) : ( -
- {sortedCollections.map(collection => ( -
router.push(`/collection/${collection.slug || collection.id}`)} - > - {/* Thumbnail Section */} - - - {/* Collection Info */} -
- {/* Header with name and description - more space */} -
-
-
-

- {collectionDisplayName(collection)} -

- {/* System collection indicator */} - {collection.isSystemCollection && ( -
- - 🔒 SYSTEM - -
- - - -
- {VOCAB.SYSTEM_COLLECTION_SYNC_HINT} -
-
-
-
- )} -
- {/* Edit/Delete buttons - hidden for system collections */} - {!collection.isSystemCollection && ( -
- - -
- )} -
- {collection.description && ( -

- {collection.description} -

- )} -
- - {/* Compact Stats */} -
-
- - {collection.cardCount} cards - - - {formatCurrency(collection.value)} - -
- - {formatDate(collection.createdAt)} - -
- - {/* Tags */} - {collection.tags && collection.tags.length > 0 && ( -
- {collection.tags.slice(0, 2).map(tag => ( - - {tag} - - ))} - {collection.tags.length > 2 && ( - - +{collection.tags.length - 2} - - )} -
- )} - - {/* Creator/Facepile and View Button Row */} -
-
- {/* Creator info or facepile */} -
-
- {collection.creator ? collection.creator.charAt(0).toUpperCase() : 'A'} -
- - {collection.creator ? collection.creator.split('@')[0] : 'alice'} - -
- {/* Additional collaborators could go here as overlapping avatars */} -
- -
-
-
- ))} -
- )} -
- - { - setShowCreateModal(false); - setTagInput(''); - }} - onCreate={handleCreateCollection} - /> - - { - setShowSuccessModal(false); - router.push(`/collection/${createdCollection.slug || createdCollection.id}`); - }} - onStay={() => setShowSuccessModal(false)} - /> - - { - setEditingCollection(null); - setEditTagInput(''); - }} - onUpdate={handleUpdateCollection} - /> +
); -} \ No newline at end of file +}