From cba2a312e1edcba2939ef4b4cd9876a90e9fbcdd Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 17:49:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20the=20preview=20is=20a=20viewer=20?= =?UTF-8?q?=E2=80=94=20one=20page=20at=20a=20time,=20arrows,=20keys,=20swi?= =?UTF-8?q?pe,=20a=20PDF=20tab=20and=20downloads;=20the=20theme=20leaves?= =?UTF-8?q?=20the=20list=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- docs/my-resources.md | 14 ++- public/css/styles.css | 34 ++++-- public/js/myResources.js | 206 +++++++++++++++++++++------------ src/utils/previewPages.js | Bin 4221 -> 4223 bytes test/deck-themes.test.js | 13 ++- test/resource-previews.test.js | 9 +- 6 files changed, 183 insertions(+), 93 deletions(-) diff --git a/docs/my-resources.md b/docs/my-resources.md index 6d05cff6..0189d46c 100644 --- a/docs/my-resources.md +++ b/docs/my-resources.md @@ -436,6 +436,14 @@ shared with them, each row saying `owned` and `shared_by_name`. `GET /api/my-resources/:id/preview` renders the resource the way its download is built and turns it into one PNG per page (`src/utils/previewPages.js`: -Gotenberg to PDF, `pdftoppm` to pages), keyed on `updated_at` and theme and -kept under the OS temp directory; `…/preview/:page` serves a page. Theme -samples have the same pair under `theme-sample/:id/preview`. +Gotenberg to PDF, `pdftoppm` at 150 dpi), keyed on `updated_at`, theme and +resolution and kept under the OS temp directory; `…/preview/:page` serves a +page. Theme samples have the same pair under `theme-sample/:id/preview`. + +The viewer (`openPreview` in `public/js/myResources.js`) shows one page at a +time filling the screen — arrows, ←/→, PageUp/PageDown, Home/End, a swipe on a +phone, "Page n of N" — fetching pages as they are reached and two ahead. A +second tab, **PDF**, loads the resource's PDF export into the page so it can +be read as a document or printed from the browser; the download buttons for +every format sit in the bar. The theme is chosen in the editor, not on the +list row. diff --git a/public/css/styles.css b/public/css/styles.css index 4e4e879f..f1e5c7d8 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1303,15 +1303,33 @@ textarea.full-input{resize:vertical;} 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; } +.mr-preview { position:fixed; inset:0; z-index:9000; background:#0b1220; display:flex; flex-direction:column; color:#fff; } 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; } +.mr-preview-bar { display:flex; align-items:center; gap:10px; padding:8px 12px; background:#0f172a; flex:0 0 auto; flex-wrap:wrap; } +.mr-preview-title { font-weight:600; font-size:14px; flex:1 1 200px; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.mr-preview-tabs { display:flex; gap:4px; background:rgba(255,255,255,.08); border-radius:8px; padding:3px; } +.mr-preview-tab { background:transparent; border:none; color:#cbd5e1; padding:5px 12px; border-radius:6px; font-size:13px; font-weight:600; cursor:pointer; } +.mr-preview-tab.is-on { background:#fff; color:#0f172a; } +.mr-preview-downloads { display:flex; gap:4px; } +.mr-preview-dl { background:rgba(255,255,255,.12); border:none; color:#fff; padding:5px 10px; border-radius:6px; font-size:12px; font-weight:600; cursor:pointer; } +.mr-preview-dl:hover, .mr-preview-close:hover { background:rgba(255,255,255,.22); } +.mr-preview-close { background:rgba(255,255,255,.12); border:none; color:#fff; padding:6px 14px; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; } +.mr-preview-stage { flex:1; min-height:0; display:flex; align-items:center; justify-content:center; gap:8px; padding:10px; position:relative; } +.mr-preview-stage[data-view="pdf"] .mr-preview-frame, .mr-preview-stage[data-view="pdf"] .mr-preview-nav { display:none; } +.mr-preview-frame { flex:1; height:100%; min-width:0; display:flex; align-items:center; justify-content:center; } +.mr-preview-page { max-width:100%; max-height:100%; width:auto; height:auto; object-fit:contain; background:#fff; box-shadow:0 8px 40px rgba(0,0,0,.6); border-radius:4px; } +.mr-preview-nav { background:rgba(255,255,255,.12); border:none; color:#fff; font-size:34px; line-height:1; padding:10px 14px; border-radius:10px; cursor:pointer; flex:0 0 auto; user-select:none; } +.mr-preview-nav:disabled { opacity:.25; cursor:default; } +.mr-preview-pdf { flex:1; height:100%; border:none; background:#fff; border-radius:4px; } +.mr-preview-foot { flex:0 0 auto; text-align:center; padding:6px 0 10px; } +.mr-preview-count { font-size:13px; font-weight:600; color:#fff; background:rgba(255,255,255,.15); padding:4px 14px; border-radius:20px; } +.mr-preview-status { color:#e2e8f0; font-size:14px; margin:24px; text-align:center; } +@media (max-width:600px) { + .mr-preview-nav { position:absolute; top:50%; transform:translateY(-50%); font-size:26px; padding:8px 10px; background:rgba(15,23,42,.55); } + .mr-preview-nav[data-step="-1"] { left:6px; } .mr-preview-nav[data-step="1"] { right:6px; } + .mr-preview-stage { padding:4px; } + .mr-preview-downloads { display:none; } +} .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 7a0049b0..d9e9b827 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -101,7 +101,6 @@ // 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. @@ -831,15 +830,17 @@ // 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 formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf']; var look = document.createElement('button'); look.className = 'btn-sm btn-ghost'; look.type = 'button'; look.dataset.preview = String(row.id); look.innerHTML = ' Preview'; + look.dataset.previewTitle = row.title || row.topic || 'Preview'; + look.dataset.previewFormats = formats.join(','); 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'; @@ -865,22 +866,8 @@ 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); - } + // The theme is chosen in the editor when the deck is made or modified; + // the list row only looks and downloads. // Send the rendered file to the owner's own Nextcloud. Offered only when // there is a Nextcloud to send it to. @@ -911,32 +898,6 @@ 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 @@ -969,7 +930,16 @@ 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; } + if (preview) { + var pid = encodeURIComponent(preview.dataset.preview); + openPreview('/api/my-resources/' + pid + '/preview', preview.dataset.previewTitle || 'Preview', { + pdf: '/api/my-resources/' + pid + '/export?format=pdf', + downloads: (preview.dataset.previewFormats || '').split(',').filter(Boolean).map(function (f) { + return { label: f.toUpperCase(), url: '/api/my-resources/' + pid + '/export?format=' + f }; + }) + }); + return; + } var download = event.target.closest && event.target.closest('[data-download]'); if (download) return downloadResource(download.dataset.download, download.dataset.format, download); @@ -1083,64 +1053,152 @@ // 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) { + // One page at a time, the way a deck is shown: arrows, keys, a swipe on a + // phone, a counter, and the PDF itself on a second tab for anyone who wants + // to read it as a document or print it. Pages are fetched as they are + // reached, two ahead, and kept as object URLs until the viewer closes. + function openPreview(base, title, opts) { + opts = opts || {}; 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.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.innerHTML = + '
' + + '' + + '
' + + '' + + (opts.pdf ? '' : '') + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '

