1540 lines
67 KiB
HTML
1540 lines
67 KiB
HTML
<!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>
|
||
<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; }
|
||
</style>
|
||
</head>
|
||
<body class="min-h-screen">
|
||
|
||
<!-- 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">
|
||
<span class="text-2xl">📚</span>
|
||
<h1 class="text-xl font-bold text-white">Manga Downloader</h1>
|
||
</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>
|
||
</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 (каждый с новой строки) https://3.readmanga.ru/manga_slug" class="flex-1 px-3 py-2 text-sm resize-none"></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()" class="btn-primary text-sm">➕ В очередь</button>
|
||
</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>
|
||
</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>
|
||
</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',
|
||
};
|
||
|
||
// ── WebSocket ────────────────────────────────
|
||
let ws, wsReconnectTimer;
|
||
|
||
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
|
||
setInterval(() => { if(ws.readyState===1) ws.send('ping'); }, 20000);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
document.getElementById('ws-dot').className = 'w-2 h-2 rounded-full bg-red-500';
|
||
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();
|
||
break;
|
||
|
||
case 'manga_queued':
|
||
if(!state.mangas[msg.url]) {
|
||
state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format,
|
||
chapters_total: 0, chapters_done: 0, size_human: '—' };
|
||
}
|
||
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.mangas[msg.url].status = 'downloading';
|
||
if(msg.started_at) state.mangas[msg.url].started_at = msg.started_at;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ── Tabs ─────────────────────────────────────
|
||
let newsUnreadCount = 0;
|
||
|
||
function switchTab(tab) {
|
||
['mangas', 'news', 'history'].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(); }
|
||
}
|
||
|
||
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'});
|
||
}
|
||
|
||
// ── 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;
|
||
|
||
try {
|
||
const r = await fetch('/api/queue', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({urls, format: fmt})
|
||
});
|
||
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 = '';
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ── 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>`;
|
||
}
|
||
|
||
// ── Время загрузки ────────────────────────────
|
||
// Храним интервал живого таймера: 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 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>
|
||
${autoOn ? `<button onclick="checkNow('${u}')" class="text-indigo-400 hover:text-indigo-300 text-xs">↻</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('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>` : ''}
|
||
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ── 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 init() {
|
||
_initDeleteModal();
|
||
await loadStats();
|
||
connectWS();
|
||
// Загружаем список манги
|
||
try {
|
||
const r = await fetch('/api/mangas');
|
||
const mangas = await r.json();
|
||
mangas.forEach(m => { state.mangas[m.url] = m; });
|
||
renderList();
|
||
} catch(e) {}
|
||
setInterval(loadStats, 15000);
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
|
||
// Закрытие модалки по клику снаружи
|
||
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>
|
||
|