pediatric-ai-scribe-v3/public/js/myResources.js
Daniel c7864f763e
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 12s
feat: sharing is by link — follow it, accept, and it is in your resources
The author presses "Copy a share link" and sends it however they like.
Whoever follows it (app.pedshub.com/#share=<token>) is signed in first if
need be — the token survives the trip through the SSO — then shown what it
is and who from, and adds it with one press. Only the token's hash is
stored; a link lasts 30 days and can be withdrawn; accepting twice is
harmless; the owner following their own link changes nothing. Sharing by
email is gone: nobody is looked up by address. "Everyone signed in" stays
as a switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 15:20:35 +02:00

1154 lines
54 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();
});
// 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);
if (list) list.addEventListener('change', onRowChange);
// 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();
}
// 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;
// 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 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> Generating...'; }
status('Searching the library and writing. This takes a moment.');
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 || '',
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) {
if (!data.success) throw new Error(data.error || 'Generation failed');
// Say what it was written from. Ungrounded material presented as
// grounded is the failure worth preventing.
var g = data.grounding || {};
status(g.used
? 'Saved. Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Saved. Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.',
g.used ? 'good' : null);
reportSearches(data.searches);
// A deck that fell back came out as plain slides. Silently handing
// someone the plainer artifact left them comparing two decks with no
// idea why one had layouts and the other did not — and asking again
// usually gets the designed one, which is only worth knowing if the
// fallback is visible.
if (data.deckFallback) {
status('Saved, but as plain slides: the model could not produce a slide ' +
'design twice (' + data.deckFallback + '). Generating again usually gets one.', 'bad');
}
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
loadLibrary();
})
.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'; }
});
}
// 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');
}
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, data) {
var img = document.createElement('img');
img.alt = 'Generated teaching illustration';
img.style.maxWidth = '100%';
card.append(img);
m.hydrateImage(img, data.imageUrl).catch(function () { img.alt = 'Private image unavailable'; });
});
}).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.
if (!window.confirm('Delete this image? Any resource that used it will render without it.')) return;
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…');
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');
// Same reporting as generating: what it was written from, what was
// searched, and any figure that came back.
var g = data.grounding || {};
// 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 (data.unchanged) {
say('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.
say('Applied' +
(data.saw ? ', after looking at all ' + data.saw + ' slides' : '') +
(g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
'. Download it to see the result.', 'good');
}
reportSearches(data.searches);
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
if (box && !data.unchanged) box.value = '';
loadLibrary();
})
.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.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);
} else if (row.owned !== false && row.shared_with_all) {
var all = document.createElement('div');
all.style.cssText = 'font-size:11px;color:var(--g500);';
all.textContent = 'Shared with everyone';
body.appendChild(all);
}
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 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.title = 'See every page here, without downloading';
wrap.appendChild(look);
var formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf'];
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);
// Every presentation takes a theme now — markdown slides too.
if (row.kind !== 'article' && themeCatalogue.length > 1) {
var theme = document.createElement('select');
theme.className = 'btn-sm';
theme.dataset.theme = String(row.id);
theme.title = 'Theme — changes the colours of the next download';
theme.style.cssText = 'font-size:11px;padding:2px 4px;border:1px solid var(--g200);border-radius:6px;background:var(--white,#fff);max-width:130px;';
themeCatalogue.forEach(function (t) {
var option = document.createElement('option');
option.value = t.id;
option.textContent = t.name;
theme.appendChild(option);
});
theme.value = row.theme || themeCatalogue[0].id;
wrap.appendChild(theme);
}
// 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 = 'Save to my Nextcloud as ' + cloud.dataset.format.toUpperCase();
var cloudIcon = document.createElement('i');
cloudIcon.className = 'fas fa-cloud-arrow-up';
cloud.appendChild(cloudIcon);
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;
}
// Delegated like the download and delete buttons, so rows re-rendered by a
// refresh do not need rebinding.
function onRowChange(event) {
var select = event.target.closest && event.target.closest('[data-theme]');
if (!select) return;
var previous = select.dataset.previous || '';
select.disabled = true;
fetch('/api/my-resources/' + encodeURIComponent(select.dataset.theme) + '/theme', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ theme: select.value })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not change the theme');
select.dataset.previous = select.value;
if (typeof showToast === 'function') showToast('Theme changed — download it again to see it.', 'success');
})
.catch(function (err) {
// Put the control back where it was: a select showing a theme the
// resource does not have is worse than no feedback.
if (previous) select.value = previous;
if (typeof showToast === 'function') showToast(err.message, 'error');
})
.finally(function () { select.disabled = false; });
}
// 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) { openPreview('/api/my-resources/' + encodeURIComponent(preview.dataset.preview) + '/preview', preview.dataset.previewTitle || 'Preview'); 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>' +
'<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" class="mr-share-all"> Or: anyone signed in to this site can open it</label>' +
'<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) {
panel.querySelector('.mr-share-all').checked = !!state.sharedWithAll;
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 = state.sharedWithAll ? 'Shared with everyone.' : '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(); })
.then(function (d) { if (!d.success) throw new Error(d.error || 'Could not load'); paint(d); loadLibrary(); })
.catch(function (e) { showToast(e.message, 'error'); });
}
panel.querySelector('.mr-share-all').addEventListener('change', function (e) {
fetch(base + '/all', { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: e.target.checked }) })
.then(function (r) { return r.json(); })
.then(function (d) { if (!d.success) throw new Error(d.error || 'Could not change sharing'); showToast(d.sharedWithAll ? 'Shared with everyone on the site' : 'No longer shared with everyone', 'success'); return load(); })
.catch(function (err) { showToast(err.message, 'error'); load(); });
});
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.
function openPreview(base, title) {
var old = document.getElementById('mr-preview');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'mr-preview';
overlay.className = 'mr-preview';
overlay.innerHTML = '<div class="mr-preview-bar"><span class="mr-preview-title"></span>' +
'<span class="mr-preview-count" role="status"></span>' +
'<button type="button" class="btn-sm btn-ghost mr-preview-close" aria-label="Close preview"><i class="fas fa-xmark"></i> Close</button></div>' +
'<div class="mr-preview-pages"><p class="mr-preview-status">Rendering pages…</p></div>';
overlay.querySelector('.mr-preview-title').textContent = title || 'Preview';
document.body.appendChild(overlay);
document.body.classList.add('mr-preview-open');
var urls = [];
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(); }
document.addEventListener('keydown', onKey);
overlay.querySelector('.mr-preview-close').addEventListener('click', close);
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
var pagesEl = overlay.querySelector('.mr-preview-pages');
var countEl = overlay.querySelector('.mr-preview-count');
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');
pagesEl.innerHTML = '';
countEl.textContent = d.pages + (d.pages === 1 ? ' page' : ' pages');
var i = 1;
function next() {
if (i > d.pages || !overlay.isConnected) return;
var n = i++;
var img = document.createElement('img');
img.className = 'mr-preview-page';
img.alt = 'Page ' + n;
pagesEl.appendChild(img);
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); img.src = u; })
.catch(function () { img.alt = 'Page ' + n + ' could not be shown'; })
.finally(next);
}
next(); next();
})
.catch(function (err) {
pagesEl.innerHTML = '';
var p = document.createElement('p');
p.className = 'mr-preview-status';
p.textContent = err.message;
pagesEl.appendChild(p);
});
}
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);
}
}());