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
1154 lines
54 KiB
JavaScript
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);
|
|
}
|
|
}());
|