diff --git a/dental-server/image-processor.js b/dental-server/image-processor.js index 074da32..e3ca232 100644 --- a/dental-server/image-processor.js +++ b/dental-server/image-processor.js @@ -104,15 +104,15 @@ class ImageProcessor { // MÉTODOS DE UTILIDADE // ================================================================ - async getThumbnail(imageBuffer, width = 300, height = 300) { + async getThumbnail(imageBuffer, width = 500, height = 500) { try { const sharp = require('sharp'); return await sharp(imageBuffer) - .resize(width, height, { + .resize(width, height, { fit: 'inside', - withoutEnlargement: true + withoutEnlargement: true }) - .jpeg({ quality: 50, force: true }) + .jpeg({ quality: 80, force: true }) .toBuffer(); } catch (error) { console.error('Erro ao gerar thumbnail:', error); diff --git a/dental-server/public/app.js b/dental-server/public/app.js index efc2e2a..bad760b 100644 --- a/dental-server/public/app.js +++ b/dental-server/public/app.js @@ -655,20 +655,28 @@ async function showTransformModal(imageId) { throw new Error('Imagem não encontrada na lista carregada.'); } + // Painel "Preview" sempre mostra a versão atual (com transformações aplicadas) const imageUrl = image.filename.startsWith('http') ? image.filename : `/uploads/${image.filename}`; - - // Atribui a imagem às tags do DOM para exibição no painel - document.getElementById('originalImgPreview').src = imageUrl; document.getElementById('transformedImgPreview').src = imageUrl; - - // Check if the image has been modified (has original_image_id) + + // Painel "Original": se a imagem foi transformada, busca a versão anterior ao processamento const btnResetImage = document.getElementById('btnResetImage'); if (image.original_image_id) { btnResetImage.style.display = 'flex'; btnResetImage.setAttribute('data-modified', 'true'); + // Carrega a imagem-mãe (full-res, sem transformações) no painel Original + fetchWithAuth(`${API_URL}/images/${image.original_image_id}/file-url`) + .then(r => r.ok ? r.json() : null) + .then(data => { + const origUrl = data && data.url ? data.url : imageUrl; + document.getElementById('originalImgPreview').src = origUrl; + }) + .catch(() => { document.getElementById('originalImgPreview').src = imageUrl; }); } else { btnResetImage.style.display = 'none'; btnResetImage.setAttribute('data-modified', 'false'); + // Sem transformações: original = atual + document.getElementById('originalImgPreview').src = imageUrl; } document.getElementById('transformModalTitle').innerText = `${escapeHtml(image.patient_name || 'Desconhecido')} - ${formatDate(image.created_at)}`; diff --git a/dental-server/public/style.css b/dental-server/public/style.css index c2a0a70..f4604ff 100644 --- a/dental-server/public/style.css +++ b/dental-server/public/style.css @@ -97,11 +97,12 @@ body { } .sidebar-header { - padding: 0 24px 32px; + padding: 0 20px 16px; display: flex; align-items: center; gap: 12px; border-bottom: none; + flex-shrink: 0; } .logo-icon { @@ -127,8 +128,8 @@ body { .sidebar-nav { display: flex; flex-direction: column; - gap: 8px; - padding: 0 16px; + gap: 2px; + padding: 0 12px; flex: 1; overflow-y: auto; } @@ -170,8 +171,10 @@ body { } .sidebar-footer { - padding: 0 16px; - margin-bottom: 12px; + padding: 12px 16px; + border-top: 1px solid var(--border-color); + margin-top: auto; + flex-shrink: 0; } .logout-btn { @@ -841,11 +844,7 @@ body { padding: 10px 16px; } -.sidebar-footer { - padding: 20px; - border-top: 1px solid var(--border-color); - margin-top: auto; -} +/* sidebar-footer definida acima, perto do .sidebar */ .sidebar-brand { display: flex; diff --git a/dental-server/routes/images.js b/dental-server/routes/images.js index 12bf28f..4980d85 100644 --- a/dental-server/routes/images.js +++ b/dental-server/routes/images.js @@ -456,6 +456,23 @@ router.delete('/:id', async (req, res) => { } }); +// ================================================================ +// GET /api/images/:id/file-url — Retorna a URL de visualização +// full-res de uma imagem (para o painel Original do modal) +// ================================================================ +router.get('/:id/file-url', async (req, res) => { + try { + const image = await db.get('SELECT filename FROM images WHERE id = $1', [req.params.id]); + if (!image) return res.status(404).json({ error: 'Imagem não encontrada' }); + const url = image.filename.startsWith('http') + ? image.filename + : `/uploads/${image.filename}`; + res.json({ url }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + // ================================================================ // PUT /api/images/:id/toggle-enabled - Habilitar/Desabilitar // ================================================================ @@ -475,4 +492,51 @@ router.put('/:id/toggle-enabled', async (req, res) => { } }); +// ================================================================ +// POST /api/images/backfill-thumbs — Gera thumbnails para imagens +// que ainda não têm thumb_filename. Roda em background; retorna +// imediatamente com o total de imagens na fila. +// ================================================================ +router.post('/backfill-thumbs', async (req, res) => { + try { + const rows = await db.all( + `SELECT id, filename, client_name, patient_name + FROM images + WHERE thumb_filename IS NULL AND enabled = true + ORDER BY created_at DESC` + ); + res.json({ queued: rows.length, message: 'Backfill iniciado em background' }); + // Processa sem bloquear a resposta + setImmediate(() => runThumbBackfill(rows)); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +async function runThumbBackfill(rows) { + const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', 'uploads'); + const PROCESSED_DIR = process.env.PROCESSED_DIR || path.join(__dirname, '..', '..', 'processed'); + let done = 0, failed = 0; + for (const row of rows) { + try { + // Tenta carregar a imagem do disco local + let imgBuffer = null; + for (const dir of [UPLOAD_DIR, PROCESSED_DIR]) { + try { imgBuffer = await fs.readFile(path.join(dir, row.filename)); break; } catch (_) {} + } + if (!imgBuffer) { failed++; continue; } + const thumbBuffer = await imageProcessor.getThumbnail(imgBuffer); + const thumbFilename = `thumb_${row.filename.replace(/\.[^.]+$/, '')}.jpg`; + await storage.saveImage(thumbFilename, thumbBuffer, row.client_name, row.patient_name, false); + await db.run('UPDATE images SET thumb_filename = $1 WHERE id = $2', [thumbFilename, row.id]); + done++; + } catch (e) { + failed++; + } + } + console.log(`[backfill-thumbs] concluído: ${done} geradas, ${failed} falhas de ${rows.length} total`); +} + +// Exporta a função para ser chamada no boot do servidor module.exports = router; +module.exports.runThumbBackfill = runThumbBackfill; diff --git a/dental-server/server.js b/dental-server/server.js index 45412ef..83694c3 100644 --- a/dental-server/server.js +++ b/dental-server/server.js @@ -507,6 +507,8 @@ async function serveHtmlWithCacheBuster(res, filePath) { // Injeta versão em tags de script e css content = content.replace(/src="([^"]+\.js)(?:\?v=[^"]*)?"/g, `src="$1?v=${APP_VERSION}"`); content = content.replace(/href="([^"]+\.css)(?:\?v=[^"]*)?"/g, `href="$1?v=${APP_VERSION}"`); + // Substitui o número de versão exibido na sidebar (elimina hardcode no HTML) + content = content.replace(/(]*>)\s*v\d+\.\d+\.\d+\s*(<\/span>)/g, `$1v${APP_VERSION}$2`); res.set('Content-Type', 'text/html'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); @@ -1233,6 +1235,21 @@ async function start() { redis.initRedis(); // Iniciar servidor + // Backfill de thumbnails ausentes: roda em background após o boot + setTimeout(async () => { + try { + const { runThumbBackfill } = require('./routes/images'); + const missing = await db.all( + `SELECT id, filename, client_name, patient_name FROM images + WHERE thumb_filename IS NULL AND enabled = true ORDER BY created_at DESC` + ); + if (missing.length > 0) { + console.log(`[boot] Iniciando backfill de thumbnails para ${missing.length} imagem(ns) sem thumb...`); + runThumbBackfill(missing); + } + } catch (e) { /* silencia erro de backfill para não afetar o boot */ } + }, 5000); + server.listen(PORT, () => { console.log('═══════════════════════════════════════════════════════'); console.log('🚀 DENTAL IMAGE SERVER STARTED');