card-grader/static/app.js
Barely Removable 2b3bbaf40f Add a structured authenticity signal with a distinct banner, instead of burying it in card_note
The Charizard grade that prompted this had the model writing 'possible
non-standard/proxy print' directly into card_note -- the only free-text
field available -- since there was nowhere else for that observation to go.
That's why it got no visual treatment: the UI renders card_note as a plain
grey subtitle, identical to any ordinary card name.

Added authenticity as its own top-level field (flag: none/worth_checking/
likely_not_genuine, plus an observation) alongside the four grading
categories but explicitly separate from them -- this is about whether the
card is a genuine product at all, not its condition, and PSA authenticates
before it grades, so a flagged card's estimated_grade goes to null rather
than a number.

Prompted with concrete tells for both classes of concern: fan-made/proxy
prints (fictional sets, non-existent number combos, home-printer texture,
'proxy' watermarks) and counterfeits of real cards (colour/font/holo
mismatches against genuine copies). Trimming (already covered under CORNERS
from an earlier fix) now also sets this field when flagged, so the UI has
one place to check regardless of which specific issue triggered it.

UI: a full banner (not a hint line) above the slab -- critical red for
likely_not_genuine, warning amber for worth_checking -- plus a matching dot
next to the card name in History so it's visible without opening the card.
Both colours reuse --critical/--warning rather than the brand red, since
this is exactly the 'distrust this' signal those are already reserved for.

Migration tested against the live schema shape; existing rows get
authenticity=None and render with no banner, as before.
2026-08-23 08:19:58 -07:00

