card-grader/static/app.js

589 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Card Grader — UI */
const state = { settings: {}, modelGuide: {}, history: [] };
const $ = (sel) => document.querySelector(sel);
function esc(text) {
return String(text === null || text === undefined ? '' : text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/* ------------------------------------------------------------------ api */
// The path this app is mounted under (e.g. "/cards"), or "" at the domain
// root — set server-side via a data attribute since a script has no other
// reliable way to know where it was served from.
const APP_BASE = document.documentElement.dataset.base || '';
async function api(path, options = {}) {
const response = await fetch(APP_BASE + path, {
headers: { 'Content-Type': 'application/json' },
...options,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const text = await response.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; }
if (!response.ok) {
throw new Error((data && data.error) || `Request failed (${response.status})`);
}
return data;
}
function banner(message, isError = false) {
const node = $('#banner');
if (!message) { node.hidden = true; return; }
node.className = isError ? 'banner err' : 'banner';
node.textContent = message;
node.hidden = false;
if (!isError) setTimeout(() => { node.hidden = true; }, 3200);
}
/* ------------------------------------------------------------- file read */
//
// Read straight to a data URL at pick time, and only clear the input once
// the bytes are safely in hand. Holding File objects and reading them later
// looks equivalent and isn't: clearing input.value (which is what lets you
// re-pick the same file) can detach the underlying blob in some browsers, so
// a read attempted afterward fails with no useful reason. Reading now
// sidesteps that, and the data URL doubles as the preview source.
function bytesToBase64(bytes) {
// Chunked: fromCharCode.apply on a multi-megabyte array blows the stack.
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
async function readOneImage(file) {
// Two independent read paths, because they fail independently. FileReader
// is the older API and is the one that trips over files a phone exposes
// through a cloud provider or a scoped-storage URI; file.arrayBuffer() is
// the modern path and frequently succeeds where it doesn't. Trying both
// turns a hard failure into a retry, and if both fail the real
// DOMException name gets reported rather than a guess at the cause.
const errors = [];
try {
const buffer = await file.arrayBuffer();
if (buffer && buffer.byteLength) {
const type = file.type || 'image/jpeg';
return `data:${type};base64,${bytesToBase64(new Uint8Array(buffer))}`;
}
errors.push('arrayBuffer returned nothing');
} catch (err) {
errors.push(`arrayBuffer: ${err && err.name ? err.name : err}`);
}
try {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error || new Error('unknown'));
reader.readAsDataURL(file);
});
} catch (err) {
errors.push(`FileReader: ${err && err.name ? err.name : err}`);
}
const detail = errors.join('; ');
const hint = /NotReadable|NotFound/i.test(detail)
? ' The file may be stored in the cloud rather than on the device — open it once in your photo app so it downloads, then try again.'
: ' Try saving a copy of it first, then pick the copy.';
throw new Error(`Could not read "${file.name || 'that file'}" (${detail}).${hint}`);
}
async function readPickedImages(input, limit) {
const picked = Array.from(input.files || []).slice(0, Math.max(0, limit));
const out = [];
for (const file of picked) {
out.push({ name: file.name || 'upload', dataUrl: await readOneImage(file) });
}
input.value = '';
return out;
}
function pickedToPayload(items) {
return items.map((i) => ({ filename: i.name, image_base64: i.dataUrl.split(',')[1] }));
}
/* --------------------------------------------------------------- grading */
const SEVERITY_PILL = {
none: 'pill-grade', minor: 'pill-marginal', moderate: 'pill-marginal',
major: 'pill-critical', cannot_assess: 'pill-raw',
};
const SEVERITY_LABEL = {
none: 'clean', minor: 'minor', moderate: 'moderate', major: 'major',
cannot_assess: "can't tell from photo",
};
const gradeState = { files: [], result: null, busy: false };
function renderGradeBlock(g) {
if (!g) return '';
const m = g.edge_measurements || null;
let measuredLine = '';
if (m && m.reliable === false) {
measuredLine = `not measurable — ${m.reason || "this card's finish"}`;
} else if (m && m.edges) {
measuredLine = ['top', 'right', 'bottom', 'left']
.filter((s) => m.edges[s])
.map((s) => `${s} ${m.edges[s].percent.toFixed(0)}%`).join(' · ');
}
const cm = g.centering_measurement || null;
const centeringLine = cm
? `measured — left/right ${cm.horizontal_label} · top/bottom ${cm.vertical_label}` : '';
const rows = ['centering', 'corners', 'edges', 'surface'].map((key) => {
const cat = (g.categories || {})[key] || {};
const sev = cat.severity || 'cannot_assess';
let extra = '';
if (key === 'edges' && measuredLine) {
extra = `<div class="hint">measured whitening — ${esc(measuredLine)}</div>`;
} else if (key === 'centering' && centeringLine) {
extra = `<div class="hint">${esc(centeringLine)}</div>`;
}
return `<tr>
<td style="text-transform:capitalize">${key}</td>
<td><span class="pill ${SEVERITY_PILL[sev]}">${esc(SEVERITY_LABEL[sev])}</span></td>
<td class="cardcell-meta">${esc(cat.observation || '')}${extra}</td>
</tr>`;
}).join('');
return `
<div class="note">A photo can't show surface scratches, print lines, or light edge wear the
way a grader's raking light does — and a seller's listing photo is often lit to hide them.
Treat this as a rough screen, not a prediction of what it comes back as.</div>
<div style="margin:14px 0">${renderSlab(g)}</div>
<div class="table-scroll"><table class="grid"><tbody>${rows}</tbody></table></div>
${(g.limitations || []).length ? `<div style="margin-top:10px"><strong>Couldn't check:</strong>
<ul>${g.limitations.map((l) => `<li>${esc(l)}</li>`).join('')}</ul></div>` : ''}
${g.note ? `<p class="cardcell-meta">${esc(g.note)}</p>` : ''}`;
}
const GRADE_WORD = {
10: 'Gem Mint', 9: 'Mint', 8: 'NM-MT', 7: 'Near Mint', 6: 'EX-MT',
5: 'Excellent', 4: 'VG-EX', 3: 'Very Good', 2: 'Good', 1: 'Poor',
};
const TYPE_LABEL = {
pokemon: 'Pokémon', sports: 'Sports', other_tcg: 'TCG', other: 'Card',
};
function slabClass(grade) {
if (grade === null || grade === undefined) return 'slab-low';
if (grade >= 10) return 'slab-10';
if (grade === 9) return 'slab-9';
if (grade >= 7) return 'slab-8';
if (grade >= 5) return 'slab-6';
return 'slab-low';
}
function renderSlab(g, opts = {}) {
const grade = g.estimated_grade;
const range = (g.grade_low !== null && g.grade_high !== null && g.grade_low !== g.grade_high)
? `realistically ${g.grade_low}${g.grade_high}` : '';
const title = opts.title || g.card_note || 'Card';
const bits = [range, `${g.confidence || ''} confidence`].filter(Boolean).join(' · ');
return `
<div class="slab ${slabClass(grade)}">
<div class="slab-main">
${g.card_type ? `<div><span class="card-type-tag">${esc(TYPE_LABEL[g.card_type] || 'Card')}</span></div>` : ''}
<div class="slab-title">${esc(title)}</div>
<div class="slab-sub">${esc(bits)}</div>
</div>
<div class="slab-grade">
<span class="n">${grade === null || grade === undefined ? '—' : grade}</span>
<span class="word">${esc(GRADE_WORD[grade] || 'estimate')}</span>
</div>
</div>`;
}
function renderGradeReview() {
const el = $('#grade-review');
if (!gradeState.files.length && !gradeState.result) { el.innerHTML = ''; return; }
if (!gradeState.result) {
el.innerHTML = `
<div class="panel" style="margin-top:14px;background:var(--plane)">
<div class="panel-head">
<h2>${gradeState.files.length} photo(s) ready</h2>
<span class="hint">more angles = a tighter estimate</span>
</div>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
${gradeState.files.map((f) =>
`<img src="${f.dataUrl}" alt="" style="height:110px;border-radius:8px">`).join('')}
<div style="display:flex;flex-direction:column;gap:8px">
<button class="btn btn-quiet btn-sm" id="grade-add-more">+ Add another photo</button>
<button class="btn" id="grade-run"${gradeState.busy ? ' disabled' : ''}>
${gradeState.busy ? 'Grading…' : 'Estimate the grade'}</button>
<button class="btn btn-quiet btn-sm" id="grade-cancel">Cancel</button>
</div>
</div>
</div>`;
return;
}
const g = gradeState.result.grade;
const cost = gradeState.result.grade.estimated_cost;
el.innerHTML = `
<div class="panel" style="margin-top:14px;background:var(--plane)">
<div class="panel-head">
<h2>Grade estimate</h2>
<span class="hint">${cost ? `about $${cost.toFixed(3)}` : ''}</span>
</div>
${renderGradeBlock(g)}
<div class="drawer-actions">
<button class="btn btn-quiet" id="grade-discard">Done</button>
</div>
</div>`;
}
async function runGrade() {
gradeState.busy = true;
renderGradeReview();
const status = $('#grade-status');
status.hidden = false;
status.textContent = `Reading ${gradeState.files.length} photo(s)… this takes a few seconds and costs a few cents.`;
try {
const body = {
images: pickedToPayload(gradeState.files),
label: ($('#grade-label').value || '').trim() || null,
};
const key = myApiKey();
if (key) body.api_key = key;
gradeState.result = await api('/api/grade', { method: 'POST', body });
status.hidden = true;
$('#grade-label').value = '';
loadHistory().catch(() => {});
} catch (err) {
status.textContent = `Grading failed: ${err.message}`;
} finally {
gradeState.busy = false;
renderGradeReview();
}
}
function resetGradeState() {
gradeState.files = [];
gradeState.result = null;
gradeState.busy = false;
$('#grade-status').hidden = true;
renderGradeReview();
}
async function addPickedFrom(input) {
try {
const picked = await readPickedImages(input, 6 - gradeState.files.length);
if (!picked.length) return;
gradeState.files.push(...picked);
renderGradeReview();
} catch (err) {
$('#grade-status').hidden = false;
$('#grade-status').textContent = err.message;
}
}
$('#btn-grade').addEventListener('click', () => {
resetGradeState();
$('#grade-file').click();
});
$('#grade-file').addEventListener('change', (e) => addPickedFrom(e.target));
// Camera shots add onto whatever's already picked (front, then flip to the
// back for a second shot) rather than resetting — resetGradeState() is only
// for starting a fresh card, which the library button already does.
$('#btn-camera').addEventListener('click', () => $('#grade-camera').click());
$('#grade-camera').addEventListener('change', (e) => addPickedFrom(e.target));
$('#grade-review').addEventListener('click', (e) => {
if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; }
if (e.target.closest('#grade-cancel') || e.target.closest('#grade-discard')) {
resetGradeState(); return;
}
if (e.target.closest('#grade-run')) {
if (!gradeState.busy) runGrade();
}
});
/* --------------------------------------------------------------- history */
function gradePillClass(grade) {
if (grade === null || grade === undefined) return 'pill-raw';
if (grade >= 8) return 'pill-grade';
if (grade >= 5) return 'pill-marginal';
return 'pill-critical';
}
function fmtWhen(iso) {
if (!iso) return '';
return String(iso).replace('T', ' ').slice(0, 16);
}
async function loadHistory() {
const data = await api('/api/history');
state.history = data.grades;
renderHistory();
}
function renderHistory() {
const rows = state.history;
$('#history-count').textContent = rows.length ? `${rows.length} graded` : '';
$('#history-empty').hidden = rows.length > 0;
$('#history-body').innerHTML = rows.map((g) => `
<tr data-history-row="${g.id}">
<td>
<div class="cardcell">
${g.thumbnail ? `<img src="${g.thumbnail}" alt="">` : '<span class="thumb-blank"></span>'}
<div>
<div class="cardcell-name">${esc(g.label || g.card_note || 'Untitled card')}</div>
<div class="cardcell-meta">${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)</div>
</div>
</div>
</td>
<td><span class="pill ${gradePillClass(g.estimated_grade)}">${
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
<td class="cardcell-meta">${esc(g.confidence || '')}</td>
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td>
<td class="num"><button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button></td>
</tr>`).join('');
}
function renderEntryModal(g) {
$('#entry-modal').innerHTML = `
<div class="panel-head">
<h2>${esc(g.label || g.card_note || 'Untitled card')}</h2>
<button class="close" id="entry-close">&times;</button>
</div>
<div class="cardcell-meta" style="margin-bottom:10px">${esc(fmtWhen(g.created_at))} ·
${g.usage && g.usage.model ? esc(g.usage.model) : ''}</div>
${renderGradeBlock(g)}
<div class="drawer-actions">
<input class="input" id="entry-label" placeholder="Rename this card…" value="${esc(g.label || '')}" style="flex:1 1 200px">
<button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button>
<span class="spacer"></span>
<button class="btn btn-danger btn-sm" data-history-delete="${g.id}">Delete</button>
</div>`;
$('#entry-modal').hidden = false;
$('#entry-scrim').hidden = false;
}
function closeEntryModal() {
$('#entry-modal').hidden = true;
$('#entry-scrim').hidden = true;
}
$('#history-body').addEventListener('click', async (e) => {
const del = e.target.closest('[data-history-delete]');
if (del) {
e.stopPropagation();
await api(`/api/history/${del.dataset.historyDelete}`, { method: 'DELETE' });
await loadHistory();
return;
}
const row = e.target.closest('[data-history-row]');
if (row) {
try {
const data = await api(`/api/history/${row.dataset.historyRow}`);
renderEntryModal(data.grade);
} catch (err) {
banner(err.message, true);
}
}
});
$('#entry-modal').addEventListener('click', async (e) => {
if (e.target.closest('#entry-close')) return closeEntryModal();
const del = e.target.closest('[data-history-delete]');
if (del) {
await api(`/api/history/${del.dataset.historyDelete}`, { method: 'DELETE' });
closeEntryModal();
await loadHistory();
return;
}
if (e.target.closest('#entry-save-label')) {
const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete;
const label = $('#entry-label').value.trim();
await api(`/api/history/${id}`, { method: 'PATCH', body: { label } });
closeEntryModal();
await loadHistory();
banner('Renamed.');
}
});
$('#entry-scrim').addEventListener('click', closeEntryModal);
/* -------------------------------------------------------------- settings */
async function openSettings() {
const s = state.settings;
try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; }
const modelOptions = Object.entries(state.modelGuide).map(([id, info]) =>
`<option value="${id}" ${s.vision_model === id ? 'selected' : ''}>
${esc(info.label)} — ~$${info.per_grade.toFixed(3)}/grade</option>`).join('');
const locked = s.settings_locked;
$('#settings-modal').innerHTML = `
<div class="panel-head">
<h2>Settings</h2>
<button class="close" id="settings-close">&times;</button>
</div>
<div class="section">
<h3>Your API key</h3>
<div class="fields">
<div class="field wide">
<label>Anthropic API key (this browser only)</label>
<input class="input" id="set-my-key" type="password" value="${esc(myApiKey() || '')}"
placeholder="sk-ant-...">
<span class="suffix">Stored only in this browser and sent with your own grading
requests — never saved on the server. Get one at console.anthropic.com.
${s.server_key_configured
? 'This server already has a key set up, so you can leave this blank and use that instead — but then the owner pays for your grades.'
: 'This server has no key of its own, so you need one here to grade anything.'}</span>
</div>
</div>
</div>
<div class="section">
<h3>Server settings${locked ? ' <span class="pill pill-raw">locked</span>' : ''}</h3>
${locked ? `<div class="note">This server is shared, so its settings are read-only.
Use your own key above.</div>` : `
<div class="fields">
<div class="field wide">
<label>Server API key (used when a visitor has none)</label>
<input class="input" id="set-api-key" type="password"
placeholder="${s.server_key_configured ? '•••••••• already set — type to replace' : 'sk-ant-...'}">
<span class="suffix">Never sent back to the browser once saved. Leave blank to keep
the current one.</span>
</div>
<div class="field wide">
<label>Vision model</label>
<select id="set-model">${modelOptions}</select>
<span class="suffix">Sonnet 5 is the default for a tested reason — run head-to-head
against Haiku on the same cards, Haiku misread a PSA centering tolerance and landed
three grades off. Drop to Haiku only if cost matters more than accuracy to you.</span>
</div>
</div>`}
</div>
<div class="drawer-actions">
<button class="btn" id="settings-save">Save</button>
</div>`;
$('#settings-modal').hidden = false;
$('#modal-scrim').hidden = false;
}
/* A personal key lives in localStorage, never on the server — that's what
lets someone use a shared instance without spending the owner's credits. */
function myApiKey() {
try { return localStorage.getItem('cardgrader_api_key') || ''; } catch (_) { return ''; }
}
function setMyApiKey(value) {
try {
if (value) localStorage.setItem('cardgrader_api_key', value);
else localStorage.removeItem('cardgrader_api_key');
} catch (_) { /* private browsing — the field just won't persist */ }
}
function closeSettings() {
$('#settings-modal').hidden = true;
$('#modal-scrim').hidden = true;
}
async function saveSettings() {
setMyApiKey($('#set-my-key').value.trim());
// Only push server settings when this instance allows it, and only send a
// key when one was actually typed — an empty box means "leave it alone",
// not "erase it".
const serverKeyField = $('#set-api-key');
if (serverKeyField) {
const payload = { vision_model: $('#set-model').value };
const typed = serverKeyField.value.trim();
if (typed) payload.anthropic_api_key = typed;
state.settings = await api('/api/settings', { method: 'POST', body: payload });
}
closeSettings();
updateModelCostHint();
banner('Settings saved.');
}
function updateModelCostHint() {
const info = state.modelGuide[state.settings.vision_model];
$('#model-cost-hint').textContent = info ? `~$${info.per_grade.toFixed(3)} per grade (${info.label})` : '';
}
$('#btn-settings').addEventListener('click', () => openSettings().catch((err) => banner(err.message, true)));
$('#settings-modal').addEventListener('click', (e) => {
if (e.target.closest('#settings-close')) return closeSettings();
if (e.target.closest('#settings-save')) saveSettings().catch((err) => banner(err.message, true));
});
$('#modal-scrim').addEventListener('click', closeSettings);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeSettings(); closeEntryModal(); }
});
/* ---------------------------------------------------------------- load */
async function load() {
state.settings = await api('/api/settings');
try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; }
updateModelCostHint();
await loadHistory();
if (!state.settings.server_key_configured && !myApiKey()) {
banner('Add your Anthropic API key in Settings before grading a card.');
}
}
load().catch((err) => banner(`Could not load: ${err.message}`, true));
/* ------------------------------------------------------------------ pwa */
//
// Service workers only run in a secure context: https, or localhost. Over a
// plain LAN address (http://192.168.x.x) registration silently fails and the
// app stays a normal web page — which is exactly why the README pushes you
// through a tunnel rather than just handing friends your LAN IP.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(APP_BASE + '/sw.js').catch(() => {
/* Not fatal — the app works fine uninstalled. */
});
});
}
// Android/Chrome fires this instead of showing its own prompt, so the offer
// has to be surfaced deliberately. iOS never fires it: Safari has no
// programmatic install, only the manual Share > Add to Home Screen, so the
// hint below covers that case instead of pretending a button exists.
let deferredInstall = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredInstall = e;
const btn = $('#btn-install');
if (btn) btn.hidden = false;
});
document.addEventListener('click', async (e) => {
if (!e.target.closest('#btn-install')) return;
if (!deferredInstall) {
banner("No install prompt available — try Chrome's own menu (⋮ → Install app) instead.", true);
return;
}
try {
await deferredInstall.prompt();
const choice = await deferredInstall.userChoice;
if (choice.outcome !== 'accepted') banner(`Install ${choice.outcome}.`);
} catch (err) {
banner(`Install failed: ${err.message}`, true);
} finally {
deferredInstall = null;
$('#btn-install').hidden = true;
}
});