ocr_annotator/templates/index.html
2026-06-24 13:28:14 +04:00

746 lines
33 KiB
HTML
Raw Permalink 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.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OCR Annotator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Unbounded:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d0d0d; --surface: #161616; --surface2: #1f1f1f;
--border: #2a2a2a; --accent: #e8ff3d; --accent2: #3dffc8;
--text: #e8e8e8; --muted: #555; --danger: #ff4d4d; --labeled: #3dffc8;
--warn: #ffb13d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'JetBrains Mono', monospace; min-height: 100vh; }
/* ── LOBBY ── */
#lobby {
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 32px; padding: 40px;
}
.lobby-logo { font-family: 'Unbounded', sans-serif; font-size: 22px; font-weight: 700; color: var(--accent); letter-spacing: 0.08em; }
.lobby-logo span { color: var(--muted); }
.lobby-sub { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; }
.folder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; width: 100%; max-width: 800px; }
.folder-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 3px;
padding: 20px; cursor: pointer; transition: all 0.15s; position: relative; overflow: hidden;
}
.folder-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.folder-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--accent); transform: scaleX(0); transition: transform 0.2s; transform-origin: left; }
.folder-card:hover::before { transform: scaleX(1); }
.folder-name { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 10px; word-break: break-all; }
.folder-bar { height: 2px; background: var(--border); border-radius: 1px; margin-bottom: 8px; overflow: hidden; }
.folder-bar-fill { height: 100%; background: var(--accent2); border-radius: 1px; transition: width 0.4s; }
.folder-stats { font-size: 10px; color: var(--muted); display: flex; justify-content: space-between; }
.folder-stats .done { color: var(--accent2); }
.no-folders { text-align: center; color: var(--muted); font-size: 12px; line-height: 1.8; }
/* ── APP SHELL ── */
#app { display: none; grid-template-rows: auto 1fr auto; min-height: 100vh; }
header {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 12px 20px; display: flex; align-items: center; gap: 16px;
}
.back-btn {
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
padding: 5px 10px; border: 1px solid var(--border); background: transparent;
color: var(--muted); cursor: pointer; border-radius: 2px; letter-spacing: 0.05em;
transition: all 0.15s; white-space: nowrap;
}
.back-btn:hover { border-color: var(--text); color: var(--text); }
.logo { font-family: 'Unbounded', sans-serif; font-size: 12px; font-weight: 700; color: var(--accent); letter-spacing: 0.08em; white-space: nowrap; }
.logo span { color: var(--muted); }
.subfolder-tag { font-size: 10px; color: var(--muted); background: var(--surface2); border: 1px solid var(--border); padding: 3px 8px; border-radius: 2px; }
.progress-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.progress-bar { height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); transition: width 0.4s; border-radius: 1px; }
.progress-text { font-size: 10px; color: var(--muted); }
.progress-text strong { color: var(--accent); }
.header-actions { display: flex; gap: 8px; flex-shrink: 0; }
.btn {
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600;
letter-spacing: 0.06em; padding: 7px 14px; border: 1px solid var(--border);
background: transparent; color: var(--text); cursor: pointer; text-transform: uppercase;
transition: all 0.15s; border-radius: 2px; white-space: nowrap;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn-accent { background: var(--accent); color: #000; border-color: var(--accent); }
.btn-accent:hover { background: #fff; border-color: #fff; color: #000; }
.btn-danger { border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-warn { border-color: var(--warn); color: var(--warn); }
.btn-warn:hover { background: var(--warn); color: #000; border-color: var(--warn); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn:disabled:hover { border-color: var(--border); color: var(--text); background: transparent; }
.auto-hint {
font-size: 10px; color: var(--warn); letter-spacing: 0.05em;
white-space: nowrap; border: 1px solid var(--warn); border-radius: 2px;
padding: 3px 8px;
}
#recheck-text { color: var(--warn); }
/* Confirm dialog */
.confirm-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 200;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(3px);
}
.confirm-box {
background: var(--surface); border: 1px solid var(--danger); border-radius: 4px;
padding: 28px 32px; max-width: 380px; width: 90%; text-align: center;
}
.confirm-icon { font-size: 32px; margin-bottom: 12px; }
.confirm-title { font-family: 'Unbounded', sans-serif; font-size: 13px; color: var(--text); margin-bottom: 8px; }
.confirm-sub { font-size: 11px; color: var(--muted); margin-bottom: 24px; line-height: 1.6; word-break: break-all; }
.confirm-btns { display: flex; gap: 10px; justify-content: center; }
/* ── MAIN ── */
main { display: grid; grid-template-columns: 240px 1fr; overflow: hidden; height: calc(100vh - 54px - 42px); }
/* SIDEBAR */
.sidebar { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
.sidebar-header {
padding: 10px 14px; border-bottom: 1px solid var(--border);
font-size: 9px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase;
display: flex; justify-content: space-between; align-items: center;
}
.filter-btns { display: flex; gap: 3px; }
.filter-btn {
font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 3px 6px;
border: 1px solid var(--border); background: transparent; color: var(--muted);
cursor: pointer; border-radius: 2px; letter-spacing: 0.04em; transition: all 0.15s;
}
.filter-btn.active { border-color: var(--accent); color: var(--accent); }
.image-list { flex: 1; overflow-y: auto; padding: 6px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.image-item {
padding: 7px 9px; border-radius: 2px; cursor: pointer; font-size: 10px; color: var(--muted);
display: flex; align-items: center; gap: 7px; border: 1px solid transparent;
margin-bottom: 2px; transition: background 0.1s;
}
.image-item:hover { background: var(--surface2); color: var(--text); }
.image-item.active { background: var(--surface2); border-color: var(--accent); color: var(--text); }
.image-item.labeled { color: var(--labeled); }
.image-item.labeled .dot { background: var(--labeled); }
.image-item.auto { color: var(--warn); }
.image-item.auto .dot { background: var(--warn); }
.item-badge { font-size: 9px; flex-shrink: 0; }
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border); flex-shrink: 0; }
.item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* PANEL */
.panel { display: flex; flex-direction: column; overflow: hidden; }
/* IMAGE AREA */
.image-area {
flex: 1; display: flex; align-items: center; justify-content: center;
background: var(--bg); position: relative; overflow: hidden; cursor: grab;
}
.image-area:active { cursor: grabbing; }
.image-area::before {
content: ''; position: absolute; inset: 0;
background-image: linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 28px 28px; opacity: 0.25; pointer-events: none;
}
.zoom-controls {
position: absolute; top: 12px; right: 12px; z-index: 10;
display: flex; flex-direction: column; gap: 4px;
}
.zoom-btn {
width: 28px; height: 28px; border: 1px solid var(--border); background: var(--surface);
color: var(--text); font-size: 14px; cursor: pointer; border-radius: 2px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; font-family: monospace;
}
.zoom-btn:hover { border-color: var(--accent); color: var(--accent); }
.zoom-level {
font-size: 9px; color: var(--muted); text-align: center;
font-family: 'JetBrains Mono', monospace; letter-spacing: 0.05em;
}
/* The zoomable canvas */
#zoom-canvas {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
transform-origin: center center;
will-change: transform;
}
.img-frame { border: 1px solid var(--border); background: var(--surface2); padding: 4px; box-shadow: 0 0 40px rgba(0,0,0,0.7); }
#current-image { display: block; max-width: 800px; max-height: 280px; object-fit: contain; image-rendering: pixelated; }
.empty-state { text-align: center; color: var(--muted); z-index: 1; }
.empty-state .icon { font-size: 40px; margin-bottom: 12px; }
.empty-state p { font-size: 11px; line-height: 1.7; }
/* INPUT */
.input-area { background: var(--surface); border-top: 1px solid var(--border); padding: 14px 20px; display: flex; flex-direction: column; gap: 10px; }
.input-row { display: flex; gap: 10px; align-items: center; }
.input-label { font-size: 10px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; }
#text-input {
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
font-family: 'JetBrains Mono', monospace; font-size: 15px; padding: 9px 13px;
border-radius: 2px; outline: none; transition: border-color 0.15s; letter-spacing: 0.02em;
}
#text-input:focus { border-color: var(--accent); }
#text-input::placeholder { color: var(--muted); }
.actions-row { display: flex; align-items: center; justify-content: space-between; }
.nav-btns { display: flex; gap: 8px; }
.hotkeys { font-size: 9px; color: var(--muted); display: flex; gap: 10px; flex-wrap: wrap; }
.hotkey { display: flex; align-items: center; gap: 4px; }
.key { background: var(--surface2); border: 1px solid var(--border); border-radius: 2px; padding: 2px 5px; font-size: 9px; }
/* FOOTER */
footer {
background: var(--surface); border-top: 1px solid var(--border);
padding: 0 20px; height: 42px; display: flex; align-items: center;
justify-content: space-between; font-size: 10px; color: var(--muted);
}
.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--labeled); margin-right: 6px; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
#current-filename { color: var(--muted); }
.toast {
position: fixed; bottom: 56px; right: 20px; background: var(--accent); color: #000;
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600;
padding: 9px 16px; border-radius: 2px; letter-spacing: 0.05em;
transform: translateY(16px); opacity: 0; transition: all 0.2s; z-index: 100; pointer-events: none;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.error { background: var(--danger); color: #fff; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
</style>
</head>
<body>
<!-- LOBBY -->
<div id="lobby">
<div style="text-align:center">
<div class="lobby-logo">OCR<span>//</span>Annotator</div>
<div class="lobby-sub" style="margin-top:6px">Выберите папку для разметки</div>
</div>
<div class="folder-grid" id="folder-grid">
<div class="no-folders">Загрузка...</div>
</div>
<div style="font-size:10px;color:var(--muted);text-align:center">
Каждый пользователь выбирает свою подпапку — разметка сохраняется независимо.<br>
При экспорте все подпапки объединяются в ZIP (картинки + labels.tsv).
</div>
</div>
<!-- APP -->
<div id="app">
<header>
<button class="back-btn" onclick="goLobby()">← Папки</button>
<div class="logo">OCR<span>//</span>Annotator</div>
<div class="subfolder-tag" id="subfolder-tag"></div>
<div class="progress-wrap">
<div class="progress-bar"><div class="progress-fill" id="progress-fill" style="width:0%"></div></div>
<div class="progress-text"><strong id="labeled-count">0</strong> / <span id="total-count">0</span> размечено<span id="recheck-text"></span></div>
</div>
<div class="header-actions">
<button class="btn btn-warn" id="autolabel-btn" onclick="startAutolabel()" title="Авто-разметить все неразмеченные картинки моделью TrOCR (требует перепроверки)">🤖 Авто-разметка</button>
<button class="btn btn-danger" onclick="confirmDelete()">🗑 Удалить</button>
<button class="btn" onclick="exportLabels()">⬇ Экспорт ZIP</button>
<button class="btn btn-accent" onclick="saveAndNext()">Сохранить →</button>
</div>
</header>
<main>
<div class="sidebar">
<div class="sidebar-header">
<span>Файлы</span>
<div class="filter-btns">
<button class="filter-btn active" onclick="setFilter('all',this)">Все</button>
<button class="filter-btn" onclick="setFilter('unlabeled',this)">Нет</button>
<button class="filter-btn" onclick="setFilter('auto',this)">🤖 Проверить</button>
<button class="filter-btn" onclick="setFilter('labeled',this)">Готовы</button>
</div>
</div>
<div class="image-list" id="image-list"></div>
</div>
<div class="panel">
<div class="image-area" id="image-area">
<!-- zoom controls -->
<div class="zoom-controls">
<button class="zoom-btn" onclick="zoomStep(0.25)" title="Приблизить">+</button>
<div class="zoom-level" id="zoom-level">100%</div>
<button class="zoom-btn" onclick="zoomStep(-0.25)" title="Отдалить"></button>
<button class="zoom-btn" onclick="resetZoom()" title="Сбросить" style="font-size:10px"></button>
</div>
<div id="zoom-canvas">
<div class="img-frame" id="img-frame" style="display:none">
<img id="current-image" src="" alt="crop">
</div>
</div>
<div class="empty-state" id="empty-state">
<div class="icon">🗂</div>
<p>Нет изображений</p>
</div>
</div>
<div class="input-area">
<div class="input-row">
<span class="input-label">Текст →</span>
<input type="text" id="text-input" placeholder="Введите распознанный текст..." autocomplete="off" spellcheck="false">
<span class="auto-hint" id="auto-hint" style="display:none">🤖 авто — перепроверьте</span>
</div>
<div class="actions-row">
<div class="nav-btns">
<button class="btn" onclick="navigate(-1)">← Назад</button>
<button class="btn" onclick="navigate(1)">Пропустить →</button>
</div>
<div class="hotkeys">
<div class="hotkey"><span class="key">Enter</span> сохранить и далее</div>
<div class="hotkey"><span class="key">Tab</span> пропустить</div>
<div class="hotkey"><span class="key">Alt+←/→</span> навигация</div>
<div class="hotkey"><span class="key">колесо</span> зум</div>
</div>
</div>
</div>
</div>
</main>
<footer>
<div><span class="status-dot"></span><span id="status-msg">Готов</span></div>
<div id="current-filename"></div>
</footer>
</div>
<!-- Confirm delete dialog -->
<div class="confirm-overlay" id="confirm-overlay" style="display:none">
<div class="confirm-box">
<div class="confirm-icon">🗑</div>
<div class="confirm-title">Удалить изображение?</div>
<div class="confirm-sub" id="confirm-filename"></div>
<div class="confirm-btns">
<button class="btn" id="btn-cancel-delete">Отмена</button>
<button class="btn btn-danger" id="btn-confirm-delete">Удалить навсегда</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
let images = [];
let currentIndex = 0;
let filter = 'all';
let currentSubfolder = null;
// ── Zoom state ─────────────────────────────────────────────────────────────
let scale = 1;
let translateX = 0;
let translateY = 0;
let isPanning = false;
let panStart = { x: 0, y: 0 };
function applyTransform() {
document.getElementById('zoom-canvas').style.transform =
`translate(${translateX}px, ${translateY}px) scale(${scale})`;
document.getElementById('zoom-level').textContent = Math.round(scale * 100) + '%';
}
function zoomStep(delta) {
scale = Math.min(8, Math.max(0.25, scale + delta));
applyTransform();
}
function resetZoom() {
scale = 1; translateX = 0; translateY = 0;
applyTransform();
}
// Wheel zoom
document.addEventListener('wheel', e => {
const area = document.getElementById('image-area');
if (!area.contains(e.target)) return;
e.preventDefault();
const delta = e.deltaY < 0 ? 0.15 : -0.15;
scale = Math.min(8, Math.max(0.25, scale + delta));
applyTransform();
}, { passive: false });
// Pan with mouse drag
const imageArea = document.getElementById('image-area');
imageArea.addEventListener('mousedown', e => {
if (e.target.closest('.zoom-controls')) return;
isPanning = true;
panStart = { x: e.clientX - translateX, y: e.clientY - translateY };
});
window.addEventListener('mousemove', e => {
if (!isPanning) return;
translateX = e.clientX - panStart.x;
translateY = e.clientY - panStart.y;
applyTransform();
});
window.addEventListener('mouseup', () => { isPanning = false; });
// ── Lobby ──────────────────────────────────────────────────────────────────
async function loadLobby() {
const res = await fetch('/api/subfolders');
const subs = await res.json();
const grid = document.getElementById('folder-grid');
if (subs.length === 0) {
grid.innerHTML = '<div class="no-folders">Нет папок с изображениями.<br>Создайте подпапки внутри <code>data/images/</code></div>';
return;
}
grid.innerHTML = '';
subs.forEach(s => {
const auto = s.auto || 0;
const verified = s.labeled - auto;
const pct = s.total > 0 ? (verified / s.total * 100) : 0;
const autoTag = auto ? `<span style="color:var(--warn)">${auto} 🤖 на проверку</span>` : '';
const card = document.createElement('div');
card.className = 'folder-card';
card.innerHTML = `
<div class="folder-name">📁 ${s.name}</div>
<div class="folder-bar"><div class="folder-bar-fill" style="width:${pct}%"></div></div>
<div class="folder-stats">
<span class="done">${verified} готово</span>
${autoTag}
<span>${s.total} файлов</span>
</div>`;
card.onclick = () => openSubfolder(s.name);
grid.appendChild(card);
});
}
async function openSubfolder(name) {
currentSubfolder = name;
document.getElementById('subfolder-tag').textContent = name;
document.getElementById('lobby').style.display = 'none';
document.getElementById('app').style.display = 'grid';
await loadImages();
startPolling();
resumeAutolabelIfRunning();
}
function goLobby() {
stopPolling();
clearInterval(autolabelTimer);
currentSubfolder = null;
document.getElementById('app').style.display = 'none';
document.getElementById('lobby').style.display = 'flex';
loadLobby();
}
// ── Polling for multi-user sync ────────────────────────────────────────────
let pollTimer = null;
function startPolling() {
pollTimer = setInterval(async () => {
if (!currentSubfolder) return;
const res = await fetch(`/api/images/${encodeURIComponent(currentSubfolder)}`);
const fresh = await res.json();
// Update labels without disrupting current selection
fresh.forEach(f => {
const img = images.find(i => i.filename === f.filename);
if (img) { img.labeled = f.labeled; img.text = f.text; }
});
updateProgress();
renderList();
}, 5000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
// ── Image list ─────────────────────────────────────────────────────────────
async function loadImages() {
const res = await fetch(`/api/images/${encodeURIComponent(currentSubfolder)}`);
images = await res.json();
updateProgress();
renderList();
const firstUnlabeled = images.findIndex(i => !i.labeled);
selectImage(firstUnlabeled >= 0 ? firstUnlabeled : 0);
}
function filteredImages() {
if (filter === 'labeled') return images.filter(i => i.labeled && !i.auto);
if (filter === 'unlabeled') return images.filter(i => !i.labeled);
if (filter === 'auto') return images.filter(i => i.labeled && i.auto);
return images;
}
function renderList() {
const list = document.getElementById('image-list');
const items = filteredImages();
list.innerHTML = '';
if (items.length === 0) {
list.innerHTML = '<div style="padding:14px;font-size:10px;color:var(--muted);text-align:center">Нет файлов</div>';
return;
}
items.forEach(img => {
const realIdx = images.indexOf(img);
const div = document.createElement('div');
const stateClass = img.labeled ? (img.auto ? ' auto' : ' labeled') : '';
div.className = 'image-item' + stateClass + (realIdx === currentIndex ? ' active' : '');
const badge = img.auto ? '<span class="item-badge">🤖</span>' : '';
div.innerHTML = `<div class="dot"></div><div class="item-text">${img.filename}</div>${badge}`;
div.onclick = () => selectImage(realIdx);
list.appendChild(div);
});
}
function selectImage(index) {
if (!images.length) return;
index = Math.max(0, Math.min(index, images.length - 1));
currentIndex = index;
const img = images[index];
document.getElementById('img-frame').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
document.getElementById('current-image').src =
`/images/${encodeURIComponent(currentSubfolder)}/${encodeURIComponent(img.filename)}`;
document.getElementById('current-filename').textContent = img.filename;
document.getElementById('text-input').value = img.text || '';
document.getElementById('auto-hint').style.display = img.auto ? 'inline-block' : 'none';
document.getElementById('text-input').focus();
resetZoom();
renderList();
scrollActiveIntoView();
}
function scrollActiveIntoView() {
const active = document.querySelector('.image-item.active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
// ── Save / nav ─────────────────────────────────────────────────────────────
async function saveLabel() {
if (!images.length) return;
const img = images[currentIndex];
const text = document.getElementById('text-input').value.trim();
const res = await fetch(`/api/label/${encodeURIComponent(currentSubfolder)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: img.filename, text })
});
const data = await res.json();
if (data.ok) {
img.text = text;
img.labeled = text.length > 0;
img.auto = false; // manual save = human-verified
document.getElementById('auto-hint').style.display = 'none';
updateProgress();
showToast(text ? `${text}` : 'Метка удалена');
renderList();
}
}
async function saveAndNext() { await saveLabel(); navigate(1); }
function navigate(dir) {
const items = filteredImages();
const curFiltered = items.indexOf(images[currentIndex]);
const next = curFiltered + dir;
if (next >= 0 && next < items.length) {
selectImage(images.indexOf(items[next]));
} else if (dir > 0 && currentIndex < images.length - 1) {
selectImage(currentIndex + 1);
} else if (dir < 0 && currentIndex > 0) {
selectImage(currentIndex - 1);
}
}
function updateProgress() {
const total = images.length;
// «Готово» = только проверенные человеком; авто-метки ждут перепроверки.
const verified = images.filter(i => i.labeled && !i.auto).length;
const auto = images.filter(i => i.auto).length;
const pct = total > 0 ? (verified / total * 100) : 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('labeled-count').textContent = verified;
document.getElementById('total-count').textContent = total;
document.getElementById('recheck-text').textContent = auto ? ` · ${auto} 🤖 на проверку` : '';
document.getElementById('status-msg').textContent =
total > 0 ? `${total - verified} осталось` : 'Нет файлов';
}
function setFilter(f, btn) {
filter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderList();
}
function exportLabels() {
window.location.href = '/api/export';
showToast('Экспорт dataset.zip...');
}
// ── Auto-labeling (TrOCR) ────────────────────────────────────────────────────
let autolabelTimer = null;
async function startAutolabel() {
if (!currentSubfolder) return;
const remaining = images.filter(i => !i.labeled).length;
if (remaining === 0) { showToast('Нет неразмеченных изображений'); return; }
if (!confirm(
`Авто-разметить ${remaining} неразмеченных изображений моделью TrOCR?\n\n` +
`Метки сохранятся с пометкой «🤖 требует перепроверки» — их нужно вручную проверить.\n` +
`Первый запуск может занять время: модель скачивается из HuggingFace.`
)) return;
const btn = document.getElementById('autolabel-btn');
btn.disabled = true;
document.getElementById('status-msg').textContent = '🤖 Запуск авто-разметки...';
try {
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}`, { method: 'POST' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
showToast(d.error || 'Не удалось запустить', true);
btn.disabled = false;
return;
}
} catch (err) {
showToast('Ошибка: ' + err.message, true);
btn.disabled = false;
return;
}
pollAutolabel();
}
function pollAutolabel() {
clearInterval(autolabelTimer);
document.getElementById('autolabel-btn').disabled = true;
autolabelTimer = setInterval(async () => {
if (!currentSubfolder) { clearInterval(autolabelTimer); return; }
let s;
try {
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}/status`);
s = await res.json();
} catch (e) { return; }
if (s.state === 'running') {
const pct = s.total ? Math.round(s.done / s.total * 100) : 0;
document.getElementById('status-msg').textContent =
`🤖 Авто-разметка: ${s.done}/${s.total} (${pct}%)`;
} else if (s.state === 'done') {
clearInterval(autolabelTimer);
document.getElementById('autolabel-btn').disabled = false;
showToast('Авто-разметка завершена — проверьте метки 🤖');
await loadImages();
} else if (s.state === 'error') {
clearInterval(autolabelTimer);
document.getElementById('autolabel-btn').disabled = false;
showToast('Ошибка авто-разметки: ' + (s.error || ''), true);
updateProgress();
} else {
clearInterval(autolabelTimer);
document.getElementById('autolabel-btn').disabled = false;
}
}, 1500);
}
async function resumeAutolabelIfRunning() {
if (!currentSubfolder) return;
try {
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}/status`);
const s = await res.json();
if (s.state === 'running') pollAutolabel();
} catch (e) {}
}
// ── Delete ─────────────────────────────────────────────────────────────────
function confirmDelete() {
if (!images.length) return;
const img = images[currentIndex];
document.getElementById('confirm-filename').textContent = img.filename;
document.getElementById('confirm-overlay').style.display = 'flex';
}
function closeConfirm() {
document.getElementById('confirm-overlay').style.display = 'none';
try { document.getElementById('text-input').focus(); } catch(e) {}
}
async function doDelete() {
if (!images.length) return;
// Snapshot before closing (focus() can trigger blur events)
const subfolder = currentSubfolder;
const img = images[currentIndex];
const idx = currentIndex;
document.getElementById('confirm-overlay').style.display = 'none';
let res, data;
try {
res = await fetch(`/api/delete/${encodeURIComponent(subfolder)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: img.filename })
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.json();
} catch(err) {
console.error('Delete error:', err);
showToast(`Ошибка: ${err.message}`, true);
return;
}
if (data && data.ok) {
showToast(`Удалено: ${img.filename}`, true);
images.splice(idx, 1);
updateProgress();
if (images.length === 0) {
document.getElementById('img-frame').style.display = 'none';
document.getElementById('empty-state').style.display = 'flex';
document.getElementById('current-filename').textContent = '—';
document.getElementById('text-input').value = '';
renderList();
return;
}
selectImage(Math.min(idx, images.length - 1));
} else {
showToast('Ошибка удаления', true);
}
}
// Wire up dialog buttons
document.getElementById('btn-confirm-delete').addEventListener('click', doDelete);
document.getElementById('btn-cancel-delete').addEventListener('click', closeConfirm);
document.getElementById('confirm-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('confirm-overlay')) closeConfirm();
});
function showToast(msg, isError = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isError ? ' error' : '');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.className = 'toast', 2000);
}
// ── Hotkeys ────────────────────────────────────────────────────────────────
document.getElementById('text-input').addEventListener('keydown', async e => {
if (e.key === 'Enter') { e.preventDefault(); await saveAndNext(); }
if (e.key === 'Tab') { e.preventDefault(); navigate(1); }
});
document.addEventListener('keydown', e => {
if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); navigate(1); }
if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); }
if (e.key === 'Escape') closeConfirm();
if (e.key === 'Delete' && e.target.tagName !== 'INPUT') confirmDelete();
});
// ── Init ───────────────────────────────────────────────────────────────────
loadLobby();
</script>
</body>
</html>