deckhearth/pages/admin/card-submissions.js
varutasu e81dd49752
feat(scanner): server-side scan pipeline (convoy #2) (#35)
* feat(scanner): move card identification server-side (convoy #2)

Replace browser Gemini/OCR with POST /api/scan/identify, add card_submissions
review queue, remove user-writable cards INSERT, and surface disambiguation
when catalog matching is ambiguous.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: allowlist server-only lib/scan-gemini.js in LLM key gate

The scan pipeline helper lives under lib/ but is imported exclusively
from pages/api/scan/identify — exclude it from the client-side URL scan.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 08:47:05 -05:00

145 lines
4.9 KiB
JavaScript

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(() => {
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>
);
}