- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS - Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption) - Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local - Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416 - Add video modal in journal UI with player and download fallback - Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots - Extend MIME types for media
1670 lines
68 KiB
JavaScript
1670 lines
68 KiB
JavaScript
let currentEntries = [];
|
||
let seenEntryIds = new Set();
|
||
let highlightNewIds = new Set();
|
||
let highlightClearTimer = null;
|
||
let editId = null;
|
||
let editGroups = [];
|
||
let editModules = [];
|
||
let moduleNames = new Map();
|
||
let moduleFilterId = '';
|
||
let moduleSearchTimer = null;
|
||
let editModuleSearchTimer = null;
|
||
let modulesLoaded = false;
|
||
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 editAiSuggestion = null;
|
||
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 isPlayableVideoFile(name) {
|
||
return /\.(mp4|m4v|webm|ogv)$/i.test(name || '');
|
||
}
|
||
|
||
function isVideoFile(name) {
|
||
return /\.(mp4|m4v|webm|ogv|mov|mkv|avi|mpeg|mpg|3gp|ts)$/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 url = `${API}/api/files/${f.token}`;
|
||
let link;
|
||
if (isImageFile(f.name)) {
|
||
link = `<a class="f-link" href="#" data-img="${url}" title="Открыть">${esc(f.name)}</a>`;
|
||
} else if (isPlayableVideoFile(f.name)) {
|
||
link = `<a class="f-link" href="${url}?play=1" data-video="${url}?play=1" data-video-name="${esc(f.name)}" data-video-token="${f.token}" title="Смотреть">${esc(f.name)}</a>`;
|
||
} else {
|
||
const note = isVideoFile(f.name) ? 'Скачать (браузер не умеет играть этот формат)' : 'Скачать';
|
||
link = `<a class="f-link" href="${url}" download title="${note}">${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)}${e.module_name ? ' · ' + esc(e.module_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="Удалить">×</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>` : ''}
|
||
${e.module_name ? `<span class="group-badge" style="left:auto;right:8px;background:rgba(37,99,235,.8)" title="Модуль: ${esc(e.module_name)}">${esc(e.module_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="Удалить">×</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;
|
||
}
|
||
|
||
function renderModuleOptions(datalistId, list) {
|
||
list.forEach(m => moduleNames.set(m.name, m.id));
|
||
const dl = document.getElementById(datalistId);
|
||
if (!dl) return;
|
||
dl.innerHTML = '';
|
||
list.forEach(m => {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.name;
|
||
dl.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
async function fetchModules(q, limit) {
|
||
const p = new URLSearchParams();
|
||
if (q) p.set('search', q);
|
||
p.set('limit', String(limit));
|
||
p.set('active', '1');
|
||
const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() });
|
||
if (!res.ok) return [];
|
||
const data = await res.json().catch(() => ({}));
|
||
return data.modules || [];
|
||
}
|
||
|
||
async function resolveModuleId(name) {
|
||
const n = (name || '').trim();
|
||
if (!n) return '';
|
||
if (moduleNames.has(n)) return String(moduleNames.get(n));
|
||
const list = await fetchModules(n, 10);
|
||
const lower = n.toLowerCase();
|
||
const exact = list.find(m => m.name.toLowerCase() === lower);
|
||
return exact ? String(exact.id) : null;
|
||
}
|
||
|
||
async function loadModuleOptions() {
|
||
editModules = await fetchModules('', 50);
|
||
modulesLoaded = true;
|
||
renderModuleOptions('moduleFilterList', editModules);
|
||
}
|
||
|
||
async function applyModuleFilter() {
|
||
const inp = document.getElementById('moduleFilter');
|
||
const resolved = await resolveModuleId(inp.value);
|
||
if (resolved === null) {
|
||
inp.value = '';
|
||
moduleFilterId = '';
|
||
showToast('Модуль не найден — фильтр сброшен');
|
||
} else {
|
||
moduleFilterId = resolved;
|
||
}
|
||
page = 1;
|
||
loadEntries();
|
||
}
|
||
|
||
function fillEditModuleInput(entry) {
|
||
const inp = document.getElementById('editModule');
|
||
if (!inp) return;
|
||
if (entry.module_name && entry.module_id) moduleNames.set(entry.module_name, entry.module_id);
|
||
inp.value = entry.module_name || '';
|
||
renderModuleOptions('editModuleList', editModules);
|
||
}
|
||
|
||
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);
|
||
if (moduleFilterId) p.set('module_id', moduleFilterId);
|
||
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;
|
||
editAiSuggestion = null;
|
||
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;
|
||
if (!modulesLoaded) await loadModuleOptions();
|
||
fillEditModuleInput(e);
|
||
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 photoAiFaceMode = 'off';
|
||
let photoAiFaceModel = 'gfpgan';
|
||
let photoAiDevice = '';
|
||
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);
|
||
})();
|
||
|
||
const PHOTO_AI_MODES = {
|
||
x2: { model: 'x2plus', face: 'off', face_model: null, title: 'Универсально (x2)' },
|
||
x4: { model: 'general-x4v3', face: 'off', face_model: null, title: 'Быстро (x4)' },
|
||
faces: { model: 'x2plus', face: 'face', face_model: null, title: 'Лица (восстановление)' },
|
||
facesbg: { model: 'x2plus', face: 'all', face_model: null, title: 'Лица + фон' },
|
||
};
|
||
|
||
function photoAiMode() {
|
||
const sel = document.getElementById('enhanceAiMode');
|
||
const key = sel && PHOTO_AI_MODES[sel.value] ? sel.value : 'x2';
|
||
const m = { ...PHOTO_AI_MODES[key] };
|
||
m.face_model = m.face === 'off' ? null : photoAiFaceModel;
|
||
return m;
|
||
}
|
||
|
||
function defaultPhotoAiModeKey() {
|
||
if (photoAiFaceMode === 'all') return 'facesbg';
|
||
if (photoAiFaceMode === 'face') return 'faces';
|
||
return 'x2';
|
||
}
|
||
|
||
function updateAiHint() {
|
||
const hint = document.getElementById('enhanceAiHint');
|
||
if (!hint) return;
|
||
const cpu = photoAiDevice.startsWith('cpu');
|
||
hint.textContent = cpu ? 'Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут' : '';
|
||
hint.style.display = photoAiEnabled && cpu ? '' : 'none';
|
||
}
|
||
|
||
async function loadPhotoAiDevice() {
|
||
if (!isAdmin()) return;
|
||
try {
|
||
const r = await fetch(`${API}/api/photo-ai/health`, { headers: hdr() });
|
||
if (!r.ok) return;
|
||
const h = await r.json();
|
||
if (h && h.device) photoAiDevice = String(h.device);
|
||
updateAiHint();
|
||
} catch (e) {}
|
||
}
|
||
|
||
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';
|
||
if (['off', 'face', 'all'].includes(f.photo_ai_face_mode)) photoAiFaceMode = f.photo_ai_face_mode;
|
||
if (['gfpgan', 'codeformer'].includes(f.photo_ai_face_model)) photoAiFaceModel = f.photo_ai_face_model;
|
||
const sel = document.getElementById('enhanceAiMode');
|
||
if (sel) sel.value = defaultPhotoAiModeKey();
|
||
loadPhotoAiDevice();
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function runPhotoAi() {
|
||
if (!enhanceEntryId || enhanceSaving) return;
|
||
const entryId = enhanceEntryId;
|
||
const mode = photoAiMode();
|
||
const faceNote = mode.face === 'off'
|
||
? 'Может занять до минуты на CPU.'
|
||
: `Восстановление лиц (${mode.face_model}), на CPU может занять несколько минут.`;
|
||
if (!confirm(`ИИ-обработка фото: ${mode.title}. ${faceNote} Продолжить?`)) return;
|
||
enhanceSaving = true;
|
||
const btn = document.getElementById('enhanceAiBtn');
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai`, {
|
||
method: 'POST',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ model: mode.model, face: mode.face, face_model: mode.face_model }),
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error(err.error || 'Ошибка запуска ИИ');
|
||
}
|
||
const { jobId } = await res.json();
|
||
enhanceAiJobId = jobId;
|
||
showEnhanceResult(false);
|
||
setEnhanceLoadingText(mode.face === 'off'
|
||
? 'ИИ обрабатывает фото... (может занять до минуты)'
|
||
: 'ИИ обрабатывает фото и восстанавливает лица...');
|
||
btn.textContent = '⏳ ИИ...';
|
||
const result = await new Promise((resolve, reject) => {
|
||
photoAiPollTimer = setInterval(async () => {
|
||
try {
|
||
const r = await fetch(`${API}/api/entries/${entryId}/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(() => {
|
||
const show = photoAiEnabled ? '' : 'none';
|
||
document.getElementById('enhanceAiBtn').style.display = show;
|
||
document.getElementById('enhanceAiMode').style.display = show;
|
||
updateAiHint();
|
||
});
|
||
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;
|
||
editAiSuggestion = suggestion.trim();
|
||
}
|
||
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);
|
||
}
|
||
|
||
function editSourceFor(description) {
|
||
if (!editAiSuggestion) return 'manual';
|
||
return description === editAiSuggestion ? 'ai' : 'ai_manual';
|
||
}
|
||
|
||
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 moduleId = await resolveModuleId(document.getElementById('editModule').value);
|
||
if (moduleId === null) { alert('Тема модуля не найдена. Выберите из списка подсказок.'); return; }
|
||
|
||
const formData = new FormData();
|
||
formData.append('student_name', name);
|
||
formData.append('group_id', group_id);
|
||
formData.append('description', description);
|
||
formData.append('module_id', moduleId);
|
||
formData.append('edit_source', editSourceFor(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('moduleFilter').addEventListener('change', applyModuleFilter);
|
||
document.getElementById('moduleFilter').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); applyModuleFilter(); }
|
||
});
|
||
document.getElementById('moduleFilter').addEventListener('input', () => {
|
||
clearTimeout(moduleSearchTimer);
|
||
const q = document.getElementById('moduleFilter').value.trim();
|
||
moduleSearchTimer = setTimeout(async () => {
|
||
renderModuleOptions('moduleFilterList', await fetchModules(q, 20));
|
||
}, 250);
|
||
});
|
||
document.getElementById('editModule').addEventListener('input', () => {
|
||
clearTimeout(editModuleSearchTimer);
|
||
const q = document.getElementById('editModule').value.trim();
|
||
editModuleSearchTimer = setTimeout(async () => {
|
||
renderModuleOptions('editModuleList', await fetchModules(q, 20));
|
||
}, 250);
|
||
});
|
||
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);
|
||
if (moduleFilterId) p.set('module_id', moduleFilterId);
|
||
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.module_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();
|
||
});
|
||
|
||
function openVideo(url, name, token) {
|
||
const modal = document.getElementById('videoModal');
|
||
const player = document.getElementById('videoPlayer');
|
||
const title = document.getElementById('videoTitle');
|
||
const dl = document.getElementById('videoDownload');
|
||
if (!modal || !player) return;
|
||
player.src = url;
|
||
player.currentTime = 0;
|
||
title.textContent = name || 'Видео';
|
||
dl.href = `${API}/api/files/${token}`;
|
||
dl.setAttribute('download', '');
|
||
modal.classList.add('open');
|
||
const play = player.play();
|
||
if (play && typeof play.catch === 'function') play.catch(() => {});
|
||
}
|
||
|
||
function closeVideo() {
|
||
const modal = document.getElementById('videoModal');
|
||
const player = document.getElementById('videoPlayer');
|
||
if (!modal || !player) return;
|
||
try { player.pause(); } catch {}
|
||
player.removeAttribute('src');
|
||
player.load();
|
||
modal.classList.remove('open');
|
||
}
|
||
|
||
document.getElementById('videoModal').addEventListener('click', e => {
|
||
if (e.target === e.currentTarget) closeVideo();
|
||
});
|
||
document.getElementById('videoClose').addEventListener('click', closeVideo);
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'Escape') return;
|
||
if (document.getElementById('videoModal').classList.contains('open')) closeVideo();
|
||
});
|
||
|
||
document.addEventListener('click', e => {
|
||
const v = e.target.closest('[data-video]');
|
||
if (!v) return;
|
||
e.preventDefault();
|
||
openVideo(v.dataset.video, v.dataset.videoName, v.dataset.videoToken);
|
||
});
|
||
|
||
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();
|
||
loadModuleOptions();
|
||
loadStudentOptions();
|
||
loadEntries();
|
||
connectLiveUpdates();
|
||
}
|
||
})(); |