mangalib
This commit is contained in:
@@ -106,6 +106,9 @@
|
||||
<!-- Stats Row -->
|
||||
<div id="stats-row" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6"></div>
|
||||
|
||||
<!-- Auth Warnings -->
|
||||
<div id="auth-warnings" class="hidden mb-4 flex flex-col gap-2"></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>
|
||||
@@ -157,6 +160,7 @@
|
||||
<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>
|
||||
<button onclick="filterMangas('ongoing')" id="filter-ongoing" class="text-xs px-3 py-1 rounded-full text-gray-400 hover:text-white">🔄 Продолжаются</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -378,6 +382,7 @@ const state = {
|
||||
filter: 'all',
|
||||
sources: [], // [{id, slug, display_name, domains}]
|
||||
currentUser: null, // {id, username, role}
|
||||
authWarnings: {}, // source_slug → {source_slug, source_name}
|
||||
};
|
||||
|
||||
// ── Auth ─────────────────────────────────────
|
||||
@@ -523,6 +528,7 @@ function handleEvent(msg) {
|
||||
case 'snapshot':
|
||||
msg.mangas.forEach(m => { state.mangas[m.url] = m; });
|
||||
renderList();
|
||||
renderAuthWarnings();
|
||||
loadStats();
|
||||
// Дополнительно запрашиваем свежие данные с сервера — на случай если
|
||||
// пока WS был отключён, статусы изменились и события были потеряны
|
||||
@@ -750,6 +756,29 @@ function handleEvent(msg) {
|
||||
updateMangaRow(msg.url);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth_required':
|
||||
if(state.mangas[msg.url]) {
|
||||
state.mangas[msg.url].status = 'stopped';
|
||||
state.mangas[msg.url].last_error = `auth_required:${msg.source_slug}`;
|
||||
if(msg.finished_at) state.mangas[msg.url].finished_at = msg.finished_at;
|
||||
}
|
||||
state.authWarnings[msg.source_slug] = {source_slug: msg.source_slug, source_name: msg.source_slug};
|
||||
renderList();
|
||||
renderAuthWarnings();
|
||||
loadStats();
|
||||
break;
|
||||
|
||||
case 'source_settings_updated':
|
||||
loadSources().then(() => {
|
||||
// Clear warnings for sources that now have a token
|
||||
state.sources.forEach(s => {
|
||||
if(s.has_token) delete state.authWarnings[s.slug];
|
||||
});
|
||||
// Refresh mangas to get cleared last_error values
|
||||
_refreshMangaList().then(() => renderAuthWarnings());
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1300,6 +1329,21 @@ function renderSources() {
|
||||
</button>
|
||||
</span>` : ''}
|
||||
</div>
|
||||
${s.supports_auth_token && isAdmin() ? `
|
||||
<div class="mt-3 pt-3" style="border-top:1px solid #1e293b">
|
||||
<div class="text-xs text-gray-400 mb-2">Токен авторизации (Bearer JWT)</div>
|
||||
${s.has_token ? `<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs text-green-400">✓ Токен сохранён</span>
|
||||
<button onclick="clearSourceToken(${s.id})" class="text-xs px-2 py-1 rounded" style="background:#1e293b;color:#ef4444;border:1px solid #374151">Удалить</button>
|
||||
</div>` : ''}
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="token-input-${s.id}" type="password" placeholder="${s.has_token ? 'Введите новый токен для замены' : 'eyJ0eXAiOiJKV1Qi...'}"
|
||||
class="text-xs px-2 py-1 rounded flex-1" style="background:#0f1117;border:1px solid #334155;color:#e2e8f0;min-width:0"
|
||||
onkeydown="if(event.key==='Enter') saveSourceToken(${s.id})">
|
||||
<button onclick="saveSourceToken(${s.id})" class="text-xs px-3 py-1 rounded font-semibold flex-shrink-0" style="background:#4f46e5;color:white">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
@@ -1358,6 +1402,78 @@ async function removeDomain(sourceId, domain) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSourceToken(sourceId) {
|
||||
const input = document.getElementById('token-input-' + sourceId);
|
||||
if(!input) return;
|
||||
const token = input.value.trim();
|
||||
try {
|
||||
const r = await fetch(`/api/sources/${sourceId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({settings: {auth_token: token}}),
|
||||
});
|
||||
if(!r.ok) {
|
||||
const err = await r.json();
|
||||
_showNotification('Ошибка: ' + (err.detail || 'неизвестная ошибка'), 'error');
|
||||
return;
|
||||
}
|
||||
input.value = '';
|
||||
_showNotification('Токен сохранён', 'success');
|
||||
await loadSources();
|
||||
} catch(e) {
|
||||
_showNotification('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSourceToken(sourceId) {
|
||||
if(!confirm('Удалить токен авторизации?')) return;
|
||||
try {
|
||||
const r = await fetch(`/api/sources/${sourceId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({settings: {auth_token: ''}}),
|
||||
});
|
||||
if(r.ok) {
|
||||
_showNotification('Токен удалён', 'success');
|
||||
await loadSources();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderAuthWarnings() {
|
||||
const container = document.getElementById('auth-warnings');
|
||||
if(!container) return;
|
||||
// Collect unique source slugs with unresolved auth errors from current manga state
|
||||
const slugs = {};
|
||||
Object.values(state.mangas).forEach(m => {
|
||||
const err = m.last_error || '';
|
||||
if(err.startsWith('auth_required:')) {
|
||||
const slug = err.slice('auth_required:'.length);
|
||||
if(!slugs[slug]) {
|
||||
const src = state.sources.find(s => s.slug === slug);
|
||||
slugs[slug] = src ? src.display_name : slug;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Also include warnings from state.authWarnings (received via WS before manga list refresh)
|
||||
Object.entries(state.authWarnings).forEach(([slug, info]) => {
|
||||
if(!slugs[slug]) slugs[slug] = info.source_name || slug;
|
||||
});
|
||||
const entries = Object.entries(slugs);
|
||||
if(!entries.length) {
|
||||
container.classList.add('hidden');
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = entries.map(([slug, name]) => `
|
||||
<div class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm" style="background:#431407;border:1px solid #7c2d12;color:#fed7aa">
|
||||
<span style="font-size:1.1rem">⚠</span>
|
||||
<span>Токен авторизации для <strong>${escHtml(name)}</strong> устарел или отсутствует. Обновите токен в <button onclick="switchTab('settings')" class="underline hover:text-orange-200">Настройках</button>.</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ── Switch Source Modal ───────────────────────
|
||||
let _switchSourceUrl = null;
|
||||
|
||||
@@ -1829,7 +1945,11 @@ function _rowAuto(m) {
|
||||
|
||||
function _sortedMangas() {
|
||||
let mangas = Object.values(state.mangas);
|
||||
if(state.filter !== 'all') mangas = mangas.filter(m => m.status === state.filter);
|
||||
if(state.filter === 'ongoing') {
|
||||
mangas = mangas.filter(m => m.pub_status === 'ongoing');
|
||||
} else 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;
|
||||
@@ -2310,6 +2430,7 @@ async function _refreshMangaList() {
|
||||
const mangas = await r.json();
|
||||
mangas.forEach(m => { state.mangas[m.url] = m; });
|
||||
renderList();
|
||||
renderAuthWarnings();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user