- Light/dark theme toggle persisted in localStorage - Gallery pagination: page size 50/100/150/200 with prev/next - Fullscreen modal viewer (image/video/audio) with metadata and delete - Delete content: DELETE /api/generations/:id removes file (media-dir guarded) and journal record; added Journal.deleteById - Provider types now include openai - HTML output escaping in UI
274 lines
12 KiB
JavaScript
274 lines
12 KiB
JavaScript
'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 ---
|
|
$$('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');
|
|
if (btn.dataset.tab === 'usage') loadStats();
|
|
if (btn.dataset.tab === 'gallery') loadGallery();
|
|
if (btn.dataset.tab === 'providers') loadProviders();
|
|
});
|
|
});
|
|
|
|
// --- Usage ---
|
|
async function loadStats() {
|
|
const range = $('#statsRange').value;
|
|
const q = range ? `?sinceDays=${range}` : '';
|
|
const s = await api(`/api/stats${q}`);
|
|
$('#statCards').innerHTML = `
|
|
<div class="stat"><div class="n">$${usd(s.totalCostUsd)}</div><div class="l">Всего потрачено</div></div>
|
|
<div class="stat"><div class="n">${s.totalCount}</div><div class="l">Всего операций</div></div>
|
|
<div class="stat"><div class="n">${s.byProvider.length}</div><div class="l">Провайдеров</div></div>
|
|
<div class="stat"><div class="n">${s.byCapability.length}</div><div class="l">Типов операций</div></div>`;
|
|
const fill = (id, rows, key) => {
|
|
$(`#${id} tbody`).innerHTML = rows.map((r) =>
|
|
`<tr><td>${esc(r[key])}</td><td>${r.count}</td><td>$${usd(r.costUsd)}</td></tr>`).join('')
|
|
|| '<tr><td colspan="3" class="muted">Нет данных</td></tr>';
|
|
};
|
|
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('')
|
|
|| '<div class="muted">Пока нет сгенерированного контента.</div>';
|
|
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); });
|
|
}
|
|
|
|
function renderTile(it) {
|
|
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
|
|
let media = '';
|
|
if (it.mediaUrl) {
|
|
if (it.capability === 'image') media = `<div class="thumb" data-view="${it.id}"><img src="${it.mediaUrl}" loading="lazy" /></div>`;
|
|
else if (it.capability === 'tts' || it.capability === 'music') media = `<div style="padding:10px"><audio controls src="${it.mediaUrl}"></audio></div>`;
|
|
else if (it.capability === 'video') media = `<div class="thumb" data-view="${it.id}"><video style="width:100%;height:150px;background:#000" src="${it.mediaUrl}"></video></div>`;
|
|
else media = `<a href="${it.mediaUrl}" target="_blank" style="display:block;padding:20px;text-align:center">Открыть файл</a>`;
|
|
} else {
|
|
const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
|
|
media = `<div style="height:150px;display:flex;align-items:center;justify-content:center" class="muted">${status}</div>`;
|
|
}
|
|
return `<div class="tile">
|
|
<button class="del" data-del="${it.id}" title="Удалить">✕</button>
|
|
${media}
|
|
<div class="meta">
|
|
<div>${esc(it.capability)} · <span class="m">${cost}</span></div>
|
|
<div class="m">${esc(it.model)}</div>
|
|
<div class="m">${new Date(it.createdAt).toLocaleString()}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
$('#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 = `<img src="${rec.mediaUrl}" />`;
|
|
else if (rec.mediaUrl && rec.capability === 'video') body = `<video controls autoplay src="${rec.mediaUrl}"></video>`;
|
|
else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) body = `<audio controls autoplay src="${rec.mediaUrl}"></audio>`;
|
|
else body = `<div class="muted">Нет предпросмотра для этого типа.</div>`;
|
|
|
|
const kv = `
|
|
<div class="kv">Модель: ${esc(rec.model)}</div>
|
|
<div class="kv">Провайдер: ${esc(rec.provider)} · Возможность: ${esc(rec.capability)}</div>
|
|
<div class="kv">Стоимость: ${rec.costUsd != null ? '$' + usd(rec.costUsd) : '—'}</div>
|
|
<div class="kv">Создано: ${new Date(rec.createdAt).toLocaleString()}</div>
|
|
${rec.inputSummary ? `<div class="kv">Вход: ${esc(rec.inputSummary)}</div>` : ''}
|
|
${rec.error ? `<div class="kv" style="color:var(--err)">Ошибка: ${esc(rec.error)}</div>` : ''}`;
|
|
|
|
$('#modalRoot').innerHTML = `
|
|
<div class="modal-backdrop" id="modalBackdrop">
|
|
<div class="modal">
|
|
<div class="modal-head">
|
|
<strong>${esc(rec.capability)} · ${esc(rec.model)}</strong>
|
|
<span class="spacer"></span>
|
|
${rec.mediaUrl ? `<a class="ghost" href="${rec.mediaUrl}" target="_blank" style="text-decoration:none">Открыть в новой вкладке</a>` : ''}
|
|
<button class="ghost danger" id="modalDel">Удалить</button>
|
|
<button class="ghost" id="modalClose">Закрыть ✕</button>
|
|
</div>
|
|
<div class="modal-body">${body}${kv}</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
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) => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
|
}
|
|
$('#providersTable tbody').innerHTML = providers.map((p) => {
|
|
const status = [
|
|
p.isDefault ? '<span class="pill def">default</span>' : '',
|
|
p.enabled ? '<span class="pill on">включён</span>' : '<span class="pill off">выключен</span>',
|
|
].join(' ');
|
|
return `<tr>
|
|
<td>${esc(p.name)}</td>
|
|
<td>${esc(p.type)}</td>
|
|
<td>${p.hasKey ? `<code>${esc(p.keyHint || '••••')}</code>` : '<span class="muted">нет</span>'}</td>
|
|
<td>${status}</td>
|
|
<td>
|
|
<button class="ghost" data-check="${esc(p.name)}">Проверить</button>
|
|
<button class="ghost" data-toggle="${esc(p.name)}" data-en="${p.enabled}">${p.enabled ? 'Выключить' : 'Включить'}</button>
|
|
<button class="ghost" data-default="${esc(p.name)}">Сделать default</button>
|
|
<button class="ghost danger" data-del-provider="${esc(p.name)}">Удалить</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('') || '<tr><td colspan="5" class="muted">Нет провайдеров. Добавьте выше.</td></tr>';
|
|
|
|
$$('[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();
|