* Extract useCollectionView hook and CollectionPageView (Brief 3). Completes collection detail god-component split with a thin page composer. Co-authored-by: Cursor <cursoragent@cursor.com> * Pass user/authLoading into useCollectionView; drop stray id from return Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
501 lines
14 KiB
JavaScript
501 lines
14 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/router';
|
|
import { VOCAB, collectionDisplayName } from './collection-vocabulary.js';
|
|
import { downloadCollectionCardsCsv } from './collection-cards-csv.js';
|
|
|
|
/**
|
|
* Card/collection page state and handlers (god-component split).
|
|
*/
|
|
export function useCollectionView({ user = null, authLoading = true } = {}) {
|
|
const router = useRouter();
|
|
const { identifier } = router.query;
|
|
|
|
const [collection, setCollection] = useState(null);
|
|
const [cards, setCards] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isFavorited, setIsFavorited] = useState(false);
|
|
const [showShareModal, setShowShareModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [copySuccess, setCopySuccess] = useState(false);
|
|
const [selectedTCG, setSelectedTCG] = useState('MTG');
|
|
|
|
// Edit form state
|
|
const [editForm, setEditForm] = useState({
|
|
name: '',
|
|
description: '',
|
|
isPublic: false,
|
|
image: '',
|
|
tags: []
|
|
});
|
|
|
|
// Filter states
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
|
const [selectedType, setSelectedType] = useState('All Types');
|
|
const [groupBy, setGroupBy] = useState('Group by Game');
|
|
const [sortBy, setSortBy] = useState('Sort by Name');
|
|
const [viewMode, setViewMode] = useState('grid');
|
|
const [searchCards, setSearchCards] = useState('');
|
|
const [searchResults, setSearchResults] = useState([]);
|
|
const [showSearchResults, setShowSearchResults] = useState(false);
|
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
|
|
|
// Card interaction states
|
|
const [selectedCards, setSelectedCards] = useState([]);
|
|
const [favoritedCards, setFavoritedCards] = useState(new Set());
|
|
|
|
const loadFavoritedCards = async () => {
|
|
try {
|
|
const token = localStorage.getItem('auth_token');
|
|
if (!token) return;
|
|
|
|
const response = await fetch('/api/favorites?type=card', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const favoriteIds = new Set(data.favorites.map(fav => parseInt(fav.item_id)));
|
|
setFavoritedCards(favoriteIds);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading favorited cards:', error);
|
|
}
|
|
};
|
|
|
|
const checkIfFavorited = async () => {
|
|
try {
|
|
const response = await fetch(`/api/favorites?type=collection`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const isFav = data.favorites.some(fav => fav.item_id === collection?.id);
|
|
setIsFavorited(isFav);
|
|
} else {
|
|
console.error('Failed to check favorites:', response.status);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking favorites:', error);
|
|
}
|
|
};
|
|
|
|
const fetchCollectionData = async () => {
|
|
try {
|
|
// First try to fetch without authentication (for public collections)
|
|
let response = await fetch(`/api/collections/${identifier}`);
|
|
|
|
// If that fails and we have a user, try with authentication
|
|
if (!response.ok && user) {
|
|
response = await fetch(`/api/collections/${identifier}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
}
|
|
});
|
|
}
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
// Check if we accessed via numeric ID and need to redirect to slug
|
|
if (data.slug && identifier !== data.slug && !isNaN(parseInt(identifier))) {
|
|
// Redirect to slug URL
|
|
router.replace(`/collection/${data.slug}`, undefined, { shallow: false });
|
|
return;
|
|
}
|
|
|
|
setCollection(data);
|
|
setEditForm({
|
|
name: data.name || '',
|
|
description: data.description || '',
|
|
isPublic: data.isPublic || false,
|
|
image: data.image || '',
|
|
tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : [])
|
|
});
|
|
|
|
// Fetch collection cards (try public first, then authenticated)
|
|
let cardsResponse = await fetch(`/api/collections/${identifier}/cards`);
|
|
|
|
if (!cardsResponse.ok && user) {
|
|
cardsResponse = await fetch(`/api/collections/${identifier}/cards`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
}
|
|
});
|
|
}
|
|
|
|
if (cardsResponse.ok) {
|
|
const cardsData = await cardsResponse.json();
|
|
setCards(cardsData.cards || []);
|
|
}
|
|
|
|
// Check if collection is favorited
|
|
checkIfFavorited();
|
|
} else if (response.status === 401 || response.status === 403) {
|
|
// Collection is private and user is not authenticated/authorized
|
|
if (!user) {
|
|
router.push('/login');
|
|
} else {
|
|
// User is authenticated but doesn't have access
|
|
router.push('/collections');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching collection:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (identifier) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- load list when slug or auth changes
|
|
fetchCollectionData();
|
|
if (user) {
|
|
loadFavoritedCards();
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug or auth changes
|
|
}, [identifier, user]);
|
|
|
|
const handleEditCollection = async () => {
|
|
try {
|
|
const response = await fetch(`/api/collections/${identifier}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
name: editForm.name,
|
|
description: editForm.description,
|
|
isPublic: editForm.isPublic,
|
|
image: editForm.image,
|
|
tags: editForm.tags
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const updatedCollection = await response.json();
|
|
|
|
// If the name changed and we got a new slug, redirect
|
|
if (updatedCollection.slug && updatedCollection.slug !== identifier) {
|
|
router.push(`/collection/${updatedCollection.slug}`);
|
|
} else {
|
|
// Just refresh the data
|
|
fetchCollectionData();
|
|
}
|
|
|
|
setShowEditModal(false);
|
|
} 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 () => {
|
|
try {
|
|
const response = await fetch(`/api/collections/${identifier}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
router.push('/collections');
|
|
} 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 handleSearchCards = async (query) => {
|
|
if (query.length < 2) {
|
|
setSearchResults([]);
|
|
setShowSearchResults(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/cards/search?q=${encodeURIComponent(query)}&limit=10`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setSearchResults(data.cards || []);
|
|
setShowSearchResults(true);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error searching cards:', error);
|
|
}
|
|
};
|
|
|
|
const handleAddCard = async (card) => {
|
|
try {
|
|
const response = await fetch(`/api/collections/${identifier}/cards`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
cardId: card.id,
|
|
quantity: 1
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
setSearchCards('');
|
|
setShowSearchResults(false);
|
|
fetchCollectionData(); // Refresh the collection data
|
|
}
|
|
} catch (error) {
|
|
console.error('Error adding card:', error);
|
|
}
|
|
};
|
|
|
|
const toggleFavorite = async () => {
|
|
try {
|
|
if (isFavorited) {
|
|
// Remove from favorites
|
|
const response = await fetch('/api/favorites', {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
itemType: 'collection',
|
|
itemId: collection.id
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
setIsFavorited(false);
|
|
} else {
|
|
console.error('Failed to remove favorite:', response.status);
|
|
}
|
|
} else {
|
|
// Add to favorites
|
|
const response = await fetch('/api/favorites', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
itemType: 'collection',
|
|
itemId: collection.id
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
setIsFavorited(true);
|
|
} else {
|
|
console.error('Failed to add favorite:', response.status);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling favorite:', error);
|
|
}
|
|
};
|
|
|
|
const togglePublic = async () => {
|
|
try {
|
|
const response = await fetch(`/api/collections/${identifier}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
isPublic: !collection.isPublic
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
setCollection(prev => ({
|
|
...prev,
|
|
isPublic: !prev.isPublic
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating collection:', error);
|
|
}
|
|
};
|
|
|
|
const handleImageUpload = async (imageUrl) => {
|
|
try {
|
|
const response = await fetch(`/api/collections/${identifier}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
image: imageUrl
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
setCollection(prev => ({
|
|
...prev,
|
|
image: imageUrl
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating collection image:', error);
|
|
}
|
|
};
|
|
|
|
// Card interaction handlers
|
|
const handleToggleSelect = (card) => {
|
|
setSelectedCards(prev => {
|
|
const isSelected = prev.some(c => c.id === card.id);
|
|
if (isSelected) {
|
|
return prev.filter(c => c.id !== card.id);
|
|
} else {
|
|
return [...prev, card];
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleToggleFavorite = async (card) => {
|
|
try {
|
|
const isFavorited = favoritedCards.has(card.id);
|
|
const method = isFavorited ? 'DELETE' : 'POST';
|
|
|
|
const response = await fetch('/api/favorites', {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
itemType: 'card',
|
|
itemId: card.id
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
setFavoritedCards(prev => {
|
|
const newSet = new Set(prev);
|
|
if (isFavorited) {
|
|
newSet.delete(card.id);
|
|
} else {
|
|
newSet.add(card.id);
|
|
}
|
|
return newSet;
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling favorite:', error);
|
|
}
|
|
};
|
|
|
|
const handleAddToCollection = (card) => {
|
|
// This would open a collection selection modal
|
|
console.log('Add to collection:', card);
|
|
};
|
|
|
|
const handleAddToDeck = (card) => {
|
|
// This would open a deck selection modal
|
|
console.log('Add to deck:', card);
|
|
};
|
|
|
|
const handleDownloadCSV = () => {
|
|
downloadCollectionCardsCsv(cards, collection?.name);
|
|
};
|
|
|
|
// Group cards by game
|
|
const groupedCards = cards.reduce((acc, card) => {
|
|
const game = card.game || 'Other';
|
|
if (!acc[game]) acc[game] = [];
|
|
acc[game].push(card);
|
|
return acc;
|
|
}, {});
|
|
|
|
// Get game display names and counts
|
|
const gameStats = {};
|
|
Object.keys(groupedCards).forEach(game => {
|
|
if (game && game !== 'Other') {
|
|
gameStats[game] = groupedCards[game].length;
|
|
}
|
|
});
|
|
|
|
const showInitialLoading = authLoading || loading;
|
|
|
|
return {
|
|
cards,
|
|
collection,
|
|
copySuccess,
|
|
editForm,
|
|
favoritedCards,
|
|
gameStats,
|
|
groupBy,
|
|
groupedCards,
|
|
handleAddCard,
|
|
handleAddToCollection,
|
|
handleAddToDeck,
|
|
handleDeleteCollection,
|
|
handleDownloadCSV,
|
|
handleEditCollection,
|
|
handleImageUpload,
|
|
handleSearchCards,
|
|
handleToggleFavorite,
|
|
handleToggleSelect,
|
|
identifier,
|
|
isFavorited,
|
|
loading,
|
|
router,
|
|
searchCards,
|
|
searchQuery,
|
|
searchResults,
|
|
selectedCards,
|
|
selectedRarity,
|
|
selectedTCG,
|
|
selectedType,
|
|
setCards,
|
|
setCollection,
|
|
setCopySuccess,
|
|
setEditForm,
|
|
setFavoritedCards,
|
|
setGroupBy,
|
|
setIsFavorited,
|
|
setLoading,
|
|
setSearchCards,
|
|
setSearchQuery,
|
|
setSearchResults,
|
|
setSelectedCards,
|
|
setSelectedRarity,
|
|
setSelectedTCG,
|
|
setSelectedType,
|
|
setShowDeleteModal,
|
|
setShowEditModal,
|
|
setShowSearchResults,
|
|
setShowShareModal,
|
|
setShowUploadModal,
|
|
setSortBy,
|
|
setViewMode,
|
|
showDeleteModal,
|
|
showEditModal,
|
|
showInitialLoading,
|
|
showSearchResults,
|
|
showShareModal,
|
|
showUploadModal,
|
|
sortBy,
|
|
viewMode
|
|
};
|
|
}
|