feat(export): фото ученика и логотип в экспорте профиля

- /api/export/student: выборка student_photos, отдельный блок photos/ и
  метаданные student_photos в data.json, счётчик в totals
- логотип из system_logo кладётся в корень архива и подставляется в шапку
- student-report.js: секция «Фотографии ученика», логотип в топбаре,
  навигация ограничена 5 пунктами, пустые секции (Опыт работы) не рендерятся
- главное фото профиля/обложки приоритетно берётся из личных фото ученика
This commit is contained in:
dev
2026-10-03 22:57:35 +03:00
parent 2377707297
commit 0dc8ffdb87
2 changed files with 160 additions and 35 deletions
+51 -5
View File
@@ -3992,10 +3992,25 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
}
const student = studRes.rows[0] || { name };
const studentPhotosRes = student.id
? await pool.query('SELECT photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC', [student.id])
: { rows: [] };
const zip = createZipWriter();
if (opts.includePhotos) zip.addDir('photos');
if (opts.includeFiles) zip.addDir('files');
let logoFile = null;
const logoSetting = await getSetting('system_logo', '');
if (isSafeUploadPath(logoSetting)) {
const logoKey = logoSetting.slice('/uploads/'.length);
const logoData = await storage.getBuffer(logoKey);
if (logoData) {
const logoExt = (path.extname(logoKey) || '.png').toLowerCase();
logoFile = 'logo' + (/^\.[a-z0-9]{1,5}$/.test(logoExt) ? logoExt : '.png');
zip.addFile(logoFile, logoData, new Date());
}
}
const seenPhotos = new Set();
const photosBuilt = [];
async function addPhoto(p) {
@@ -4026,11 +4041,39 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
for (const p of photosRes.rows) await addPhoto(p);
for (const m of mainsRes.rows) await addPhoto(m);
photosBuilt.sort((a, b) => new Date(b.createdAt || Date.now()) - new Date(a.createdAt || Date.now()));
if (avatarStored) {
if (!seenPhotos.has(avatarStored)) await addPhoto({ photo_path: '/uploads/' + avatarStored, caption: 'Фото резидента', created_at: student.created_at });
const idx = photosBuilt.findIndex(p => p.stored === avatarStored);
if (idx > 0) photosBuilt.unshift(photosBuilt.splice(idx, 1)[0]);
}
const studentPhotosBuilt = [];
if (opts.includePhotos) {
const mainPath = (student.profile && student.profile.photo_path) || student.photo_path || null;
for (const sp of studentPhotosRes.rows) {
if (!isSafeUploadPath(sp.photo_path)) continue;
const stored = sp.photo_path.slice('/uploads/'.length);
if (studentPhotosBuilt.some(p => p.stored === stored)) continue;
if (!seenPhotos.has(stored)) {
const data = await storage.getBuffer(stored);
if (!data) continue;
zip.addFile('photos/' + stored, data, sp.created_at ? new Date(sp.created_at) : new Date());
seenPhotos.add(stored);
}
studentPhotosBuilt.push({
stored,
createdAt: sp.created_at || null,
main: sp.photo_path === mainPath,
});
}
if (avatarStored && !studentPhotosBuilt.some(p => p.stored === avatarStored)) {
if (!seenPhotos.has(avatarStored)) {
const data = await storage.getBuffer(avatarStored);
if (data) {
zip.addFile('photos/' + avatarStored, data, student.created_at ? new Date(student.created_at) : new Date());
seenPhotos.add(avatarStored);
}
}
studentPhotosBuilt.unshift({ stored: avatarStored, createdAt: student.created_at || null, main: true });
}
const mi = studentPhotosBuilt.findIndex(p => p.main);
if (mi > 0) studentPhotosBuilt.unshift(studentPhotosBuilt.splice(mi, 1)[0]);
}
const groupPhotosBuilt = [];
@@ -4106,9 +4149,11 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
modules: modulesRes.rows,
photos: photosBuilt,
groupPhotos: groupPhotosBuilt,
studentPhotos: studentPhotosBuilt,
files: filesBuilt,
generatedAt: new Date(),
period,
logo: logoFile,
};
const html = renderStudentReport(reportData, opts);
zip.addFile('index.html', Buffer.from(html, 'utf8'));
@@ -4117,10 +4162,11 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
student: reportData.student,
profile: student.profile || null,
period: period || null,
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length },
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length, studentPhotos: studentPhotosBuilt.length },
options: opts,
entries: entryRows.map(e => ({ id: e.id, group: e.group_name, module: e.module_name || null, created_at: e.created_at, ai_status: e.ai_status, description: e.description })),
photos: photosBuilt.map(p => ({ file: 'photos/' + p.stored, caption: p.caption, created_at: p.createdAt, entry_id: p.entryId })),
student_photos: studentPhotosBuilt.map(p => ({ file: 'photos/' + p.stored, main: !!p.main, created_at: p.createdAt })),
files: filesBuilt.map(f => ({ file: 'files/' + f.saved, name: f.original, size: f.size, created_at: f.createdAt, entry_id: f.entryId })),
};
zip.addFile('data.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8'));
+109 -30
View File
@@ -207,6 +207,7 @@ function safeHref(value) {
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 NAV_LIMIT = 5;
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',
@@ -294,6 +295,18 @@ function buildProfileView(data, opts) {
};
});
const studentPhotoCards = showPhotos ? arr(data.studentPhotos).map(sp => {
const src = 'photos/' + sp.stored;
const g = galleryAdd('image', src, sp.main ? 'Главное фото' : 'Фото ученика');
return {
src,
g,
date: showDates ? longDate(sp.createdAt) : '',
main: !!sp.main,
student: true,
};
}) : [];
const photoByEntry = new Map();
for (const p of photos) {
if (!photoByEntry.has(p.entryId)) photoByEntry.set(p.entryId, []);
@@ -465,18 +478,20 @@ function buildProfileView(data, opts) {
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 mainStudentCard = studentPhotoCards.find(p => p.main) || studentPhotoCards[0] || null;
const avatar = showPhotos ? (photoCards[1] || photoCards[0] || mainStudentCard) : null;
const cover = showPhotos ? (photoCards[0] || mainStudentCard) : null;
const mainPhotoCard = showPhotos && groupPhotoCards.length ? groupPhotoCards[0] : null;
const profilePhotoCard = showPhotos && photoCards.length ? photoCards[0] : null;
const profilePhotoCard = showPhotos ? (mainStudentCard || photoCards[0] || null) : null;
const eduYears = education.map(m => m.period).filter(Boolean);
const idLabel = student.id ? 'ID S-' + String(student.id).padStart(4, '0') : null;
const logo = /^[a-zA-Z0-9._-]+\.[a-zA-Z0-9]{1,8}$/.test(String(data.logo || '')) ? String(data.logo) : null;
return {
name, student, profile, entries, education, projects, photoCards, groupPhotoCards, tasks, gallery, groups, stats,
name, student, profile, entries, education, projects, photoCards, groupPhotoCards, studentPhotoCards, 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),
avatar, cover, idLabel, mainPhotoCard, profilePhotoCard, logo,
role: str(profile.role, 200),
bio: str(profile.bio, 2000) || (autoBio.join(' ') || null),
quote: str(profile.quote, 300),
status: str(profile.status, 60) || 'Активно учится',
@@ -519,9 +534,10 @@ svg{display:block;flex:none}
.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{display:flex;align-items:center;gap:12px;font-weight:800;letter-spacing:-.02em;min-width:0}
.brand__logo{height:44px;flex:0 1 auto;min-width:0;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;box-shadow:0 10px 24px rgba(109,140,255,.35);overflow:hidden}
.brand__logo svg{width:22px;height:22px;stroke-width:2.2;flex:none}
.brand__logo img{max-width:min(40vw,240px);max-height:44px;width:auto;height:auto;object-fit:contain;display:block}
.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}
@@ -612,7 +628,7 @@ svg{display:block;flex:none}
.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}}
@media (max-width:760px){.modules{grid-template-columns:1fr}}
.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%}
@@ -636,7 +652,8 @@ svg{display:block;flex:none}
.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)}}
@media (max-width:720px){.rail{gap:12px;padding-bottom:12px}.rail--half>*{flex:0 0 calc(50% - 6px)}}
@media (max-width:720px){#projectsRail>*{flex:0 0 100%}}
.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}
@@ -660,12 +677,18 @@ svg{display:block;flex:none}
.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}}
@media (max-width:620px){.photos{grid-template-columns: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}
.sp-list{display:flex;flex-direction:column;gap:var(--gap)}
.prj-card--row{flex-direction:row;align-items:stretch}
.prj-card--row>.media{flex:0 0 clamp(180px,26%,360px);aspect-ratio:4/3}
.prj-card--row .prj-card__body{flex:1}
@media (max-width:620px){.prj-card--row{flex-direction:column}.prj-card--row>.media{flex:0 0 auto;aspect-ratio:16/9}}
@media print{.sp-list>.prj-card{page-break-inside:avoid}}
.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}}
@media (max-width:720px){.skills{grid-template-columns: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}
@@ -677,7 +700,7 @@ svg{display:block;flex:none}
.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{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:14px;align-items:start;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}
@@ -694,6 +717,7 @@ svg{display:block;flex:none}
.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}}
@media (max-width:620px){.task__right{grid-column:1 / -1;order:-1;align-items:flex-start}.task__thumbs{display:grid;grid-template-columns:1fr;gap:8px;width:100%}.task__thumbs img{width:100%;height:auto;aspect-ratio:4 / 3;border-radius:12px}}
.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}
@@ -821,6 +845,7 @@ function heroHtml(v) {
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.studentPhotoCards.length) actions.push('<a class="btn btn--ghost" href="#student-photos">' + iconTag('user-round') + 'Фото ученика</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('')
@@ -930,6 +955,30 @@ function groupPhotosHtml(v) {
'</figure>').join('');
}
function studentPhotosHtml(v) {
if (!v.studentPhotoCards.length) {
return emptyBlock('image', 'Фотографий ученика пока нет',
'Личные фотографии резидента появятся здесь, как только их добавят в данных профиля.');
}
const total = v.studentPhotoCards.length;
return '<div class="sp-list">' + v.studentPhotoCards.map((p, i) =>
'<article class="card prj-card prj-card--row reveal">' +
mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) +
'<div class="prj-card__body">' +
'<span class="badge ' + (p.main ? 'badge--ok' : 'badge--info') + '">' +
iconTag(p.main ? 'star' : 'image') + (p.main ? 'Главное фото' : 'Фото ученика') + '</span>' +
'<h3 class="prj-card__title">' + escapeHtml(total > 1 ? 'Фото ' + (i + 1) + ' из ' + total : 'Фотография резидента') + '</h3>' +
'<p class="prj-card__text">' + escapeHtml(
(p.main ? 'Личная фотография резидента' : 'Дополнительная фотография резидента') +
(total > 1 ? ' · ' + v.name : '')) + '</p>' +
'<div class="prj-card__foot"><span class="prj-card__stats">' +
(p.date ? '<span>' + iconTag('calendar-days') + escapeHtml(p.date) + '</span>' : '') +
(total > 1 ? '<span>' + iconTag('images') + total + ' ' + plural(total, 'фото', 'фото', 'фото') + '</span>' : '') +
'</span><a class="btn btn--sm" href="' + escapeHtml(p.src) + '" data-g="' + p.g + '">' +
iconTag('maximize-2') + 'Просмотр</a></div>' +
'</div></article>').join('') + '</div>';
}
function tasksHtml(v) {
if (!v.tasks.length) {
return emptyBlock('list-checks', 'Записей о занятиях пока нет',
@@ -968,7 +1017,9 @@ function aboutHtml(v) {
: (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>';
'<p class="meta">' + iconTag('info') + ((v.profilePhotoCard || v.mainPhotoCard || v.avatar)
? (v.profilePhotoCard && v.profilePhotoCard.student ? 'Личная фотография резидента' : 'Фотография из архива занятий')
: 'Место под реальное фото автора профиля') + '</p></article>';
const contactsCard = '<article class="card about__card reveal"><h3>' + iconTag('at-sign') + 'Контакты</h3>' +
(v.contacts.length
@@ -988,6 +1039,7 @@ function aboutHtml(v) {
function footerHtml(v, generatedAt) {
const links = [];
if (v.entries.length) links.push('<a href="#lessons">Занятия</a>');
if (v.studentPhotoCards.length) links.push('<a href="#student-photos">Фото ученика</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>');
@@ -998,16 +1050,35 @@ 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>');
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('');
const all = [
{ id: 'profile', icon: 'user-round', label: 'Профиль', rank: 0 },
{ id: 'student-photos', icon: 'images', label: 'Фото ученика', rank: 1 },
{ id: 'lessons', icon: 'list-checks', label: 'Занятия', rank: 2 },
{ id: 'projects', icon: 'folder-git-2', label: 'Работы', rank: 3 },
{ id: 'education', icon: 'book-open', label: 'Образование', rank: 4 },
{ id: 'about', icon: 'info', label: 'О себе', rank: 5 },
{ id: 'photos', icon: 'images', label: 'Фотографии', rank: 6 },
{ id: 'group-photos', icon: 'users-round', label: 'Фото группы', rank: 7 },
{ id: 'skills', icon: 'sparkles', label: 'Навыки', rank: 8 },
{ id: 'experience', icon: 'briefcase', label: 'Опыт', rank: 9 },
];
const sections = {
'profile': true,
'student-photos': v.studentPhotoCards.length > 0,
'photos': v.photoCards.length > 0,
'group-photos': v.groupPhotoCards.length > 0,
'education': true,
'experience': v.experience.length > 0,
'skills': v.skillGroups.length > 0,
'projects': v.projects.length > 0,
'lessons': v.tasks.length > 0,
'about': true,
};
return all
.filter(i => sections[i.id])
.slice(0, NAV_LIMIT)
.map(i => '<a class="nav-link" href="#' + i.id + '" data-spy="' + i.id + '">' + iconTag(i.icon) + '<span>' + i.label + '</span></a>')
.join('');
}
function educationHtml(v) {
@@ -1146,6 +1217,11 @@ function renderStudentReport(data, opts) {
sectionHead('activity', 'Информация об авторе', 'Показатели резидента по данным журнала занятий и архива работ.') +
(periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') +
'<div class="stats">' + statsHtml(v) + '</div></section>');
sections.push('<section class="section" id="student-photos">' +
sectionHead('user-round', 'Фотографии ученика', v.studentPhotoCards.length > 1
? 'Личные фотографии резидента. Нажмите «Просмотр», чтобы открыть снимок крупнее.'
: 'Личная фотография резидента. Нажмите «Просмотр», чтобы открыть её крупнее.') +
studentPhotosHtml(v) + '</section>');
sections.push('<section class="section" id="photos">' +
sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') +
'<div class="photos">' + photosHtml(v) + '</div></section>');
@@ -1155,10 +1231,12 @@ function renderStudentReport(data, opts) {
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>');
if (v.experience.length) {
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>');
@@ -1174,7 +1252,8 @@ function renderStudentReport(data, opts) {
'<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>' +
'<a class="brand" href="#top"><span class="brand__logo">' +
(v.logo ? '<img src="' + escapeHtml(v.logo) + '" alt="Логотип">' : 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">' +