Files
WhatIDo/student-report.js
T
2026-09-19 13:06:21 +03:00

1165 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
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 '<i data-lucide="' + iconName(name, 'circle') + '"></i>';
}
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 '<div class="ph ph--' + ratioMod(w, h) + '" style="aspect-ratio:' + w + '/' + h + (style ? ';' + style : '') + '" role="img" aria-label="' +
escapeHtml(label) + ' — место под фото ' + w + '×' + h + '">' +
'<i class="ph__icon" data-lucide="' + iconName(icon, 'image') + '"></i>' +
'<span class="ph__label">' + escapeHtml(label) + '</span>' +
'<span class="ph__size">' + w + ' × ' + h + ' px</span></div>';
}
function mediaBlock(src, mod, alt, g) {
return '<span class="media media--' + mod + '"' + (g >= 0 ? ' data-g="' + g + '"' : '') + '>' +
'<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(alt) + '" loading="lazy" decoding="async"></span>';
}
function videoBlock(src, mod, g) {
return '<span class="media media--' + mod + '"' + (g >= 0 ? ' data-g="' + g + '"' : '') + '>' +
'<video src="' + escapeHtml(src) + '" preload="metadata" muted playsinline></video></span>';
}
function sectionHead(icon, title, hint) {
return '<div class="section__head"><div><h2 class="section__title"><span class="icon-badge">' + iconTag(icon) + '</span>' +
escapeHtml(title) + '</h2>' + (hint ? '<p class="section__hint">' + escapeHtml(hint) + '</p>' : '') + '</div></div>';
}
function railHead(icon, title, hint, railId) {
return '<div class="section__head"><div><h2 class="section__title"><span class="icon-badge">' + iconTag(icon) + '</span>' +
escapeHtml(title) + '</h2>' + (hint ? '<p class="section__hint">' + escapeHtml(hint) + '</p>' : '') + '</div>' +
'<div class="rail-controls"><div class="rail-progress" aria-hidden="true"><i data-rail-progress="' + railId + '"></i></div>' +
'<button class="icon-btn" type="button" data-rail-prev="' + railId + '" aria-label="Назад">' + iconTag('arrow-left') + '</button>' +
'<button class="icon-btn" type="button" data-rail-next="' + railId + '" aria-label="Вперёд">' + iconTag('arrow-right') + '</button></div></div>';
}
function emptyBlock(icon, title, text) {
return '<div class="card empty">' + iconTag(icon) + '<b>' + escapeHtml(title) + '</b>' +
(text ? '<small>' + escapeHtml(text) + '</small>' : '') + '</div>';
}
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('<button class="btn btn--primary" type="button" data-action="print">' + iconTag('printer') + 'Печать / PDF</button>');
const mail = v.contacts.find(c => (c.href || '').toLowerCase().startsWith('mailto:')) || v.contacts.find(c => c.href);
if (mail) actions.push('<a class="btn" href="' + escapeHtml(mail.href) + '">' + iconTag('send') + 'Написать</a>');
if (v.photoCards.length) actions.push('<a class="btn btn--ghost" href="#photos">' + iconTag('images') + 'Фотографии</a>');
const facts = v.facts.length
? v.facts.map(f => '<span class="fact">' + iconTag(f.icon) + '<span>' + escapeHtml(f.text) + '</span></span>').join('')
: '<span class="fact">' + iconTag('info') + '<span>Данные профиля ещё не заполнены</span></span>';
const tags = v.heroTags.length
? v.heroTags.map(t => '<span class="chip chip--brand">' + iconTag('tag') + escapeHtml(t) + '</span>').join('')
: '<span class="chip">' + iconTag('tag') + 'теги не заполнены</span>';
return '<article class="card hero">' +
'<div class="hero__cover">' + cover + '</div>' +
'<div class="hero__grid">' +
'<div class="hero__media"><div class="hero__avatar">' + avatar + '</div>' +
'<span class="hero__status"><span class="hero__pulse"></span>' + escapeHtml(v.status + (v.statusNote ? ' · ' + v.statusNote : '')) + '</span></div>' +
'<div class="hero__info">' +
(eyebrow ? '<span class="hero__eyebrow">' + iconTag('id-card') + escapeHtml(eyebrow) + '</span>' : '') +
'<h1 class="hero__name">' + escapeHtml(v.name) + '</h1>' +
(v.role ? '<p class="hero__role">' + escapeHtml(v.role) + '</p>' : '') +
(v.bio ? '<p class="hero__role" style="font-size:1rem">' + escapeHtml(v.bio) + '</p>' : '') +
'<div class="hero__tags">' + tags + '</div>' +
'<div class="hero__facts">' + facts + '</div>' +
'<div class="hero__actions">' + actions.join('') + '</div>' +
'</div></div></article>';
}
function statsHtml(v) {
return v.stats.map(s => {
const raw = s.raw !== undefined ? s.raw : String(s.value);
return '<article class="card stat reveal">' +
'<div class="stat__top"><span class="stat__icon">' + iconTag(s.icon) + '</span>' +
(s.delta ? '<span class="stat__delta">' + iconTag('trending-up') + escapeHtml(s.delta) + '</span>' : '') +
'</div>' +
'<p class="stat__value" data-count="' + escapeHtml(raw) + '">' + escapeHtml(String(s.value)) +
(s.suffix ? ' <small>' + escapeHtml(s.suffix) + '</small>' : '') + '</p>' +
'<p class="stat__label">' + escapeHtml(s.label) + '</p>' +
(s.hint ? '<p class="stat__hint">' + escapeHtml(s.hint) + '</p>' : '') +
'</article>';
}).join('');
}
function skillsHtml(v) {
if (!v.skillGroups.length) {
return emptyBlock('sparkles', 'Навыки пока не заполнены',
'Список навыков и технологий можно добавить в данных профиля ученика.');
}
return v.skillGroups.map(g => '<article class="card skill-group reveal">' +
'<div class="skill-group__head">' + iconTag(g.icon) + '<span>' + escapeHtml(g.title) + '</span>' +
'<span class="skill-group__count">' + g.items.length + ' ' + plural(g.items.length, 'пункт', 'пункта', 'пунктов') + '</span></div>' +
'<div class="skill-list">' + g.items.map(it => {
const has = it.value !== null && it.value !== undefined;
return '<div class="skill"><div class="skill__top">' +
'<span class="skill__name">' + escapeHtml(it.name) + '</span>' +
'<span class="skill__val">' + escapeHtml([it.level, has ? it.value + '%' : null].filter(Boolean).join(' · ') || '—') + '</span>' +
'</div><div class="bar' + (has ? '' : ' bar--muted') + '"><i data-progress="' + (has ? it.value : 0) + '"></i></div></div>';
}).join('') + '</div></article>').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 => '<article class="card prj-card">' +
projectMedia(p) +
'<div class="prj-card__body">' +
'<span class="badge badge--info">' + iconTag(p.icon) + escapeHtml(p.ext || 'Файл') + '</span>' +
'<h3 class="prj-card__title">' + escapeHtml(p.title) + '</h3>' +
(p.description ? '<p class="prj-card__text">' + escapeHtml(p.description) + '</p>' : '') +
(p.tags.length ? '<div class="prj-card__tags">' + p.tags.map(t => '<span class="chip">' + escapeHtml(t) + '</span>').join('') + '</div>' : '') +
'<div class="prj-card__foot"><span class="prj-card__stats">' +
(p.date ? '<span>' + iconTag('calendar-days') + escapeHtml(p.date) + '</span>' : '') +
(p.size ? '<span>' + iconTag('hard-drive') + escapeHtml(p.size) + '</span>' : '') +
'</span><a class="btn btn--sm" href="' + escapeHtml(p.href) + '"' + (p.g >= 0 ? ' data-g="' + p.g + '"' : ' target="_blank" rel="noopener"') + '>' +
iconTag(p.g >= 0 ? 'maximize-2' : 'external-link') + (p.g >= 0 ? 'Просмотр' : 'Открыть') + '</a></div>' +
'</div></article>').join('');
}
function photosHtml(v) {
if (!v.photoCards.length) {
return emptyBlock('image', 'Фотографий пока нет',
'Фотографии занятий появятся здесь, как только ученик отправит их вместе с записью.');
}
return v.photoCards.map(p => '<figure class="card photo-card reveal">' +
mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) +
(p.caption || p.date || p.group
? '<figcaption>' + (p.caption ? '<b>' + escapeHtml(p.caption) + '</b>' : '') +
(p.date || p.group ? '<span>' + escapeHtml([p.date, p.group].filter(Boolean).join(' · ')) + '</span>' : '') + '</figcaption>'
: '') +
'</figure>').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 = ['<span class="badge ' + (t.done ? 'badge--ok' : (t.icon === 'triangle-alert' ? 'badge--muted' : 'badge--progress')) + '">' + escapeHtml(t.statusLabel) + '</span>'];
if (t.thumbs.length) right.push('<div class="task__thumbs">' + t.thumbs.map(x => '<img src="' + escapeHtml(x.src) + '" alt="фото" data-g="' + x.g + '" loading="lazy">').join('') + '</div>');
if (t.files.length) right.push('<div class="task__extra">' + t.files.map(f => '<a href="' + escapeHtml(f.href) + '"' +
(f.g >= 0 ? ' data-g="' + f.g + '"' : ' target="_blank" rel="noopener"') + '>' + iconTag(f.icon) + escapeHtml(truncate(f.title, 28)) + '</a>').join('') + '</div>');
return '<article class="card task reveal">' +
'<span class="task__check' + checkCls + '">' + iconTag(t.icon) + '</span>' +
'<div class="task__main"><p class="task__title">' + escapeHtml(t.title) + '</p>' +
(t.sub ? '<p class="task__sub">' + escapeHtml(t.sub) + '</p>' : '') + '</div>' +
'<div class="task__right">' + right.join('') + '</div>' +
'</article>';
}).join('');
}
function aboutHtml(v) {
const bio = '<article class="card about__bio reveal">' +
'<h3 class="section__title" style="font-size:1.15rem">' + iconTag('user-round') + 'Коротко о себе</h3>' +
(v.bio ? '<p class="about__text">' + escapeHtml(v.bio) + '</p>'
: '<p class="about__text">Здесь появится рассказ ученика о себе. Добавьте текст в данных профиля.</p>') +
(v.quote ? '<p class="about__quote">«' + escapeHtml(v.quote) + '»</p>' : '') +
(v.facts.length ? '<div class="hero__facts">' + v.facts.map(f => '<span class="fact">' + iconTag(f.icon) + '<span>' + escapeHtml(f.text) + '</span></span>').join('') + '</div>' : '') +
'</article>';
const photoCard = '<article class="card about__card reveal"><h3>' + iconTag('image') + 'Фото автора</h3>' +
(v.avatar
? '<div class="about__photo"><img src="' + escapeHtml(v.avatar.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.avatar.g + '" loading="lazy"></div>'
: phBlock('Фото автора', 800, 600, 'image')) +
'<p class="meta">' + iconTag('info') + (v.avatar ? 'Фотография из архива занятий' : 'Место под реальное фото автора профиля') + '</p></article>';
const contactsCard = '<article class="card about__card reveal"><h3>' + iconTag('at-sign') + 'Контакты</h3>' +
(v.contacts.length
? '<div class="contacts">' + v.contacts.map(c => (c.href
? '<a class="contact" href="' + escapeHtml(c.href) + '">' + iconTag(c.icon) + escapeHtml(c.label) + '</a>'
: '<span class="contact">' + iconTag(c.icon) + escapeHtml(c.label) + '</span>')).join('') + '</div>'
: '<p class="meta">' + iconTag('info') + 'Контакты не заполнены</p>') + '</article>';
const achCard = '<article class="card about__card reveal"><h3>' + iconTag('trophy') + 'Достижения</h3>' +
(v.achievements.length
? '<ul class="about__list">' + v.achievements.map(a => '<li>' + iconTag('circle-check-big') + '<span>' + escapeHtml(a) + '</span></li>').join('') + '</ul>'
: '<p class="meta">' + iconTag('info') + 'Достижения пока не отмечены</p>') + '</article>';
return bio + '<div class="about__side">' + photoCard + contactsCard + achCard + '</div>';
}
function footerHtml(v, generatedAt) {
const links = [];
if (v.entries.length) links.push('<a href="#lessons">Занятия</a>');
if (v.photoCards.length) links.push('<a href="#photos">Фотографии</a>');
if (v.projects.length) links.push('<a href="#projects">Работы</a>');
links.push('<a href="#top">Наверх</a>');
return '<footer class="footer"><p>© <span id="year"></span> Академия · профиль ученика ' + escapeHtml(v.name) +
' · сформировано ' + escapeHtml(longDate(generatedAt)) + '</p>' +
'<nav class="footer__links" aria-label="Футер">' + links.join('') + '</nav></footer>';
}
function navItems(v) {
const items = ['<a class="nav-link" href="#profile" data-spy="profile">' + iconTag('user-round') + '<span>Профиль</span></a>'];
if (v.photoCards.length) items.push('<a class="nav-link" href="#photos" data-spy="photos">' + iconTag('images') + '<span>Фотографии</span></a>');
items.push('<a class="nav-link" href="#education" data-spy="education">' + iconTag('book-open') + '<span>Образование</span></a>');
if (v.experience.length) items.push('<a class="nav-link" href="#experience" data-spy="experience">' + iconTag('briefcase') + '<span>Опыт</span></a>');
if (v.skillGroups.length) items.push('<a class="nav-link" href="#skills" data-spy="skills">' + iconTag('sparkles') + '<span>Навыки</span></a>');
if (v.projects.length) items.push('<a class="nav-link" href="#projects" data-spy="projects">' + iconTag('folder-git-2') + '<span>Работы</span></a>');
if (v.tasks.length) items.push('<a class="nav-link" href="#lessons" data-spy="lessons">' + iconTag('list-checks') + '<span>Занятия</span></a>');
items.push('<a class="nav-link" href="#about" data-spy="about">' + iconTag('info') + '<span>О себе</span></a>');
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
? '<span class="badge badge--muted">' + iconTag('circle-dashed') + 'Нет данных</span>'
: (p >= 100 ? '<span class="badge badge--ok">' + iconTag('badge-check') + 'Пройден</span>'
: '<span class="badge badge--progress">' + iconTag('loader') + 'В процессе</span>'));
if (m.grade) foot.push('<span class="module__grade">' + iconTag('award') + ' ' + escapeHtml(m.grade) + '</span>');
const meta = [m.code, m.period, m.lessons ? m.lessons + ' ' + plural(m.lessons, 'занятие', 'занятия', 'занятий') + ' в программе' : null].filter(Boolean).join(' · ');
return '<article class="card module reveal">' +
'<div class="module__head"><span class="module__icon">' + iconTag(m.icon) + '</span>' +
'<div style="display:flex;flex-direction:column;gap:4px">' +
'<h3 class="module__title">' + escapeHtml(m.title) + '</h3>' +
(m.teacher ? '<p class="module__sub">' + escapeHtml(m.teacher) + '</p>' : '') +
'<p class="module__code">' + escapeHtml(meta) + '</p>' +
'</div></div>' +
'<div class="module__progress"><div class="bar' + barCls + '" style="flex:1"><i data-progress="' + (hasBar ? p : 0) + '"></i></div>' +
'<b>' + (hasBar ? p + '%' : '—') + '</b></div>' +
'<div class="module__foot">' + foot.join('') +
'<span class="meta">' + iconTag('clipboard-list') + escapeHtml(String(m.done)) + ' записей</span>' +
'</div></article>';
}).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 '<article class="card exp-card">' +
'<div class="exp-card__media">' + media + '<span class="exp-card__logo">' + iconTag('briefcase') + '</span></div>' +
'<div class="exp-card__body">' +
'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
'<span class="badge badge--info">' + iconTag('briefcase') + escapeHtml(e.badge) + '</span>' +
(e.date ? '<span class="meta">' + iconTag('calendar-days') + escapeHtml(e.date) + '</span>' : '') +
'</div>' +
'<h3 class="exp-card__title">' + escapeHtml(e.title) + '</h3>' +
(e.company || e.period ? '<p class="exp-card__company">' + escapeHtml([e.company, e.period].filter(Boolean).join(' · ')) + '</p>' : '') +
(e.description ? '<p class="exp-card__text">' + escapeHtml(e.description) + '</p>' : '') +
(e.tags.length ? '<div class="hero__tags">' + e.tags.map(t => '<span class="chip">' + iconTag('tag') + escapeHtml(t) + '</span>').join('') + '</div>' : '') +
'<div class="exp-card__foot"><span class="meta">' + iconTag('user-check') + escapeHtml(e.company || v.name) + '</span></div>' +
'</div></article>';
}).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 = \'<i data-lucide="\' + (root.dataset.theme === "light" ? "sun" : "moon") + \'"></i>\'; 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 = \'<i data-lucide="\' + (open ? "x" : "menu") + \'"></i>\'; 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 ? '<span class="chip chip--brand">' + iconTag('calendar-range') + escapeHtml(data.period) + '</span>' : '';
const sections = [];
sections.push('<section class="section" id="profile">' + heroHtml(v) + '</section>');
sections.push('<section class="section" id="stats">' +
sectionHead('activity', 'Информация об авторе', 'Показатели ученика по данным журнала занятий и архива работ.') +
(periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') +
'<div class="stats">' + statsHtml(v) + '</div></section>');
sections.push('<section class="section" id="photos">' +
sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') +
'<div class="photos">' + photosHtml(v) + '</div></section>');
sections.push('<section class="section" id="education">' +
sectionHead('book-open', 'Образование', 'Темы программы, которые проходил ученик, и прогресс по ним.') +
'<div class="modules">' + educationHtml(v) + '</div></section>');
sections.push('<section class="section" id="experience">' +
railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') +
'<div class="rail-wrap"><div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0" aria-label="Лента опыта">' +
experienceHtml(v) + '</div></div></section>');
sections.push('<section class="section" id="skills">' +
sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые ученик уже проходил.') +
'<div class="skills">' + skillsHtml(v) + '</div></section>');
sections.push('<section class="section" id="projects">' +
railHead('folder-git-2', 'Работы', 'Файлы, прикреплённые учеником к записям занятий.', 'projectsRail') +
'<div class="rail-wrap"><div class="rail rail--third" id="projectsRail" data-rail="projectsRail" tabindex="0" aria-label="Лента работ">' +
projectsHtml(v) + '</div></div></section>');
sections.push('<section class="section" id="lessons">' +
sectionHead('list-checks', 'Занятия', 'Записи журнала: что ученик делал, фотографии и файлы занятий.') +
'<div class="tasks">' + tasksHtml(v) + '</div></section>');
sections.push('<section class="section" id="about">' +
sectionHead('info', 'Немного о себе', 'Рассказ ученика, контакты и достижения.') +
'<div class="about">' + aboutHtml(v) + '</div></section>');
const topbar = '<header class="topbar" id="top"><div class="topbar__inner">' +
'<a class="brand" href="#top"><span class="brand__logo">' + iconTag('graduation-cap') + '</span>' +
'<span class="brand__text">Академия<small>Профиль ученика</small></span></a>' +
'<nav class="topbar__nav" id="mainNav" aria-label="Меню профиля">' + navItems(v) + '</nav>' +
'<div class="topbar__actions">' +
'<button class="icon-btn" type="button" data-action="print" title="Печать / сохранить в PDF">' + iconTag('printer') + '</button>' +
'<button class="icon-btn" id="themeToggle" type="button" title="Светлая / тёмная тема">' + iconTag('moon') + '</button>' +
'<div class="user-chip" title="Ученик"><span class="user-chip__ph">' + iconTag('user-round') + '</span>' +
'<span class="user-chip__name">' + escapeHtml(chipInit(v.name)) + '</span></div>' +
'<button class="icon-btn topbar__burger" id="burger" type="button" aria-expanded="false" aria-controls="mainNav" aria-label="Открыть меню">' +
iconTag('menu') + '</button></div></div></header>';
const lightbox = '<div class="lb" id="lb"><div class="lb__media" id="lbMedia"></div>' +
'<span class="lb__cap" id="lbCap"></span><span class="lb__count" id="lbCount"></span>' +
'<button class="lb__btn lb__prev" id="lbPrev" type="button" aria-label="Назад">' + iconTag('arrow-left') + '</button>' +
'<button class="lb__btn lb__next" id="lbNext" type="button" aria-label="Вперёд">' + iconTag('arrow-right') + '</button>' +
'<button class="lb__btn lb__close" id="lbClose" type="button" aria-label="Закрыть">' + iconTag('x') + '</button></div>';
const galleryJson = JSON.stringify(v.gallery).replace(/</g, '\\u003c');
const script = '<script>\nvar GALLERY = ' + galleryJson + ';\n' + reportScript() + '\n</script>';
return '<!DOCTYPE html>\n<html lang="ru" data-theme="dark">\n<head>\n' +
'<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
'<title>' + escapeHtml(v.name) + ' — профиль ученика</title>\n' +
'<link rel="preconnect" href="https://fonts.googleapis.com">\n' +
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n' +
'<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">\n' +
'<style>' + reportCss() + '</style>\n</head>\n<body>\n' +
topbar + '\n<main class="wrap page">\n' + sections.join('\n') + '\n' + footerHtml(v, generatedAt) + '\n</main>\n' +
'<div class="toast" id="toast" role="status" aria-live="polite"></div>\n' +
lightbox + '\n' + script + '\n</body>\n</html>';
}
module.exports = { createZipWriter, renderStudentReport };