fix(lint): clear ESLint baseline in components/ #61
13 changed files with 178 additions and 106 deletions
|
|
@ -9,9 +9,6 @@ export default function AdminProtected({ children }) {
|
|||
const [accessDenied, setAccessDenied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkAdminAccess();
|
||||
}, []);
|
||||
|
||||
const checkAdminAccess = async () => {
|
||||
try {
|
||||
// Get token from localStorage
|
||||
|
|
@ -50,6 +47,9 @@ export default function AdminProtected({ children }) {
|
|||
}
|
||||
};
|
||||
|
||||
checkAdminAccess();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout user={null}>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
|
|
@ -27,26 +51,22 @@ export default function AuthLayout({ children }) {
|
|||
|
||||
{/* Floating Embers */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
{embers.map((ember) => (
|
||||
<div
|
||||
key={i}
|
||||
key={ember.key}
|
||||
className="absolute animate-ember-float opacity-30"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 8}s`,
|
||||
animationDuration: `${8 + Math.random() * 4}s`
|
||||
left: ember.left,
|
||||
top: ember.top,
|
||||
animationDelay: ember.animationDelay,
|
||||
animationDuration: ember.animationDuration,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-1 h-1 rounded-full"
|
||||
style={{
|
||||
backgroundColor: theme === 'dark'
|
||||
? `rgba(255, ${100 + Math.random() * 100}, 0, 0.6)`
|
||||
: `rgba(251, ${100 + Math.random() * 46}, 60, 0.4)`,
|
||||
boxShadow: theme === 'dark'
|
||||
? `0 0 8px rgba(255, ${100 + Math.random() * 100}, 0, 0.4)`
|
||||
: `0 0 6px rgba(251, ${100 + Math.random() * 46}, 60, 0.3)`
|
||||
backgroundColor: ember.backgroundColor,
|
||||
boxShadow: ember.boxShadow,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="absolute border-2 rounded-lg transition-all duration-200"
|
||||
style={{
|
||||
left: `${(card.bounds.x / videoRef.current?.videoWidth) * 100}%`,
|
||||
top: `${(card.bounds.y / videoRef.current?.videoHeight) * 100}%`,
|
||||
width: `${(card.bounds.width / videoRef.current?.videoWidth) * 100}%`,
|
||||
height: `${(card.bounds.height / videoRef.current?.videoHeight) * 100}%`,
|
||||
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
|
||||
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
|
||||
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
|
||||
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
|
||||
borderColor:
|
||||
card.status === 'confirmed' ? '#10B981' : // Green for confirmed
|
||||
card.status === 'scanned' ? '#3B82F6' : // Blue for scanned
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ export default function CollaboratorFacepile({ collectionId, creatorEmail }) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (collectionId) {
|
||||
fetchCollaborators();
|
||||
}
|
||||
}, [collectionId]);
|
||||
if (!collectionId) return;
|
||||
|
||||
const fetchCollaborators = async () => {
|
||||
try {
|
||||
|
|
@ -33,6 +30,9 @@ export default function CollaboratorFacepile({ collectionId, creatorEmail }) {
|
|||
}
|
||||
};
|
||||
|
||||
fetchCollaborators();
|
||||
}, [collectionId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
|
|||
|
|
@ -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,13 +14,17 @@ export default function CollectionSelectionModal({
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchCollections();
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||
if (isOpen && !prevIsOpen) {
|
||||
setPrevIsOpen(true);
|
||||
setSelectedCollections([]);
|
||||
setSearchQuery('');
|
||||
} else if (!isOpen && prevIsOpen) {
|
||||
setPrevIsOpen(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const fetchCollections = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -42,6 +47,9 @@ export default function CollectionSelectionModal({
|
|||
}
|
||||
};
|
||||
|
||||
fetchCollections();
|
||||
}, [isOpen]);
|
||||
|
||||
const handleCollectionToggle = (collectionId) => {
|
||||
setSelectedCollections(prev => {
|
||||
if (prev.includes(collectionId)) {
|
||||
|
|
|
|||
|
|
@ -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 ? [
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<span>Found in database</span>
|
||||
</div>
|
||||
)}
|
||||
{ownedQuantity !== null && ownedQuantity > 0 && (
|
||||
{resolvedOwnedQuantity !== null && resolvedOwnedQuantity > 0 && (
|
||||
<div
|
||||
role="status"
|
||||
className="text-xs font-medium mt-1"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`You already own ${ownedQuantity} copies of this card`}
|
||||
aria-label={`You already own ${resolvedOwnedQuantity} copies of this card`}
|
||||
>
|
||||
You own {ownedQuantity}
|
||||
You own {resolvedOwnedQuantity}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,18 +15,14 @@ export default function ShareModal({
|
|||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchInvitedUsers();
|
||||
fetchCurrentUser();
|
||||
}
|
||||
}, [isOpen, collectionId]);
|
||||
if (!isOpen) return;
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
}
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
|
@ -41,8 +37,8 @@ export default function ShareModal({
|
|||
try {
|
||||
const response = await fetch(`/api/collections/${collectionId}/permissions`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
}
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
|
@ -53,6 +49,10 @@ export default function ShareModal({
|
|||
}
|
||||
};
|
||||
|
||||
fetchInvitedUsers();
|
||||
fetchCurrentUser();
|
||||
}, [isOpen, collectionId]);
|
||||
|
||||
const handleSearch = async (query) => {
|
||||
setSearchQuery(query);
|
||||
if (query.length < 2) {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue