feat(admin): add batch selection + batch edit (Phase 2)
- AdminBulkSelectionToolbar: floating toolbar with batch edit action + clear selection
- BatchEditModal: form for editing allowed fields (rarity, verified, game, mana_cost, cmc, card_type, edhrec_rank, layout, released_at, reserved, reprint) with live preview
- API: POST /api/admin/cards/batch with validation, allowlist, 1000-card cap, audit logging
- Hook: selection state (Set), select-all, batch edit workflow
- Table: checkbox column, row highlighting, select-all header
- Build: all routes compile, lint clean (pre-existing only), 342 tests pass
2026-09-18 15:57:14 -04:00
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
feat(admin): add admin card catalog (Phase 1)
- New admin API: /api/admin/cards with filters (game/rarity/set/price/verified),
sorting, pagination, admin rate limiting
- New admin page: /admin/catalog with GlassSurface table, filters panel,
sortable columns, infinite scroll pagination
- New hook: useAdminCatalog for state management
- Components: AdminCatalogView, AdminCatalogFilters, AdminCatalogTable, AdminCatalogPagination
- Protected by withAdmin middleware, reuses design system tokens
2026-09-18 15:06:01 -04:00
|
|
|
|
|
|
|
|
export function useAdminCatalog() {
|
|
|
|
|
const [cards, setCards] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [selectedGame, setSelectedGame] = useState('all');
|
|
|
|
|
const [selectedRarity, setSelectedRarity] = useState('all');
|
|
|
|
|
const [selectedSet, setSelectedSet] = useState('all');
|
|
|
|
|
const [minPrice, setMinPrice] = useState('');
|
|
|
|
|
const [maxPrice, setMaxPrice] = useState('');
|
|
|
|
|
const [verifiedOnly, setVerifiedOnly] = useState(false);
|
|
|
|
|
const [sortColumn, setSortColumn] = useState('name');
|
|
|
|
|
const [sortDirection, setSortDirection] = useState('asc');
|
|
|
|
|
const [pagination, setPagination] = useState({
|
|
|
|
|
page: 1,
|
|
|
|
|
limit: 50,
|
|
|
|
|
total: 0,
|
|
|
|
|
pages: 0
|
|
|
|
|
});
|
|
|
|
|
const [filters, setFilters] = useState({
|
|
|
|
|
games: [],
|
|
|
|
|
rarities: [],
|
|
|
|
|
sets: []
|
|
|
|
|
});
|
|
|
|
|
const [hasMore, setHasMore] = useState(true);
|
|
|
|
|
const [error, setError] = useState(null);
|
|
|
|
|
const loadingMoreRef = useRef(false);
|
|
|
|
|
const hasMoreRef = useRef(true);
|
|
|
|
|
const abortControllerRef = useRef(null);
|
|
|
|
|
|
feat(admin): add batch selection + batch edit (Phase 2)
- AdminBulkSelectionToolbar: floating toolbar with batch edit action + clear selection
- BatchEditModal: form for editing allowed fields (rarity, verified, game, mana_cost, cmc, card_type, edhrec_rank, layout, released_at, reserved, reprint) with live preview
- API: POST /api/admin/cards/batch with validation, allowlist, 1000-card cap, audit logging
- Hook: selection state (Set), select-all, batch edit workflow
- Table: checkbox column, row highlighting, select-all header
- Build: all routes compile, lint clean (pre-existing only), 342 tests pass
2026-09-18 15:57:14 -04:00
|
|
|
// Batch selection state
|
|
|
|
|
const [selectedCardIds, setSelectedCardIds] = useState(new Set());
|
|
|
|
|
const [selectAllPages, setSelectAllPages] = useState(false);
|
|
|
|
|
const [batchEditModalOpen, setBatchEditModalOpen] = useState(false);
|
|
|
|
|
const [batchEditLoading, setBatchEditLoading] = useState(false);
|
|
|
|
|
const [batchEditError, setBatchEditError] = useState(null);
|
|
|
|
|
|
feat(admin): add admin card catalog (Phase 1)
- New admin API: /api/admin/cards with filters (game/rarity/set/price/verified),
sorting, pagination, admin rate limiting
- New admin page: /admin/catalog with GlassSurface table, filters panel,
sortable columns, infinite scroll pagination
- New hook: useAdminCatalog for state management
- Components: AdminCatalogView, AdminCatalogFilters, AdminCatalogTable, AdminCatalogPagination
- Protected by withAdmin middleware, reuses design system tokens
2026-09-18 15:06:01 -04:00
|
|
|
const fetchCards = async (isLoadMore = false) => {
|
|
|
|
|
if (isLoadMore && (loadingMoreRef.current || !hasMoreRef.current)) return;
|
|
|
|
|
|
|
|
|
|
let signal;
|
|
|
|
|
try {
|
|
|
|
|
if (isLoadMore) {
|
|
|
|
|
setLoadingMore(true);
|
|
|
|
|
loadingMoreRef.current = true;
|
|
|
|
|
} else {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
|
({ signal } = abortControllerRef.current);
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
query: searchQuery,
|
|
|
|
|
game: selectedGame,
|
|
|
|
|
rarity: selectedRarity,
|
|
|
|
|
set: selectedSet,
|
|
|
|
|
minPrice,
|
|
|
|
|
maxPrice,
|
|
|
|
|
page: isLoadMore ? pagination.page + 1 : 1,
|
|
|
|
|
limit: pagination.limit,
|
|
|
|
|
sort: sortColumn,
|
|
|
|
|
dir: sortDirection,
|
|
|
|
|
verifiedOnly: verifiedOnly.toString()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`/api/admin/cards?${params}`, {
|
|
|
|
|
signal
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`HTTP ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
if (signal.aborted) return;
|
|
|
|
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
const newCards = data.cards;
|
|
|
|
|
if (isLoadMore) {
|
|
|
|
|
setCards(prev => [...prev, ...newCards]);
|
|
|
|
|
} else {
|
|
|
|
|
setCards(newCards);
|
|
|
|
|
}
|
|
|
|
|
setPagination(data.pagination);
|
|
|
|
|
setFilters(data.filters);
|
|
|
|
|
setHasMore(data.pagination.hasMore);
|
|
|
|
|
hasMoreRef.current = data.pagination.hasMore;
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(data.error || 'Failed to fetch cards');
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err.name !== 'AbortError') {
|
|
|
|
|
console.error('Error fetching admin cards:', err);
|
|
|
|
|
setError(err.message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (!signal?.aborted) {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
setLoadingMore(false);
|
|
|
|
|
loadingMoreRef.current = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSearchChange = (value) => {
|
|
|
|
|
setSearchQuery(value);
|
|
|
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleGameChange = (value) => {
|
|
|
|
|
setSelectedGame(value);
|
|
|
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRarityChange = (value) => {
|
|
|
|
|
setSelectedRarity(value);
|
|
|
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSetChange = (value) => {
|
|
|
|
|
setSelectedSet(value);
|
|
|
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMinPriceChange = (value) => {
|
|
|
|
|
setMinPrice(value);
|
|
|
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMaxPriceChange = (value) => {
|
|
|
|
|
setMaxPrice(value);
|
|
|
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleVerifiedChange = (value) => {
|
|
|
|
|
setVerifiedOnly(value);
|
|
|
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSort = (column) => {
|
|
|
|
|
if (sortColumn === column) {
|
|
|
|
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
|
|
|
} else {
|
|
|
|
|
setSortColumn(column);
|
|
|
|
|
setSortDirection('asc');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLoadMore = () => {
|
|
|
|
|
if (!loadingMore && hasMore) {
|
|
|
|
|
fetchCards(true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRefresh = () => {
|
|
|
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
fetchCards(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLimitChange = (limit) => {
|
|
|
|
|
setPagination(prev => ({ ...prev, limit, page: 1 }));
|
|
|
|
|
setCards([]);
|
|
|
|
|
};
|
|
|
|
|
|
feat(admin): add batch selection + batch edit (Phase 2)
- AdminBulkSelectionToolbar: floating toolbar with batch edit action + clear selection
- BatchEditModal: form for editing allowed fields (rarity, verified, game, mana_cost, cmc, card_type, edhrec_rank, layout, released_at, reserved, reprint) with live preview
- API: POST /api/admin/cards/batch with validation, allowlist, 1000-card cap, audit logging
- Hook: selection state (Set), select-all, batch edit workflow
- Table: checkbox column, row highlighting, select-all header
- Build: all routes compile, lint clean (pre-existing only), 342 tests pass
2026-09-18 15:57:14 -04:00
|
|
|
// Selection handlers
|
|
|
|
|
const handleToggleSelect = useCallback((cardId) => {
|
|
|
|
|
setSelectedCardIds(prev => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (next.has(cardId)) {
|
|
|
|
|
next.delete(cardId);
|
|
|
|
|
} else {
|
|
|
|
|
next.add(cardId);
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleSelectAll = useCallback(() => {
|
|
|
|
|
if (selectAllPages) {
|
|
|
|
|
setSelectAllPages(false);
|
|
|
|
|
setSelectedCardIds(new Set());
|
|
|
|
|
} else {
|
|
|
|
|
// Select all cards in current view
|
|
|
|
|
const ids = new Set(cards.map(c => c.id));
|
|
|
|
|
setSelectedCardIds(ids);
|
|
|
|
|
setSelectAllPages(false); // Only current page by default
|
|
|
|
|
}
|
|
|
|
|
}, [cards, selectAllPages]);
|
|
|
|
|
|
|
|
|
|
const handleClearSelection = useCallback(() => {
|
|
|
|
|
setSelectedCardIds(new Set());
|
|
|
|
|
setSelectAllPages(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const isSelected = useCallback((cardId) => selectedCardIds.has(cardId), [selectedCardIds]);
|
|
|
|
|
const selectedCount = selectedCardIds.size;
|
|
|
|
|
|
|
|
|
|
// Batch edit handlers
|
|
|
|
|
const openBatchEditModal = useCallback(() => {
|
|
|
|
|
if (selectedCount > 0) {
|
|
|
|
|
setBatchEditModalOpen(true);
|
|
|
|
|
setBatchEditError(null);
|
|
|
|
|
}
|
|
|
|
|
}, [selectedCount]);
|
|
|
|
|
|
|
|
|
|
const closeBatchEditModal = useCallback(() => {
|
|
|
|
|
setBatchEditModalOpen(false);
|
|
|
|
|
setBatchEditError(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const executeBatchEdit = useCallback(async (updates) => {
|
|
|
|
|
if (selectedCount === 0) return;
|
|
|
|
|
setBatchEditLoading(true);
|
|
|
|
|
setBatchEditError(null);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/admin/cards/batch', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
cardIds: Array.from(selectedCardIds),
|
|
|
|
|
updates
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (!response.ok) throw new Error(data.error || 'Batch edit failed');
|
|
|
|
|
|
|
|
|
|
// Refresh cards to reflect changes
|
|
|
|
|
await fetchCards(false);
|
|
|
|
|
closeBatchEditModal();
|
|
|
|
|
handleClearSelection();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Batch edit error:', err);
|
|
|
|
|
setBatchEditError(err.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setBatchEditLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [selectedCardIds, selectedCount, fetchCards, closeBatchEditModal, handleClearSelection]);
|
|
|
|
|
|
feat(admin): add admin card catalog (Phase 1)
- New admin API: /api/admin/cards with filters (game/rarity/set/price/verified),
sorting, pagination, admin rate limiting
- New admin page: /admin/catalog with GlassSurface table, filters panel,
sortable columns, infinite scroll pagination
- New hook: useAdminCatalog for state management
- Components: AdminCatalogView, AdminCatalogFilters, AdminCatalogTable, AdminCatalogPagination
- Protected by withAdmin middleware, reuses design system tokens
2026-09-18 15:06:01 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
fetchCards(false);
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (abortControllerRef.current) {
|
|
|
|
|
abortControllerRef.current.abort();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
cards,
|
|
|
|
|
loading,
|
|
|
|
|
loadingMore,
|
|
|
|
|
searchQuery,
|
|
|
|
|
selectedGame,
|
|
|
|
|
selectedRarity,
|
|
|
|
|
selectedSet,
|
|
|
|
|
minPrice,
|
|
|
|
|
maxPrice,
|
|
|
|
|
verifiedOnly,
|
|
|
|
|
sortColumn,
|
|
|
|
|
sortDirection,
|
|
|
|
|
pagination,
|
|
|
|
|
filters,
|
|
|
|
|
hasMore,
|
|
|
|
|
error,
|
|
|
|
|
handleSearchChange,
|
|
|
|
|
handleGameChange,
|
|
|
|
|
handleRarityChange,
|
|
|
|
|
handleSetChange,
|
|
|
|
|
handleMinPriceChange,
|
|
|
|
|
handleMaxPriceChange,
|
|
|
|
|
handleVerifiedChange,
|
|
|
|
|
handleSort,
|
|
|
|
|
handleLoadMore,
|
|
|
|
|
handleRefresh,
|
|
|
|
|
handleLimitChange,
|
feat(admin): add batch selection + batch edit (Phase 2)
- AdminBulkSelectionToolbar: floating toolbar with batch edit action + clear selection
- BatchEditModal: form for editing allowed fields (rarity, verified, game, mana_cost, cmc, card_type, edhrec_rank, layout, released_at, reserved, reprint) with live preview
- API: POST /api/admin/cards/batch with validation, allowlist, 1000-card cap, audit logging
- Hook: selection state (Set), select-all, batch edit workflow
- Table: checkbox column, row highlighting, select-all header
- Build: all routes compile, lint clean (pre-existing only), 342 tests pass
2026-09-18 15:57:14 -04:00
|
|
|
// Selection
|
|
|
|
|
selectedCardIds,
|
|
|
|
|
selectAllPages,
|
|
|
|
|
selectedCount,
|
|
|
|
|
handleToggleSelect,
|
|
|
|
|
handleSelectAll,
|
|
|
|
|
handleClearSelection,
|
|
|
|
|
isSelected,
|
|
|
|
|
// Batch edit
|
|
|
|
|
batchEditModalOpen,
|
|
|
|
|
batchEditLoading,
|
|
|
|
|
batchEditError,
|
|
|
|
|
openBatchEditModal,
|
|
|
|
|
closeBatchEditModal,
|
|
|
|
|
executeBatchEdit,
|
feat(admin): add admin card catalog (Phase 1)
- New admin API: /api/admin/cards with filters (game/rarity/set/price/verified),
sorting, pagination, admin rate limiting
- New admin page: /admin/catalog with GlassSurface table, filters panel,
sortable columns, infinite scroll pagination
- New hook: useAdminCatalog for state management
- Components: AdminCatalogView, AdminCatalogFilters, AdminCatalogTable, AdminCatalogPagination
- Protected by withAdmin middleware, reuses design system tokens
2026-09-18 15:06:01 -04:00
|
|
|
setSearchQuery,
|
|
|
|
|
setSelectedGame,
|
|
|
|
|
setSelectedRarity,
|
|
|
|
|
setSelectedSet,
|
|
|
|
|
setMinPrice,
|
|
|
|
|
setMaxPrice,
|
|
|
|
|
setVerifiedOnly,
|
|
|
|
|
setCards,
|
|
|
|
|
setPagination,
|
|
|
|
|
setHasMore,
|
|
|
|
|
setFilters
|
|
|
|
|
};
|
|
|
|
|
}
|