Rendering pages…

' + + '' + + '
' + + '
'; overlay.querySelector('.mr-preview-title').textContent = title || 'Preview'; + var downloadsEl = overlay.querySelector('.mr-preview-downloads'); + (opts.downloads || []).forEach(function (d) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'mr-preview-dl'; b.textContent = d.label; b.title = 'Download as ' + d.label; + b.addEventListener('click', function () { fetchToFile(d.url, (title || 'resource') + '.' + d.label.toLowerCase()); }); + downloadsEl.appendChild(b); + }); document.body.appendChild(overlay); document.body.classList.add('mr-preview-open'); - var urls = []; + + var urls = [], pages = 0, current = 1, blobs = {}, pending = {}, view = 'pages'; + var frame = overlay.querySelector('.mr-preview-frame'); + var countEl = overlay.querySelector('.mr-preview-count'); + var stage = overlay.querySelector('.mr-preview-stage'); + var pdfFrame = null; + 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(); } + function onKey(e) { + if (e.key === 'Escape') close(); + else if (view === 'pages' && (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ')) { e.preventDefault(); go(current + 1); } + else if (view === 'pages' && (e.key === 'ArrowLeft' || e.key === 'PageUp')) { e.preventDefault(); go(current - 1); } + else if (view === 'pages' && e.key === 'Home') go(1); + else if (view === 'pages' && e.key === 'End') go(pages); + } document.addEventListener('keydown', onKey); overlay.querySelector('.mr-preview-close').addEventListener('click', close); - overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); + overlay.querySelectorAll('.mr-preview-nav').forEach(function (b) { + b.addEventListener('click', function () { go(current + Number(b.dataset.step)); }); + }); + // A swipe on a phone turns the page. + var touchX = null; + stage.addEventListener('touchstart', function (e) { touchX = e.touches[0].clientX; }, { passive: true }); + stage.addEventListener('touchend', function (e) { + if (touchX === null || view !== 'pages') return; + var dx = e.changedTouches[0].clientX - touchX; touchX = null; + if (Math.abs(dx) > 50) go(current + (dx < 0 ? 1 : -1)); + }, { passive: true }); + + function fetchPage(n) { + if (blobs[n] || pending[n] || n < 1 || n > pages) return Promise.resolve(blobs[n]); + pending[n] = 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); blobs[n] = u; return u; }) + .finally(function () { delete pending[n]; }); + return pending[n]; + } + function show(n) { + frame.innerHTML = ''; + var img = document.createElement('img'); + img.className = 'mr-preview-page'; + img.alt = 'Page ' + n + ' of ' + pages; + img.draggable = false; + frame.appendChild(img); + fetchPage(n).then(function (u) { if (current === n && u) img.src = u; }) + .catch(function () { if (current === n) frame.innerHTML = '

