pediatric-ai-scribe-v3/public/js/myResources.js
Daniel d2a06b0fcf feat: modifying a resource is a job, the same as generating one
Modify held the request open for a library search, a PubMed search, a web
search and a restating model call. That is minutes, and a browser gives up
first — Firefox abandons a non-streaming fetch at five minutes, the same
failure generating was moved off the request to fix in ef574edd. The server
carried on and saved the result while the person watched an error, and closing
the tab killed the work outright.

POST /my-resources/:id/refine now records the request and answers 202 with the
job, exactly as /generate does. The writing moved into refineResource(), which
the job runner dispatches to by kind; the job list, the five-second polling,
the restart recovery and the three-in-flight cap are all the work they already
did, unchanged. Ownership is checked again inside refineResource because the
resource can be deleted while the job waits.

The page follows the job instead of the response. Reporting is unchanged — the
unchanged reply, what was seen and what was searched — it is only said from the
job list now, so it still reaches the person who asked for it after a reload.
2026-09-16 23:28:42 +02:00

1346 lines
63 KiB
JavaScript

// ============================================================
// MY RESOURCES
// A person's own generated teaching material.
//
// Private to whoever made it, and needing no role beyond being signed in. The
// server enforces that independently — every query there filters on the owner —
// so this only has to be an honest interface to it.
// ============================================================
(function () {
var inited = false;
// A model an administrator adds has to show up here too, without a reload.
// loadOptions also decides whether the model row is shown at all, so this is
// the same call rather than a partial refresh of the select.
document.addEventListener('models-changed', function () {
if (inited) loadOptions();
});
document.addEventListener('tabChanged', function (e) {
if (!e.detail || e.detail.tab !== 'myresources') return;
if (!inited) { init(); inited = true; }
// Reopening the tab is a fresh start, not a resumed one. The DOM survives
// the visit, so without this the previous run's illustration and status sit
// under an empty form as though they belonged to it.
else clearResults();
acceptPendingShare();
loadLibrary();
refreshJobs();
});
// What an administrator has switched on. Read once, used by both option
// groups and by the instruction check.
var available = { images: false, web: false, pubmed: false };
// Someone who writes "add a diagram of the airway" in the instructions has
// said what they want as plainly as ticking the box would. Left alone, the
// request is simply dropped and the resource comes back without a picture
// and without a word about why. These are the ways people actually ask.
var IMAGE_REQUEST = /\b(image|images|diagram|diagrams|illustration|illustrations|illustrate|illustrated|figure|figures|picture|pictures|drawing|drawings|chart|charts|graphic|graphics|infographic|visual|visuals|schematic)\b/i;
function looksLikeImageRequest(text) {
return IMAGE_REQUEST.test(String(text || ''));
}
// Turning the option on is the honest response: it does what was asked, it is
// visible, and it can be turned straight back off. Silently ignoring the
// sentence is not. Once it has been switched off by hand, it stays off —
// re-ticking it on every keystroke would be a fight.
function wireImageIntent(textareaId, checkboxId, hintId) {
var box = document.getElementById(textareaId);
var check = document.getElementById(checkboxId);
var hint = document.getElementById(hintId);
if (!box || !check || !hint) return;
var overruled = false;
check.addEventListener('change', function () {
if (!check.checked) overruled = true;
if (check.checked) hint.hidden = true;
});
box.addEventListener('input', function () {
var asked = looksLikeImageRequest(box.value);
if (!asked) { hint.hidden = true; return; }
if (!available.images) {
hint.textContent = 'Your instructions mention a figure, but no image model is configured, so none can be made.';
hint.hidden = false;
return;
}
if (check.checked || overruled) {
hint.hidden = overruled ? false : true;
if (overruled) hint.textContent = 'Your instructions ask for a figure. Tick the illustration option above to get one.';
return;
}
check.checked = true;
hint.textContent = 'Illustration switched on, because your instructions ask for a figure. Untick it if you would rather not.';
hint.hidden = false;
});
}
function init() {
var kind = document.getElementById('mr-kind');
if (kind) kind.addEventListener('change', syncFormatFields);
syncFormatFields();
var generate = document.getElementById('btn-mr-generate');
if (generate) generate.addEventListener('click', runGenerate);
var refresh = document.getElementById('btn-mr-refresh');
// Refreshes whichever view is showing, rather than always the documents.
if (refresh) refresh.addEventListener('click', function () {
var panel = document.getElementById('mr-images-panel');
if (panel && !panel.hidden) loadImages(true); else loadLibrary();
});
var docsTab = document.getElementById('tab-mr-docs');
var imagesTab = document.getElementById('tab-mr-images');
if (docsTab) docsTab.addEventListener('click', function () { showLibraryTab('docs'); });
if (imagesTab) imagesTab.addEventListener('click', function () { showLibraryTab('images'); });
var moreImages = document.getElementById('btn-mr-images-more');
if (moreImages) moreImages.addEventListener('click', function () { loadImages(false); });
// One delegated handler: rows are rebuilt on every refresh, so binding per
// row would leak listeners and miss anything added later.
var list = document.getElementById('mr-list');
if (list) list.addEventListener('click', onRowClick);
// Filtering is local: the whole library is already in hand, so searching it
// is instant and costs no request.
var search = document.getElementById('mr-search');
if (search) search.addEventListener('input', renderLibrary);
var modify = document.getElementById('btn-mr-modify');
if (modify) modify.addEventListener('click', runModify);
wireImageIntent('mr-refinement', 'mr-with-images', 'mr-image-hint');
wireImageIntent('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint');
loadOptions();
refreshJobs();
}
// What an administrator has approved. The model row stays hidden unless there
// is a genuine choice to make — one approved model is not a decision anyone
// should be asked to take.
function loadOptions() {
fetch('/api/my-resources/options', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data || !data.success) return;
var select = document.getElementById('mr-model');
var modelRow = document.getElementById('mr-model-row');
var models = data.models || [];
if (select) {
// Rebuilt, but a choice already made is kept: this also runs when an
// administrator adds a model, and moving someone off the model they
// picked mid-form would be worse than not refreshing at all.
var chosen = select.value;
select.textContent = '';
models.forEach(function (id) {
var option = document.createElement('option');
option.value = id;
option.textContent = id;
if (id === data.defaultModel) option.selected = true;
select.appendChild(option);
});
if (chosen && models.indexOf(chosen) !== -1) select.value = chosen;
}
if (modelRow) modelRow.hidden = models.length < 2;
// The theme catalogue. Structural slides carry no colour of their own,
// so this is the whole of a deck's styling — one choice rather than a
// colour field the model would have to guess a value for.
var themeSelect = document.getElementById('mr-theme');
var themeRow = document.getElementById('mr-theme-row');
var themes = Array.isArray(data.themes) ? data.themes : [];
themeCatalogue = themes;
nextcloudConnected = Boolean(data.nextcloudConnected);
if (themeSelect && themes.length) {
var chosenTheme = themeSelect.value;
themeSelect.textContent = '';
themes.forEach(function (t) {
var option = document.createElement('option');
option.value = t.id;
option.textContent = t.name;
option.title = t.description || '';
themeSelect.appendChild(option);
});
if (chosenTheme && themes.some(function (t) { return t.id === chosenTheme; })) {
themeSelect.value = chosenTheme;
}
describeTheme(themes);
themeSelect.onchange = function () { describeTheme(themes); };
}
if (themeRow) themeRow.hidden = themes.length < 2;
// The shape of the session. "Free" is the default: the model lays the
// deck out as it sees fit, which is what every deck was until now.
var formatSelect = document.getElementById('mr-format');
var formatRow = document.getElementById('mr-format-row');
var formats = Array.isArray(data.formats) ? data.formats : [];
if (formatSelect && formats.length) {
var chosenFormat = formatSelect.value;
formatSelect.textContent = '';
var free = document.createElement('option');
free.value = ''; free.textContent = 'Free — let the model choose';
formatSelect.appendChild(free);
formats.forEach(function (f) {
var option = document.createElement('option');
option.value = f.id;
option.textContent = f.name;
formatSelect.appendChild(option);
});
if (chosenFormat && formats.some(function (f) { return f.id === chosenFormat; })) {
formatSelect.value = chosenFormat;
}
describeFormat(formats);
formatSelect.onchange = function () { describeFormat(formats); };
}
if (formatRow) formatRow.hidden = !formats.length;
// Hidden entirely unless an administrator enabled it, so an option
// never appears that someone could tick and then be refused. Both
// groups are driven from the same answer: Generate and Modify offer
// the same choices, so they must offer the same ones.
available = {
images: Boolean(data.imagesAvailable),
web: Boolean(data.webSearchAvailable),
pubmed: Boolean(data.pubmedAvailable)
};
[['mr-images-row', 'images'], ['mr-web-row', 'web'], ['mr-pubmed-row', 'pubmed'],
['mr-modify-images-row', 'images'], ['mr-modify-web-row', 'web'],
['mr-modify-pubmed-row', 'pubmed']].forEach(function (pair) {
var row = document.getElementById(pair[0]);
if (row) row.hidden = !available[pair[1]];
});
})
.catch(function () { /* the defaults still work without this */ });
}
function syncFormatFields() {
var isArticle = (document.getElementById('mr-kind') || {}).value === 'article';
var slides = document.getElementById('mr-slide-count');
var words = document.getElementById('mr-word-wrap');
if (slides && slides.parentElement) slides.parentElement.hidden = isArticle;
if (words) words.hidden = !isArticle;
}
function status(text, tone) {
var el = document.getElementById('mr-status');
if (!el) return;
el.textContent = text || '';
el.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
// Everything the last run left on screen. The tab keeps its DOM between
// visits — app.js loads a component once and marks it data-loaded — so an
// illustration from a previous generation stayed visible under an empty form
// when the tab was reopened, looking like output for a topic nobody had
// typed. It cleared on a full page refresh and only then, which is why it
// read as a leak.
// A sample deck to download: every layout, filler text, this theme.
//
// Not a picture of one slide, and not a picture of their deck. The question a
// theme picker answers is what the thing will look like, and the file itself
// answers it better than any screenshot — opened in PowerPoint, at the size it
// will be shown, with the fonts actually substituted the way they will be.
//
// An ordinary link, so it is there whether or not anything renders.
function showThemeSample() {
var select = document.getElementById('mr-theme');
var box = document.getElementById('mr-theme-preview');
if (!select || !select.value || !box) return;
var name = (select.options[select.selectedIndex] || {}).text || 'this theme';
var link = box.querySelector('a') || document.createElement('a');
link.href = '/api/my-resources/theme-sample/' + encodeURIComponent(select.value);
link.textContent = 'Download a sample deck in ' + name;
link.title = 'Every slide layout, with placeholder text, as a PowerPoint file';
link.style.cssText = 'font-size:12px;color:var(--blue);text-decoration:none;';
if (!link.parentNode) box.appendChild(link);
// And the same sample as pages, here, for a phone with no PowerPoint.
var look = box.querySelector('button') || document.createElement('button');
look.type = 'button';
look.className = 'btn-sm btn-ghost';
look.style.cssText = 'margin-left:10px;font-size:12px;';
look.innerHTML = '<i class="fas fa-eye"></i> Preview';
look.onclick = function () { openPreview('/api/my-resources/theme-sample/' + encodeURIComponent(select.value) + '/preview', 'Sample deck: ' + name); };
if (!look.parentNode) box.appendChild(look);
box.hidden = false;
}
// Says who the chosen format is for and how long it runs.
function describeFormat(formats) {
var select = document.getElementById('mr-format');
var hint = document.getElementById('mr-format-hint');
if (!select || !hint) return;
var chosen = formats.filter(function (f) { return f.id === select.value; })[0];
// Audience and shape only: how long the session runs is the slide count
// above, which guides rather than dictates, and a second number here
// read as a contradiction of it.
hint.textContent = chosen
? [chosen.audience ? 'For ' + chosen.audience : '', chosen.description || ''].filter(Boolean).join('. ')
: 'The layouts are chosen slide by slide with no fixed order of parts.';
}
// Says what the chosen theme is for, which is the part a name cannot carry.
function describeTheme(themes) {
var select = document.getElementById('mr-theme');
var hint = document.getElementById('mr-theme-hint');
if (!select || !hint) return;
var chosen = themes.filter(function (t) { return t.id === select.value; })[0];
hint.textContent = chosen ? (chosen.description || '') : '';
showThemeSample();
}
function clearResults() {
['mr-images', 'mr-searches', 'mr-image-failures'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
status('');
}
function runGenerate() {
var topic = (document.getElementById('mr-topic') || {}).value || '';
if (!topic.trim()) { status('Enter a topic first.', 'bad'); return; }
clearResults();
var btn = document.getElementById('btn-mr-generate');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Queuing...'; }
// Queued, not awaited. Writing a deck takes minutes, and a browser that
// holds a request open that long gives up on its own — Firefox after five
// — and reported a network error over a deck the server went on to save.
// The server answers at once with a job; the list below follows it, the
// library refreshes when it lands, and nothing here depends on this tab
// staying open. Another can be started while one is being written.
var corpusBox = document.getElementById('mr-use-corpus');
fetch('/api/my-resources/generate', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
topic: topic.trim(),
kind: (document.getElementById('mr-kind') || {}).value || 'presentation',
slideCount: (document.getElementById('mr-slide-count') || {}).value,
wordCount: (document.getElementById('mr-word-count') || {}).value,
refinement: (document.getElementById('mr-refinement') || {}).value || '',
details: (document.getElementById('mr-details') || {}).value || '',
useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true',
model: (document.getElementById('mr-model') || {}).value || '',
theme: (document.getElementById('mr-theme') || {}).value || '',
format: (document.getElementById('mr-format') || {}).value || '',
withImages: (document.getElementById('mr-with-images') || {}).checked ? 'true' : 'false',
withWebSearch: (document.getElementById('mr-web-search') || {}).checked ? 'true' : 'false',
withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false'
})
})
.then(function (r) { return r.json().then(function (data) { return { ok: r.ok, data: data || {} }; }); })
.then(function (out) {
if (!out.ok || !out.data.success) throw new Error(out.data.error || 'Generation could not be started');
status('Queued. It is being written on the server — you can leave this page or start another. ' +
'It appears in your library when it is done.', 'good');
refreshJobs();
})
.catch(function (err) { status(err.message, 'bad'); })
.finally(function () {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate'; }
});
}
// ── Jobs: what is being written, what landed, what failed ──
//
// Polled while anything is in flight, five seconds apart; otherwise not at
// all. A job seen going from running to done is announced once, with what
// the finished request used to say, and the library is reloaded so the new
// row is there without a click.
var jobsSeen = {};
var jobsTimer = null;
function refreshJobs() {
clearTimeout(jobsTimer);
fetch('/api/my-resources/jobs', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
var jobs = (data && data.jobs) || [];
var landed = false;
jobs.forEach(function (job) {
var before = jobsSeen[job.id];
if (before && before !== job.status && (job.status === 'done' || job.status === 'failed')) {
landed = true;
if (job.status === 'done') announceDone(job);
else status('“' + job.topic + '” could not be written: ' + (job.error || 'Generation failed'), 'bad');
}
jobsSeen[job.id] = job.status;
});
renderJobs(jobs);
if (landed) loadLibrary();
var active = jobs.some(function (job) { return job.status === 'queued' || job.status === 'running'; });
if (active) jobsTimer = setTimeout(refreshJobs, 5000);
})
.catch(function () { jobsTimer = setTimeout(refreshJobs, 15000); });
}
function announceDone(job) {
var r = job.result || {};
var g = r.grounding || {};
var title = (r.resource && r.resource.title) || job.topic;
// A modification reports itself exactly as it did when the request waited for
// it; only where the answer arrives has changed.
if (job.kind === 'refine') {
// The model can return the document back unchanged. That is a failed
// modification, and saying "Applied" for it sent people off to download
// an identical file and conclude the feature was broken.
if (r.unchanged) {
status('“' + title + '”: the model returned it unchanged — nothing was modified. ' +
'Try naming the slide or section to change, and what to change about it.', 'bad');
} else {
// Whether it could see the slides is worth saying: it is the difference
// between "slide 4 looks crowded" being actionable and being guesswork,
// and it explains why this took longer.
status('“' + title + '” modified' +
(r.saw ? ', after looking at all ' + r.saw + ' slides' : '') +
(g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
'. Download it to see the result.', 'good');
}
reportSearches(r.searches);
showIllustrations(r.imageJobs || []);
reportImageFailures(r.imageFailures);
return;
}
status('“' + title + '” is in your library. ' + (g.used
? 'Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.'),
g.used ? 'good' : null);
reportSearches(r.searches);
if (r.deckFallback) {
status('Saved, but as plain slides: the model could not produce a slide ' +
'design twice (' + r.deckFallback + '). Generating again usually gets one.', 'bad');
}
showIllustrations(r.imageJobs || []);
reportImageFailures(r.imageFailures);
}
function elapsed(job) {
var t = Date.parse(job.started_at || job.created_at);
if (!t) return '';
var s = Math.max(0, Math.round((Date.now() - t) / 1000));
return s < 60 ? s + 's' : Math.floor(s / 60) + 'm ' + (s % 60) + 's';
}
function renderJobs(jobs) {
var box = document.getElementById('mr-jobs');
if (!box) return;
box.textContent = '';
if (!jobs.length) { box.hidden = true; return; }
box.hidden = false;
jobs.forEach(function (job) {
var row = document.createElement('div');
row.className = 'mr-job';
row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--g200);';
var icon = document.createElement('i');
var busy = job.status === 'queued' || job.status === 'running';
icon.className = busy ? 'fas fa-spinner fa-spin' : job.status === 'done' ? 'fas fa-check' : 'fas fa-triangle-exclamation';
icon.style.color = busy ? 'var(--g600)' : job.status === 'done' ? 'var(--green)' : 'var(--red)';
var label = document.createElement('span');
label.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
label.textContent = job.topic + ' · ' + (job.kind === 'refine' ? 'modification'
: job.kind === 'article' ? 'article' : 'presentation');
var state = document.createElement('span');
state.style.cssText = 'color:var(--g600);white-space:nowrap;';
state.textContent = job.status === 'queued' ? 'Queued'
: job.status === 'running' ? (job.kind === 'refine' ? 'Rewriting… ' : 'Writing… ') + elapsed(job)
: job.status === 'done' ? 'Done'
: 'Failed — ' + (job.error || 'Generation failed');
row.appendChild(icon); row.appendChild(label); row.appendChild(state);
if (!busy) {
var dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'btn-sm btn-ghost';
dismiss.textContent = 'Dismiss';
dismiss.addEventListener('click', function () {
fetch('/api/my-resources/jobs/' + job.id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function () { delete jobsSeen[job.id]; refreshJobs(); })
.catch(function () {});
});
row.appendChild(dismiss);
}
box.appendChild(row);
});
}
// Say what was searched for. A query that left the network is worth showing
// plainly rather than leaving someone to wonder whether it happened.
function reportSearches(searches) {
(searches || []).forEach(function (s) {
if (typeof showToast !== 'function') return;
var where = s.tool === 'pubmed_search' ? 'PubMed' : 'the web';
showToast(s.count
? 'Searched ' + where + ' for "' + s.query + '" \u2014 ' + s.count + ' used.'
: 'Nothing found on ' + where + ' for "' + s.query + '".', 'info');
});
}
function ticked(id) {
var el = document.getElementById(id);
return Boolean(el && el.checked);
}
// The shared poller, so an image made here behaves exactly like one made in
// the assistant: same status line, same durable job, same asset endpoint. It
// lives in an ES module and this file is a classic script, hence the dynamic
// import — which also means a failure to load it cannot break generation.
function reportImageFailures(failures) {
if (!(failures || []).length || typeof showToast !== 'function') return;
// Fewer pictures than asked for, without a word, would look like the model
// ignoring the request rather than the queue refusing it.
showToast(failures.length === 1
? 'One illustration could not be started: ' + failures[0]
: failures.length + ' illustrations could not be started.', 'error');
}
// The picture belongs to the deck and to the Images tab, not to the page:
// a full-size illustration under the form read as the output of the run,
// and stayed there looking like a leak. Here only the job's status line is
// shown, and when the picture is ready the library is refreshed so it turns
// up as a tile under Images, where every figure this account made lives.
function showIllustrations(jobs) {
var box = document.getElementById('mr-images');
if (!box) return;
box.innerHTML = '';
if (!jobs.length) return;
import('/js/generatedImages.js').then(function (m) {
m.renderImageJobs(box, jobs, 'my_resources', function (card) {
var line = card.querySelector('[role="status"]');
if (line) line.textContent = 'Illustration ready \u2014 it is in the deck and under Images.';
loadLibrary();
});
}).catch(function () {
if (typeof showToast === 'function') showToast('An illustration was generated but could not be displayed.', 'info');
});
}
// ── The image library ──────────────────────────────────────────────────────
// Every picture this account has generated, across all three features. Kept
// because a figure outlives the deck it was drawn for: the deck gets replaced,
// the diagram is still good.
//
// Thumbnails, not originals. The server stores a 256px preview beside each
// asset, so a grid of thirty costs a few kB each instead of thirty full-size
// renders — data-image-thumb is what asks hydrateImage for the small copy.
// The catalogue, once, shared by the generate form and every library row.
var themeCatalogue = [];
var nextcloudConnected = false;
var imagesLoaded = false;
var nextImagesBefore = null;
function showLibraryTab(which) {
var docs = which !== 'images';
[['tab-mr-docs', docs], ['tab-mr-images', !docs]].forEach(function (pair) {
var tab = document.getElementById(pair[0]);
if (!tab) return;
// aria-selected is the whole state: the stylesheet keys off it, so the
// visible highlight and what a screen reader announces cannot disagree.
tab.setAttribute('aria-selected', pair[1] ? 'true' : 'false');
});
var list = document.getElementById('mr-list');
var panel = document.getElementById('mr-images-panel');
var search = document.getElementById('mr-docs-search');
if (list) list.hidden = !docs;
if (search) search.hidden = !docs;
if (panel) panel.hidden = docs;
// Fetched the first time the tab is opened, not on page load: most visits
// never open it, and it is a database read plus a thumbnail per tile.
if (!docs && !imagesLoaded) loadImages(true);
}
function loadImages(reset) {
var grid = document.getElementById('mr-images-grid');
var empty = document.getElementById('mr-images-empty');
var more = document.getElementById('btn-mr-images-more');
if (!grid) return;
if (reset) { grid.textContent = ''; nextImagesBefore = null; }
if (empty) { empty.hidden = false; empty.textContent = 'Loading…'; }
var url = '/api/generated-images?limit=60' +
(nextImagesBefore && !reset ? '&before=' + encodeURIComponent(nextImagesBefore) : '');
fetch(url, { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not load your images');
imagesLoaded = true;
nextImagesBefore = data.nextBefore || null;
if (more) more.hidden = !nextImagesBefore;
if (empty) {
empty.hidden = Boolean(data.images.length) || Boolean(grid.children.length);
empty.textContent = 'No images yet. Tick "Add illustrations" when you generate, ' +
'or ask the Clinical Assistant for a diagram.';
}
import('/js/generatedImages.js').then(function (m) {
data.images.forEach(function (image) { grid.appendChild(imageTile(image, m)); });
}).catch(function () {
if (empty) { empty.hidden = false; empty.textContent = 'Images could not be displayed.'; }
});
})
.catch(function (err) {
if (empty) { empty.hidden = false; empty.textContent = err.message; }
});
}
// Built as elements, never innerHTML: the caption is a model-written prompt.
function imageTile(image, m) {
var tile = document.createElement('figure');
tile.className = 'img-tile';
// The picture is the control: clicking it opens the full size. A button
// rather than a div, so it is reachable and announced without inventing
// keyboard handling.
var frame = document.createElement('button');
frame.type = 'button';
frame.className = 'img-tile-frame loading';
frame.title = 'Open full size';
var img = document.createElement('img');
img.alt = image.prompt ? 'Generated illustration: ' + image.prompt.slice(0, 80) : 'Generated illustration';
img.setAttribute('data-image-thumb', '256');
img.loading = 'lazy';
frame.appendChild(img);
frame.addEventListener('click', function () { openImage(image, m); });
var badge = document.createElement('span');
badge.className = 'img-tile-badge';
badge.textContent = image.source;
frame.appendChild(badge);
m.hydrateImage(img, image.imageUrl)
.then(function () { frame.classList.remove('loading'); })
.catch(function () {
frame.classList.remove('loading');
img.remove();
var gone = document.createElement('span');
gone.style.cssText = 'font-size:11px;color:var(--g500);';
gone.textContent = 'Unavailable';
frame.appendChild(gone);
});
var body = document.createElement('div');
body.className = 'img-tile-body';
var prompt = document.createElement('figcaption');
prompt.className = 'img-tile-prompt';
prompt.textContent = image.prompt || 'No description recorded';
prompt.title = image.prompt || '';
var meta = document.createElement('div');
meta.className = 'img-tile-meta';
meta.textContent = new Date(image.createdAt).toLocaleDateString(undefined,
{ year: 'numeric', month: 'short', day: 'numeric' }) +
(image.bytes ? ' · ' + Math.max(1, Math.round(image.bytes / 1024)) + ' kB' : '');
var actions = document.createElement('div');
actions.className = 'img-tile-actions';
var download = document.createElement('button');
download.type = 'button';
download.className = 'img-tile-action';
download.textContent = 'Download';
download.addEventListener('click', function () { saveImage(image, m, download); });
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'img-tile-action danger';
remove.textContent = 'Delete';
remove.addEventListener('click', function () { deleteImage(image, tile, remove); });
actions.append(download, remove);
body.append(prompt, meta, actions);
tile.append(frame, body);
return tile;
}
// The full-size copy, fetched the same authenticated way as the tile. A plain
// link would not carry the session on a mobile client, and the asset is served
// no-store on purpose.
function openImage(image, m) {
var overlay = document.createElement('div');
overlay.className = 'img-lightbox';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Generated illustration');
var full = document.createElement('img');
full.alt = image.prompt || 'Generated illustration';
var bar = document.createElement('div');
bar.className = 'img-lightbox-bar';
var caption = document.createElement('p');
caption.className = 'img-lightbox-caption';
caption.textContent = image.prompt || 'No description recorded';
var close = document.createElement('button');
close.type = 'button';
close.className = 'img-tile-action';
close.textContent = 'Close';
var save = document.createElement('button');
save.type = 'button';
save.className = 'img-tile-action';
save.textContent = 'Download';
save.addEventListener('click', function () { saveImage(image, m, save); });
bar.append(save, close);
overlay.append(full, caption, bar);
// Escape and a click on the backdrop both close it; a click on the picture
// or the buttons does not.
function dismiss() {
overlay.remove();
document.removeEventListener('keydown', onKey);
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
function onKey(e) { if (e.key === 'Escape') dismiss(); }
var lastFocus = document.activeElement;
overlay.addEventListener('click', function (e) { if (e.target === overlay) dismiss(); });
close.addEventListener('click', dismiss);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
close.focus();
m.hydrateImage(full, image.imageUrl).catch(function () {
full.remove();
caption.textContent = 'That image could not be loaded.';
});
}
// Fetched, then saved from memory. A plain <a download href> would work in a
// browser carrying the session cookie and fail on a mobile client, whose
// session is a bearer token an <a> cannot send — and these assets are served
// no-store precisely so they are not sitting in a cache to be linked to.
function saveImage(image, m, button) {
var label = button.textContent;
button.disabled = true;
button.textContent = 'Saving…';
m.privateImageBlob(image.imageUrl)
.then(function (blob) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'illustration-' + image.id.slice(0, 8) + '.' +
({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[image.mime] || 'png';
document.body.appendChild(link);
link.click();
link.remove();
// Revoked on the next frame: revoking immediately races the download in
// some browsers.
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
})
.catch(function () {
if (typeof showToast === 'function') showToast('That image could not be downloaded.', 'error');
})
.finally(function () { button.disabled = false; button.textContent = label; });
}
function deleteImage(image, tile, button) {
// Asked, because it removes the picture from every deck that used it. The
// deck keeps working; the figure is simply no longer there.
var message = 'Delete this image? Any resource that used it will render without it.';
if (!window.showConfirm) { if (window.confirm(message)) doDelete(); return; }
window.showConfirm(message, doDelete, { danger: true, confirmText: 'Delete' });
function doDelete() {
button.disabled = true;
fetch('/api/generated-images/' + encodeURIComponent(image.id), {
method: 'DELETE', headers: getAuthHeaders()
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not delete that image');
tile.remove();
var grid = document.getElementById('mr-images-grid');
var empty = document.getElementById('mr-images-empty');
if (grid && !grid.children.length && empty) {
empty.hidden = false;
empty.textContent = 'No images yet.';
}
})
.catch(function (err) {
button.disabled = false;
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
}
// The library as last fetched. Held so searching and the Modify picker both
// work from one copy rather than each asking the server again.
var library = [];
// Someone followed a share link. The token was set aside at page load (it
// survives the trip through the SSO); now that they are signed in and on
// this tab, say what it is and who from, and add it when they say yes.
function acceptPendingShare() {
var token = '';
try { token = localStorage.getItem('ped_pending_share') || ''; localStorage.removeItem('ped_pending_share'); } catch (e) {}
if (!token) return;
var base = '/api/my-resources/share-link/' + encodeURIComponent(token);
fetch(base, { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'That link is not valid any more');
if (d.own) { showToast('That is your own resource', 'info'); return; }
var what = (d.kind === 'article' ? 'the article' : 'the deck') + ' "' + d.title + '"';
showConfirm((d.sharedBy || 'Someone') + ' shared ' + what + ' with you. Add it to your resources?', function () {
fetch(base + '/accept', { method: 'POST', headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (a) { if (!a.success) throw new Error(a.error || 'Could not add it'); showToast('"' + a.title + '" is in your resources', 'success'); loadLibrary(); })
.catch(function (err) { showToast(err.message, 'error'); });
}, { confirmText: 'Add to my resources' });
})
.catch(function (err) { showToast(err.message, 'error'); });
}
function loadLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;
fetch('/api/my-resources', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
library = (data && data.resources) || [];
renderLibrary();
syncModifyTargets();
})
.catch(function () {
library = [];
list.textContent = '';
var failed = document.createElement('p');
failed.style.cssText = 'margin:0;font-size:13px;color:var(--red);';
failed.textContent = 'Could not load your resources.';
list.appendChild(failed);
syncModifyTargets();
});
}
function matches(row, needle) {
if (!needle) return true;
return (String(row.title || '') + ' ' + String(row.topic || '')).toLowerCase().indexOf(needle) !== -1;
}
function renderLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;
var search = document.getElementById('mr-search');
var needle = String((search && search.value) || '').trim().toLowerCase();
var rows = library.filter(function (row) { return matches(row, needle); });
list.textContent = '';
if (!rows.length) {
var empty = document.createElement('p');
empty.style.cssText = 'margin:0;font-size:13px;color:var(--g400);';
// Two different situations, and saying "nothing yet" to someone whose
// search simply missed would be wrong.
empty.textContent = library.length
? 'Nothing matches "' + needle + '".'
: 'Nothing yet. Generate something above and it will appear here.';
list.appendChild(empty);
return;
}
rows.forEach(function (row) { list.appendChild(renderRow(row)); });
}
// The Modify picker is the same library, so it is rebuilt from it — and the
// selection survives a refresh, because losing it mid-sentence is maddening.
function syncModifyTargets() {
var select = document.getElementById('mr-modify-target');
if (!select) return;
var previous = select.value;
select.textContent = '';
if (!library.length) {
var none = document.createElement('option');
none.value = '';
none.textContent = 'Nothing to modify yet';
select.appendChild(none);
select.disabled = true;
return;
}
select.disabled = false;
// Only what is mine can be modified; a share is read-only.
library.filter(function (row) { return row.owned !== false; }).forEach(function (row) {
var option = document.createElement('option');
option.value = String(row.id);
option.textContent = (row.title || 'Untitled') +
' \u2014 ' + (row.kind === 'article' ? 'article'
: row.has_deck === false ? 'presentation, plain text' : 'presentation');
select.appendChild(option);
});
if (previous && library.some(function (row) { return String(row.id) === previous; })) {
select.value = previous;
}
}
function runModify() {
var select = document.getElementById('mr-modify-target');
var box = document.getElementById('mr-modify-instructions');
var status = document.getElementById('mr-modify-status');
var btn = document.getElementById('btn-mr-modify');
function say(message, tone) {
if (!status) return;
status.textContent = message || '';
status.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
var id = select && select.value;
var instructions = String((box && box.value) || '').trim();
if (!id) return say('Nothing to modify yet.', 'bad');
if (!instructions) return say('Say what to change.', 'bad');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Applying'; }
clearResults();
say('Rewriting…');
// Queued like a generation, not awaited. A revision restates the whole
// resource and can take minutes; a request held open that long is abandoned
// by the browser while the server carries on, which is exactly how a
// modification looked like it had failed and then really had been saved.
// The job list reports it, so leaving the page no longer loses the work.
fetch('/api/my-resources/' + encodeURIComponent(id) + '/refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
instructions: instructions,
useCorpus: ticked('mr-modify-corpus') ? 'true' : 'false',
withPubmed: ticked('mr-modify-pubmed') ? 'true' : 'false',
withWebSearch: ticked('mr-modify-web-search') ? 'true' : 'false',
withImages: ticked('mr-modify-images') ? 'true' : 'false',
model: (document.getElementById('mr-model') || {}).value || ''
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not apply the changes');
say('Rewriting in the background. You can leave this page — it keeps going.', 'good');
if (box) box.value = '';
refreshJobs();
})
.catch(function (err) { say(err.message, 'bad'); })
.finally(function () {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-pen-to-square"></i> Apply changes'; }
});
}
// Built as elements rather than innerHTML: a title comes from a model, and
// this is the one place it reaches the page.
function renderRow(row) {
var wrap = document.createElement('div');
wrap.className = 'saved-enc-item';
wrap.style.cssText = 'padding:8px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;';
var body = document.createElement('div');
body.className = 'mr-row-body';
body.style.flex = '1';
body.style.minWidth = '180px';
var title = document.createElement('div');
title.style.cssText = 'font-weight:600;font-size:13px;';
title.textContent = row.title || 'Untitled';
// The modified time, when there is one. Showing only the creation time meant
// a modification that did apply still looked like nothing had happened, so
// the timestamp was the thing people used to conclude modify was broken.
var created = new Date(row.created_at);
var updated = row.updated_at ? new Date(row.updated_at) : created;
var edited = updated - created > 2000;
var meta = document.createElement('div');
meta.style.cssText = 'font-size:11px;color:var(--g500);';
meta.textContent = (row.kind === 'article' ? 'Article' : 'Presentation') +
' · ' + (edited ? 'modified ' + updated.toLocaleString()
: created.toLocaleString()) +
(row.grounded_count ? ' · ' + row.grounded_count + ' library excerpts' : ' · not grounded') +
// Only worth saying when it is the weaker kind. A deck is the normal case.
(row.kind !== 'article' && row.has_deck === false ? ' · plain text, no slide layout' : '');
if (row.owned === false && row.shared_by_name) {
var from = document.createElement('div');
from.style.cssText = 'font-size:11px;color:var(--blue);';
from.textContent = 'Shared by ' + row.shared_by_name;
body.appendChild(from);
}
body.appendChild(title);
body.appendChild(meta);
wrap.appendChild(body);
// An article has no slides, so offering PowerPoint would produce a deck of
// paragraphs. A presentation as Word is fine — prose absorbs slide content
// without overflowing anything.
// Look first, download after. The preview is the file's own pages as
// pictures, so it shows exactly what the download would.
var formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf'];
var look = document.createElement('button');
look.className = 'btn-sm btn-ghost';
look.type = 'button';
look.dataset.preview = String(row.id);
look.innerHTML = '<i class="fas fa-eye"></i> Preview';
look.dataset.previewTitle = row.title || row.topic || 'Preview';
look.dataset.previewFormats = formats.join(',');
look.title = 'See every page here, without downloading';
wrap.appendChild(look);
formats.forEach(function (format) {
var btn = document.createElement('button');
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.dataset.download = String(row.id);
btn.dataset.format = format;
btn.textContent = format.toUpperCase();
btn.title = 'Download as ' + format.toUpperCase();
wrap.appendChild(btn);
});
// Re-skinning is a column write, not a regeneration: the next download
// renders from the same deck in different colours. Only for a row that has
// a deck — flat markdown has no palette to change.
// Reading is what a share gives; changing is the author's.
if (row.owned === false) { return wrap; }
var share = document.createElement('button');
share.className = 'btn-sm btn-ghost';
share.type = 'button';
share.dataset.share = String(row.id);
share.innerHTML = '<i class="fas fa-user-plus"></i> Share';
share.title = 'Share with people on this site';
wrap.appendChild(share);
// The theme is chosen in the editor when the deck is made or modified;
// the list row only looks and downloads.
// Send the rendered file to the owner's own Nextcloud. Offered only when
// there is a Nextcloud to send it to.
if (nextcloudConnected) {
var cloud = document.createElement('button');
cloud.className = 'btn-sm btn-ghost';
cloud.type = 'button';
cloud.dataset.nextcloud = String(row.id);
cloud.dataset.format = row.kind === 'article' ? 'docx' : 'pptx';
cloud.title = 'Upload to your Nextcloud as ' + cloud.dataset.format.toUpperCase();
var cloudIcon = document.createElement('i');
cloudIcon.className = 'fas fa-cloud-arrow-up';
cloud.appendChild(cloudIcon);
cloud.appendChild(document.createTextNode(' Nextcloud'));
wrap.appendChild(cloud);
}
var del = document.createElement('button');
del.className = 'btn-sm btn-ghost';
del.type = 'button';
del.dataset.remove = String(row.id);
del.style.color = 'var(--red)';
del.title = 'Delete';
var icon = document.createElement('i');
icon.className = 'fas fa-trash';
del.appendChild(icon);
wrap.appendChild(del);
return wrap;
}
// Rendered server-side and pushed straight to their storage — the file never
// travels through this browser, which is the point: it is a copy in their own
// Nextcloud, not a download they then have to file away.
function sendToNextcloud(id, format, btn) {
var icon = btn.querySelector('i');
var was = icon ? icon.className : '';
btn.disabled = true;
if (icon) icon.className = 'fas fa-spinner fa-spin';
fetch('/api/my-resources/' + encodeURIComponent(id) + '/to-nextcloud', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ format: format })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not send it');
if (icon) icon.className = 'fas fa-cloud-arrow-up';
if (typeof showToast === 'function') showToast('Saved to your Nextcloud: ' + data.path, 'success');
})
.catch(function (err) {
if (icon) icon.className = was;
if (typeof showToast === 'function') showToast(err.message, 'error');
})
.finally(function () { btn.disabled = false; });
}
function onRowClick(event) {
var cloud = event.target.closest && event.target.closest('[data-nextcloud]');
if (cloud) return sendToNextcloud(cloud.dataset.nextcloud, cloud.dataset.format, cloud);
var shareBtn = event.target.closest && event.target.closest('[data-share]');
if (shareBtn) { openSharePanel(shareBtn.dataset.share, shareBtn.closest('.saved-enc-item')); return; }
var preview = event.target.closest && event.target.closest('[data-preview]');
if (preview) {
var pid = encodeURIComponent(preview.dataset.preview);
openPreview('/api/my-resources/' + pid + '/preview', preview.dataset.previewTitle || 'Preview', {
pdf: '/api/my-resources/' + pid + '/export?format=pdf',
downloads: (preview.dataset.previewFormats || '').split(',').filter(Boolean).map(function (f) {
return { label: f.toUpperCase(), url: '/api/my-resources/' + pid + '/export?format=' + f };
})
});
return;
}
var download = event.target.closest && event.target.closest('[data-download]');
if (download) return downloadResource(download.dataset.download, download.dataset.format, download);
var remove = event.target.closest && event.target.closest('[data-remove]');
if (remove) {
showConfirm('Delete this resource? This cannot be undone.', function () {
fetch('/api/my-resources/' + encodeURIComponent(remove.dataset.remove), {
method: 'DELETE', headers: getAuthHeaders()
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not delete');
loadLibrary();
})
.catch(function (err) { showToast(err.message, 'error'); });
}, { danger: true, confirmText: 'Delete' });
}
}
// Fetched rather than linked, because the download needs the auth header and
// an <a href> cannot carry one.
function downloadResource(id, format, btn) {
var original = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
fetch('/api/my-resources/' + encodeURIComponent(id) + '/export?format=' + encodeURIComponent(format), {
headers: getAuthHeaders()
})
.then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Download failed'); });
var name = 'resource.' + format;
var disposition = r.headers.get('Content-Disposition') || '';
var match = disposition.match(/filename="([^"]+)"/);
if (match) name = match[1];
return r.blob().then(function (blob) { saveBlob(blob, name); });
})
.catch(function (err) { showToast(err.message, 'error'); })
.finally(function () { btn.disabled = false; btn.textContent = original; });
}
// ── Sharing ──────────────────────────────────────────────
// Under the row: a switch for everyone, an email to add one person, and the
// list of people it is shared with, each with a way to withdraw.
function openSharePanel(id, rowEl) {
if (!rowEl) return;
var existing = rowEl.querySelector('.mr-share');
if (existing) { existing.remove(); return; }
var panel = document.createElement('div');
panel.className = 'mr-share';
panel.style.cssText = 'flex-basis:100%;margin-top:8px;padding:10px 12px;border:1px solid var(--g200);border-radius:8px;background:var(--g50);display:grid;gap:8px;font-size:13px;';
panel.innerHTML = '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button type="button" class="btn-sm btn-primary mr-share-link"><i class="fas fa-link"></i> Copy a share link</button>' +
'<span class="mr-share-link-note" style="font-size:12px;color:var(--g500);">Send it however you like. Whoever follows it and accepts gets it in their resources; the link works for 30 days.</span>' +
'<input type="text" class="mr-share-link-url admin-control" readonly hidden style="flex-basis:100%;font-size:12px;"></div>' +
'<div class="mr-share-people" style="display:grid;gap:4px;"></div>';
rowEl.appendChild(panel);
var base = '/api/my-resources/' + encodeURIComponent(id) + '/shares';
function paint(state) {
var list = panel.querySelector('.mr-share-people');
list.innerHTML = '';
if (!state.people.length) {
var none = document.createElement('div'); none.style.cssText = 'font-size:12px;color:var(--g500);';
none.textContent = 'Nobody has accepted a link yet.';
list.appendChild(none); return;
}
state.people.forEach(function (p) {
var line = document.createElement('div');
line.style.cssText = 'display:flex;align-items:center;gap:8px;';
var who = document.createElement('span'); who.style.flex = '1';
who.textContent = (p.name ? p.name + ' · ' : '') + p.email;
var out = document.createElement('button'); out.type = 'button'; out.className = 'btn-sm btn-ghost';
out.textContent = 'Remove'; out.title = 'Withdraw the share';
out.addEventListener('click', function () {
fetch(base + '/' + encodeURIComponent(p.id), { method: 'DELETE', headers: getAuthHeaders() })
.then(function (r) { return r.json(); }).then(load).catch(function (e) { showToast(e.message, 'error'); });
});
line.appendChild(who); line.appendChild(out); list.appendChild(line);
});
}
function load() {
return fetch(base, { headers: getAuthHeaders() }).then(function (r) { return r.json(); })
// Painting the panel must not redraw the library: a redraw rebuilds the
// rows and the panel vanished the moment it opened. The row's own
// "shared with everyone" line is updated in place instead.
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not load');
paint(d);
})
.catch(function (e) { showToast(e.message, 'error'); });
}
panel.querySelector('.mr-share-link').addEventListener('click', function () {
var out = panel.querySelector('.mr-share-link-url');
fetch('/api/my-resources/' + encodeURIComponent(id) + '/share-link', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ days: 30 }) })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not make a link');
out.value = d.url; out.hidden = false; out.select();
var copy = navigator.clipboard ? navigator.clipboard.writeText(d.url) : Promise.reject();
return copy.then(function () { showToast('Link copied — send it to whoever should have this', 'success'); })
.catch(function () { showToast('Link ready below — copy it by hand', 'info'); });
})
.catch(function (err) { showToast(err.message, 'error'); });
});
load();
}
// ── The preview gallery ──────────────────────────────────
// One overlay: the pages of a document, top to bottom, at the width of the
// screen. Each page is fetched with the auth header (an <img src> cannot
// carry one) and shown as it arrives, so the first page is up before the
// last is rendered. Pinch-zoom is the browser's own.
// One page at a time, the way a deck is shown: arrows, keys, a swipe on a
// phone, a counter, and the PDF itself on a second tab for anyone who wants
// to read it as a document or print it. Pages are fetched as they are
// reached, two ahead, and kept as object URLs until the viewer closes.
function openPreview(base, title, opts) {
opts = opts || {};
var old = document.getElementById('mr-preview');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'mr-preview';
overlay.className = 'mr-preview';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML =
'<div class="mr-preview-bar">' +
'<span class="mr-preview-title"></span>' +
'<div class="mr-preview-tabs" role="tablist">' +
'<button type="button" class="mr-preview-tab is-on" data-view="pages" role="tab">Pages</button>' +
(opts.pdf ? '<button type="button" class="mr-preview-tab" data-view="pdf" role="tab">PDF</button>' : '') +
'</div>' +
'<span class="mr-preview-downloads"></span>' +
'<button type="button" class="mr-preview-close" aria-label="Close preview">&#10005; Close</button>' +
'</div>' +
'<div class="mr-preview-stage" data-view="pages">' +
'<button type="button" class="mr-preview-nav" data-step="-1" aria-label="Previous page">&#8249;</button>' +
'<div class="mr-preview-frame"><p class="mr-preview-status">Rendering pages…</p></div>' +
'<button type="button" class="mr-preview-nav" data-step="1" aria-label="Next page">&#8250;</button>' +
'</div>' +
'<div class="mr-preview-foot"><span class="mr-preview-count" role="status"></span></div>';
overlay.querySelector('.mr-preview-title').textContent = title || 'Preview';
var downloadsEl = overlay.querySelector('.mr-preview-downloads');
(opts.downloads || []).forEach(function (d) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'mr-preview-dl'; b.textContent = d.label; b.title = 'Download as ' + d.label;
b.addEventListener('click', function () { fetchToFile(d.url, (title || 'resource') + '.' + d.label.toLowerCase()); });
downloadsEl.appendChild(b);
});
document.body.appendChild(overlay);
document.body.classList.add('mr-preview-open');
var urls = [], pages = 0, current = 1, blobs = {}, pending = {}, view = 'pages';
var frame = overlay.querySelector('.mr-preview-frame');
var countEl = overlay.querySelector('.mr-preview-count');
var stage = overlay.querySelector('.mr-preview-stage');
var pdfFrame = null;
function close() {
overlay.remove();
document.body.classList.remove('mr-preview-open');
urls.forEach(function (u) { URL.revokeObjectURL(u); });
document.removeEventListener('keydown', onKey);
}
function onKey(e) {
if (e.key === 'Escape') close();
else if (view === 'pages' && (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ')) { e.preventDefault(); go(current + 1); }
else if (view === 'pages' && (e.key === 'ArrowLeft' || e.key === 'PageUp')) { e.preventDefault(); go(current - 1); }
else if (view === 'pages' && e.key === 'Home') go(1);
else if (view === 'pages' && e.key === 'End') go(pages);
}
document.addEventListener('keydown', onKey);
overlay.querySelector('.mr-preview-close').addEventListener('click', close);
overlay.querySelectorAll('.mr-preview-nav').forEach(function (b) {
b.addEventListener('click', function () { go(current + Number(b.dataset.step)); });
});
// A swipe on a phone turns the page.
var touchX = null;
stage.addEventListener('touchstart', function (e) { touchX = e.touches[0].clientX; }, { passive: true });
stage.addEventListener('touchend', function (e) {
if (touchX === null || view !== 'pages') return;
var dx = e.changedTouches[0].clientX - touchX; touchX = null;
if (Math.abs(dx) > 50) go(current + (dx < 0 ? 1 : -1));
}, { passive: true });
function fetchPage(n) {
if (blobs[n] || pending[n] || n < 1 || n > pages) return Promise.resolve(blobs[n]);
pending[n] = fetch(base + '/' + n, { headers: getAuthHeaders() })
.then(function (r) { if (!r.ok) throw new Error('page ' + n); return r.blob(); })
.then(function (blob) { var u = URL.createObjectURL(blob); urls.push(u); blobs[n] = u; return u; })
.finally(function () { delete pending[n]; });
return pending[n];
}
function show(n) {
frame.innerHTML = '';
var img = document.createElement('img');
img.className = 'mr-preview-page';
img.alt = 'Page ' + n + ' of ' + pages;
img.draggable = false;
frame.appendChild(img);
fetchPage(n).then(function (u) { if (current === n && u) img.src = u; })
.catch(function () { if (current === n) frame.innerHTML = '<p class="mr-preview-status">Page ' + n + ' could not be shown</p>'; });
fetchPage(n + 1); fetchPage(n + 2);
countEl.textContent = 'Page ' + n + ' of ' + pages;
overlay.querySelectorAll('.mr-preview-nav').forEach(function (b) {
b.disabled = Number(b.dataset.step) < 0 ? n <= 1 : n >= pages;
});
}
function go(n) {
if (!pages) return;
n = Math.max(1, Math.min(pages, n));
if (n === current && frame.querySelector('img')) return;
current = n;
show(n);
}
function switchView(name) {
view = name;
overlay.querySelectorAll('.mr-preview-tab').forEach(function (t) { t.classList.toggle('is-on', t.dataset.view === name); t.setAttribute('aria-selected', String(t.dataset.view === name)); });
stage.dataset.view = name;
overlay.querySelector('.mr-preview-foot').hidden = name !== 'pages';
if (name === 'pdf') {
if (!pdfFrame) {
pdfFrame = document.createElement('iframe');
pdfFrame.className = 'mr-preview-pdf';
pdfFrame.title = (title || 'Preview') + ' as PDF';
stage.appendChild(pdfFrame);
fetch(opts.pdf, { headers: getAuthHeaders() })
.then(function (r) { if (!r.ok) throw new Error('The PDF could not be produced'); return r.blob(); })
.then(function (blob) { var u = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' })); urls.push(u); pdfFrame.src = u; })
.catch(function (err) { pdfFrame.remove(); pdfFrame = null; frame.innerHTML = '<p class="mr-preview-status">' + err.message + '</p>'; });
}
}
}
overlay.querySelectorAll('.mr-preview-tab').forEach(function (t) { t.addEventListener('click', function () { switchView(t.dataset.view); }); });
fetch(base, { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not render a preview');
pages = d.pages;
show(1);
})
.catch(function (err) {
frame.innerHTML = '';
var p = document.createElement('p');
p.className = 'mr-preview-status';
p.textContent = err.message;
frame.appendChild(p);
});
}
function fetchToFile(url, name) {
fetch(url, { headers: getAuthHeaders() })
.then(function (r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); })
.then(function (blob) { saveBlob(blob, name); })
.catch(function (err) { status(err.message); });
}
function saveBlob(blob, name) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = name;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
}());