feat(students): multi-photo gallery, group photos in export, KIBERone rebrand

This commit is contained in:
dev
2026-09-23 13:32:09 +03:00
parent 5e2533b876
commit 4623358f21
8 changed files with 475 additions and 105 deletions
+89 -45
View File
@@ -248,7 +248,7 @@ function buildProfileView(data, opts) {
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 name = str(data.name, 150) || str(student.name, 150) || 'Резидент';
const profContacts = arr(profile.contacts);
const profAchievements = arr(profile.achievements);
@@ -281,6 +281,19 @@ function buildProfileView(data, opts) {
}
}
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, []);
@@ -375,12 +388,12 @@ function buildProfileView(data, opts) {
});
const stats = [];
if (showEntries) stats.push({ icon: 'clipboard-list', value: entries.length, suffix: plural(entries.length, 'запись', 'записи', 'записей'), label: 'Записей в журнале', hint: 'что делал ученик на занятиях' });
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: 'от первой записи в журнале' });
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) : '';
@@ -439,29 +452,31 @@ function buildProfileView(data, opts) {
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);
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 (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 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, tasks, gallery, groups, stats,
name, student, profile, entries, education, projects, photoCards, groupPhotoCards, tasks, gallery, groups, stats,
skillGroups, experience, contacts, achievements, facts, heroTags,
avatar, cover, idLabel,
role: str(profile.role, 200) || (education.length ? 'Ученик программы: ' + education[0].title : null),
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) || 'Активно учится',
@@ -563,10 +578,11 @@ svg{display:block;flex:none}
.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}
.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;padding-top:clamp(10px,2vw,26px)}
.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}
@@ -667,11 +683,11 @@ svg{display:block;flex:none}
.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__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{display:flex;flex-wrap:wrap;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}
@@ -790,12 +806,16 @@ function emptyBlock(icon, title, text) {
}
function heroHtml(v) {
const cover = v.cover
? mediaBlock(v.cover.src, 'wide', 'Обложка профиля', v.cover.g)
: phBlock('Обложка профиля', 1440, 480, 'wallpaper', 'border-radius:var(--radius-sm)');
const avatar = v.avatar
? mediaBlock(v.avatar.src, 'square', v.name, v.avatar.g)
: phBlock('Фото ученика', 480, 480, 'user-round');
const 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>');
@@ -842,7 +862,7 @@ function statsHtml(v) {
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>' +
@@ -865,7 +885,7 @@ function projectMedia(p) {
function projectsHtml(v) {
if (!v.projects.length) {
return emptyBlock('folder-git-2', 'Работ в портфолио пока нет',
'Файлы появятся здесь, как только ученик прикрепит их к записи занятия.');
'Файлы появятся здесь, как только резидент прикрепит их к записи занятия.');
}
return v.projects.map(p => '<article class="card prj-card">' +
projectMedia(p) +
@@ -885,7 +905,7 @@ function projectsHtml(v) {
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) +
@@ -896,22 +916,37 @@ function photosHtml(v) {
'</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) 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>');
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('');
}
@@ -920,16 +955,20 @@ 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>') +
: '<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>';
(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
@@ -950,9 +989,10 @@ 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> Академия · профиль ученика ' + escapeHtml(v.name) +
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>';
}
@@ -960,6 +1000,7 @@ function footerHtml(v, generatedAt) {
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>');
@@ -972,7 +1013,7 @@ function navItems(v) {
function educationHtml(v) {
if (!v.education.length) {
return emptyBlock('book-open', 'Темы программы ещё не заполнены',
'Добавьте темы модулей в админке (раздел «Темы модулей») или отметьте пройденные модули в данных профиля ученика.');
'Добавьте темы модулей в админке (раздел «Темы модулей») или отметьте пройденные модули в данных профиля резидента.');
}
return v.education.map(m => {
const p = m.progress;
@@ -1003,7 +1044,7 @@ function educationHtml(v) {
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');
@@ -1102,41 +1143,44 @@ function renderStudentReport(data, opts) {
const sections = [];
sections.push('<section class="section" id="profile">' + heroHtml(v) + '</section>');
sections.push('<section class="section" id="stats">' +
sectionHead('activity', 'Информация об авторе', 'Показатели ученика по данным журнала занятий и архива работ.') +
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', 'Образование', 'Темы программы, которые проходил ученик, и прогресс по ним.') +
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', 'Навыки', 'Темы и технологии, которые ученик уже проходил.') +
sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые резидент уже проходил.') +
'<div class="skills">' + skillsHtml(v) + '</div></section>');
sections.push('<section class="section" id="projects">' +
railHead('folder-git-2', 'Работы', 'Файлы, прикреплённые учеником к записям занятий.', 'projectsRail') +
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', 'Занятия', 'Записи журнала: что ученик делал, фотографии и файлы занятий.') +
sectionHead('list-checks', 'Занятия', 'Записи журнала: что резидент делал, фотографии и файлы занятий.') +
'<div class="tasks">' + tasksHtml(v) + '</div></section>');
sections.push('<section class="section" id="about">' +
sectionHead('info', 'Немного о себе', 'Рассказ ученика, контакты и достижения.') +
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>' +
'<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>' +
'<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>';
@@ -1152,7 +1196,7 @@ function renderStudentReport(data, opts) {
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' +
'<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' +