diff --git a/NexusAI/README.md b/NexusAI/README.md
index 18f2ce2..5908f50 100644
--- a/NexusAI/README.md
+++ b/NexusAI/README.md
@@ -162,9 +162,10 @@ npm run admin
```
Возможности:
+- **Тема** — переключатель светлая/тёмная (сохраняется в localStorage).
- **Потребление** — дашборд трат: суммарно, по провайдерам, по возможностям, по дням (из журнала).
-- **Галерея** — просмотр сгенерированного контента (изображения/аудио/видео), фильтр по возможности.
-- **Провайдеры** — добавить/включить/выключить, назначить default, ввести ключ (шифруется), **проверить ключ** реальным вызовом. Ключи показываются только маской.
+- **Галерея** — просмотр сгенерированного контента (изображения/аудио/видео), фильтр по возможности, пагинация 50/100/150/200, модальное окно на весь экран, удаление контента (файл + запись).
+- **Провайдеры** — добавить/включить/выключить, назначить default, ввести ключ (шифруется), **проверить ключ** реальным вызовом, типы `nordrouter` и `openai`. Ключи показываются только маской.
Переменные: `NEXUS_ADMIN_HOST` (default `127.0.0.1`), `NEXUS_ADMIN_PORT` (default `4123`), `NEXUS_MASTER_KEY` (для ключей).
diff --git a/NexusAI/packages/admin/public/app.js b/NexusAI/packages/admin/public/app.js
index 32a48fb..5e68604 100644
--- a/NexusAI/packages/admin/public/app.js
+++ b/NexusAI/packages/admin/public/app.js
@@ -24,6 +24,21 @@ async function api(path, opts) {
}
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) => {
@@ -50,7 +65,7 @@ async function loadStats() {
${s.byCapability.length}
Типов операций
`;
const fill = (id, rows, key) => {
$(`#${id} tbody`).innerHTML = rows.map((r) =>
- `| ${r[key]} | ${r.count} | $${usd(r.costUsd)} |
`).join('')
+ `| ${esc(r[key])} | ${r.count} | $${usd(r.costUsd)} |
`).join('')
|| '| Нет данных |
';
};
fill('byProvider', s.byProvider, 'provider');
@@ -59,34 +74,123 @@ async function loadStats() {
}
$('#statsRange').addEventListener('change', loadStats);
-// --- Gallery ---
+// --- 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 q = cap ? `?capability=${cap}&limit=60` : '?limit=60';
- const { items } = await api(`/api/generations${q}`);
+ 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); });
}
+
function renderTile(it) {
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
let media = '';
if (it.mediaUrl) {
- if (it.capability === 'image') media = `
`;
+ if (it.capability === 'image') media = ``;
else if (it.capability === 'tts' || it.capability === 'music') media = ``;
- else if (it.capability === 'video') media = ``;
+ else if (it.capability === 'video') media = ``;
else media = `Открыть файл`;
} else {
const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
media = `${status}
`;
}
- return ``;
+ 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 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 = `
+
+
+
+
${esc(rec.capability)} · ${esc(rec.model)}
+
+ ${rec.mediaUrl ? `
Открыть в новой вкладке` : ''}
+
+
+
+
${body}${kv}
+
+
`;
+
+ 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); }
}
-$('#galleryReload').addEventListener('click', loadGallery);
-$('#galleryFilter').addEventListener('change', loadGallery);
// --- Providers ---
let CRYPTO_ENABLED = true;
@@ -98,7 +202,7 @@ async function loadProviders() {
: '⚠ NEXUS_MASTER_KEY не задан — ключи из БД недоступны';
const typeSel = $('#pType');
if (!typeSel.options.length) {
- typeSel.innerHTML = knownTypes.map((t) => ``).join('');
+ typeSel.innerHTML = knownTypes.map((t) => ``).join('');
}
$('#providersTable tbody').innerHTML = providers.map((p) => {
const status = [
@@ -106,15 +210,15 @@ async function loadProviders() {
p.enabled ? 'включён' : 'выключен',
].join(' ');
return `
- | ${p.name} |
- ${p.type} |
- ${p.hasKey ? `${p.keyHint || '••••'}` : 'нет'} |
+ ${esc(p.name)} |
+ ${esc(p.type)} |
+ ${p.hasKey ? `${esc(p.keyHint || '••••')}` : 'нет'} |
${status} |
-
-
-
-
+
+
+
+
|
`;
}).join('') || '| Нет провайдеров. Добавьте выше. |
';
@@ -122,7 +226,7 @@ async function loadProviders() {
$$('[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]').forEach((b) => b.onclick = () => delProvider(b.dataset.del));
+ $$('[data-del-provider]').forEach((b) => b.onclick = () => delProvider(b.dataset.delProvider));
}
$('#pSave').addEventListener('click', async () => {
diff --git a/NexusAI/packages/admin/public/index.html b/NexusAI/packages/admin/public/index.html
index 77dca9b..7b35f0a 100644
--- a/NexusAI/packages/admin/public/index.html
+++ b/NexusAI/packages/admin/public/index.html
@@ -1,22 +1,31 @@
-
+
NexusAI Admin
NexusAI Admin
+
+