diff --git a/public/css/styles.css b/public/css/styles.css
index 24d7f98e..4e4e879f 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -1301,6 +1301,17 @@ textarea.full-input{resize:vertical;}
the card ends, then sits where it always did.
The negative margins let the bar span the card's padding rather than floating
in a gutter; the background is opaque because content scrolls under it. */
+/* The document preview: pages as pictures, edge to edge on a phone, a strip
+ down the middle on a desktop; the bar stays put, the pages scroll. */
+.mr-preview { position:fixed; inset:0; z-index:9000; background:rgba(15,23,42,.85); display:flex; flex-direction:column; }
+body.mr-preview-open { overflow:hidden; }
+.mr-preview-bar { display:flex; align-items:center; gap:10px; padding:8px 12px; background:var(--g900,#0f172a); color:#fff; flex:0 0 auto; }
+.mr-preview-title { font-weight:600; font-size:14px; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.mr-preview-count { font-size:12px; color:#cbd5e1; }
+.mr-preview-close { color:#fff; }
+.mr-preview-pages { flex:1; overflow:auto; -webkit-overflow-scrolling:touch; padding:10px; display:flex; flex-direction:column; align-items:center; gap:10px; }
+.mr-preview-page { width:100%; max-width:960px; height:auto; background:#fff; box-shadow:0 2px 12px rgba(0,0,0,.35); border-radius:4px; min-height:120px; }
+.mr-preview-status { color:#e2e8f0; font-size:14px; margin:24px 0; }
.admin-save-row {
border-top:1px solid var(--g100); display:flex; align-items:center; gap:8px; flex-wrap:wrap;
position:sticky; bottom:0; z-index:2;
diff --git a/public/js/myResources.js b/public/js/myResources.js
index 337e38d3..60a954c0 100644
--- a/public/js/myResources.js
+++ b/public/js/myResources.js
@@ -229,6 +229,14 @@
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 = ' 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;
}
@@ -784,6 +792,16 @@
// 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 = ' 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');
@@ -799,7 +817,8 @@
// 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) {
+ // 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);
@@ -899,6 +918,8 @@
var cloud = event.target.closest && event.target.closest('[data-nextcloud]');
if (cloud) return sendToNextcloud(cloud.dataset.nextcloud, cloud.dataset.format, cloud);
+ 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);
@@ -939,6 +960,69 @@
.finally(function () { btn.disabled = false; btn.textContent = original; });
}
+ // ── 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 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 = '
' +
+ '' +
+ '
' +
+ '
Rendering pages…
';
+ 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');
diff --git a/src/routes/myResources.js b/src/routes/myResources.js
index 20f5090f..58e6d964 100644
--- a/src/routes/myResources.js
+++ b/src/routes/myResources.js
@@ -38,6 +38,7 @@ var logger = require('../utils/logger');
var webSearch = require('../utils/webSearch');
var pubmedSearch = require('../utils/pubmedSearch');
var documentExport = require('../utils/documentExport');
+var previewPages = require('../utils/previewPages');
// Scoped to this router's own prefix. Mounted on /api, a bare
// router.use(authMiddleware) would gate every /api path below it in server.js.
@@ -586,6 +587,102 @@ router.post('/my-resources/:id/to-nextcloud', async function (req, res) {
}
});
+// ── Previews: pages as pictures ─────────────────────────────
+// Looking without downloading, on a phone as much as anywhere. A resource is
+// rendered the way its download would be (PowerPoint for a presentation,
+// Word for an article), turned into one PNG per page, and shown in the page
+// itself. Rendered once per version — the key carries updated_at and the
+// theme — and served from disk afterwards.
+function previewKeyFor(row) {
+ return previewPages.cacheKey(['resource', row.id, row.updated_at, row.theme || '', row.kind]);
+}
+
+async function resourceOfficeBytes(row, user) {
+ var format = row.kind === 'article' ? 'docx' : 'pptx';
+ var scratch = await require('fs/promises').mkdtemp(require('path').join(require('os').tmpdir(), 'figs-'));
+ try {
+ var figures = await collectFigures(row.image_ids, user, scratch);
+ var bytes = await documentExport.render(row.markdown, row.kind, format,
+ { images: figures, deck: row.deck, theme: row.theme, figureIds: figureIdList(row.image_ids) });
+ return { bytes: bytes, mime: documentExport.mimeFor(format), extension: format };
+ } finally {
+ await require('fs/promises').rm(scratch, { recursive: true, force: true }).catch(function () {});
+ }
+}
+
+router.get('/my-resources/:id/preview', async function (req, res) {
+ try {
+ var row = await db.get(
+ 'SELECT id, kind, markdown, image_ids, deck, theme, updated_at FROM user_resources WHERE id = ? AND user_id = ?',
+ [parseInt(req.params.id, 10), req.user.id]
+ );
+ if (!row) return res.status(404).json({ error: 'Not found' });
+ var key = previewKeyFor(row);
+ var pages = await previewPages.ensure(key, function () { return resourceOfficeBytes(row, req.user); });
+ res.json({ success: true, pages: pages, key: key });
+ } catch (err) {
+ logger.warn('[my-resources] preview', { id: req.params.id, error: err.message });
+ res.status(503).json({ error: 'Could not render a preview. Download the file instead.' });
+ }
+});
+
+router.get('/my-resources/:id/preview/:page', async function (req, res) {
+ try {
+ var row = await db.get(
+ 'SELECT id, kind, theme, updated_at FROM user_resources WHERE id = ? AND user_id = ?',
+ [parseInt(req.params.id, 10), req.user.id]
+ );
+ if (!row) return res.status(404).json({ error: 'Not found' });
+ var png = await previewPages.page(previewKeyFor(row), parseInt(req.params.page, 10));
+ if (!png) return res.status(404).json({ error: 'No such page' });
+ res.setHeader('Content-Type', 'image/png');
+ res.setHeader('Cache-Control', 'private, max-age=3600');
+ res.send(png);
+ } catch (err) {
+ res.status(500).json({ error: 'Could not read the preview' });
+ }
+});
+
+// The sample deck of a theme, as pages, so a theme can be judged on a phone.
+function themeSampleKey(id, theme) {
+ // The catalogue entry itself is part of the key, so an edited palette
+ // renders afresh rather than serving yesterday's colours.
+ return previewPages.cacheKey(['theme-sample', id, JSON.stringify(theme || {})]);
+}
+
+router.get('/my-resources/theme-sample/:id/preview', async function (req, res) {
+ try {
+ var id = deckSchema.themeId(req.params.id);
+ if (!id) return res.status(404).json({ error: 'No such theme' });
+ var theme = deckSchema.themes().filter(function (t) { return t.id === id; })[0];
+ var pages = await previewPages.ensure(themeSampleKey(id, theme), async function () {
+ var deck = deckSample.build(theme);
+ deck.theme = id;
+ var pptx = await documentExport.renderDeck(deck, [deckSample.FIGURE], [deckSample.FIGURE_JOB]);
+ return { bytes: pptx, mime: documentExport.FORMATS.pptx.mime, extension: 'pptx' };
+ });
+ res.json({ success: true, pages: pages });
+ } catch (err) {
+ logger.warn('[my-resources] theme sample preview', { theme: req.params.id, error: err.message });
+ res.status(503).json({ error: 'Could not render the sample.' });
+ }
+});
+
+router.get('/my-resources/theme-sample/:id/preview/:page', async function (req, res) {
+ try {
+ var id = deckSchema.themeId(req.params.id);
+ if (!id) return res.status(404).json({ error: 'No such theme' });
+ var theme = deckSchema.themes().filter(function (t) { return t.id === id; })[0];
+ var png = await previewPages.page(themeSampleKey(id, theme), parseInt(req.params.page, 10));
+ if (!png) return res.status(404).json({ error: 'No such page' });
+ res.setHeader('Content-Type', 'image/png');
+ res.setHeader('Cache-Control', 'private, max-age=86400');
+ res.send(png);
+ } catch (err) {
+ res.status(500).json({ error: 'Could not read the preview' });
+ }
+});
+
// ── Theme previews ──────────────────────────────────────────
// A sample deck per theme: every layout the renderer can draw, filler text
// throughout, downloaded and opened in PowerPoint.
diff --git a/src/utils/openapiRoutes.js b/src/utils/openapiRoutes.js
index 5d017635..99533c03 100644
--- a/src/utils/openapiRoutes.js
+++ b/src/utils/openapiRoutes.js
@@ -25,6 +25,10 @@ var parameters = {
var operations = {
// ── Speech ──────────────────────────────────────────────────────────
+ 'GET /api/my-resources/:id/preview': { summary: 'Render a resource as pages for viewing in place', description: 'Returns the page count and a key; pages are served as PNG by the sibling route. Rendered once per version (updated_at and theme).' },
+ 'GET /api/my-resources/:id/preview/:page': { summary: 'One page of a resource preview, as PNG' },
+ 'GET /api/my-resources/theme-sample/:id/preview': { summary: 'Render a theme\'s sample deck as pages' },
+ 'GET /api/my-resources/theme-sample/:id/preview/:page': { summary: 'One page of a theme sample, as PNG' },
'GET /api/nextcloud/config': {
summary: 'The site\'s Nextcloud address, if one is set',
description: 'nextcloud.url (or NEXTCLOUD_URL). When present the settings page hides the address field and the sign-in and app-password routes use it when none is given.'
diff --git a/src/utils/previewPages.js b/src/utils/previewPages.js
new file mode 100644
index 00000000..a39397df
Binary files /dev/null and b/src/utils/previewPages.js differ
diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js
index c2f48798..e30e7c21 100644
--- a/test/deck-themes.test.js
+++ b/test/deck-themes.test.js
@@ -81,7 +81,7 @@ test('the theme is shown by a deck the renderer built, not a mocked-up swatch',
test('the library offers a theme only where there is a deck to re-skin', () => {
const ui = read('public/js/myResources.js');
- assert.match(ui, /row\.kind !== 'article' && row\.has_deck !== false/);
+ assert.match(ui, /row\.kind !== 'article' && themeCatalogue\.length > 1/);
assert.match(ui, /data-theme|dataset\.theme/);
// A failed change puts the control back rather than showing a theme the
// resource does not have.
diff --git a/test/my-resources-refine.test.js b/test/my-resources-refine.test.js
index ef4dcb51..b94448df 100644
--- a/test/my-resources-refine.test.js
+++ b/test/my-resources-refine.test.js
@@ -80,6 +80,7 @@ function router(t, overrides = {}) {
'../utils/learningRetrieval': { retrieve: async () => ({ context: '', sources: [], reason: null }) },
'../utils/logger': quiet,
'../utils/nextcloudFiles': { send: async () => '/PediatricScribe/2026-01-01/x.pptx' },
+ '../utils/previewPages': { cacheKey: () => 'key', ensure: async () => 1, page: async () => null },
'../utils/metrics': { resourceRefines: { inc() {} }, resourceVocabularyGaps: { inc() {} } },
'../utils/pubmedSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' },
'../utils/webSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' },
diff --git a/test/resource-previews.test.js b/test/resource-previews.test.js
new file mode 100644
index 00000000..e49b4da7
--- /dev/null
+++ b/test/resource-previews.test.js
@@ -0,0 +1,48 @@
+// A resource, or a theme's sample deck, shown as pages in the page itself —
+// on a phone as much as anywhere — rendered once per version.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
+
+test('the cache key follows the version, so a modification or a re-skin renders afresh', () => {
+ const previews = require('../src/utils/previewPages');
+ const a = previews.cacheKey(['resource', 7, '2026-09-13T10:00:00Z', 'ward-teal', 'presentation']);
+ const same = previews.cacheKey(['resource', 7, '2026-09-13T10:00:00Z', 'ward-teal', 'presentation']);
+ const edited = previews.cacheKey(['resource', 7, '2026-09-13T11:00:00Z', 'ward-teal', 'presentation']);
+ const reskinned = previews.cacheKey(['resource', 7, '2026-09-13T10:00:00Z', 'slate', 'presentation']);
+ assert.equal(a, same);
+ assert.notEqual(a, edited);
+ assert.notEqual(a, reskinned);
+ assert.match(a, /^[0-9a-f]{40}$/, 'a directory name, nothing a caller typed');
+});
+
+test('previews are served for a resource and for a theme sample, and only to the owner', () => {
+ const route = read('src/routes/myResources.js');
+ assert.match(route, /router\.get\('\/my-resources\/:id\/preview', async/);
+ assert.match(route, /router\.get\('\/my-resources\/:id\/preview\/:page', async/);
+ assert.match(route, /router\.get\('\/my-resources\/theme-sample\/:id\/preview', async/);
+ assert.match(route, /router\.get\('\/my-resources\/theme-sample\/:id\/preview\/:page', async/);
+ // The resource routes read with the owner in the WHERE, like every other route here.
+ const preview = route.slice(route.indexOf("router.get('/my-resources/:id/preview'"), route.indexOf("// The sample deck of a theme"));
+ assert.equal((preview.match(/AND user_id = \?/g) || []).length, 2);
+ // Rendered the way the download is: the same exporter, the same theme.
+ assert.match(route, /documentExport\.render\(row\.markdown, row\.kind, format,\s*\{ images: figures, deck: row\.deck, theme: row\.theme, figureIds: figureIdList\(row\.image_ids\) \}\)/);
+ assert.match(route, /previewPages\.cacheKey\(\['resource', row\.id, row\.updated_at, row\.theme \|\| '', row\.kind\]\)/);
+ assert.match(route, /previewPages\.cacheKey\(\['theme-sample', id, JSON\.stringify\(theme \|\| \{\}\)\]\)/);
+});
+
+test('the page offers Preview beside the downloads and beside the theme sample, and the gallery scrolls', () => {
+ const js = read('public/js/myResources.js');
+ assert.match(js, /look\.dataset\.preview = String\(row\.id\)/);
+ assert.match(js, /openPreview\('\/api\/my-resources\/theme-sample\/' \+ encodeURIComponent\(select\.value\) \+ '\/preview'/);
+ // Fetched with the auth header — an cannot carry one.
+ assert.match(js, /fetch\(base \+ '\/' \+ n, \{ headers: getAuthHeaders\(\) \}\)/);
+ assert.match(js, /URL\.revokeObjectURL\(u\)/, 'blob URLs are released when the preview closes');
+ // Every presentation takes a theme now, markdown slides included.
+ assert.doesNotMatch(js, /row\.has_deck !== false && themeCatalogue\.length > 1/);
+ const css = read('public/css/styles.css');
+ assert.match(css, /\.mr-preview-pages \{[^}]*overflow:auto/);
+ assert.match(css, /\.mr-preview-page \{[^}]*width:100%/);
+});