feat(students): аватары в списке студентов с превью и лайтбоксом
Круглый аватар слева от поля имени: миниатюра главного фото через thumbSrc, иначе инициалы. Клик по фото открывает imgModal с полным размером, клик по пустому — карточку профиля для загрузки. Аватар синхронизируется после загрузки, смены и удаления фото.
This commit is contained in:
@@ -161,6 +161,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.item .name-input:focus{border-color:var(--accent)}
|
.item .name-input:focus{border-color:var(--accent)}
|
||||||
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
||||||
.item .group-select:focus{border-color:var(--accent)}
|
.item .group-select:focus{border-color:var(--accent)}
|
||||||
|
.student-main{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
|
||||||
|
.student-main .name-input{flex:1;min-width:0}
|
||||||
|
.student-avatar{width:40px;height:40px;flex:0 0 auto;border-radius:50%;overflow:hidden;border:1px solid var(--border);background:var(--bg);padding:0;cursor:zoom-in;display:flex;align-items:center;justify-content:center;transition:border-color .15s,opacity .15s}
|
||||||
|
.student-avatar img{width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.student-avatar:hover{border-color:var(--accent);opacity:.9}
|
||||||
|
.student-avatar.empty{cursor:pointer;border-style:dashed}
|
||||||
|
.student-avatar.empty span{font-size:.75rem;font-weight:700;color:var(--muted);letter-spacing:.02em;user-select:none}
|
||||||
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
|
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
|
||||||
.item.expired:hover{opacity:.85}
|
.item.expired:hover{opacity:.85}
|
||||||
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
|
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
|
||||||
|
|||||||
+70
-2
@@ -30,6 +30,53 @@ async function loadStudents() {
|
|||||||
renderStudents();
|
renderStudents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function studentInitials(name) {
|
||||||
|
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (!parts.length) return 'У';
|
||||||
|
const a = parts[0].charAt(0);
|
||||||
|
const b = parts.length > 1 ? parts[1].charAt(0) : (parts[0].length > 1 ? parts[0].charAt(1) : '');
|
||||||
|
return (a + b).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoFullSrc(p) {
|
||||||
|
if (!p) return '';
|
||||||
|
return p.startsWith('/') ? `${API}${p}` : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStudentAvatar(el, path, name) {
|
||||||
|
if (!el) return;
|
||||||
|
if (path) {
|
||||||
|
el.classList.remove('empty');
|
||||||
|
el.dataset.studentPhoto = path;
|
||||||
|
el.title = 'Увеличить фото';
|
||||||
|
el.innerHTML = `<img src="${esc(thumbSrc(path))}" alt="" loading="lazy">`;
|
||||||
|
const img = el.querySelector('img');
|
||||||
|
if (img) img.addEventListener('error', () => setStudentAvatar(el, '', name), { once: true });
|
||||||
|
} else {
|
||||||
|
el.classList.add('empty');
|
||||||
|
delete el.dataset.studentPhoto;
|
||||||
|
el.title = 'Фото не загружено — нажмите, чтобы загрузить';
|
||||||
|
el.innerHTML = `<span>${esc(studentInitials(name))}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncStudentAvatar(id, photoPath) {
|
||||||
|
const el = document.getElementById(`savatar-${id}`);
|
||||||
|
if (!el) return;
|
||||||
|
const nameInput = document.getElementById(`sname-${id}`);
|
||||||
|
const name = nameInput ? nameInput.value : '';
|
||||||
|
if (photoPath !== undefined) {
|
||||||
|
setStudentAvatar(el, photoPath || '', name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setStudentAvatar(el, data.photo_path || '', name);
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
function renderStudents() {
|
function renderStudents() {
|
||||||
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
|
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
|
||||||
const gid = document.getElementById('studentGroupFilter').value;
|
const gid = document.getElementById('studentGroupFilter').value;
|
||||||
@@ -43,7 +90,10 @@ function renderStudents() {
|
|||||||
else {
|
else {
|
||||||
list.innerHTML = slice.map(s => `
|
list.innerHTML = slice.map(s => `
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
|
<div class="student-main">
|
||||||
|
<button type="button" class="student-avatar" id="savatar-${s.id}" data-avatar-student="${s.id}"></button>
|
||||||
|
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
|
||||||
|
</div>
|
||||||
<select class="group-select" id="sgroup-${s.id}"></select>
|
<select class="group-select" id="sgroup-${s.id}"></select>
|
||||||
<div class="student-actions">
|
<div class="student-actions">
|
||||||
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
||||||
@@ -55,7 +105,10 @@ function renderStudents() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
|
slice.forEach(s => {
|
||||||
|
setStudentAvatar(document.getElementById(`savatar-${s.id}`), s.photo_path, s.name);
|
||||||
|
loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
|
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
|
||||||
}
|
}
|
||||||
@@ -244,6 +297,7 @@ async function uploadStudentPhoto(file) {
|
|||||||
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
|
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await loadStudentPhotos(profileDataId);
|
await loadStudentPhotos(profileDataId);
|
||||||
|
syncStudentAvatar(profileDataId);
|
||||||
showToast('Фото загружено');
|
showToast('Фото загружено');
|
||||||
} else {
|
} else {
|
||||||
const e = await res.json().catch(() => ({}));
|
const e = await res.json().catch(() => ({}));
|
||||||
@@ -262,6 +316,7 @@ async function setMainStudentPhoto(pid) {
|
|||||||
const r = await res.json();
|
const r = await res.json();
|
||||||
document.getElementById('pfPhoto').value = r.photo_path || '';
|
document.getElementById('pfPhoto').value = r.photo_path || '';
|
||||||
renderStudentPhotos();
|
renderStudentPhotos();
|
||||||
|
syncStudentAvatar(profileDataId, r.photo_path || '');
|
||||||
showToast('Главное фото обновлено');
|
showToast('Главное фото обновлено');
|
||||||
} else {
|
} else {
|
||||||
const e = await res.json().catch(() => ({}));
|
const e = await res.json().catch(() => ({}));
|
||||||
@@ -279,6 +334,7 @@ async function delStudentPhoto(pid) {
|
|||||||
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
|
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await loadStudentPhotos(profileDataId);
|
await loadStudentPhotos(profileDataId);
|
||||||
|
syncStudentAvatar(profileDataId);
|
||||||
showToast('Фото удалено');
|
showToast('Фото удалено');
|
||||||
} else {
|
} else {
|
||||||
const e = await res.json().catch(() => ({}));
|
const e = await res.json().catch(() => ({}));
|
||||||
@@ -522,7 +578,19 @@ document.getElementById('attachModal').addEventListener('click', e => { if (e.ta
|
|||||||
document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd);
|
document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd);
|
||||||
document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach);
|
document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach);
|
||||||
|
|
||||||
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') document.getElementById('imgModal').classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('studentList').addEventListener('click', e => {
|
document.getElementById('studentList').addEventListener('click', e => {
|
||||||
|
const av = e.target.closest('[data-avatar-student]');
|
||||||
|
if (av) {
|
||||||
|
const p = av.dataset.studentPhoto;
|
||||||
|
if (p) showImg(photoFullSrc(p));
|
||||||
|
else openProfileData(parseInt(av.dataset.avatarStudent, 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const sv = e.target.closest('[data-save-student]');
|
const sv = e.target.closest('[data-save-student]');
|
||||||
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
|
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
|
||||||
const cp = e.target.closest('[data-copy-student]');
|
const cp = e.target.closest('[data-copy-student]');
|
||||||
|
|||||||
@@ -209,6 +209,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="imgModal">
|
||||||
|
<img id="imgModalSrc" alt="">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user