Page ' + n + ' could not be shown

'; }); + fetchPage(n + 1); fetchPage(n + 2); + countEl.textContent = 'Page ' + n + ' of ' + pages; + overlay.querySelectorAll('.mr-preview-nav').forEach(function (b) { + b.disabled = Number(b.dataset.step) < 0 ? n <= 1 : n >= pages; + }); + } + function go(n) { + if (!pages) return; + n = Math.max(1, Math.min(pages, n)); + if (n === current && frame.querySelector('img')) return; + current = n; + show(n); + } + function switchView(name) { + view = name; + overlay.querySelectorAll('.mr-preview-tab').forEach(function (t) { t.classList.toggle('is-on', t.dataset.view === name); t.setAttribute('aria-selected', String(t.dataset.view === name)); }); + stage.dataset.view = name; + overlay.querySelector('.mr-preview-foot').hidden = name !== 'pages'; + if (name === 'pdf') { + if (!pdfFrame) { + pdfFrame = document.createElement('iframe'); + pdfFrame.className = 'mr-preview-pdf'; + pdfFrame.title = (title || 'Preview') + ' as PDF'; + stage.appendChild(pdfFrame); + fetch(opts.pdf, { headers: getAuthHeaders() }) + .then(function (r) { if (!r.ok) throw new Error('The PDF could not be produced'); return r.blob(); }) + .then(function (blob) { var u = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' })); urls.push(u); pdfFrame.src = u; }) + .catch(function (err) { pdfFrame.remove(); pdfFrame = null; frame.innerHTML = '

' + err.message + '

'; }); + } + } + } + overlay.querySelectorAll('.mr-preview-tab').forEach(function (t) { t.addEventListener('click', function () { switchView(t.dataset.view); }); }); - 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(); + pages = d.pages; + show(1); }) .catch(function (err) { - pagesEl.innerHTML = ''; + frame.innerHTML = ''; var p = document.createElement('p'); p.className = 'mr-preview-status'; p.textContent = err.message; - pagesEl.appendChild(p); + frame.appendChild(p); }); } + function fetchToFile(url, name) { + fetch(url, { headers: getAuthHeaders() }) + .then(function (r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); }) + .then(function (blob) { saveBlob(blob, name); }) + .catch(function (err) { status(err.message); }); + } + function saveBlob(blob, name) { var url = URL.createObjectURL(blob); var link = document.createElement('a'); diff --git a/src/utils/previewPages.js b/src/utils/previewPages.js index a39397dfaf5522d1701fd8766add1c4f9461ac01..d117072253a789980245de0f97f8a476a594da07 100644 GIT binary patch delta 50 zcmeyX@Lyp=Gz+8Y}3!LzB%@Szd7i E0Gk33xBvhE diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js index e30e7c21..6ee3de12 100644 --- a/test/deck-themes.test.js +++ b/test/deck-themes.test.js @@ -79,13 +79,14 @@ test('the theme is shown by a deck the renderer built, not a mocked-up swatch', assert.doesNotMatch(route, /THEME_PREVIEW_DIR|theme-preview/, 'the cached-PNG preview is gone'); }); -test('the library offers a theme only where there is a deck to re-skin', () => { +test('the theme is chosen in the editor, not on the list row', () => { const ui = read('public/js/myResources.js'); - 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. - assert.match(ui, /if \(previous\) select\.value = previous;/); + // The list row looks and downloads; it carries no theme dropdown. + assert.doesNotMatch(ui, /theme\.dataset\.theme = String\(row\.id\)/); + assert.doesNotMatch(ui, /closest\('\[data-theme\]'\)/); + // The editor still offers the catalogue and a sample of each theme. + assert.match(ui, /getElementById\('mr-theme'\)/); + assert.match(ui, /theme-sample/); }); test('a modification keeps the deck\'s theme', () => { diff --git a/test/resource-previews.test.js b/test/resource-previews.test.js index 27196ed4..bf3d600f 100644 --- a/test/resource-previews.test.js +++ b/test/resource-previews.test.js @@ -43,6 +43,11 @@ test('the page offers Preview beside the downloads and beside the theme sample, // 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%/); + assert.match(css, /\.mr-preview-page \{[^}]*max-height:100%/); + assert.match(css, /\.mr-preview-pdf \{/); + const mr = read('public/js/myResources.js'); + assert.match(mr, /data-view="pdf"/); + assert.match(mr, /e\.key === 'ArrowRight'/); + assert.match(mr, /touchend/); + assert.doesNotMatch(mr, /theme\.dataset\.theme = String\(row\.id\)/, 'the list row no longer carries a theme dropdown'); });