From 4623358f21f1b339469c7d9b76685bd6e5e27854 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 23 Sep 2026 13:32:09 +0300 Subject: [PATCH] feat(students): multi-photo gallery, group photos in export, KIBERone rebrand --- db/init.sql | 9 ++ db/migration.sql | 9 ++ public/admin.css | 7 ++ public/js/students.js | 118 +++++++++++++++++++++++++ public/students.html | 95 ++++++++++---------- server.js | 201 ++++++++++++++++++++++++++++++++++++++++-- student-profile.html | 7 +- student-report.js | 134 ++++++++++++++++++---------- 8 files changed, 475 insertions(+), 105 deletions(-) diff --git a/db/init.sql b/db/init.sql index b0922e9..9ec683e 100644 --- a/db/init.sql +++ b/db/init.sql @@ -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 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 ( id SERIAL PRIMARY KEY, student_name VARCHAR(150) NOT NULL, diff --git a/db/migration.sql b/db/migration.sql index 5a31d58..6b6a3c5 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -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 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 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); diff --git a/public/admin.css b/public/admin.css index 4a55848..3bd1a31 100644 --- a/public/admin.css +++ b/public/admin.css @@ -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)} diff --git a/public/js/students.js b/public/js/students.js index 20423e5..22b6e94 100644 --- a/public/js/students.js +++ b/public/js/students.js @@ -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 = 'Пока нет загруженных фото'; + return; + } + wrap.innerHTML = profilePhotos.map(p => ` +
+ + ${p.photo_path === main ? 'Главная' : ''} + +
+ `).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 () => { diff --git a/public/students.html b/public/students.html index 6a3a658..1b90070 100644 --- a/public/students.html +++ b/public/students.html @@ -131,78 +131,77 @@