feat(photos): раздел «Фото» — единая галерея всех загруженных фотографий

Новый read-only раздел для просмотра всех фотографий системы:
GET /api/photos собирает через UNION ALL пять источников — главное фото
записи, фото записи (entry_photos), фотохронику групп, фото учеников и фото
тем модулей. Фильтры search/student_name/group_id/date_from/date_to и
пагинация limit/offset, ответ { photos, total }. Не-admin ограничен
своими филиалами (branch_id), чужой group_id отдаёт 403.

Фронтенд: public/photos.html + public/js/photos.js — сетка превью
(/uploads/thumb/...), бейдж источника, описание, ученик/группа, дата,
ссылка на источник, lightbox по клику, поиск с debounce и пагинация.
Навигация: пункт «Фото» в сайдбаре и плитка в быстрых действиях дашборда.
This commit is contained in:
dev
2026-09-28 00:15:32 +03:00
parent 2afe676969
commit 31542de33b
6 changed files with 383 additions and 31 deletions
+144
View File
@@ -0,0 +1,144 @@
const PAGE_SIZE = 24;
let page = 1;
let totalPhotos = 0;
let currentPhotos = [];
let groupsLoaded = false;
let studentsLoaded = false;
let sourcesLoaded = false;
let searchTimer = null;
function humanSize(b) {
if (!b) return '';
if (b < 1024) return b + ' Б';
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
function photoLink(p) {
if (p.source_type === 'entry') return `journal.html#entry-${p.source_id}`;
if (p.source_type === 'entry_photo') return `journal.html#entry-${p.source_id}`;
if (p.source_type === 'group_photo') return 'groups.html';
if (p.source_type === 'student_photo') return 'students.html';
if (p.source_type === 'module_photo') return 'modules.html';
return '';
}
function photoCardHTML(p) {
const url = p.path;
const meta = [];
if (p.student_name) meta.push(esc(p.student_name));
if (p.group_name) meta.push(esc(p.group_name));
const link = photoLink(p);
return `
<div class="ph-card">
<img src="${esc(thumbSrc(url))}" data-img="${esc(url)}" loading="lazy" decoding="async" alt="">
<div class="ph-body">
<span class="ph-badge">${esc(p.source_label)}</span>
<div class="ph-title">${esc(p.title || 'Без описания')}</div>
${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''}
<div class="ph-time">${new Date(p.created_at).toLocaleString('ru')}</div>
${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''}
</div>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!currentPhotos.length) {
grid.innerHTML = '<div class="empty" style="grid-column:1/-1">Фотографий не найдено</div>';
return;
}
grid.innerHTML = currentPhotos.map(photoCardHTML).join('');
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
while (sel.options.length > 1) sel.remove(1);
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id; o.textContent = g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
function fillSourceOptions() {
if (sourcesLoaded) return;
const sel = document.getElementById('sourceFilter');
const labels = [];
currentPhotos.forEach(p => { if (!labels.includes(p.source_label)) labels.push(p.source_label); });
const known = ['Главное фото записи', 'Фото записи', 'Фотохроника группы', 'Фото ученика', 'Тема модуля'];
known.concat(labels.filter(l => !known.includes(l))).forEach(label => {
const o = document.createElement('option');
o.value = label; o.textContent = label;
sel.appendChild(o);
});
sourcesLoaded = true;
}
async function loadPhotos() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/photos?${p}`, { headers: hdr() });
if (!res.ok) {
document.getElementById('grid').innerHTML = '<div class="empty" style="grid-column:1/-1">Не удалось загрузить фотографии</div>';
return;
}
const data = await res.json();
const srcFilter = document.getElementById('sourceFilter').value;
currentPhotos = srcFilter ? data.photos.filter(x => x.source_label === srcFilter) : data.photos;
totalPhotos = srcFilter ? currentPhotos.length : data.total;
fillSourceOptions();
renderGrid();
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalPhotos / PAGE_SIZE)), goPage);
}
function goPage(p) {
page = p;
loadPhotos();
}
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('sourceFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadPhotos(); }, 300);
});
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
(async () => {
if (await checkAuth()) {
buildSidebar('photos');
await loadGroupOptions();
await loadStudentOptions();
loadPhotos();
}
})();