Files
WhatIDo/public/js/journal.js
T
dev 5874e2b619 feat: photo jobs apply/reject flow with applied flag in history
- DB: add photo_jobs.applied column (init + migration + ensure)
- Worker: generate preview only (after_path), no longer mutates entry
- New POST /api/entries/:id/photo/jobs/:jobId/apply — apply done job
  result to entry (backs up current photo, marks applied)
- New POST /api/entries/:id/photo/jobs/:jobId/reject — discard result,
  delete temp file, mark rejected
- saveEnhance: apply AI result directly when sliders are at defaults
- Photo history: '✓ Применить' action for unapplied done AI jobs;
  'rejected' status label
- sweepOrphanedUploads keeps done-not-applied preview files
2026-09-17 18:51:26 +03:00

1447 lines
60 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 enhanceAiJobId = 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();
enhanceAiJobId = jobId;
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;
enhanceAiJobId = 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 isDefaultEnhanceParams() {
const p = enhanceParams();
return p.brightness === 100 && p.contrast === 100 && p.saturate === 100 && p.denoise === 0 && p.sharp === 0;
}
async function applyAiResult() {
if (!enhanceAiJobId || !enhanceEntryId) return false;
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/apply`, {
method: 'POST',
headers: hdr(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Ошибка применения ИИ-результата');
enhanceAiResultPath = null;
enhanceAiJobId = null;
return true;
}
function cleanupAiPreview() {
if (!enhanceEntryId) return;
if (enhanceAiJobId) {
fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/reject`, {
method: 'POST',
headers: hdr(),
}).catch(() => {});
} else if (enhanceAiResultPath) {
fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/preview`, {
method: 'DELETE',
headers: hdrJson(),
body: JSON.stringify({ path: enhanceAiResultPath }),
}).catch(() => {});
}
enhanceAiResultPath = null;
enhanceAiJobId = null;
}
function closeEnhance() {
cleanupAiPreview();
document.getElementById('enhanceModal').classList.remove('open');
document.getElementById('photoHistoryModal').classList.remove('open');
enhanceEntryId = null;
enhanceSourceImg = null;
enhanceAiResultPath = null;
}
function photoJobThumb(p) {
if (!p) return '';
if (p.startsWith('/uploads/.originals/')) return `${API}${p}`;
return `${API}/uploads/thumb/${p.split('/').pop()}`;
}
const PHOTO_JOB_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат' };
const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка', rejected: 'Отклонено' };
async function loadPhotoHistory() {
if (!enhanceEntryId) return;
const list = document.getElementById('photoHistoryList');
list.innerHTML = '<div style="color:var(--muted);font-size:.85rem">Загрузка...</div>';
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs`, { headers: hdr() });
if (!res.ok) throw new Error('Ошибка загрузки истории');
const rows = await res.json();
if (!rows.length) {
list.innerHTML = '<div style="color:var(--muted);font-size:.85rem">Изменений фотографии пока не было.</div>';
return;
}
list.innerHTML = rows.map(r => {
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(photoJobThumb(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(photoJobThumb(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
const canApply = r.status === 'done' && r.applied === false && r.action === 'ai' && !!r.after_path;
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback' && !canApply;
const err = r.error ? `<div style="color:#ef4444;font-size:.75rem;margin-top:4px">${esc(r.error)}</div>` : '';
const time = r.finished_at || r.created_at;
return `<div style="display:flex;gap:10px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:10px">
<div style="display:flex;flex-direction:column;gap:4px;text-align:center">
${before}
<span style="font-size:.65rem;color:var(--muted)">было</span>
</div>
<div style="color:var(--muted)">→</div>
<div style="display:flex;flex-direction:column;gap:4px;text-align:center">
${after}
<span style="font-size:.65rem;color:var(--muted)">стало</span>
</div>
<div style="flex:1;min-width:0">
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}</span>
</div>
${err}
</div>
${canRollback ? `<button type="button" class="btn-primary ghost" style="white-space:nowrap" data-job-rollback="${r.id}">↩ Откатить</button>` : ''}
${canApply ? `<button type="button" class="btn-primary" style="white-space:nowrap" data-job-apply="${r.id}">✓ Применить</button>` : ''}
</div>`;
}).join('');
} catch (e) {
list.innerHTML = `<div style="color:#ef4444;font-size:.85rem">${esc(e.message)}</div>`;
}
}
function openPhotoHistory() {
document.getElementById('photoHistoryTitle').textContent = `История изменений фотографии — запись #${enhanceEntryId}`;
document.getElementById('photoHistoryModal').classList.add('open');
loadPhotoHistory();
}
async function rollbackPhotoJob(jobId) {
if (!enhanceEntryId || enhanceSaving) return;
if (!confirm('Откатить фотографию к сохранённой версии? Текущая версия сохранится в истории.')) return;
enhanceSaving = true;
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${jobId}/rollback`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Ошибка отката');
showToast('Фотография отката');
document.getElementById('photoHistoryModal').classList.remove('open');
const mp = data.photo_path;
document.getElementById('enhanceOrig').src = `${API}${mp}`;
showEnhanceResult(false);
setEnhanceLoadingText('Загрузка...');
const img = new Image();
img.onload = () => {
enhanceSourceImg = img;
renderEnhancePreview();
};
img.onerror = () => setEnhanceLoadingText('Не удалось загрузить фото');
img.src = `${API}${mp}`;
refreshEntryCard(enhanceEntryId);
} catch (e) {
alert(e.message);
} finally {
enhanceSaving = false;
}
}
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 (enhanceAiResultPath && isDefaultEnhanceParams()) {
await applyAiResult();
engineLabel = 'ИИ';
done = true;
} else 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('enhanceHistoryBtn').addEventListener('click', openPhotoHistory);
document.getElementById('photoHistoryCloseBtn').addEventListener('click', () => document.getElementById('photoHistoryModal').classList.remove('open'));
document.getElementById('photoHistoryModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
document.getElementById('photoHistoryList').addEventListener('click', e => {
const btn = e.target.closest('[data-job-rollback]');
if (btn) {
rollbackPhotoJob(parseInt(btn.dataset.jobRollback, 10));
return;
}
const applyBtn = e.target.closest('[data-job-apply]');
if (applyBtn) applyPhotoJob(parseInt(applyBtn.dataset.jobApply, 10));
});
async function applyPhotoJob(jobId) {
if (!enhanceEntryId || enhanceSaving) return;
if (!confirm('Применить обработанную фотографию к записи? Текущая версия сохранится в истории.')) return;
enhanceSaving = true;
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${jobId}/apply`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Ошибка применения');
showToast('Фотография применена');
const mp = data.photo_path;
const origImg = document.getElementById('enhanceOrig');
if (origImg && mp) origImg.src = `${API}${mp}`;
loadPhotoHistory();
refreshEntryCard(enhanceEntryId);
} catch (e) {
alert(e.message);
} finally {
enhanceSaving = false;
}
}
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();
}
})();