PR #144 (`31da384`, 2026-06-13) shipped a `ReferenceError: useFocusTrap is not defined` to production because the flat ESLint config did NOT enable the core `no-undef` rule — only `react/jsx-no-undef` (which catches undefined JSX components, not plain JS identifier references). This PR closes that gap, narrowly. ## What changes - `eslint.config.mjs`: enable `no-undef: 'error'` for source files + define the ~40 browser / Node / Vitest globals the rule needs. Hand-curated globals list (rejected pulling in the `globals` npm package for one config block). - 3 latent bugs surfaced + fixed (NOT silenced with disables): | Site | Bug | Fix | |------|-----|-----| | `components/CollectionPageView.js:238` | `onClick={toggleFavorite}` — fn defined in `lib/use-collection-view.js:269` (collection-level favorite) but missing from the hook's `return {}` | Added to hook return + component destructure | | `components/CollectionPageView.js:532` | `onTogglePublic={togglePublic}` — same pattern, fn at line 315 of the hook | Same shape: hook return + destructure | | `components/ShareModal.js:99` | `fetchInvitedUsers()` scoped inside the useEffect body but called from `handleInvite` outside | Extracted to component scope via `useCallback`; effect dep array updated | Bugs 1 + 2 broke the "Favorite collection" button and the public-toggle in the Share modal on the collection-detail page. Bug 3 broke the "refresh invitee list" path after a successful invite. None had been flagged because the operator hadn't exercised those exact flows since the relevant hooks were last refactored. - `components/ShareModal.js`: also adds an eslint-disable for `react-hooks/set-state-in-effect` on the moved `fetchInvitedUsers()` call. Matches the canonical pattern in `pages/profile.js:90` — async fetch; setState fires post-resolve, not synchronously to the effect body. ## Why not pull in @eslint/js/recommended wholesale? The recommended bundle also enables `no-unused-vars`, `no-prototype-builtins`, `no-empty`, `no-cond-assign`, and ~10 others — each would generate dozens of pre-existing violations on this codebase. The right rule-by-rule sweep is the deferred `adopt-eslint-recommended-set` convoy. This PR is scoped to the one rule that would have caught PR #144's bug class. ## Test plan - [x] `npm run lint` — clean (1 pre-existing unrelated warning on `CollectionsPageView.js`'s `eslint-disable` directive — out of scope) - [x] `npm run test:run` — 25 files / 123 tests pass - [ ] CI on this PR - [ ] Post-merge: exercise the three formerly-broken paths (favorite a collection from its detail page; toggle a collection public via Share modal; invite a user and confirm the invitee list refreshes) ## Convoy doc `.convoys/enable-no-undef-eslint-rule.md` documents the surfaced bugs, D1 (no-undef only vs recommended bundle), D2 (hand-curated globals vs `globals` package), risks, and acceptance. Co-authored-by: Cursor <cursoragent@cursor.com>
503 lines
14 KiB
JavaScript
503 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,
|
|
toggleFavorite,
|
|
togglePublic,
|
|
viewMode
|
|
};
|
|
}
|