Admin UX: light/dark theme, gallery pagination, content modal + delete, openai type
- 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
This commit is contained in:
+3
-2
@@ -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` (для ключей).
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<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>${r[key]}</td><td>${r.count}</td><td>$${usd(r.costUsd)}</td></tr>`).join('')
|
||||
`<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');
|
||||
@@ -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('')
|
||||
|| '<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 = `<img src="${it.mediaUrl}" loading="lazy" />`;
|
||||
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 = `<video controls style="width:100%;height:150px;background:#000" src="${it.mediaUrl}"></video>`;
|
||||
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">${media}<div class="meta">
|
||||
<div>${it.capability} · <span class="m">${cost}</span></div>
|
||||
<div class="m">${it.model}</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>`;
|
||||
</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); }
|
||||
}
|
||||
$('#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) => `<option value="${t}">${t}</option>`).join('');
|
||||
typeSel.innerHTML = knownTypes.map((t) => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
||||
}
|
||||
$('#providersTable tbody').innerHTML = providers.map((p) => {
|
||||
const status = [
|
||||
@@ -106,15 +210,15 @@ async function loadProviders() {
|
||||
p.enabled ? '<span class="pill on">включён</span>' : '<span class="pill off">выключен</span>',
|
||||
].join(' ');
|
||||
return `<tr>
|
||||
<td>${p.name}</td>
|
||||
<td>${p.type}</td>
|
||||
<td>${p.hasKey ? `<code>${p.keyHint || '••••'}</code>` : '<span class="muted">нет</span>'}</td>
|
||||
<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="${p.name}">Проверить</button>
|
||||
<button class="ghost" data-toggle="${p.name}" data-en="${p.enabled}">${p.enabled ? 'Выключить' : 'Включить'}</button>
|
||||
<button class="ghost" data-default="${p.name}">Сделать default</button>
|
||||
<button class="ghost danger" data-del="${p.name}">Удалить</button>
|
||||
<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>';
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NexusAI Admin</title>
|
||||
<style>
|
||||
:root {
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0f1115; --panel: #171a21; --panel2: #1f242e; --line: #2a2f3a;
|
||||
--text: #e6e9ef; --muted: #9aa4b2; --accent: #4f8cff; --ok: #35c46a;
|
||||
--warn: #f0b429; --err: #f26d6d;
|
||||
--warn: #f0b429; --err: #f26d6d; --shadow: rgba(0,0,0,.5);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f4f6fa; --panel: #ffffff; --panel2: #f0f2f7; --line: #dce1ea;
|
||||
--text: #1a1f2b; --muted: #5c6675; --accent: #2f6fed; --ok: #1a9e51;
|
||||
--warn: #b3810f; --err: #d63d3d; --shadow: rgba(0,0,0,.18);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
transition: background .15s, color .15s; }
|
||||
header { padding: 14px 20px; border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; gap: 16px; }
|
||||
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
||||
.badge { font-size: 12px; color: var(--muted); }
|
||||
header .spacer { flex: 1; }
|
||||
#themeToggle { background: var(--panel2); border: 1px solid var(--line); color: var(--text);
|
||||
border-radius: 8px; padding: 6px 12px; cursor: pointer; font: inherit; }
|
||||
nav { display: flex; gap: 6px; padding: 12px 20px 0; }
|
||||
nav button { background: var(--panel); color: var(--muted); border: 1px solid var(--line);
|
||||
padding: 8px 14px; border-radius: 8px 8px 0 0; cursor: pointer; }
|
||||
@@ -39,6 +48,7 @@
|
||||
button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 6px 10px; cursor: pointer; }
|
||||
button.danger { color: var(--err); border-color: var(--err); }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; }
|
||||
.row > div { flex: 1; min-width: 140px; }
|
||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; }
|
||||
@@ -46,22 +56,43 @@
|
||||
.pill.off { background: rgba(242,109,109,.15); color: var(--err); }
|
||||
.pill.def { background: rgba(79,140,255,.15); color: var(--accent); }
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
|
||||
.tile { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
||||
.tile { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px;
|
||||
overflow: hidden; position: relative; }
|
||||
.tile .thumb { cursor: pointer; }
|
||||
.tile img { width: 100%; height: 150px; object-fit: cover; display: block; background: #000; }
|
||||
.tile .meta { padding: 8px 10px; font-size: 12px; }
|
||||
.tile .meta .m { color: var(--muted); }
|
||||
.tile .del { position: absolute; top: 6px; right: 6px; background: var(--panel);
|
||||
border: 1px solid var(--line); color: var(--err); border-radius: 6px; padding: 2px 8px;
|
||||
cursor: pointer; font-size: 12px; opacity: .85; }
|
||||
.tile .del:hover { opacity: 1; }
|
||||
.pager { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.muted { color: var(--muted); }
|
||||
.hidden { display: none; }
|
||||
.toast { position: fixed; right: 16px; bottom: 16px; background: var(--panel2);
|
||||
border: 1px solid var(--line); padding: 10px 14px; border-radius: 8px; }
|
||||
border: 1px solid var(--line); padding: 10px 14px; border-radius: 8px; box-shadow: 0 6px 24px var(--shadow); }
|
||||
audio { width: 100%; }
|
||||
code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; }
|
||||
/* Modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.7);
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 50; }
|
||||
.modal { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
max-width: 92vw; max-height: 92vh; overflow: auto; box-shadow: 0 20px 60px var(--shadow); }
|
||||
.modal-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--panel); }
|
||||
.modal-head .spacer { flex: 1; }
|
||||
.modal-body { padding: 16px; }
|
||||
.modal-body img, .modal-body video { max-width: 88vw; max-height: 72vh; display: block; margin: 0 auto; }
|
||||
.modal-body audio { width: 60vw; max-width: 600px; }
|
||||
.kv { color: var(--muted); font-size: 12px; margin-top: 10px; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>NexusAI Admin</h1>
|
||||
<span class="badge" id="cryptoBadge"></span>
|
||||
<span class="spacer"></span>
|
||||
<button id="themeToggle" title="Переключить тему">🌙 Тёмная</button>
|
||||
</header>
|
||||
<nav>
|
||||
<button data-tab="usage" class="active">Потребление</button>
|
||||
@@ -113,8 +144,22 @@
|
||||
<option value="search">search</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="max-width:180px">
|
||||
<label>Элементов на странице</label>
|
||||
<select id="pageSize">
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="150">150</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="max-width:120px"><label> </label><button class="ghost" id="galleryReload">Обновить</button></div>
|
||||
</div>
|
||||
<div class="pager" style="margin-top:12px">
|
||||
<button class="ghost" id="prevPage">← Назад</button>
|
||||
<span class="muted" id="pageInfo">Страница 1</span>
|
||||
<button class="ghost" id="nextPage">Вперёд →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery" id="gallery"></div>
|
||||
</section>
|
||||
@@ -143,6 +188,8 @@
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="modalRoot"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AdminContext } from './context.js';
|
||||
|
||||
const log = createLogger('admin:api');
|
||||
|
||||
const KNOWN_TYPES = ['nordrouter'];
|
||||
const KNOWN_TYPES = ['nordrouter', 'openai'];
|
||||
|
||||
function errorReply(err: unknown): { statusCode: number; body: { error: string; code: string } } {
|
||||
const e = NexusError.from(err);
|
||||
@@ -150,6 +150,34 @@ export function registerRoutes(app: FastifyInstance, ctx: AdminContext): void {
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/generations/:id', async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
const rec = ctx.journal.getById(id);
|
||||
if (!rec) return reply.code(404).send({ error: 'not found', code: 'NOT_FOUND' });
|
||||
|
||||
let fileRemoved = false;
|
||||
if (rec.filePath) {
|
||||
const mediaDir = path.resolve(ctx.config.mediaDir);
|
||||
const resolved = path.resolve(rec.filePath);
|
||||
// Only delete files that live inside the media dir.
|
||||
if (resolved.startsWith(mediaDir + path.sep) && fs.existsSync(resolved)) {
|
||||
try {
|
||||
fs.unlinkSync(resolved);
|
||||
fileRemoved = true;
|
||||
} catch (e) {
|
||||
log.warn('failed to delete file', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
const recordRemoved = ctx.journal.deleteById(id);
|
||||
return { ok: true, fileRemoved, recordRemoved };
|
||||
} catch (err) {
|
||||
const { statusCode, body } = errorReply(err);
|
||||
return reply.code(statusCode).send(body);
|
||||
}
|
||||
});
|
||||
|
||||
log.info('routes registered');
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,11 @@ export class Journal {
|
||||
return rows as GenerationRecord[];
|
||||
}
|
||||
|
||||
deleteById(id: string): boolean {
|
||||
const r = this.db.prepare('DELETE FROM generations WHERE id = ?').run(id);
|
||||
return r.changes > 0;
|
||||
}
|
||||
|
||||
stats(sinceDays?: number): UsageStats {
|
||||
const filter = sinceDays
|
||||
? `WHERE created_at >= datetime('now', '-${Math.max(1, Math.floor(sinceDays))} days')`
|
||||
|
||||
Reference in New Issue
Block a user