A picture of one slide answers a narrower question than the one a theme picker is asked. What a person wants to know is what a deck will look like in this theme — all of it, at the size it will be shown, with the fonts substituted the way they will be. One rendered slide showed one layout, in content someone then read instead of looking at. So: a sample deck per theme. Every layout the renderer can draw — title, bullets with a sub-point, both two-column forms, table, callout, figure, full-slide figure, section divider, a custom slide with shapes, an arrow and a chart, and a references slide — with filler text throughout. Download it, open it, see the theme. The text is deliberately meaningless. Clinical content in a specimen invites you to read it, and then you are judging the teaching rather than the type; that is what the old croup slide got wrong. No engine. The previous preview needed a Gotenberg round trip and a pdftoppm to produce a PNG, cached on disk because of what it cost, and could fail in ways a missing picture cannot explain. python-pptx builds the file in ~330ms and PowerPoint draws it. The link is a plain anchor, so it is there whether or not anything on the server is well. The figure placeholder rides the same path a generated figure does — attachFigures downgrades a figure slide with no picture to bullets and an image slide to a section, so without it the sample would silently stop showing those two layouts. Verified: all five themes build, 11 slides, the chart is a real chart and the placeholder embeds as real media. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
951 lines
42 KiB
JavaScript
951 lines
42 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();
|
|
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);
|
|
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 || '',
|
|
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 = [];
|
|
|
|
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;
|
|
library.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' : '');
|
|
|
|
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.
|
|
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.
|
|
if (row.kind !== 'article' && row.has_deck !== false && 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 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; });
|
|
}
|
|
|
|
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);
|
|
}
|
|
}());
|