'use strict';
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
function toast(msg) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 2600);
}
async function api(path, opts) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
function usd(n) { return (n == null ? 0 : n).toFixed(4); }
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
// --- Theme ---
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
const btn = $('#themeToggle');
btn.textContent = theme === 'dark' ? '🌙 Тёмная' : '☀️ Светлая';
localStorage.setItem('nexus-theme', theme);
}
$('#themeToggle').addEventListener('click', () => {
const cur = document.documentElement.getAttribute('data-theme');
applyTheme(cur === 'dark' ? 'light' : 'dark');
});
applyTheme(localStorage.getItem('nexus-theme') || 'dark');
// --- Tabs ---
let activeTab = 'usage';
function refreshActiveTab() {
if (activeTab === 'usage') return loadStats();
if (activeTab === 'gallery') return loadGallery();
if (activeTab === 'providers') return loadProviders();
}
$$('nav button').forEach((btn) => {
btn.addEventListener('click', () => {
$$('nav button').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
$$('main section').forEach((s) => s.classList.add('hidden'));
$(`#tab-${btn.dataset.tab}`).classList.remove('hidden');
activeTab = btn.dataset.tab;
refreshActiveTab();
});
});
// --- Auto-refresh (no page reload) ---
let autoTimer = null;
function isModalOpen() { return $('#modalRoot').innerHTML.trim() !== ''; }
function setupAutoRefresh() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
const on = $('#autoRefresh').checked;
localStorage.setItem('nexus-autorefresh', on ? '1' : '0');
localStorage.setItem('nexus-autorefresh-int', $('#autoRefreshInterval').value);
if (!on) return;
const ms = parseInt($('#autoRefreshInterval').value, 10) || 10000;
autoTimer = setInterval(() => {
// Don't disrupt the user while a modal is open or an audio is playing.
if (isModalOpen()) return;
if (document.hidden) return;
refreshActiveTab();
}, ms);
}
$('#autoRefresh').checked = localStorage.getItem('nexus-autorefresh') === '1';
$('#autoRefreshInterval').value = localStorage.getItem('nexus-autorefresh-int') || '10000';
$('#autoRefresh').addEventListener('change', setupAutoRefresh);
$('#autoRefreshInterval').addEventListener('change', setupAutoRefresh);
setupAutoRefresh();
// --- Usage ---
async function loadStats() {
const range = $('#statsRange').value;
const q = range ? `?sinceDays=${range}` : '';
const s = await api(`/api/stats${q}`);
$('#statCards').innerHTML = `
$${usd(s.totalCostUsd)}
Всего потрачено
${s.totalCount}
Всего операций
${s.byProvider.length}
Провайдеров
${s.byCapability.length}
Типов операций
`;
const fill = (id, rows, key) => {
$(`#${id} tbody`).innerHTML = rows.map((r) =>
`${esc(r[key])} ${r.count} $${usd(r.costUsd)} `).join('')
|| 'Нет данных ';
};
fill('byProvider', s.byProvider, 'provider');
fill('byCapability', s.byCapability, 'capability');
fill('byDay', s.byDay, 'day');
}
$('#statsRange').addEventListener('change', loadStats);
// --- Gallery (with pagination) ---
let galleryOffset = 0;
let galleryLastCount = 0;
function pageSize() { return parseInt($('#pageSize').value, 10) || 50; }
async function loadGallery() {
const cap = $('#galleryFilter').value;
const limit = pageSize();
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(galleryOffset));
if (cap) params.set('capability', cap);
const { items } = await api(`/api/generations?${params.toString()}`);
galleryLastCount = items.length;
$('#gallery').innerHTML = items.map(renderTile).join('')
|| 'Пока нет сгенерированного контента.
';
const page = Math.floor(galleryOffset / limit) + 1;
$('#pageInfo').textContent = `Страница ${page} · показано ${items.length}`;
$('#prevPage').disabled = galleryOffset === 0;
$('#nextPage').disabled = items.length < limit;
$$('[data-view]').forEach((el) => el.onclick = () => openModal(el.dataset.view));
$$('[data-del]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); delGeneration(b.dataset.del); });
$$('[data-play]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); openModal(b.dataset.play); });
}
function isAudio(it) { return it.capability === 'tts' || it.capability === 'music'; }
function isText(it) { return it.capability === 'stt' || (it.mediaUrl && /\.md$/i.test(it.mediaUrl)) || it.capability === 'vision'; }
function renderTile(it) {
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
let media = '';
if (it.mediaUrl) {
if (it.capability === 'image') {
media = ``;
} else if (isAudio(it)) {
media = `
▶
`;
} else if (it.capability === 'video') {
media = `
`;
} else if (isText(it)) {
media = `📄
`;
} else {
media = `Открыть файл `;
}
} else {
const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
media = `
${status}
`;
}
return ``;
}
$('#galleryReload').addEventListener('click', () => { galleryOffset = 0; loadGallery(); });
$('#galleryFilter').addEventListener('change', () => { galleryOffset = 0; loadGallery(); });
$('#pageSize').addEventListener('change', () => { galleryOffset = 0; loadGallery(); });
$('#prevPage').addEventListener('click', () => {
galleryOffset = Math.max(0, galleryOffset - pageSize());
loadGallery();
});
$('#nextPage').addEventListener('click', () => {
if (galleryLastCount < pageSize()) return;
galleryOffset += pageSize();
loadGallery();
});
// --- Modal viewer ---
async function openModal(id) {
let rec;
try { rec = await api(`/api/generations/${encodeURIComponent(id)}`); }
catch (e) { return toast('Ошибка: ' + e.message); }
let body = '';
if (rec.mediaUrl && rec.capability === 'image') {
body = ` `;
} else if (rec.mediaUrl && rec.capability === 'video') {
body = ` `;
} else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) {
body = ``;
} else if (rec.mediaUrl && /\.md$/i.test(rec.mediaUrl)) {
// Transcript / text document — fetch and render.
let text = '';
try { text = await (await fetch(rec.mediaUrl)).text(); } catch { text = '(не удалось загрузить текст)'; }
body = `${esc(text)}
`;
} else {
body = `Нет предпросмотра для этого типа.
`;
}
const kv = `
Модель: ${esc(rec.model)}
Провайдер: ${esc(rec.provider)} · Возможность: ${esc(rec.capability)}
Стоимость: ${rec.costUsd != null ? '$' + usd(rec.costUsd) : '—'}
Создано: ${new Date(rec.createdAt).toLocaleString()}
${rec.inputSummary ? `Вход: ${esc(rec.inputSummary)}
` : ''}
${rec.error ? `Ошибка: ${esc(rec.error)}
` : ''}`;
$('#modalRoot').innerHTML = `
`;
const close = () => { $('#modalRoot').innerHTML = ''; };
$('#modalClose').onclick = close;
$('#modalBackdrop').onclick = (e) => { if (e.target.id === 'modalBackdrop') close(); };
document.addEventListener('keydown', function onEsc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onEsc); }
});
$('#modalDel').onclick = async () => {
if (!confirm('Удалить этот контент?')) return;
await delGeneration(id, true);
close();
};
}
async function delGeneration(id, silent) {
if (!silent && !confirm('Удалить этот контент?')) return;
try {
await api(`/api/generations/${encodeURIComponent(id)}`, { method: 'DELETE' });
toast('Удалено');
loadGallery();
} catch (e) { toast('Ошибка: ' + e.message); }
}
// --- Providers ---
let CRYPTO_ENABLED = true;
async function loadProviders() {
const { providers, knownTypes, cryptoEnabled } = await api('/api/providers');
CRYPTO_ENABLED = cryptoEnabled;
$('#cryptoBadge').textContent = cryptoEnabled
? 'Шифрование ключей: включено'
: '⚠ NEXUS_MASTER_KEY не задан — ключи из БД недоступны';
const typeSel = $('#pType');
if (!typeSel.options.length) {
typeSel.innerHTML = knownTypes.map((t) => `${esc(t)} `).join('');
}
$('#providersTable tbody').innerHTML = providers.map((p) => {
const status = [
p.isDefault ? 'default ' : '',
p.enabled ? 'включён ' : 'выключен ',
].join(' ');
return `
${esc(p.name)}
${esc(p.type)}
${p.hasKey ? `${esc(p.keyHint || '••••')}` : 'нет '}
${status}
Проверить
${p.enabled ? 'Выключить' : 'Включить'}
Сделать default
Удалить
`;
}).join('') || 'Нет провайдеров. Добавьте выше. ';
$$('[data-check]').forEach((b) => b.onclick = () => checkProvider(b.dataset.check));
$$('[data-toggle]').forEach((b) => b.onclick = () => toggleProvider(b.dataset.toggle, b.dataset.en !== 'true'));
$$('[data-default]').forEach((b) => b.onclick = () => setDefault(b.dataset.default));
$$('[data-del-provider]').forEach((b) => b.onclick = () => delProvider(b.dataset.delProvider));
}
$('#pSave').addEventListener('click', async () => {
try {
const body = {
name: $('#pName').value.trim(),
type: $('#pType').value,
baseUrl: $('#pBaseUrl').value.trim() || undefined,
apiKey: $('#pKey').value.trim() || undefined,
isDefault: $('#pDefault').value === 'true',
enabled: $('#pEnabled').value === 'true',
};
if (!body.name) return toast('Укажите имя');
if (body.apiKey && !CRYPTO_ENABLED) return toast('Нельзя сохранить ключ без NEXUS_MASTER_KEY');
await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
$('#pKey').value = '';
toast('Сохранено');
loadProviders();
} catch (e) { toast('Ошибка: ' + e.message); }
});
async function checkProvider(name) {
try {
toast('Проверяю ключ...');
const r = await api(`/api/providers/${encodeURIComponent(name)}/check`, { method: 'POST', body: '{}' });
toast(r.ok ? `OK · моделей: ${r.modelsCount}` : `Ошибка ключа: ${r.error}`);
} catch (e) { toast('Ошибка: ' + e.message); }
}
async function toggleProvider(name, enabled) {
try { await api(`/api/providers/${encodeURIComponent(name)}/enabled`, { method: 'POST', body: JSON.stringify({ enabled }) }); loadProviders(); }
catch (e) { toast('Ошибка: ' + e.message); }
}
async function setDefault(name) {
try { await api(`/api/providers/${encodeURIComponent(name)}/default`, { method: 'POST', body: '{}' }); loadProviders(); }
catch (e) { toast('Ошибка: ' + e.message); }
}
async function delProvider(name) {
if (!confirm(`Удалить провайдера "${name}"?`)) return;
try { await api(`/api/providers/${encodeURIComponent(name)}`, { method: 'DELETE' }); loadProviders(); }
catch (e) { toast('Ошибка: ' + e.message); }
}
// initial
loadStats();