1209 lines
82 KiB
JavaScript
1209 lines
82 KiB
JavaScript
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, '"').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 groupPhotoCards = arr(data.groupPhotos).map(gp => {
|
||
const src = 'photos/' + gp.stored;
|
||
const g = galleryAdd('image', src, gp.caption || '');
|
||
const when = gp.takenAt || gp.createdAt;
|
||
return {
|
||
src,
|
||
g,
|
||
caption: showCaptions ? (str(gp.caption, 160) || '') : '',
|
||
date: showDates && when ? longDate(when) : '',
|
||
group: str(gp.groupName, 80) || '',
|
||
};
|
||
});
|
||
|
||
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: 'В KIBERone', 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 ? 'В KIBERone с ' + 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[1] || photoCards[0]) : null;
|
||
const cover = showPhotos && photoCards.length ? photoCards[0] : null;
|
||
const mainPhotoCard = showPhotos && groupPhotoCards.length ? groupPhotoCards[0] : null;
|
||
const profilePhotoCard = showPhotos && photoCards.length ? photoCards[0] : 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, groupPhotoCards, tasks, gallery, groups, stats,
|
||
skillGroups, experience, contacts, achievements, facts, heroTags,
|
||
avatar, cover, idLabel, mainPhotoCard, profilePhotoCard,
|
||
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(24px,3vw,40px);position:relative;z-index:0}
|
||
.hero__cover .media{border-radius:var(--radius-sm)}
|
||
.hero__grid{display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);gap:clamp(18px,2.6vw,34px);align-items:end;position:relative;z-index:1}
|
||
@media (max-width:900px){.hero__grid{grid-template-columns:1fr;align-items:start}.hero__cover{display:none}}
|
||
.hero__media{position:relative;margin-top:clamp(-72px,-5vw,-48px);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;width:100%;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{grid-column:1 / -1;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.mainPhotoCard
|
||
? mediaBlock(v.mainPhotoCard.src, 'wide', 'Обложка профиля', v.mainPhotoCard.g)
|
||
: (v.cover
|
||
? mediaBlock(v.cover.src, 'wide', 'Обложка профиля', v.cover.g)
|
||
: phBlock('Обложка профиля', 1440, 480, 'wallpaper', 'border-radius:var(--radius-sm)'));
|
||
const avatar = v.profilePhotoCard
|
||
? mediaBlock(v.profilePhotoCard.src, 'square', v.name, v.profilePhotoCard.g)
|
||
: (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 groupPhotosHtml(v) {
|
||
if (!v.groupPhotoCards.length) {
|
||
return emptyBlock('images', 'Фото группы пока нет',
|
||
'Фотографии группы появятся здесь, как только их добавят в галерее группы.');
|
||
}
|
||
return v.groupPhotoCards.map(p => '<figure class="card photo-card reveal">' +
|
||
mediaBlock(p.src, '4x3', (p.group || 'Группа') + ' — фото', 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) var extra = '<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>' +
|
||
(extra || '') +
|
||
'</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.profilePhotoCard
|
||
? '<div class="about__photo"><img src="' + escapeHtml(v.profilePhotoCard.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.profilePhotoCard.g + '" loading="lazy"></div>'
|
||
: (v.mainPhotoCard
|
||
? '<div class="about__photo"><img src="' + escapeHtml(v.mainPhotoCard.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.mainPhotoCard.g + '" loading="lazy"></div>'
|
||
: (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.profilePhotoCard || v.mainPhotoCard || 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.groupPhotoCards.length) links.push('<a href="#group-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> KIBERone · профиль резидента ' + 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>');
|
||
if (v.groupPhotoCards.length) items.push('<a class="nav-link" href="#group-photos" data-spy="group-photos">' + iconTag('users-round') + '<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="group-photos">' +
|
||
sectionHead('users-round', 'Фотографии группы', 'Снимки из хроники группы резидента. Нажмите на фотографию, чтобы открыть её крупнее.') +
|
||
'<div class="photos">' + groupPhotosHtml(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">KIBERone<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 }; |