From 1ef81f9d1ce337d3a22949ca4e5ca956f404d08a Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 19 Sep 2026 13:06:21 +0300 Subject: [PATCH] wip: student profile/report --- db/init.sql | 9 +- db/migration.sql | 3 + public/js/modules.js | 103 ++- public/js/students.js | 129 ++++ public/modules.html | 20 + public/students.html | 80 +++ server.js | 768 +++++++++-------------- student-profile.html | 1394 +++++++++++++++++++++++++++++++++++++++++ student-report.js | 1165 ++++++++++++++++++++++++++++++++++ todo.md | 78 +++ 10 files changed, 3269 insertions(+), 480 deletions(-) create mode 100644 student-profile.html create mode 100644 student-report.js create mode 100644 todo.md diff --git a/db/init.sql b/db/init.sql index 88faa7b..8e314ad 100644 --- a/db/init.sql +++ b/db/init.sql @@ -29,18 +29,25 @@ CREATE TABLE IF NOT EXISTS modules ( name VARCHAR(200) NOT NULL UNIQUE, lessons_count INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT true, + photo_path VARCHAR(255), created_at TIMESTAMPTZ DEFAULT now() ); ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255); CREATE TABLE IF NOT EXISTS students ( id SERIAL PRIMARY KEY, name VARCHAR(150) NOT NULL UNIQUE, group_id INT REFERENCES groups(id), - created_at TIMESTAMPTZ DEFAULT now() + created_at TIMESTAMPTZ DEFAULT now(), + photo_path VARCHAR(255), + profile JSONB ); +ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255); +ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB; + CREATE TABLE IF NOT EXISTS entries ( id SERIAL PRIMARY KEY, student_name VARCHAR(150) NOT NULL, diff --git a/db/migration.sql b/db/migration.sql index 65f40ad..289f707 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS banned_ips ( ); ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id); +ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255); +ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB; CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, @@ -233,5 +235,6 @@ CREATE TABLE IF NOT EXISTS modules ( ); ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255); ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id); diff --git a/public/js/modules.js b/public/js/modules.js index c857277..98aedc0 100644 --- a/public/js/modules.js +++ b/public/js/modules.js @@ -2,6 +2,7 @@ let modulesList = []; let mPage = 1; const M_PAGE_SIZE = 25; let mSearchTimer = null; +let modulePhotoDeleteFlag = false; function mPlural(n, one, few, many) { n = Math.abs(n) % 100; const n1 = n % 10; @@ -33,6 +34,7 @@ function renderModules(total) { } else { list.innerHTML = modulesList.map(m => `
+ ${m.photo_path ? `` : ''}
${esc(m.name)}${m.is_active ? '' : ' Отключён'}
${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}
@@ -50,6 +52,26 @@ function renderModules(total) { renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); }); } +function resetModulePhoto(m) { + modulePhotoDeleteFlag = false; + const fileInput = document.getElementById('modulePhotoFile'); + const preview = document.getElementById('modulePhotoPreview'); + const delBtn = document.getElementById('modulePhotoDeleteBtn'); + const note = document.getElementById('modulePhotoNote'); + fileInput.value = ''; + if (m && m.photo_path) { + preview.src = thumbSrc(m.photo_path); + preview.style.display = 'block'; + delBtn.style.display = 'inline-block'; + note.textContent = 'Загрузка новой картинки заменит текущую'; + } else { + preview.removeAttribute('src'); + preview.style.display = 'none'; + delBtn.style.display = 'none'; + note.textContent = 'jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)'; + } +} + function openModuleModal(id = null) { const modal = document.getElementById('moduleModal'); const form = document.getElementById('moduleForm'); @@ -66,14 +88,62 @@ function openModuleModal(id = null) { form.name.value = m.name; document.getElementById('moduleLessons').value = String(m.lessons_count); document.getElementById('moduleId').value = m.id; + resetModulePhoto(m); } else { title.textContent = 'Создать модуль'; submitBtn.textContent = 'Создать'; + resetModulePhoto(null); } modal.classList.add('open'); setTimeout(() => form.name.focus(), 100); } +function previewModulePhotoFile() { + const file = document.getElementById('modulePhotoFile').files[0]; + const preview = document.getElementById('modulePhotoPreview'); + const delBtn = document.getElementById('modulePhotoDeleteBtn'); + const note = document.getElementById('modulePhotoNote'); + if (!file) return; + modulePhotoDeleteFlag = false; + preview.src = URL.createObjectURL(file); + preview.style.display = 'block'; + delBtn.style.display = 'none'; + note.textContent = 'Новая картинка будет сохранена после нажатия «Сохранить»'; +} + +function removeModulePhoto() { + const preview = document.getElementById('modulePhotoPreview'); + const delBtn = document.getElementById('modulePhotoDeleteBtn'); + const note = document.getElementById('modulePhotoNote'); + if (!confirm('Удалить картинку модуля?')) return; + modulePhotoDeleteFlag = true; + document.getElementById('modulePhotoFile').value = ''; + preview.removeAttribute('src'); + preview.style.display = 'none'; + delBtn.style.display = 'none'; + note.textContent = 'Картинка будет удалена после нажатия «Сохранить»'; +} + +function pasteModulePhotoFile(e) { + const modal = document.getElementById('moduleModal'); + if (!modal.classList.contains('open')) return; + const items = e.clipboardData && e.clipboardData.items; + if (!items) return; + for (const item of items) { + if (item.type && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (!file) continue; + e.preventDefault(); + const dt = new DataTransfer(); + dt.items.add(file); + const input = document.getElementById('modulePhotoFile'); + input.files = dt.files; + previewModulePhotoFile(); + return; + } + } +} + function closeModuleModal() { document.getElementById('moduleModal').classList.remove('open'); } @@ -118,14 +188,30 @@ document.getElementById('moduleForm').addEventListener('submit', async (e) => { headers: hdrJson(), body: JSON.stringify({ name, lessons_count }) }); - if (res.ok) { - showToast(id ? 'Модуль обновлён' : 'Модуль создан'); - closeModuleModal(); - loadModules(); - } else { - const err = await res.json().catch(() => ({})); - alert(err.error || 'Ошибка'); + if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; } + const moduleRow = await res.json(); + const mid = moduleRow.id; + const fileInput = document.getElementById('modulePhotoFile'); + if (modulePhotoDeleteFlag) { + const delRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'DELETE', headers: hdr() }); + if (!delRes.ok) { + const err = await delRes.json().catch(() => ({})); + alert(err.error || 'Ошибка удаления картинки'); + } } + const file = fileInput.files[0]; + if (file) { + const fd = new FormData(); + fd.append('photo', file); + const upRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'POST', headers: hdr(), body: fd }); + if (!upRes.ok) { + const err = await upRes.json().catch(() => ({})); + alert(err.error || 'Ошибка загрузки картинки'); + } + } + showToast(id ? 'Модуль обновлён' : 'Модуль создан'); + closeModuleModal(); + loadModules(); }); function parseBatchModules(raw) { @@ -208,6 +294,9 @@ document.getElementById('batchModuleModal').addEventListener('click', e => { if document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal()); document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal); document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); }); +document.getElementById('modulePhotoFile').addEventListener('change', previewModulePhotoFile); +document.getElementById('modulePhotoDeleteBtn').addEventListener('click', removeModulePhoto); +document.addEventListener('paste', pasteModulePhotoFile); document.getElementById('moduleSearch').addEventListener('input', () => { clearTimeout(mSearchTimer); mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300); diff --git a/public/js/students.js b/public/js/students.js index a647429..20423e5 100644 --- a/public/js/students.js +++ b/public/js/students.js @@ -47,6 +47,8 @@ function renderStudents() {
+ + @@ -156,6 +158,125 @@ async function submitExport() { await exportStudent(name, opts); } +async function exportStudentProfile(id) { + const s = allStudents.find(x => x.id === id); + if (!s) return; + const btn = document.querySelector(`[data-profile-student="${id}"]`); + const label = btn ? btn.textContent : ''; + if (btn) { btn.disabled = true; btn.textContent = 'Готовим...'; } + showToast(`Формируем профиль: ${s.name}...`); + try { + const res = await fetch(`${API}/api/export/student?name=${encodeURIComponent(s.name)}`, { headers: hdr() }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + showToast(e.error || 'Не удалось сформировать профиль'); + return; + } + const blob = await res.blob(); + downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`); + showToast(`Готово: ${s.name}`); + } catch (err) { + showToast('Ошибка при формировании профиля'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = label; } + } +} + +let profileDataId = null; + +function openProfileData(id) { + profileDataId = id; + loadGroupOptions(); + fetch(`${API}/api/students/${id}/profile`, { headers: hdr() }) + .then(r => (r.ok ? r.json() : Promise.reject(new Error('load')))) + .then(data => fillProfileForm(data.profile, data.name, data.photo_path)) + .catch(() => fillProfileForm(null, '', '')); + document.getElementById('profileDataModal').classList.add('open'); +} + +function closeProfileData() { + document.getElementById('profileDataModal').classList.remove('open'); + profileDataId = null; +} + +function fillProfileForm(profile, name, photoPath) { + const p = profile || {}; + const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); }; + document.getElementById('profileDataTitle').textContent = name || 'Профиль ученика'; + set('pfRole', p.role); set('pfStatus', p.status); set('pfStatusNote', p.status_note); + set('pfCity', p.city); set('pfMentor', p.mentor); set('pfJoined', p.joined); + set('pfBio', p.bio); set('pfQuote', p.quote); + set('pfTags', (p.tags || []).join(', ')); + set('pfContacts', (p.contacts || []).map(c => [c.icon || 'link', c.label, c.href || ''].join(' | ')).join('\n')); + set('pfAchievements', (p.achievements || []).join('\n')); + set('pfSkills', (p.skills || []).map(s => [s.group || 'Навыки', s.name, s.level || '', s.value === null || s.value === undefined ? '' : s.value].join(' | ')).join('\n')); + set('pfExperience', (p.experience || []).map(e => [e.title, e.company || '', e.period || '', e.date || '', e.badge || '', e.description || '', (e.tags || []).join(',')].join(' | ')).join('\n')); + set('pfEducation', (p.education || []).map(m => [m.module, m.progress === null || m.progress === undefined ? '' : m.progress, m.grade || '', m.teacher || ''].join(' | ')).join('\n')); + set('pfPhoto', photoPath || ''); +} + +function collectProfile() { + const val = id => document.getElementById(id).value.trim(); + const lines = id => val(id).split('\n').map(s => s.trim()).filter(Boolean); + const cols = line => line.split('|').map(s => s.trim()); + const intOrNull = s => (s === '' || s === null || s === undefined ? null : Number(s)); + const out = { + role: val('pfRole') || null, + status: val('pfStatus') || null, + status_note: val('pfStatusNote') || null, + city: val('pfCity') || null, + mentor: val('pfMentor') || null, + joined: val('pfJoined') || null, + bio: val('pfBio') || null, + quote: val('pfQuote') || null, + tags: val('pfTags') ? val('pfTags').split(',').map(s => s.trim()).filter(Boolean) : [], + contacts: lines('pfContacts').map(l => { + const c = cols(l); + return { icon: c[0] || 'link', label: c[1] || '', href: c[2] || '' }; + }).filter(c => c.label), + achievements: lines('pfAchievements'), + skills: lines('pfSkills').map(l => { + const c = cols(l); + return { group: c[0] || 'Навыки', name: c[1] || '', level: c[2] || null, value: intOrNull(c[3]) }; + }).filter(s => s.name), + experience: lines('pfExperience').map(l => { + const c = cols(l); + return { + title: c[0] || '', company: c[1] || null, period: c[2] || null, date: c[3] || null, + badge: c[4] || null, description: c[5] || null, + tags: c[6] ? c[6].split(',').map(s => s.trim()).filter(Boolean) : [], + }; + }).filter(e => e.title), + education: lines('pfEducation').map(l => { + const c = cols(l); + return { module: c[0] || '', progress: intOrNull(c[1]), grade: c[2] || null, teacher: c[3] || null }; + }).filter(m => m.module), + }; + const hasData = Object.keys(out).some(k => (Array.isArray(out[k]) ? out[k].length : out[k])); + return hasData ? out : null; +} + +async function saveProfileData(e) { + e.preventDefault(); + if (!profileDataId) return; + const body = { profile: collectProfile() }; + const photoPath = document.getElementById('pfPhoto').value.trim(); + body.photo_path = photoPath || null; + const res = await fetch(`${API}/api/students/${profileDataId}/profile`, { + method: 'PUT', + headers: hdrJson(), + body: JSON.stringify(body), + }); + if (res.ok) { + closeProfileData(); + loadStudents(); + showToast('Данные профиля сохранены'); + } else { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Не удалось сохранить данные профиля'); + } +} + async function delStudent(id) { if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return; await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() }); @@ -318,10 +439,18 @@ document.getElementById('studentList').addEventListener('click', e => { if (cp) { copyStudentLink(cp.dataset.copyStudent); return; } const ex = e.target.closest('[data-export-student]'); if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; } + const pf = e.target.closest('[data-profile-student]'); + if (pf) { exportStudentProfile(parseInt(pf.dataset.profileStudent, 10)); return; } + const pd = e.target.closest('[data-profile-data]'); + if (pd) { openProfileData(parseInt(pd.dataset.profileData, 10)); return; } const dl = e.target.closest('[data-del-student]'); if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); } }); +document.getElementById('profileDataForm').addEventListener('submit', saveProfileData); +document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData); +document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); }); + (async () => { if (await checkAuth()) { buildSidebar(document.body.dataset.page); diff --git a/public/modules.html b/public/modules.html index 494ac14..6118952 100644 --- a/public/modules.html +++ b/public/modules.html @@ -43,6 +43,18 @@
+
+ +
+ +
+ + + +
+ jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V) +
+
@@ -78,6 +90,7 @@ .module-item{display:flex;justify-content:space-between;align-items:center;gap:12px} .module-info{min-width:0;display:flex;flex-direction:column;gap:4px} .module-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.module-thumb{width:48px;height:48px;border-radius:8px;object-fit:cover;flex-shrink:0} .module-meta{font-size:.78rem;color:var(--muted)} .module-item.module-off .module-name{color:var(--muted)} .module-badge{display:inline-block;margin-left:8px;padding:1px 8px;border-radius:999px;font-size:.7rem;font-weight:600;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle} @@ -85,6 +98,13 @@ .module-item .btn-link.danger:hover{border-color:#ef4444} #batchModuleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block} #batchModuleModal .hint b{color:var(--text)} +.module-photo-controls{display:flex;flex-direction:column;gap:10px;align-items:flex-start;width:100%} +.module-photo-preview{width:100%;max-height:180px;object-fit:contain;border:1px solid var(--border);border-radius:10px;background:var(--bg)} +.module-photo-actions{display:flex;align-items:center;gap:8px} +.module-photo-pick{text-align:center;cursor:pointer;margin:0} +.module-photo-actions .btn-link.danger{color:#ef4444;border-color:rgba(239,68,68,.35)} +.module-photo-actions .btn-link.danger:hover{border-color:#ef4444} +#moduleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block} @media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}} diff --git a/public/students.html b/public/students.html index 85e077f..6a3a658 100644 --- a/public/students.html +++ b/public/students.html @@ -130,6 +130,86 @@
+ +
diff --git a/server.js b/server.js index 10a140e..fead479 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ const helmet = require('helmet'); const bcrypt = require('bcrypt'); const heicConvert = require('heic-convert'); const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker'); +const { createZipWriter, renderStudentReport } = require('./student-report'); const https = require('https'); const path = require('path'); @@ -615,15 +616,16 @@ async function removeEntryFiles(entryId) { async function sweepOrphanedUploads() { const dir = path.join(__dirname, 'uploads'); if (!fs.existsSync(dir)) return; - const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }] = await Promise.all([ + const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }] = await Promise.all([ pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'), pool.query('SELECT path AS p FROM project_files'), pool.query('SELECT photo_path AS p FROM group_photos'), pool.query('SELECT photo_path AS p FROM entry_photos'), pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`), + pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'), ]); const refs = new Set(); - [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, ''))); + [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, ''))); for (const f of fs.readdirSync(dir)) { const fp = path.join(dir, f); if (!fs.statSync(fp).isFile()) continue; @@ -674,6 +676,7 @@ async function ensureModulesTable() { created_at TIMESTAMPTZ DEFAULT now() )`); await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true'); + await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255)'); await pool.query('ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL'); await pool.query('CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id)'); } @@ -1285,6 +1288,107 @@ function reqAiStatus(v, fallback) { return AI_STATUSES.has(s) ? s : fallback; } +const PROFILE_HREF_RE = /^(https?:\/\/|mailto:|tel:|\/|#)/i; +const PROFILE_EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/; + +function profText(v, max) { + if (v === null || v === undefined) return null; + if (typeof v !== 'string') throw new Error('Ожидалась строка'); + const s = v.trim(); + if (!s) return null; + if (s.length > max) throw new Error('Слишком длинное значение'); + return s; +} + +function profIcon(v) { + const s = String(v || '').trim().toLowerCase(); + return /^[a-z0-9-]{1,32}$/.test(s) ? s : 'link'; +} + +function profHref(v) { + const s = String(v || '').trim(); + if (!s || s.length > 500) return null; + return (PROFILE_HREF_RE.test(s) || PROFILE_EMAIL_RE.test(s)) ? s : null; +} + +function profList(v, max, fn) { + if (v === null || v === undefined) return []; + if (!Array.isArray(v)) throw new Error('Ожидался список'); + const out = []; + for (const item of v.slice(0, max)) { + const row = fn(item); + if (row) out.push(row); + } + return out; +} + +function sanitizeStudentProfile(raw) { + if (raw === null || raw === undefined) return null; + if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Ожидался объект профиля'); + const out = { + role: profText(raw.role, 200), + status: profText(raw.status, 60), + status_note: profText(raw.status_note, 120), + city: profText(raw.city, 120), + mentor: profText(raw.mentor, 150), + joined: profText(raw.joined, 120), + bio: profText(raw.bio, 2000), + quote: profText(raw.quote, 300), + tags: profList(raw.tags, 20, t => profText(t, 40)), + achievements: profList(raw.achievements, 40, a => profText(a, 200)), + contacts: profList(raw.contacts, 20, c => { + if (!c || typeof c !== 'object') return null; + const label = profText(c.label, 120); + if (!label) return null; + return { icon: profIcon(c.icon), label, href: profHref(c.href) }; + }), + skills: profList(raw.skills, 80, s => { + if (!s || typeof s !== 'object') return null; + const name = profText(s.name, 120); + if (!name) return null; + const value = (s.value === null || s.value === undefined || s.value === '') ? null : optInt(s.value, 0, 100); + return { group: profText(s.group, 80) || 'Навыки', name, level: profText(s.level, 40), value }; + }), + experience: profList(raw.experience, 30, e => { + if (!e || typeof e !== 'object') return null; + const title = profText(e.title, 160); + if (!title) return null; + return { + title, + company: profText(e.company, 160), + period: profText(e.period, 80), + date: profText(e.date, 40), + badge: profText(e.badge, 40), + description: profText(e.description, 800), + tags: profList(e.tags, 10, t => profText(t, 40)), + }; + }), + education: profList(raw.education, 60, m => { + if (!m || typeof m !== 'object') return null; + const module = profText(m.module, 200); + if (!module) return null; + const progress = (m.progress === null || m.progress === undefined || m.progress === '') ? null : optInt(m.progress, 0, 100); + return { module, progress, grade: profText(m.grade, 80), teacher: profText(m.teacher, 150) }; + }), + stats: profList(raw.stats, 12, s => { + if (!s || typeof s !== 'object') return null; + const label = profText(s.label, 80); + const value = (s.value === null || s.value === undefined) ? null : String(s.value).trim().slice(0, 20); + if (!label || !value) return null; + return { + icon: profIcon(s.icon || 'star'), + value, + suffix: profText(s.suffix, 20), + label, + hint: profText(s.hint, 120), + delta: profText(s.delta, 60), + }; + }), + }; + const hasData = Object.values(out).some(v => (Array.isArray(v) ? v.length > 0 : v !== null)); + return hasData ? out : null; +} + function normalizeRestoreData(data) { const groups = (data.groups || []).map(x => ({ id: reqInt(x.id), @@ -1300,6 +1404,8 @@ function normalizeRestoreData(data) { name: reqStr(x.name, 150), created_at: optTs(x.created_at), group_id: optInt(x.group_id, 0, 2147483647), + photo_path: optUploadPath(x.photo_path, 255), + profile: sanitizeStudentProfile(x.profile), })); const entries = (data.entries || []).map(x => ({ id: reqInt(x.id), @@ -1349,6 +1455,7 @@ function normalizeRestoreData(data) { name: reqStr(x.name, 200), lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0, is_active: x.is_active !== false, + photo_path: optUploadPath(x.photo_path, 255), created_at: optTs(x.created_at), })); const entry_photos = (data.entry_photos || []).map(x => ({ @@ -1526,14 +1633,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } for (const x of ndata.students) { await client.query( - 'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)', - [x.id, x.name, x.created_at, x.group_id] + 'INSERT INTO students (id, name, created_at, group_id, photo_path, profile) VALUES ($1,$2,$3,$4,$5,$6)', + [x.id, x.name, x.created_at, x.group_id, x.photo_path, x.profile ? JSON.stringify(x.profile) : null] ); } for (const x of ndata.modules) { await client.query( - 'INSERT INTO modules (id, name, lessons_count, is_active, created_at) VALUES ($1,$2,$3,$4,$5)', - [x.id, x.name, x.lessons_count, x.is_active, x.created_at] + 'INSERT INTO modules (id, name, lessons_count, is_active, photo_path, created_at) VALUES ($1,$2,$3,$4,$5,$6)', + [x.id, x.name, x.lessons_count, x.is_active, x.photo_path, x.created_at] ); } for (const x of ndata.entries) { @@ -2375,6 +2482,48 @@ app.put('/api/modules/:id/restore', requireAdmin, async (req, res) => { res.json(rows[0]); }); +const modulePhotoUpload = upload.single('photo'); +app.post('/api/modules/:id/photo', requireAdmin, (req, res) => { + modulePhotoUpload(req, res, async (err) => { + if (err) { + if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' }); + if (err.message === 'Only images') return res.status(400).json({ error: 'Картинка: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' }); + if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' }); + return res.status(400).json({ error: 'Недопустимый файл' }); + } + if (!req.file) return res.status(400).json({ error: 'Файл обязателен' }); + try { + const mod = await pool.query('SELECT photo_path FROM modules WHERE id = $1', [req.params.id]); + if (!mod.rows.length) { + removeUpload(req.file); + return res.status(404).json({ error: 'Не найдено' }); + } + await convertPhoto(req.file); + const oldPath = mod.rows[0].photo_path; + const { rows } = await pool.query( + 'UPDATE modules SET photo_path = $1 WHERE id = $2 RETURNING *', + [`/uploads/${req.file.filename}`, req.params.id] + ); + if (oldPath) safeUnlink(oldPath); + await logAudit(req, 'module.photo.create', { id: req.params.id, photo_path: rows[0].photo_path }); + res.json(rows[0]); + } catch (e) { + removeUpload(req.file); + console.error('POST /api/modules/:id/photo:', e); + res.status(500).json({ error: e.message }); + } + }); +}); + +app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => { + const { rows } = await pool.query('SELECT id, photo_path FROM modules WHERE id = $1', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); + if (rows[0].photo_path) safeUnlink(rows[0].photo_path); + await pool.query('UPDATE modules SET photo_path = NULL WHERE id = $1', [req.params.id]); + await logAudit(req, 'module.photo.delete', { id: req.params.id, photo_path: rows[0].photo_path }); + res.json({ ok: true }); +}); + // --- Students CRUD --- app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => { const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => { @@ -2499,463 +2648,74 @@ 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; +async function studentProfileAccess(user, id) { + const { rows } = await pool.query( + 'SELECT id, name, group_id, photo_path, profile FROM students WHERE id = $1', + [id] + ); + if (!rows.length) return { found: false }; + const gid = rows[0].group_id; + if (user.role !== 'admin' && gid && !(await groupBelongsToBranches(user, gid))) { + return { found: false, forbidden: true }; } - 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; + return { found: true, student: rows[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 }; +app.get('/api/students/:id/profile', requireAuth, async (req, res) => { + let id; + try { + id = reqInt(req.params.id); + } catch { + return res.status(400).json({ error: 'Неверный id ученика' }); } - 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]); - }, - }; -} + const acc = await studentProfileAccess(req.user, id); + if (!acc.found) { + return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' }); + } + res.json({ ...acc.student, profile: acc.student.profile || null }); +}); + +app.put('/api/students/:id/profile', requireAuth, async (req, res) => { + let id; + try { + id = reqInt(req.params.id); + } catch { + return res.status(400).json({ error: 'Неверный id ученика' }); + } + let profile; + let photoPath; + try { + profile = sanitizeStudentProfile(req.body?.profile); + photoPath = req.body?.photo_path === undefined ? undefined : optUploadPath(req.body.photo_path, 255); + } catch (e) { + return res.status(400).json({ error: 'Неверные данные профиля: ' + e.message }); + } + const acc = await studentProfileAccess(req.user, id); + if (!acc.found) { + return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' }); + } + const sets = ['profile = $1']; + const params = [profile ? JSON.stringify(profile) : null]; + if (photoPath !== undefined) { + params.push(photoPath); + sets.push(`photo_path = $${params.length}`); + } + params.push(id); + const { rows } = await pool.query( + `UPDATE students SET ${sets.join(', ')} WHERE id = $${params.length} + RETURNING id, name, group_id, photo_path, profile`, + params + ); + await logAudit(req, 'student.profile.update', { id, name: acc.student.name, blocks: profile ? Object.keys(profile) : [] }); + invalidateStudents(); + res.json(rows[0]); +}); 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 plural = (n, one, few, many) => { - const m10 = n % 10, m100 = n % 100; - if (m10 === 1 && m100 !== 11) return one; - if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few; - return many; - }; - const counters = []; - if (showEntries) counters.push(`
${entries.length}${plural(entries.length, 'занятие', 'занятия', 'занятий')}
`); - if (showPhotos) counters.push(`
${photos.length}${plural(photos.length, 'фотография', 'фотографии', 'фотографий')}
`); - if (showFiles) counters.push(`
${files.length}${plural(files.length, 'работа', 'работы', 'работ')}
`); - counters.push(`
${groups.length}${plural(groups.length, 'группа', 'группы', 'групп')}
`); - 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 ` -
- ${escapeHtml(name)} — фото - ${caption ? `
${escapeHtml(caption)}
` : ''} -
`; - }).join('') : ''; - const projectCards = showFiles ? files.map((f, i) => { - 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 ? ` data-g="${fg}"` : ' target="_blank" rel="noopener"'; - return ` - `; - }).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 => ` - фото`).join('') : ''; - const entryFiles = showFiles ? files.filter(f => f.entryId === e.id) : []; - const fls = entryFiles.length ? `
${entryFiles.map(f => `${escapeHtml(f.original)}`).join('')}
` : ''; - const desc = e.description ? `

${escapeHtml(e.description)}

` : ''; - const gr = e.group_name ? `${escapeHtml(e.group_name)}` : ''; - const dt = showDates && e.created_at ? `${escapeHtml(fmtLongDate(e.created_at))}` : ''; - return ` -
- ${dt ? `
${escapeHtml(fmtLongDate(e.created_at))}
` : ''} -
- ${gr ? `${escapeHtml(e.group_name)}` : ''} - ${desc} - ${thumbs ? `
${thumbs}
` : ''} - ${fls} -
-
`; - }).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 ? `
${escapeHtml(name)}
` : ''; - const navItems = []; - navItems.push('Обзор'); - if (showPhotos) navItems.push('Фотографии'); - if (showFiles) navItems.push('Работы'); - if (showEntries) navItems.push('Занятия'); - const nav = navItems.join(''); - return ` - - - - -${escapeHtml(name)} — портфолио - - - - -
-
-
- ${heroAvatar || '
🎨
'} -
-

Портфолио ученика

-

${escapeHtml(name)}

-

${metaBits.join(' · ')}

-
-
-
- ${showFiles ? 'Смотреть работы' : ''} - ${showEntries ? 'Хронология занятий' : ''} -
-
- -
-

Обзор

-
- ${counters.join('')} -
-
- - ${showPhotos ? `
-

Фотографии

[${photos.length}]
- ${photoCards ? `
${photoCards}
` : '

Фотографий пока нет.

'} -
` : ''} - - ${showFiles ? `
-

Проекты и работы

[${files.length}]
- ${projectCards ? `
${projectCards}
` : '

Работ пока нет.

'} -
` : ''} - - ${showEntries ? `
-

Журнал занятий

[${entries.length}]
- ${lessonRows ? `
${lessonRows}
` : '

Записей о занятиях пока нет.

'} -
` : ''} - -
- Сформировано ${genLabel} - WhatIDo · Портфолио -
-
- - - -`; -} - app.get('/api/export/student', requireAuth, async (req, res) => { let name; try { @@ -3004,31 +2764,50 @@ app.get('/api/export/student', requireAuth, async (req, res) => { } 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} + const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes] = await Promise.all([ + pool.query( + `SELECT s.id, s.name, s.created_at, s.group_id, s.photo_path, s.profile, + g.name AS group_name, b.name AS branch_name + FROM students s + LEFT JOIN groups g ON g.id = s.group_id + LEFT JOIN branches b ON b.id = g.branch_id + WHERE s.name = $1`, + [name] + ), + pool.query(`SELECT e.id, e.description, e.created_at, e.module_id, e.ai_status, g.name AS group_name, m.name AS module_name + FROM entries e JOIN groups g ON g.id = e.group_id + LEFT JOIN modules m ON m.id = e.module_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 + pool.query(`SELECT ep.photo_path, ep.caption, ep.entry_id, e.description, e.created_at, g.name AS group_name 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 + pool.query(`SELECT e.photo_path, e.description, e.created_at, e.id AS entry_id, g.name AS group_name 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 + pool.query(`SELECT pf.path, pf.name, pf.entry_id, e.created_at, e.description, g.name AS group_name, m.name AS module_name FROM project_files pf JOIN entries e ON e.id = pf.entry_id JOIN groups g ON g.id = e.group_id + LEFT JOIN modules m ON m.id = e.module_id WHERE ${condStr} AND pf.detached_at IS NULL ORDER BY e.created_at DESC, pf.id DESC`, params), + pool.query(`SELECT m.id, m.name, m.lessons_count, + count(e.id)::int AS entries_count, + min(e.created_at) AS first_at, max(e.created_at) AS last_at + FROM entries e + JOIN groups g ON g.id = e.group_id + JOIN modules m ON m.id = e.module_id + WHERE ${condStr} + GROUP BY m.id ORDER BY min(e.created_at)`, params), ]); const entryRows = entriesRes.rows; if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) { return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' }); } + const student = studRes.rows[0] || { name }; const zip = createZipWriter(); if (opts.includePhotos) zip.addDir('photos'); if (opts.includeFiles) zip.addDir('files'); @@ -3042,14 +2821,33 @@ app.get('/api/export/student', requireAuth, async (req, res) => { seenPhotos.add(stored); const src = path.join(UPLOADS_DIR, stored); if (!fs.existsSync(src)) return; + const ts = p.created_at ? new Date(p.created_at) : new Date(); 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 }); + zip.addFile('photos/' + stored, data, ts); + photosBuilt.push({ + stored, + caption: p.caption || null, + createdAt: p.created_at || null, + desc: p.description || null, + entryId: p.entry_id || null, + groupName: p.group_name || null, + }); } + let avatarStored = null; if (opts.includePhotos) { + const profilePhoto = (student.profile && student.profile.photo_path) || student.photo_path; + if (profilePhoto && isSafeUploadPath(profilePhoto)) { + const stored = profilePhoto.slice('/uploads/'.length); + if (fs.existsSync(path.join(UPLOADS_DIR, stored))) avatarStored = stored; + } 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)); + photosBuilt.sort((a, b) => new Date(b.createdAt || Date.now()) - new Date(a.createdAt || Date.now())); + if (avatarStored) { + if (!seenPhotos.has(avatarStored)) addPhoto({ photo_path: '/uploads/' + avatarStored, caption: 'Фото ученика', created_at: student.created_at }); + const idx = photosBuilt.findIndex(p => p.stored === avatarStored); + if (idx > 0) photosBuilt.unshift(photosBuilt.splice(idx, 1)[0]); + } } const seenFiles = new Map(); @@ -3072,30 +2870,56 @@ app.get('/api/export/student', requireAuth, async (req, res) => { } 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 }); + filesBuilt.push({ + saved, + original: f.name, + ext: ext ? ext.slice(1).toUpperCase() : '', + size: data.length, + createdAt: f.created_at, + entryId: f.entry_id, + desc: f.description || null, + moduleName: f.module_name || null, + groupName: f.group_name || null, + }); } } - 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({ + const reportData = { name, - groups, + student: { + id: student.id || null, + name: student.name || name, + created_at: student.created_at || null, + group_name: student.group_name || null, + branch_name: student.branch_name || null, + }, + profile: student.profile || null, entries: entryRows, + modules: modulesRes.rows, photos: photosBuilt, files: filesBuilt, generatedAt: new Date(), period, - }, opts); + }; + const html = renderStudentReport(reportData, opts); zip.addFile('index.html', Buffer.from(html, 'utf8')); + const meta = { + exported_at: new Date().toISOString(), + student: reportData.student, + profile: student.profile || null, + period: period || null, + totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length }, + options: opts, + entries: entryRows.map(e => ({ id: e.id, group: e.group_name, module: e.module_name || null, created_at: e.created_at, ai_status: e.ai_status, description: e.description })), + photos: photosBuilt.map(p => ({ file: 'photos/' + p.stored, caption: p.caption, created_at: p.createdAt, entry_id: p.entryId })), + files: filesBuilt.map(f => ({ file: 'files/' + f.saved, name: f.original, size: f.size, created_at: f.createdAt, entry_id: f.entryId })), + }; + zip.addFile('data.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8')); const buf = zip.toBuffer(); await logAudit(req, 'export.student', { diff --git a/student-profile.html b/student-profile.html new file mode 100644 index 0000000..dd6982c --- /dev/null +++ b/student-profile.html @@ -0,0 +1,1394 @@ + + + + + +Профиль ученика — Академия + + + + + + + + + + + + +
+ <AppTopbar /> +
+ + + АкадемияStudent Profile + + + + +
+ + + +
+ + — +
+ +
+
+
+ +
+ + +
+ <ProfileHero /> +
+
+ + +
+ <AuthorStats /> +
+

Информация об авторе

+

Сколько учится, сколько проектов сделал, сколько занятий посетил и другие показатели.

+
+
+
+ +
+ <EducationTimeline /> +
+

Образование

+

Пройденные модули программы, преподаватели и итоговый прогресс по каждому модулю.

+
+
+
+ + +
+ <WorkExperience /> +
+
+

Опыт работы

+

Стажировки, фриланс и командные проекты академии — ленту можно крутить вбок.

+
+
+ + + +
+
+
+
+
+
+ + +
+ <SkillsCloud /> +
+

Навыки

+

Краткий список модулей и технологий, которые ученик уже проходил.

+
+
+
+ +
+ <ProjectsRail /> +
+
+

Проекты

+

Работы, которые ученик защитил на курсе. Лента прокручивается вбок.

+
+
+ + + +
+
+
+
+
+
+ + +
+ <CompletedLessons /> +
+

Пройденные задания и уроки

+

Последние сданные задания, оценки преподавателя и статус проверки.

+
+
+
+ + +
+ <AboutAuthor /> +
+

Немного о себе

+

Пара строк от автора профиля, контакты и главные достижения.

+
+
+
+ + + +
+ +
+ + + + \ No newline at end of file diff --git a/student-report.js b/student-report.js new file mode 100644 index 0000000..8fc3b86 --- /dev/null +++ b/student-report.js @@ -0,0 +1,1165 @@ +const zlib = require('zlib'); + +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]); + }, + }; +} + +const MONTHS_GEN = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']; +const MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']; + +function escapeHtml(str) { + return String(str === null || str === undefined ? '' : str) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function asDate(iso) { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} + +function longDate(iso) { + const d = asDate(iso); + if (!d) return ''; + return d.getDate() + ' ' + MONTHS_GEN[d.getMonth()] + ' ' + d.getFullYear(); +} + +function monthYear(iso) { + const d = asDate(iso); + if (!d) return ''; + return MONTHS_GEN[d.getMonth()] + ' ' + d.getFullYear(); +} + +function rangeLabel(from, to) { + const a = asDate(from); + const b = asDate(to); + if (!a && !b) return ''; + if (!a) return 'до ' + monthYear(to); + if (!b) return 'с ' + monthYear(from); + if (a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()) return monthYear(from); + return MONTHS_SHORT[a.getMonth()] + ' ' + a.getFullYear() + ' — ' + MONTHS_SHORT[b.getMonth()] + ' ' + b.getFullYear(); +} + +function bytes(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(value, max) { + const s = String(value || '').replace(/\s+/g, ' ').trim(); + return s.length > max ? s.slice(0, max - 1) + '…' : s; +} + +function firstLine(value, max) { + const s = String(value || '').split('\n').map(x => x.trim()).filter(Boolean)[0] || ''; + return truncate(s, max); +} + +function plural(n, one, few, many) { + const m10 = n % 10; + const m100 = n % 100; + if (m10 === 1 && m100 !== 11) return one; + if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few; + return many; +} + +function initials(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + const a = parts[0] ? parts[0][0] : ''; + const b = parts[1] ? parts[1][0] : ''; + return (a + b).toUpperCase() || '?'; +} + +function chipInit(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + if (!parts.length) return '—'; + return parts[0] + (parts[1] ? ' ' + parts[1][0] + '.' : ''); +} + +function str(value, max) { + if (value === null || value === undefined) return null; + const s = String(value).trim(); + if (!s) return null; + return max && s.length > max ? s.slice(0, max) : s; +} + +function num(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function arr(value) { + return Array.isArray(value) ? value : []; +} + +function iconName(value, fallback) { + const s = String(value || '').trim().toLowerCase(); + return /^[a-z0-9-]{1,32}$/.test(s) ? s : fallback; +} + +function safeHref(value) { + const s = String(value || '').trim(); + if (!s || s.length > 500) return null; + if (/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(s)) return s; + if (/^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/.test(s)) return 'mailto:' + s; + return null; +} + +const IMG_EXT = new Set(['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'BMP', 'AVIF', 'SVG', 'ICO', 'JFIF', 'HEIC', 'HEIF']); +const VID_EXT = new Set(['MP4', 'WEBM', 'MOV', 'M4V', 'OGV', 'MKV', 'MPEG', 'MPG', '3GP', 'AVI']); +const EXT_ICONS = { + PDF: 'file-text', DOC: 'file-text', DOCX: 'file-text', TXT: 'file-text', RTF: 'file-text', + XLS: 'table-2', XLSX: 'table-2', CSV: 'table-2', PPT: 'presentation', PPTX: 'presentation', + ZIP: 'folder-archive', RAR: 'folder-archive', '7Z': 'folder-archive', TAR: 'folder-archive', GZ: 'folder-archive', + JS: 'file-code-2', TS: 'file-code-2', HTML: 'file-code-2', CSS: 'file-code-2', JSON: 'file-code-2', + PY: 'file-code-2', JAVA: 'file-code-2', CS: 'file-code-2', CPP: 'file-code-2', C: 'file-code-2', + PSD: 'image', AI: 'image', MP3: 'music', WAV: 'music', OGG: 'music', FLAC: 'music', + FIG: 'figma', SKETCH: 'figma', BLEND: 'box', OBJ: 'box', STL: 'box', FBX: 'box', GLB: 'box', +}; + +function extIcon(ext) { + const e = String(ext || '').toUpperCase(); + if (IMG_EXT.has(e)) return 'image'; + if (VID_EXT.has(e)) return 'video'; + return EXT_ICONS[e] || 'file'; +} + +function iconForGroup(title) { + const t = String(title || '').toLowerCase(); + if (/вёрстк|верстк|css|html|стил|дизайн|ui|ux/.test(t)) return 'layout-panel-left'; + if (/javascript|js|программ|код|разработ/.test(t)) return 'braces'; + if (/vue|react|framework|компонент/.test(t)) return 'component'; + if (/инструмент|git|сборк|figma|vite/.test(t)) return 'wrench'; + if (/данн|sql|база|api/.test(t)) return 'database'; + if (/робот|python|ии|ai|нейро/.test(t)) return 'bot'; + return 'sparkles'; +} + +function buildProfileView(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 student = data.student || {}; + const profile = (data.profile && typeof data.profile === 'object' && !Array.isArray(data.profile)) ? data.profile : {}; + const entries = arr(data.entries); + const dbModules = arr(data.modules); + const photos = arr(data.photos); + const files = arr(data.files); + const name = str(data.name, 150) || str(student.name, 150) || 'Ученик'; + const profContacts = arr(profile.contacts); + const profAchievements = arr(profile.achievements); + + const gallery = []; + const galleryIdx = new Map(); + function galleryAdd(type, src, caption) { + if (galleryIdx.has(src)) return galleryIdx.get(src); + const idx = gallery.length; + gallery.push({ type, src, caption: caption || '' }); + galleryIdx.set(src, idx); + return idx; + } + + const photoMap = new Map(); + const photoCards = []; + if (showPhotos) { + for (const p of photos) { + const src = 'photos/' + p.stored; + const g = galleryAdd('image', src, p.caption || ''); + const card = { + src, + g, + caption: showCaptions ? (str(p.caption, 160) || '') : '', + date: showDates ? longDate(p.createdAt) : '', + group: str(p.groupName, 80) || '', + entryId: p.entryId, + }; + photoMap.set(p.stored, card); + photoCards.push(card); + } + } + + const photoByEntry = new Map(); + for (const p of photos) { + if (!photoByEntry.has(p.entryId)) photoByEntry.set(p.entryId, []); + photoByEntry.get(p.entryId).push(p); + } + + const projectMap = new Map(); + const projects = []; + if (showFiles) { + for (const f of files) { + const ext = String(f.ext || '').toUpperCase(); + const isImg = IMG_EXT.has(ext); + const isVid = VID_EXT.has(ext); + const href = 'files/' + f.saved; + const card = { + href, + ext, + icon: extIcon(ext), + isImg, + isVid, + g: (isImg || isVid) ? galleryAdd(isVid ? 'video' : 'image', href, f.original) : -1, + title: str(f.original, 160) || 'Файл', + description: truncate(f.desc, 170) || (bytes(f.size) + (ext ? ' · ' + ext : '')), + tags: [ext, str(f.moduleName, 70)].filter(Boolean), + date: showDates ? longDate(f.createdAt) : '', + size: bytes(f.size), + entryId: f.entryId, + }; + projectMap.set(f.saved, card); + projects.push(card); + } + } + + const fileByEntry = new Map(); + for (const f of files) { + if (!fileByEntry.has(f.entryId)) fileByEntry.set(f.entryId, []); + fileByEntry.get(f.entryId).push(f); + } + + const tasks = showEntries ? entries.map(e => { + const st = e.ai_status; + const ok = st === 'done' || st === 'skipped'; + const bad = st === 'error'; + return { + icon: ok ? 'check' : (bad ? 'triangle-alert' : 'loader'), + done: ok, + statusLabel: ok ? (st === 'skipped' ? 'Принято' : 'Проверено') : (bad ? 'Ошибка проверки' : 'В проверке'), + title: firstLine(e.description, 120) || 'Запись занятия', + sub: [str(e.module_name, 90) || str(e.group_name, 90), showDates ? longDate(e.created_at) : ''].filter(Boolean).join(' · '), + thumbs: (photoByEntry.get(e.id) || []).slice(0, 4).map(p => photoMap.get(p.stored)).filter(Boolean), + files: (fileByEntry.get(e.id) || []).slice(0, 5).map(f => projectMap.get(f.saved)).filter(Boolean), + }; + }) : []; + + const groupSet = new Set(); + if (student.group_name) groupSet.add(student.group_name); + for (const e of entries) if (e.group_name) groupSet.add(e.group_name); + const groups = [...groupSet]; + + const firstAt = entries.length ? entries[entries.length - 1].created_at : student.created_at; + const days = (() => { + const d = asDate(firstAt); + if (!d) return 0; + return Math.max(1, Math.round((Date.now() - d.getTime()) / 86400000)); + })(); + + const eduMeta = new Map(); + for (const m of arr(profile.education)) { + const key = str(m && m.module, 200); + if (key) eduMeta.set(key.toLowerCase(), m); + } + const MODULE_ICONS = ['file-code-2', 'braces', 'component', 'route', 'file-type-2', 'figma', 'layout-panel-left', 'wrench']; + const education = dbModules.map((m, i) => { + const meta = eduMeta.get(String(m.name || '').trim().toLowerCase()) || {}; + const lessons = num(m.lessons_count) || 0; + const done = num(m.entries_count) || 0; + let progress = (meta.progress === null || meta.progress === undefined) ? null : num(meta.progress); + if (progress === null && lessons > 0) progress = Math.max(1, Math.min(100, Math.round((done / lessons) * 100))); + if (progress !== null) progress = Math.max(0, Math.min(100, Math.round(progress))); + const teacher = str(meta.teacher, 150); + return { + icon: iconName(meta.icon, MODULE_ICONS[i % MODULE_ICONS.length]), + code: 'MOD-' + String(m.id).padStart(2, '0'), + title: str(m.name, 200) || 'Модуль', + teacher: teacher ? 'Преподаватель: ' + teacher : null, + period: rangeLabel(m.first_at, m.last_at), + lessons: lessons || null, + done, + progress, + grade: str(meta.grade, 90), + }; + }); + + const stats = []; + if (showEntries) stats.push({ icon: 'clipboard-list', value: entries.length, suffix: plural(entries.length, 'запись', 'записи', 'записей'), label: 'Записей в журнале', hint: 'что делал ученик на занятиях' }); + if (showPhotos) stats.push({ icon: 'image', value: photos.length, suffix: plural(photos.length, 'фото', 'фото', 'фото'), label: 'Фотографий', hint: 'снимки с занятий' }); + if (showFiles) stats.push({ icon: 'folder-git-2', value: files.length, suffix: plural(files.length, 'работа', 'работы', 'работ'), label: 'Работ в портфолио', hint: 'файлы, прикреплённые к занятиям' }); + stats.push({ icon: 'users-round', value: groups.length, suffix: plural(groups.length, 'группа', 'группы', 'групп'), label: 'Групп', hint: groups.join(' · ') || '—' }); + stats.push({ icon: 'book-open-check', value: education.length, suffix: plural(education.length, 'тема', 'темы', 'тем'), label: 'Тем программы', hint: 'модули, которые проходил ученик' }); + if (days && showEntries) stats.push({ icon: 'calendar-clock', value: days, suffix: plural(days, 'день', 'дня', 'дней'), label: 'В академии', hint: 'от первой записи в журнале' }); + for (const s of arr(profile.stats)) { + const label = str(s && s.label, 80); + const value = (s && s.value !== undefined && s.value !== null) ? String(s.value).trim().slice(0, 20) : ''; + if (!label || !value) continue; + stats.push({ icon: iconName(s.icon, 'star'), value, raw: value, suffix: str(s.suffix, 20), label, hint: str(s.hint, 120), delta: str(s.delta, 60) }); + } + + const skillGroups = []; + if (arr(profile.skills).length) { + const map = new Map(); + for (const s of arr(profile.skills)) { + const title = str(s && s.group, 80) || 'Навыки'; + const itemName = str(s && s.name, 120); + if (!itemName) continue; + const value = (s.value === null || s.value === undefined) ? null : Math.max(0, Math.min(100, Math.round(Number(s.value) || 0))); + if (!map.has(title)) map.set(title, []); + map.get(title).push({ name: itemName, level: str(s.level, 40), value }); + } + for (const [title, items] of map) skillGroups.push({ title, icon: iconForGroup(title), items }); + } else if (education.length) { + skillGroups.push({ + title: 'Пройденные темы', + icon: 'sparkles', + items: education.map(m => ({ name: m.title, level: m.progress === null ? null : (m.progress >= 100 ? 'Изучен' : 'В процессе'), value: m.progress })), + }); + } + + const mediaPool = projects.filter(p => p.isImg).map(p => p.src); + const experience = arr(profile.experience).map((e, i) => ({ + title: str(e && e.title, 160) || 'Кейс', + company: str(e && e.company, 160), + period: str(e && e.period, 80), + date: str(e && e.date, 40) || (showDates ? longDate(e && e.createdAt) : ''), + badge: str(e && e.badge, 40) || 'Кейс', + description: str(e && e.description, 800), + tags: arr(e && e.tags).map(t => str(t, 40)).filter(Boolean), + media: mediaPool.length ? mediaPool[i % mediaPool.length] : null, + })); + + const contacts = profContacts.map(c => ({ + icon: iconName(c && c.icon, 'link'), + label: str(c && c.label, 120), + href: safeHref(c && c.href), + })).filter(c => c.label); + + const autoAchievements = []; + if (showEntries && entries.length) autoAchievements.push(entries.length + ' ' + plural(entries.length, 'запись', 'записи', 'записей') + ' в журнале занятий'); + if (showPhotos && photos.length) autoAchievements.push(photos.length + ' ' + plural(photos.length, 'фотография', 'фотографии', 'фотографий') + ' в архиве занятий'); + if (showFiles && files.length) autoAchievements.push(files.length + ' ' + plural(files.length, 'работа', 'работы', 'работ') + ' в портфолио'); + if (education.length) autoAchievements.push('Пройдено тем программы: ' + education.length + ' — ' + education.slice(0, 3).map(m => m.title).join(', ')); + const achievements = profAchievements.length + ? profAchievements.map(a => str(a, 200)).filter(Boolean) + : autoAchievements; + + const facts = []; + const city = str(profile.city, 120) || str(student.branch_name, 120); + if (city) facts.push({ icon: 'map-pin', text: city }); + if (groups.length) facts.push({ icon: 'users-round', text: groups.join(' · ') }); + const joined = str(profile.joined, 120) || (student.created_at ? 'В академии с ' + monthYear(student.created_at) : null); + if (joined) facts.push({ icon: 'calendar-check', text: joined }); + const mentor = str(profile.mentor, 150); + if (mentor) facts.push({ icon: 'user-check', text: 'Наставник: ' + mentor }); + if (education.length) facts.push({ icon: 'book-open', text: education.length + ' ' + plural(education.length, 'тема', 'темы', 'тем') + ' программы' }); + + const autoBio = []; + if (groups.length) autoBio.push('Ученик группы ' + groups.join(', ') + '.'); + if (entries.length) autoBio.push('В журнале ' + entries.length + ' ' + plural(entries.length, 'запись', 'записи', 'записей') + ' о занятиях.'); + if (photos.length) autoBio.push('Фотографий: ' + photos.length + '.'); + if (files.length) autoBio.push('Работ в портфолио: ' + files.length + '.'); + const tagList = arr(profile.tags).map(t => str(t, 40)).filter(Boolean); + const heroTags = tagList.length ? tagList : education.slice(0, 6).map(m => m.title); + const avatar = showPhotos && photoCards.length ? photoCards[0] : null; + const cover = showPhotos && photoCards.length > 1 ? photoCards[1] : null; + const eduYears = education.map(m => m.period).filter(Boolean); + const idLabel = student.id ? 'ID S-' + String(student.id).padStart(4, '0') : null; + + return { + name, student, profile, entries, education, projects, photoCards, tasks, gallery, groups, stats, + skillGroups, experience, contacts, achievements, facts, heroTags, + avatar, cover, idLabel, + role: str(profile.role, 200) || (education.length ? 'Ученик программы: ' + education[0].title : null), + bio: str(profile.bio, 2000) || (autoBio.join(' ') || null), + quote: str(profile.quote, 300), + status: str(profile.status, 60) || 'Активно учится', + statusNote: str(profile.status_note, 120) || (student.created_at ? 'с ' + monthYear(student.created_at) : ''), + periodLabel: eduYears.length ? eduYears[0] + ' — ' + eduYears[eduYears.length - 1] : '', + showEntries, showPhotos, showFiles, showCaptions, showDates, days, + }; +} + +function reportCss() { + return ` +:root{--bg:#080c1a;--bg-soft:#0d1424;--surface:rgba(255,255,255,.045);--surface-2:rgba(255,255,255,.03);--surface-hover:rgba(255,255,255,.08);--stroke:rgba(255,255,255,.09);--stroke-strong:rgba(255,255,255,.22);--text:#e9eeff;--muted:#96a3c6;--brand:#6d8cff;--brand-2:#a855f7;--accent:#22d3ee;--ok:#34d399;--warn:#fbbf24;--danger:#fb7185;--topbar-bg:rgba(8,12,26,.78);--radius:22px;--radius-sm:14px;--radius-xs:10px;--gap:18px;--max:1440px;--shadow:0 22px 60px rgba(3,7,22,.5);--shadow-soft:0 10px 30px rgba(3,7,22,.35)} +[data-theme="light"]{--bg:#f2f5ff;--bg-soft:#fff;--surface:rgba(255,255,255,.92);--surface-2:rgba(15,23,50,.035);--surface-hover:#eef2ff;--stroke:rgba(20,30,70,.10);--stroke-strong:rgba(20,30,70,.24);--text:#101733;--muted:#5a678b;--brand:#3f5df0;--brand-2:#8b3ff0;--accent:#0891b2;--ok:#059669;--warn:#b45309;--danger:#e11d48;--topbar-bg:rgba(255,255,255,.82);--shadow:0 22px 55px rgba(30,45,95,.14);--shadow-soft:0 10px 26px rgba(30,45,95,.10)} +*,*::before,*::after{box-sizing:border-box} +*{margin:0;padding:0} +html{scroll-behavior:smooth;scroll-padding-top:96px} +body{min-height:100vh;font-family:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;font-size:16px;line-height:1.55;color:var(--text);background-color:var(--bg);background-image:radial-gradient(1100px 620px at 12% -8%,rgba(109,140,255,.24),transparent 62%),radial-gradient(900px 520px at 92% -4%,rgba(168,85,247,.20),transparent 58%),radial-gradient(800px 600px at 60% 108%,rgba(34,211,238,.12),transparent 60%);background-attachment:fixed;-webkit-font-smoothing:antialiased} +a{color:inherit;text-decoration:none} +button{font:inherit;color:inherit;background:none;border:0;cursor:pointer} +ul,ol{list-style:none} +svg{display:block;flex:none} +::selection{background:rgba(109,140,255,.35)} +:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:6px} +::-webkit-scrollbar{width:10px;height:10px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--stroke-strong);border-radius:999px} +::-webkit-scrollbar-thumb:hover{background:var(--brand)} +@media (prefers-reduced-motion:reduce){*{animation-duration:.001ms !important;transition-duration:.001ms !important;scroll-behavior:auto !important}} +.wrap{width:100%;max-width:var(--max);margin:0 auto;padding:0 24px} +@media (max-width:640px){.wrap{padding:0 14px}} +.page{padding-top:clamp(20px,3vw,38px);padding-bottom:90px;display:flex;flex-direction:column;gap:clamp(26px,3.4vw,52px)} +.section{display:flex;flex-direction:column;gap:18px;scroll-margin-top:96px;min-width:0} +.section__head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap} +.section__head>*{min-width:0} +.section__title{display:flex;align-items:center;gap:14px;font-size:clamp(1.3rem,2.2vw,1.95rem);font-weight:800;letter-spacing:-.02em;line-height:1.15;flex-wrap:wrap;min-width:0} +.section__hint{color:var(--muted);font-size:.95rem;max-width:70ch} +.icon-badge{width:46px;height:46px;flex:none;border-radius:var(--radius-sm);display:grid;place-items:center;background:linear-gradient(140deg,rgba(109,140,255,.26),rgba(168,85,247,.22));border:1px solid var(--stroke);color:var(--text);box-shadow:inset 0 1px 0 rgba(255,255,255,.08)} +.icon-badge svg{width:22px;height:22px;stroke-width:2} +.card{background:var(--surface);border:1px solid var(--stroke);border-radius:var(--radius);box-shadow:var(--shadow);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px)} +.topbar{position:sticky;top:0;z-index:60;background:var(--topbar-bg);border-bottom:1px solid var(--stroke);backdrop-filter:blur(20px) saturate(160%);-webkit-backdrop-filter:blur(20px) saturate(160%)} +.topbar__inner{max-width:var(--max);margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:14px} +@media (max-width:640px){.topbar__inner{padding:10px 14px}} +.brand{display:flex;align-items:center;gap:12px;font-weight:800;letter-spacing:-.02em} +.brand__logo{width:44px;height:44px;border-radius:14px;display:grid;place-items:center;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;box-shadow:0 10px 24px rgba(109,140,255,.35)} +.brand__logo svg{width:22px;height:22px;stroke-width:2.2} +.brand__text small{display:block;font-size:.72rem;font-weight:600;color:var(--muted);letter-spacing:.06em;text-transform:uppercase} +@media (max-width:540px){.brand__text{display:none}} +.topbar__nav{display:flex;align-items:center;gap:4px;flex:1;justify-content:center} +.nav-link{display:flex;align-items:center;gap:8px;padding:10px 15px;border-radius:999px;font-size:.94rem;font-weight:600;color:var(--muted);white-space:nowrap;transition:background .2s ease,color .2s ease} +.nav-link svg{width:17px;height:17px;stroke-width:2} +.nav-link:hover{background:var(--surface-hover);color:var(--text)} +.nav-link[aria-current="true"]{background:linear-gradient(120deg,rgba(109,140,255,.22),rgba(168,85,247,.18));color:var(--text);box-shadow:inset 0 0 0 1px var(--stroke)} +.topbar__actions{display:flex;align-items:center;gap:8px;margin-left:auto} +.icon-btn{width:44px;height:44px;border-radius:14px;display:grid;place-items:center;background:var(--surface);border:1px solid var(--stroke);color:var(--text);transition:background .2s ease,transform .2s ease,border-color .2s ease} +.icon-btn svg{width:19px;height:19px;stroke-width:2} +.icon-btn:hover{background:var(--surface-hover);border-color:var(--stroke-strong);transform:translateY(-2px)} +.icon-btn[aria-pressed="true"]{background:linear-gradient(120deg,rgba(109,140,255,.3),rgba(168,85,247,.26));border-color:var(--stroke-strong)} +.user-chip{display:flex;align-items:center;gap:10px;padding:6px 14px 6px 6px;border-radius:999px;background:var(--surface);border:1px solid var(--stroke);font-weight:700;font-size:.9rem} +.user-chip__ph{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;flex:none;background:linear-gradient(135deg,rgba(109,140,255,.3),rgba(168,85,247,.28));border:1px dashed var(--stroke-strong);color:var(--muted)} +.user-chip__ph svg{width:16px;height:16px} +@media (max-width:1000px){.user-chip__name{display:none}} +.topbar__burger{display:none} +@media (max-width:1080px){.topbar__nav{position:absolute;top:100%;left:0;right:0;flex-direction:column;align-items:stretch;padding:10px 16px 18px;gap:4px;display:none;background:var(--bg-soft);border-bottom:1px solid var(--stroke);box-shadow:var(--shadow)}.topbar__nav.is-open{display:flex}.nav-link{padding:14px 16px;border-radius:var(--radius-sm);font-size:1rem}.topbar__burger{display:grid}} +.btn{display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:13px 20px;border-radius:var(--radius-sm);font-weight:700;font-size:.95rem;border:1px solid var(--stroke);background:var(--surface);color:var(--text);transition:background .2s ease,transform .2s ease,border-color .2s ease,box-shadow .2s ease} +.btn svg{width:18px;height:18px;stroke-width:2} +.btn:hover{background:var(--surface-hover);border-color:var(--stroke-strong);transform:translateY(-2px)} +.btn--primary{background:linear-gradient(120deg,var(--brand),var(--brand-2));color:#fff;border-color:transparent;box-shadow:0 14px 30px rgba(109,140,255,.32)} +.btn--primary:hover{box-shadow:0 18px 40px rgba(109,140,255,.42)} +.btn--ghost{background:transparent} +.btn--sm{padding:9px 14px;font-size:.85rem;border-radius:var(--radius-xs)} +.btn--sm svg{width:16px;height:16px} +.btn[disabled]{opacity:.45;cursor:not-allowed;transform:none} +.chip{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:999px;background:var(--surface-2);border:1px solid var(--stroke);color:var(--muted);font-size:.82rem;font-weight:600;white-space:nowrap} +.chip svg{width:14px;height:14px;stroke-width:2.2} +.chip--brand{color:var(--text);background:linear-gradient(120deg,rgba(109,140,255,.18),rgba(168,85,247,.14))} +.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.74rem;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid var(--stroke)} +.badge svg{width:13px;height:13px;stroke-width:2.4} +.badge--ok{color:var(--ok);background:rgba(52,211,153,.14);border-color:rgba(52,211,153,.36)} +.badge--progress{color:var(--warn);background:rgba(251,191,36,.14);border-color:rgba(251,191,36,.36)} +.badge--info{color:var(--accent);background:rgba(34,211,238,.14);border-color:rgba(34,211,238,.36)} +.badge--muted{color:var(--muted);background:var(--surface-2)} +.meta{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.84rem;font-weight:600;flex-wrap:wrap} +.meta svg{width:15px;height:15px;stroke-width:2} +.meta__dot{width:4px;height:4px;border-radius:50%;background:var(--stroke-strong);flex:none} +.ph{position:relative;display:grid;place-items:center;gap:8px;overflow:hidden;background-color:var(--surface-2);background-image:repeating-linear-gradient(45deg,transparent 0 12px,rgba(127,150,255,.07) 12px 24px),linear-gradient(135deg,rgba(109,140,255,.16),rgba(168,85,247,.14));border:1px dashed var(--stroke-strong);color:var(--muted);text-align:center;padding:14px} +.ph__icon{width:clamp(26px,3.4vw,42px);height:clamp(26px,3.4vw,42px);stroke-width:1.6;opacity:.85} +.ph__label{font-size:.8rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase} +.ph__size{position:absolute;right:12px;bottom:10px;padding:3px 9px;border-radius:999px;font-family:'JetBrains Mono',ui-monospace,monospace;font-size:.7rem;font-weight:500;background:rgba(6,10,24,.55);color:#e4eaff;border:1px solid var(--stroke)} +[data-theme="light"] .ph__size{background:rgba(255,255,255,.86);color:#31386a} +.ph--square{aspect-ratio:1/1} +.ph--16x9{aspect-ratio:16/9} +.ph--4x3{aspect-ratio:4/3} +.ph--wide{aspect-ratio:21/9} +.media{position:relative;display:block;width:100%;overflow:hidden;background:var(--surface-2)} +.media img,.media video{width:100%;height:100%;object-fit:cover;display:block} +.media--4x3{aspect-ratio:4/3} +.media--16x9{aspect-ratio:16/9} +.media--square{aspect-ratio:1/1} +.media--wide{aspect-ratio:21/9} +.media[data-g]{cursor:zoom-in} +.hero{padding:clamp(20px,2.6vw,34px);overflow:hidden;position:relative} +.hero__cover{border-radius:var(--radius-sm);margin-bottom:clamp(-72px,-5vw,-48px)} +.hero__grid{display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);gap:clamp(18px,2.6vw,34px);align-items:end} +@media (max-width:900px){.hero__grid{grid-template-columns:1fr;align-items:start}.hero__cover{display:none}} +.hero__media{position:relative;padding-top:clamp(10px,2vw,26px)} +.hero__avatar{border-radius:26px;overflow:hidden;box-shadow:var(--shadow)} +.hero__status{position:absolute;left:14px;bottom:14px;padding:7px 12px;border-radius:999px;background:rgba(6,10,24,.6);backdrop-filter:blur(8px);border:1px solid var(--stroke);color:#eaf0ff;font-size:.78rem;font-weight:800;display:inline-flex;align-items:center;gap:7px} +.hero__pulse{width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 0 rgba(52,211,153,.7);animation:pulse 2s infinite} +@keyframes pulse{70%{box-shadow:0 0 0 12px rgba(52,211,153,0)}100%{box-shadow:0 0 0 0 rgba(52,211,153,0)}} +.hero__info{display:flex;flex-direction:column;gap:14px;min-width:0} +.hero__eyebrow{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:.82rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;flex-wrap:wrap} +.hero__name{font-size:clamp(1.9rem,4.4vw,3.4rem);font-weight:800;letter-spacing:-.035em;line-height:1.05;overflow-wrap:anywhere} +.hero__role{font-size:clamp(1rem,1.7vw,1.3rem);font-weight:600;color:var(--muted);max-width:60ch} +.hero__tags{display:flex;flex-wrap:wrap;gap:8px} +.hero__actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:4px} +.hero__facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-top:8px} +@media (max-width:640px){.hero__facts{grid-template-columns:repeat(2,minmax(0,1fr))}} +.fact{display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:var(--radius-sm);background:var(--surface-2);border:1px solid var(--stroke);min-width:0} +.fact svg{width:18px;height:18px;color:var(--brand);flex:none} +.fact span{font-weight:600;font-size:.9rem;color:var(--muted);overflow-wrap:anywhere} +.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:var(--gap)} +@media (max-width:620px){.stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}} +.stat{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:10px;min-width:0} +.stat__top{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap} +.stat__icon{width:46px;height:46px;border-radius:var(--radius-sm);display:grid;place-items:center;flex:none;background:linear-gradient(140deg,rgba(109,140,255,.22),rgba(168,85,247,.18));border:1px solid var(--stroke);color:var(--brand)} +@media (max-width:620px){.stat__icon{width:38px;height:38px}} +.stat__icon svg{width:21px;height:21px;stroke-width:2} +.stat__value{font-size:clamp(1.7rem,3.2vw,2.6rem);font-weight:800;letter-spacing:-.04em;line-height:1} +.stat__value small{font-size:.5em;font-weight:700;color:var(--muted);letter-spacing:0} +.stat__label{font-size:.92rem;font-weight:700} +.stat__hint{font-size:.78rem;color:var(--muted);font-weight:600;overflow-wrap:anywhere} +.stat__delta{display:inline-flex;align-items:center;gap:5px;font-size:.78rem;font-weight:800;color:var(--ok)} +.stat__delta svg{width:14px;height:14px;stroke-width:2.4} +.modules{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--gap)} +@media (max-width:720px){.modules{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}} +.module{padding:clamp(14px,1.7vw,20px);display:flex;flex-direction:column;gap:12px;min-width:0} +.module__head{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap} +.module__head>div{min-width:0;flex:1 1 55%} +.module__icon{width:46px;height:46px;border-radius:var(--radius-sm);display:grid;place-items:center;flex:none;background:var(--surface-2);border:1px solid var(--stroke);color:var(--accent)} +.module__icon svg{width:22px;height:22px;stroke-width:2} +@media (max-width:720px){.module__icon{width:38px;height:38px}.module__icon svg{width:18px;height:18px}} +.module__title{font-size:1.05rem;font-weight:800;letter-spacing:-.01em;overflow-wrap:anywhere} +.module__sub{font-size:.8rem;color:var(--muted);font-weight:600;overflow-wrap:anywhere} +.module__code{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:.72rem;color:var(--muted)} +.module__foot{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap} +.module__grade{font-size:.8rem;font-weight:800;color:var(--ok)} +.bar{height:8px;border-radius:999px;background:var(--surface-2);border:1px solid var(--stroke);overflow:hidden} +.bar>i{display:block;height:100%;width:0;border-radius:inherit;background:linear-gradient(90deg,var(--brand),var(--accent));transition:width 1.1s cubic-bezier(.2,.8,.2,1)} +.bar--warn>i{background:linear-gradient(90deg,var(--warn),var(--danger))} +.bar--muted>i{background:linear-gradient(90deg,var(--muted),var(--stroke-strong))} +.module__progress{display:flex;align-items:center;gap:10px} +.module__progress b{font-size:.8rem;font-weight:800;white-space:nowrap} +.rail-wrap{position:relative} +.rail{display:flex;gap:var(--gap);overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scroll-behavior:smooth;padding:4px 4px 16px;scrollbar-width:thin} +.rail>*{scroll-snap-align:start;min-width:0} +.rail--half>*{flex:0 0 calc(50% - var(--gap)/2)} +.rail--third>*{flex:0 0 calc((100% - var(--gap) * 2) / 3)} +@media (max-width:1100px){.rail--third>*{flex:0 0 calc(50% - var(--gap)/2)}} +@media (max-width:720px){.rail{gap:12px;padding-bottom:12px}.rail--half>*,.rail--third>*{flex:0 0 calc(50% - 6px)}} +.rail-controls{display:flex;align-items:center;gap:8px} +.rail-progress{height:6px;border-radius:999px;background:var(--surface-2);border:1px solid var(--stroke);overflow:hidden;width:min(260px,40vw)} +.rail-progress>i{display:block;height:100%;width:20%;border-radius:inherit;background:linear-gradient(90deg,var(--brand),var(--accent));transition:transform .25s ease,width .25s ease} +.exp-card{display:flex;flex-direction:column;overflow:hidden} +.exp-card__media{position:relative} +.exp-card__logo{position:absolute;left:14px;bottom:-22px;width:56px;height:56px;border-radius:16px;display:grid;place-items:center;background:var(--bg-soft);border:1px solid var(--stroke);box-shadow:var(--shadow-soft);color:var(--brand)} +.exp-card__logo svg{width:24px;height:24px;stroke-width:2} +.exp-card__body{padding:32px clamp(14px,1.6vw,20px) clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:10px;flex:1;min-width:0} +.exp-card__title{font-size:clamp(1rem,1.5vw,1.2rem);font-weight:800;letter-spacing:-.02em;overflow-wrap:anywhere} +.exp-card__company{font-size:.88rem;font-weight:700;color:var(--muted)} +.exp-card__text{font-size:.88rem;color:var(--muted);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} +.exp-card__foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;flex-wrap:wrap} +.prj-card{display:flex;flex-direction:column;overflow:hidden;transition:transform .25s ease,box-shadow .25s ease} +.prj-card:hover{transform:translateY(-6px)} +.prj-card__body{padding:clamp(14px,1.6vw,18px);display:flex;flex-direction:column;gap:10px;flex:1;min-width:0} +.prj-card__title{font-size:clamp(.98rem,1.4vw,1.15rem);font-weight:800;overflow-wrap:anywhere} +.prj-card__text{font-size:.85rem;color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.prj-card__tags{display:flex;flex-wrap:wrap;gap:6px} +.prj-card__foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto;flex-wrap:wrap} +.prj-card__stats{display:flex;align-items:center;gap:12px;color:var(--muted);font-size:.8rem;font-weight:700} +.prj-card__stats span{display:inline-flex;align-items:center;gap:5px} +.prj-card__stats svg{width:15px;height:15px} +.photos{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px} +@media (max-width:620px){.photos{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}} +.photo-card{overflow:hidden;display:flex;flex-direction:column;min-width:0} +.photo-card figcaption{padding:10px 12px;font-size:.8rem;font-weight:600;color:var(--muted);display:flex;flex-direction:column;gap:2px;min-width:0} +.photo-card figcaption b{color:var(--text);font-weight:700;overflow-wrap:anywhere} +.skills{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--gap)} +@media (max-width:720px){.skills{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}} +.skill-group{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:14px;min-width:0} +.skill-group__head{display:flex;align-items:center;gap:10px;font-weight:800;font-size:1rem} +.skill-group__head svg{width:19px;height:19px;color:var(--brand);flex:none} +.skill-group__count{margin-left:auto;font-size:.76rem;font-weight:800;color:var(--muted);background:var(--surface-2);border:1px solid var(--stroke);padding:3px 9px;border-radius:999px;white-space:nowrap} +.skill-list{display:flex;flex-direction:column;gap:11px} +.skill{display:flex;flex-direction:column;gap:6px;min-width:0} +.skill__top{display:flex;align-items:center;justify-content:space-between;gap:8px} +.skill__name{font-size:.86rem;font-weight:700;overflow-wrap:anywhere} +.skill__val{font-size:.74rem;font-weight:800;color:var(--muted);white-space:nowrap} +.tasks{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px} +@media (max-width:760px){.tasks{grid-template-columns:1fr}} +.task{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:14px;align-items:center;padding:14px 16px;transition:background .2s ease,border-color .2s ease} +.task:hover{background:var(--surface-hover);border-color:var(--stroke-strong)} +.task__check{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;flex:none;background:rgba(52,211,153,.14);border:1px solid rgba(52,211,153,.32);color:var(--ok)} +.task__check svg{width:20px;height:20px;stroke-width:2.4} +.task__check--progress{background:rgba(251,191,36,.14);border-color:rgba(251,191,36,.32);color:var(--warn)} +.task__check--error{background:rgba(251,113,133,.14);border-color:rgba(251,113,133,.32);color:var(--danger)} +.task__main{min-width:0;display:flex;flex-direction:column;gap:6px} +.task__title{font-size:.95rem;font-weight:800;overflow-wrap:anywhere} +.task__sub{font-size:.79rem;color:var(--muted);font-weight:600;overflow-wrap:anywhere} +.task__right{display:flex;flex-direction:column;align-items:flex-end;gap:6px} +.task__extra{display:flex;flex-wrap:wrap;gap:6px} +.task__extra a{display:inline-flex;align-items:center;gap:5px;font-size:.75rem;font-weight:700;color:var(--muted);border:1px solid var(--stroke);border-radius:999px;padding:3px 9px;background:var(--surface-2)} +.task__extra a:hover{color:var(--text);border-color:var(--stroke-strong)} +.task__extra a svg{width:12px;height:12px} +.task__thumbs{display:flex;gap:6px;flex-wrap:wrap} +.task__thumbs img{width:64px;height:64px;object-fit:cover;border-radius:10px;border:1px solid var(--stroke);cursor:zoom-in} +@media (max-width:520px){.task{grid-template-columns:auto minmax(0,1fr)}.task__right{grid-column:2;align-items:flex-start}} +.about{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:var(--gap)} +@media (max-width:960px){.about{grid-template-columns:1fr}} +.about__bio{padding:clamp(18px,2vw,26px);display:flex;flex-direction:column;gap:14px} +.about__text{font-size:clamp(.95rem,1.2vw,1.05rem);color:var(--muted)} +.about__text b{color:var(--text)} +.about__quote{margin-top:auto;padding-left:16px;border-left:3px solid var(--brand);font-style:italic;font-weight:600;color:var(--text)} +.about__side{display:grid;gap:var(--gap);grid-template-columns:1fr} +@media (max-width:960px){.about__side{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media (max-width:640px){.about__side{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}} +.about__card{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:12px;min-width:0} +.about__card h3{font-size:.95rem;font-weight:800;display:flex;align-items:center;gap:9px} +.about__card h3 svg{width:18px;height:18px;color:var(--brand);flex:none} +.about__photo{border-radius:var(--radius-sm);overflow:hidden} +.about__photo img{width:100%;display:block;aspect-ratio:4/3;object-fit:cover} +.contacts{display:flex;flex-wrap:wrap;gap:8px} +.contact{display:inline-flex;align-items:center;gap:9px;padding:10px 13px;border-radius:var(--radius-xs);background:var(--surface-2);border:1px solid var(--stroke);font-size:.85rem;font-weight:700;transition:background .2s ease,transform .2s ease;overflow-wrap:anywhere} +.contact:hover{background:var(--surface-hover);transform:translateY(-2px)} +.contact svg{width:16px;height:16px;color:var(--brand);flex:none} +.about__list{display:flex;flex-direction:column;gap:10px} +.about__list li{display:flex;gap:10px;align-items:flex-start;font-size:.86rem;font-weight:600;color:var(--muted)} +.about__list svg{width:17px;height:17px;color:var(--ok);margin-top:2px;flex:none} +.empty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:clamp(22px,3vw,38px);text-align:center;color:var(--muted);font-size:.9rem;font-weight:600} +.empty svg{width:30px;height:30px;stroke-width:1.8;opacity:.8} +.empty b{color:var(--text);font-weight:800;font-size:.98rem} +.empty small{font-weight:600;opacity:.85;max-width:56ch} +.footer{margin-top:10px;padding:26px 0 0;border-top:1px solid var(--stroke);display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;color:var(--muted);font-size:.85rem;font-weight:600} +.footer__links{display:flex;gap:16px;flex-wrap:wrap} +.footer__links a:hover{color:var(--text)} +.toast{position:fixed;left:50%;bottom:26px;z-index:90;padding:12px 20px;border-radius:999px;background:var(--bg-soft);border:1px solid var(--stroke);box-shadow:var(--shadow);font-weight:700;font-size:.88rem;opacity:0;transform:translate(-50%,140%);transition:transform .3s cubic-bezier(.2,.8,.2,1),opacity .3s ease;pointer-events:none;max-width:88vw;text-align:center} +.toast.is-show{transform:translate(-50%,0);opacity:1} +.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +.reveal{opacity:0;transform:translateY(18px);transition:opacity .6s ease,transform .6s cubic-bezier(.2,.8,.2,1)} +.reveal.is-in{opacity:1;transform:none} +.lb{position:fixed;inset:0;z-index:100;display:none;align-items:center;justify-content:center;background:rgba(4,7,18,.9);backdrop-filter:blur(6px);padding:24px} +.lb.open{display:flex} +.lb__media{max-width:min(1200px,92vw);max-height:86vh;display:grid;place-items:center} +.lb__media img,.lb__media video{max-width:min(1200px,92vw);max-height:78vh;border-radius:var(--radius-sm);display:block} +.lb__cap{position:absolute;left:50%;bottom:26px;transform:translateX(-50%);color:#dfe6ff;font-size:.85rem;font-weight:600;max-width:80vw;text-align:center} +.lb__btn{position:absolute;width:48px;height:48px;border-radius:14px;display:grid;place-items:center;background:var(--surface);border:1px solid var(--stroke);color:#eaf0ff} +.lb__btn svg{width:20px;height:20px} +.lb__btn:hover{background:var(--surface-hover)} +.lb__prev{left:18px;top:50%;transform:translateY(-50%)} +.lb__next{right:18px;top:50%;transform:translateY(-50%)} +.lb__close{right:18px;top:18px} +.lb__count{position:absolute;left:18px;top:18px;padding:6px 12px;border-radius:999px;background:var(--surface);border:1px solid var(--stroke);color:#eaf0ff;font-size:.78rem;font-weight:700} +@media (max-width:640px){.lb__prev{left:8px}.lb__next{right:8px}.lb__btn{width:40px;height:40px}} +html.no-icons i[data-lucide]{display:inline-block;width:1em;height:1em;border-radius:4px;background:currentColor;opacity:.35;flex:none} +html.no-icons .ph__icon{width:32px;height:32px} +@media print{ + :root{--bg:#fff;--bg-soft:#fff;--surface:#fff;--surface-2:#f5f7ff;--text:#101733;--muted:#48506e;--stroke:rgba(20,30,70,.2);--shadow:none;--shadow-soft:none} + body{background-image:none !important} + .topbar,.hero__actions,.rail-controls,.lb,.toast,.footer__links{display:none !important} + .card{box-shadow:none;border-color:rgba(20,30,70,.25)} + .rail{flex-wrap:wrap;overflow:visible} + .rail--half>*,.rail--third>*{flex:0 0 calc(50% - var(--gap)/2)} + .page{gap:22px;padding-bottom:0} + .reveal{opacity:1 !important;transform:none !important} + .media img{object-fit:contain} +} +@media (max-width:620px){.badge{font-size:.68rem;padding:4px 8px;gap:4px}.chip{font-size:.76rem;padding:6px 10px}.stat__delta,.stat__hint{font-size:.74rem}.stat__label{font-size:.85rem}.module__title{font-size:.95rem}.module__sub,.exp-card__company{font-size:.76rem}.skill__name{font-size:.8rem}.icon-badge{width:40px;height:40px}.fact span{font-size:.82rem}.btn{padding:11px 16px;font-size:.9rem}.rail-progress{width:min(150px,34vw)}} +@media (max-width:400px){.stats{grid-template-columns:1fr}.about__side{grid-template-columns:1fr}} +`; +} +function iconTag(name) { + return ''; +} + +function ratioMod(w, h) { + if (w === h) return 'square'; + const r = w / h; + if (Math.abs(r - 16 / 9) < 0.02) return '16x9'; + if (Math.abs(r - 4 / 3) < 0.02) return '4x3'; + return 'wide'; +} + +function phBlock(label, w, h, icon, style) { + return ''; +} + +function mediaBlock(src, mod, alt, g) { + return '= 0 ? ' data-g="' + g + '"' : '') + '>' + + '' + escapeHtml(alt) + ''; +} + +function videoBlock(src, mod, g) { + return '= 0 ? ' data-g="' + g + '"' : '') + '>' + + ''; +} + +function sectionHead(icon, title, hint) { + return '

' + iconTag(icon) + '' + + escapeHtml(title) + '

' + (hint ? '

' + escapeHtml(hint) + '

' : '') + '
'; +} + +function railHead(icon, title, hint, railId) { + return '

' + iconTag(icon) + '' + + escapeHtml(title) + '

' + (hint ? '

' + escapeHtml(hint) + '

' : '') + '
' + + '
' + + '' + + '
'; +} + +function emptyBlock(icon, title, text) { + return '
' + iconTag(icon) + '' + escapeHtml(title) + '' + + (text ? '' + escapeHtml(text) + '' : '') + '
'; +} + +function heroHtml(v) { + const cover = v.cover + ? mediaBlock(v.cover.src, 'wide', 'Обложка профиля', v.cover.g) + : phBlock('Обложка профиля', 1440, 480, 'wallpaper', 'border-radius:var(--radius-sm)'); + const avatar = v.avatar + ? mediaBlock(v.avatar.src, 'square', v.name, v.avatar.g) + : phBlock('Фото ученика', 480, 480, 'user-round'); + const eyebrow = [v.idLabel, str(v.periodLabel, 60)].filter(Boolean).join(' · '); + const actions = []; + actions.push(''); + const mail = v.contacts.find(c => (c.href || '').toLowerCase().startsWith('mailto:')) || v.contacts.find(c => c.href); + if (mail) actions.push('' + iconTag('send') + 'Написать'); + if (v.photoCards.length) actions.push('' + iconTag('images') + 'Фотографии'); + const facts = v.facts.length + ? v.facts.map(f => '' + iconTag(f.icon) + '' + escapeHtml(f.text) + '').join('') + : '' + iconTag('info') + 'Данные профиля ещё не заполнены'; + const tags = v.heroTags.length + ? v.heroTags.map(t => '' + iconTag('tag') + escapeHtml(t) + '').join('') + : '' + iconTag('tag') + 'теги не заполнены'; + return '
' + + '
' + cover + '
' + + '
' + + '
' + avatar + '
' + + '' + escapeHtml(v.status + (v.statusNote ? ' · ' + v.statusNote : '')) + '
' + + '
' + + (eyebrow ? '' + iconTag('id-card') + escapeHtml(eyebrow) + '' : '') + + '

' + escapeHtml(v.name) + '

' + + (v.role ? '

' + escapeHtml(v.role) + '

' : '') + + (v.bio ? '

' + escapeHtml(v.bio) + '

' : '') + + '
' + tags + '
' + + '
' + facts + '
' + + '
' + actions.join('') + '
' + + '
'; +} + +function statsHtml(v) { + return v.stats.map(s => { + const raw = s.raw !== undefined ? s.raw : String(s.value); + return '
' + + '
' + iconTag(s.icon) + '' + + (s.delta ? '' + iconTag('trending-up') + escapeHtml(s.delta) + '' : '') + + '
' + + '

' + escapeHtml(String(s.value)) + + (s.suffix ? ' ' + escapeHtml(s.suffix) + '' : '') + '

' + + '

' + escapeHtml(s.label) + '

' + + (s.hint ? '

' + escapeHtml(s.hint) + '

' : '') + + '
'; + }).join(''); +} + +function skillsHtml(v) { + if (!v.skillGroups.length) { + return emptyBlock('sparkles', 'Навыки пока не заполнены', + 'Список навыков и технологий можно добавить в данных профиля ученика.'); + } + return v.skillGroups.map(g => '
' + + '
' + iconTag(g.icon) + '' + escapeHtml(g.title) + '' + + '' + g.items.length + ' ' + plural(g.items.length, 'пункт', 'пункта', 'пунктов') + '
' + + '
' + g.items.map(it => { + const has = it.value !== null && it.value !== undefined; + return '
' + + '' + escapeHtml(it.name) + '' + + '' + escapeHtml([it.level, has ? it.value + '%' : null].filter(Boolean).join(' · ') || '—') + '' + + '
'; + }).join('') + '
').join(''); +} + +function projectMedia(p) { + if (p.isImg) return mediaBlock(p.href, '16x9', p.title, p.g); + if (p.isVid) return videoBlock(p.href, '16x9', p.g); + return phBlock(p.ext ? p.ext + ' · файл' : 'Файл проекта', 1280, 720, p.icon); +} + +function projectsHtml(v) { + if (!v.projects.length) { + return emptyBlock('folder-git-2', 'Работ в портфолио пока нет', + 'Файлы появятся здесь, как только ученик прикрепит их к записи занятия.'); + } + return v.projects.map(p => '
' + + projectMedia(p) + + '
' + + '' + iconTag(p.icon) + escapeHtml(p.ext || 'Файл') + '' + + '

' + escapeHtml(p.title) + '

' + + (p.description ? '

' + escapeHtml(p.description) + '

' : '') + + (p.tags.length ? '
' + p.tags.map(t => '' + escapeHtml(t) + '').join('') + '
' : '') + + '
' + + (p.date ? '' + iconTag('calendar-days') + escapeHtml(p.date) + '' : '') + + (p.size ? '' + iconTag('hard-drive') + escapeHtml(p.size) + '' : '') + + '= 0 ? ' data-g="' + p.g + '"' : ' target="_blank" rel="noopener"') + '>' + + iconTag(p.g >= 0 ? 'maximize-2' : 'external-link') + (p.g >= 0 ? 'Просмотр' : 'Открыть') + '
' + + '
').join(''); +} + +function photosHtml(v) { + if (!v.photoCards.length) { + return emptyBlock('image', 'Фотографий пока нет', + 'Фотографии занятий появятся здесь, как только ученик отправит их вместе с записью.'); + } + return v.photoCards.map(p => '
' + + mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) + + (p.caption || p.date || p.group + ? '
' + (p.caption ? '' + escapeHtml(p.caption) + '' : '') + + (p.date || p.group ? '' + escapeHtml([p.date, p.group].filter(Boolean).join(' · ')) + '' : '') + '
' + : '') + + '
').join(''); +} + +function tasksHtml(v) { + if (!v.tasks.length) { + return emptyBlock('list-checks', 'Записей о занятиях пока нет', + 'Как только ученик отправит запись через форму, она появится в этом разделе.'); + } + return v.tasks.map(t => { + const checkCls = t.done ? '' : (t.icon === 'triangle-alert' ? ' task__check--error' : ' task__check--progress'); + const right = ['' + escapeHtml(t.statusLabel) + '']; + if (t.thumbs.length) right.push('
' + t.thumbs.map(x => 'фото').join('') + '
'); + if (t.files.length) right.push(''); + return '
' + + '' + iconTag(t.icon) + '' + + '

' + escapeHtml(t.title) + '

' + + (t.sub ? '

' + escapeHtml(t.sub) + '

' : '') + '
' + + '
' + right.join('') + '
' + + '
'; + }).join(''); +} + +function aboutHtml(v) { + const bio = '
' + + '

' + iconTag('user-round') + 'Коротко о себе

' + + (v.bio ? '

' + escapeHtml(v.bio) + '

' + : '

Здесь появится рассказ ученика о себе. Добавьте текст в данных профиля.

') + + (v.quote ? '

«' + escapeHtml(v.quote) + '»

' : '') + + (v.facts.length ? '
' + v.facts.map(f => '' + iconTag(f.icon) + '' + escapeHtml(f.text) + '').join('') + '
' : '') + + '
'; + + const photoCard = '

' + iconTag('image') + 'Фото автора

' + + (v.avatar + ? '
' + escapeHtml(v.name) + '
' + : phBlock('Фото автора', 800, 600, 'image')) + + '

' + iconTag('info') + (v.avatar ? 'Фотография из архива занятий' : 'Место под реальное фото автора профиля') + '

'; + + const contactsCard = '

' + iconTag('at-sign') + 'Контакты

' + + (v.contacts.length + ? '
' + v.contacts.map(c => (c.href + ? '' + iconTag(c.icon) + escapeHtml(c.label) + '' + : '' + iconTag(c.icon) + escapeHtml(c.label) + '')).join('') + '
' + : '

' + iconTag('info') + 'Контакты не заполнены

') + '
'; + + const achCard = '

' + iconTag('trophy') + 'Достижения

' + + (v.achievements.length + ? '
    ' + v.achievements.map(a => '
  • ' + iconTag('circle-check-big') + '' + escapeHtml(a) + '
  • ').join('') + '
' + : '

' + iconTag('info') + 'Достижения пока не отмечены

') + '
'; + + return bio + '
' + photoCard + contactsCard + achCard + '
'; +} + +function footerHtml(v, generatedAt) { + const links = []; + if (v.entries.length) links.push('Занятия'); + if (v.photoCards.length) links.push('Фотографии'); + if (v.projects.length) links.push('Работы'); + links.push('Наверх'); + return '

© Академия · профиль ученика ' + escapeHtml(v.name) + + ' · сформировано ' + escapeHtml(longDate(generatedAt)) + '

' + + '
'; +} + +function navItems(v) { + const items = ['' + iconTag('user-round') + 'Профиль']; + if (v.photoCards.length) items.push('' + iconTag('images') + 'Фотографии'); + items.push('' + iconTag('book-open') + 'Образование'); + if (v.experience.length) items.push('' + iconTag('briefcase') + 'Опыт'); + if (v.skillGroups.length) items.push('' + iconTag('sparkles') + 'Навыки'); + if (v.projects.length) items.push('' + iconTag('folder-git-2') + 'Работы'); + if (v.tasks.length) items.push('' + iconTag('list-checks') + 'Занятия'); + items.push('' + iconTag('info') + 'О себе'); + return items.join(''); +} + +function educationHtml(v) { + if (!v.education.length) { + return emptyBlock('book-open', 'Темы программы ещё не заполнены', + 'Добавьте темы модулей в админке (раздел «Темы модулей») или отметьте пройденные модули в данных профиля ученика.'); + } + return v.education.map(m => { + const p = m.progress; + const hasBar = p !== null; + const barCls = hasBar ? (p >= 100 ? '' : ' bar--warn') : ' bar--muted'; + const foot = []; + foot.push(p === null + ? '' + iconTag('circle-dashed') + 'Нет данных' + : (p >= 100 ? '' + iconTag('badge-check') + 'Пройден' + : '' + iconTag('loader') + 'В процессе')); + if (m.grade) foot.push('' + iconTag('award') + ' ' + escapeHtml(m.grade) + ''); + const meta = [m.code, m.period, m.lessons ? m.lessons + ' ' + plural(m.lessons, 'занятие', 'занятия', 'занятий') + ' в программе' : null].filter(Boolean).join(' · '); + return '
' + + '
' + iconTag(m.icon) + '' + + '
' + + '

' + escapeHtml(m.title) + '

' + + (m.teacher ? '

' + escapeHtml(m.teacher) + '

' : '') + + '

' + escapeHtml(meta) + '

' + + '
' + + '
' + + '' + (hasBar ? p + '%' : '—') + '
' + + '
' + foot.join('') + + '' + iconTag('clipboard-list') + escapeHtml(String(m.done)) + ' записей' + + '
'; + }).join(''); +} + +function experienceHtml(v) { + if (!v.experience.length) { + return emptyBlock('briefcase', 'Опыт работы пока не заполнен', + 'Кейсы, стажировки и командные проекты можно добавить в данных профиля ученика — они появятся в этом архиве.'); + } + return v.experience.map(e => { + const media = e.media ? mediaBlock(e.media, '16x9', e.title, -1) : phBlock('Фото кейса', 1280, 720, 'image'); + return '
' + + '
' + media + '
' + + '
' + + '
' + + '' + iconTag('briefcase') + escapeHtml(e.badge) + '' + + (e.date ? '' + iconTag('calendar-days') + escapeHtml(e.date) + '' : '') + + '
' + + '

' + escapeHtml(e.title) + '

' + + (e.company || e.period ? '

' + escapeHtml([e.company, e.period].filter(Boolean).join(' · ')) + '

' : '') + + (e.description ? '

' + escapeHtml(e.description) + '

' : '') + + (e.tags.length ? '
' + e.tags.map(t => '' + iconTag('tag') + escapeHtml(t) + '').join('') + '
' : '') + + '
' + iconTag('user-check') + escapeHtml(e.company || v.name) + '
' + + '
'; + }).join(''); +} + +function reportScript() { + const a = [ + '(function(){', + 'function refresh(){ if(window.lucide && typeof window.lucide.createIcons === "function") window.lucide.createIcons(); }', + 'var root = document.documentElement;', + 'var themeBtn = document.getElementById("themeToggle");', + 'try { var saved = localStorage.getItem("whatido-profile-theme"); if(saved) root.dataset.theme = saved; } catch(e){}', + 'function paintTheme(){ if(!themeBtn) return; themeBtn.innerHTML = \'\'; refresh(); }', + 'paintTheme();', + 'if(themeBtn) themeBtn.addEventListener("click", function(){ root.dataset.theme = root.dataset.theme === "light" ? "dark" : "light"; try { localStorage.setItem("whatido-profile-theme", root.dataset.theme); } catch(e){} paintTheme(); });', + 'var nav = document.getElementById("mainNav");', + 'var burger = document.getElementById("burger");', + 'if(burger && nav){', + ' burger.addEventListener("click", function(){ var open = nav.classList.toggle("is-open"); burger.setAttribute("aria-expanded", open ? "true" : "false"); burger.innerHTML = \'\'; refresh(); });', + ' nav.addEventListener("click", function(e){ if(e.target.closest(".nav-link")) nav.classList.remove("is-open"); });', + '}', + 'var toast = document.getElementById("toast"); var toastTimer;', + 'function showToast(text){ if(!toast) return; toast.textContent = text; toast.classList.add("is-show"); clearTimeout(toastTimer); toastTimer = setTimeout(function(){ toast.classList.remove("is-show"); }, 2600); }', + 'document.querySelectorAll("[data-rail]").forEach(function(rail){', + ' var id = rail.dataset.rail;', + ' var bar = document.querySelector(\'[data-rail-progress="\' + id + \'"]\');', + ' function step(){ var card = rail.firstElementChild; return card ? card.getBoundingClientRect().width + 18 : rail.clientWidth * 0.5; }', + ' function sync(){ if(!bar) return; var max = rail.scrollWidth - rail.clientWidth; var visible = Math.min(rail.clientWidth / rail.scrollWidth, 1); var ratio = max > 0 ? rail.scrollLeft / max : 0; bar.style.width = Math.max(visible * 100, 10) + "%"; bar.style.transform = "translateX(" + (ratio * (100 / Math.max(visible * 100, 10) - 1) * 100) + "%)"; }', + ' rail.addEventListener("scroll", sync, { passive: true });', + ' window.addEventListener("resize", sync);', + ' sync();', + ' var prev = document.querySelector(\'[data-rail-prev="\' + id + \'"]\');', + ' var next = document.querySelector(\'[data-rail-next="\' + id + \'"]\');', + ' if(prev) prev.addEventListener("click", function(){ rail.scrollBy({ left: -step(), behavior: "smooth" }); });', + ' if(next) next.addEventListener("click", function(){ rail.scrollBy({ left: step(), behavior: "smooth" }); });', + ' rail.addEventListener("keydown", function(e){ if(e.key === "ArrowRight"){ e.preventDefault(); rail.scrollBy({ left: step(), behavior: "smooth" }); } if(e.key === "ArrowLeft"){ e.preventDefault(); rail.scrollBy({ left: -step(), behavior: "smooth" }); } });', + '});', + 'var lb = document.getElementById("lb"); var lbMedia = document.getElementById("lbMedia"); var lbCap = document.getElementById("lbCap"); var lbCount = document.getElementById("lbCount");', + 'var cur = 0;', + 'function renderLb(i){ if(!GALLERY.length || !lbMedia) return; cur = (i + GALLERY.length) % GALLERY.length; var m = GALLERY[cur]; lbMedia.innerHTML = ""; var el; if(m.type === "video"){ el = document.createElement("video"); el.src = m.src; el.controls = true; } else { el = document.createElement("img"); el.src = m.src; el.alt = ""; } lbMedia.appendChild(el); if(lbCap) lbCap.textContent = m.caption || ""; if(lbCount) lbCount.textContent = (cur + 1) + " / " + GALLERY.length; }', + 'function openLb(i){ if(!lb) return; renderLb(i); lb.classList.add("open"); }', + 'function closeLb(){ if(!lb) return; lb.classList.remove("open"); if(lbMedia) lbMedia.innerHTML = ""; }', + 'document.addEventListener("click", function(e){', + ' var g = e.target.closest("[data-g]");', + ' if(g){ e.preventDefault(); openLb(parseInt(g.getAttribute("data-g"), 10) || 0); return; }', + ' if(e.target === lb) closeLb();', + ' var act = e.target.closest("[data-action]");', + ' if(act){ var a = act.getAttribute("data-action"); if(a === "print") window.print(); if(a === "top") window.scrollTo({ top: 0, behavior: "smooth" }); }', + '});', + ]; + const b = [ + 'var lbPrev = document.getElementById("lbPrev"); var lbNext = document.getElementById("lbNext"); var lbClose = document.getElementById("lbClose");', + 'if(lbPrev) lbPrev.addEventListener("click", function(e){ e.preventDefault(); e.stopPropagation(); renderLb(cur - 1); });', + 'if(lbNext) lbNext.addEventListener("click", function(e){ e.preventDefault(); e.stopPropagation(); renderLb(cur + 1); });', + 'if(lbClose) lbClose.addEventListener("click", function(e){ e.preventDefault(); e.stopPropagation(); closeLb(); });', + 'document.addEventListener("keydown", function(e){ if(!lb || !lb.classList.contains("open")) return; if(e.key === "Escape") closeLb(); else if(e.key === "ArrowRight") renderLb(cur + 1); else if(e.key === "ArrowLeft") renderLb(cur - 1); });', + 'function animateValue(el){ var raw = String(el.dataset.count); var target = parseFloat(raw.replace(",", ".")); if(isNaN(target)) return; var decimals = raw.indexOf(",") >= 0 ? 1 : 0; var suffix = el.querySelector("small") ? el.querySelector("small").outerHTML : ""; var start = performance.now(); function tick(now){ var p = Math.min((now - start) / 1100, 1); var eased = 1 - Math.pow(1 - p, 3); el.innerHTML = (target * eased).toFixed(decimals).replace(".", ",") + " " + suffix; if(p < 1) requestAnimationFrame(tick); } requestAnimationFrame(tick); }', + 'setTimeout(function(){ document.querySelectorAll("[data-progress]").forEach(function(el){ el.style.width = el.dataset.progress + "%"; }); }, 120);', + 'var targets = Array.prototype.slice.call(document.querySelectorAll(".reveal"));', + 'function reveal(el){ el.classList.add("is-in"); el.querySelectorAll(".bar > i[data-progress]").forEach(function(x){ x.style.width = x.dataset.progress + "%"; }); var counter = el.querySelector("[data-count]"); if(counter) animateValue(counter); }', + 'if(typeof IntersectionObserver !== "function"){ targets.forEach(reveal); }', + 'else {', + ' targets.filter(function(el){ return el.getBoundingClientRect().top < window.innerHeight * 0.92; }).forEach(reveal);', + ' var io = new IntersectionObserver(function(entries){ entries.forEach(function(en){ if(!en.isIntersecting) return; reveal(en.target); io.unobserve(en.target); }); }, { rootMargin: "0px 0px -5% 0px", threshold: 0.05 });', + ' targets.filter(function(el){ return !el.classList.contains("is-in"); }).forEach(function(el){ io.observe(el); });', + ' var spy = new IntersectionObserver(function(entries){ entries.forEach(function(en){ if(!en.isIntersecting) return; document.querySelectorAll(".nav-link").forEach(function(l){ l.setAttribute("aria-current", String(l.dataset.spy === en.target.id)); }); }); }, { rootMargin: "-45% 0px -50% 0px" });', + ' document.querySelectorAll("[data-spy]").forEach(function(link){ var sec = document.getElementById(link.dataset.spy); if(sec) spy.observe(sec); });', + '}', + 'var year = document.getElementById("year"); if(year) year.textContent = new Date().getFullYear();', + 'var CDN = ["https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js", "https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"];', + 'function loadIcons(i){ if(i >= CDN.length){ document.documentElement.classList.add("no-icons"); return; } var s = document.createElement("script"); s.src = CDN[i]; s.async = true; s.onload = refresh; s.onerror = function(){ loadIcons(i + 1); }; document.head.appendChild(s); }', + 'loadIcons(0);', + '})();', + ]; + return a.concat(b).join('\n'); +} + +function renderStudentReport(data, opts) { + const v = buildProfileView(data, opts); + const generatedAt = data.generatedAt || new Date(); + const periodChip = data.period ? '' + iconTag('calendar-range') + escapeHtml(data.period) + '' : ''; + const sections = []; + sections.push('
' + heroHtml(v) + '
'); + sections.push('
' + + sectionHead('activity', 'Информация об авторе', 'Показатели ученика по данным журнала занятий и архива работ.') + + (periodChip ? '
' + periodChip + '
' : '') + + '
' + statsHtml(v) + '
'); + sections.push('
' + + sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') + + '
' + photosHtml(v) + '
'); + sections.push('
' + + sectionHead('book-open', 'Образование', 'Темы программы, которые проходил ученик, и прогресс по ним.') + + '
' + educationHtml(v) + '
'); + sections.push('
' + + railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') + + '
' + + experienceHtml(v) + '
'); + sections.push('
' + + sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые ученик уже проходил.') + + '
' + skillsHtml(v) + '
'); + sections.push('
' + + railHead('folder-git-2', 'Работы', 'Файлы, прикреплённые учеником к записям занятий.', 'projectsRail') + + '
' + + projectsHtml(v) + '
'); + sections.push('
' + + sectionHead('list-checks', 'Занятия', 'Записи журнала: что ученик делал, фотографии и файлы занятий.') + + '
' + tasksHtml(v) + '
'); + sections.push('
' + + sectionHead('info', 'Немного о себе', 'Рассказ ученика, контакты и достижения.') + + '
' + aboutHtml(v) + '
'); + + const topbar = '
' + + '' + + 'АкадемияПрофиль ученика' + + '' + + '
' + + '' + + '' + + '
' + iconTag('user-round') + '' + + '' + escapeHtml(chipInit(v.name)) + '
' + + '
'; + + const lightbox = '
' + + '' + + '' + + '' + + '
'; + + const galleryJson = JSON.stringify(v.gallery).replace(/'; + + return '\n\n\n' + + '\n\n' + + '' + escapeHtml(v.name) + ' — профиль ученика\n' + + '\n' + + '\n' + + '\n' + + '\n\n\n' + + topbar + '\n
\n' + sections.join('\n') + '\n' + footerHtml(v, generatedAt) + '\n
\n' + + '
\n' + + lightbox + '\n' + script + '\n\n'; +} + +module.exports = { createZipWriter, renderStudentReport }; \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..af8a6e6 --- /dev/null +++ b/todo.md @@ -0,0 +1,78 @@ +# TODO: профиль ученика + ZIP-архив + +## Задача + +1. На `/students.html` у каждого ученика — кнопка **«Профиль»**, которая одним нажатием + отдаёт ZIP-архив со всеми данными ученика (фото, работы/файлы, записи журнала). +2. Сгенерированная страница `index.html` внутри архива должна быть оформлена + **полностью по дизайну `student-profile.html`** (тёмная/светлая тема, токены, + топбар, hero, статистика, образование, опыт, навыки, проекты, уроки, «о себе», футер). +3. Если в БД нет данных для заполнения блоков этой страницы — страница не должна + ломаться или показывать выдуманные данные: блоки рендерятся из того, что есть, + пустые разделы получают аккуратный empty-state, а **данные можно добавить позже** + (без правок кода). + +## План работ + +- [x] Разведка: `server.js` (экспорт `/api/export/student`, `createZipWriter`, + `renderStudentReport`), `public/students.html` + `public/js/students.js`, + `db/init.sql` + `db/migration.sql`, дизайн-макет `student-profile.html`. +- [x] `todo.md` (этот файл). +- [x] БД: у ученика появляются поля для будущего заполнения профиля — + `students.profile JSONB` и `students.photo_path` (`db/init.sql`, `db/migration.sql`). +- [x] Бэкап/восстановление: новые поля сохраняются и восстанавливаются + (`normalizeRestoreData`, `INSERT INTO students`). +- [x] API: `GET/PUT /api/students/:id/profile` (requireAuth + проверка филиала, + валидация структуры профиля, аудит `student.profile.update`). +- [x] Новый модуль `student-report.js`: ZIP-writer (перенесён из `server.js`) + + `renderStudentReport()` — страница профиля ученика по дизайну + `student-profile.html` (самодостаточный HTML+CSS+JS, Lucide через CDN с + fallback-заглушками, лайтбокс, ленты, тема, печать, анимации). +- [x] `server.js`: вызов рендера через модуль, убрать старый рендер из `server.js`, + расширить выборки экспорта (модули, статус ИИ, филиал, `students.profile`). +- [x] `public/students.html` + `public/js/students.js`: кнопка **«Профиль»** + (прямая выгрузка ZIP) и кнопка **«Данные»** (модальное окно заполнения данных + профиля: роль, статус, город, наставник, о себе, цитата, теги, контакты, + достижения, навыки, опыт, прогресс по модулям). +- [x] Архив: `index.html` + `photos/` + `files/` + `data.json` (метаданные профиля). +- [x] Проверка: `node --check`, генерация тестового ZIP и разбор `zipfile` + (структура + наличие разделов профиля в `index.html`). + +## Решения и допущения + +- «index.html» из задачи — это `index.html` внутри ZIP-архива (страница профиля + ученика). Именно он теперь оформлен по `student-profile.html`. Публичный + `public/index.html` (форма отправки записи) не переделывался: это отдельная + страница, и в задаче про неё данных нет. +- Кнопка называется «Профиль» (в задаче опечатка «Профаил»), подпись в подсказке: + «Скачать профиль ученика (ZIP: фото и работы)». +- Дизайн-макет использует Lucide-иконки через CDN; в архиве оставлен тот же подход, + потому что он полностью офлайн-деградирует (класс `no-icons`), как и в макете. +- Данные, которых нет в БД (опыт, навыки, контакты, достижения, прогресс по модулям, + фото), рендерятся из полей `students.profile`; пока их нет — показывается + empty-state с подсказкой, а заполнить их можно в админке («Данные» у ученика). +- В `renderStudentReport` добавлен раздел «Фотографии» (лента в стиле макета) — + без него фото из архива негде было бы посмотреть; ленты и лайтбокс — в стиле + `student-profile.html`. + +## Как применить на существующей базе + +```bash +docker compose exec -T db psql -U app -d whereldo \ + -c "ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);" \ + -c "ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;" +docker compose up -d --build app +``` + +(`db/migration.sql` уже содержит эти же ALTER-команды; на чистой базе `init.sql` +создаёт поля сразу.) + +## Как проверить + +```bash +# 1. Админка → «Ученики»: у каждого ученика кнопки «Профиль» и «Данные» +# 2. «Данные» → заполнить роль/био/навыки/контакты/достижения/опыт → Сохранить +# 3. «Профиль» → скачивается profile_<имя>_<дата>.zip: +# index.html (страница профиля по student-profile.html), photos/, files/, data.json +``` +