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

This commit is contained in:
dev
2026-09-23 13:32:09 +03:00
parent 5e2533b876
commit 4623358f21
8 changed files with 475 additions and 105 deletions
+7
View File
@@ -196,6 +196,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.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.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)}
+118
View File
@@ -183,10 +183,12 @@ async function exportStudentProfile(id) {
}
let profileDataId = null;
let profilePhotos = [];
function openProfileData(id) {
profileDataId = id;
loadGroupOptions();
loadStudentPhotos(id);
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
@@ -199,6 +201,94 @@ function closeProfileData() {
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) {
const p = profile || {};
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('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(); });
(async () => {
+47 -48
View File
@@ -131,78 +131,77 @@
</div>
<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>
<form id="profileDataForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Роль / направление</label>
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
</div>
<div class="settings-field" style="flex:1">
<label>Статус</label>
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
</div>
<form id="profileDataForm" style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
<div class="settings-field">
<label>Роль / направление</label>
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Примечание к статусу</label>
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
</div>
<div class="settings-field" style="flex:1">
<label>Город</label>
<input type="text" id="pfCity" class="settings-input">
</div>
<div class="settings-field">
<label>Статус</label>
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Наставник</label>
<input type="text" id="pfMentor" class="settings-input">
</div>
<div class="settings-field" style="flex:1">
<label>В академии с</label>
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
</div>
<div class="settings-field">
<label>Примечание к статусу</label>
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Город</label>
<input type="text" id="pfCity" class="settings-input">
</div>
<div class="settings-field">
<label>Наставник</label>
<input type="text" id="pfMentor" class="settings-input">
</div>
<div class="settings-field">
<label>В академии с</label>
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
</div>
<div class="settings-field" style="grid-column:1/-1">
<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 class="settings-field full">
<div class="settings-field">
<label>Цитата</label>
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Теги (через запятую)</label>
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
<textarea id="pfContacts" class="settings-input" style="min-height:70px" placeholder="mail | maria@example.com | mailto:maria@example.com&#10;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&#10;send | @nickname | https://t.me/nickname"></textarea>
<span class="hint">Иконки: mail, send, phone, github, globe, link, at-sign</span>
</div>
<div class="settings-field full">
<div class="settings-field">
<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 class="settings-field full">
<div class="settings-field">
<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 class="settings-field full">
<div class="settings-field">
<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 class="settings-field full">
<div class="settings-field">
<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 class="settings-field full">
<label>Фото ученика — путь вида /uploads/имя_файла (необязательно)</label>
<input type="text" id="pfPhoto" class="settings-input" placeholder="/uploads/1726740000000-ab12cd34.jpg">
<span class="hint">Если не указано, берётся первое фото из записей ученика</span>
<div class="settings-field" style="grid-column:1/-1">
<label>Фотографии ученика</label>
<input type="hidden" id="pfPhoto">
<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 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="submit" class="btn-primary">Сохранить</button>
</div>