Files
WhatIDo/public/js/journal.js
T
dev 27a2238082 feat: compare slider UI for photo enhance modal
- Side-by-side before/after comparison with draggable divider
- Zoom (scroll wheel) and pan when zoomed in, double-click to reset
- Sliders moved to sidebar panel, responsive layout
- Original/Result badges on the comparison view
- Helper functions: setEnhanceClip, showEnhanceResult, applyEnhanceTransform
2026-09-17 17:02:35 +03:00

1292 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let currentEntries = [];
let seenEntryIds = new Set();
let highlightNewIds = new Set();
let highlightClearTimer = null;
let editId = null;
let editGroups = [];
let groupsLoaded = false;
let studentsLoaded = false;
const PAGE_SIZE = 20;
let page = 1;
let totalEntries = 0;
let searchTimer = null;
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
let aiCorrecting = false;
let editDeletePhoto = false;
let linkSettings = null;
async function loadLinkSettings() {
if (!linkSettings) {
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
linkSettings = await res.json().catch(() => ({}));
}
return linkSettings;
}
function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function mainPhotoPath(e) {
if (e.photo_path) return e.photo_path;
if (e.photos && e.photos.length) return e.photos[0].photo_path;
return null;
}
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => {
const link = isImageFile(f.name)
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`;
}).join('')}</div>`;
}
const AI_STATUS_LABELS = {
pending: 'ИИ: в очереди',
processing: 'ИИ: проверяет…',
done: 'ИИ: проверено',
skipped: 'ИИ: пропущено',
error: 'ИИ: ошибка',
reverted: 'ИИ: возвращён оригинал',
};
const AI_STATUS_ICONS = {
pending: 'clock',
processing: 'loader',
done: 'circle-check',
skipped: 'skip-forward',
error: 'circle-alert',
reverted: 'undo',
};
function aiBadge(e) {
if (!e || !e.ai_status) return '';
const st = e.ai_status;
const label = AI_STATUS_LABELS[st] || `ИИ: ${st}`;
const icon = AI_STATUS_ICONS[st] || 'sparkles';
return `<span class="ai-badge ai-badge-ic ai-badge-${esc(st)}" data-ai-badge="${e.id}" title="${esc(label)}" role="img" aria-label="${esc(label)}"><i data-lucide="${icon}"></i></span>`;
}
function findFile(id) {
for (const e of currentEntries) {
const f = (e.files || []).find(x => x.id === id);
if (f) return f;
}
return null;
}
async function detachFile(id) {
const f = findFile(id);
if (!f) return;
if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) {
showToast('Файл откреплён');
if (f.entry_id) refreshEntryCard(f.entry_id);
else loadEntries();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
}
}
function gridEntryHTML(e) {
const mp = mainPhotoPath(e);
return `
<div class="card${highlightNewIds.has(e.id) ? ' entry-appear' : ' entry-enter'}">
${mp ? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
${filesHTML(e.files)}
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
</div>
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
<button class="del" data-del="${e.id}" title="Удалить">&times;</button>
</div>
`;
}
function cardEntryHTML(e) {
const mp = mainPhotoPath(e);
return `
<div class="journal-card${highlightNewIds.has(e.id) ? ' entry-appear' : ' entry-enter'}">
${mp
? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
${e.group_name ? `<span class="group-badge" title="${esc(e.group_name)}">${esc(e.group_name)}</span>` : ''}
<div class="body">
<div class="name">${esc(e.student_name)}</div>
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
${filesHTML(e.files)}
</div>
<div class="foot">
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
<span>
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
<button class="del" data-del="${e.id}" title="Удалить">&times;</button>
</span>
</div>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!currentEntries.length) {
const fmt = d => d ? d.split('-').reverse().join('.') : '';
const from = fmt(document.getElementById('dateFrom').value);
const to = fmt(document.getElementById('dateTo').value);
let range = '';
if (from && to) range = ` за ${from} – ${to}`;
else if (from) range = ` с ${from}`;
else if (to) range = ` по ${to}`;
grid.className = 'grid';
grid.innerHTML = `<div class="empty">Ничего не найдено${range}</div>`;
return;
}
if (view === 'cards') {
grid.className = 'journal-cards';
grid.innerHTML = currentEntries.map(cardEntryHTML).join('');
} else {
grid.className = 'grid';
grid.innerHTML = currentEntries.map(gridEntryHTML).join('');
}
const items = Array.from(grid.children);
items.forEach((el, i) => {
el.style.animationDelay = `${Math.min(i * 45, 500)}ms`;
});
renderIcons();
}
function refreshEntryCardDom(e) {
const btn = document.querySelector(`[data-edit="${e.id}"]`);
const card = btn ? btn.closest('.card, .journal-card') : null;
if (!card) return;
const tmp = document.createElement('div');
tmp.innerHTML = (view === 'cards' ? cardEntryHTML(e) : gridEntryHTML(e)).trim();
const fresh = tmp.firstElementChild;
fresh.classList.remove('entry-enter', 'entry-appear');
fresh.style.animationDelay = '';
card.replaceWith(fresh);
renderIcons();
}
async function refreshEntryCard(id) {
const res = await fetch(`${API}/api/entries/${id}`, { headers: hdr() });
if (!res.ok) return false;
const e = await res.json();
const idx = currentEntries.findIndex(x => x.id === e.id);
if (idx !== -1) currentEntries[idx] = e;
refreshEntryCardDom(e);
return true;
}
function setView(v) {
view = v === 'cards' ? 'cards' : 'list';
localStorage.setItem('journalView', view);
document.querySelectorAll('#viewToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.view === view);
});
renderGrid();
}
function setDefaultDates() {
const today = new Date().toISOString().slice(0, 10);
document.getElementById('dateFrom').value = today;
document.getElementById('dateTo').value = today;
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
while (sel.options.length > 1) sel.remove(1);
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id; o.textContent = g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
async function loadEntries() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
const data = await res.json();
const firstLoad = seenEntryIds.size === 0;
const newIds = firstLoad ? [] : data.entries.filter(e => !seenEntryIds.has(e.id)).map(e => e.id);
if (newIds.length) {
highlightNewIds = new Set([...highlightNewIds, ...newIds]);
if (highlightClearTimer) clearTimeout(highlightClearTimer);
highlightClearTimer = setTimeout(() => { highlightNewIds = new Set(); }, 8000);
}
data.entries.forEach(e => seenEntryIds.add(e.id));
currentEntries = data.entries;
totalEntries = data.total;
renderGrid();
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
}
function goPage(p) {
page = p;
loadEntries();
}
let liveSource = null;
let liveReconnectTimer = null;
let entriesRefreshTimer = null;
function applyAiUpdate(update) {
const e = currentEntries.find(x => x.id === update.id);
if (!e) return;
e.ai_status = update.ai_status;
e.ai_error = update.ai_error || null;
if (update.description !== null && update.description !== undefined) e.description = update.description;
if (update.description_ai !== null && update.description_ai !== undefined) e.description_ai = update.description_ai;
if (update.description_original !== null && update.description_original !== undefined) e.description_original = update.description_original;
if (update.ai_status === 'done' || update.ai_status === 'reverted') e.ai_checked_at = new Date().toISOString();
document.querySelectorAll(`[data-ai-badge="${update.id}"]`).forEach(badgeEl => {
const desc = badgeEl.closest('.desc');
if (desc && update.description !== null && update.description !== undefined) {
desc.textContent = e.description;
desc.insertAdjacentHTML('beforeend', aiBadge(e));
} else {
badgeEl.outerHTML = aiBadge(e);
}
});
renderIcons();
const editBadge = document.getElementById('editAiStatus');
if (editId === update.id && editBadge && editBadge.style.display !== 'none') {
renderEditAiStatus(e);
}
if (editId === update.id) {
const ta = document.getElementById('editDesc');
if (ta && document.activeElement !== ta && update.description !== null && update.description !== undefined) {
ta.value = e.description;
}
}
}
function connectLiveUpdates() {
if (liveSource) { liveSource.close(); liveSource = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
liveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
liveSource.addEventListener('entries_changed', () => {
if (entriesRefreshTimer) return;
entriesRefreshTimer = setTimeout(() => {
entriesRefreshTimer = null;
showToast('Поступила новая запись — список обновлён');
page = 1;
loadEntries();
}, 300);
});
liveSource.addEventListener('ai_status', ev => {
try { applyAiUpdate(JSON.parse(ev.data)); } catch (err) {}
});
liveSource.onerror = () => {
if (liveSource) { liveSource.close(); liveSource = null; }
if (liveReconnectTimer) clearTimeout(liveReconnectTimer);
liveReconnectTimer = setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (liveSource) liveSource.close(); });
}
async function openEdit(id) {
const e = currentEntries.find(x => x.id === id);
if (!e) return;
editId = id;
editDeletePhoto = false;
fillAutocomplete();
document.getElementById('editName').value = e.student_name;
document.getElementById('editDesc').value = e.description;
renderEditAiStatus(e);
const sel = document.getElementById('editGroup');
if (!editGroups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
editGroups = await res.json();
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
sel.value = e.group_id;
renderEditPhotos(e.photos || [], e.photo_path);
document.getElementById('editModal').classList.add('open');
}
function renderEditPhotos(photos, mainPath) {
const preview = document.getElementById('editPhotoPreview');
preview.innerHTML = '';
if (!photos.length) return;
photos.forEach((p) => {
const isMain = p.photo_path === mainPath;
const div = document.createElement('div');
div.className = 'file-preview-item';
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
div.dataset.photoId = p.id;
const applyStyle = isMain
? 'background:#16a34a;border:none;color:#fff;border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:default;white-space:nowrap'
: 'background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:pointer;white-space:nowrap';
div.innerHTML = `<img src="${thumbSrc(p.photo_path)}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.photo_path.split('/').pop())}</span> <button type="button" class="apply-photo-btn" style="${applyStyle}" title="${isMain ? 'Главное фото записи' : 'Сделать главным фото записи'}" ${isMain ? 'disabled' : ''}>${isMain ? 'Применено ✓' : 'Применить'}</button> <button type="button" class="delete-entry-photo" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.1rem;line-height:1" title="Удалить фото">✕</button>`;
preview.appendChild(div);
const thumb = div.querySelector('img');
const showMissing = () => {
const ph = document.createElement('span');
ph.textContent = 'нет файла';
ph.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center;background:var(--bg);border:1px dashed var(--border);border-radius:4px;font-size:.6rem;color:var(--muted);text-align:center;line-height:1.1;flex:0 0 auto';
thumb.replaceWith(ph);
};
thumb.addEventListener('error', showMissing);
if (thumb.complete && thumb.naturalWidth === 0) showMissing();
div.querySelector('.delete-entry-photo').addEventListener('click', async () => {
if (!confirm('Удалить это фото?')) return;
const res = await fetch(`${API}/api/entries/${editId}/photos/${p.id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
div.remove();
showToast('Фото удалено');
refreshEntryCard(editId);
} else {
alert('Ошибка удаления');
}
});
});
}
async function applyMainPhoto(btn) {
const div = btn.closest('.file-preview-item');
const photoId = div?.dataset.photoId;
if (!photoId) return;
btn.disabled = true;
const res = await fetch(`${API}/api/entries/${editId}/photos/${photoId}/main`, { method: 'PUT', headers: hdr() });
if (!res.ok) {
btn.disabled = false;
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
return;
}
document.querySelectorAll('#editPhotoPreview .apply-photo-btn').forEach(b => {
b.disabled = false;
b.textContent = 'Применить';
b.title = 'Сделать главным фото записи';
b.style.background = 'var(--card)';
b.style.color = 'var(--text)';
});
btn.disabled = true;
btn.textContent = 'Применено ✓';
btn.title = 'Главное фото записи';
btn.style.background = '#16a34a';
btn.style.color = '#fff';
showToast('Главное фото обновлено');
refreshEntryCard(editId);
}
function closeEdit() {
document.getElementById('editModal').classList.remove('open');
document.getElementById('editFiles').value = '';
document.getElementById('editFilesPreview').innerHTML = '';
document.getElementById('editPhoto').value = '';
document.getElementById('editPhotoPreview').innerHTML = '';
document.getElementById('editAiStatus').style.display = 'none';
editId = null;
editDeletePhoto = false;
}
let enhanceEntryId = null;
let enhanceSourceImg = null;
let enhanceTimer = null;
let enhanceSaving = false;
let enhanceEngine = 'auto';
let enhanceEngineLoaded = false;
let photoAiEnabled = false;
let photoAiPollTimer = null;
let enhanceAiResultPath = null;
let enhanceClipPos = 50;
function setEnhanceClip(p) {
enhanceClipPos = Math.min(100, Math.max(0, p));
document.getElementById('enhanceTop').style.clipPath = `inset(0 0 0 ${enhanceClipPos}%)`;
document.getElementById('enhanceDivider').style.left = enhanceClipPos + '%';
}
function showEnhanceResult(show) {
document.getElementById('enhanceTop').style.display = show ? '' : 'none';
document.getElementById('enhanceLoading').style.display = show ? 'none' : '';
}
function setEnhanceLoadingText(text) {
const el = document.getElementById('enhanceLoading');
el.style.display = '';
el.textContent = text;
}
let enhanceZoom = 1;
let enhancePanX = 0;
let enhancePanY = 0;
function applyEnhanceTransform() {
const t = `translate(${enhancePanX}px, ${enhancePanY}px) scale(${enhanceZoom})`;
document.getElementById('enhanceStage').style.transform = t;
document.getElementById('enhanceInner').style.transform = t;
const area = document.getElementById('enhanceCompare');
area.classList.toggle('zoomed', enhanceZoom > 1);
if (enhanceZoom <= 1) area.classList.remove('panning');
}
function clampEnhancePan() {
const rect = document.getElementById('enhanceCompare').getBoundingClientRect();
const minX = rect.width * (1 - enhanceZoom);
const minY = rect.height * (1 - enhanceZoom);
enhancePanX = Math.min(0, Math.max(minX, enhancePanX));
enhancePanY = Math.min(0, Math.max(minY, enhancePanY));
}
function resetEnhanceZoom() {
enhanceZoom = 1;
enhancePanX = 0;
enhancePanY = 0;
applyEnhanceTransform();
}
(function initEnhanceDrag() {
const area = document.getElementById('enhanceCompare');
let dragging = false;
let dividerDrag = false;
let lastX = 0;
let lastY = 0;
const move = ev => {
const rect = area.getBoundingClientRect();
setEnhanceClip(((ev.clientX - rect.left) / rect.width) * 100);
};
area.addEventListener('pointerdown', ev => {
dividerDrag = !!ev.target.closest('#enhanceDivider');
dragging = true;
lastX = ev.clientX;
lastY = ev.clientY;
area.setPointerCapture(ev.pointerId);
if (!dividerDrag && enhanceZoom === 1) move(ev);
});
area.addEventListener('pointermove', ev => {
if (!dragging) return;
if (dividerDrag || enhanceZoom === 1) {
move(ev);
} else {
area.classList.add('panning');
enhancePanX += ev.clientX - lastX;
enhancePanY += ev.clientY - lastY;
lastX = ev.clientX;
lastY = ev.clientY;
clampEnhancePan();
applyEnhanceTransform();
}
});
const end = () => {
dragging = false;
area.classList.remove('panning');
};
area.addEventListener('pointerup', end);
area.addEventListener('pointercancel', end);
area.addEventListener('wheel', ev => {
ev.preventDefault();
const rect = area.getBoundingClientRect();
const cx = ev.clientX - rect.left;
const cy = ev.clientY - rect.top;
const prev = enhanceZoom;
const next = Math.min(5, Math.max(1, enhanceZoom * (ev.deltaY < 0 ? 1.15 : 1 / 1.15)));
if (next === prev) return;
enhanceZoom = next;
enhancePanX = cx - (cx - enhancePanX) * (next / prev);
enhancePanY = cy - (cy - enhancePanY) * (next / prev);
clampEnhancePan();
applyEnhanceTransform();
}, { passive: false });
area.addEventListener('dblclick', resetEnhanceZoom);
})();
async function loadEnhanceEngine() {
if (enhanceEngineLoaded) return;
enhanceEngineLoaded = true;
try {
const r = await fetch(`${API}/api/public-settings`);
const f = await r.json();
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
photoAiEnabled = f.photo_ai_enabled === 'true';
} catch (e) {}
}
async function runPhotoAi() {
if (!enhanceEntryId || enhanceSaving) return;
const entryId = enhanceEntryId;
if (!confirm('ИИ-восстановление фото (Real-ESRGAN). Может занять до минуты на CPU. Продолжить?')) return;
enhanceSaving = true;
const btn = document.getElementById('enhanceAiBtn');
btn.disabled = true;
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка запуска ИИ');
}
const { jobId } = await res.json();
showEnhanceResult(false);
setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)');
btn.textContent = '⏳ ИИ...';
const result = await new Promise((resolve, reject) => {
photoAiPollTimer = setInterval(async () => {
try {
const r = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/${jobId}`, { headers: hdr() });
if (!r.ok) throw new Error('Ошибка опроса статуса');
const s = await r.json();
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
else if (s.status === 'error') { clearInterval(photoAiPollTimer); reject(new Error(s.error || 'Ошибка ИИ')); }
} catch (e) { clearInterval(photoAiPollTimer); reject(e); }
}, 2000);
});
showToast('ИИ результат готов');
enhanceAiResultPath = result.photo_path;
const aiImg = new Image();
aiImg.onload = () => {
enhanceSourceImg = aiImg;
['Brightness', 'Contrast', 'Saturate'].forEach(k => {
document.getElementById('enhance' + k).value = 100;
document.getElementById('enhance' + k + 'Val').textContent = '100%';
});
document.getElementById('enhanceSharp').value = 0;
document.getElementById('enhanceSharpVal').textContent = '0%';
document.getElementById('enhanceDenoise').value = 0;
document.getElementById('enhanceDenoiseVal').textContent = '0%';
renderEnhancePreview();
};
aiImg.onerror = () => { showToast('Не удалось загрузить результат ИИ'); };
aiImg.src = `${API}${result.photo_path}`;
} catch (e) {
showEnhanceResult(true);
alert(e.message);
} finally {
enhanceSaving = false;
btn.disabled = false;
btn.textContent = '🤖 ИИ';
}
}
async function restoreOriginal() {
if (!enhanceEntryId || enhanceSaving) return;
if (!confirm('Вернуть исходную фотографию? Текущая улучшенная версия будет удалена.')) return;
enhanceSaving = true;
const btn = document.getElementById('enhanceRestoreBtn');
btn.disabled = true;
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/restore-original`, { method: 'POST', headers: hdr() });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка восстановления');
}
const savedId = enhanceEntryId;
showToast('Оригинал возвращён');
closeEnhance();
refreshEntryCard(savedId);
} catch (e) {
alert(e.message);
} finally {
enhanceSaving = false;
btn.disabled = false;
}
}
function openEnhance(id) {
const e = currentEntries.find(x => x.id === id);
if (!e) return;
const mp = mainPhotoPath(e);
if (!mp) { showToast('У записи нет фото'); return; }
enhanceEntryId = id;
enhanceSourceImg = null;
enhanceAiResultPath = null;
['Brightness', 'Contrast', 'Saturate'].forEach(k => {
const el = document.getElementById('enhance' + k);
el.value = 100;
document.getElementById('enhance' + k + 'Val').textContent = '100%';
});
const sharp = document.getElementById('enhanceSharp');
sharp.value = 0;
document.getElementById('enhanceSharpVal').textContent = '0%';
const orig = document.getElementById('enhanceOrig');
orig.src = `${API}${mp}`;
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
loadEnhanceEngine().then(() => {
document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none';
});
const canvas = document.getElementById('enhanceCanvas');
setEnhanceClip(50);
resetEnhanceZoom();
showEnhanceResult(false);
setEnhanceLoadingText('Загрузка...');
const img = new Image();
img.onload = () => {
enhanceSourceImg = img;
renderEnhancePreview();
};
img.onerror = () => {
setEnhanceLoadingText('Не удалось загрузить фото');
};
img.src = `${API}${mp}`;
document.getElementById('enhanceModal').classList.add('open');
}
function cleanupAiPreview() {
if (!enhanceAiResultPath || !enhanceEntryId) return;
const toClean = enhanceAiResultPath;
const entryId = enhanceEntryId;
fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/preview`, {
method: 'DELETE',
headers: hdrJson(),
body: JSON.stringify({ path: toClean }),
}).catch(() => {});
enhanceAiResultPath = null;
}
function closeEnhance() {
cleanupAiPreview();
document.getElementById('enhanceModal').classList.remove('open');
enhanceEntryId = null;
enhanceSourceImg = null;
enhanceAiResultPath = null;
}
function medianFilterPass(ctx, w, h) {
const data = ctx.getImageData(0, 0, w, h);
const src = data.data;
const out = new Uint8ClampedArray(src);
const row = w * 4;
const win = new Array(9);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * row + x * 4;
for (let ch = 0; ch < 3; ch++) {
win[0] = src[i - row - 4 + ch]; win[1] = src[i - row + ch]; win[2] = src[i - row + 4 + ch];
win[3] = src[i - 4 + ch]; win[4] = src[i + ch]; win[5] = src[i + 4 + ch];
win[6] = src[i + row - 4 + ch]; win[7] = src[i + row + ch]; win[8] = src[i + row + 4 + ch];
win.sort((a, b) => a - b);
out[i + ch] = win[4];
}
}
}
data.data.set(out);
ctx.putImageData(data, 0, 0);
}
function sharpenPass(ctx, w, h, sharp) {
const data = ctx.getImageData(0, 0, w, h);
const src = data.data;
const out = new Uint8ClampedArray(src);
const row = w * 4;
const a = (sharp / 100) * 1.5;
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * row + x * 4;
for (let ch = 0; ch < 3; ch++) {
const v = (1 + 4 * a) * src[i + ch]
- a * src[i - 4 + ch] - a * src[i + 4 + ch]
- a * src[i - row + ch] - a * src[i + row + ch];
out[i + ch] = v;
}
}
}
data.data.set(out);
ctx.putImageData(data, 0, 0);
}
function drawEnhanced(ctx, img, w, h) {
const b = document.getElementById('enhanceBrightness').value;
const c = document.getElementById('enhanceContrast').value;
const s = document.getElementById('enhanceSaturate').value;
const dn = parseInt(document.getElementById('enhanceDenoise').value, 10);
const sp = parseInt(document.getElementById('enhanceSharp').value, 10);
ctx.filter = `brightness(${b}%) contrast(${c}%) saturate(${s}%)`;
ctx.drawImage(img, 0, 0, w, h);
ctx.filter = 'none';
if (dn > 0) {
medianFilterPass(ctx, w, h);
if (dn > 70) medianFilterPass(ctx, w, h);
}
if (sp > 0) sharpenPass(ctx, w, h, sp);
}
function renderEnhancePreview() {
if (!enhanceSourceImg) return;
const img = enhanceSourceImg;
const scale = Math.min(1, 1600 / img.naturalWidth);
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.getElementById('enhanceCanvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
drawEnhanced(ctx, img, w, h);
showEnhanceResult(true);
}
function scheduleEnhancePreview() {
document.getElementById('enhanceBrightnessVal').textContent = document.getElementById('enhanceBrightness').value + '%';
document.getElementById('enhanceContrastVal').textContent = document.getElementById('enhanceContrast').value + '%';
document.getElementById('enhanceSaturateVal').textContent = document.getElementById('enhanceSaturate').value + '%';
document.getElementById('enhanceDenoiseVal').textContent = document.getElementById('enhanceDenoise').value + '%';
document.getElementById('enhanceSharpVal').textContent = document.getElementById('enhanceSharp').value + '%';
if (enhanceTimer) clearTimeout(enhanceTimer);
enhanceTimer = setTimeout(renderEnhancePreview, 120);
}
function enhanceParams() {
return {
brightness: parseInt(document.getElementById('enhanceBrightness').value, 10),
contrast: parseInt(document.getElementById('enhanceContrast').value, 10),
saturate: parseInt(document.getElementById('enhanceSaturate').value, 10),
denoise: parseInt(document.getElementById('enhanceDenoise').value, 10),
sharp: parseInt(document.getElementById('enhanceSharp').value, 10),
};
}
function estimateNoise(img) {
const aw = 160;
const ah = Math.max(1, Math.round(img.naturalHeight * aw / img.naturalWidth));
const ac = document.createElement('canvas');
ac.width = aw;
ac.height = ah;
const actx = ac.getContext('2d');
actx.drawImage(img, 0, 0, aw, ah);
const d = actx.getImageData(0, 0, aw, ah).data;
let sum = 0;
let n = 0;
for (let y = 0; y < ah - 1; y++) {
for (let x = 0; x < aw - 1; x++) {
const i = (y * aw + x) * 4;
const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
const lr = 0.299 * d[i + 4] + 0.587 * d[i + 5] + 0.114 * d[i + 6];
const lb = 0.299 * d[i + aw * 4] + 0.587 * d[i + aw * 4 + 1] + 0.114 * d[i + aw * 4 + 2];
sum += Math.abs(l - lr) + Math.abs(l - lb);
n += 2;
}
}
return n ? sum / n : 0;
}
function autoEnhance() {
if (!enhanceSourceImg) return;
const img = enhanceSourceImg;
const aw = 160;
const ah = Math.max(1, Math.round(img.naturalHeight * aw / img.naturalWidth));
const ac = document.createElement('canvas');
ac.width = aw;
ac.height = ah;
const actx = ac.getContext('2d');
actx.drawImage(img, 0, 0, aw, ah);
const d = actx.getImageData(0, 0, aw, ah).data;
const hist = new Array(256).fill(0);
let total = 0;
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] < 16) continue;
hist[Math.round(0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2])]++;
total++;
}
const pct = p => {
let acc = 0;
const target = total * p;
for (let v = 0; v < 256; v++) {
acc += hist[v];
if (acc >= target) return v;
}
return 255;
};
let lo = pct(0.005);
let hi = pct(0.995);
if (hi - lo < 30) {
lo = Math.max(0, lo - 15);
hi = Math.min(255, hi + 15);
}
const contrast = Math.round(Math.min(150, Math.max(100, 25500 / (hi - lo))));
const brightness = Math.round(Math.min(120, Math.max(85, 12800 / Math.max(1, (lo + hi) / 2))));
const noise = estimateNoise(img);
let denoise = 0;
let sharp = 30;
if (noise > 6) {
denoise = Math.min(50, Math.round(noise * 6));
sharp = 15;
}
const saturate = 106;
document.getElementById('enhanceBrightness').value = brightness;
document.getElementById('enhanceContrast').value = contrast;
document.getElementById('enhanceSaturate').value = saturate;
document.getElementById('enhanceDenoise').value = denoise;
document.getElementById('enhanceSharp').value = sharp;
scheduleEnhancePreview();
}
async function saveEnhance() {
if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return;
await loadEnhanceEngine();
const btn = document.getElementById('enhanceSaveBtn');
enhanceSaving = true;
btn.disabled = true;
btn.textContent = 'Сохранение...';
const entryId = enhanceEntryId;
try {
let done = false;
let engineLabel = 'клиент';
if (enhanceEngine !== 'client' && !enhanceAiResultPath) {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ params: enhanceParams() }),
signal: ctrl.signal,
});
clearTimeout(timer);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка сервера');
}
engineLabel = 'сервер';
done = true;
} catch (e) {
if (enhanceEngine === 'server') throw e;
}
}
if (!done) {
const img = enhanceSourceImg;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
drawEnhanced(ctx, img, canvas.width, canvas.height);
const blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/jpeg', 0.92));
const fd = new FormData();
fd.append('photo', blob, 'enhanced.jpg');
const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка сохранения');
}
}
showToast(`Фото улучшено (${engineLabel}) и сохранено`);
closeEnhance();
refreshEntryCard(entryId);
} catch (e) {
alert(e.message);
} finally {
enhanceSaving = false;
btn.disabled = false;
btn.textContent = 'Применить';
}
}
async function aiCorrect() {
if (aiCorrecting) return;
const textarea = document.getElementById('editDesc');
const text = textarea.value.trim();
if (!text) { showToast('Нет текста для исправления'); return; }
const btn = document.getElementById('aiCorrectBtn');
const modal = document.getElementById('aiCorrectModal');
const suggestionEl = document.getElementById('aiSuggestion');
aiCorrecting = true;
btn.disabled = true;
btn.textContent = '⏳';
suggestionEl.textContent = 'Обработка...';
modal.classList.add('open');
try {
const res = await fetch(`${API}/api/ai/correct`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ text })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Ошибка ИИ');
}
const data = await res.json();
suggestionEl.textContent = data.suggestion || text;
} catch (e) {
suggestionEl.textContent = 'Ошибка: ' + e.message;
showToast('ИИ недоступен: ' + e.message);
} finally {
aiCorrecting = false;
btn.disabled = false;
btn.textContent = '✨';
}
}
function closeAiCorrect() {
document.getElementById('aiCorrectModal').classList.remove('open');
}
function acceptAiCorrect() {
const suggestion = document.getElementById('aiSuggestion').textContent;
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
document.getElementById('editDesc').value = suggestion;
}
closeAiCorrect();
}
function renderEditAiStatus(e) {
const el = document.getElementById('editAiStatus');
if (!e || !e.ai_status) { el.style.display = 'none'; el.innerHTML = ''; return; }
el.style.display = 'block';
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru'));
let html = `<span>${esc(parts.join(' · '))}</span>`;
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
el.innerHTML = html;
const rb = document.getElementById('aiRevertBtn');
if (rb) rb.addEventListener('click', () => revertAi(e.id));
const cb = document.getElementById('aiRecheckBtn');
if (cb) cb.addEventListener('click', () => recheckAi(e.id));
}
async function revertAi(id) {
if (!confirm('Вернуть исходный текст записи (до ИИ-проверки)?')) return;
const res = await fetch(`${API}/api/entries/${id}/ai/revert`, { method: 'POST', headers: hdr() });
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
const e = currentEntries.find(x => x.id === id);
if (e) {
if (e.description_original) e.description = e.description_original;
e.description_ai = null;
e.ai_status = 'reverted';
e.ai_error = null;
}
showToast('Оригинал восстановлен');
closeEdit();
refreshEntryCard(id);
}
async function recheckAi(id) {
const res = await fetch(`${API}/api/entries/${id}/ai/recheck`, { method: 'POST', headers: hdr() });
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
const e = currentEntries.find(x => x.id === id);
if (e) {
e.ai_status = 'pending';
e.ai_error = null;
}
showToast('Отправлено на ИИ-проверку');
closeEdit();
refreshEntryCard(id);
}
async function saveEdit() {
if (!editId) return;
const name = document.getElementById('editName').value.trim();
const group_id = document.getElementById('editGroup').value;
const description = document.getElementById('editDesc').value.trim();
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
const formData = new FormData();
formData.append('student_name', name);
formData.append('group_id', group_id);
formData.append('description', description);
const photoFiles = document.getElementById('editPhoto').files;
for (const f of photoFiles) formData.append('photo', f);
const res = await fetch(`${API}/api/entries/${editId}`, {
method: 'PUT',
headers: hdr(),
body: formData
});
if (!res.ok) { const err = await res.json(); alert(err.error); return; }
const files = document.getElementById('editFiles').files;
if (files.length) {
const filesFormData = new FormData();
for (const f of files) filesFormData.append('files', f);
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
method: 'POST',
headers: hdr(),
body: filesFormData
});
if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => ({}));
alert(err.error || 'Ошибка загрузки файлов');
return;
}
}
const id = editId;
closeEdit();
const refreshed = await refreshEntryCard(id);
if (!refreshed) loadEntries();
showToast('Запись обновлена');
}
async function delEntry(id) {
if (!confirm('Переместить запись в корзину? Файлы и фото сохранятся.')) return;
await fetch(`${API}/api/entries/${id}`, { method: 'DELETE', headers: hdr() });
showToast('Запись перемещена в корзину');
loadEntries();
}
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadEntries(); }, 300);
});
document.getElementById('editForm').addEventListener('submit', (e) => {
e.preventDefault();
saveEdit();
});
document.getElementById('editFiles').addEventListener('change', (e) => {
const preview = document.getElementById('editFilesPreview');
preview.innerHTML = '';
Array.from(e.target.files).forEach((f, i) => {
const div = document.createElement('div');
div.className = 'file-preview-item';
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
div.innerHTML = `<span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" data-remove-file="${i}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
preview.appendChild(div);
});
});
document.getElementById('editPhoto').addEventListener('change', (e) => {
const preview = document.getElementById('editPhotoPreview');
Array.from(e.target.files).forEach(f => {
const div = document.createElement('div');
div.className = 'file-preview-item';
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
const url = URL.createObjectURL(f);
div.innerHTML = `<img src="${url}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" data-remove-new-photo="${f.name}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
preview.appendChild(div);
});
});
function removeEditFile(index) {
const input = document.getElementById('editFiles');
const dt = new DataTransfer();
Array.from(input.files).forEach((f, i) => { if (i !== index) dt.items.add(f); });
input.files = dt.files;
input.dispatchEvent(new Event('change'));
}
async function exportCSV() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
const entries = (await res.json()).entries;
if (!entries.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия и имя', 'Группа', 'Описание', 'Дата', 'Фото']];
entries.forEach(e => {
rows.push([
e.student_name,
e.group_name,
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
e.photo_path ? `${API}${e.photo_path}` : ''
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const bom = '\uFEFF';
const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const dateLabel = df && dt ? `${df}_${dt}` : 'all';
a.download = `journal_${dateLabel}.csv`;
a.click();
URL.revokeObjectURL(url);
}
let linkGroups = [];
let linkStudentsLoaded = false;
let createdLinkUrl = '';
function fmtD(d) { return d ? d.split('-').reverse().join('.') : 'весь период'; }
async function openLinkCreate() {
const gid = document.getElementById('groupFilter').value;
if (!gid) { alert('Сначала выберите группу в фильтре'); return; }
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (!linkGroups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
linkGroups = await res.json();
}
if (!linkStudentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
linkStudentsLoaded = true;
}
const currentStudent = document.getElementById('studentFilter').value;
const g = linkGroups.find(x => String(x.id) === String(gid));
document.getElementById('linkPreview').innerHTML =
`Будут показаны записи: <b>${esc(g ? g.name : '')}</b><br>Период: <b>${fmtD(df)}</b> — <b>${fmtD(dt)}</b>`;
document.getElementById('linkGroupPreview').value = g ? g.name : '';
const dfl = df.split('-').reverse().join('.');
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
document.getElementById('linkStudent').value = currentStudent;
document.getElementById('linkExpiresDays').value = '7';
document.getElementById('linkShowNames').checked = false;
document.getElementById('linkPassword').value = '';
document.getElementById('linkMessage').value = '';
document.getElementById('linkUrl').value = '';
const s = await loadLinkSettings();
document.getElementById('linkShowNames').checked = s.share_show_student_names !== 'false';
document.getElementById('linkShowMessage').checked = s.share_show_student_message === 'true';
document.getElementById('linkShowDate').checked = s.share_show_entry_date === 'true';
document.getElementById('linkShowPhotos').checked = s.share_show_group_photos !== 'false';
document.getElementById('linkCreateView').style.display = '';
document.getElementById('linkResultView').style.display = 'none';
document.getElementById('linkModal').classList.add('open');
}
function closeLinkCreate() {
document.getElementById('linkModal').classList.remove('open');
}
async function createLink() {
const name = document.getElementById('linkNameInput').value.trim();
const group_id = document.getElementById('groupFilter').value;
const student_name = document.getElementById('linkStudent').value;
const date_from = document.getElementById('dateFrom').value;
const date_to = document.getElementById('dateTo').value;
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
const show_student_names = document.getElementById('linkShowNames').checked;
const access_password = document.getElementById('linkPassword').value;
const message = document.getElementById('linkMessage').value.trim();
const link_url = document.getElementById('linkUrl').value.trim();
if (!name) { alert('Введите название'); return; }
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
const res = await fetch(`${API}/api/links`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, show_student_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null, show_student_message: document.getElementById('linkShowMessage').checked, show_entry_date: document.getElementById('linkShowDate').checked, show_group_photos: document.getElementById('linkShowPhotos').checked })
});
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
const link = await res.json();
createdLinkUrl = `${location.origin}/s/${link.token}`;
document.getElementById('linkUrlResult').value = createdLinkUrl;
document.getElementById('linkCreateView').style.display = 'none';
document.getElementById('linkResultView').style.display = '';
showToast('Ссылка создана');
}
function copyLink() { copyText(createdLinkUrl); }
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); });
document.getElementById('linkModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeLinkCreate(); });
document.getElementById('grid').addEventListener('click', e => {
const det = e.target.closest('[data-detach]');
if (det) { detachFile(parseInt(det.dataset.detach, 10)); return; }
const ed = e.target.closest('[data-edit]');
if (ed) { openEdit(parseInt(ed.dataset.edit, 10)); return; }
const en = e.target.closest('[data-enhance]');
if (en) { openEnhance(parseInt(en.dataset.enhance, 10)); return; }
const dl = e.target.closest('[data-del]');
if (dl) { delEntry(parseInt(dl.dataset.del, 10)); }
});
document.getElementById('editFilesPreview').addEventListener('click', e => {
const btn = e.target.closest('[data-remove-file]');
if (btn) removeEditFile(parseInt(btn.dataset.removeFile, 10));
});
document.getElementById('editPhotoPreview').addEventListener('click', e => {
const removeBtn = e.target.closest('[data-remove-photo]');
if (removeBtn) {
document.getElementById('editPhoto').value = '';
document.getElementById('editPhotoPreview').innerHTML = '';
return;
}
const applyBtn = e.target.closest('.apply-photo-btn');
if (applyBtn) { applyMainPhoto(applyBtn); return; }
const removeNewBtn = e.target.closest('[data-remove-new-photo]');
if (removeNewBtn) {
const fileName = removeNewBtn.dataset.removeNewPhoto;
const input = document.getElementById('editPhoto');
const dt = new DataTransfer();
Array.from(input.files).forEach(f => { if (f.name !== fileName) dt.items.add(f); });
input.files = dt.files;
removeNewBtn.closest('.file-preview-item').remove();
}
});
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
document.getElementById('enhanceCancelBtn').addEventListener('click', closeEnhance);
document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance);
document.getElementById('enhanceAiBtn').addEventListener('click', runPhotoAi);
document.getElementById('enhanceRestoreBtn').addEventListener('click', restoreOriginal);
document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance);
document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceSaturate').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceDenoise').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceSharp').addEventListener('input', scheduleEnhancePreview);
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
document.getElementById('aiCorrectModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAiCorrect();
});
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
document.getElementById('editCancelBtn').addEventListener('click', closeEdit);
document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate);
document.getElementById('linkCreateSaveBtn').addEventListener('click', createLink);
document.getElementById('linkCopyBtn').addEventListener('click', copyLink);
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
document.querySelectorAll('#viewToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.view === view);
});
setDefaultDates();
loadGroupOptions();
loadStudentOptions();
loadEntries();
connectLiveUpdates();
}
})();