diff --git a/public/components/my-resources.html b/public/components/my-resources.html index 92c498ed..088f4826 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -49,6 +49,9 @@

+ +
diff --git a/public/js/myResources.js b/public/js/myResources.js index 0d071b36..ac8d77ce 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -101,6 +101,7 @@ // 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. @@ -150,6 +151,7 @@ var themeSelect = document.getElementById('mr-theme'); var themeRow = document.getElementById('mr-theme-row'); var themes = Array.isArray(data.themes) ? data.themes : []; + themeCatalogue = themes; if (themeSelect && themes.length) { var chosenTheme = themeSelect.value; themeSelect.textContent = ''; @@ -165,6 +167,7 @@ } describeTheme(themes); themeSelect.onchange = function () { describeTheme(themes); }; + showThemePreview(); } if (themeRow) themeRow.hidden = themes.length < 2; @@ -208,6 +211,22 @@ // when the tab was reopened, looking like output for a topic nobody had // typed. It cleared on a full page refresh and only then, which is why it // read as a leak. + // A rendered slide in the chosen palette. The renderer draws it, so it cannot + // promise a look the export does not deliver. + function showThemePreview() { + var select = document.getElementById('mr-theme'); + var box = document.getElementById('mr-theme-preview'); + if (!select || !box) return; + var img = box.querySelector('img') || document.createElement('img'); + img.alt = 'A slide rendered in the ' + (select.options[select.selectedIndex] || {}).text + ' theme'; + img.loading = 'lazy'; + img.style.cssText = 'max-width:min(100%,340px);border:1px solid var(--g200);border-radius:8px;display:block;'; + img.onerror = function () { box.hidden = true; }; + img.onload = function () { box.hidden = false; }; + img.src = '/api/my-resources/theme-preview/' + encodeURIComponent(select.value); + if (!img.parentNode) box.appendChild(img); + } + // Says what the chosen theme is for, which is the part a name cannot carry. function describeTheme(themes) { var select = document.getElementById('mr-theme'); @@ -215,6 +234,7 @@ if (!select || !hint) return; var chosen = themes.filter(function (t) { return t.id === select.value; })[0]; hint.textContent = chosen ? (chosen.description || '') : ''; + showThemePreview(); } function clearResults() { @@ -338,6 +358,8 @@ // Thumbnails, not originals. The server stores a 256px preview beside each // asset, so a grid of thirty costs a few kB each instead of thirty full-size // renders — data-image-thumb is what asks hydrateImage for the small copy. + // The catalogue, once, shared by the generate form and every library row. + var themeCatalogue = []; var imagesLoaded = false; var nextImagesBefore = null; @@ -767,6 +789,25 @@ wrap.appendChild(btn); }); + // 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) { + 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); + } + var del = document.createElement('button'); del.className = 'btn-sm btn-ghost'; del.type = 'button'; @@ -781,6 +822,33 @@ 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; }); + } + function onRowClick(event) { var download = event.target.closest && event.target.closest('[data-download]'); if (download) return downloadResource(download.dataset.download, download.dataset.format, download); diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 8337a573..f7417724 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -485,6 +485,66 @@ router.post('/my-resources/generate', async function (req, res) { } }); +// ── Theme previews ────────────────────────────────────────── +// One representative slide per theme, rendered by the renderer itself and +// cached. Drawn rather than mocked up: a hand-made swatch drifts the moment a +// palette or a layout changes, and the point of a preview is to be true. +// +// Cached on disk because it costs a pptx render, a Gotenberg round trip and a +// pdftoppm — far too much to repeat per page view, and the output only changes +// when the catalogue or the renderer does. +var THEME_PREVIEW_DIR = require('path').join(require('os').tmpdir(), 'deck-theme-previews'); + +function previewDeck(theme) { + return { + theme: theme.id, + slides: [{ + type: 'compare', + heading: theme.name, + columns: [ + { label: 'MILD', bullets: [{ text: 'Barking cough', level: 0 }, { text: 'No stridor at rest', level: 0 }] }, + { label: 'SEVERE', bullets: [{ text: 'Stridor at rest', level: 0 }, { text: 'Marked recession', level: 0 }] } + ] + }] + }; +} + +router.get('/my-resources/theme-preview/:id', async function (req, res) { + var fsp = require('fs/promises'); + var pathMod = require('path'); + try { + var id = deckSchema.themeId(req.params.id); + if (!id) return res.status(404).json({ error: 'No such theme' }); + + var cached = pathMod.join(THEME_PREVIEW_DIR, id + '.png'); + try { + var hit = await fsp.readFile(cached); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'private, max-age=86400'); + return res.send(hit); + } catch (e) { /* not rendered yet */ } + + var theme = deckSchema.themes().filter(function (t) { return t.id === id; })[0]; + var pptx = await documentExport.renderDeck(previewDeck(theme), [], []); + var images = await deckReview.slideImages(pptx, documentExport.GOTENBERG, + documentExport.FORMATS.pptx.mime); + if (!images.length) return res.status(503).json({ error: 'Preview rendering unavailable' }); + + var png = Buffer.from(images[0].dataBase64, 'base64'); + // Best effort: a preview that cannot be cached is still a preview. + try { + await fsp.mkdir(THEME_PREVIEW_DIR, { recursive: true }); + await fsp.writeFile(cached, png); + } catch (e) {} + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'private, max-age=86400'); + res.send(png); + } catch (err) { + logger.warn('[my-resources] theme preview', { theme: req.params.id, error: err.message }); + res.status(503).json({ error: 'Preview rendering unavailable' }); + } +}); + // ── The owner's library ───────────────────────────────────── router.get('/my-resources', async function (req, res) { try { @@ -494,7 +554,8 @@ router.get('/my-resources', async function (req, res) { // weaker modification path — that the owner should be able to see which // kind they have. 'SELECT id, title, kind, topic, grounded_count, created_at, updated_at, ' + - "(deck IS NOT NULL AND jsonb_array_length(COALESCE(deck->'slides', '[]'::jsonb)) > 0) AS has_deck " + + "(deck IS NOT NULL AND jsonb_array_length(COALESCE(deck->'slides', '[]'::jsonb)) > 0) AS has_deck, " + + "COALESCE(deck->>'theme', '') AS theme " + 'FROM user_resources WHERE user_id = ? ORDER BY created_at DESC LIMIT ?', [req.user.id, MAX_PER_USER] ); diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js new file mode 100644 index 00000000..3e998c04 --- /dev/null +++ b/test/deck-themes.test.js @@ -0,0 +1,81 @@ +// A deck's look is a theme, not a colour the model writes into a field. The +// slide vocabulary is structural — bullets, compare, table, callout — and none +// of it carries a colour, so this is the whole of a deck's styling. +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'); +const deckSchema = require('../src/utils/deckSchema'); +const catalogue = JSON.parse(read('assets/deck-themes.json')).themes; + +test('every theme is complete, and every colour is a real colour', () => { + assert.ok(catalogue.length >= 4, 'a picker of one is not a choice'); + for (const theme of catalogue) { + for (const key of ['id', 'name', 'accent', 'ink', 'muted', 'rule', 'paper', 'font']) { + assert.ok(theme[key], theme.id + ' is missing ' + key); + } + for (const key of ['accent', 'ink', 'muted', 'rule', 'paper']) { + assert.match(theme[key], /^[0-9A-Fa-f]{6}$/, theme.id + '.' + key + ' is not six hex digits'); + } + } + const ids = catalogue.map(t => t.id); + assert.equal(ids.length, new Set(ids).size, 'theme ids must be unique'); +}); + +test('the app and the renderer read one catalogue, so they cannot disagree', () => { + const py = read('scripts/render_pptx.py'); + assert.match(py, /assets", "deck-themes\.json/); + const js = read('src/utils/deckSchema.js'); + assert.match(js, /deck-themes\.json/); + assert.deepEqual(deckSchema.themes().map(t => t.id), catalogue.map(t => t.id)); +}); + +test('an unknown theme is dropped rather than stored', () => { + // A stored deck must never claim a theme the renderer will silently ignore. + const slides = [{ type: 'bullets', heading: 'x', bullets: ['a'] }]; + assert.equal(deckSchema.normalise({ theme: 'teaching-amber', slides }).theme, 'teaching-amber'); + assert.equal(deckSchema.normalise({ theme: 'not-a-theme', slides }).theme, ''); + assert.equal(deckSchema.normalise({ theme: '../../etc/passwd', slides }).theme, ''); + assert.equal(deckSchema.normalise({ slides }).theme, ''); +}); + +test('the renderer rebinds its palette instead of hardcoding one', () => { + // Every builder reads these names, which is what makes a theme a rebinding + // rather than a change to ten slide builders. + const py = read('scripts/render_pptx.py'); + assert.match(py, /def apply_theme\(theme_id\)/); + assert.match(py, /global INK, MUTED, ACCENT, RULE, PAPER, FONT/); + assert.match(py, /apply_theme\(spec\.get\("theme"\)\)/); + // An unusable theme must not cost the deck its render. + assert.match(py, /a deck rendering in the wrong colours beats a deck not rendering/); +}); + +test('re-skinning is a column write, never a regeneration', () => { + const route = read('src/routes/myResources.js'); + const handler = route.slice(route.indexOf("router.put('/my-resources/:id/theme'")); + assert.match(handler.slice(0, 1400), /UPDATE user_resources SET deck = \?::jsonb/); + assert.doesNotMatch(handler.slice(0, 1400), /callAI/, 'no model call: nothing can reword a slide'); + assert.match(handler.slice(0, 1400), /AND user_id = \?/, 'scoped to the owner'); + assert.match(handler.slice(0, 1400), /has no slide layout/, 'flat markdown has no theme'); +}); + +test('the preview is rendered by the renderer, not mocked up', () => { + // A hand-drawn swatch drifts the moment a palette or a layout changes. + const route = read('src/routes/myResources.js'); + const preview = route.slice(route.indexOf("router.get('/my-resources/theme-preview/:id'")); + assert.match(preview.slice(0, 1800), /documentExport\.renderDeck\(previewDeck\(theme\)/); + assert.match(preview.slice(0, 1800), /deckReview\.slideImages/); + assert.match(preview.slice(0, 1800), /deckSchema\.themeId\(req\.params\.id\)/, 'the id is validated, not used as a path'); + assert.match(route, /THEME_PREVIEW_DIR/, 'cached: it costs a render, a Gotenberg trip and a rasterise'); +}); + +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, /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;/); +});