Files
manga/frontend/index.html
2026-04-30 19:32:13 +03:00

2070 lines
89 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>Manga Downloader</title>
<link rel="icon" type="image/png" href="/static/favicon.png"/>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background: #0f1117; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
.card { background: #1a1d2e; border: 1px solid #2d3148; }
.pill { display:inline-flex; align-items:center; gap:4px; padding:2px 10px; border-radius:9999px; font-size:0.72rem; font-weight:600; }
.pill-queued { background:#1e293b; color:#94a3b8; }
.pill-downloading { background:#1e3a5f; color:#60a5fa; }
.pill-done { background:#14532d; color:#4ade80; }
.pill-failed { background:#450a0a; color:#f87171; }
.pill-stopped { background:#2d1f00; color:#fbbf24; }
.pill-pub-completed { background:#14532d; color:#86efac; font-size:0.65rem; }
.pill-pub-ongoing { background:#1e3a5f; color:#93c5fd; font-size:0.65rem; }
.pill-pub-unknown { background:#1f1f2e; color:#6b7280; font-size:0.65rem; }
/* Toggle switch */
.toggle { position:relative; display:inline-block; width:36px; height:20px; }
.toggle input { opacity:0; width:0; height:0; }
.toggle-slider { position:absolute; cursor:pointer; top:0;left:0;right:0;bottom:0;
background:#374151; border-radius:20px; transition:.3s; }
.toggle-slider:before { position:absolute; content:""; height:14px; width:14px;
left:3px; bottom:3px; background:white; border-radius:50%; transition:.3s; }
input:checked + .toggle-slider { background:#4f46e5; }
input:checked + .toggle-slider:before { transform:translateX(16px); }
.history-badge { display:inline-block; width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.badge-downloaded { background:#22c55e; }
.badge-auto_downloaded { background:#818cf8; }
.badge-new_chapter_found { background:#f59e0b; }
.badge-check_started { background:#374151; }
.badge-check_done { background:#374151; }
.progress-bar { height:4px; background:#1e293b; border-radius:2px; overflow:hidden; }
.progress-fill { height:100%; border-radius:2px; transition: width 0.3s ease; }
.progress-fill-blue { background: linear-gradient(90deg,#3b82f6,#8b5cf6); }
.progress-fill-green { background: #22c55e; }
.btn-primary { background:#4f46e5; color:white; padding:8px 20px; border-radius:8px; font-weight:600; cursor:pointer; transition:background 0.2s; }
.btn-primary:hover { background:#4338ca; }
.btn-danger { background:#7f1d1d; color:#fca5a5; padding:4px 12px; border-radius:6px; font-size:0.75rem; cursor:pointer; transition:background 0.2s; }
.btn-danger:hover { background:#991b1b; }
.chapter-row:hover { background:#1e2235; }
.stat-card { background:#161928; border:1px solid #2d3148; border-radius:12px; padding:16px; text-align:center; }
input, textarea, select { background:#0f1117; border:1px solid #2d3148; color:#e2e8f0; border-radius:8px; }
input:focus, textarea:focus, select:focus { outline:none; border-color:#4f46e5; }
.fade-in { animation: fadeIn 0.3s ease; }
.row-new { animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
.pulse-dot { width:8px; height:8px; border-radius:50%; background:#3b82f6; animation:pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:#0f1117; } ::-webkit-scrollbar-thumb { background:#2d3148; border-radius:3px; }
/* Login screen */
#login-screen { position:fixed; inset:0; z-index:9999; background:#0f1117; display:flex; align-items:center; justify-content:center; }
#login-screen.hidden { display:none; }
.login-card { background:#1a1d2e; border:1px solid #2d3148; border-radius:16px; padding:40px; width:100%; max-width:380px; }
</style>
</head>
<body class="min-h-screen">
<!-- Login screen -->
<div id="login-screen">
<div class="login-card">
<div class="flex flex-col items-center gap-2 mb-8 justify-center">
<img src="/static/logo.png" alt="Manga Downloader" class="h-16 w-auto">
<span class="text-white font-bold text-xl tracking-wide">Manga Downloader</span>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="text-xs text-gray-400 mb-1 block">Логин</label>
<input id="login-input" type="text" autocomplete="username"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Логин">
</div>
<div>
<label class="text-xs text-gray-400 mb-1 block">Пароль</label>
<input id="password-input" type="password" autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Пароль">
</div>
<div id="login-error" class="text-sm text-red-400 hidden"></div>
<button id="login-btn" onclick="doLogin()" class="btn-primary w-full mt-2">Войти</button>
</div>
</div>
</div>
<!-- Header -->
<header class="border-b border-gray-800 px-6 py-4 flex items-center justify-between sticky top-0 z-50" style="background:#0f1117ee;backdrop-filter:blur(12px)">
<div class="flex items-center gap-3">
<img src="/static/logo.png" alt="Manga Downloader" class="h-10 w-auto">
<span class="text-white font-semibold text-lg tracking-wide">Manga Downloader</span>
</div>
<div class="flex items-center gap-4">
<div id="ws-status" class="flex items-center gap-2 text-sm text-gray-400">
<div class="w-2 h-2 rounded-full bg-gray-600" id="ws-dot"></div>
<span id="ws-text">Подключение...</span>
</div>
<button id="logout-btn" onclick="doLogout()" class="hidden text-xs text-gray-500 hover:text-gray-300 px-3 py-1 rounded-lg transition-colors" style="background:#1e293b">Выйти</button>
</div>
</header>
<div class="max-w-7xl mx-auto px-4 py-6">
<!-- Stats Row -->
<div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div>
<!-- Add Manga Panel -->
<div class="card rounded-xl p-5 mb-6">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Добавить мангу</h2>
<div class="flex flex-col md:flex-row gap-3">
<textarea id="url-input" rows="2" placeholder="Один или несколько URL (каждый с новой строки)&#10;https://3.readmanga.ru/manga_slug" class="flex-1 px-3 py-2 text-sm resize-none" oninput="onUrlInputChange()"></textarea>
<div class="flex flex-col gap-2">
<select id="fmt-select" class="px-3 py-2 text-sm">
<option value="cbz">CBZ</option>
<option value="pdf">PDF</option>
<option value="epub">EPUB</option>
<option value="all">Все форматы</option>
</select>
<button onclick="addToQueue()" id="add-btn" class="btn-primary text-sm"> В очередь</button>
</div>
</div>
<!-- Source detection hint -->
<div id="source-hint" class="mt-2 hidden">
<div id="source-hint-found" class="hidden text-xs text-green-400 flex items-center gap-2">
<span>🔗 Источник:</span>
<span id="source-hint-name" class="font-semibold"></span>
</div>
<div id="source-hint-unknown" class="hidden flex flex-col gap-2">
<div class="text-xs text-yellow-400">⚠ Домен не распознан. Выберите источник вручную:</div>
<select id="source-manual-select" class="px-3 py-2 text-sm w-full md:w-72">
<option value="">— выберите источник —</option>
</select>
</div>
</div>
<div id="add-msg" class="mt-2 text-sm text-green-400 hidden"></div>
</div>
<!-- Manga List + History Tabs -->
<div class="card rounded-xl overflow-hidden">
<div class="px-5 py-0 border-b border-gray-800 flex items-center justify-between gap-4">
<div class="flex">
<button onclick="switchTab('mangas')" id="tab-mangas"
class="px-4 py-3 text-sm font-semibold border-b-2 border-indigo-500 text-white">📚 Список</button>
<button onclick="switchTab('news')" id="tab-news"
class="px-4 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-400 hover:text-white">🔔 Новости</button>
<button onclick="switchTab('history')" id="tab-history"
class="px-4 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-400 hover:text-white">🕒 История</button>
<button onclick="switchTab('settings')" id="tab-settings"
class="px-4 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-400 hover:text-white">⚙️ Настройки</button>
</div>
<div id="manga-filters" class="flex gap-2 py-2">
<button onclick="filterMangas('all')" id="filter-all" class="text-xs px-3 py-1 rounded-full bg-indigo-600 text-white">Все</button>
<button onclick="filterMangas('downloading')" id="filter-downloading" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Загружается</button>
<button onclick="filterMangas('queued')" id="filter-queued" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Очередь</button>
<button onclick="filterMangas('done')" id="filter-done" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Готово</button>
<button onclick="filterMangas('failed')" id="filter-failed" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Ошибки</button>
<button onclick="filterMangas('stopped')" id="filter-stopped" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">Остановлены</button>
</div>
</div>
<!-- Manga List -->
<div id="tab-content-mangas">
<div id="manga-list" class="divide-y divide-gray-800">
<div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div>
</div>
</div>
<!-- News -->
<div id="tab-content-news" class="hidden">
<div class="px-5 py-3 flex items-center gap-3 border-b border-gray-800">
<span class="text-xs text-gray-400">Новые и автодокаченные главы</span>
<div id="news-unread-badge" class="hidden px-2 py-0.5 rounded-full text-xs font-bold bg-indigo-600 text-white"></div>
<button onclick="loadNews()" class="ml-auto text-xs text-indigo-400 hover:text-indigo-300">↻ Обновить</button>
</div>
<div id="news-list" class="divide-y divide-gray-800 max-h-[600px] overflow-y-auto">
<div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div>
</div>
</div>
<!-- History -->
<div id="tab-content-history" class="hidden">
<div class="px-5 py-3 flex items-center gap-3 border-b border-gray-800">
<div class="flex gap-3 text-xs text-gray-400 flex-wrap">
<span class="flex items-center gap-1"><span class="history-badge badge-downloaded"></span> Скачано</span>
<span class="flex items-center gap-1"><span class="history-badge badge-auto_downloaded"></span> Автодокачка</span>
<span class="flex items-center gap-1"><span class="history-badge badge-new_chapter_found"></span> Найдена новая глава</span>
<span class="flex items-center gap-1"><span class="history-badge badge-check_done"></span> Проверка</span>
</div>
<button onclick="loadHistory()" class="ml-auto text-xs text-indigo-400 hover:text-indigo-300">↻ Обновить</button>
</div>
<div id="history-list" class="divide-y divide-gray-800 max-h-[600px] overflow-y-auto">
<div class="px-5 py-8 text-center text-gray-500 text-sm">Загрузка...</div>
</div>
</div>
<!-- Settings -->
<div id="tab-content-settings" class="hidden">
<div class="px-5 py-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-1">Источники</h3>
<p class="text-xs text-gray-500 mb-4">Источники определяются в коде приложения. Здесь можно управлять доменами для каждого источника.</p>
<div id="sources-list" class="flex flex-col gap-3"></div>
</div>
</div>
</div>
</div>
<!-- Switch Source Modal -->
<div id="switch-source-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
<h3 class="font-semibold text-white text-base">↔ Сменить источник</h3>
<div class="text-sm text-gray-400" id="switch-source-current"></div>
<div class="flex flex-col gap-2">
<label class="text-xs text-gray-400">Новый источник</label>
<select id="switch-source-select" class="px-3 py-2 text-sm w-full"></select>
<div id="switch-source-warning" class="text-xs text-yellow-400 hidden"></div>
</div>
<div class="flex gap-3 justify-end mt-2">
<button onclick="closeSwitchSourceModal()"
class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white"
style="background:#1e293b">Отмена</button>
<button onclick="confirmSwitchSource()"
class="px-4 py-2 rounded-lg text-sm font-semibold text-white"
style="background:#312e81">Применить</button>
</div>
</div>
</div>
<!-- Detail Modal -->
<div id="modal" class="fixed inset-0 z-50 hidden items-center justify-center" style="background:rgba(0,0,0,0.7)">
<div class="card rounded-2xl w-full max-w-3xl max-h-[85vh] overflow-hidden flex flex-col mx-4">
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
<h3 class="font-semibold text-white" id="modal-title">Детали</h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-white text-xl"></button>
</div>
<div id="modal-body" class="flex-1 overflow-y-auto p-6"></div>
</div>
</div>
<!-- Модалка подтверждения удаления -->
<div id="delete-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
<h3 class="font-semibold text-white text-base">Удалить мангу?</h3>
<p class="text-sm text-gray-400" id="delete-modal-title"></p>
<label class="flex items-start gap-3 cursor-pointer select-none group">
<div class="mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 border-gray-600 group-hover:border-red-500
flex items-center justify-center transition-colors"
id="delete-files-box"
style="background:#0f1117">
<svg id="delete-files-check" class="hidden w-3 h-3 text-white" fill="none" viewBox="0 0 12 12">
<path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>
<div class="text-sm text-gray-200">Удалить скачанные файлы</div>
<div class="text-xs text-gray-500 mt-0.5" id="delete-modal-size"></div>
</div>
</label>
<div class="flex gap-3 justify-end mt-2">
<button onclick="closeDeleteModal()"
class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white"
style="background:#1e293b">Отмена</button>
<button onclick="confirmDelete()"
class="px-4 py-2 rounded-lg text-sm font-semibold text-white"
style="background:#7f1d1d">Удалить</button>
</div>
</div>
</div>
<!-- Модалка редактирования метаданных -->
<div id="edit-meta-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
<h3 class="font-semibold text-white text-base">✏️ Редактировать название</h3>
<div class="flex flex-col gap-3">
<div>
<label class="text-xs text-gray-400 mb-1 block">Название (ru)</label>
<input id="edit-meta-title-ru" type="text"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Название на русском">
</div>
<div>
<label class="text-xs text-gray-400 mb-1 block">Полное название</label>
<input id="edit-meta-title-full" type="text"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Полное название">
</div>
<p class="text-xs text-gray-500">⚠️ Папка не переименуется. Метаданные файлов обновятся автоматически.</p>
</div>
<div class="flex gap-3 justify-end mt-2">
<button onclick="closeEditMeta()"
class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white"
style="background:#1e293b">Отмена</button>
<button onclick="saveEditMeta()" id="edit-meta-save-btn"
class="px-4 py-2 rounded-lg text-sm font-semibold text-white"
style="background:#4f46e5">Сохранить</button>
</div>
</div>
</div>
<!-- Модалка переименования папки -->
<div id="rename-folder-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
<h3 class="font-semibold text-white text-base">📁 Переименовать папку</h3>
<div>
<label class="text-xs text-gray-400 mb-1 block">Новое имя папки</label>
<input id="rename-folder-input" type="text"
class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
style="background:#0f1117" placeholder="Название папки">
<p class="text-xs text-gray-500 mt-2">Специальные символы будут удалены. Пробелы заменятся на «_».</p>
</div>
<div class="flex gap-3 justify-end mt-2">
<button onclick="closeRenameFolder()"
class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white"
style="background:#1e293b">Отмена</button>
<button onclick="saveRenameFolder()" id="rename-folder-save-btn"
class="px-4 py-2 rounded-lg text-sm font-semibold text-white"
style="background:#4f46e5">Переименовать</button>
</div>
</div>
</div>
<script>
// ── State ────────────────────────────────────
const state = {
mangas: {}, // url → manga object
chapters: {}, // manga_url → [chapter, ...]
filter: 'all',
sources: [], // [{id, slug, display_name, domains}]
};
// ── Auth ─────────────────────────────────────
function showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('logout-btn').classList.add('hidden');
// Закрываем WS если открыт
if(ws) { try { ws.close(); } catch(_){} ws = null; }
}
function hideLoginScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('logout-btn').classList.remove('hidden');
}
async function checkAuth() {
try {
const r = await fetch('/api/auth/check');
const data = await r.json();
if(!data.auth_enabled) { hideLoginScreen(); return true; }
if(data.authenticated) { hideLoginScreen(); return true; }
showLoginScreen();
return false;
} catch(e) {
showLoginScreen();
return false;
}
}
async function doLogin() {
const btn = document.getElementById('login-btn');
const err = document.getElementById('login-error');
const login = document.getElementById('login-input').value.trim();
const password = document.getElementById('password-input').value;
err.classList.add('hidden');
btn.disabled = true; btn.textContent = 'Входим...';
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({login, password}),
});
if(!r.ok) {
const d = await r.json().catch(()=>({}));
err.textContent = d.detail || 'Неверный логин или пароль';
err.classList.remove('hidden');
return;
}
hideLoginScreen();
await initApp();
} catch(e) {
err.textContent = 'Ошибка сети';
err.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Войти';
}
}
async function doLogout() {
await fetch('/api/logout', {method:'POST'}).catch(()=>{});
showLoginScreen();
document.getElementById('login-input').value = '';
document.getElementById('password-input').value = '';
// Сбрасываем состояние
Object.keys(state.mangas).forEach(k => delete state.mangas[k]);
Object.keys(state.chapters).forEach(k => delete state.chapters[k]);
document.getElementById('stats-row').innerHTML = '';
document.getElementById('manga-list').innerHTML = '';
}
// Глобальный перехват 401
const _origFetch = window.fetch;
window.fetch = async function(...args) {
const r = await _origFetch.apply(this, args);
if(r.status === 401) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if(!url.includes('/api/auth/check') && !url.includes('/api/login')) {
showLoginScreen();
}
}
return r;
};
// ── WebSocket ────────────────────────────────
let ws, wsReconnectTimer;
let _pingInterval = null;
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-green-400';
document.getElementById('ws-text').textContent = 'Подключено';
clearTimeout(wsReconnectTimer);
// Keepalive — один интервал, предыдущий убираем
if(_pingInterval) clearInterval(_pingInterval);
_pingInterval = setInterval(() => { if(ws && ws.readyState === 1) ws.send('ping'); }, 20000);
};
ws.onclose = (e) => {
document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-red-500';
if(_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; }
if(e.code === 4401) {
document.getElementById('ws-text').textContent = 'Нет доступа';
showLoginScreen();
return;
}
document.getElementById('ws-text').textContent = 'Переподключение...';
wsReconnectTimer = setTimeout(connectWS, 3000);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
handleEvent(msg);
};
}
function handleEvent(msg) {
switch(msg.type) {
case 'snapshot':
msg.mangas.forEach(m => { state.mangas[m.url] = m; });
renderList();
loadStats();
// Дополнительно запрашиваем свежие данные с сервера — на случай если
// пока WS был отключён, статусы изменились и события были потеряны
_refreshMangaList();
break;
case 'manga_queued':
if(!state.mangas[msg.url]) {
const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null;
state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format,
chapters_total: 0, chapters_done: 0, size_human: '—',
source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null };
} else {
state.mangas[msg.url].status = 'queued';
}
renderList();
loadStats();
break;
case 'manga_preview':
if(state.mangas[msg.url]) {
state.mangas[msg.url].title = msg.title;
state.mangas[msg.url].title_ru = msg.title_ru;
state.mangas[msg.url].title_full = msg.title_full;
state.mangas[msg.url].pub_status = msg.pub_status;
state.mangas[msg.url].chapters_total = msg.chapters_total;
updateMangaRow(msg.url);
}
break;
case 'manga_start':
if(!state.mangas[msg.url]) {
// Манга не в state — берём свежие данные с сервера
_refreshMangaList();
} else {
state.mangas[msg.url].status = 'downloading';
if(msg.started_at) state.mangas[msg.url].started_at = msg.started_at;
state.mangas[msg.url].finished_at = null;
renderList();
}
break;
case 'manga_info':
if(state.mangas[msg.url]) {
state.mangas[msg.url].title = msg.title;
state.mangas[msg.url].title_ru = msg.title_ru;
state.mangas[msg.url].title_full = msg.title_full;
state.mangas[msg.url].pub_status = msg.pub_status;
state.mangas[msg.url].chapters_total = msg.chapters_total;
state.mangas[msg.url].chapters_done = 0;
}
renderList();
break;
case 'chapter_start':
if(state.mangas[msg.url]) {
state.mangas[msg.url].current_chapter = msg.chapter_title;
state.mangas[msg.url].chapters_done = msg.chapters_done;
state.mangas[msg.url].chapters_total = msg.chapters_total;
state.mangas[msg.url]._current_chapter_url = msg.chapter_url;
state.mangas[msg.url]._current_pages_done = 0;
state.mangas[msg.url]._current_pages_total = 0;
}
updateMangaRow(msg.url);
if(state.chapters[msg.url]) {
const ch = state.chapters[msg.url].find(c => c.chapter_url === msg.chapter_url);
if(ch) { ch.status = 'downloading'; ch.pages_done = 0; ch.pages_total = 0; }
updateModalIfOpen(msg.url);
}
break;
case 'chapter_skipped':
if(state.mangas[msg.url] && state.mangas[msg.url].status !== 'done') {
state.mangas[msg.url].chapters_done = msg.chapters_done;
}
updateMangaRow(msg.url);
break;
case 'page_done':
if(state.mangas[msg.url] && state.mangas[msg.url]._current_chapter_url === msg.chapter_url) {
state.mangas[msg.url]._current_pages_done = msg.pages_done;
state.mangas[msg.url]._current_pages_total = msg.pages_total;
}
if(state.chapters[msg.url]) {
const ch = state.chapters[msg.url].find(c => c.chapter_url === msg.chapter_url);
if(ch) { ch.pages_done = msg.pages_done; ch.pages_total = msg.pages_total; }
updateModalIfOpen(msg.url);
}
updateMangaRow(msg.url);
break;
case 'chapter_done':
if(state.mangas[msg.url]) {
if(state.mangas[msg.url].status !== 'done') {
state.mangas[msg.url].chapters_done = msg.chapters_done;
}
state.mangas[msg.url].current_chapter = null;
state.mangas[msg.url]._current_pages_done = 0;
state.mangas[msg.url]._current_pages_total = 0;
}
if(state.chapters[msg.url]) {
const ch = state.chapters[msg.url].find(c => c.chapter_url === msg.chapter_url);
if(ch) { ch.status = 'done'; }
updateModalIfOpen(msg.url);
}
updateMangaRow(msg.url);
_pushNewsItem(msg);
break;
case 'chapter_failed':
if(state.chapters[msg.url]) {
const ch = state.chapters[msg.url].find(c => c.chapter_url === msg.chapter_url);
if(ch) { ch.status = 'failed'; }
updateModalIfOpen(msg.url);
}
break;
case 'manga_prioritized':
if(state.mangas[msg.url]) state.mangas[msg.url].status = 'queued';
if(msg.preempted_url && state.mangas[msg.preempted_url]) {
state.mangas[msg.preempted_url].status = 'queued';
state.mangas[msg.preempted_url].current_chapter = null;
state.mangas[msg.preempted_url]._current_pages_done = 0;
state.mangas[msg.preempted_url]._current_pages_total = 0;
}
renderList();
break;
case 'auto_update_changed':
if(state.mangas[msg.url]) state.mangas[msg.url].auto_update = msg.auto_update ? 1 : 0;
updateMangaRow(msg.url);
break;
case 'new_chapter_found':
case 'check_started':
case 'check_done':
// Обновим историю если вкладка открыта
if(!document.getElementById('tab-content-history').classList.contains('hidden')) {
loadHistory();
}
if(msg.type === 'check_done' && state.mangas[msg.url]) {
state.mangas[msg.url].last_checked_at = new Date().toISOString().replace('Z','');
updateMangaRow(msg.url);
}
break;
case 'manga_stopped':
if(state.mangas[msg.url]) state.mangas[msg.url].status = 'stopped';
renderList();
loadStats();
break;
case 'meta_refreshed':
// Ничего не делаем визуально — файлы обновлены на диске
break;
case 'manga_meta_updated':
if(state.mangas[msg.url]) {
state.mangas[msg.url].title = msg.title;
state.mangas[msg.url].title_ru = msg.title_ru;
state.mangas[msg.url].title_full = msg.title_full;
updateMangaRow(msg.url);
}
break;
case 'manga_folder_renamed':
if(state.mangas[msg.url]) {
state.mangas[msg.url].folder_name = msg.folder_name;
}
break;
case 'queue_positions': {
// Обновляем queue_position для всех манг
const pos = msg.positions || {};
Object.values(state.mangas).forEach(m => {
const newPos = pos[m.url] ?? null;
if(m.queue_position !== newPos) {
m.queue_position = newPos;
updateMangaRow(m.url);
}
});
break;
}
case 'manga_done':
if(state.mangas[msg.url]) {
state.mangas[msg.url].status = 'done';
if(msg.chapters_done != null) state.mangas[msg.url].chapters_done = msg.chapters_done;
if(msg.chapters_total != null) state.mangas[msg.url].chapters_total = msg.chapters_total;
if(msg.finished_at) state.mangas[msg.url].finished_at = msg.finished_at;
state.mangas[msg.url].current_chapter = null;
state.mangas[msg.url]._current_pages_done = 0;
state.mangas[msg.url]._current_pages_total = 0;
}
renderList();
loadStats();
break;
case 'manga_failed':
if(state.mangas[msg.url]) {
state.mangas[msg.url].status = 'failed';
if(msg.finished_at) state.mangas[msg.url].finished_at = msg.finished_at;
}
renderList();
loadStats();
break;
case 'source_unknown':
_showNotification('⚠ Источник не определён для ' + (state.mangas[msg.url]?.title || msg.url) + '. Выберите источник.', 'warn');
if(state.mangas[msg.url]) { state.mangas[msg.url].status = 'failed'; renderList(); }
break;
case 'source_domain_added':
case 'source_domain_removed':
loadSources();
break;
case 'source_switched':
if(state.mangas[msg.url]) {
// Обновляем source у манги из актуального списка источников
const newSrc = state.sources.find(s => s.id === msg.new_source_id);
if(newSrc) state.mangas[msg.url].source = {id: newSrc.id, slug: newSrc.slug, display_name: newSrc.display_name};
updateMangaRow(msg.url);
}
break;
}
}
// ── Tabs ─────────────────────────────────────
let newsUnreadCount = 0;
function switchTab(tab) {
['mangas', 'news', 'history', 'settings'].forEach(t => {
document.getElementById('tab-content-'+t).classList.toggle('hidden', t !== tab);
const btn = document.getElementById('tab-'+t);
btn.className = t === tab
? 'px-4 py-3 text-sm font-semibold border-b-2 border-indigo-500 text-white'
: 'px-4 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-400 hover:text-white';
});
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
if(tab === 'history') loadHistory();
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
if(tab === 'settings') loadSources();
}
function updateNewsBadge() {
const badge = document.getElementById('news-unread-badge');
const tabBtn = document.getElementById('tab-news');
if(newsUnreadCount > 0) {
badge.textContent = newsUnreadCount;
badge.classList.remove('hidden');
tabBtn.innerHTML = `🔔 Новости <span class="ml-1 px-1.5 py-0.5 rounded-full text-xs bg-indigo-600 text-white">${newsUnreadCount}</span>`;
} else {
badge.classList.add('hidden');
tabBtn.textContent = '🔔 Новости';
}
}
// ── News ──────────────────────────────────────
async function loadNews() {
try {
const r = await fetch('/api/news?limit=100');
const items = await r.json();
renderNews(items);
} catch(e) {}
}
function renderNews(items) {
const el = document.getElementById('news-list');
if(!items.length) {
el.innerHTML = '<div class="px-5 py-10 text-center text-gray-500 text-sm">Нет новостей</div>';
return;
}
// Группируем по манге + дате
el.innerHTML = items.map(item => {
const d = new Date(item.created_at + 'Z');
const time = d.toLocaleString('ru-RU', {day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit'});
const isAuto = item.event_type === 'auto_downloaded';
const isNew = item.event_type === 'new_chapter_found';
const mangaTitle = item.manga_title || item.manga_url;
return `
<div class="px-5 py-3 flex items-start gap-4 chapter-row fade-in">
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center text-xl
${isAuto ? 'bg-indigo-900' : isNew ? 'bg-yellow-900' : 'bg-green-900'}">
${isAuto ? '🔄' : isNew ? '✨' : '📥'}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-0.5">
<span class="text-xs font-bold ${isAuto ? 'text-indigo-400' : isNew ? 'text-yellow-400' : 'text-green-400'}">
${isAuto ? 'Автодокачка' : isNew ? 'Новая глава' : 'Скачано'}
</span>
<span class="text-xs text-gray-500">${time}</span>
</div>
<div class="text-sm font-medium text-white truncate">${escHtml(mangaTitle)}</div>
<div class="text-xs text-gray-400 mt-0.5">
${item.volume ? `Том ${item.volume} · ` : ''}Глава ${item.chapter_number || '?'}
${item.chapter_title ? ` — <span class="text-gray-300">${escHtml(item.chapter_title)}</span>` : ''}
</div>
</div>
</div>`;
}).join('');
}
function _pushNewsItem(event) {
// Реалтайм: добавить карточку вверх списка если вкладка открыта
const tab = document.getElementById('tab-content-news');
if(!tab.classList.contains('hidden')) {
loadNews();
} else {
newsUnreadCount++;
updateNewsBadge();
}
}
// ── History ───────────────────────────────────
async function loadHistory() {
try {
const r = await fetch('/api/history?limit=150');
const items = await r.json();
renderHistory(items);
} catch(e) {}
}
function renderHistory(items) {
const el = document.getElementById('history-list');
if(!items.length) {
el.innerHTML = '<div class="px-5 py-8 text-center text-gray-500 text-sm">История пуста</div>';
return;
}
const labels = {
downloaded: 'Скачана глава',
auto_downloaded: 'Автодокачка главы',
new_chapter_found: 'Найдена новая глава',
check_started: 'Проверка запущена',
check_done: 'Проверка завершена',
};
el.innerHTML = items.map(h => {
const d = new Date(h.created_at + 'Z');
const time = d.toLocaleString('ru-RU', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
const hide = ['check_started','check_done'].includes(h.event_type);
return `<div class="px-5 py-2.5 flex items-start gap-3 ${hide ? 'opacity-40' : ''}">
<span class="history-badge badge-${h.event_type} mt-1.5 flex-shrink-0"></span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-gray-500 flex-shrink-0">${time}</span>
<span class="text-xs font-medium text-gray-300">${escHtml(h.manga_title || h.manga_url)}</span>
</div>
<div class="text-xs text-gray-400 mt-0.5">
${labels[h.event_type] || h.event_type}
${h.chapter_title ? `: <span class="text-gray-200">${escHtml(h.chapter_title)}</span>` : ''}
${h.details ? `<span class="text-gray-500"> — ${escHtml(h.details)}</span>` : ''}
</div>
</div>
</div>`;
}).join('');
}
// ── Auto-update toggle ────────────────────────
async function toggleAutoUpdate(url, checkbox) {
const enabled = checkbox.checked;
try {
await fetch(`/api/mangas/auto_update?url=${encodeURIComponent(url)}&enabled=${enabled}`, {method:'POST'});
if(state.mangas[url]) state.mangas[url].auto_update = enabled ? 1 : 0;
} catch(e) {
checkbox.checked = !enabled; // откат
}
}
async function checkNow(url) {
await fetch('/api/mangas/check_now?url='+encodeURIComponent(url), {method:'POST'});
}
async function checkNowBtn(btn, url) {
if(btn.disabled) return;
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = '⏳';
btn.style.color = '#fbbf24';
try {
const r = await fetch('/api/mangas/check_now?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok) {
btn.textContent = '✓';
btn.style.color = '#4ade80';
setTimeout(() => {
btn.textContent = orig;
btn.style.color = '';
btn.disabled = false;
}, 2500);
} else {
throw new Error();
}
} catch {
btn.textContent = '✕';
btn.style.color = '#f87171';
setTimeout(() => {
btn.textContent = orig;
btn.style.color = '';
btn.disabled = false;
}, 2000);
}
}
// ── Source detection ─────────────────────────
let _resolveTimer = null;
let _resolvedSourceId = null; // null = found via domain, undefined = unknown
async function onUrlInputChange() {
clearTimeout(_resolveTimer);
_resolveTimer = setTimeout(_resolveSource, 400);
}
async function _resolveSource() {
const raw = document.getElementById('url-input').value.trim();
const hint = document.getElementById('source-hint');
const hintFound = document.getElementById('source-hint-found');
const hintUnknown = document.getElementById('source-hint-unknown');
// Берём первый непустой URL
const url = raw.split('\n').map(u=>u.trim()).filter(Boolean)[0];
if(!url) {
hint.classList.add('hidden');
_resolvedSourceId = null;
document.getElementById('add-btn').disabled = false;
return;
}
try {
const r = await fetch('/api/resolve-source?url=' + encodeURIComponent(url));
const data = await r.json();
hint.classList.remove('hidden');
if(data.source) {
hintFound.classList.remove('hidden');
hintUnknown.classList.add('hidden');
document.getElementById('source-hint-name').textContent = data.source.display_name;
_resolvedSourceId = data.source.id;
document.getElementById('add-btn').disabled = false;
} else {
hintFound.classList.add('hidden');
hintUnknown.classList.remove('hidden');
_resolvedSourceId = undefined; // неизвестен — нужен ручной выбор
document.getElementById('add-btn').disabled = true;
// Заполняем список источников
const sel = document.getElementById('source-manual-select');
sel.innerHTML = '<option value="">— выберите источник —</option>';
(state.sources || []).forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.display_name;
sel.appendChild(opt);
});
sel.onchange = () => {
document.getElementById('add-btn').disabled = !sel.value;
};
}
} catch(e) {
hint.classList.add('hidden');
_resolvedSourceId = null;
document.getElementById('add-btn').disabled = false;
}
}
// ── API ──────────────────────────────────────
async function loadStats() {
try {
const r = await fetch('/api/stats');
const s = await r.json();
renderStats(s);
} catch(e) {}
}
async function addToQueue() {
const raw = document.getElementById('url-input').value.trim();
const fmt = document.getElementById('fmt-select').value;
const urls = raw.split('\n').map(u=>u.trim()).filter(Boolean);
if(!urls.length) return;
// Определяем source_id
let sourceId = null;
if(_resolvedSourceId === undefined) {
// Неизвестный домен — нужен ручной выбор
const manualVal = document.getElementById('source-manual-select').value;
if(!manualVal) { alert('Выберите источник для добавления манги'); return; }
sourceId = parseInt(manualVal);
} else if(_resolvedSourceId !== null) {
sourceId = _resolvedSourceId;
}
try {
const body = {urls, format: fmt};
if(sourceId !== null) body.source_id = sourceId;
const r = await fetch('/api/queue', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const data = await r.json();
const msg = document.getElementById('add-msg');
msg.textContent = `✓ Добавлено: ${data.added.length}, уже есть: ${data.skipped.length}`;
msg.classList.remove('hidden');
if(data.added.length) {
document.getElementById('url-input').value = '';
document.getElementById('source-hint').classList.add('hidden');
_resolvedSourceId = null;
document.getElementById('add-btn').disabled = false;
}
setTimeout(()=>msg.classList.add('hidden'), 4000);
} catch(e) {
alert('Ошибка: ' + e.message);
}
}
async function prioritizeManga(url) {
const r = await fetch('/api/mangas/prioritize?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok) {
const data = await r.json();
if(data.message) return; // уже загружается
}
}
async function stopManga(url) {
await fetch('/api/mangas/stop?url='+encodeURIComponent(url), {method:'POST'});
}
async function resumeManga(url) {
const r = await fetch('/api/mangas/resume?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok && state.mangas[url]) {
state.mangas[url].status = 'queued';
updateMangaRow(url);
}
}
// ── Sources ───────────────────────────────────
async function loadSources() {
try {
const r = await fetch('/api/sources');
if(r.ok) {
state.sources = await r.json();
if(!document.getElementById('tab-content-settings').classList.contains('hidden')) {
renderSources();
}
}
} catch(e) {}
}
function renderSources() {
const container = document.getElementById('sources-list');
if(!container) return;
if(!state.sources.length) {
container.innerHTML = '<div class="text-sm text-gray-500">Нет доступных источников</div>';
return;
}
container.innerHTML = state.sources.map(s => `
<div class="rounded-lg p-4" style="background:#0f172a;border:1px solid #1e293b">
<div class="flex items-center justify-between mb-3">
<div>
<span class="text-sm font-semibold text-white">${escHtml(s.display_name)}</span>
<span class="ml-2 text-xs text-gray-500">slug: ${escHtml(s.slug)}</span>
</div>
</div>
<div class="flex flex-wrap gap-2 items-center">
${s.domains.map(d => `
<span class="flex items-center gap-1 text-xs px-2 py-1 rounded" style="background:#1e293b;color:#94a3b8">
${escHtml(d)}
<button onclick="removeDomain(${s.id}, '${escHtml(d)}')"
title="Удалить домен"
style="color:#ef4444;background:none;border:none;cursor:pointer;padding:0 2px;font-size:0.8rem;line-height:1">✕</button>
</span>
`).join('')}
<span id="add-domain-area-${s.id}">
<button onclick="showAddDomain(${s.id})"
style="font-size:0.7rem;padding:3px 8px;border-radius:4px;background:#1e293b;color:#6ee7b7;border:1px dashed #334155;cursor:pointer">
+ домен
</button>
</span>
</div>
</div>
`).join('');
}
function showAddDomain(sourceId) {
const area = document.getElementById('add-domain-area-' + sourceId);
if(!area) return;
area.innerHTML = `
<span class="flex items-center gap-1">
<input id="new-domain-input-${sourceId}" type="text" placeholder="example.com"
class="text-xs px-2 py-1 rounded" style="background:#1e293b;color:#e2e8f0;border:1px solid #334155;width:140px"
onkeydown="if(event.key==='Enter') addDomain(${sourceId}); if(event.key==='Escape') renderSources();">
<button onclick="addDomain(${sourceId})"
style="font-size:0.75rem;padding:3px 8px;border-radius:4px;background:#166534;color:#86efac;cursor:pointer">✓</button>
<button onclick="renderSources()"
style="font-size:0.75rem;padding:3px 8px;border-radius:4px;background:#1e293b;color:#94a3b8;cursor:pointer">✕</button>
</span>
`;
setTimeout(() => document.getElementById('new-domain-input-' + sourceId)?.focus(), 50);
}
async function addDomain(sourceId) {
const input = document.getElementById('new-domain-input-' + sourceId);
if(!input) return;
const domain = input.value.trim().toLowerCase();
if(!domain) return;
try {
const r = await fetch(`/api/sources/${sourceId}/domains`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({domain}),
});
if(!r.ok) {
const err = await r.json();
_showNotification('Ошибка: ' + (err.detail || 'неизвестная ошибка'), 'error');
return;
}
await loadSources();
} catch(e) {
_showNotification('Ошибка: ' + e.message, 'error');
}
}
async function removeDomain(sourceId, domain) {
if(!confirm(`Удалить домен «${domain}»?`)) return;
try {
const r = await fetch(`/api/sources/${sourceId}/domains/${encodeURIComponent(domain)}`, {method: 'DELETE'});
if(!r.ok) {
const err = await r.json();
_showNotification('Ошибка: ' + (err.detail || 'неизвестная ошибка'), 'error');
return;
}
await loadSources();
} catch(e) {
_showNotification('Ошибка: ' + e.message, 'error');
}
}
// ── Switch Source Modal ───────────────────────
let _switchSourceUrl = null;
function openSwitchSourceModal(url) {
_switchSourceUrl = url;
const manga = state.mangas[url];
const modal = document.getElementById('switch-source-modal');
const sel = document.getElementById('switch-source-select');
const warning = document.getElementById('switch-source-warning');
document.getElementById('switch-source-current').textContent =
'Текущий источник: ' + (manga?.source?.display_name || 'не определён');
sel.innerHTML = '<option value="">— выберите источник —</option>';
state.sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.display_name;
if(manga?.source?.id === s.id) opt.selected = true;
sel.appendChild(opt);
});
try {
const domain = new URL(url).hostname.replace(/^www\./, '');
warning.textContent = `⚠ Домен «${domain}» будет перепривязан к выбранному источнику. Это затронет все манги с этого домена.`;
warning.classList.remove('hidden');
} catch(e) { warning.classList.add('hidden'); }
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeSwitchSourceModal() {
_switchSourceUrl = null;
const modal = document.getElementById('switch-source-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function confirmSwitchSource() {
const url = _switchSourceUrl;
const sourceId = parseInt(document.getElementById('switch-source-select').value);
if(!url || !sourceId) return;
try {
const r = await fetch('/api/mangas/switch-source', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url, source_id: sourceId}),
});
if(!r.ok) {
const err = await r.json();
_showNotification('Ошибка: ' + (err.detail || 'неизвестная ошибка'), 'error');
return;
}
const data = await r.json();
closeSwitchSourceModal();
_showNotification(
`✓ Источник изменён на «${data.source_name}»` +
(data.chapters_reset ? `. Сброшено глав: ${data.chapters_reset}` : ''), 'ok'
);
if(state.mangas[url]) {
const src = state.sources.find(s => s.id === sourceId);
if(src) state.mangas[url].source = {id: src.id, slug: src.slug, display_name: src.display_name};
updateMangaRow(url);
}
} catch(e) {
_showNotification('Ошибка: ' + e.message, 'error');
}
}
document.addEventListener('click', function(e) {
const modal = document.getElementById('switch-source-modal');
if(modal && !modal.classList.contains('hidden') && e.target === modal) closeSwitchSourceModal();
});
// ── Notification helper ───────────────────────
function _showNotification(text, type='ok') {
const el = document.getElementById('add-msg');
if(!el) return;
el.textContent = text;
el.style.color = type === 'error' ? '#f87171' : type === 'warn' ? '#fbbf24' : '#4ade80';
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 5000);
}
// ── Delete modal ─────────────────────────────
let _deleteUrl = null;
let _deleteFilesChecked = false;
function deleteManga(url) {
_deleteUrl = url;
_deleteFilesChecked = false;
const m = state.mangas[url];
const title = m ? (m.title || url) : url;
const size = m ? (m.size_human || '—') : '—';
document.getElementById('delete-modal-title').textContent = title;
document.getElementById('delete-modal-size').textContent = `Занимает на диске: ${size}`;
_setDeleteCheck(false);
const dm = document.getElementById('delete-modal');
dm.classList.remove('hidden');
dm.classList.add('flex');
}
function closeDeleteModal() {
const dm = document.getElementById('delete-modal');
dm.classList.add('hidden');
dm.classList.remove('flex');
_deleteUrl = null;
}
function _setDeleteCheck(val) {
_deleteFilesChecked = val;
const box = document.getElementById('delete-files-box');
const check = document.getElementById('delete-files-check');
if(val) {
box.style.background = '#7f1d1d';
box.style.borderColor = '#ef4444';
check.classList.remove('hidden');
} else {
box.style.background = '#0f1117';
box.style.borderColor = '#4b5563';
check.classList.add('hidden');
}
}
// Клик по чекбоксу и backdrop — вешаем в init()
function _initDeleteModal() {
document.getElementById('delete-files-box').addEventListener('click', () => {
_setDeleteCheck(!_deleteFilesChecked);
});
document.getElementById('delete-modal').addEventListener('click', function(e) {
if(e.target === this) closeDeleteModal();
});
}
async function confirmDelete() {
if(!_deleteUrl) return;
const url = _deleteUrl;
const withFiles = _deleteFilesChecked;
closeDeleteModal();
await fetch(`/api/mangas?url=${encodeURIComponent(url)}&delete_files=${withFiles}`, {method:'DELETE'});
delete state.mangas[url];
renderList();
loadStats();
}
async function refreshMeta(url) {
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok) {
const btn = document.querySelector(`[data-refresh-url="${CSS.escape(url)}"]`);
if(btn) { btn.textContent = '✓'; btn.disabled = true; setTimeout(() => { btn.textContent = '🏷'; btn.disabled = false; }, 2000); }
}
}
async function refreshMetaModal(url) {
const btn = document.getElementById('modal-refresh-meta-btn');
if(btn) { btn.textContent = '⏳ Обновляем...'; btn.disabled = true; }
const r = await fetch('/api/mangas/refresh_meta?url='+encodeURIComponent(url), {method:'POST'});
if(btn) {
if(r.ok) {
btn.textContent = '✅ Метатеги обновлены';
btn.style.color = '#4ade80';
btn.style.borderColor = '#166534';
setTimeout(() => {
btn.textContent = '🏷 Обновить метатеги';
btn.disabled = false;
btn.style.color = '#a78bfa';
btn.style.borderColor = '#312e81';
}, 2500);
} else {
btn.textContent = '❌ Ошибка';
btn.disabled = false;
}
}
}
async function forceRedownload(url) {
if(!confirm('Скачать заново ВСЕ главы? Уже скачанные файлы будут перезаписаны.')) return;
const r = await fetch('/api/mangas/force_redownload?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok && state.mangas[url]) {
state.mangas[url].status = 'queued';
updateMangaRow(url);
}
}
async function forceRedownloadModal(url) {
if(!confirm('Скачать заново ВСЕ главы? Уже скачанные файлы будут перезаписаны.')) return;
const r = await fetch('/api/mangas/force_redownload?url='+encodeURIComponent(url), {method:'POST'});
if(r.ok && state.mangas[url]) {
state.mangas[url].status = 'queued';
updateMangaRow(url);
}
closeModal();
}
async function openDetail(url, initialTab = 'overview') {
try {
const r = await fetch('/api/mangas/detail?url='+encodeURIComponent(url));
const data = await r.json();
state.chapters[url] = data.chapters;
document.getElementById('modal-title').textContent = data.title || url;
document.getElementById('modal').dataset.currentUrl = url;
renderModalBody(data);
switchModalTab(initialTab);
document.getElementById('modal').classList.remove('hidden');
document.getElementById('modal').classList.add('flex');
} catch(e) {
alert('Ошибка загрузки: ' + e);
}
}
function closeModal() {
const m = document.getElementById('modal');
m.classList.add('hidden');
m.classList.remove('flex');
delete m.dataset.currentUrl;
}
function updateModalIfOpen(mangaUrl) {
const modal = document.getElementById('modal');
if(modal.classList.contains('hidden')) return;
if(modal.dataset.currentUrl !== mangaUrl) return;
const chapters = state.chapters[mangaUrl];
if(!chapters) return;
renderModalChapters(chapters);
}
// ── Render ────────────────────────────────────
function statusPill(status) {
const labels = {queued:'В очереди', downloading:'Загружается', done:'Готово', failed:'Ошибка', stopped:'Остановлена'};
return `<span class="pill pill-${status}">${status==='downloading'?'<span class="pulse-dot"></span>':''} ${labels[status]||status}</span>`;
}
function progressBar(done, total, color='blue') {
const pct = total > 0 ? Math.round(done/total*100) : 0;
return `
<div class="progress-bar mt-1">
<div class="progress-fill progress-fill-${color}" style="width:${pct}%"></div>
</div>
<div class="text-xs text-gray-500 mt-0.5">${done} / ${total} (${pct}%)</div>`;
}
function renderStats(s) {
document.getElementById('stats-row').innerHTML = `
<div class="stat-card"><div class="text-2xl font-bold text-white">${s.mangas_total}</div><div class="text-xs text-gray-400 mt-1">Всего манги</div></div>
<div class="stat-card"><div class="text-2xl font-bold text-blue-400">${s.mangas_downloading}</div><div class="text-xs text-gray-400 mt-1">Загружается</div></div>
<div class="stat-card"><div class="text-2xl font-bold text-purple-400">${s.mangas_queued}</div><div class="text-xs text-gray-400 mt-1">В очереди</div></div>
<div class="stat-card"><div class="text-2xl font-bold text-green-400">${s.mangas_done}</div><div class="text-xs text-gray-400 mt-1">Готово</div></div>
${s.mangas_stopped ? `<div class="stat-card"><div class="text-2xl font-bold text-yellow-400">${s.mangas_stopped}</div><div class="text-xs text-gray-400 mt-1">Остановлено</div></div>` : ''}
<div class="stat-card"><div class="text-2xl font-bold text-yellow-300">${s.total_size_human}</div><div class="text-xs text-gray-400 mt-1">На диске</div></div>
`;
}
function filterMangas(f) {
state.filter = f;
document.querySelectorAll('[id^="filter-"]').forEach(btn => {
btn.className = 'text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white';
});
document.getElementById('filter-'+f).className = 'text-xs px-3 py-1 rounded-full bg-indigo-600 text-white';
renderList();
}
function pubStatusPill(s) {
const map = {completed:'✅ Завершён', ongoing:'🔄 Продолжается', unknown:''};
if(!map[s] || s === 'unknown') return '';
return `<span class="pill pill-pub-${s}">${map[s]}</span>`;
}
function _sourceBadge(source) {
if(!source) return '<span style="font-size:0.65rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#64748b">Источник неизвестен</span>';
if(source.slug === 'unknown') return '<span style="font-size:0.65rem;padding:2px 6px;border-radius:4px;background:#450a0a;color:#fca5a5">' + escHtml(source.display_name) + '</span>';
return '<span style="font-size:0.65rem;padding:2px 6px;border-radius:4px;background:#0f2a1e;color:#6ee7b7">' + escHtml(source.display_name) + '</span>';
}
// ── Время загрузки ────────────────────────────
// Храним интервал живого таймера: url → intervalId
const _timerIntervals = {};
function _formatDuration(seconds) {
seconds = Math.max(0, Math.floor(seconds));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if(h > 0) return `${h}ч ${m}мин`;
if(m > 0) return `${m}мин ${s}с`;
return `${s}с`;
}
function _elapsedSec(isoStart) {
if(!isoStart) return null;
const start = new Date(isoStart + (isoStart.endsWith('Z') ? '' : 'Z'));
return (Date.now() - start.getTime()) / 1000;
}
function _totalDurationSec(isoStart, isoEnd) {
if(!isoStart || !isoEnd) return null;
const start = new Date(isoStart + (isoStart.endsWith('Z') ? '' : 'Z'));
const end = new Date(isoEnd + (isoEnd.endsWith('Z') ? '' : 'Z'));
return (end.getTime() - start.getTime()) / 1000;
}
function _etaStr(m) {
const chDone = m.chapters_done || 0;
const chTotal = m.chapters_total || 0;
if(chDone < 2 || chTotal <= chDone) return null;
const elapsed = _elapsedSec(m.started_at);
if(!elapsed || elapsed <= 0) return null;
const rate = chDone / elapsed; // глав/сек
const remaining = (chTotal - chDone) / rate;
return _formatDuration(remaining);
}
function _timerHtml(m) {
if(m.status === 'downloading' && m.started_at) {
const elapsed = _elapsedSec(m.started_at);
const elapsedStr = elapsed !== null ? _formatDuration(elapsed) : '…';
const eta = _etaStr(m);
return `<span id="timer-${CSS.escape(m.url)}" class="text-blue-300">⏱ ${elapsedStr}${eta ? ` · ост. ~${eta}` : ''}</span>`;
}
if((m.status === 'done' || m.status === 'failed' || m.status === 'stopped') && m.started_at) {
const total = _totalDurationSec(m.started_at, m.finished_at || m.updated_at);
if(total !== null && total > 0) {
const color = m.status === 'done' ? 'text-green-400' : 'text-gray-500';
return `<span class="${color}">⏱ ${_formatDuration(total)}</span>`;
}
}
return '';
}
// Запускаем/останавливаем живые таймеры
function _syncTimers() {
const mangas = Object.values(state.mangas);
// Остановить таймеры для манг которые уже не загружаются
Object.keys(_timerIntervals).forEach(url => {
const m = state.mangas[url];
if(!m || m.status !== 'downloading') {
clearInterval(_timerIntervals[url]);
delete _timerIntervals[url];
}
});
// Запустить таймеры для активных манг
mangas.forEach(m => {
if(m.status === 'downloading' && m.started_at && !_timerIntervals[m.url]) {
_timerIntervals[m.url] = setInterval(() => _tickTimer(m.url), 1000);
}
});
}
function _tickTimer(url) {
const m = state.mangas[url];
if(!m || m.status !== 'downloading') {
clearInterval(_timerIntervals[url]);
delete _timerIntervals[url];
return;
}
const el = document.getElementById('timer-' + CSS.escape(url));
if(!el) return;
const elapsed = _elapsedSec(m.started_at);
if(elapsed === null) return;
const eta = _etaStr(m);
el.textContent = `${_formatDuration(elapsed)}${eta ? ` · ост. ~${eta}` : ''}`;
}
function renderMangaRow(m) {
const chTotal = m.chapters_total || 0;
const chDone = m.chapters_done || 0;
const pDone = m._current_pages_done || 0;
const pTotal = m._current_pages_total || 0;
const sizeStr = m.size_human || '—';
let progressHtml = '';
if(m.status === 'downloading' || m.status === 'done') {
progressHtml = `
<div class="mt-2">
${progressBar(chDone, chTotal, 'blue')}
${m.status === 'downloading' && pTotal > 0 ? `
<div class="text-xs text-gray-500 mt-1">Текущая глава: ${pDone}/${pTotal} стр.</div>
${progressBar(pDone, pTotal, 'green')}
` : ''}
${m.current_chapter ? `<div class="text-xs text-purple-300 mt-1 truncate">📖 ${m.current_chapter}</div>` : ''}
</div>`;
}
return `<div class="px-5 py-4 chapter-row cursor-pointer" id="row-${CSS.escape(m.url)}" onclick="openDetail('${escHtml(m.url)}')">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span data-r="status">${statusPill(m.status)}</span>
<span data-r="pubstatus">${pubStatusPill(m.pub_status || 'unknown')}</span>
<span data-r="source">${_sourceBadge(m.source)}</span>
<span class="text-sm font-medium text-white truncate" data-r="title">${escHtml(m.title || m.url)}</span>
</div>
<div class="text-xs text-gray-500 mt-0.5 flex gap-3 flex-wrap">
<span data-r="chcount">📖 ${chDone}/${chTotal} глав</span>
<span data-r="size">💾 ${sizeStr}</span>
<span class="uppercase text-indigo-400">${m.format || 'cbz'}</span>
<span data-r="timer">${_timerHtml(m)}</span>
<span data-r="qpos">${m.queue_position ? `<span class="text-yellow-400">Позиция в очереди: ${m.queue_position}</span>` : ''}</span>
<span data-r="checked">${m.last_checked_at ? `<span title="${escHtml(m.last_checked_at)}">🔍 ${_relTime(m.last_checked_at)}</span>` : ''}</span>
</div>
<div data-r="progress">${progressHtml}</div>
</div>
<div class="flex flex-col gap-2 flex-shrink-0 items-end" onclick="event.stopPropagation()">
<div class="flex gap-1.5 flex-wrap justify-end" data-r="buttons">${_rowButtons(m)}</div>
<div data-r="auto">${_rowAuto(m)}</div>
</div>
</div>
</div>`;
}
function _relTime(iso) {
if(!iso) return '—';
const d = new Date(iso + (iso.endsWith('Z') ? '' : 'Z'));
const diff = Date.now() - d.getTime();
const mm = Math.floor(diff/60000);
if(mm < 2) return 'только что';
if(mm < 60) return `${mm} мин. назад`;
const h = Math.floor(mm/60);
if(h < 24) return `${h} ч. назад`;
return `${Math.floor(h/24)} д. назад`;
}
function _rowButtons(m) {
const u = escHtml(m.url);
const isActive = m.status === 'downloading' || m.status === 'queued';
return `
<button onclick="openDetail('${u}','overview')"
title="Информация о манге"
style="background:#1e293b;color:#94a3b8;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer"></button>
${(m.errors_count ?? 0) > 0
? `<button onclick="openDetail('${u}','errors')"
title="${m.errors_count} проблем при загрузке"
style="background:#450a0a;color:#fca5a5;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">⚠️ ${m.errors_count}</button>`
: ''}
${isActive
? `<button onclick="stopManga('${u}')" class="btn-danger" title="Остановить" style="background:#7c2d12;color:#fdba74">⏸</button>`
: ''}
${m.status === 'stopped' || m.status === 'failed'
? `<button onclick="resumeManga('${u}')" title="Возобновить" style="background:#14532d;color:#86efac;padding:4px 12px;border-radius:6px;font-size:0.75rem;cursor:pointer">▶</button>`
: ''}
${m.status === 'queued'
? `<button onclick="prioritizeManga('${u}')" title="Загрузить первой" style="background:#312e81;color:#a5b4fc;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">🚀</button>`
: ''}
<button onclick="deleteManga('${u}')" class="btn-danger" title="Удалить">✕</button>
`;
}
function _rowAuto(m) {
if(m.pub_status !== 'ongoing') return '';
const u = escHtml(m.url);
const autoOn = m.auto_update == 1;
return `
<div class="flex items-center gap-2 text-xs text-gray-400">
<label class="toggle" onclick="event.stopPropagation()">
<input type="checkbox" ${autoOn ? 'checked' : ''} onchange="toggleAutoUpdate('${u}', this)">
<span class="toggle-slider"></span>
</label>
<span>Авто</span>
<button onclick="event.stopPropagation(); checkNowBtn(this, '${u}')"
title="Проверить новые главы сейчас"
class="text-indigo-400 hover:text-white transition-colors px-1 rounded"
style="line-height:1">↻</button>
</div>`;
}
function _sortedMangas() {
let mangas = Object.values(state.mangas);
if(state.filter !== 'all') mangas = mangas.filter(m => m.status === state.filter);
const order = {downloading: 0, queued: 1, stopped: 2, failed: 3, done: 4};
mangas.sort((a, b) => {
const oa = order[a.status] ?? 2, ob = order[b.status] ?? 2;
if(oa !== ob) return oa - ob;
return (b.added_at || '').localeCompare(a.added_at || '');
});
return mangas;
}
function renderList() {
const mangas = _sortedMangas();
const el = document.getElementById('manga-list');
if(!mangas.length) {
el.innerHTML = `<div class="px-5 py-8 text-center text-gray-500 text-sm">Пусто</div>`;
return;
}
// Убираем "Пусто"-заглушку если была
if(el.firstElementChild && !el.firstElementChild.id) el.innerHTML = '';
const existingIds = new Set(Array.from(el.children).map(e => e.id).filter(Boolean));
const newIds = new Set(mangas.map(m => 'row-' + CSS.escape(m.url)));
// Удаляем строки которых больше нет
existingIds.forEach(id => {
if(!newIds.has(id)) { const old = document.getElementById(id); if(old) old.remove(); }
});
// Вставляем/обновляем строки в правильном порядке
mangas.forEach((m, i) => {
const rowId = 'row-' + CSS.escape(m.url);
let rowEl = document.getElementById(rowId);
if(!rowEl) {
// Новая строка — создаём с анимацией
const tmp = document.createElement('div');
tmp.innerHTML = renderMangaRow(m);
rowEl = tmp.firstElementChild;
rowEl.classList.add('row-new');
rowEl.addEventListener('animationend', () => rowEl.classList.remove('row-new'), {once: true});
el.appendChild(rowEl);
} else {
_patchRow(rowEl, m);
}
// Восстанавливаем порядок если нужно
const sibling = el.children[i];
if(sibling !== rowEl) el.insertBefore(rowEl, sibling || null);
});
_syncTimers();
}
// Точечно обновляет только изменившиеся части строки
function _patchRow(el, m) {
const set = (attr, html) => {
const node = el.querySelector(`[data-r="${attr}"]`);
if(node && node.innerHTML !== html) node.innerHTML = html;
};
const chDone = m.chapters_done || 0;
const chTotal = m.chapters_total || 0;
const pDone = m._current_pages_done || 0;
const pTotal = m._current_pages_total || 0;
let progressHtml = '';
if(m.status === 'downloading' || m.status === 'done') {
progressHtml = `
<div class="mt-2">
${progressBar(chDone, chTotal, 'blue')}
${m.status === 'downloading' && pTotal > 0 ? `
<div class="text-xs text-gray-500 mt-1">Текущая глава: ${pDone}/${pTotal} стр.</div>
${progressBar(pDone, pTotal, 'green')}
` : ''}
${m.current_chapter ? `<div class="text-xs text-purple-300 mt-1 truncate">📖 ${m.current_chapter}</div>` : ''}
</div>`;
}
set('status', statusPill(m.status));
set('pubstatus', pubStatusPill(m.pub_status || 'unknown'));
set('source', _sourceBadge(m.source));
set('title', escHtml(m.title || m.url));
set('chcount', `📖 ${chDone}/${chTotal} глав`);
set('size', `💾 ${m.size_human || '—'}`);
set('timer', _timerHtml(m));
set('qpos', m.queue_position ? `<span class="text-yellow-400">Позиция в очереди: ${m.queue_position}</span>` : '');
set('checked', m.last_checked_at ? `<span title="${escHtml(m.last_checked_at)}">🔍 ${_relTime(m.last_checked_at)}</span>` : '');
set('progress', progressHtml);
set('buttons', _rowButtons(m));
set('auto', _rowAuto(m));
}
function updateMangaRow(url) {
const m = state.mangas[url];
if(!m) return;
const el = document.getElementById('row-' + CSS.escape(url));
if(el) _patchRow(el, m);
_syncTimers();
}
function renderModalBody(data) {
const modal = document.getElementById('modal-body');
const stats = data.stats || {};
const errors = data.errors || [];
const files = data.files || [];
const errBadge = errors.length
? `<span class="ml-1 px-1.5 py-0.5 rounded-full text-xs bg-red-700 text-white">${errors.length}</span>` : '';
modal.innerHTML = `
<!-- Внутренние вкладки модалки -->
<div class="flex border-b border-gray-700 mb-4 -mt-2 -mx-6 px-6">
<button onclick="switchModalTab('overview')" id="mtab-overview"
class="px-3 py-2 text-sm font-semibold border-b-2 border-indigo-500 text-white">📊 Обзор</button>
<button onclick="switchModalTab('chapters')" id="mtab-chapters"
class="px-3 py-2 text-sm font-semibold border-b-2 border-transparent text-gray-400 hover:text-white">
📖 Главы (${data.chapters_total || data.chapters?.length || 0})</button>
<button onclick="switchModalTab('errors')" id="mtab-errors"
class="px-3 py-2 text-sm font-semibold border-b-2 border-transparent ${errors.length ? 'text-red-400 hover:text-red-300' : 'text-gray-500'}">
⚠️ Ошибки${errBadge}</button>
</div>
<!-- Обзор -->
<div id="mtab-content-overview">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="stat-card">
<div class="text-lg font-bold text-white">${data.chapters_total || 0}</div>
<div class="text-xs text-gray-400">Глав всего</div>
</div>
<div class="stat-card">
<div class="text-lg font-bold text-green-400">${stats.chapters_done ?? data.chapters_done ?? 0}</div>
<div class="text-xs text-gray-400">Скачано</div>
</div>
<div class="stat-card">
<div class="text-lg font-bold ${stats.chapters_failed > 0 ? 'text-red-400' : 'text-gray-500'}">${stats.chapters_failed ?? 0}</div>
<div class="text-xs text-gray-400">Ошибок</div>
</div>
<div class="stat-card">
<div class="text-lg font-bold text-yellow-300">${data.size_human || '—'}</div>
<div class="text-xs text-gray-400">На диске</div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
<div class="stat-card">
<div class="text-lg font-bold text-blue-400">${stats.total_pages_downloaded ?? 0}</div>
<div class="text-xs text-gray-400">Стр. скачано</div>
</div>
<div class="stat-card">
<div class="text-lg font-bold text-gray-400">${stats.total_pages_expected ?? 0}</div>
<div class="text-xs text-gray-400">Стр. ожидалось</div>
</div>
<div class="stat-card">
<div class="text-lg font-bold ${(stats.pages_missing ?? 0) > 0 ? 'text-orange-400' : 'text-gray-500'}">${stats.pages_missing ?? 0}</div>
<div class="text-xs text-gray-400">Стр. пропущено</div>
</div>
<div class="stat-card">
<div class="text-lg font-bold text-purple-400">${data.files_count || 0}</div>
<div class="text-xs text-gray-400">Файлов</div>
</div>
</div>
<div class="space-y-2 text-xs mb-4">
<div class="flex gap-2 items-center flex-wrap">
<span class="text-gray-400 font-semibold">URL:</span>
<a href="${escHtml(data.url)}" target="_blank" class="text-indigo-400 hover:underline truncate">${escHtml(data.url)}</a>
</div>
${data.pub_status && data.pub_status !== 'unknown' ? `
<div class="flex gap-2 items-center">
<span class="text-gray-400 font-semibold">Выпуск:</span>
${pubStatusPill(data.pub_status)}
</div>` : ''}
${data.title_full ? `
<div class="flex gap-2 items-start flex-wrap">
<span class="text-gray-400 font-semibold flex-shrink-0">Полный тайтл:</span>
<span class="text-gray-300">${escHtml(data.title_full)}</span>
</div>` : ''}
${data.format ? `
<div class="flex gap-2 items-center">
<span class="text-gray-400 font-semibold">Формат:</span>
<span class="text-indigo-400 uppercase font-bold">${escHtml(data.format)}</span>
</div>` : ''}
${data.added_at ? `
<div class="flex gap-2 items-center">
<span class="text-gray-400 font-semibold">Добавлена:</span>
<span class="text-gray-300">${new Date(data.added_at+'Z').toLocaleString('ru-RU')}</span>
</div>` : ''}
${data.started_at ? `
<div class="flex gap-2 items-center">
<span class="text-gray-400 font-semibold">Начало загрузки:</span>
<span class="text-gray-300">${new Date(data.started_at+'Z').toLocaleString('ru-RU')}</span>
</div>` : ''}
${data.started_at && data.finished_at ? `
<div class="flex gap-2 items-center">
<span class="text-gray-400 font-semibold">Время загрузки:</span>
<span class="text-green-400 font-semibold">⏱ ${_formatDuration(_totalDurationSec(data.started_at, data.finished_at))}</span>
</div>` : ''}
${data.started_at && !data.finished_at && data.status === 'downloading' ? `
<div class="flex gap-2 items-center">
<span class="text-gray-400 font-semibold">Идёт загрузка:</span>
<span class="text-blue-300 font-semibold">⏱ ${_formatDuration(_elapsedSec(data.started_at))}</span>
</div>` : ''}
</div>
${files.length ? `
<details class="mb-2">
<summary class="cursor-pointer text-sm text-gray-400 hover:text-white">📁 Файлы (${files.length})</summary>
<div class="space-y-1 mt-2 max-h-36 overflow-y-auto">
${files.map(f=>`<div class="flex justify-between text-xs px-2 py-1 rounded" style="background:#0f1117">
<span class="text-gray-300 truncate">${escHtml(f.name)}</span>
<span class="text-gray-500 flex-shrink-0 ml-2">${f.size_human}</span>
</div>`).join('')}
</div>
</details>` : '<div class="text-xs text-gray-500 mb-3">Файлов на диске нет</div>'}
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-800">
<button onclick="openEditMeta('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
✏️ Редактировать название
</button>
${data.status !== 'downloading' ? `
<button onclick="openRenameFolder('${escHtml(data.url)}', '${escHtml(data.folder_name || '')}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
📁 Переименовать папку
</button>` : ''}
${data.status === 'done' ? `
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
🏷 Обновить метатеги
</button>` : ''}
${data.status !== 'downloading' && data.status !== 'queued' ? `
<button onclick="forceRedownloadModal('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#0c1a2e;color:#93c5fd;border:1px solid #1e3a5f">
↺ Скачать заново
</button>` : ''}
${data.status !== 'downloading' ? `
<button onclick="openSwitchSourceModal('${escHtml(data.url)}')"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
style="background:#0f2a1e;color:#6ee7b7;border:1px solid #1e3a2e">
↔ Сменить источник
</button>` : ''}
</div>
</div>
<!-- Главы -->
<div id="mtab-content-chapters" class="hidden">
<div class="flex gap-3 text-xs text-gray-500 mb-3 flex-wrap">
<span class="flex items-center gap-1">✅ Скачано: <b class="text-green-400">${stats.chapters_done ?? 0}</b></span>
<span class="flex items-center gap-1">⏳ Ожидает: <b class="text-gray-400">${stats.chapters_pending ?? 0}</b></span>
<span class="flex items-center gap-1">❌ Ошибка: <b class="text-red-400">${stats.chapters_failed ?? 0}</b></span>
${(stats.chapters_partial ?? 0) > 0 ? `<span class="flex items-center gap-1">⚠️ Частичные: <b class="text-orange-400">${stats.chapters_partial}</b></span>` : ''}
</div>
<div id="modal-chapters" class="space-y-0.5 max-h-[480px] overflow-y-auto">
${renderChaptersHtml(data.chapters || [])}
</div>
</div>
<!-- Ошибки -->
<div id="mtab-content-errors" class="hidden">
${errors.length === 0
? `<div class="py-12 text-center">
<div class="text-4xl mb-3">✅</div>
<div class="text-gray-400 font-medium">Ошибок нет</div>
<div class="text-gray-600 text-sm mt-1">Всё скачано успешно</div>
</div>`
: `<div class="text-xs text-gray-500 mb-3">
Всего проблем: <b class="text-white">${errors.length}</b>
${stats.pages_missing > 0 ? ` · Пропущено страниц: <b class="text-orange-400">${stats.pages_missing}</b>` : ''}
</div>
<div class="space-y-1 max-h-[480px] overflow-y-auto">
${errors.map(c => renderErrorRow(c)).join('')}
</div>
<div class="mt-4 pt-3 border-t border-gray-800">
<button onclick="retryErrors('${escHtml(data.url)}')"
class="text-xs px-4 py-2 rounded-lg font-semibold"
style="background:#312e81;color:#a5b4fc">
🔄 Повторить все неудачные
</button>
</div>`}
</div>
`;
}
function switchModalTab(tab) {
['overview','chapters','errors'].forEach(t => {
document.getElementById('mtab-content-'+t).classList.toggle('hidden', t !== tab);
const btn = document.getElementById('mtab-'+t);
if(btn) btn.className = btn.className.replace(
/border-b-2 border-\S+/g,
t === tab ? 'border-b-2 border-indigo-500' : 'border-b-2 border-transparent'
).replace(/\btext-white\b/, t === tab ? 'text-white' : 'text-gray-400 hover:text-white')
.replace(/\btext-gray-400 hover:text-white\b/, t === tab ? 'text-white' : 'text-gray-400 hover:text-white')
.replace(/\btext-red-400 hover:text-red-300\b/, t === tab ? 'text-red-300' : 'text-red-400 hover:text-red-300');
});
}
function renderErrorRow(c) {
const isPartial = c.error_type === 'partial';
const pDone = c.pages_done || 0;
const pTotal = c.pages_total || 0;
const pct = pTotal > 0 ? Math.round(pDone / pTotal * 100) : 0;
return `
<div class="px-3 py-3 rounded-lg mb-1" style="background:#1a0e0e;border:1px solid ${isPartial ? '#78350f44' : '#7f1d1d44'}">
<div class="flex items-start gap-2">
<span class="text-lg flex-shrink-0">${isPartial ? '⚠️' : '❌'}</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-semibold ${isPartial ? 'text-orange-400' : 'text-red-400'}">${escHtml(c.error_label)}</span>
<span class="text-xs text-gray-500">Том ${c.volume || '?'} · Гл. ${c.number || '?'}</span>
</div>
<div class="text-xs text-gray-300 mt-0.5 truncate">${escHtml(c.title || '')}</div>
${isPartial ? `
<div class="mt-1.5">
<div class="progress-bar" style="height:3px">
<div class="progress-fill progress-fill-${pct < 50 ? 'blue' : 'green'}" style="width:${pct}%"></div>
</div>
<div class="text-xs text-gray-500 mt-0.5">${pDone} из ${pTotal} стр. (${pct}%)</div>
</div>` : ''}
<a href="${escHtml(c.chapter_url || '')}" target="_blank"
class="text-xs text-indigo-400 hover:underline mt-0.5 inline-block truncate max-w-full">
${escHtml(c.chapter_url || '')}
</a>
</div>
</div>
</div>`;
}
async function retryErrors(mangaUrl) {
await fetch('/api/mangas/retry_errors?url='+encodeURIComponent(mangaUrl), {method:'POST'});
closeModal();
await resumeManga(mangaUrl);
}
function renderModalChapters(chapters) {
const el = document.getElementById('modal-chapters');
if(el) el.innerHTML = renderChaptersHtml(chapters);
}
function renderChaptersHtml(chapters) {
if(!chapters.length) return '<div class="text-gray-500 text-sm">Нет данных</div>';
return chapters.map(ch => {
const icons = {done:'✅', failed:'❌', pending:'⏳', downloading:'🔄'};
const icon = icons[ch.status] || '⏳';
const pDone = ch.pages_done || 0;
const pTotal = ch.pages_total || 0;
return `<div class="flex items-center gap-3 px-3 py-2 rounded-lg chapter-row text-xs">
<span>${icon}</span>
<div class="flex-1 min-w-0">
<div class="flex gap-2 items-center">
<span class="text-gray-200">Т${ch.volume||0} Гл.${ch.number||0}</span>
<span class="text-gray-400 truncate">${escHtml(ch.title||'')}</span>
</div>
${ch.status === 'downloading' && pTotal > 0 ? `
<div class="progress-bar mt-1" style="height:3px">
<div class="progress-fill progress-fill-green" style="width:${Math.round(pDone/pTotal*100)}%"></div>
</div>
<span class="text-gray-500">${pDone}/${pTotal} стр.</span>
` : ''}
</div>
</div>`;
}).join('');
}
function escHtml(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Edit Meta ────────────────────────────────
let _editMetaUrl = null;
function openEditMeta(url) {
_editMetaUrl = url;
const m = state.mangas[url] || {};
document.getElementById('edit-meta-title-ru').value = m.title_ru || m.title || '';
document.getElementById('edit-meta-title-full').value = m.title_full || '';
const modal = document.getElementById('edit-meta-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeEditMeta() {
const modal = document.getElementById('edit-meta-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
_editMetaUrl = null;
}
async function saveEditMeta() {
if(!_editMetaUrl) return;
const btn = document.getElementById('edit-meta-save-btn');
btn.disabled = true; btn.textContent = '⏳ Сохраняем...';
const title_ru = document.getElementById('edit-meta-title-ru').value.trim();
const title_full = document.getElementById('edit-meta-title-full').value.trim();
try {
const r = await fetch('/api/mangas/update_meta', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({url: _editMetaUrl, title_ru, title_full}),
});
if(!r.ok) throw new Error(await r.text());
// Update local state
if(state.mangas[_editMetaUrl]) {
state.mangas[_editMetaUrl].title = title_ru;
state.mangas[_editMetaUrl].title_ru = title_ru;
state.mangas[_editMetaUrl].title_full = title_full;
}
renderList();
closeEditMeta();
} catch(e) {
alert('Ошибка: ' + e.message);
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
// ── Rename Folder ────────────────────────────
let _renameFolderUrl = null;
function openRenameFolder(url, currentFolder) {
_renameFolderUrl = url;
const m = state.mangas[url] || {};
const cur = currentFolder || m.folder_name || (m.title_ru || m.title || '').replace(/[^\w\s\-]/g,'').trim().replace(/ /g,'_');
document.getElementById('rename-folder-input').value = cur;
const modal = document.getElementById('rename-folder-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeRenameFolder() {
const modal = document.getElementById('rename-folder-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
_renameFolderUrl = null;
}
async function saveRenameFolder() {
if(!_renameFolderUrl) return;
const btn = document.getElementById('rename-folder-save-btn');
btn.disabled = true; btn.textContent = '⏳ Переименовываем...';
const folder_name = document.getElementById('rename-folder-input').value.trim();
try {
const r = await fetch('/api/mangas/rename_folder', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({url: _renameFolderUrl, folder_name}),
});
if(!r.ok) throw new Error((await r.json()).detail || await r.text());
const data = await r.json();
if(state.mangas[_renameFolderUrl]) {
state.mangas[_renameFolderUrl].folder_name = data.folder_name;
}
closeRenameFolder();
} catch(e) {
alert('Ошибка: ' + e.message);
} finally {
btn.disabled = false; btn.textContent = 'Переименовать';
}
}
// ── Init ─────────────────────────────────────
async function _refreshMangaList() {
try {
const r = await fetch('/api/mangas');
if(!r.ok) return;
const mangas = await r.json();
mangas.forEach(m => { state.mangas[m.url] = m; });
renderList();
} catch(e) {}
}
async function initApp() {
_initDeleteModal();
await loadStats();
await loadSources();
connectWS();
await _refreshMangaList();
// Периодически синхронизируем список манг — подстраховка от потерянных WS событий
setInterval(_refreshMangaList, 20000);
setInterval(loadStats, 15000);
}
async function init() {
const ok = await checkAuth();
if(ok) await initApp();
}
document.addEventListener('DOMContentLoaded', () => {
init();
// Enter в полях логина
['login-input','password-input'].forEach(id => {
document.getElementById(id).addEventListener('keydown', e => {
if(e.key === 'Enter') doLogin();
});
});
});
// Закрытие модалки по клику снаружи
document.getElementById('modal').addEventListener('click', function(e) {
if(e.target === this) closeModal();
});
document.getElementById('edit-meta-modal').addEventListener('click', function(e) {
if(e.target === this) closeEditMeta();
});
document.getElementById('rename-folder-modal').addEventListener('click', function(e) {
if(e.target === this) closeRenameFolder();
});
</script>
</body>
</html>