feat: редизайн страницы отчёта ученика — портфолио-стиль, галерея с листанием и видео

- renderStudentReport: детский учебный дизайн (крупные скругления, sticky-навигация, секции-карточки)

- журнал занятий в две колонки (одна на мобильных)

- единая галерея фото и видео: листание кнопками/стрелками, счётчик

- воспроизведение видео прямо в лайтбоксе

- кнопка закрытия ✕, закрытие по фону и Esc

- модалка экспорта отчёта ученика (период и выбор содержимого)
This commit is contained in:
dev
2026-09-12 23:55:35 +03:00
parent 3c40e94a57
commit fac668567e
3 changed files with 706 additions and 0 deletions
+86
View File
@@ -47,6 +47,7 @@ function renderStudents() {
<select class="group-select" id="sgroup-${s.id}"></select>
<div class="student-actions">
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
<button class="btn-link" data-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</button>
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
<button class="btn-del" data-del-student="${s.id}" title="Удалить">&times;</button>
</div>
@@ -80,6 +81,81 @@ async function saveStudent(id) {
else { const e = await res.json(); alert(e.error); }
}
let exportPendingName = null;
function openExportModal(name) {
exportPendingName = name;
document.getElementById('exportName').textContent = name;
document.getElementById('exportForm').reset();
document.getElementById('exportDateFrom').value = '';
document.getElementById('expEntries').checked = true;
document.getElementById('expPhotos').checked = true;
document.getElementById('expCaptions').checked = true;
document.getElementById('expFiles').checked = true;
document.getElementById('expDates').checked = true;
document.getElementById('exportModal').classList.add('open');
}
function closeExportModal() {
document.getElementById('exportModal').classList.remove('open');
exportPendingName = null;
}
function onExportPhotosToggle() {
const on = document.getElementById('expPhotos').checked;
document.getElementById('expCaptions').disabled = !on;
}
async function exportStudent(name, opts) {
showToast(`Формируем отчёт: ${name}...`);
try {
const q = new URLSearchParams({ name });
if (opts.dateFrom) q.set('date_from', opts.dateFrom);
if (opts.dateTo) q.set('date_to', opts.dateTo);
q.set('include_entries', opts.entries ? '1' : '0');
q.set('include_photos', opts.photos ? '1' : '0');
q.set('include_captions', opts.captions ? '1' : '0');
q.set('include_files', opts.files ? '1' : '0');
q.set('show_dates', opts.dates ? '1' : '0');
const res = await fetch(`${API}/api/export/student?${q}`, { headers: hdr() });
if (!res.ok) {
const e = await res.json();
alert(e.error || 'Не удалось сформировать отчёт');
return;
}
const blob = await res.blob();
downloadBlob(blob, `student_${safeWindowsName(name)}_report.zip`);
showToast(`Готово: ${name}`);
} catch (err) {
alert('Ошибка при формировании отчёта');
}
}
async function submitExport() {
const name = exportPendingName;
if (!name) return;
const captionsEl = document.getElementById('expCaptions');
const opts = {
entries: document.getElementById('expEntries').checked,
photos: document.getElementById('expPhotos').checked,
files: document.getElementById('expFiles').checked,
captions: captionsEl.checked && !captionsEl.disabled,
dates: document.getElementById('expDates').checked,
dateFrom: document.getElementById('exportDateFrom').value,
dateTo: document.getElementById('exportDateTo').value,
};
if (!opts.entries && !opts.photos && !opts.files) {
alert('Выберите хотя бы одну категорию: записи, фото или файлы');
return;
}
if (opts.dateFrom && opts.dateTo && opts.dateFrom > opts.dateTo) {
alert('Дата «С» позже даты «По»');
return;
}
closeExportModal();
await exportStudent(name, opts);
}
async function delStudent(id) {
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
@@ -217,6 +293,14 @@ document.getElementById('attachSearch').addEventListener('input', () => {
aSearchTimer = setTimeout(renderAttachList, 300);
});
document.getElementById('exportForm').addEventListener('submit', e => {
e.preventDefault();
submitExport();
});
document.getElementById('exportCancelBtn').addEventListener('click', closeExportModal);
document.getElementById('expPhotos').addEventListener('change', onExportPhotosToggle);
document.getElementById('exportModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeExportModal(); });
document.getElementById('addStudentBtn').addEventListener('click', addStudent);
document.getElementById('batchAddBtn').addEventListener('click', openBatchAdd);
document.getElementById('batchAttachBtn').addEventListener('click', openBatchAttach);
@@ -232,6 +316,8 @@ document.getElementById('studentList').addEventListener('click', e => {
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
const cp = e.target.closest('[data-copy-student]');
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
const ex = e.target.closest('[data-export-student]');
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
const dl = e.target.closest('[data-del-student]');
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
});
+52
View File
@@ -53,6 +53,58 @@
</div>
</div>
<div class="modal-overlay" id="exportModal">
<div class="edit-modal" style="max-width:480px">
<h3>Экспорт отчёта: <span id="exportName" style="color:var(--accent)"></span></h3>
<form id="exportForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Период (необязательно)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="date" id="exportDateFrom" class="settings-input" style="min-width:0;flex:1">
<input type="date" id="exportDateTo" class="settings-input" style="min-width:0;flex:1">
</div>
<span class="hint">Ограничить записи/фото/файлы датами</span>
</div>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expEntries" checked>
Записи занятий (текст)
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expPhotos" checked>
Фотографии
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expCaptions" checked>
Подписи к фотографиям
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expFiles" checked>
Файлы
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expDates" checked>
Показывать даты
</label>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="exportCancelBtn">Отмена</button>
<button type="submit" class="btn-primary">Скачать ZIP</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="attachModal">
<div class="edit-modal" style="max-width:480px">
<h3>Прикрепить к группе</h3>
+568
View File
@@ -2163,6 +2163,574 @@ app.delete('/api/students/:id', requireAuth, async (req, res) => {
res.json({ ok: true });
});
// --- Student portfolio export (ZIP: HTML report + photos + files) ---
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[n] = c;
}
return table;
})();
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function dosDateTime(d = new Date()) {
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
date: ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
function createZipWriter() {
const parts = [];
const central = [];
let count = 0;
let offset = 0;
function buildEntry(nameBuf, method, crc, compressed, plain, dt) {
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(dt.time, 10);
local.writeUInt16LE(dt.date, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(compressed, 18);
local.writeUInt32LE(plain, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
const cen = Buffer.alloc(46);
cen.writeUInt32LE(0x02014b50, 0);
cen.writeUInt16LE(20, 4);
cen.writeUInt16LE(20, 6);
cen.writeUInt16LE(0x0800, 8);
cen.writeUInt16LE(method, 10);
cen.writeUInt16LE(dt.time, 12);
cen.writeUInt16LE(dt.date, 14);
cen.writeUInt32LE(crc, 16);
cen.writeUInt32LE(compressed, 20);
cen.writeUInt32LE(plain, 24);
cen.writeUInt16LE(nameBuf.length, 28);
cen.writeUInt16LE(0, 30);
cen.writeUInt16LE(0, 32);
cen.writeUInt16LE(0, 34);
cen.writeUInt16LE(0, 36);
cen.writeUInt32LE(0, 38);
cen.writeUInt32LE(offset, 42);
return { local, cen, nameBuf };
}
return {
addFile(name, data, d) {
const nameBuf = Buffer.from(name, 'utf8');
const dt = dosDateTime(d);
const crc = crc32(data);
const compressed = zlib.deflateRawSync(data, { level: 9 });
const e = buildEntry(nameBuf, 8, crc, compressed.length, data.length, dt);
const chunk = Buffer.concat([e.local, e.nameBuf, compressed]);
parts.push(chunk);
central.push(Buffer.concat([e.cen, e.nameBuf]));
offset += chunk.length;
count++;
},
addDir(name) {
const nameBuf = Buffer.from(String(name).replace(/\/?$/, '/'), 'utf8');
const dt = dosDateTime();
const e = buildEntry(nameBuf, 0, 0, 0, 0, dt);
const chunk = Buffer.concat([e.local, e.nameBuf]);
parts.push(chunk);
central.push(Buffer.concat([e.cen, e.nameBuf]));
offset += chunk.length;
count++;
},
toBuffer() {
const centralStart = offset;
const centralBuf = Buffer.concat(central);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(count, 8);
eocd.writeUInt16LE(count, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(centralStart, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...parts, centralBuf, eocd]);
},
};
}
function fmtLongDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
function fmtBytes(n) {
if (!Number.isFinite(n)) return '';
if (n < 1024) return n + ' Б';
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(/\.0$/, '') + ' КБ';
return (n / (1024 * 1024)).toFixed(1).replace(/\.0$/, '') + ' МБ';
}
function truncate(str, max) {
const s = String(str || '');
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function renderStudentReport(data, opts) {
const o = opts || {};
const showEntries = o.includeEntries !== false;
const showPhotos = o.includePhotos !== false;
const showFiles = o.includeFiles !== false;
const showCaptions = o.includeCaptions !== false;
const showDates = o.showDates !== false;
const { name, groups, entries, photos, files, generatedAt, period } = data;
const IMG_EXT = new Set(['JPG','JPEG','PNG','GIF','WEBP','BMP','AVIF','SVG','ICO','JFIF']);
const VID_EXT = new Set(['MP4','WEBM','MOV','M4V','OGV','MKV','MPEG','MPG','3GP','AVI']);
const gallery = [];
const galIdx = new Map();
for (const p of photos) { gallery.push({ type: 'image', src: 'photos/' + p.stored }); galIdx.set('photos/' + p.stored, gallery.length - 1); }
for (const f of files) {
const fn = String(f.original || f.saved || '');
const ext = fn.indexOf('.') >= 0 ? fn.split('.').pop().toUpperCase() : '';
if (VID_EXT.has(ext)) { gallery.push({ type: 'video', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); }
else if (IMG_EXT.has(ext)) { gallery.push({ type: 'image', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); }
}
const avatar = showPhotos && photos.length ? photos[0].stored : null;
const statsChips = [];
if (showEntries) statsChips.push(`<div class="chip"><b>${entries.length}</b><span>занятий</span></div>`);
if (showPhotos) statsChips.push(`<div class="chip"><b>${photos.length}</b><span>фотографий</span></div>`);
if (showFiles) statsChips.push(`<div class="chip"><b>${files.length}</b><span>файлов</span></div>`);
const photoCards = showPhotos ? photos.map(p => {
let caption = '';
if (showCaptions && p.caption) caption = truncate(p.caption, 120);
if (!caption && showDates && !p.caption) caption = fmtLongDate(p.createdAt);
const pg = galIdx.get('photos/' + p.stored);
return `
<figure class="ph" data-g="${pg}">
<img src="photos/${escapeHtml(p.stored)}" alt="${escapeHtml(name)} — фото" loading="lazy">
${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''}
</figure>`;
}).join('') : '';
const fileRows = showFiles ? files.map(f => {
const ext = f.original.indexOf('.') >= 0 ? f.original.split('.').pop().toUpperCase().slice(0, 8) : 'FILE';
const meta = showDates ? `${fmtLongDate(f.createdAt)} · ${fmtBytes(f.size)}` : fmtBytes(f.size);
const fg = galIdx.get('files/' + f.saved);
const gattr = fg !== undefined ? ` class="gfile" data-g="${fg}"` : ' target="_blank" rel="noopener"';
return `
<li>
<a href="files/${escapeHtml(f.saved)}"${gattr}>
<span class="badge">${escapeHtml(ext)}</span>
<span class="fname">${escapeHtml(f.original)}</span>
<span class="fmeta">${escapeHtml(meta)}</span>
</a>
</li>`;
}).join('') : '';
const photosByEntry = new Map();
for (const p of photos) {
if (!photosByEntry.has(p.entryId)) photosByEntry.set(p.entryId, []);
photosByEntry.get(p.entryId).push(p.stored);
}
const lessonRows = showEntries ? entries.map(e => {
const thumbs = showPhotos ? (photosByEntry.get(e.id) || []).slice(0, 4).map(t => `
<img class="tph" src="photos/${escapeHtml(t)}" alt="фото" data-g="${galIdx.get('photos/' + t)}" loading="lazy">`).join('') : '';
const entryFiles = showFiles ? files.filter(f => f.entryId === e.id) : [];
const fls = entryFiles.length ? `<div class="lfiles">${entryFiles.map(f => `<a href="files/${escapeHtml(f.saved)}" target="_blank" rel="noopener">${escapeHtml(f.original)}</a>`).join('')}</div>` : '';
const desc = e.description ? `<p class="ldesc">${escapeHtml(e.description)}</p>` : '';
const gr = e.group_name ? `<span class="lgroup">${escapeHtml(e.group_name)}</span>` : '';
const dt = showDates && e.created_at ? `<span class="ldate">${escapeHtml(fmtLongDate(e.created_at))}</span>` : '';
const head = dt + gr;
return `
<article class="lesson">
${head ? `<div class="lhead">${head}</div>` : ''}
${desc}
${thumbs ? `<div class="lthumbs">${thumbs}</div>` : ''}
${fls}
</article>`;
}).join('') : '';
const groupLine = groups.length ? escapeHtml(groups.join(' · ')) : '';
const genLabel = escapeHtml(fmtLongDate(generatedAt));
const metaBits = [];
if (groupLine) metaBits.push(groupLine);
if (period) metaBits.push(escapeHtml(period));
metaBits.push(`Сформировано ${genLabel}`);
const heroAvatar = avatar ? `<div class="avatar"><img src="photos/${escapeHtml(avatar)}" alt="${escapeHtml(name)}"></div>` : '';
const navItems = [];
if (showPhotos) navItems.push('<a class="navlink" href="#photos">Фотографии</a>');
if (showFiles) navItems.push('<a class="navlink" href="#files">Работы</a>');
if (showEntries) navItems.push('<a class="navlink" href="#lessons">Занятия</a>');
const nav = navItems.length >= 2 ? '<nav class="nav" id="topNav">' + navItems.join('') + '</nav>' : '';
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(name)} — портфолио</title>
<style>
:root{--cream:#FFF7EC;--ink:#2B2B33;--muted:#98919B;--yellow:#F7C90E;--teal:#14B8A6;--violet:#8B5CF6;--coral:#FF6B6B;--sky:#38BDF8;--line:#FFEAD2}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:"Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;background:var(--cream);color:var(--ink);line-height:1.55}
body::before{content:"";position:fixed;inset:0;z-index:-1;background:radial-gradient(520px 400px at 8% -2%,rgba(247,201,14,.18),transparent 62%),radial-gradient(620px 430px at 100% 3%,rgba(20,184,166,.15),transparent 58%)}
.wrap{max-width:960px;margin:0 auto;padding:28px 18px 58px}
.nav{position:sticky;top:12px;z-index:40;display:flex;justify-content:center;gap:8px;flex-wrap:wrap;background:rgba(255,255,255,.92);border:1px solid #F1E2C6;border-radius:999px;padding:8px 14px;box-shadow:0 12px 26px -16px rgba(20,30,50,.35);margin-top:26px}
.nav .navlink{text-decoration:none;color:var(--ink);background:#fff;border:1px solid #F1E2C6;border-radius:999px;padding:8px 18px;font-size:.9rem;font-weight:600;transition:all .15s}
.nav .navlink:hover{transform:translateY(-1px)}
.nav .navlink.on{background:var(--yellow);color:#231f12;border-color:transparent;box-shadow:0 4px 12px -4px rgba(247,201,14,.5)}
.hero{overflow:hidden;border-radius:34px;padding:36px 34px;background:linear-gradient(135deg,#FFF6DB,#FFEDBF 52%,#FFE19A);box-shadow:0 24px 50px -24px rgba(247,201,14,.5);position:relative}
.hero .deco{position:absolute;border-radius:50%;background:rgba(255,255,255,.55);pointer-events:none}
.hero .deco.d1{width:150px;height:150px;right:-40px;top:-46px}
.hero .deco.d2{width:80px;height:80px;left:40%;bottom:-30px;background:rgba(20,184,166,.14)}
.hero .deco.d3{width:44px;height:44px;left:-14px;top:30%}
.avatar-col{position:relative;display:flex;gap:24px;align-items:center}
.avatar{flex:0 0 auto;width:104px;height:104px;border-radius:50%;overflow:hidden;border:5px solid #fff;background:#fff;box-shadow:0 10px 24px -6px rgba(0,0,0,.26);transform:rotate(-4deg)}
.avatar img{width:100%;height:100%;object-fit:cover;display:block;border-radius:50%}
.avatar.aemp{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#F7C90E,#FFB34A);font-size:2.4rem;transform:rotate(-4deg)}
.kicker{font-size:.82rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#B07A00;margin-bottom:6px}
h1{font-size:clamp(1.65rem,4.4vw,2.35rem);font-weight:800;letter-spacing:-.02em;line-height:1.12;margin-bottom:6px}
.meta{font-size:.95rem;color:#7A6A33;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px}
.stats{display:flex;gap:12px;flex-wrap:wrap}
.chip{background:#fff;border:1px solid #F3DFC0;border-radius:18px;padding:10px 16px;display:flex;flex-direction:column;min-width:92px;box-shadow:0 8px 18px -12px rgba(20,30,50,.35)}
.chip b{font-size:1.5rem;line-height:1.05}
.chip span{font-size:.78rem;color:var(--muted)}
.stats .chip:nth-child(1) b{color:var(--coral)}
.stats .chip:nth-child(2) b{color:var(--teal)}
.stats .chip:nth-child(3) b{color:var(--violet)}
section{background:#fff;border:1px solid var(--line);border-radius:30px;padding:26px 26px 28px;margin-top:26px;box-shadow:0 16px 34px -22px rgba(20,30,50,.4)}
.p-sec{border-top:6px solid var(--teal)}
.f-sec{border-top:6px solid var(--violet)}
.l-sec{border-top:6px solid var(--coral)}
h2{font-size:1.25rem;font-weight:800;display:flex;align-items:center;gap:10px;margin-bottom:18px}
h2 .sico{width:38px;height:38px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;font-size:1.25rem;flex:0 0 auto}
.p-sec h2 .sico{background:#E3FAF7}
.f-sec h2 .sico{background:#EFE9FF}
.l-sec h2 .sico{background:#FFE9EE}
.empty{color:var(--muted);border:2px dashed #F0DDBE;border-radius:20px;padding:24px 18px;text-align:center;font-size:.95rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px}
.ph{background:#fff;border-radius:20px;overflow:hidden;cursor:zoom-in;border:3px solid #fff;box-shadow:0 10px 22px -14px rgba(20,30,50,.4);transition:transform .16s ease,box-shadow .16s ease}
.ph:hover{transform:translateY(-4px) rotate(-1deg);box-shadow:0 18px 30px -16px rgba(20,30,50,.45)}
.ph img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}
.ph figcaption{background:rgba(255,247,236,.92);padding:9px 12px;font-size:.82rem;color:#6A6155;line-height:1.35}
.filelist{list-style:none;display:flex;flex-direction:column;gap:9px}
.filelist li a{display:flex;align-items:center;gap:14px;background:#FDFAF4;border:1px solid #F1E2C6;border-radius:18px;padding:12px 16px;text-decoration:none;color:var(--ink);transition:transform .15s,box-shadow .15s}
.filelist li a:hover{transform:translateX(4px);box-shadow:0 10px 20px -12px rgba(20,30,50,.35)}
.badge{flex:0 0 auto;min-width:52px;text-align:center;font-size:.68rem;font-weight:800;letter-spacing:.04em;padding:6px 9px;border-radius:10px;background:linear-gradient(135deg,#F7C90E,#FFB34A);color:#231f12}
.fname{flex:1;min-width:0;font-weight:700;word-break:break-word}
.fmeta{flex:0 0 auto;color:var(--muted);font-size:.8rem;white-space:nowrap}
.gfile{cursor:pointer}
.lessons{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:stretch}
.lesson{background:#FFFCF6;border:1px solid #F3E4CA;border-left:6px solid var(--coral);border-radius:20px;padding:16px 20px}
.lhead{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
.ldate{background:var(--coral);color:#fff;border-radius:999px;padding:3px 12px;font-size:.74rem;font-weight:700}
.lgroup{background:rgba(20,184,166,.14);color:#0C9488;font-weight:700;border-radius:999px;padding:3px 12px;font-size:.74rem}
.ldesc{white-space:pre-wrap;color:#3A3733;font-size:.95rem}
.lthumbs{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.tph{width:80px;height:80px;object-fit:cover;border-radius:14px;cursor:zoom-in;border:2px solid #fff;box-shadow:0 6px 12px -8px rgba(20,30,50,.35)}
.lfiles{margin-top:10px;display:flex;flex-wrap:wrap;gap:8px}
.lfiles a{font-size:.8rem;color:#0C9488;background:rgba(20,184,166,.1);border-radius:999px;padding:5px 12px;text-decoration:none;word-break:break-word}
.lfiles a:hover{background:rgba(20,184,166,.18)}
footer{margin-top:44px;color:#B9B4BE;font-size:.82rem;text-align:center}
.lightbox{position:fixed;inset:0;background:rgba(22,20,40,.9);display:none;flex-direction:column;z-index:60;padding:16px 92px 48px;cursor:zoom-out;overflow:hidden}
.lightbox.open{display:flex}
.lightbox .lb-media{display:flex;align-items:center;justify-content:center;flex:1;min-height:0}
.lightbox .lb-media img,.lightbox .lb-media video{display:block;max-width:100%;max-height:100%;object-fit:contain;border-radius:14px}
.lightbox .lb-media video{background:#000;max-height:calc(100% - 16px)}
.lightbox .lb-btn{position:absolute;top:50%;transform:translateY(-50%);z-index:5;width:52px;height:52px;border-radius:50%;border:none;background:rgba(247,201,14,.94);color:#231f12;font-size:1.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 22px -8px rgba(0,0,0,.5)}
.lightbox .lb-btn:hover{background:#F7C90E}
.lightbox .lb-prev{left:20px}
.lightbox .lb-next{right:20px}
.lightbox .lb-count{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);background:rgba(22,20,40,.72);color:#fff;border-radius:999px;padding:6px 16px;font-size:.85rem;letter-spacing:.03em}
.lightbox .lb-close{position:absolute;top:16px;right:20px;z-index:6;width:44px;height:44px;border-radius:50%;border:none;background:rgba(22,20,40,.6);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 18px -8px rgba(0,0,0,.5)}
.lightbox .lb-close:hover{background:rgba(247,201,14,.92);color:#231f12}
@media print{body::before{display:none}.nav{display:none}.wrap{max-width:none;padding:0;-webkit-print-color-adjust:exact}body{background:#fff}.hero,section{box-shadow:none}section,.lesson,.ph{break-inside:avoid}}
@media (max-width:560px){.avatar-col{flex-direction:column;align-items:flex-start;gap:16px}.hero{padding:26px 22px}.grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}.stats{gap:8px}.fmeta{display:none}.chip{min-width:74px;padding:8px 12px}.lessons{grid-template-columns:1fr}.lightbox{padding:8px 6px 44px}.lb-prev{left:6px}.lb-next{right:6px}.lb-btn{width:44px;height:44px;font-size:1.4rem}.lb-close{width:38px;height:38px;top:10px;right:10px;font-size:1.15rem}}
</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<span class="deco d1"></span>
<span class="deco d2"></span>
<span class="deco d3"></span>
<div class="avatar-col">
${heroAvatar || '<div class="avatar aemp"><span>🎨</span></div>'}
<div>
<p class="kicker">✦ Портфолио ученика</p>
<h1>${escapeHtml(name)}</h1>
<p class="meta">${metaBits.join(' · ')}</p>
<div class="stats">
${statsChips.join('')}
</div>
</div>
</div>
</header>
${nav}
${showPhotos ? `<section id="photos" class="p-sec">
<h2><span class="sico">🏞️</span>Фотографии</h2>
${photoCards ? `<div class="grid">${photoCards}</div>` : '<p class="empty">🎈 Фотографий пока нет</p>'}
</section>` : ''}
${showFiles ? `<section id="files" class="f-sec">
<h2><span class="sico">📁</span>Работы и файлы</h2>
${fileRows ? `<ul class="filelist">${fileRows}</ul>` : '<p class="empty">🎈 Файлов пока нет</p>'}
</section>` : ''}
${showEntries ? `<section id="lessons" class="l-sec">
<h2><span class="sico">🎒</span>Журнал занятий</h2>
${lessonRows ? `<div class="lessons">${lessonRows}</div>` : '<p class="empty">🎈 Записей о занятиях пока нет</p>'}
</section>` : ''}
<footer>Сформировано ${genLabel} · WhatIDo</footer>
</div>
<div class="lightbox" id="lightbox">
<button type="button" class="lb-btn lb-prev" id="lbPrev" aria-label="Назад">&#10094;</button>
<div class="lb-media" id="lbMedia"></div>
<button type="button" class="lb-btn lb-next" id="lbNext" aria-label="Вперёд">&#10095;</button>
<button type="button" class="lb-close" id="lbClose" aria-label="Закрыть">&#10005;</button>
<div class="lb-count" id="lbCount"></div>
</div>
<script>
(function () {
var GAL = ${JSON.stringify(gallery)};
var box = document.getElementById('lightbox');
var media = document.getElementById('lbMedia');
var count = document.getElementById('lbCount');
var current = 0;
function isVid(m) { return m && m.type === 'video'; }
function render(i) {
if (!GAL.length) return;
var m = GAL[i];
current = i;
count.textContent = (i + 1) + ' / ' + GAL.length;
media.innerHTML = '';
if (isVid(m)) {
var v = document.createElement('video');
v.src = m.src;
v.controls = true;
v.autoplay = true;
media.appendChild(v);
} else {
var im = document.createElement('img');
im.src = m.src;
im.alt = '';
media.appendChild(im);
}
}
function open(i) { if (!GAL.length) return; render(i); box.classList.add('open'); }
function close() { box.classList.remove('open'); media.innerHTML = ''; }
function step(d) { if (!GAL.length) return; render((current + d + GAL.length) % GAL.length); }
document.addEventListener('click', function (e) {
var t = e.target.closest('[data-g]');
if (t) { e.preventDefault(); var gi = parseInt(t.getAttribute('data-g'), 10); if (!isNaN(gi) && gi >= 0 && GAL[gi]) open(gi); return; }
if (e.target === box) close();
});
document.getElementById('lbPrev').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); step(-1); });
document.getElementById('lbNext').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); step(1); });
document.getElementById('lbClose').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); close(); });
document.addEventListener('keydown', function (e) {
if (!box.classList.contains('open')) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowRight') step(1);
else if (e.key === 'ArrowLeft') step(-1);
});
var links = Array.prototype.slice.call(document.querySelectorAll('.navlink'));
function spy() {
if (!links.length) return;
var hit = null;
for (var i = 0; i < links.length; i++) {
var el = document.getElementById(links[i].getAttribute('href').slice(1));
if (el && el.getBoundingClientRect().top <= 140) hit = links[i];
}
for (var j = 0; j < links.length; j++) {
if (links[j] === hit) links[j].classList.add('on');
else links[j].classList.remove('on');
}
}
if (links.length) window.addEventListener('scroll', spy, { passive: true });
spy();
})();
</script>
</body>
</html>`;
}
app.get('/api/export/student', requireAuth, async (req, res) => {
let name;
try {
name = reqStr(req.query.name, 150);
} catch {
return res.status(400).json({ error: 'Укажите имя ученика' });
}
const opts = {
includeEntries: req.query.include_entries !== '0',
includePhotos: req.query.include_photos !== '0',
includeFiles: req.query.include_files !== '0',
includeCaptions: req.query.include_captions !== '0',
showDates: req.query.show_dates !== '0',
};
if (!opts.includeEntries && !opts.includePhotos && !opts.includeFiles) {
return res.status(400).json({ error: 'Выберите, что включать в отчёт' });
}
let dateFrom = null;
let dateTo = null;
try {
if (req.query.date_from) dateFrom = optDate(req.query.date_from);
if (req.query.date_to) dateTo = optDate(req.query.date_to);
} catch {
return res.status(400).json({ error: 'Неверный период' });
}
if (dateFrom && dateTo && dateFrom > dateTo) {
return res.status(400).json({ error: 'Дата «С» позже даты «По»' });
}
const hasPeriod = !!(dateFrom || dateTo);
try {
const conds = ['e.student_name = $1', 'e.deleted_at IS NULL'];
const params = [name];
const bw = branchWhere(req.user, 'g');
if (dateFrom) {
params.push(dateFrom);
conds.push(`e.created_at >= $${params.length}::date`);
}
if (dateTo) {
params.push(dateTo);
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
}
if (bw.params.length) {
const start = params.length + 1;
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
params.push(...bw.params);
}
const condStr = conds.join(' AND ');
const whereStr = ' WHERE ' + condStr;
const [studRes, entriesRes, photosRes, mainsRes, filesRes] = await Promise.all([
pool.query(`SELECT s.name, g.name AS group_name FROM students s LEFT JOIN groups g ON g.id = s.group_id WHERE s.name = $1`, [name]),
pool.query(`SELECT e.id, e.description, e.created_at, g.name AS group_name
FROM entries e JOIN groups g ON g.id = e.group_id${whereStr}
ORDER BY e.created_at DESC`, params),
pool.query(`SELECT ep.photo_path, ep.caption, ep.entry_id, e.description, e.created_at
FROM entry_photos ep
JOIN entries e ON e.id = ep.entry_id
JOIN groups g ON g.id = e.group_id${whereStr}
ORDER BY e.created_at DESC, ep.sort_order ASC, ep.id ASC`, params),
pool.query(`SELECT e.photo_path, e.description, e.created_at, e.id AS entry_id
FROM entries e JOIN groups g ON g.id = e.group_id
WHERE e.photo_path IS NOT NULL AND ${condStr}
ORDER BY e.created_at DESC`, params),
pool.query(`SELECT pf.path, pf.name, pf.entry_id, e.created_at
FROM project_files pf
JOIN entries e ON e.id = pf.entry_id
JOIN groups g ON g.id = e.group_id
WHERE ${condStr} AND pf.detached_at IS NULL
ORDER BY e.created_at DESC, pf.id DESC`, params),
]);
const entryRows = entriesRes.rows;
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
}
const zip = createZipWriter();
if (opts.includePhotos) zip.addDir('photos');
if (opts.includeFiles) zip.addDir('files');
const seenPhotos = new Set();
const photosBuilt = [];
function addPhoto(p) {
if (!isSafeUploadPath(p.photo_path)) return;
const stored = p.photo_path.slice('/uploads/'.length);
if (seenPhotos.has(stored)) return;
seenPhotos.add(stored);
const src = path.join(UPLOADS_DIR, stored);
if (!fs.existsSync(src)) return;
const data = fs.readFileSync(src);
zip.addFile('photos/' + stored, data, new Date(p.created_at));
photosBuilt.push({ stored, caption: p.caption, createdAt: p.created_at, desc: p.description, entryId: p.entry_id });
}
if (opts.includePhotos) {
for (const p of photosRes.rows) addPhoto(p);
for (const m of mainsRes.rows) addPhoto(m);
photosBuilt.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
const seenFiles = new Map();
const filesBuilt = [];
if (opts.includeFiles) {
for (const f of filesRes.rows) {
if (!isSafeUploadPath(f.path)) continue;
const stored = f.path.slice('/uploads/'.length);
const src = path.join(UPLOADS_DIR, stored);
if (!fs.existsSync(src)) continue;
const data = fs.readFileSync(src);
let base = String(f.name || 'file').replace(/[\\/:*?"<>|]/g, '_').replace(/^[.\s]+/, '').slice(0, 120) || 'file';
const ext = path.extname(base);
const stem = ext ? base.slice(0, -ext.length) : base;
let saved = base;
let n = 1;
while (seenFiles.has(saved)) {
n++;
saved = `${stem}(${n})${ext}`;
}
seenFiles.set(saved, true);
zip.addFile('files/' + saved, data, new Date(f.created_at));
filesBuilt.push({ saved, original: f.name, size: data.length, createdAt: f.created_at, entryId: f.entry_id });
}
}
const groupSet = new Set();
if (studRes.rows[0]?.group_name) groupSet.add(studRes.rows[0].group_name);
for (const e of entryRows) if (e.group_name) groupSet.add(e.group_name);
const groups = [...groupSet];
let period = null;
if (hasPeriod) {
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
}
const html = renderStudentReport({
name,
groups,
entries: entryRows,
photos: photosBuilt,
files: filesBuilt,
generatedAt: new Date(),
period,
}, opts);
zip.addFile('index.html', Buffer.from(html, 'utf8'));
const buf = zip.toBuffer();
await logAudit(req, 'export.student', {
student: name,
entries: entryRows.length,
photos: photosBuilt.length,
files: filesBuilt.length,
opts,
date_from: dateFrom,
date_to: dateTo,
});
const safeName = name.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'student';
const zipDate = new Date().toISOString().slice(0, 10);
const zipFname = `student_${safeName}_${zipDate}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFname}"; filename*=UTF-8''${encodeURIComponent(`student_${name}_${zipDate}.zip`)}`);
res.send(buf);
} catch (err) {
console.error('export student:', err);
res.status(500).json({ error: err.message });
}
});
// --- Entries ---
app.get('/api/entries', requireAuth, async (req, res) => {
const { group_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;