Files
MangaCircus/public/collections.js

127 lines
12 KiB
JavaScript

'use strict';
// randomUUID is secure-context-only; tailnet HTTP and LAN IPs need this fallback.
function collectionId() {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
(async () => {
let me, data;
try { [me, data] = await Promise.all([api('api/me'), api('api/admin/catalog')]); } catch (e) { return showError(e); }
topNav(me);
let selected = new URLSearchParams(location.search).get('id');
const main = settingsShell(me, 'admin/collections').content;
const message = h('p', { role: 'status', 'aria-live': 'polite' });
let dirty = false, busy = false, workspace, jsonDraft = null;
const notice = (text, error = false) => {
message.textContent = text; message.className = error ? 'error' : '';
if (error) message.scrollIntoView({ block: 'nearest' });
};
const changed = () => { dirty = true; notice('Unsaved changes — save the collection to apply them.'); };
addEventListener('beforeunload', event => { if (dirty || jsonDraft !== null) { event.preventDefault(); event.returnValue = ''; } });
async function request(endpoint, body, success = 'Saved', replaceJson = false) {
if (busy) return false;
if (jsonDraft !== null && !replaceJson) { notice('Validate & save JSON first, or Reload catalog to discard the JSON draft.', true); return false; }
busy = true; workspace.disabled = true; main.setAttribute('aria-busy', 'true'); notice('Saving…');
try {
data = { ...data, ...await api(endpoint, body) }; dirty = false; jsonDraft = null; busy = false; draw(); notice(success); return true;
} catch (e) { notice(e.message + (dirty ? ' Your unsaved changes are still here.' : ''), true); return false; }
finally { busy = false; workspace.disabled = false; main.removeAttribute('aria-busy'); }
}
const persist = () => request('api/admin/catalog', data);
const cachedRequest = endpoint => dirty || jsonDraft !== null
? notice('Save your changes before importing or researching metadata.', true)
: request(endpoint, { m: researchManga, revision: data.revision });
let researchManga;
const select = (items, value, change, label = 'Catalog selection') => {
const input = h('select', { 'aria-label': label, onchange: e => change(e.target.value) }, items.map(item => h('option', { value: typeof item === 'object' ? item.id : item }, typeof item === 'object' ? item.title : item)));
input.value = value; return input;
};
function draw() {
main.textContent = '';
workspace = h('fieldset', { class: 'catalog-workspace', disabled: busy });
main.append(h('p', { class: 'muted' }, 'Collections organize the library. Relationships describe works. Reading order is your choice.'), message, workspace);
workspace.append(h('div', { class: 'row' }, select([{ id: '', title: 'Choose collection' }, ...data.collections], selected || '', value => { selected = value; draw(); }, 'Choose collection'),
h('button', { class: 'btn', onclick: () => {
const unused = data.mangas.find(m => !data.collections.some(c => c.members.some(member => member.mangaId === m.id)));
if (!unused) { notice(data.mangas.length ? 'All manga are already grouped. Remove a manga from its current collection before creating another.' : 'Add manga to the library before creating a collection.', true); return; }
try {
const c = { id: collectionId(), title: 'New collection', description: '', members: [{ mangaId: unused.id, role: 'main', readingOrder: 1 }], coverMode: 'selected', coverMangaId: unused.id, grouped: true };
data.collections.push(c); selected = c.id; changed(); draw(); $('input[aria-label="Collection title"]', main).focus();
} catch (e) { notice(e.message, true); }
} }, 'New collection'),
h('button', { class: 'btn', onclick: async () => {
if ((dirty || jsonDraft !== null) && !confirm('Discard unsaved catalog changes and reload?')) return;
await request('api/admin/catalog', undefined, 'Catalog reloaded', true);
} }, 'Reload catalog'), h('button', { class: 'btn', onclick: persist }, 'Save all changes')));
const c = data.collections.find(c => c.id === selected);
if (c) {
const title = h('input', { value: c.title, 'aria-label': 'Collection title', oninput: e => { c.title = e.target.value; changed(); } });
const description = h('textarea', { 'aria-label': 'Collection description', oninput: e => { c.description = e.target.value; changed(); } }, c.description);
const file = h('input', { type: 'file', accept: 'image/jpeg,image/png,image/webp,image/gif,image/avif,image/bmp', 'aria-label': 'Custom cover', onchange: async e => {
const chosen = e.target.files[0]; if (!chosen) return;
if (chosen.size > 600000) { notice('Choose an image under 600 KB.', true); return; }
// Read first, then serialize save + upload so no other edit can race its revision.
let base64;
try { base64 = await new Promise((resolve, reject) => {
const reader = new FileReader(); reader.onerror = () => reject(new Error('Could not read this image.'));
reader.onload = () => resolve(reader.result.split(',')[1]); reader.readAsDataURL(chosen);
}); } catch (err) { notice(err.message, true); return; }
if (!await persist()) return;
await request('api/admin/collection-cover', { id: c.id, revision: data.revision, base64 }, 'Cover saved');
} });
const members = h('div', { class: 'collection-editor' });
for (const member of c.members) members.append(h('div', { class: 'row', 'data-member': member.mangaId },
h('span', { class: 'grow' }, data.mangas.find(m => m.id === member.mangaId)?.title || member.mangaId),
select(data.roles, member.role, value => { member.role = value; changed(); }, 'Member role'),
h('input', { type: 'number', min: 0, value: member.readingOrder, 'aria-label': 'Reading order', oninput: e => { member.readingOrder = +e.target.value; changed(); } }),
h('button', { class: 'btn small', disabled: c.members.length === 1, title: c.members.length === 1 ? 'A collection needs a member. Delete the collection to ungroup its last manga.' : 'Remove from this collection', onclick: () => { c.members = c.members.filter(m => m !== member); if (c.coverMangaId === member.mangaId) c.coverMangaId = c.members[0]?.mangaId; changed(); draw(); } }, 'Remove')));
const available = data.mangas.filter(m => !data.collections.some(group => group.members.some(member => member.mangaId === m.id)));
const add = select([{ id: '', title: available.length ? 'Add a manga…' : 'No ungrouped manga available' }, ...available], '', value => { if (value) { c.members.push({ mangaId: value, role: 'side-story', readingOrder: Math.max(0, ...c.members.map(m => m.readingOrder)) + 1 }); changed(); draw(); } }, 'Add manga to collection');
add.disabled = !available.length;
workspace.append(h('section', { class: 'card' }, h('h2', {}, 'Collection'), title, description,
h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: c.grouped, onchange: e => { c.grouped = e.target.checked; changed(); } }), 'Group on the dashboard'),
h('div', { class: 'row' }, h('span', {}, 'Cover'), select(['selected', 'mosaic', ...(c.customCover ? ['custom'] : [])], c.coverMode, value => { c.coverMode = value; changed(); }, 'Cover mode'),
select(c.members.map(member => ({ id: member.mangaId, title: data.mangas.find(m => m.id === member.mangaId)?.title || member.mangaId })), c.coverMangaId, value => { c.coverMangaId = value; changed(); }, 'Cover manga'), file),
h('p', { class: 'muted small' }, 'Uploading a custom cover saves your changes first. A collection must contain at least one manga.'),
h('h3', {}, 'Members & reading order'), members, add,
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: persist }, 'Save collection'),
h('button', { class: 'btn danger', onclick: async () => { if (!confirm('Remove this collection? Its manga and reading progress remain.')) return; data.collections = data.collections.filter(group => group !== c); selected = ''; changed(); if (!await persist()) draw(); } }, 'Delete collection'))));
}
const from = select(data.mangas, data.mangas[0]?.id, () => {}, 'Relationship source'), to = select(data.mangas, data.mangas[1]?.id, () => {}, 'Relationship target'), type = select(data.types, 'sequel', () => {}, 'Relationship type');
workspace.append(h('section', { class: 'card' }, h('h2', {}, 'Relationships'),
h('p', { class: 'muted' }, 'Direction: the target is a sequel/prequel/etc. of the source. Suggestions need review; accepting a relationship does not create a collection.'),
h('div', { class: 'row' }, from, h('span', {}, '→'), type, h('span', {}, '→'), to,
h('button', { class: 'btn', onclick: () => {
if (dirty || jsonDraft !== null) return notice('Save your collection changes before editing relationships.', true);
if (from.value === to.value) return notice('Choose two different manga for a relationship.', true);
if (data.relations.some(r => r.from === from.value && r.to === to.value && r.type === type.value)) return notice('That relationship already exists.', true);
data.relations.push({ from: from.value, to: to.value, type: type.value, state: 'confirmed', method: 'manual', evidence: [], explanation: '' }); changed(); persist();
} }, 'Add relationship')),
h('button', { class: 'btn', onclick: () => cachedRequest('api/admin/catalog/suggest') }, 'Import cached metadata suggestions'),
h('div', { class: 'collection-editor' }, data.relations.map(r => h('div', { 'data-relation': JSON.stringify([r.from, r.to, r.type]) },
h('p', {}, `${data.mangas.find(m => m.id === r.from)?.title || r.from} → ${r.type} → ${data.mangas.find(m => m.id === r.to)?.title || r.to}`),
h('p', { class: 'muted small' }, r.explanation),
r.evidence.map(e => h('a', { href: safeHref(e.url), target: '_blank', rel: 'noopener', class: 'btn small' }, e.provider || 'Evidence')),
select(['suggested', 'confirmed', 'rejected', 'conflicted'], r.state, value => {
if (dirty || jsonDraft !== null) { notice('Save your collection changes before editing relationships.', true); draw(); return; }
r.state = value; changed(); persist();
}, 'Relationship state'),
h('button', { class: 'btn small', onclick: () => {
if (dirty || jsonDraft !== null) return notice('Save your collection changes before editing relationships.', true);
data.relations = data.relations.filter(item => item !== r); changed(); persist();
} }, 'Remove'))))));
researchManga = researchManga || data.mangas[0]?.id;
const research = select(data.mangas, researchManga, value => researchManga = value, 'Research manga');
workspace.append(h('section', { class: 'card' }, h('h2', {}, 'Public metadata research'),
h('p', { class: 'muted' }, 'Uses the verified AniList ID and a 30-day cache. Results remain suggestions. An agent can use the JSON export to research conflicts and propose edits.'),
h('div', { class: 'row' }, research, h('button', { class: 'btn', onclick: () => cachedRequest('api/admin/catalog/research') }, 'Research relationships'))));
const json = h('textarea', { class: 'catalog-json', 'aria-label': 'Catalog JSON', oninput: e => { jsonDraft = e.target.value; notice('Unsaved JSON — use Validate & save JSON to apply it.'); } }, jsonDraft ?? JSON.stringify({ version: 1, revision: data.revision, collections: data.collections, relations: data.relations }, null, 2));
workspace.append(h('details', { class: 'card' }, h('summary', {}, 'Advanced · JSON import/export & agent research packet'), json,
h('button', { class: 'btn', onclick: async () => { try { const parsed = JSON.parse(json.value); await request('api/admin/catalog', parsed, 'Saved', true); } catch (e) { notice(e.message, true); } } }, 'Validate & save JSON')));
}
draw();
})();