Files
OneCAD/assets/icons/ui/design/tools/review/review.js
T

286 lines
14 KiB
JavaScript

// No framework, build step, external resources or model integration.
const S = @@STATE@@;
const fin = new Set(S.final);
const notes = {...S.notes};
let base = {final: [...S.final].sort(), notes: {...notes}};
let filter = 'open', tab = '', dirty = false, timer;
let saves = Promise.resolve();
const query = document.getElementById('q');
const message = document.getElementById('msg');
const cards = [...document.querySelectorAll('.card')];
const markers = [...document.querySelectorAll('.gm')];
const staticKey = 'icon-review:' + S.rev;
const prefsKey = 'icon-review-preferences';
const reloadKey = 'icon-review-reload-view';
function state() {
return {final: [...fin].sort(), notes: Object.fromEntries(
Object.entries(notes).filter(([, value]) => value.trim()))};
}
function payload(snapshot) { return JSON.stringify({base, ...snapshot, rev: S.rev}); }
function showMessage(text, error = false) {
message.textContent = text;
message.classList.toggle('error', error);
}
function run() {
let finals = 0, noted = 0, visible = 0;
for (const card of cards) {
const id = card.dataset.id;
const accepted = fin.has(id), hasNote = Boolean((notes[id] || '').trim());
card.classList.toggle('final', accepted);
card.classList.toggle('hasnote', hasNote && !accepted);
card.querySelector('.ck').title = accepted ? 'Reopen for review' : 'Accept as final';
card.querySelector('.ck').setAttribute('aria-label', (accepted ? 'Reopen ' : 'Accept ') + id + (accepted ? '' : ' as final'));
if (accepted) finals++;
if (hasNote && !accepted) noted++;
const matches = (filter === 'all' || (filter === 'open' && !accepted) ||
(filter === 'notes' && hasNote && !accepted) || (filter === 'final' && accepted)) &&
(!tab || card.dataset.tab === tab) && card.dataset.k.includes(query.value.toLowerCase());
card.hidden = !matches;
if (matches) visible++;
}
for (const marker of markers) {
let next = marker.nextElementSibling, any = false;
while (next && !next.classList.contains('gm')) {
if (!next.hidden) any = true;
next = next.nextElementSibling;
}
marker.hidden = !any;
}
document.getElementById('cnt').textContent = `${finals} final · ${cards.length - finals} open · ${noted} with notes`;
document.getElementById('empty').hidden = visible > 0;
}
function scheduleSave() {
dirty = true;
clearTimeout(timer);
timer = setTimeout(flush, 250);
}
async function flush() {
clearTimeout(timer);
const snapshot = state();
saves = saves.catch(() => {}).then(async () => {
if (!S.serving) {
localStorage.setItem(staticKey, JSON.stringify(snapshot));
dirty = JSON.stringify(snapshot) !== JSON.stringify(state());
showMessage('Static preview: Export downloads the review file. Use --serve to save to the repository.');
return;
}
const response = await fetch('/api/review', {method: 'POST', body: payload(snapshot)});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Save failed');
base = JSON.parse(JSON.stringify(snapshot));
dirty = JSON.stringify(snapshot) !== JSON.stringify(state());
showMessage('Saved');
});
try { await saves; return true; }
catch (error) { showMessage(error.message, true); return false; }
}
function view() {
return {filter, tab, q: query.value, y: scrollY};
}
function preferences() {
return {dark: document.body.classList.contains('dark'), vision: document.body.dataset.vision, size: document.documentElement.style.getPropertyValue('--sz')};
}
function savePreferences() { try { localStorage.setItem(prefsKey, JSON.stringify(preferences())); } catch {} }
function setSize(size) {
document.documentElement.style.setProperty('--sz', size + 'px');
document.body.classList.toggle('small', size <= 24);
document.querySelectorAll('.sz').forEach(button => button.classList.toggle('on', +button.dataset.s === size));
}
function selectFilter(value) {
filter = value;
document.querySelectorAll('[data-f]').forEach(button => button.classList.toggle('on', button.dataset.f === value));
}
function selectTab(value) {
tab = value;
document.querySelectorAll('.chip').forEach(button => button.classList.toggle('on', (button.dataset.tab || '') === value));
}
// Temporary palette editor: CSS variables recolour every rendered theme, size
// and vision variant without touching SVGs, notes or acceptance hashes.
const paletteDraftKey = 'icon-review-palette-draft';
const paletteStatus = document.getElementById('palette-status');
const paletteApply = document.getElementById('palette-apply');
const paletteReset = document.getElementById('palette-reset');
const palettePaints = Object.fromEntries(S.palette.tokens.filter(token => !token.linked && !token.knockout)
.map(token => [token.id, {light: token.light, dark: token.dark}]));
const paletteInputs = [];
let paletteDirty = false, paletteSaving = false;
function paletteVariable(id, theme) { return '--icon-palette-' + id.replaceAll('.', '-') + '-' + theme; }
function paletteChanges() {
return Object.fromEntries(S.palette.tokens.filter(token => !token.linked && !token.knockout &&
['light', 'dark'].some(theme => palettePaints[token.id][theme] !== token[theme]))
.map(token => [token.id, {...palettePaints[token.id]}]));
}
function updatePalette() {
for (const [id, paints] of Object.entries(palettePaints)) {
for (const theme of ['light', 'dark']) document.documentElement.style.setProperty(paletteVariable(id, theme), paints[theme]);
}
for (const {input, token, theme} of paletteInputs) {
input.value = palettePaints[token.linked || token.id][theme];
input.title = `${token.name} · ${theme}: ${input.value}${token.linked ? ' (follows construction stroke)' : ''}`;
}
paletteDirty = Object.keys(paletteChanges()).length > 0;
paletteApply.disabled = !paletteDirty || paletteSaving;
paletteReset.disabled = !paletteDirty || paletteSaving;
try {
if (paletteDirty) sessionStorage.setItem(paletteDraftKey, JSON.stringify({revision: S.palette.revision, changes: paletteChanges()}));
else sessionStorage.removeItem(paletteDraftKey);
} catch {}
}
for (const token of S.palette.tokens) {
const row = document.createElement('div'); row.className = 'palette-token';
for (const theme of ['light', 'dark']) {
if (token.knockout) {
const swatch = document.createElement('span'); swatch.className = 'palette-knockout';
swatch.title = token.name + ' · ' + theme + ': transparent cutout';
swatch.setAttribute('aria-label', swatch.title); row.appendChild(swatch); continue;
}
const input = document.createElement('input'); input.type = 'color'; input.value = token[theme];
input.disabled = Boolean(token.linked);
input.setAttribute('aria-label', token.name + ' ' + theme + ' colour');
input.oninput = () => {
palettePaints[token.id][theme] = input.value;
updatePalette(); paletteStatus.textContent = 'Preview only · Apply saves both themes to palette.toml';
paletteStatus.classList.remove('error');
};
paletteInputs.push({input, token, theme}); row.appendChild(input);
}
const label = document.createElement('span'); label.textContent = token.name + (token.linked ? ' ↗' : '');
row.appendChild(label); document.getElementById('palette-tokens').appendChild(row);
}
document.body.classList.add('palette-preview');
document.getElementById('palette-all').onclick = () => { selectFilter('all'); selectTab(''); query.value = ''; run(); };
paletteReset.onclick = () => {
for (const token of S.palette.tokens.filter(token => !token.linked && !token.knockout)) palettePaints[token.id] = {light: token.light, dark: token.dark};
updatePalette(); paletteStatus.textContent = 'Preview reset · saved palette unchanged'; paletteStatus.classList.remove('error');
};
paletteApply.onclick = async () => {
if (!paletteDirty || paletteSaving) return;
if (dirty && !await flush()) return;
if (!S.serving) {
paletteStatus.textContent = 'Preview only · run the review server to apply colours to palette.toml'; return;
}
paletteSaving = true; updatePalette();
try {
const response = await fetch('/api/palette', {method: 'POST', body: JSON.stringify({revision: S.palette.revision, changes: paletteChanges()})});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Palette save failed');
S.palette = {revision: result.revision, tokens: result.tokens};
paletteStatus.textContent = 'Saved to palette.toml · rebuild the app to use these colours';
paletteStatus.classList.remove('error');
} catch (error) { paletteStatus.textContent = error.message; paletteStatus.classList.add('error'); }
finally { paletteSaving = false; updatePalette(); }
};
try {
const draft = JSON.parse(sessionStorage.getItem(paletteDraftKey) || 'null');
if (draft?.revision === S.palette.revision) {
for (const [id, paints] of Object.entries(draft.changes)) {
if (palettePaints[id] && ['light','dark'].every(theme => /^#[0-9a-f]{6}$/i.test(paints[theme] || ''))) palettePaints[id] = {...paints};
}
paletteStatus.textContent = 'Unsaved palette preview restored';
} else if (draft) { paletteStatus.textContent = 'Saved palette changed · older preview was not restored'; }
} catch {}
updatePalette();
for (const card of cards) {
const id = card.dataset.id;
const pop = document.createElement('div');
pop.className = 'pop';
const textarea = document.createElement('textarea');
textarea.placeholder = 'What should change in this icon?';
textarea.setAttribute('aria-label', 'Notes for ' + id);
textarea.value = notes[id] || '';
pop.appendChild(textarea); card.appendChild(pop);
textarea.oninput = () => { notes[id] = textarea.value; run(); scheduleSave(); };
card.querySelector('.ck').onclick = () => {
if (card.dataset.missing === 'true') return;
fin.has(id) ? fin.delete(id) : fin.add(id);
card.classList.remove('open'); run(); scheduleSave();
};
card.querySelector('.nt').onclick = event => {
const open = !card.classList.contains('open');
cards.forEach(other => other.classList.remove('open'));
card.classList.toggle('open', open);
if (open) textarea.focus();
event.stopPropagation();
};
pop.onclick = event => event.stopPropagation();
}
document.addEventListener('click', () => cards.forEach(card => card.classList.remove('open')));
document.addEventListener('keydown', event => {
if (event.key === 'Escape') cards.forEach(card => card.classList.remove('open'));
});
document.querySelectorAll('[data-f]').forEach(button => button.onclick = () => { selectFilter(button.dataset.f); run(); });
document.querySelectorAll('.chip').forEach(button => button.onclick = () => { selectTab(button.dataset.tab || ''); run(); });
document.querySelectorAll('.sz').forEach(button => button.onclick = () => { setSize(+button.dataset.s); savePreferences(); });
document.getElementById('dk').onclick = function () {
document.body.classList.toggle('dark'); this.classList.toggle('on'); savePreferences();
};
document.getElementById('vision').onchange = event => {
document.body.dataset.vision = event.target.value; savePreferences();
};
query.oninput = run;
document.getElementById('rescan').onclick = async () => {
if (!dirty || await flush()) location.reload();
};
document.getElementById('exp').onclick = async () => {
if (dirty && !await flush()) return;
if (!S.serving) {
const snapshot = state();
const accepted = Object.fromEntries(snapshot.final.map(id => [id, S.hashes[id]]));
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([JSON.stringify({version: 1, final: accepted, notes: snapshot.notes}, null, 1)], {type: 'application/json'}));
link.download = 'review.json'; link.click(); URL.revokeObjectURL(link.href);
return;
}
try {
await saves;
const snapshot = state();
const response = await fetch('/api/export', {method: 'POST', body: payload(snapshot)});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Export failed');
base = JSON.parse(JSON.stringify(snapshot));
showMessage(result.sent ? `Queued ${result.sent} note${result.sent === 1 ? '' : 's'} in the local review inbox` : 'No open notes to export');
} catch (error) { showMessage(error.message, true); }
};
try {
if (!S.serving) {
const saved = JSON.parse(localStorage.getItem(staticKey) || 'null');
if (saved) { fin.clear(); saved.final.forEach(id => fin.add(id)); Object.assign(notes, saved.notes); }
}
const prefs = JSON.parse(localStorage.getItem(prefsKey) || 'null');
if (prefs) {
if (prefs.vision && ['off', 'red-green', 'blue-yellow'].includes(prefs.vision)) { document.body.dataset.vision = prefs.vision; document.getElementById('vision').value = prefs.vision; }
if (prefs.dark) document.getElementById('dk').click();
const size = parseInt(prefs.size);
if ([16,24,48,64].includes(size)) setSize(size);
}
// Only an automatic artwork reload restores filters. A normal opening always
// starts at Open / Everything, so yesterday's filter cannot hide new artwork.
const restored = JSON.parse(sessionStorage.getItem(reloadKey) || 'null');
sessionStorage.removeItem(reloadKey);
if (restored) {
selectFilter(restored.filter); selectTab(restored.tab); query.value = restored.q;
requestAnimationFrame(() => scrollTo(0, restored.y));
}
} catch {}
run();
if (S.serving) setInterval(async () => {
if (dirty || paletteDirty || paletteSaving || document.activeElement?.tagName === 'TEXTAREA') return;
try {
const response = await fetch('/api/review');
const current = await response.json();
if (current.rev && current.rev !== S.rev) {
sessionStorage.setItem(reloadKey, JSON.stringify(view()));
location.reload(); return;
}
if (JSON.stringify([current.final, current.notes]) !== JSON.stringify([[...fin].sort(), notes])) {
fin.clear(); current.final.forEach(id => fin.add(id));
Object.keys(notes).forEach(id => delete notes[id]); Object.assign(notes, current.notes);
base = {final: [...current.final].sort(), notes: {...notes}};
cards.forEach(card => { card.querySelector('textarea').value = notes[card.dataset.id] || ''; });
run();
}
} catch { showMessage('Review server unavailable; reconnect before saving.', true); }
}, 3000);