Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
145 lines
5.4 KiB
JavaScript
145 lines
5.4 KiB
JavaScript
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">${fmtFull(p.created_at)}</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();
|
|
}
|
|
})();
|