From 79f52b456c72403b2732c3bdb4fdc4d831ee6237 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 2 Jun 2026 00:45:28 -0500 Subject: [PATCH] fix(lint): clear ESLint baseline in components/ Resolve react-hooks purity, immutability, refs, and set-state-in-effect violations without behavior changes; align img usage with pages/ disable pattern for external URLs. Co-authored-by: Cursor --- components/AdminProtected.js | 10 ++-- components/AnimatedFireLogo.js | 1 + components/AuthLayout.js | 44 +++++++++++----- components/CameraScanner.js | 47 ++++++++++++++--- components/CardItem.js | 1 + components/CollaboratorFacepile.js | 12 ++--- components/CollectionSelectionModal.js | 54 +++++++++++-------- components/Layout.js | 14 +++-- components/ManaSymbolSettings.js | 18 ++++--- components/ManaSymbols.js | 1 + components/ScannedCardItem.js | 9 ++-- components/ShareModal.js | 72 +++++++++++++------------- components/UploadImageModal.js | 1 + 13 files changed, 178 insertions(+), 106 deletions(-) diff --git a/components/AdminProtected.js b/components/AdminProtected.js index ea0296c..17f4928 100644 --- a/components/AdminProtected.js +++ b/components/AdminProtected.js @@ -9,10 +9,7 @@ export default function AdminProtected({ children }) { const [accessDenied, setAccessDenied] = useState(false); useEffect(() => { - checkAdminAccess(); - }, []); - - const checkAdminAccess = async () => { + const checkAdminAccess = async () => { try { // Get token from localStorage const token = localStorage.getItem('auth_token'); @@ -48,7 +45,10 @@ export default function AdminProtected({ children }) { } finally { setLoading(false); } - }; + }; + + checkAdminAccess(); + }, []); if (loading) { return ( diff --git a/components/AnimatedFireLogo.js b/components/AnimatedFireLogo.js index 9833b4b..4d2a6fb 100644 --- a/components/AnimatedFireLogo.js +++ b/components/AnimatedFireLogo.js @@ -1,3 +1,4 @@ +/* eslint-disable @next/next/no-img-element -- Static brand logo URL; next/image migration is out of scope. */ import { useTheme } from '../lib/theme-context'; export default function AnimatedFireLogo({ size = 100 }) { diff --git a/components/AuthLayout.js b/components/AuthLayout.js index d325716..7caea10 100644 --- a/components/AuthLayout.js +++ b/components/AuthLayout.js @@ -1,7 +1,31 @@ +import { useMemo } from 'react'; import { useTheme } from '../lib/theme-context'; +function buildEmberParticles(theme) { + return Array.from({ length: 12 }, (_, i) => { + const warmChannel = 100 + Math.random() * 100; + const lightWarmChannel = 100 + Math.random() * 46; + return { + key: i, + left: `${Math.random() * 100}%`, + top: `${Math.random() * 100}%`, + animationDelay: `${Math.random() * 8}s`, + animationDuration: `${8 + Math.random() * 4}s`, + backgroundColor: + theme === 'dark' + ? `rgba(255, ${warmChannel}, 0, 0.6)` + : `rgba(251, ${lightWarmChannel}, 60, 0.4)`, + boxShadow: + theme === 'dark' + ? `0 0 8px rgba(255, ${warmChannel}, 0, 0.4)` + : `0 0 6px rgba(251, ${lightWarmChannel}, 60, 0.3)`, + }; + }); +} + export default function AuthLayout({ children }) { const { theme } = useTheme(); + const embers = useMemo(() => buildEmberParticles(theme), [theme]); return (
@@ -27,26 +51,22 @@ export default function AuthLayout({ children }) { {/* Floating Embers */}
- {Array.from({ length: 12 }).map((_, i) => ( + {embers.map((ember) => (
diff --git a/components/CameraScanner.js b/components/CameraScanner.js index 4016817..77229ea 100644 --- a/components/CameraScanner.js +++ b/components/CameraScanner.js @@ -1,6 +1,11 @@ +/* eslint-disable @next/next/no-img-element -- External card image URLs in disambiguation UI; next/image migration is out of scope. */ import { useState, useEffect, useRef } from 'react'; import { useFocusTrap } from '../lib/use-focus-trap.js'; +function rateLimitCooldownUntil(ms) { + return Date.now() + ms; +} + async function uploadScanCapture(imageData) { const response = await fetch('/api/scan/upload-image', { method: 'POST', @@ -51,6 +56,7 @@ export default function CameraScanner({ onCardScanned, onError }) { // Mana symbol settings const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); + const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 }); // Configure canvas contexts for optimal performance useEffect(() => { @@ -167,7 +173,6 @@ export default function CameraScanner({ onCardScanned, onError }) { score, aspectRatio, edgeDensity, - timestamp: Date.now() }); } } @@ -356,7 +361,7 @@ export default function CameraScanner({ onCardScanned, onError }) { }); if (response.status === 429) { - visionCooldownUntilRef.current = Date.now() + 60_000; + visionCooldownUntilRef.current = rateLimitCooldownUntil(60_000); reportScannerError('Too many scan attempts. Please wait a moment and try again.'); return; } @@ -558,6 +563,7 @@ export default function CameraScanner({ onCardScanned, onError }) { return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- refine runs per disambiguation session, not per handler identity }, [disambiguation?.cardTracker?.id, disambiguation?.imageData]); // Server-side card identification @@ -781,14 +787,38 @@ export default function CameraScanner({ onCardScanned, onError }) { } }; + useEffect(() => { + const video = videoRef.current; + if (!video || !isStreaming) return undefined; + + const syncVideoMetrics = () => { + setVideoMetrics({ + width: video.videoWidth || 0, + height: video.videoHeight || 0, + }); + }; + + video.addEventListener('loadedmetadata', syncVideoMetrics); + video.addEventListener('resize', syncVideoMetrics); + syncVideoMetrics(); + + return () => { + video.removeEventListener('loadedmetadata', syncVideoMetrics); + video.removeEventListener('resize', syncVideoMetrics); + }; + }, [isStreaming]); + // Auto-start detection when camera starts useEffect(() => { if (isStreaming && !isDetecting) { // Small delay to let camera stabilize - setTimeout(() => { + const timerId = setTimeout(() => { startDetection(); }, 1000); + return () => clearTimeout(timerId); } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: start detection once per stream session }, [isStreaming]); // Cleanup on unmount @@ -796,6 +826,7 @@ export default function CameraScanner({ onCardScanned, onError }) { return () => { stopCamera(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount }, []); return ( @@ -832,17 +863,17 @@ export default function CameraScanner({ onCardScanned, onError }) { /> {/* Card Detection Overlays - Only when streaming */} - {isStreaming && trackedCards + {isStreaming && videoMetrics.width > 0 && videoMetrics.height > 0 && trackedCards .filter(card => card.status === 'confirmed' || card.status === 'scanned') .map(card => (
{ - if (collectionId) { - fetchCollaborators(); - } - }, [collectionId]); + if (!collectionId) return; - const fetchCollaborators = async () => { + const fetchCollaborators = async () => { try { const response = await fetch(`/api/collections/${collectionId}/permissions`, { headers: { @@ -31,7 +28,10 @@ export default function CollaboratorFacepile({ collectionId, creatorEmail }) { } finally { setLoading(false); } - }; + }; + + fetchCollaborators(); + }, [collectionId]); if (loading) { return ( diff --git a/components/CollectionSelectionModal.js b/components/CollectionSelectionModal.js index b90c967..4a13ae2 100644 --- a/components/CollectionSelectionModal.js +++ b/components/CollectionSelectionModal.js @@ -1,3 +1,4 @@ +/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */ import { useState, useEffect } from 'react'; import { VOCAB } from '../lib/collection-vocabulary.js'; @@ -13,34 +14,41 @@ export default function CollectionSelectionModal({ const [submitting, setSubmitting] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [prevIsOpen, setPrevIsOpen] = useState(isOpen); + if (isOpen && !prevIsOpen) { + setPrevIsOpen(true); + setSelectedCollections([]); + setSearchQuery(''); + } else if (!isOpen && prevIsOpen) { + setPrevIsOpen(false); + } + useEffect(() => { - if (isOpen) { - fetchCollections(); - setSelectedCollections([]); - setSearchQuery(''); - } - }, [isOpen]); + if (!isOpen) return; - const fetchCollections = async () => { - setLoading(true); - try { - const response = await fetch('/api/collections?excludeSystem=true'); + const fetchCollections = async () => { + setLoading(true); + try { + const response = await fetch('/api/collections?excludeSystem=true'); - if (response.ok) { - const data = await response.json(); - // The API returns an array directly, not wrapped in collections property - setCollections(Array.isArray(data) ? data : data.collections || []); - } else { - console.error('Failed to fetch collections:', response.status, response.statusText); + if (response.ok) { + const data = await response.json(); + // The API returns an array directly, not wrapped in collections property + setCollections(Array.isArray(data) ? data : data.collections || []); + } else { + console.error('Failed to fetch collections:', response.status, response.statusText); + setCollections([]); + } + } catch (error) { + console.error('Error fetching collections:', error); setCollections([]); + } finally { + setLoading(false); } - } catch (error) { - console.error('Error fetching collections:', error); - setCollections([]); - } finally { - setLoading(false); - } - }; + }; + + fetchCollections(); + }, [isOpen]); const handleCollectionToggle = (collectionId) => { setSelectedCollections(prev => { diff --git a/components/Layout.js b/components/Layout.js index b15d357..1dfa846 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -155,13 +155,19 @@ function NavigationContent({ user, router, onItemClick }) { const [isCommunityExpanded, setIsCommunityExpanded] = useState( router.pathname.startsWith('/community') ); + const [lastCommunityPath, setLastCommunityPath] = useState(router.pathname); - // Auto-expand Community section when navigating to community pages - useEffect(() => { - if (router.pathname.startsWith('/community')) { + if ( + router.pathname.startsWith('/community') && + router.pathname !== lastCommunityPath + ) { + setLastCommunityPath(router.pathname); + if (!isCommunityExpanded) { setIsCommunityExpanded(true); } - }, [router.pathname]); + } else if (router.pathname !== lastCommunityPath) { + setLastCommunityPath(router.pathname); + } // Navigation structure for authenticated users const authenticatedNavigation = user ? [ diff --git a/components/ManaSymbolSettings.js b/components/ManaSymbolSettings.js index 2a67675..7992300 100644 --- a/components/ManaSymbolSettings.js +++ b/components/ManaSymbolSettings.js @@ -4,18 +4,20 @@ import { useState, useEffect } from 'react'; * Mana Symbol Settings Component * Allows users to toggle between custom circular symbols and Scryfall SVG symbols */ +function readUseSVGFromStorage() { + if (typeof window === 'undefined') return false; + const savedSetting = localStorage.getItem('mana-symbol-svg'); + return savedSetting === 'true'; +} + export default function ManaSymbolSettings({ onSettingsChange }) { - const [useSVG, setUseSVG] = useState(false); + const [useSVG, setUseSVG] = useState(readUseSVGFromStorage); useEffect(() => { - // Load setting from localStorage + if (typeof window === 'undefined') return; const savedSetting = localStorage.getItem('mana-symbol-svg'); - if (savedSetting !== null) { - const shouldUseSVG = savedSetting === 'true'; - setUseSVG(shouldUseSVG); - if (onSettingsChange) { - onSettingsChange({ useSVG: shouldUseSVG }); - } + if (savedSetting !== null && onSettingsChange) { + onSettingsChange({ useSVG: savedSetting === 'true' }); } }, [onSettingsChange]); diff --git a/components/ManaSymbols.js b/components/ManaSymbols.js index ec82f2f..6399c23 100644 --- a/components/ManaSymbols.js +++ b/components/ManaSymbols.js @@ -1,3 +1,4 @@ +/* eslint-disable @next/next/no-img-element -- Scryfall mana SVG URLs; next/image migration is out of scope. */ import { useState, useEffect } from 'react'; import { parseManaSymbols, getColorSymbol } from '../lib/mana-symbols'; diff --git a/components/ScannedCardItem.js b/components/ScannedCardItem.js index a9f6d50..c5dd3d2 100644 --- a/components/ScannedCardItem.js +++ b/components/ScannedCardItem.js @@ -1,3 +1,4 @@ +/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */ import { useEffect, useState } from 'react'; import { formatProcessedDestination, VOCAB } from '../lib/collection-vocabulary.js'; @@ -19,10 +20,10 @@ export default function ScannedCardItem({ isAdding = false, }) { const [ownedQuantity, setOwnedQuantity] = useState(null); + const resolvedOwnedQuantity = card.databaseId ? ownedQuantity : null; useEffect(() => { if (!card.databaseId) { - setOwnedQuantity(null); return; } @@ -115,14 +116,14 @@ export default function ScannedCardItem({ Found in database
)} - {ownedQuantity !== null && ownedQuantity > 0 && ( + {resolvedOwnedQuantity !== null && resolvedOwnedQuantity > 0 && (
- You own {ownedQuantity} + You own {resolvedOwnedQuantity}
)}
diff --git a/components/ShareModal.js b/components/ShareModal.js index 81749cc..dbd0a1a 100644 --- a/components/ShareModal.js +++ b/components/ShareModal.js @@ -15,44 +15,44 @@ export default function ShareModal({ const [copySuccess, setCopySuccess] = useState(false); useEffect(() => { - if (isOpen) { - fetchInvitedUsers(); - fetchCurrentUser(); - } + if (!isOpen) return; + + const fetchCurrentUser = async () => { + try { + const response = await fetch('/api/auth/verify', { + headers: { + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }, + }); + if (response.ok) { + const data = await response.json(); + setCurrentUser(data.user); + } + } catch (error) { + console.error('Error fetching current user:', error); + } + }; + + const fetchInvitedUsers = async () => { + try { + const response = await fetch(`/api/collections/${collectionId}/permissions`, { + headers: { + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }, + }); + if (response.ok) { + const data = await response.json(); + setInvitedUsers(data.permissions || []); + } + } catch (error) { + console.error('Error fetching invited users:', error); + } + }; + + fetchInvitedUsers(); + fetchCurrentUser(); }, [isOpen, collectionId]); - const fetchCurrentUser = async () => { - try { - const response = await fetch('/api/auth/verify', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - } - }); - if (response.ok) { - const data = await response.json(); - setCurrentUser(data.user); - } - } catch (error) { - console.error('Error fetching current user:', error); - } - }; - - const fetchInvitedUsers = async () => { - try { - const response = await fetch(`/api/collections/${collectionId}/permissions`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - } - }); - if (response.ok) { - const data = await response.json(); - setInvitedUsers(data.permissions || []); - } - } catch (error) { - console.error('Error fetching invited users:', error); - } - }; - const handleSearch = async (query) => { setSearchQuery(query); if (query.length < 2) { diff --git a/components/UploadImageModal.js b/components/UploadImageModal.js index 92248fa..fef95df 100644 --- a/components/UploadImageModal.js +++ b/components/UploadImageModal.js @@ -1,3 +1,4 @@ +/* eslint-disable @next/next/no-img-element -- Local preview data URLs; next/image migration is out of scope. */ import { useState } from 'react'; export default function UploadImageModal({ isOpen, onClose, onUpload, currentImage }) { -- 2.45.2