2026-05-27 09:47:05 -04:00
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import Layout from '../../components/Layout';
|
|
|
|
|
import AdminProtected from '../../components/AdminProtected';
|
|
|
|
|
|
|
|
|
|
function CardSubmissionsAdmin() {
|
|
|
|
|
const [submissions, setSubmissions] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState(null);
|
|
|
|
|
const [processingId, setProcessingId] = useState(null);
|
|
|
|
|
|
|
|
|
|
const loadSubmissions = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/admin/card-submissions?status=pending', {
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('Failed to load submissions');
|
|
|
|
|
}
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
setSubmissions(data.submissions || []);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-06-02 01:48:07 -04:00
|
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount fetch; setLoading runs inside async loader
|
2026-05-27 09:47:05 -04:00
|
|
|
loadSubmissions();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const reviewSubmission = async (submissionId, action) => {
|
|
|
|
|
setProcessingId(submissionId);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/admin/card-submissions', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ submissionId, action }),
|
|
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(data.error || 'Review failed');
|
|
|
|
|
}
|
|
|
|
|
await loadSubmissions();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setProcessingId(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
|
|
|
|
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
Card Scan Submissions
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
Review cards identified by the scanner that are not yet in the global catalog.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="mb-4 p-3 rounded-lg border border-red-500 text-sm text-red-600">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<p style={{ color: 'var(--text-secondary)' }}>Loading…</p>
|
|
|
|
|
) : submissions.length === 0 ? (
|
|
|
|
|
<p style={{ color: 'var(--text-secondary)' }}>No pending submissions.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="space-y-4">
|
|
|
|
|
{submissions.map((sub) => {
|
|
|
|
|
const payload = sub.ocr_payload || {};
|
|
|
|
|
return (
|
|
|
|
|
<li
|
|
|
|
|
key={sub.id}
|
|
|
|
|
className="p-4 rounded-xl border"
|
|
|
|
|
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex justify-between gap-4 mb-2">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
{payload.name || sub.ocr_text || 'Unknown card'}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
{payload.game} · confidence {sub.ocr_confidence ?? '—'}% · by {sub.submitter_email}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
#{sub.id}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{sub.ocr_text && (
|
|
|
|
|
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
{sub.ocr_text}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={processingId === sub.id}
|
|
|
|
|
onClick={() => reviewSubmission(sub.id, 'approve')}
|
|
|
|
|
className="px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50"
|
|
|
|
|
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
|
|
|
|
>
|
|
|
|
|
Approve
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={processingId === sub.id}
|
|
|
|
|
onClick={() => reviewSubmission(sub.id, 'reject')}
|
|
|
|
|
className="px-3 py-1.5 rounded-lg text-sm border disabled:opacity-50"
|
|
|
|
|
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
|
|
|
>
|
|
|
|
|
Reject
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function CardSubmissionsPage() {
|
|
|
|
|
return (
|
|
|
|
|
<AdminProtected>
|
|
|
|
|
{(user) => (
|
|
|
|
|
<Layout user={user}>
|
|
|
|
|
<CardSubmissionsAdmin />
|
|
|
|
|
</Layout>
|
|
|
|
|
)}
|
|
|
|
|
</AdminProtected>
|
|
|
|
|
);
|
|
|
|
|
}
|