@@ -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 }) {