feat(students): multi-photo gallery, group photos in export, KIBERone rebrand
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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 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>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user