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…
Rendering pages…
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 a39397df..d1170722 100644 Binary files a/src/utils/previewPages.js and b/src/utils/previewPages.js differ 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'); });