2356 lines
102 KiB
HTML
2356 lines
102 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>
|
||
<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>
|
||
<span id="user-info" class="hidden text-xs text-gray-400 px-2"></span>
|
||
</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" 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>
|
||
<!-- Управление пользователями (только для admin) -->
|
||
<div id="users-section" class="hidden px-5 py-4 border-t border-gray-800">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider">Пользователи</h3>
|
||
<button onclick="openAddUserModal()" class="text-xs px-3 py-1.5 rounded-lg font-semibold text-white" style="background:#4f46e5">+ Добавить</button>
|
||
</div>
|
||
<div id="users-list" class="flex flex-col gap-2"></div>
|
||
</div>
|
||
<!-- Смена своего пароля -->
|
||
<div id="chpwd-section" class="px-5 py-4 border-t border-gray-800">
|
||
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3>
|
||
<div class="flex flex-col gap-2 max-w-sm">
|
||
<input id="chpwd-new" type="password" placeholder="Новый пароль"
|
||
class="px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
|
||
style="background:#0f1117">
|
||
<button onclick="changeOwnPassword()" class="text-xs px-4 py-2 rounded-lg font-semibold text-white" style="background:#4f46e5">Сохранить пароль</button>
|
||
<div id="chpwd-msg" class="text-xs hidden"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- User Modal -->
|
||
<div id="user-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" id="user-modal-title">Добавить пользователя</h3>
|
||
<div class="flex flex-col gap-3">
|
||
<div>
|
||
<label class="text-xs text-gray-400 mb-1 block">Логин</label>
|
||
<input id="user-modal-username" 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="username">
|
||
</div>
|
||
<div id="user-modal-pwd-wrap">
|
||
<label class="text-xs text-gray-400 mb-1 block">Пароль <span id="user-modal-pwd-hint" class="text-gray-600">(оставьте пустым чтобы не менять)</span></label>
|
||
<input id="user-modal-password" type="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>
|
||
<label class="text-xs text-gray-400 mb-1 block">Роль</label>
|
||
<select id="user-modal-role" class="w-full px-3 py-2 text-sm rounded-lg border border-gray-700" style="background:#0f1117;color:#e2e8f0">
|
||
<option value="user">user — обычный</option>
|
||
<option value="admin">admin — администратор</option>
|
||
</select>
|
||
</div>
|
||
<div id="user-modal-error" class="text-xs text-red-400 hidden"></div>
|
||
</div>
|
||
<div class="flex gap-3 justify-end mt-2">
|
||
<button onclick="closeUserModal()" class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white" style="background:#1e293b">Отмена</button>
|
||
<button onclick="saveUserModal()" id="user-modal-save" class="px-4 py-2 rounded-lg text-sm font-semibold text-white" style="background:#4f46e5">Сохранить</button>
|
||
</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}]
|
||
currentUser: null, // {id, username, role}
|
||
};
|
||
|
||
// ── 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');
|
||
const uinfo = document.getElementById('user-info');
|
||
if(uinfo && state.currentUser) {
|
||
const roleLabel = state.currentUser.role === 'admin' ? '👑' : '👤';
|
||
uinfo.textContent = `${roleLabel} ${state.currentUser.username}`;
|
||
uinfo.classList.remove('hidden');
|
||
}
|
||
const chpwd = document.getElementById('chpwd-section');
|
||
if(chpwd) chpwd.classList.toggle('hidden', !!(state.currentUser && state.currentUser.is_env_admin));
|
||
}
|
||
|
||
async function checkAuth() {
|
||
try {
|
||
const r = await fetch('/api/auth/check');
|
||
const data = await r.json();
|
||
if(data.authenticated && data.user) {
|
||
state.currentUser = data.user;
|
||
hideLoginScreen();
|
||
return true;
|
||
}
|
||
if(!data.auth_enabled) {
|
||
// auth отключён (обратная совместимость)
|
||
state.currentUser = {id: 0, username: 'guest', role: 'admin'};
|
||
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;
|
||
}
|
||
const d = await r.json().catch(()=>({}));
|
||
if(d.user) state.currentUser = d.user;
|
||
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(()=>{});
|
||
state.currentUser = null;
|
||
showLoginScreen();
|
||
document.getElementById('login-input').value = '';
|
||
document.getElementById('password-input').value = '';
|
||
const uinfo = document.getElementById('user-info');
|
||
if(uinfo) uinfo.classList.add('hidden');
|
||
// Сбрасываем состояние
|
||
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: '—',
|
||
added_by: msg.added_by || null,
|
||
added_by_username: msg.added_by_username || null,
|
||
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(); showUsersSection(); }
|
||
}
|
||
|
||
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) {
|
||
// Немедленно обновляем UI, не дожидаясь WS-события
|
||
if(state.mangas[url]) {
|
||
state.mangas[url].status = 'stopped';
|
||
state.mangas[url].is_active = false;
|
||
renderList();
|
||
loadStats();
|
||
}
|
||
await fetch('/api/mangas/stop?url='+encodeURIComponent(url), {method:'POST'});
|
||
}
|
||
|
||
// ── Helpers доступа ─────────────────────────
|
||
function isAdmin() {
|
||
return state.currentUser && state.currentUser.role === 'admin';
|
||
}
|
||
|
||
function canManage(manga) {
|
||
if(!state.currentUser) return false;
|
||
if(state.currentUser.role === 'admin') return true;
|
||
if(!manga) return false;
|
||
return manga.added_by === state.currentUser.id;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ── Users management (admin only) ─────────────
|
||
let _userModalEditId = null;
|
||
const _userDataCache = {};
|
||
|
||
function showUsersSection() {
|
||
if(isAdmin()) {
|
||
document.getElementById('users-section').classList.remove('hidden');
|
||
loadUsers();
|
||
}
|
||
}
|
||
|
||
async function loadUsers() {
|
||
if(!isAdmin()) return;
|
||
try {
|
||
const r = await fetch('/api/users');
|
||
if(!r.ok) return;
|
||
const users = await r.json();
|
||
renderUsers(users);
|
||
} catch(e) {}
|
||
}
|
||
|
||
function renderUsers(users) {
|
||
const el = document.getElementById('users-list');
|
||
if(!el) return;
|
||
if(!users.length) {
|
||
el.innerHTML = '<div class="text-xs text-gray-500">Нет пользователей</div>';
|
||
return;
|
||
}
|
||
users.forEach(u => { _userDataCache[u.id] = u; });
|
||
const roleColors = {admin: 'color:#fbbf24;background:#292103', user: 'color:#86efac;background:#032911'};
|
||
el.innerHTML = users.map(u => `
|
||
<div class="flex items-center justify-between px-3 py-2 rounded-lg" style="background:#1e293b">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-sm text-white font-medium">${escHtml(u.username)}</span>
|
||
<span class="text-xs px-2 py-0.5 rounded-full font-semibold" style="${roleColors[u.role] || ''}">
|
||
${u.role === 'admin' ? '👑 admin' : '👤 user'}
|
||
</span>
|
||
${u.is_env_admin ? '<span class="text-xs text-gray-500" title="Системный администратор — пароль задаётся через AUTH_PASSWORD">🔒</span>' : ''}
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button data-action="edit-user" data-id="${u.id}"
|
||
class="text-xs px-2 py-1 rounded text-gray-400 hover:text-white" style="background:#334155">✏️</button>
|
||
${!u.is_env_admin && u.id !== state.currentUser?.id ? `<button data-action="delete-user" data-id="${u.id}"
|
||
class="text-xs px-2 py-1 rounded text-red-400 hover:text-red-300" style="background:#2d1111">✕</button>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
el.querySelectorAll('[data-action="edit-user"]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const u = _userDataCache[+btn.dataset.id];
|
||
if(u) openEditUserModal(u.id, u.username, u.role, !!u.is_env_admin);
|
||
});
|
||
});
|
||
el.querySelectorAll('[data-action="delete-user"]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const u = _userDataCache[+btn.dataset.id];
|
||
if(u) confirmDeleteUser(u.id, u.username);
|
||
});
|
||
});
|
||
}
|
||
|
||
function openAddUserModal() {
|
||
_userModalEditId = null;
|
||
document.getElementById('user-modal-title').textContent = 'Добавить пользователя';
|
||
document.getElementById('user-modal-username').value = '';
|
||
document.getElementById('user-modal-username').disabled = false;
|
||
document.getElementById('user-modal-password').value = '';
|
||
const pwdWrap = document.getElementById('user-modal-pwd-wrap');
|
||
if(pwdWrap) pwdWrap.style.display = '';
|
||
document.getElementById('user-modal-pwd-hint').style.display = 'none';
|
||
document.getElementById('user-modal-role').value = 'user';
|
||
document.getElementById('user-modal-error').classList.add('hidden');
|
||
document.getElementById('user-modal').style.display = 'flex';
|
||
document.getElementById('user-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function openEditUserModal(id, username, role, isEnvAdmin) {
|
||
_userModalEditId = id;
|
||
document.getElementById('user-modal-title').textContent = 'Редактировать пользователя';
|
||
document.getElementById('user-modal-username').value = username;
|
||
document.getElementById('user-modal-username').disabled = false;
|
||
document.getElementById('user-modal-password').value = '';
|
||
const pwdWrap = document.getElementById('user-modal-pwd-wrap');
|
||
if(isEnvAdmin) {
|
||
if(pwdWrap) pwdWrap.style.display = 'none';
|
||
} else {
|
||
if(pwdWrap) pwdWrap.style.display = '';
|
||
document.getElementById('user-modal-pwd-hint').style.display = '';
|
||
}
|
||
document.getElementById('user-modal-role').value = role;
|
||
document.getElementById('user-modal-error').classList.add('hidden');
|
||
document.getElementById('user-modal').style.display = 'flex';
|
||
document.getElementById('user-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function closeUserModal() {
|
||
document.getElementById('user-modal').style.display = 'none';
|
||
document.getElementById('user-modal').classList.add('hidden');
|
||
}
|
||
|
||
async function saveUserModal() {
|
||
const errEl = document.getElementById('user-modal-error');
|
||
const btn = document.getElementById('user-modal-save');
|
||
const username = document.getElementById('user-modal-username').value.trim();
|
||
const password = document.getElementById('user-modal-password').value;
|
||
const role = document.getElementById('user-modal-role').value;
|
||
errEl.classList.add('hidden');
|
||
btn.disabled = true;
|
||
try {
|
||
let r;
|
||
if(_userModalEditId === null) {
|
||
// Создание
|
||
if(!username || !password) { errEl.textContent = 'Логин и пароль обязательны'; errEl.classList.remove('hidden'); return; }
|
||
r = await fetch('/api/users', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({username, password, role}),
|
||
});
|
||
} else {
|
||
// Редактирование
|
||
const body = {role};
|
||
if(username) body.username = username;
|
||
if(password) body.password = password;
|
||
r = await fetch(`/api/users/${_userModalEditId}`, {
|
||
method: 'PATCH',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
if(!r.ok) {
|
||
const d = await r.json().catch(()=>({}));
|
||
errEl.textContent = d.detail || 'Ошибка';
|
||
errEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
closeUserModal();
|
||
loadUsers();
|
||
} catch(e) {
|
||
errEl.textContent = 'Ошибка сети';
|
||
errEl.classList.remove('hidden');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function confirmDeleteUser(id, username) {
|
||
if(!confirm(`Удалить пользователя «${username}»?`)) return;
|
||
const r = await fetch(`/api/users/${id}`, {method: 'DELETE'});
|
||
if(r.ok) {
|
||
loadUsers();
|
||
} else {
|
||
const d = await r.json().catch(()=>({}));
|
||
alert(d.detail || 'Ошибка удаления');
|
||
}
|
||
}
|
||
|
||
async function changeOwnPassword() {
|
||
if(!state.currentUser) return;
|
||
const pwd = document.getElementById('chpwd-new').value;
|
||
const msg = document.getElementById('chpwd-msg');
|
||
msg.classList.add('hidden');
|
||
if(!pwd) { msg.textContent = 'Введите новый пароль'; msg.className = 'text-xs text-red-400'; msg.classList.remove('hidden'); return; }
|
||
const r = await fetch(`/api/users/${state.currentUser.id}`, {
|
||
method: 'PATCH',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({password: pwd}),
|
||
});
|
||
if(r.ok) {
|
||
msg.textContent = '✓ Пароль сохранён';
|
||
msg.className = 'text-xs text-green-400';
|
||
msg.classList.remove('hidden');
|
||
document.getElementById('chpwd-new').value = '';
|
||
} else {
|
||
const d = await r.json().catch(()=>({}));
|
||
msg.textContent = d.detail || 'Ошибка';
|
||
msg.className = 'text-xs text-red-400';
|
||
msg.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
// ── 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)}
|
||
${isAdmin() ? `<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('')}
|
||
${isAdmin() ? `<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, closeModalAfter = false) {
|
||
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);
|
||
}
|
||
if(closeModalAfter) closeModal();
|
||
}
|
||
|
||
async function forceRedownloadModal(url) {
|
||
return forceRedownload(url, true);
|
||
}
|
||
|
||
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>
|
||
${isAdmin() && m.added_by_username ? `<span class="text-gray-600">👤 ${escHtml(m.added_by_username)}</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';
|
||
const manage = canManage(m);
|
||
const admin = isAdmin();
|
||
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 && manage
|
||
? `<button onclick="stopManga('${u}')" class="btn-danger" title="Остановить" style="background:#7c2d12;color:#fdba74">⏸</button>`
|
||
: ''}
|
||
${(m.status === 'stopped' || m.status === 'failed') && manage
|
||
? `<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' && admin
|
||
? `<button onclick="prioritizeManga('${u}')" title="Загрузить первой" style="background:#312e81;color:#a5b4fc;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">🚀</button>`
|
||
: ''}
|
||
${admin
|
||
? `<button onclick="deleteManga('${u}')" class="btn-danger" title="Удалить">✕</button>`
|
||
: ''}
|
||
`;
|
||
}
|
||
|
||
function _rowAuto(m) {
|
||
if(m.pub_status !== 'ongoing') return '';
|
||
if(!canManage(m)) return ''; // только владелец или admin
|
||
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.added_by_username ? `
|
||
<div class="flex gap-2 items-center">
|
||
<span class="text-gray-400 font-semibold">Добавил:</span>
|
||
<span class="text-gray-300">👤 ${escHtml(data.added_by_username)}</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">
|
||
${canManage(data) ? `
|
||
<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' && canManage(data) ? `
|
||
<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' && canManage(data) ? `
|
||
<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' && isAdmin() ? `
|
||
<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' && isAdmin() ? `
|
||
<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>` : ''}
|
||
${isAdmin() ? `
|
||
<button onclick="closeModal(); deleteManga('${escHtml(data.url)}')"
|
||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ml-auto"
|
||
style="background:#450a0a;color:#fca5a5;border:1px solid #7f1d1d">
|
||
🗑 Удалить
|
||
</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">
|
||
${canManage(data) ? `<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-green" style="width:${pct}%"></div>
|
||
</div>
|
||
<span class="text-xs text-gray-500">${pDone}/${pTotal} стр. (${pct}%)</span>
|
||
</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}),
|
||
});
|
||
const data = await r.json();
|
||
if(!r.ok) throw new Error(data.detail || 'Ошибка сервера');
|
||
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>
|
||
|