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%/); +});