feat: the preview is a viewer — one page at a time, arrows, keys, swipe, a PDF tab and downloads; the theme leaves the list row
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s

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 17:49:22 +02:00
parent b277ce4b10
commit cba2a312e1
6 changed files with 183 additions and 93 deletions

View file

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

View file

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

View file

@ -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 = '<i class="fas fa-eye"></i> 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 <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) {
// 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 = '<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.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML =
'<div class="mr-preview-bar">' +
'<span class="mr-preview-title"></span>' +
'<div class="mr-preview-tabs" role="tablist">' +
'<button type="button" class="mr-preview-tab is-on" data-view="pages" role="tab">Pages</button>' +
(opts.pdf ? '<button type="button" class="mr-preview-tab" data-view="pdf" role="tab">PDF</button>' : '') +
'</div>' +
'<span class="mr-preview-downloads"></span>' +
'<button type="button" class="mr-preview-close" aria-label="Close preview">&#10005; Close</button>' +
'</div>' +
'<div class="mr-preview-stage" data-view="pages">' +
'<button type="button" class="mr-preview-nav" data-step="-1" aria-label="Previous page">&#8249;</button>' +
'<div class="mr-preview-frame"><p class="mr-preview-status">Rendering pages…</p></div>' +
'<button type="button" class="mr-preview-nav" data-step="1" aria-label="Next page">&#8250;</button>' +
'</div>' +
'<div class="mr-preview-foot"><span class="mr-preview-count" role="status"></span></div>';
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 = '<p class="mr-preview-status">Page ' + n + ' could not be shown</p>'; });
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 = '<p class="mr-preview-status">' + err.message + '</p>'; });
}
}
}
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');

Binary file not shown.

View file

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

View file

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