feat: preview a resource, or a theme's sample deck, as pages — without downloading
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s

A PowerPoint or Word file cannot be shown in a browser and a phone has
nowhere to open one. Preview renders the resource the way its download
would be built, turns it into one PNG per page (Gotenberg to PDF, pdftoppm
to pages), and shows the pages in an overlay that scrolls and pinch-zooms
like anything else. Rendered once per version — updated_at and theme are in
the key — and served from disk afterwards. The theme picker's sample deck
has the same Preview beside its download.

Every presentation now shows the theme picker in the library, since
markdown slides take a theme too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 14:48:45 +02:00
parent 6e779e61ff
commit e92b72d406
8 changed files with 247 additions and 2 deletions

View file

@ -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;

View file

@ -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 = '<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;
}
@ -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 = '<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');
@ -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 <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');

View file

@ -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.

View file

@ -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.'

BIN
src/utils/previewPages.js Normal file

Binary file not shown.

View file

@ -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.

View file

@ -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: () => '' },

View file

@ -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 <img src> 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%/);
});