Add server-side image thumbnails (sharp): /uploads/thumb/<name> route, ?thumb=1 on file endpoints, disk-cached 480px WebP previews; grids now load thumbnails with lazy loading

This commit is contained in:
dev
2026-09-12 00:02:23 +03:00
parent 9798872f20
commit fd9b470c4c
11 changed files with 654 additions and 15 deletions
+2 -2
View File
@@ -56,7 +56,7 @@ function renderRecent(rows) {
el.innerHTML = rows.map(e => `
<div class="recent-item">
${e.photo_path
? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">`
? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">`
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
<div class="r-info">
<div class="r-name">${esc(e.student_name)}</div>
@@ -95,7 +95,7 @@ function renderPhotos(rows) {
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
el.innerHTML = rows.map(p => `
<div class="dash-photo" title="${esc(p.group_name)}">
<img src="${API}${p.photo_path}" data-img="${API}${p.photo_path}" alt="">
<img src="${thumbSrc(p.photo_path)}" data-img="${API}${p.photo_path}" loading="lazy" decoding="async" alt="">
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
</div>`).join('');
}
+1 -1
View File
@@ -34,7 +34,7 @@ function humanSize(b) {
function fileCardHTML(f) {
const url = `${API}/api/files/${f.token}`;
const thumb = isImageFile(f.name)
? `<img src="${url}" data-img="${esc(url)}" alt="">`
? `<img src="${thumbSrc(url)}" data-img="${esc(url)}" loading="lazy" decoding="async" alt="">`
: `<div class="file-icon">📄</div>`;
const target = isImageFile(f.name)
? `href="#" data-img="${esc(url)}"`
+2 -2
View File
@@ -25,7 +25,7 @@ async function loadGroups() {
return `
<div class="group-card" data-group-id="${g.id}">
${g.cover_path
? `<img class="group-cover" src="${API}${g.cover_path}" alt="">`
? `<img class="group-cover" src="${thumbSrc(g.cover_path)}" loading="lazy" decoding="async" alt="">`
: `<div class="group-cover empty-cover">📷</div>`}
<div class="group-head">
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
@@ -151,7 +151,7 @@ function photoHTML(p) {
return `
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
<div class="drag-hint" title="Перетащите, чтобы изменить порядок">⠿</div>
<img src="${API}${p.photo_path}" data-img="${API}${p.photo_path}" alt="">
<img src="${thumbSrc(p.photo_path)}" data-img="${API}${p.photo_path}" loading="lazy" decoding="async" alt="">
<div class="p-cap">${esc(p.caption || '')}</div>
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
<div class="p-actions">
+3 -3
View File
@@ -92,7 +92,7 @@ function gridEntryHTML(e) {
const mp = mainPhotoPath(e);
return `
<div class="card">
${mp ? `<img src="${API}${mp}" data-img="${API}${mp}" alt="">` : ''}
${mp ? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
@@ -111,7 +111,7 @@ function cardEntryHTML(e) {
return `
<div class="journal-card">
${mp
? `<img src="${API}${mp}" data-img="${API}${mp}" alt="">`
? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
${e.group_name ? `<span class="group-badge" title="${esc(e.group_name)}">${esc(e.group_name)}</span>` : ''}
<div class="body">
@@ -252,7 +252,7 @@ function renderEditPhotos(photos, mainPath) {
const applyStyle = isMain
? 'background:#16a34a;border:none;color:#fff;border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:default;white-space:nowrap'
: 'background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:pointer;white-space:nowrap';
div.innerHTML = `<img src="${API}${esc(p.photo_path)}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.photo_path.split('/').pop())}</span> <button type="button" class="apply-photo-btn" style="${applyStyle}" title="${isMain ? 'Главное фото записи' : 'Сделать главным фото записи'}" ${isMain ? 'disabled' : ''}>${isMain ? 'Применено ✓' : 'Применить'}</button> <button type="button" class="delete-entry-photo" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.1rem;line-height:1" title="Удалить фото">✕</button>`;
div.innerHTML = `<img src="${thumbSrc(p.photo_path)}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.photo_path.split('/').pop())}</span> <button type="button" class="apply-photo-btn" style="${applyStyle}" title="${isMain ? 'Главное фото записи' : 'Сделать главным фото записи'}" ${isMain ? 'disabled' : ''}>${isMain ? 'Применено ✓' : 'Применить'}</button> <button type="button" class="delete-entry-photo" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.1rem;line-height:1" title="Удалить фото">✕</button>`;
preview.appendChild(div);
const thumb = div.querySelector('img');
const showMissing = () => {
+8 -2
View File
@@ -2,6 +2,12 @@ function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
function thumbSrc(p){
if(!p)return p;
if(p.startsWith('/uploads/'))return '/uploads/thumb/'+p.slice('/uploads/'.length);
if(p.includes('/api/files/'))return p+(p.includes('?')?'&':'?')+'thumb=1';
return p;
}
let countdownInterval = null;
let countdownTarget = null;
@@ -216,7 +222,7 @@ function renderShare(data, password) {
if (data.show_group_photos && data.photos && data.photos.length) {
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
<a class="gphoto" href="#" data-img="${esc(p.photo_path)}">
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
<img src="${thumbSrc(p.photo_path)}" alt="${esc(p.caption || '')}" loading="lazy" decoding="async">
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
</a>
`).join('');
@@ -237,7 +243,7 @@ function renderShare(data, password) {
<div class="card">
<div class="photo">
${e.photo_path
? `<img src="${e.photo_path}" data-img="${esc(e.photo_path)}" alt="">`
? `<img src="${thumbSrc(e.photo_path)}" data-img="${esc(e.photo_path)}" loading="lazy" decoding="async" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
</div>
<div class="body">
+1 -1
View File
@@ -19,7 +19,7 @@ function filesHTML(files) {
function entryHTML(e) {
return `
<div class="card">
${e.photo_path ? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">` : ''}
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>