← back to Paul Conrad Cartoons Shadowman
public/shadowman.js
127 lines
// Inkwell — Shadow Man gallery (TK-12241).
// Original single-panel editorial cartoons, written from archive THEMES and eras (never a redraw of
// any specific archival cartoon), rendered locally ($0 SDXL) and signed "Shadow Man".
// Loads /api/shadowman and renders every kept piece INLINE with theme + era filters, a sort select
// and a density slider (all persisted to localStorage), plus curation via curate.js: select 1..many,
// big Approve / Delete buttons per card, a sticky bulk bar, and a Show-deleted/Restore toggle.
// Every card carries a created date+time chip (admin rule) with the ISO value in title=.
(function () {
const LS = { theme: 'inkwell:sm:theme', era: 'inkwell:sm:era', sort: 'inkwell:sm:sort', density: 'inkwell:sm:density', deleted: 'inkwell:sm:deleted' };
const get = (k, d) => { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } };
const set = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* ignore */ } };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const fmtDate = (iso) => {
const d = new Date(iso);
return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
};
let ITEMS = [];
const state = { theme: get(LS.theme, 'all'), era: get(LS.era, 'all'), sort: get(LS.sort, 'newest'), density: get(LS.density, 280), deleted: get(LS.deleted, false) };
let curate = null;
function sorted(list) {
const a = [...list];
const t = (x) => new Date(x.created_at).getTime() || 0;
switch (state.sort) {
case 'oldest': return a.sort((x, y) => t(x) - t(y) || x.id.localeCompare(y.id));
case 'title-az': return a.sort((x, y) => x.title.localeCompare(y.title));
case 'theme': return a.sort((x, y) => x.theme.localeCompare(y.theme) || x.title.localeCompare(y.title));
case 'era': return a.sort((x, y) => x.era.localeCompare(y.era) || x.title.localeCompare(y.title));
case 'newest':
default: return a.sort((x, y) => t(y) - t(x) || y.id.localeCompare(x.id));
}
}
function card(it) {
const ctl = InkCurate.cardControls(it, curate && curate.selected.has(it.id));
const img = it.status === 'deleted'
? '<div class="no-img">Deleted — image moved to trash</div>'
: `<img src="${esc(it.src)}" alt="${esc(it.title)} — ${esc(it.caption)}" loading="lazy">`;
return `<article class="card sm-card status-${esc(it.status || 'pending')}" data-id="${esc(it.id)}" data-theme="${esc(it.theme)}" data-era="${esc(it.era)}" tabindex="0">
<div class="curate-top">${ctl.top}</div>
<div class="thumb">${img}</div>
<div class="body">
<div class="title">${esc(it.title)}</div>
<span class="when" title="${esc(it.created_at)}">🕓 ${esc(fmtDate(it.created_at))}</span>
<div class="caption">${esc(it.caption)}</div>
<div class="badges"><span class="topic-tag sm-theme">${esc(it.theme)}</span><span class="badge sm-era">${esc(it.era)}</span></div>
</div>
${ctl.actions}
</article>`;
}
function fillSelect(el, values, current, allLabel) {
el.innerHTML = `<option value="all">${esc(allLabel)}</option>` + values.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
el.value = values.includes(current) ? current : 'all';
return el.value;
}
function visible() {
return sorted(ITEMS.filter(i => (state.deleted || i.status !== 'deleted') && (state.theme === 'all' || i.theme === state.theme) && (state.era === 'all' || i.era === state.era)));
}
function render() {
const list = visible();
const approved = ITEMS.filter(i => i.status === 'approved').length;
const deleted = ITEMS.filter(i => i.status === 'deleted').length;
document.getElementById('sm-count').textContent = `${list.length} shown · ${approved} approved · ${deleted} deleted`;
const grid = document.getElementById('sm-grid');
grid.innerHTML = list.length ? list.map(card).join('') : '<div class="empty-note">Nothing matches this filter.</div>';
grid.querySelectorAll('.sm-card').forEach(c => {
c.addEventListener('click', (e) => { if (!e.target.closest('.curate-top, .card-actions')) open(c.dataset.id); });
c.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === c) open(c.dataset.id); });
});
if (curate) curate.syncBar();
}
function open(id) {
const it = ITEMS.find(x => x.id === id);
if (!it) return;
document.getElementById('modal-body').innerHTML = `
<button class="close-x" id="modal-close">×</button>
<h2>${esc(it.title)}</h2>
<div class="modal-meta"><span class="when" title="${esc(it.created_at)}">🕓 ${esc(fmtDate(it.created_at))}</span> · ${esc(it.theme)} · ${esc(it.era)}</div>
${it.status === 'deleted' ? '<p class="modal-rep-note">Deleted — image is in the trash folder until restored.</p>' : `<img class="p24-full" src="${esc(it.src)}" alt="${esc(it.title)}">`}
<p>${esc(it.caption)}</p>
<dl><dt>Signed</dt><dd>${esc(it.signature)}</dd><dt>Scene</dt><dd>${esc(it.scene)}</dd><dt>Made with</dt><dd>${esc(it.model)} · ${esc(it.gen_seconds)}s · $${esc(it.cost_usd)}</dd></dl>
<p class="rights-note">Original AI-generated cartoon written from an archive theme and era; not a redraw of any published cartoon.</p>`;
document.getElementById('modal-backdrop').classList.add('open');
document.getElementById('modal-close').addEventListener('click', () => document.getElementById('modal-backdrop').classList.remove('open'));
}
function init() {
const themeEl = document.getElementById('sm-theme');
const eraEl = document.getElementById('sm-era');
const sortEl = document.getElementById('sm-sort');
const densEl = document.getElementById('sm-density');
const delEl = document.getElementById('sm-show-deleted');
delEl.checked = !!state.deleted;
delEl.addEventListener('change', () => { state.deleted = delEl.checked; set(LS.deleted, state.deleted); render(); });
curate = InkCurate.attach({
api: '/api/shadowman', label: 'cartoon', deleteNote: 'Its image moves to the trash folder.',
grid: document.getElementById('sm-grid'), bar: document.getElementById('sm-bulk'),
setItems: (items) => { ITEMS = items; }, render, visibleIds: () => visible().map(i => i.id),
onError: (msg) => { document.getElementById('sm-count').textContent = 'Action failed — ' + msg; },
});
state.theme = fillSelect(themeEl, [...new Set(ITEMS.map(i => i.theme))].sort(), state.theme, 'All themes');
state.era = fillSelect(eraEl, [...new Set(ITEMS.map(i => i.era))].sort(), state.era, 'All eras');
sortEl.value = state.sort;
densEl.value = state.density;
document.documentElement.style.setProperty('--sm-min', state.density + 'px');
themeEl.addEventListener('change', () => { state.theme = themeEl.value; set(LS.theme, state.theme); render(); });
eraEl.addEventListener('change', () => { state.era = eraEl.value; set(LS.era, state.era); render(); });
sortEl.addEventListener('change', () => { state.sort = sortEl.value; set(LS.sort, state.sort); render(); });
densEl.addEventListener('input', () => {
state.density = Number(densEl.value);
document.documentElement.style.setProperty('--sm-min', state.density + 'px');
set(LS.density, state.density);
});
}
fetch('/api/shadowman')
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(doc => { ITEMS = doc.items || []; init(); render(); })
.catch(err => { document.getElementById('sm-grid').innerHTML = `<div class="empty-note">Could not load Shadow Man cartoons — ${esc(err.message)}</div>`; });
})();