From 451b04ad039b3f0af98fa7590184ee73fe704497 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sun, 22 Mar 2026 00:40:43 +0000 Subject: [PATCH] Add PDF first-page thumbnails via pdftoppm Generated at upload time, stored alongside the PDF in data/pdfs/. Shown in the file picker (48px) and component detail view (80px). Gracefully skipped if pdftoppm is unavailable. Co-Authored-By: Claude Sonnet 4.6 --- public/app.mjs | 16 ++++++++++++++++ public/style.css | 18 +++++++++++++++++- server.mjs | 22 +++++++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/public/app.mjs b/public/app.mjs index e88c9b0..7e319e3 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -350,6 +350,14 @@ function render_detail_panel() { const row = document.createElement('div'); row.className = 'detail-file-row'; + if (pdf.thumb_filename) { + const thumb = document.createElement('img'); + thumb.className = 'pdf-thumb'; + thumb.src = `/pdf/${pdf.thumb_filename}`; + thumb.alt = ''; + row.appendChild(thumb); + } + const a = document.createElement('a'); a.className = 'detail-file-link'; a.href = `/pdf/${pdf.filename}`; @@ -1363,6 +1371,14 @@ function render_file_picker_list() { const row = document.createElement('div'); row.className = 'fp-row'; + if (pdf.thumb_filename) { + const thumb = document.createElement('img'); + thumb.className = 'fp-thumb'; + thumb.src = `/pdf/${pdf.thumb_filename}`; + thumb.alt = ''; + row.appendChild(thumb); + } + const name_el = document.createElement('span'); name_el.className = 'fp-name'; name_el.textContent = pdf.display_name; diff --git a/public/style.css b/public/style.css index 15bfcb0..8c50d24 100644 --- a/public/style.css +++ b/public/style.css @@ -1574,7 +1574,23 @@ nav { .detail-file-row { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.75rem; +} + +.pdf-thumb { + width: auto; + height: 80px; + border: 1px solid var(--border); + border-radius: 3px; + flex-shrink: 0; +} + +.fp-thumb { + width: auto; + height: 48px; + border: 1px solid var(--border); + border-radius: 3px; + flex-shrink: 0; } .detail-file-link { diff --git a/server.mjs b/server.mjs index 5a606e3..a53d3d9 100644 --- a/server.mjs +++ b/server.mjs @@ -5,6 +5,7 @@ import express from 'express'; import multer from 'multer'; import { unlinkSync, mkdirSync } from 'node:fs'; import { extname, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; import sharp from 'sharp'; import { generate_id } from './lib/ids.mjs'; import { compute_cell_size, process_grid_image } from './lib/grid-image.mjs'; @@ -62,6 +63,20 @@ function remove_image_file(img_id) { try { unlinkSync(join('./data/images', img_id)); } catch {} } +function generate_pdf_thumb(pdf_path, thumb_prefix) { + // Returns thumb filename (id-thumb.png) on success, null if pdftoppm unavailable. + try { + execFileSync('pdftoppm', [ + '-png', '-singlefile', '-scale-to', '512', + '-f', '1', '-l', '1', + pdf_path, thumb_prefix, + ]); + return thumb_prefix.split('/').pop() + '.png'; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // Field definitions // --------------------------------------------------------------------------- @@ -491,12 +506,16 @@ app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => { try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {} return fail(res, 'a file with that name already exists'); } + const id = generate_id(); + const thumb_prefix = join('./data/pdfs', id + '-thumb'); + const thumb_file = generate_pdf_thumb(join('./data/pdfs', req.file.filename), thumb_prefix); const pdf = { - id: generate_id(), + id, filename: req.file.filename, display_name, original_name: req.file.originalname, size: req.file.size, + thumb_filename: thumb_file, uploaded_at: Date.now(), }; set_pdf(pdf); @@ -520,6 +539,7 @@ app.delete('/api/pdfs/:id', (req, res) => { const pdf = get_pdf(req.params.id); if (!pdf) return fail(res, 'not found', 404); try { unlinkSync(join('./data/pdfs', pdf.filename)); } catch {} + if (pdf.thumb_filename) { try { unlinkSync(join('./data/pdfs', pdf.thumb_filename)); } catch {} } delete_pdf(req.params.id); ok(res); });