839 lines
34 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, opts = {}) {
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) {
const sides = ['top', 'right', 'bottom', 'left'];
measuredLine = sides
.filter((s) => m.edges[s])
.map((s) => `${s} ${m.edges[s].percent.toFixed(0)}%`).join(' · ');
// An edge excluded for reading as a different material (a die-cut clear
// window, a foil strip) comes back with no score. Listing only the
// edges that DID measure would quietly present three sides as if they
// were all four — say which one is missing and why.
const skipped = sides.filter((s) => !m.edges[s] && (m.edge_notes || {})[s]);
if (skipped.length) {
const why = m.edge_notes[skipped[0]];
measuredLine += `${measuredLine ? '; ' : ''}${skipped.join(' and ')} not measured — ${why}`;
}
}
const cm = g.centering_measurement || null;
const centeringLine = cm && cm.reliable === false
? `not measurable — ${cm.reason || "this card's cut"}`
: (cm && cm.horizontal_label
? `measured — left/right ${cm.horizontal_label} · top/bottom ${cm.vertical_label}` : '');
// Only worth surfacing when it's away from noise — most cards sit within
// a percent or two of standard and repeating that number on every card
// would just be clutter, not information. Shown against its best-matching
// standard; which standard is actually relevant is a judgment call the
// model made with the photo in hand, not something this line re-derives.
const am = g.aspect_measurement || null;
const bestDev = am && am.against_standards && am.best_match
? am.against_standards[am.best_match].deviation_percent : null;
const aspectLine = (bestDev !== null && bestDev >= 3)
? `ratio ${bestDev}% off standard (${am.best_match}) — ` +
`verify this isn't just a rotated photo before reading it as trimming` : '';
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>`;
} else if (key === 'corners' && aspectLine) {
extra = `<div class="hint">${esc(aspectLine)}</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('');
const auth = g.authenticity || null;
const authBanner = (auth && auth.flag !== 'none') ? `
<div class="authenticity-banner authenticity-${auth.flag === 'likely_not_genuine' ? 'high' : 'check'}">
<div class="authenticity-title">${auth.flag === 'likely_not_genuine'
? 'Possibly not a genuine card' : 'Worth double-checking'}</div>
${auth.observation ? `<div class="authenticity-detail">${esc(auth.observation)}</div>` : ''}
</div>` : '';
return `
${authBanner}
<div class="note note-warn">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, opts)}</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 confidence = g.confidence ? `${g.confidence} confidence` : '';
// The kicker sits where a real slab label carries its grader's logo. It
// says ESTIMATE first and always — the label is styled to look like the
// real thing, so it has to say plainly that it isn't one.
const kicker = ['Grade estimate', g.card_type ? (TYPE_LABEL[g.card_type] || 'Card') : '']
.filter(Boolean).join(' · ');
// A label needs a focal line to sit right against the grade block. Where
// the card name is suppressed (the history modal already heads with it),
// the range takes that slot rather than leaving the face half-empty.
const primary = opts.hideTitle ? (range || confidence) : title;
const secondary = opts.hideTitle
? (range ? confidence : '')
: [range, confidence].filter(Boolean).join(' · ');
return `
<div class="slab ${slabClass(grade)}">
<div class="slab-main">
<div class="slab-kicker">${esc(kicker)}</div>
${primary ? `<div class="slab-title">${esc(primary)}</div>` : ''}
${secondary ? `<div class="slab-sub">${esc(secondary)}</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,
};
// A fresh grade always runs on the server's configured default model
// (there's no per-grade model picker), so that's whose provider the
// personal key needs to match.
const key = myApiKeyForModel(state.settings.vision_model);
if (key) body.api_key = key;
gradeState.result = await api('/api/grade', { method: 'POST', body });
status.hidden = true;
$('#grade-label').value = '';
loadHistory().catch(() => {});
loadUsage().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';
}
// Mirrors slabClass so a history chip and that card's slab always land on
// the same colour band — they're the same verdict shown at two sizes.
function gradeChipClass(grade) {
if (grade === null || grade === undefined) return 'grade-chip-na';
if (grade >= 10) return 'grade-chip-10';
if (grade === 9) return 'grade-chip-9';
if (grade >= 7) return 'grade-chip-8';
if (grade >= 5) return 'grade-chip-6';
return 'grade-chip-low';
}
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function fmtWhen(iso) {
if (!iso) return '';
const s = String(iso);
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
if (!m) return s.replace('T', ' ').slice(0, 16);
const nowYear = String(new Date().getFullYear());
const day = `${MONTHS[Number(m[2]) - 1]} ${Number(m[3])}`;
// Only spend width on the year when it isn't this year's.
return `${m[1] === nowYear ? day : `${day} ${m[1]}`} · ${m[4]}`;
}
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) => {
const auth = g.authenticity;
// A dot rather than repeating the full banner text — the row is
// already tight, and opening the card gives the real explanation.
const authDot = (auth && auth.flag !== 'none')
? `<span class="authenticity-dot authenticity-${auth.flag === 'likely_not_genuine' ? 'high' : 'check'}"
title="${esc(auth.flag === 'likely_not_genuine' ? 'Possibly not a genuine card' : 'Worth double-checking')}"></span>`
: '';
return `
<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">${authDot}${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="grade-chip ${gradeChipClass(g.estimated_grade)}">${
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
<td><span class="cell-conf conf-${esc(g.confidence || 'low')}">${esc(g.confidence || '')}</span></td>
<td class="cardcell-meta cell-when">${esc(fmtWhen(g.created_at))}</td>
<td>
<div class="row-actions">
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
<button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
</div>
</td>
</tr>`;
}).join('');
}
/* ----------------------------------------------------------------- spend */
function money(v) {
const n = Number(v || 0);
// Sub-cent totals are the norm early on; rounding those to $0.00 would
// make the panel look broken rather than cheap.
return n > 0 && n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`;
}
async function loadUsage() {
let data;
try {
data = await api('/api/usage');
} catch (_) {
return; // never let this block the rest of the page
}
const users = data.users || [];
const panel = $('#usage-panel');
panel.hidden = users.length === 0;
if (!users.length) return;
const t = data.totals || {};
$('#usage-total').textContent =
`${t.grades || 0} grading call(s) · ${money(t.server_cost).replace('<', 'under ')} on the server key`;
$('#usage-body').innerHTML = users.map((u) => {
const isYou = data.you && u.username === data.you;
return `<tr>
<td class="cardcell-name">${esc(u.username)}${isYou ? ' <span class="pill pill-raw">you</span>' : ''}</td>
<td class="num">${u.grades}</td>
<td class="num">${money(u.server_cost)}</td>
<td class="num cardcell-meta">${money(u.own_cost)}</td>
<td class="cardcell-meta cell-when">${esc(fmtWhen(u.last_used))}</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, { hideTitle: true })}
<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-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
<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;
}
/* --------------------------------------------------------------- regrade */
//
// Instant when the card's original photo(s) were saved (everything graded
// from now on); otherwise falls back to picking the photo(s) again, same as
// grading fresh, just updating this row instead of creating a new one.
let regradeTargetId = null;
function applyRegradeResult(g) {
const idx = state.history.findIndex((row) => row.id === g.id);
if (idx === -1) state.history.unshift(g); else state.history[idx] = g;
// created_at was bumped server-side to reflect the just-finished regrade,
// so re-sort to match — otherwise the card sits wherever it used to be
// until the next full reload.
state.history.sort((a, b) =>
(b.created_at || '').localeCompare(a.created_at || '') || b.id - a.id);
renderHistory();
const openDelete = $('#entry-modal').querySelector('[data-history-delete]');
if (!$('#entry-modal').hidden && openDelete && Number(openDelete.dataset.historyDelete) === g.id) {
renderEntryModal(g);
}
}
async function runRegrade(id, body) {
banner('Regrading…');
try {
const data = await api(`/api/history/${id}/regrade`, { method: 'POST', body });
applyRegradeResult(data.grade);
loadUsage().catch(() => {}); // a regrade is billed too
banner('Regraded.');
} catch (err) {
banner(`Regrade failed: ${err.message}`, true);
}
}
function startRegrade(id, hasImages) {
if (hasImages) {
runRegrade(id, {});
} else {
// Say why the picker is opening. Otherwise a Regrade that silently
// asks for a photo looks broken — especially on a card that regraded
// instantly last week, before its stored photo aged out.
banner("This card's photo is no longer stored — pick it again to regrade.");
regradeTargetId = id;
$('#regrade-file').click();
}
}
$('#regrade-file').addEventListener('change', async (e) => {
const id = regradeTargetId;
regradeTargetId = null;
if (!id) return;
let picked;
try {
picked = await readPickedImages(e.target, 6);
} catch (err) {
banner(err.message, true);
return;
}
if (!picked.length) return;
await runRegrade(id, { images: pickedToPayload(picked) });
});
$('#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 regrade = e.target.closest('[data-history-regrade]');
if (regrade) {
e.stopPropagation();
startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1');
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;
}
const regrade = e.target.closest('[data-history-regrade]');
if (regrade) {
startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1');
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 isAdmin = s.is_admin;
$('#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 keys</h3>
<div class="fields">
<div class="field wide">
<label>Anthropic key (this browser only)</label>
<input class="input" id="set-my-key-anthropic" type="password"
value="${esc(myApiKey('anthropic') || '')}" placeholder="sk-ant-...">
<span class="suffix">Used when the selected model is a Claude model. Stored only in
this browser, never saved on the server. Get one at console.anthropic.com.</span>
</div>
<div class="field wide">
<label>OpenAI key (this browser only)</label>
<input class="input" id="set-my-key-openai" type="password"
value="${esc(myApiKey('openai') || '')}" placeholder="sk-...">
<span class="suffix">Used when the selected model is GPT-5.6 Sol. Same deal — this
browser only. Get one at platform.openai.com.</span>
</div>
<div class="field wide">
<span class="suffix">${
(s.anthropic_key_configured || s.openai_key_configured)
? 'This server already has a key set up for at least one provider, so you can leave the matching field above blank and use that instead — but then the owner pays for your grades.'
: 'This server has no key of its own yet, so you need one above to grade anything.'
}</span>
</div>
</div>
</div>
<div class="section">
<h3>Server settings${isAdmin ? '' : ' <span class="pill pill-raw">locked</span>'}</h3>
${!isAdmin ? `<div class="note">Only the admin can change server settings on this
instance. Use your own key above.</div>` : `
<div class="fields">
<div class="field wide">
<label>Server Anthropic key (used when a visitor has none, and the model is Claude)</label>
<input class="input" id="set-api-key-anthropic" type="password"
placeholder="${s.anthropic_key_configured ? '•••••••• already set — type to replace' : 'sk-ant-...'}">
</div>
<div class="field wide">
<label>Server OpenAI key (used when a visitor has none, and the model is GPT-5.6 Sol)</label>
<input class="input" id="set-api-key-openai" type="password"
placeholder="${s.openai_key_configured ? '•••••••• already set — type to replace' : 'sk-...'}">
<span class="suffix">Neither key is ever sent back to a browser once saved. Leave a
field blank to keep whatever's already stored for it.</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. GPT-5.6 Sol hasn't been run against real cards here yet, so treat
its results with more scrutiny until it has.</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.
Keyed per provider since a Claude key and an OpenAI key aren't
interchangeable and someone may reasonably hold both. */
function myApiKey(provider) {
try {
if (provider === 'anthropic') {
// One-time migration: friends who set a key before OpenAI support
// existed had it under the old unprefixed name. Move it once rather
// than losing it.
const legacy = localStorage.getItem('cardgrader_api_key');
if (legacy && !localStorage.getItem('cardgrader_api_key_anthropic')) {
localStorage.setItem('cardgrader_api_key_anthropic', legacy);
localStorage.removeItem('cardgrader_api_key');
}
}
return localStorage.getItem(`cardgrader_api_key_${provider}`) || '';
} catch (_) { return ''; }
}
function setMyApiKey(provider, value) {
try {
const key = `cardgrader_api_key_${provider}`;
if (value) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch (_) { /* private browsing — the field just won't persist */ }
}
// Which of the two personal keys applies to whatever model is actually
// going to run — the currently configured default, unless a specific grade
// requests a different one (nothing does yet, but the lookup is already
// provider-aware for when it does).
function myApiKeyForModel(modelId) {
const provider = (state.modelGuide[modelId] || {}).provider || 'anthropic';
return myApiKey(provider);
}
function closeSettings() {
$('#settings-modal').hidden = true;
$('#modal-scrim').hidden = true;
}
async function saveSettings() {
setMyApiKey('anthropic', $('#set-my-key-anthropic').value.trim());
setMyApiKey('openai', $('#set-my-key-openai').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 modelField = $('#set-model');
if (modelField) {
const payload = { vision_model: modelField.value };
const typedAnthropic = $('#set-api-key-anthropic').value.trim();
const typedOpenai = $('#set-api-key-openai').value.trim();
if (typedAnthropic) payload.anthropic_api_key = typedAnthropic;
if (typedOpenai) payload.openai_api_key = typedOpenai;
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();
loadUsage().catch(() => {});
const activeProvider = (state.modelGuide[state.settings.vision_model] || {}).provider || 'anthropic';
const serverHasKey = activeProvider === 'openai'
? state.settings.openai_key_configured : state.settings.anthropic_key_configured;
if (!serverHasKey && !myApiKey(activeProvider)) {
banner(`Add your ${activeProvider === 'openai' ? 'OpenAI' : '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;
}
});