feat(students): multi-photo gallery, group photos in export, KIBERone rebrand
This commit is contained in:
@@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS students (
|
|||||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS student_photos (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
photo_path VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
student_name VARCHAR(150) NOT NULL,
|
student_name VARCHAR(150) NOT NULL,
|
||||||
|
|||||||
@@ -240,3 +240,12 @@ ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT
|
|||||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS student_photos (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
photo_path VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||||
|
|||||||
@@ -196,6 +196,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.edit-modal .actions button:hover{opacity:.85}
|
.edit-modal .actions button:hover{opacity:.85}
|
||||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
|
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
|
||||||
.toast.show{opacity:1}
|
.toast.show{opacity:1}
|
||||||
|
.pf-photos{display:flex;flex-wrap:wrap;gap:10px}
|
||||||
|
.pf-photo{position:relative;width:96px;height:96px;border-radius:10px;overflow:hidden;border:2px solid var(--border);cursor:pointer;flex:0 0 auto}
|
||||||
|
.pf-photo.main{border-color:var(--accent)}
|
||||||
|
.pf-photo img{width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.pf-badge{position:absolute;left:4px;bottom:4px;background:var(--accent);color:#fff;font-size:.65rem;font-weight:600;padding:2px 6px;border-radius:6px}
|
||||||
|
.pf-del{position:absolute;top:4px;right:4px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||||
|
.pf-del:hover{background:#ef4444}
|
||||||
|
|
||||||
/* --- Фото группы (хронология) --- */
|
/* --- Фото группы (хронология) --- */
|
||||||
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
|
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
|
||||||
|
|||||||
@@ -183,10 +183,12 @@ async function exportStudentProfile(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let profileDataId = null;
|
let profileDataId = null;
|
||||||
|
let profilePhotos = [];
|
||||||
|
|
||||||
function openProfileData(id) {
|
function openProfileData(id) {
|
||||||
profileDataId = id;
|
profileDataId = id;
|
||||||
loadGroupOptions();
|
loadGroupOptions();
|
||||||
|
loadStudentPhotos(id);
|
||||||
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
|
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
|
||||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
|
||||||
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
|
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
|
||||||
@@ -199,6 +201,94 @@ function closeProfileData() {
|
|||||||
profileDataId = null;
|
profileDataId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStudentPhotos(id) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
profilePhotos = data.photos || [];
|
||||||
|
document.getElementById('pfPhoto').value = data.photo_path || '';
|
||||||
|
renderStudentPhotos();
|
||||||
|
} catch (err) {
|
||||||
|
profilePhotos = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStudentPhotos() {
|
||||||
|
const wrap = document.getElementById('pfPhotos');
|
||||||
|
if (!wrap) return;
|
||||||
|
const main = document.getElementById('pfPhoto').value.trim();
|
||||||
|
if (!profilePhotos.length) {
|
||||||
|
wrap.innerHTML = '<span class="hint" style="margin:0">Пока нет загруженных фото</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.innerHTML = profilePhotos.map(p => `
|
||||||
|
<div class="pf-photo${p.photo_path === main ? ' main' : ''}" data-pf-set="${p.id}" title="Сделать главным">
|
||||||
|
<img src="${esc(p.photo_path)}" alt="" loading="lazy">
|
||||||
|
${p.photo_path === main ? '<span class="pf-badge">Главная</span>' : ''}
|
||||||
|
<button type="button" class="pf-del" data-pf-del="${p.id}" title="Удалить фото">✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadStudentPhoto(file) {
|
||||||
|
if (!profileDataId || !file) return;
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Можно загружать только изображения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Загружаем фото...');
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadStudentPhotos(profileDataId);
|
||||||
|
showToast('Фото загружено');
|
||||||
|
} else {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
alert(e.error || 'Не удалось загрузить фото');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка при загрузке фото');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMainStudentPhoto(pid) {
|
||||||
|
if (!profileDataId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}/main`, { method: 'PUT', headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
const r = await res.json();
|
||||||
|
document.getElementById('pfPhoto').value = r.photo_path || '';
|
||||||
|
renderStudentPhotos();
|
||||||
|
showToast('Главное фото обновлено');
|
||||||
|
} else {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
alert(e.error || 'Не удалось выбрать главное фото');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка при выборе главного фото');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delStudentPhoto(pid) {
|
||||||
|
if (!profileDataId) return;
|
||||||
|
if (!confirm('Удалить это фото?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadStudentPhotos(profileDataId);
|
||||||
|
showToast('Фото удалено');
|
||||||
|
} else {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
alert(e.error || 'Не удалось удалить фото');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка при удалении фото');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fillProfileForm(profile, name, photoPath) {
|
function fillProfileForm(profile, name, photoPath) {
|
||||||
const p = profile || {};
|
const p = profile || {};
|
||||||
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
|
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
|
||||||
@@ -449,6 +539,34 @@ document.getElementById('studentList').addEventListener('click', e => {
|
|||||||
|
|
||||||
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
|
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
|
||||||
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
|
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
|
||||||
|
document.getElementById('pfPhotoUploadBtn').addEventListener('click', () => document.getElementById('pfPhotoFile').click());
|
||||||
|
document.getElementById('pfPhotoFile').addEventListener('change', e => {
|
||||||
|
const f = e.target.files && e.target.files[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (f) uploadStudentPhoto(f);
|
||||||
|
});
|
||||||
|
document.getElementById('pfPhotos').addEventListener('click', e => {
|
||||||
|
const del = e.target.closest('[data-pf-del]');
|
||||||
|
if (del) { delStudentPhoto(parseInt(del.dataset.pfDel, 10)); return; }
|
||||||
|
const set = e.target.closest('[data-pf-set]');
|
||||||
|
if (set) setMainStudentPhoto(parseInt(set.dataset.pfSet, 10));
|
||||||
|
});
|
||||||
|
document.addEventListener('paste', e => {
|
||||||
|
if (!profileDataId) return;
|
||||||
|
if (!document.getElementById('profileDataModal').classList.contains('open')) return;
|
||||||
|
const items = e.clipboardData && e.clipboardData.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.type && it.type.startsWith('image/')) {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f) {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadStudentPhoto(f);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
|
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
+32
-33
@@ -131,78 +131,77 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="profileDataModal">
|
<div class="modal-overlay" id="profileDataModal">
|
||||||
<div class="edit-modal" style="max-width:640px">
|
<div class="edit-modal" style="max-width:900px;width:96%;max-height:92dvh;overflow-y:auto">
|
||||||
<h3>Данные профиля: <span id="profileDataTitle" style="color:var(--accent)"></span></h3>
|
<h3>Данные профиля: <span id="profileDataTitle" style="color:var(--accent)"></span></h3>
|
||||||
<form id="profileDataForm" class="settings-stack" style="gap:12px">
|
<form id="profileDataForm" style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
|
||||||
<div class="settings-row">
|
<div class="settings-field">
|
||||||
<div class="settings-field" style="flex:1">
|
|
||||||
<label>Роль / направление</label>
|
<label>Роль / направление</label>
|
||||||
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
|
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="flex:1">
|
<div class="settings-field">
|
||||||
<label>Статус</label>
|
<label>Статус</label>
|
||||||
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
|
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="settings-field">
|
||||||
<div class="settings-row">
|
|
||||||
<div class="settings-field" style="flex:1">
|
|
||||||
<label>Примечание к статусу</label>
|
<label>Примечание к статусу</label>
|
||||||
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
|
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="flex:1">
|
<div class="settings-field">
|
||||||
<label>Город</label>
|
<label>Город</label>
|
||||||
<input type="text" id="pfCity" class="settings-input">
|
<input type="text" id="pfCity" class="settings-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="settings-field">
|
||||||
<div class="settings-row">
|
|
||||||
<div class="settings-field" style="flex:1">
|
|
||||||
<label>Наставник</label>
|
<label>Наставник</label>
|
||||||
<input type="text" id="pfMentor" class="settings-input">
|
<input type="text" id="pfMentor" class="settings-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="flex:1">
|
<div class="settings-field">
|
||||||
<label>В академии с</label>
|
<label>В академии с</label>
|
||||||
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
|
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="settings-field" style="grid-column:1/-1">
|
||||||
<div class="settings-field full">
|
|
||||||
<label>О себе</label>
|
<label>О себе</label>
|
||||||
<textarea id="pfBio" class="settings-input" style="min-height:90px" placeholder="Короткий рассказ ученика о себе"></textarea>
|
<textarea id="pfBio" class="settings-input" style="min-height:70px" placeholder="Короткий рассказ ученика о себе"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field">
|
||||||
<label>Цитата</label>
|
<label>Цитата</label>
|
||||||
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
|
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field">
|
||||||
<label>Теги (через запятую)</label>
|
<label>Теги (через запятую)</label>
|
||||||
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
|
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field">
|
||||||
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
|
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
|
||||||
<textarea id="pfContacts" class="settings-input" style="min-height:70px" placeholder="mail | maria@example.com | mailto:maria@example.com send | @nickname | https://t.me/nickname"></textarea>
|
<textarea id="pfContacts" class="settings-input" style="min-height:64px" placeholder="mail | maria@example.com | mailto:maria@example.com send | @nickname | https://t.me/nickname"></textarea>
|
||||||
<span class="hint">Иконки: mail, send, phone, github, globe, link, at-sign</span>
|
<span class="hint">Иконки: mail, send, phone, github, globe, link, at-sign</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field">
|
||||||
<label>Достижения — по одному в строке</label>
|
<label>Достижения — по одному в строке</label>
|
||||||
<textarea id="pfAchievements" class="settings-input" style="min-height:70px"></textarea>
|
<textarea id="pfAchievements" class="settings-input" style="min-height:64px"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field">
|
||||||
<label>Навыки — строка: Группа | Навык | Уровень | %</label>
|
<label>Навыки — строка: Группа | Навык | Уровень | %</label>
|
||||||
<textarea id="pfSkills" class="settings-input" style="min-height:70px" placeholder="Вёрстка | HTML5 · семантика | Изучен | 95"></textarea>
|
<textarea id="pfSkills" class="settings-input" style="min-height:64px" placeholder="Вёрстка | HTML5 · семантика | Изучен | 95"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field">
|
||||||
<label>Опыт работы — строка: Заголовок | Организация | Период | Дата | Бейдж | Описание | Теги (через запятую)</label>
|
<label>Опыт работы — строка: Заголовок | Организация | Период | Дата | Бейдж | Описание | Теги (через запятую)</label>
|
||||||
<textarea id="pfExperience" class="settings-input" style="min-height:70px" placeholder="Хакатон Digital City | Академия | Март 2024 | 03.2024 | Хакатон | Прототип карты заявок, 2 место | Vue 3, Leaflet"></textarea>
|
<textarea id="pfExperience" class="settings-input" style="min-height:64px" placeholder="Хакатон Digital City | Академия | Март 2024 | 03.2024 | Хакатон | Прототип карты заявок, 2 место | Vue 3, Leaflet"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field">
|
||||||
<label>Прогресс по модулям — строка: Тема модуля | Прогресс % | Оценка | Преподаватель</label>
|
<label>Прогресс по модулям — строка: Тема модуля | Прогресс % | Оценка | Преподаватель</label>
|
||||||
<textarea id="pfEducation" class="settings-input" style="min-height:70px" placeholder="JavaScript: основы языка | 100 | Оценка 4,7 | Игорь Дёмин"></textarea>
|
<textarea id="pfEducation" class="settings-input" style="min-height:64px" placeholder="JavaScript: основы языка | 100 | Оценка 4,7 | Игорь Дёмин"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field" style="grid-column:1/-1">
|
||||||
<label>Фото ученика — путь вида /uploads/имя_файла (необязательно)</label>
|
<label>Фотографии ученика</label>
|
||||||
<input type="text" id="pfPhoto" class="settings-input" placeholder="/uploads/1726740000000-ab12cd34.jpg">
|
<input type="hidden" id="pfPhoto">
|
||||||
<span class="hint">Если не указано, берётся первое фото из записей ученика</span>
|
<div class="pf-photos" id="pfPhotos"></div>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px">
|
||||||
|
<input type="file" id="pfPhotoFile" accept="image/*" hidden>
|
||||||
|
<button type="button" class="btn-primary ghost" id="pfPhotoUploadBtn">Загрузить фото</button>
|
||||||
|
<span class="hint">Клик по фото — сделать главным. Можно вставить из буфера обмена (Ctrl+V)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
</div>
|
||||||
|
<div style="grid-column:1/-1;display:flex;justify-content:flex-end;gap:8px">
|
||||||
<button type="button" class="btn-primary ghost" id="profileDataCancelBtn">Отмена</button>
|
<button type="button" class="btn-primary ghost" id="profileDataCancelBtn">Отмена</button>
|
||||||
<button type="submit" class="btn-primary">Сохранить</button>
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -616,16 +616,17 @@ async function removeEntryFiles(entryId) {
|
|||||||
async function sweepOrphanedUploads() {
|
async function sweepOrphanedUploads() {
|
||||||
const dir = path.join(__dirname, 'uploads');
|
const dir = path.join(__dirname, 'uploads');
|
||||||
if (!fs.existsSync(dir)) return;
|
if (!fs.existsSync(dir)) return;
|
||||||
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }] = await Promise.all([
|
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }] = await Promise.all([
|
||||||
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
|
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
|
||||||
pool.query('SELECT path AS p FROM project_files'),
|
pool.query('SELECT path AS p FROM project_files'),
|
||||||
pool.query('SELECT photo_path AS p FROM group_photos'),
|
pool.query('SELECT photo_path AS p FROM group_photos'),
|
||||||
pool.query('SELECT photo_path AS p FROM entry_photos'),
|
pool.query('SELECT photo_path AS p FROM entry_photos'),
|
||||||
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
|
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
|
||||||
pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'),
|
pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'),
|
||||||
|
pool.query('SELECT photo_path AS p FROM student_photos'),
|
||||||
]);
|
]);
|
||||||
const refs = new Set();
|
const refs = new Set();
|
||||||
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
|
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
|
||||||
for (const f of fs.readdirSync(dir)) {
|
for (const f of fs.readdirSync(dir)) {
|
||||||
const fp = path.join(dir, f);
|
const fp = path.join(dir, f);
|
||||||
if (!fs.statSync(fp).isFile()) continue;
|
if (!fs.statSync(fp).isFile()) continue;
|
||||||
@@ -693,6 +694,16 @@ async function ensureEntryPhotosTable() {
|
|||||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
|
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureStudentPhotosTable() {
|
||||||
|
await pool.query(`CREATE TABLE IF NOT EXISTS student_photos (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
photo_path VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)`);
|
||||||
|
await pool.query('CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id)');
|
||||||
|
}
|
||||||
|
|
||||||
async function ensurePhotoOriginalColumn() {
|
async function ensurePhotoOriginalColumn() {
|
||||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
|
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
|
||||||
}
|
}
|
||||||
@@ -1475,6 +1486,12 @@ function normalizeRestoreData(data) {
|
|||||||
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
|
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
|
||||||
created_at: optTs(x.created_at),
|
created_at: optTs(x.created_at),
|
||||||
}));
|
}));
|
||||||
|
const student_photos = (data.student_photos || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
student_id: reqInt(x.student_id),
|
||||||
|
photo_path: reqUploadPath(x.photo_path, 255),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
const group_photos = (data.group_photos || []).map(x => ({
|
const group_photos = (data.group_photos || []).map(x => ({
|
||||||
id: reqInt(x.id),
|
id: reqInt(x.id),
|
||||||
group_id: reqInt(x.group_id),
|
group_id: reqInt(x.group_id),
|
||||||
@@ -1506,13 +1523,13 @@ function normalizeRestoreData(data) {
|
|||||||
for (const [k, v] of Object.entries(data.settings || {})) {
|
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||||
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
||||||
}
|
}
|
||||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links, modules };
|
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules };
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/backup', requireAdmin, async (req, res) => {
|
app.get('/api/backup', requireAdmin, async (req, res) => {
|
||||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||||
try {
|
try {
|
||||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md] = await Promise.all([
|
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp] = await Promise.all([
|
||||||
pool.query('SELECT * FROM groups ORDER BY id'),
|
pool.query('SELECT * FROM groups ORDER BY id'),
|
||||||
pool.query('SELECT * FROM students ORDER BY id'),
|
pool.query('SELECT * FROM students ORDER BY id'),
|
||||||
pool.query('SELECT * FROM entries ORDER BY id'),
|
pool.query('SELECT * FROM entries ORDER BY id'),
|
||||||
@@ -1524,10 +1541,11 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
|
|||||||
pool.query('SELECT * FROM group_photos ORDER BY id'),
|
pool.query('SELECT * FROM group_photos ORDER BY id'),
|
||||||
pool.query('SELECT * FROM entry_photos ORDER BY id'),
|
pool.query('SELECT * FROM entry_photos ORDER BY id'),
|
||||||
pool.query('SELECT * FROM modules ORDER BY id'),
|
pool.query('SELECT * FROM modules ORDER BY id'),
|
||||||
|
pool.query('SELECT * FROM student_photos ORDER BY id'),
|
||||||
]);
|
]);
|
||||||
const settings = {};
|
const settings = {};
|
||||||
st.rows.forEach(r => { settings[r.key] = r.value; });
|
st.rows.forEach(r => { settings[r.key] = r.value; });
|
||||||
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows };
|
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows };
|
||||||
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||||||
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
||||||
const dir = path.join(__dirname, 'uploads');
|
const dir = path.join(__dirname, 'uploads');
|
||||||
@@ -1676,6 +1694,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
[x.id, x.entry_id, x.photo_path, x.caption, x.sort_order, x.created_at]
|
[x.id, x.entry_id, x.photo_path, x.caption, x.sort_order, x.created_at]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const x of ndata.student_photos) {
|
||||||
|
const ex = await client.query('SELECT 1 FROM students WHERE id = $1', [x.student_id]);
|
||||||
|
if (!ex.rowCount) continue;
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO student_photos (id, student_id, photo_path, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO NOTHING',
|
||||||
|
[x.id, x.student_id, x.photo_path, x.created_at]
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const x of ndata.users) {
|
for (const x of ndata.users) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO users (id, username, password_hash, name, role, is_active, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
'INSERT INTO users (id, username, password_hash, name, role, is_active, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||||
@@ -1694,7 +1720,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
[k, String(v ?? '')]
|
[k, String(v ?? '')]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules']) {
|
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos']) {
|
||||||
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
|
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
|
||||||
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
||||||
}
|
}
|
||||||
@@ -2667,6 +2693,8 @@ app.delete('/api/students/:id', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
|
await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
|
||||||
|
const { rows: spRows } = await pool.query('SELECT photo_path AS p FROM student_photos WHERE student_id = $1', [req.params.id]);
|
||||||
|
spRows.forEach(r => safeUnlink(r.p));
|
||||||
await logAudit(req, 'student.delete', { id: req.params.id });
|
await logAudit(req, 'student.delete', { id: req.params.id });
|
||||||
invalidateStudents();
|
invalidateStudents();
|
||||||
invalidateStats();
|
invalidateStats();
|
||||||
@@ -2736,6 +2764,133 @@ app.put('/api/students/:id/profile', requireAuth, async (req, res) => {
|
|||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const studentPhotoUpload = upload.single('photo');
|
||||||
|
|
||||||
|
app.get('/api/students/:id/photos', requireAuth, async (req, res) => {
|
||||||
|
let id;
|
||||||
|
try {
|
||||||
|
id = reqInt(req.params.id);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Неверный id ученика' });
|
||||||
|
}
|
||||||
|
const acc = await studentProfileAccess(req.user, id);
|
||||||
|
if (!acc.found) {
|
||||||
|
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT id, photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ photos: rows, photo_path: acc.student.photo_path || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/students/:id/photos', requireAuth, (req, res, next) => {
|
||||||
|
studentPhotoUpload(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
||||||
|
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||||||
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||||
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}, async (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
|
||||||
|
let id;
|
||||||
|
try {
|
||||||
|
id = reqInt(req.params.id);
|
||||||
|
} catch {
|
||||||
|
removeUpload(req.file);
|
||||||
|
return res.status(400).json({ error: 'Неверный id ученика' });
|
||||||
|
}
|
||||||
|
const acc = await studentProfileAccess(req.user, id);
|
||||||
|
if (!acc.found) {
|
||||||
|
removeUpload(req.file);
|
||||||
|
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await convertPhoto(req.file);
|
||||||
|
const photoPath = `/uploads/${req.file.filename}`;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'INSERT INTO student_photos (student_id, photo_path) VALUES ($1, $2) RETURNING id, photo_path, created_at',
|
||||||
|
[id, photoPath]
|
||||||
|
);
|
||||||
|
if (!acc.student.photo_path) {
|
||||||
|
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [photoPath, id]);
|
||||||
|
}
|
||||||
|
await logAudit(req, 'student.photo.create', { id, photo_path: photoPath });
|
||||||
|
invalidateStudents();
|
||||||
|
invalidateShare();
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) {
|
||||||
|
safeUnlink(`uploads/${req.file.filename}`);
|
||||||
|
console.error('POST /api/students/:id/photos:', e);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/students/:id/photos/:pid', requireAuth, async (req, res) => {
|
||||||
|
let id, pid;
|
||||||
|
try {
|
||||||
|
id = reqInt(req.params.id);
|
||||||
|
pid = reqInt(req.params.pid);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Неверный id' });
|
||||||
|
}
|
||||||
|
const acc = await studentProfileAccess(req.user, id);
|
||||||
|
if (!acc.found) {
|
||||||
|
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT id, photo_path FROM student_photos WHERE id = $1 AND student_id = $2',
|
||||||
|
[pid, id]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||||||
|
const { rows: rest } = await pool.query(
|
||||||
|
'SELECT photo_path FROM student_photos WHERE student_id = $1 AND id <> $2 ORDER BY id DESC LIMIT 1',
|
||||||
|
[id, pid]
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM student_photos WHERE id = $1', [pid]);
|
||||||
|
if (acc.student.photo_path === rows[0].photo_path) {
|
||||||
|
const next = rest.length ? rest[0].photo_path : null;
|
||||||
|
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [next, id]);
|
||||||
|
}
|
||||||
|
safeUnlink(rows[0].photo_path);
|
||||||
|
await logAudit(req, 'student.photo.delete', { id, photo_path: rows[0].photo_path });
|
||||||
|
invalidateStudents();
|
||||||
|
invalidateShare();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('DELETE /api/students/:id/photos/:pid:', e);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/students/:id/photos/:pid/main', requireAuth, async (req, res) => {
|
||||||
|
let id, pid;
|
||||||
|
try {
|
||||||
|
id = reqInt(req.params.id);
|
||||||
|
pid = reqInt(req.params.pid);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Неверный id' });
|
||||||
|
}
|
||||||
|
const acc = await studentProfileAccess(req.user, id);
|
||||||
|
if (!acc.found) {
|
||||||
|
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT photo_path FROM student_photos WHERE id = $1 AND student_id = $2',
|
||||||
|
[pid, id]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||||||
|
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, id]);
|
||||||
|
await logAudit(req, 'student.photo.main', { id, photo_path: rows[0].photo_path });
|
||||||
|
invalidateStudents();
|
||||||
|
invalidateShare();
|
||||||
|
res.json({ ok: true, photo_path: rows[0].photo_path });
|
||||||
|
});
|
||||||
|
|
||||||
function fmtLongDate(iso) {
|
function fmtLongDate(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
@@ -2789,7 +2944,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
const condStr = conds.join(' AND ');
|
const condStr = conds.join(' AND ');
|
||||||
const whereStr = ' WHERE ' + condStr;
|
const whereStr = ' WHERE ' + condStr;
|
||||||
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes] = await Promise.all([
|
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT s.id, s.name, s.created_at, s.group_id, s.photo_path, s.profile,
|
`SELECT s.id, s.name, s.created_at, s.group_id, s.photo_path, s.profile,
|
||||||
g.name AS group_name, b.name AS branch_name
|
g.name AS group_name, b.name AS branch_name
|
||||||
@@ -2827,6 +2982,11 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
JOIN modules m ON m.id = e.module_id
|
JOIN modules m ON m.id = e.module_id
|
||||||
WHERE ${condStr}
|
WHERE ${condStr}
|
||||||
GROUP BY m.id ORDER BY min(e.created_at)`, params),
|
GROUP BY m.id ORDER BY min(e.created_at)`, params),
|
||||||
|
pool.query(`SELECT gp.photo_path, gp.caption, gp.taken_at, gp.created_at, g.name AS group_name
|
||||||
|
FROM group_photos gp
|
||||||
|
JOIN groups g ON g.id = gp.group_id
|
||||||
|
WHERE gp.group_id = (SELECT group_id FROM students WHERE name = $1)
|
||||||
|
ORDER BY gp.sort_order ASC, gp.created_at DESC`, [name]),
|
||||||
]);
|
]);
|
||||||
const entryRows = entriesRes.rows;
|
const entryRows = entriesRes.rows;
|
||||||
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
|
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
|
||||||
@@ -2869,12 +3029,33 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
for (const m of mainsRes.rows) addPhoto(m);
|
for (const m of mainsRes.rows) addPhoto(m);
|
||||||
photosBuilt.sort((a, b) => new Date(b.createdAt || Date.now()) - new Date(a.createdAt || Date.now()));
|
photosBuilt.sort((a, b) => new Date(b.createdAt || Date.now()) - new Date(a.createdAt || Date.now()));
|
||||||
if (avatarStored) {
|
if (avatarStored) {
|
||||||
if (!seenPhotos.has(avatarStored)) addPhoto({ photo_path: '/uploads/' + avatarStored, caption: 'Фото ученика', created_at: student.created_at });
|
if (!seenPhotos.has(avatarStored)) addPhoto({ photo_path: '/uploads/' + avatarStored, caption: 'Фото резидента', created_at: student.created_at });
|
||||||
const idx = photosBuilt.findIndex(p => p.stored === avatarStored);
|
const idx = photosBuilt.findIndex(p => p.stored === avatarStored);
|
||||||
if (idx > 0) photosBuilt.unshift(photosBuilt.splice(idx, 1)[0]);
|
if (idx > 0) photosBuilt.unshift(photosBuilt.splice(idx, 1)[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupPhotosBuilt = [];
|
||||||
|
if (opts.includePhotos) {
|
||||||
|
for (const gp of groupPhotosRes.rows) {
|
||||||
|
if (!isSafeUploadPath(gp.photo_path)) continue;
|
||||||
|
const stored = gp.photo_path.slice('/uploads/'.length);
|
||||||
|
if (seenPhotos.has(stored)) continue;
|
||||||
|
const src = path.join(UPLOADS_DIR, stored);
|
||||||
|
if (!fs.existsSync(src)) continue;
|
||||||
|
const ts = gp.taken_at || gp.created_at || new Date();
|
||||||
|
zip.addFile('photos/' + stored, fs.readFileSync(src), new Date(ts));
|
||||||
|
seenPhotos.add(stored);
|
||||||
|
groupPhotosBuilt.push({
|
||||||
|
stored,
|
||||||
|
caption: gp.caption || null,
|
||||||
|
takenAt: gp.taken_at || null,
|
||||||
|
createdAt: gp.created_at || null,
|
||||||
|
groupName: gp.group_name || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const seenFiles = new Map();
|
const seenFiles = new Map();
|
||||||
const filesBuilt = [];
|
const filesBuilt = [];
|
||||||
if (opts.includeFiles) {
|
if (opts.includeFiles) {
|
||||||
@@ -2927,6 +3108,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
entries: entryRows,
|
entries: entryRows,
|
||||||
modules: modulesRes.rows,
|
modules: modulesRes.rows,
|
||||||
photos: photosBuilt,
|
photos: photosBuilt,
|
||||||
|
groupPhotos: groupPhotosBuilt,
|
||||||
files: filesBuilt,
|
files: filesBuilt,
|
||||||
generatedAt: new Date(),
|
generatedAt: new Date(),
|
||||||
period,
|
period,
|
||||||
@@ -2938,7 +3120,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
student: reportData.student,
|
student: reportData.student,
|
||||||
profile: student.profile || null,
|
profile: student.profile || null,
|
||||||
period: period || null,
|
period: period || null,
|
||||||
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length },
|
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length },
|
||||||
options: opts,
|
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 })),
|
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 })),
|
photos: photosBuilt.map(p => ({ file: 'photos/' + p.stored, caption: p.caption, created_at: p.createdAt, entry_id: p.entryId })),
|
||||||
@@ -4669,6 +4851,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|||||||
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
||||||
try { await ensureModulesTable(); } catch (err) { console.error('Modules table:', err); }
|
try { await ensureModulesTable(); } catch (err) { console.error('Modules table:', err); }
|
||||||
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
||||||
|
try { await ensureStudentPhotosTable(); } catch (err) { console.error('Student photos table:', err); }
|
||||||
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
||||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||||
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
|
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
|
||||||
|
|||||||
@@ -290,16 +290,17 @@ svg{display:block;flex:none}
|
|||||||
6. HERO: фото + имя (ProfileHero)
|
6. HERO: фото + имя (ProfileHero)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
.hero{padding:clamp(20px,2.6vw,34px);overflow:hidden;position:relative}
|
.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__cover{border-radius:var(--radius-sm);margin-bottom:clamp(24px,3vw,40px);position:relative;z-index:0}
|
||||||
.hero__grid{
|
.hero__grid{
|
||||||
display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);
|
display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);
|
||||||
gap:clamp(18px,2.6vw,34px);align-items:end;
|
gap:clamp(18px,2.6vw,34px);align-items:end;
|
||||||
|
position:relative;z-index:1;
|
||||||
}
|
}
|
||||||
@media (max-width:900px){
|
@media (max-width:900px){
|
||||||
.hero__grid{grid-template-columns:1fr;align-items:start}
|
.hero__grid{grid-template-columns:1fr;align-items:start}
|
||||||
.hero__cover{display:none}
|
.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;box-shadow:var(--shadow)}
|
.hero__avatar{border-radius:26px;box-shadow:var(--shadow)}
|
||||||
.hero__status{
|
.hero__status{
|
||||||
position:absolute;left:14px;bottom:14px;padding:7px 12px;border-radius:999px;
|
position:absolute;left:14px;bottom:14px;padding:7px 12px;border-radius:999px;
|
||||||
@@ -444,7 +445,7 @@ svg{display:block;flex:none}
|
|||||||
}
|
}
|
||||||
.task__check svg{width:20px;height:20px;stroke-width:2.4}
|
.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--progress{background:rgba(251,191,36,.14);border-color:rgba(251,191,36,.32);color:var(--warn)}
|
||||||
.task__main{min-width:0}
|
.task__main{min-width:0;width:100%}
|
||||||
.task__title{font-size:.95rem;font-weight:800;overflow-wrap:anywhere}
|
.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__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__right{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
|
||||||
|
|||||||
+86
-42
@@ -248,7 +248,7 @@ function buildProfileView(data, opts) {
|
|||||||
const dbModules = arr(data.modules);
|
const dbModules = arr(data.modules);
|
||||||
const photos = arr(data.photos);
|
const photos = arr(data.photos);
|
||||||
const files = arr(data.files);
|
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 profContacts = arr(profile.contacts);
|
||||||
const profAchievements = arr(profile.achievements);
|
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();
|
const photoByEntry = new Map();
|
||||||
for (const p of photos) {
|
for (const p of photos) {
|
||||||
if (!photoByEntry.has(p.entryId)) photoByEntry.set(p.entryId, []);
|
if (!photoByEntry.has(p.entryId)) photoByEntry.set(p.entryId, []);
|
||||||
@@ -375,12 +388,12 @@ function buildProfileView(data, opts) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stats = [];
|
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 (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: 'файлы, прикреплённые к занятиям' });
|
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: '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: 'модули, которые проходил ученик' });
|
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: 'от первой записи в журнале' });
|
if (days && showEntries) stats.push({ icon: 'calendar-clock', value: days, suffix: plural(days, 'день', 'дня', 'дней'), label: 'В KIBERone', hint: 'от первой записи в журнале' });
|
||||||
for (const s of arr(profile.stats)) {
|
for (const s of arr(profile.stats)) {
|
||||||
const label = str(s && s.label, 80);
|
const label = str(s && s.label, 80);
|
||||||
const value = (s && s.value !== undefined && s.value !== null) ? String(s.value).trim().slice(0, 20) : '';
|
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);
|
const city = str(profile.city, 120) || str(student.branch_name, 120);
|
||||||
if (city) facts.push({ icon: 'map-pin', text: city });
|
if (city) facts.push({ icon: 'map-pin', text: city });
|
||||||
if (groups.length) facts.push({ icon: 'users-round', text: groups.join(' · ') });
|
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 });
|
if (joined) facts.push({ icon: 'calendar-check', text: joined });
|
||||||
const mentor = str(profile.mentor, 150);
|
const mentor = str(profile.mentor, 150);
|
||||||
if (mentor) facts.push({ icon: 'user-check', text: 'Наставник: ' + mentor });
|
if (mentor) facts.push({ icon: 'user-check', text: 'Наставник: ' + mentor });
|
||||||
if (education.length) facts.push({ icon: 'book-open', text: education.length + ' ' + plural(education.length, 'тема', 'темы', 'тем') + ' программы' });
|
if (education.length) facts.push({ icon: 'book-open', text: education.length + ' ' + plural(education.length, 'тема', 'темы', 'тем') + ' программы' });
|
||||||
|
|
||||||
const autoBio = [];
|
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 (entries.length) autoBio.push('В журнале ' + entries.length + ' ' + plural(entries.length, 'запись', 'записи', 'записей') + ' о занятиях.');
|
||||||
if (photos.length) autoBio.push('Фотографий: ' + photos.length + '.');
|
if (photos.length) autoBio.push('Фотографий: ' + photos.length + '.');
|
||||||
if (files.length) autoBio.push('Работ в портфолио: ' + files.length + '.');
|
if (files.length) autoBio.push('Работ в портфолио: ' + files.length + '.');
|
||||||
const tagList = arr(profile.tags).map(t => str(t, 40)).filter(Boolean);
|
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 heroTags = tagList.length ? tagList : education.slice(0, 6).map(m => m.title);
|
||||||
const avatar = showPhotos && photoCards.length ? photoCards[0] : null;
|
const avatar = showPhotos && photoCards.length ? (photoCards[1] || photoCards[0]) : null;
|
||||||
const cover = showPhotos && photoCards.length > 1 ? photoCards[1] : 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 eduYears = education.map(m => m.period).filter(Boolean);
|
||||||
const idLabel = student.id ? 'ID S-' + String(student.id).padStart(4, '0') : null;
|
const idLabel = student.id ? 'ID S-' + String(student.id).padStart(4, '0') : null;
|
||||||
|
|
||||||
return {
|
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,
|
skillGroups, experience, contacts, achievements, facts, heroTags,
|
||||||
avatar, cover, idLabel,
|
avatar, cover, idLabel, mainPhotoCard, profilePhotoCard,
|
||||||
role: str(profile.role, 200) || (education.length ? 'Ученик программы: ' + education[0].title : null),
|
role: str(profile.role, 200) || (education.length ? 'Резидент программы: ' + education[0].title : null),
|
||||||
bio: str(profile.bio, 2000) || (autoBio.join(' ') || null),
|
bio: str(profile.bio, 2000) || (autoBio.join(' ') || null),
|
||||||
quote: str(profile.quote, 300),
|
quote: str(profile.quote, 300),
|
||||||
status: str(profile.status, 60) || 'Активно учится',
|
status: str(profile.status, 60) || 'Активно учится',
|
||||||
@@ -563,10 +578,11 @@ svg{display:block;flex:none}
|
|||||||
.media--wide{aspect-ratio:21/9}
|
.media--wide{aspect-ratio:21/9}
|
||||||
.media[data-g]{cursor:zoom-in}
|
.media[data-g]{cursor:zoom-in}
|
||||||
.hero{padding:clamp(20px,2.6vw,34px);overflow:hidden;position:relative}
|
.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__cover{border-radius:var(--radius-sm);margin-bottom:clamp(24px,3vw,40px);position:relative;z-index:0}
|
||||||
.hero__grid{display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);gap:clamp(18px,2.6vw,34px);align-items:end}
|
.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}}
|
@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__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__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}
|
.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 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--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__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__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__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__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{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:hover{color:var(--text);border-color:var(--stroke-strong)}
|
||||||
.task__extra a svg{width:12px;height:12px}
|
.task__extra a svg{width:12px;height:12px}
|
||||||
@@ -790,12 +806,16 @@ function emptyBlock(icon, title, text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function heroHtml(v) {
|
function heroHtml(v) {
|
||||||
const cover = v.cover
|
const cover = v.mainPhotoCard
|
||||||
|
? mediaBlock(v.mainPhotoCard.src, 'wide', 'Обложка профиля', v.mainPhotoCard.g)
|
||||||
|
: (v.cover
|
||||||
? mediaBlock(v.cover.src, 'wide', 'Обложка профиля', v.cover.g)
|
? mediaBlock(v.cover.src, 'wide', 'Обложка профиля', v.cover.g)
|
||||||
: phBlock('Обложка профиля', 1440, 480, 'wallpaper', 'border-radius:var(--radius-sm)');
|
: phBlock('Обложка профиля', 1440, 480, 'wallpaper', 'border-radius:var(--radius-sm)'));
|
||||||
const avatar = v.avatar
|
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)
|
? mediaBlock(v.avatar.src, 'square', v.name, v.avatar.g)
|
||||||
: phBlock('Фото ученика', 480, 480, 'user-round');
|
: phBlock('Фото резидента', 480, 480, 'user-round'));
|
||||||
const eyebrow = [v.idLabel, str(v.periodLabel, 60)].filter(Boolean).join(' · ');
|
const eyebrow = [v.idLabel, str(v.periodLabel, 60)].filter(Boolean).join(' · ');
|
||||||
const actions = [];
|
const actions = [];
|
||||||
actions.push('<button class="btn btn--primary" type="button" data-action="print">' + iconTag('printer') + 'Печать / PDF</button>');
|
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) {
|
function skillsHtml(v) {
|
||||||
if (!v.skillGroups.length) {
|
if (!v.skillGroups.length) {
|
||||||
return emptyBlock('sparkles', 'Навыки пока не заполнены',
|
return emptyBlock('sparkles', 'Навыки пока не заполнены',
|
||||||
'Список навыков и технологий можно добавить в данных профиля ученика.');
|
'Список навыков и технологий можно добавить в данных профиля резидента.');
|
||||||
}
|
}
|
||||||
return v.skillGroups.map(g => '<article class="card skill-group reveal">' +
|
return v.skillGroups.map(g => '<article class="card skill-group reveal">' +
|
||||||
'<div class="skill-group__head">' + iconTag(g.icon) + '<span>' + escapeHtml(g.title) + '</span>' +
|
'<div class="skill-group__head">' + iconTag(g.icon) + '<span>' + escapeHtml(g.title) + '</span>' +
|
||||||
@@ -865,7 +885,7 @@ function projectMedia(p) {
|
|||||||
function projectsHtml(v) {
|
function projectsHtml(v) {
|
||||||
if (!v.projects.length) {
|
if (!v.projects.length) {
|
||||||
return emptyBlock('folder-git-2', 'Работ в портфолио пока нет',
|
return emptyBlock('folder-git-2', 'Работ в портфолио пока нет',
|
||||||
'Файлы появятся здесь, как только ученик прикрепит их к записи занятия.');
|
'Файлы появятся здесь, как только резидент прикрепит их к записи занятия.');
|
||||||
}
|
}
|
||||||
return v.projects.map(p => '<article class="card prj-card">' +
|
return v.projects.map(p => '<article class="card prj-card">' +
|
||||||
projectMedia(p) +
|
projectMedia(p) +
|
||||||
@@ -885,7 +905,7 @@ function projectsHtml(v) {
|
|||||||
function photosHtml(v) {
|
function photosHtml(v) {
|
||||||
if (!v.photoCards.length) {
|
if (!v.photoCards.length) {
|
||||||
return emptyBlock('image', 'Фотографий пока нет',
|
return emptyBlock('image', 'Фотографий пока нет',
|
||||||
'Фотографии занятий появятся здесь, как только ученик отправит их вместе с записью.');
|
'Фотографии занятий появятся здесь, как только резидент отправит их вместе с записью.');
|
||||||
}
|
}
|
||||||
return v.photoCards.map(p => '<figure class="card photo-card reveal">' +
|
return v.photoCards.map(p => '<figure class="card photo-card reveal">' +
|
||||||
mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) +
|
mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) +
|
||||||
@@ -896,22 +916,37 @@ function photosHtml(v) {
|
|||||||
'</figure>').join('');
|
'</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) {
|
function tasksHtml(v) {
|
||||||
if (!v.tasks.length) {
|
if (!v.tasks.length) {
|
||||||
return emptyBlock('list-checks', 'Записей о занятиях пока нет',
|
return emptyBlock('list-checks', 'Записей о занятиях пока нет',
|
||||||
'Как только ученик отправит запись через форму, она появится в этом разделе.');
|
'Как только резидент отправит запись через форму, она появится в этом разделе.');
|
||||||
}
|
}
|
||||||
return v.tasks.map(t => {
|
return v.tasks.map(t => {
|
||||||
const checkCls = t.done ? '' : (t.icon === 'triangle-alert' ? ' task__check--error' : ' task__check--progress');
|
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>'];
|
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.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) + '"' +
|
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>');
|
(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">' +
|
return '<article class="card task reveal">' +
|
||||||
'<span class="task__check' + checkCls + '">' + iconTag(t.icon) + '</span>' +
|
'<span class="task__check' + checkCls + '">' + iconTag(t.icon) + '</span>' +
|
||||||
'<div class="task__main"><p class="task__title">' + escapeHtml(t.title) + '</p>' +
|
'<div class="task__main"><p class="task__title">' + escapeHtml(t.title) + '</p>' +
|
||||||
(t.sub ? '<p class="task__sub">' + escapeHtml(t.sub) + '</p>' : '') + '</div>' +
|
(t.sub ? '<p class="task__sub">' + escapeHtml(t.sub) + '</p>' : '') + '</div>' +
|
||||||
'<div class="task__right">' + right.join('') + '</div>' +
|
'<div class="task__right">' + right.join('') + '</div>' +
|
||||||
|
(extra || '') +
|
||||||
'</article>';
|
'</article>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -920,16 +955,20 @@ function aboutHtml(v) {
|
|||||||
const bio = '<article class="card about__bio reveal">' +
|
const bio = '<article class="card about__bio reveal">' +
|
||||||
'<h3 class="section__title" style="font-size:1.15rem">' + iconTag('user-round') + 'Коротко о себе</h3>' +
|
'<h3 class="section__title" style="font-size:1.15rem">' + iconTag('user-round') + 'Коротко о себе</h3>' +
|
||||||
(v.bio ? '<p class="about__text">' + escapeHtml(v.bio) + '</p>'
|
(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.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>' : '') +
|
(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>';
|
'</article>';
|
||||||
|
|
||||||
const photoCard = '<article class="card about__card reveal"><h3>' + iconTag('image') + 'Фото автора</h3>' +
|
const photoCard = '<article class="card about__card reveal"><h3>' + iconTag('image') + 'Фото автора</h3>' +
|
||||||
(v.avatar
|
(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>'
|
? '<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')) +
|
: phBlock('Фото автора', 800, 600, 'image')))) +
|
||||||
'<p class="meta">' + iconTag('info') + (v.avatar ? 'Фотография из архива занятий' : 'Место под реальное фото автора профиля') + '</p></article>';
|
'<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>' +
|
const contactsCard = '<article class="card about__card reveal"><h3>' + iconTag('at-sign') + 'Контакты</h3>' +
|
||||||
(v.contacts.length
|
(v.contacts.length
|
||||||
@@ -950,9 +989,10 @@ function footerHtml(v, generatedAt) {
|
|||||||
const links = [];
|
const links = [];
|
||||||
if (v.entries.length) links.push('<a href="#lessons">Занятия</a>');
|
if (v.entries.length) links.push('<a href="#lessons">Занятия</a>');
|
||||||
if (v.photoCards.length) links.push('<a href="#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>');
|
if (v.projects.length) links.push('<a href="#projects">Работы</a>');
|
||||||
links.push('<a href="#top">Наверх</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>' +
|
' · сформировано ' + escapeHtml(longDate(generatedAt)) + '</p>' +
|
||||||
'<nav class="footer__links" aria-label="Футер">' + links.join('') + '</nav></footer>';
|
'<nav class="footer__links" aria-label="Футер">' + links.join('') + '</nav></footer>';
|
||||||
}
|
}
|
||||||
@@ -960,6 +1000,7 @@ function footerHtml(v, generatedAt) {
|
|||||||
function navItems(v) {
|
function navItems(v) {
|
||||||
const items = ['<a class="nav-link" href="#profile" data-spy="profile">' + iconTag('user-round') + '<span>Профиль</span></a>'];
|
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.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>');
|
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.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.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) {
|
function educationHtml(v) {
|
||||||
if (!v.education.length) {
|
if (!v.education.length) {
|
||||||
return emptyBlock('book-open', 'Темы программы ещё не заполнены',
|
return emptyBlock('book-open', 'Темы программы ещё не заполнены',
|
||||||
'Добавьте темы модулей в админке (раздел «Темы модулей») или отметьте пройденные модули в данных профиля ученика.');
|
'Добавьте темы модулей в админке (раздел «Темы модулей») или отметьте пройденные модули в данных профиля резидента.');
|
||||||
}
|
}
|
||||||
return v.education.map(m => {
|
return v.education.map(m => {
|
||||||
const p = m.progress;
|
const p = m.progress;
|
||||||
@@ -1003,7 +1044,7 @@ function educationHtml(v) {
|
|||||||
function experienceHtml(v) {
|
function experienceHtml(v) {
|
||||||
if (!v.experience.length) {
|
if (!v.experience.length) {
|
||||||
return emptyBlock('briefcase', 'Опыт работы пока не заполнен',
|
return emptyBlock('briefcase', 'Опыт работы пока не заполнен',
|
||||||
'Кейсы, стажировки и командные проекты можно добавить в данных профиля ученика — они появятся в этом архиве.');
|
'Кейсы, стажировки и командные проекты можно добавить в данных профиля резидента — они появятся в этом архиве.');
|
||||||
}
|
}
|
||||||
return v.experience.map(e => {
|
return v.experience.map(e => {
|
||||||
const media = e.media ? mediaBlock(e.media, '16x9', e.title, -1) : phBlock('Фото кейса', 1280, 720, 'image');
|
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 = [];
|
const sections = [];
|
||||||
sections.push('<section class="section" id="profile">' + heroHtml(v) + '</section>');
|
sections.push('<section class="section" id="profile">' + heroHtml(v) + '</section>');
|
||||||
sections.push('<section class="section" id="stats">' +
|
sections.push('<section class="section" id="stats">' +
|
||||||
sectionHead('activity', 'Информация об авторе', 'Показатели ученика по данным журнала занятий и архива работ.') +
|
sectionHead('activity', 'Информация об авторе', 'Показатели резидента по данным журнала занятий и архива работ.') +
|
||||||
(periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') +
|
(periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') +
|
||||||
'<div class="stats">' + statsHtml(v) + '</div></section>');
|
'<div class="stats">' + statsHtml(v) + '</div></section>');
|
||||||
sections.push('<section class="section" id="photos">' +
|
sections.push('<section class="section" id="photos">' +
|
||||||
sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') +
|
sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') +
|
||||||
'<div class="photos">' + photosHtml(v) + '</div></section>');
|
'<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">' +
|
sections.push('<section class="section" id="education">' +
|
||||||
sectionHead('book-open', 'Образование', 'Темы программы, которые проходил ученик, и прогресс по ним.') +
|
sectionHead('book-open', 'Образование', 'Темы программы, которые проходил резидент, и прогресс по ним.') +
|
||||||
'<div class="modules">' + educationHtml(v) + '</div></section>');
|
'<div class="modules">' + educationHtml(v) + '</div></section>');
|
||||||
sections.push('<section class="section" id="experience">' +
|
sections.push('<section class="section" id="experience">' +
|
||||||
railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') +
|
railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') +
|
||||||
'<div class="rail-wrap"><div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0" aria-label="Лента опыта">' +
|
'<div class="rail-wrap"><div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0" aria-label="Лента опыта">' +
|
||||||
experienceHtml(v) + '</div></div></section>');
|
experienceHtml(v) + '</div></div></section>');
|
||||||
sections.push('<section class="section" id="skills">' +
|
sections.push('<section class="section" id="skills">' +
|
||||||
sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые ученик уже проходил.') +
|
sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые резидент уже проходил.') +
|
||||||
'<div class="skills">' + skillsHtml(v) + '</div></section>');
|
'<div class="skills">' + skillsHtml(v) + '</div></section>');
|
||||||
sections.push('<section class="section" id="projects">' +
|
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="Лента работ">' +
|
'<div class="rail-wrap"><div class="rail rail--third" id="projectsRail" data-rail="projectsRail" tabindex="0" aria-label="Лента работ">' +
|
||||||
projectsHtml(v) + '</div></div></section>');
|
projectsHtml(v) + '</div></div></section>');
|
||||||
sections.push('<section class="section" id="lessons">' +
|
sections.push('<section class="section" id="lessons">' +
|
||||||
sectionHead('list-checks', 'Занятия', 'Записи журнала: что ученик делал, фотографии и файлы занятий.') +
|
sectionHead('list-checks', 'Занятия', 'Записи журнала: что резидент делал, фотографии и файлы занятий.') +
|
||||||
'<div class="tasks">' + tasksHtml(v) + '</div></section>');
|
'<div class="tasks">' + tasksHtml(v) + '</div></section>');
|
||||||
sections.push('<section class="section" id="about">' +
|
sections.push('<section class="section" id="about">' +
|
||||||
sectionHead('info', 'Немного о себе', 'Рассказ ученика, контакты и достижения.') +
|
sectionHead('info', 'Немного о себе', 'Рассказ резидента, контакты и достижения.') +
|
||||||
'<div class="about">' + aboutHtml(v) + '</div></section>');
|
'<div class="about">' + aboutHtml(v) + '</div></section>');
|
||||||
|
|
||||||
const topbar = '<header class="topbar" id="top"><div class="topbar__inner">' +
|
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">' + 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>' +
|
'<nav class="topbar__nav" id="mainNav" aria-label="Меню профиля">' + navItems(v) + '</nav>' +
|
||||||
'<div class="topbar__actions">' +
|
'<div class="topbar__actions">' +
|
||||||
'<button class="icon-btn" type="button" data-action="print" title="Печать / сохранить в PDF">' + iconTag('printer') + '</button>' +
|
'<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>' +
|
'<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>' +
|
'<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="Открыть меню">' +
|
'<button class="icon-btn topbar__burger" id="burger" type="button" aria-expanded="false" aria-controls="mainNav" aria-label="Открыть меню">' +
|
||||||
iconTag('menu') + '</button></div></div></header>';
|
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' +
|
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' +
|
'<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.googleapis.com">\n' +
|
||||||
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\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' +
|
'<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' +
|
||||||
|
|||||||
Reference in New Issue
Block a user