181 lines
16 KiB
JavaScript
181 lines
16 KiB
JavaScript
/* Direct database editor. The session token stays in memory and expires server-side. */
|
|
(() => {
|
|
const KINDS = ['memories', 'goals', 'tasks', 'museum', 'profile', 'settings', 'system'];
|
|
const text = (key, values) => LifeI18n.t(`db.${key}`, values);
|
|
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
|
let session = null, expiresAt = 0, configured = null, checking = false, changeMode = false, kind = 'memories', page = 0, rows = [], total = 0, selected = null, creating = false, error = '', notice = '', draft = null, draftId = null;
|
|
const current = () => document.querySelector('#content-database');
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch('/api/db-admin/' + path, {
|
|
...options, cache: 'no-store', headers: {
|
|
'content-type': 'application/json', ...(session ? { 'x-lifeos-admin-session': session } : {}), ...options.headers
|
|
}
|
|
});
|
|
const result = await response.json();
|
|
if (response.status === 401 && !['login', 'setup', 'password'].includes(path)) { session = null; expiresAt = 0; error = text('sessionExpired'); render(current()); }
|
|
if (!response.ok) throw new Error(result.error || text('requestFailed'));
|
|
return result;
|
|
}
|
|
|
|
function login(node) {
|
|
node.innerHTML = `<div class="db-intro"><div class="eyebrow">LIFE OS / D1</div><h1>${configured ? text('title') : text('setupTitle')}</h1><p class="sub">${configured ? text('loginBody') : text('setupBody')}</p><form class="db-login">
|
|
<label>${text('server')}<input value="Life OS / D1" readonly aria-readonly="true"></label>
|
|
<label>${text('username')}<input name="username" value="lifeos-admin" readonly aria-readonly="true"></label>
|
|
<label>${configured ? text('password') : text('newPassword')}<input name="password" type="password" autocomplete="${configured ? 'current-password' : 'new-password'}" required minlength="12" maxlength="256"></label>
|
|
${configured ? '' : `<label>${text('confirmPassword')}<input name="confirmPassword" type="password" autocomplete="new-password" required minlength="12" maxlength="256"></label>`}
|
|
<div class="db-warning" role="alert"><strong>${text('warningTitle')}</strong><p>${text('warningBody')}</p></div>
|
|
<label class="db-ack"><input type="checkbox" name="acknowledged" required> <span>${text('acknowledge')}</span></label>
|
|
<div class="actions"><button class="primary" type="submit">${configured ? text('login') : text('setPassword')}</button></div>
|
|
${error ? `<p class="db-error" role="alert">${escape(error)}</p>` : ''}
|
|
</form></div>`;
|
|
node.querySelector('form').addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget, button = form.querySelector('button[type=submit]');
|
|
button.disabled = true; error = '';
|
|
try {
|
|
if (!configured && form.elements.password.value !== form.elements.confirmPassword.value) throw new Error(text('passwordMismatch'));
|
|
const result = await api(configured ? 'login' : 'setup', { method: 'POST', body: JSON.stringify({
|
|
username: form.elements.username.value, password: form.elements.password.value,
|
|
acknowledged: form.elements.acknowledged.checked
|
|
}) });
|
|
form.elements.password.value = '';
|
|
configured = true; session = result.token; expiresAt = result.expiresAt; notice = text('connected');
|
|
await load(); render(current());
|
|
} catch (cause) { error = cause.message; render(current()); }
|
|
finally { button.disabled = false; }
|
|
});
|
|
}
|
|
function changePassword(node) {
|
|
node.innerHTML = `<div class="db-intro"><div class="eyebrow">LIFE OS / D1</div><h1>${text('changePassword')}</h1><form class="db-login">
|
|
<label>${text('currentPassword')}<input name="currentPassword" type="password" autocomplete="current-password" required></label>
|
|
<label>${text('newPassword')}<input name="newPassword" type="password" autocomplete="new-password" required minlength="12" maxlength="256"></label>
|
|
<label>${text('confirmPassword')}<input name="confirmPassword" type="password" autocomplete="new-password" required minlength="12" maxlength="256"></label>
|
|
<div class="actions"><button class="primary" type="submit">${text('changePassword')}</button><button class="secondary" type="button">${text('cancel')}</button></div>
|
|
${error ? `<p class="db-error" role="alert">${escape(error)}</p>` : ''}
|
|
</form></div>`;
|
|
node.querySelector('button[type=button]').addEventListener('click', () => { changeMode = false; error = ''; render(current()); });
|
|
node.querySelector('form').addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
try {
|
|
if (form.elements.newPassword.value !== form.elements.confirmPassword.value) throw new Error(text('passwordMismatch'));
|
|
const result = await api('password', { method: 'POST', body: JSON.stringify({
|
|
currentPassword: form.elements.currentPassword.value, newPassword: form.elements.newPassword.value
|
|
}) });
|
|
form.reset(); session = result.token; expiresAt = result.expiresAt; changeMode = false;
|
|
notice = text('passwordChanged'); error = ''; render(current());
|
|
} catch (cause) { error = cause.message; render(current()); }
|
|
});
|
|
}
|
|
|
|
async function load() {
|
|
if (!session) return;
|
|
const data = await api(`tables/${kind}/rows?page=${page}`);
|
|
rows = data.rows; total = data.total;
|
|
if (selected) selected = rows.find(row => row.id === selected.id) || null;
|
|
}
|
|
|
|
function editor() {
|
|
if (!selected && !creating) return `<div class="db-placeholder">${text('selectRow')}</div>`;
|
|
const record = selected, initial = creating ? (['profile', 'settings', 'system'].includes(kind) ? {} : { id: '' }) : record.value;
|
|
return `<form class="db-editor">
|
|
<div class="db-editor-head"><strong>${creating ? text('newRecord') : escape(record.id)}</strong><span>${creating ? text('newRevision') : `v${record.revision}`}</span></div>
|
|
<label>${text('recordId')}<input name="id" value="${escape(draftId ?? (creating ? (['profile', 'settings', 'system'].includes(kind) ? kind : '') : record.id))}" ${creating && !['profile', 'settings', 'system'].includes(kind) ? 'required maxlength="200"' : 'readonly'}></label>
|
|
<label>${text('json')}<textarea name="json" spellcheck="false" rows="17">${escape(draft ?? JSON.stringify(initial, null, 2))}</textarea></label>
|
|
<p class="db-note">${text('editorHint')}</p>
|
|
<div class="actions"><button class="primary" type="submit">${creating ? text('create') : text('save')}</button>
|
|
${creating ? `<button class="secondary" type="button" data-action="cancel">${text('cancel')}</button>` : `<button class="danger" type="button" data-action="delete">${text('delete')}</button>`}</div>
|
|
</form>`;
|
|
}
|
|
|
|
function workspace(node) {
|
|
node.innerHTML = `<div class="db-shell"><header class="db-head"><div><div class="eyebrow">D1 / ${text('ownData')}</div><h1>${text('title')}</h1><p class="sub">${text('directMode')}</p></div><div class="actions"><button class="secondary" data-action="password">${text('changePassword')}</button><button class="secondary" data-action="logout">${text('logout')}</button></div></header>
|
|
<div class="db-toolbar"><div class="db-tables" role="tablist" aria-label="${text('tables')}">${KINDS.map(item => `<button role="tab" aria-selected="${kind === item}" class="${kind === item ? 'active' : ''}" data-kind="${item}">${text(item)}</button>`).join('')}</div>
|
|
<button class="secondary" data-action="refresh">${text('refresh')}</button><button class="primary" data-action="new">${text('newRecord')}</button></div>
|
|
${notice ? `<p class="db-notice" role="status">${escape(notice)}</p>` : ''}${error ? `<p class="db-error" role="alert">${escape(error)}</p>` : ''}
|
|
<div class="db-columns"><section class="db-list"><div class="db-list-head"><strong>${text(kind)}</strong><span>${text('count', { count: total })}</span></div>
|
|
<div class="db-rows">${rows.length ? rows.map(row => `<button class="db-row ${selected?.id === row.id ? 'active' : ''}" data-row="${escape(row.id)}"><strong>${escape(row.value.title || row.value.name || row.id)}</strong><small>${escape(row.id)} · v${row.revision}</small><small>${escape(row.updatedAt || '')}</small></button>`).join('') : `<p class="db-empty">${text('empty')}</p>`}</div>
|
|
<div class="db-pages"><button data-action="prev" ${page === 0 ? 'disabled' : ''}>← ${text('previous')}</button><span>${page + 1} / ${Math.max(1, Math.ceil(total / 50))}</span><button data-action="next" ${(page + 1) * 50 >= total ? 'disabled' : ''}>${text('next')} →</button></div></section>
|
|
<section class="db-detail">${editor()}</section></div>
|
|
<p class="db-foot">${text('reloadHint')} <button class="db-link" data-action="reload">${text('reloadDesktop')}</button></p>
|
|
</div>`;
|
|
node.querySelectorAll('[data-kind]').forEach(button => button.addEventListener('click', async () => {
|
|
kind = button.dataset.kind; page = 0; selected = null; creating = false; draft = null; draftId = null; error = ''; notice = '';
|
|
try { await load(); render(current()); } catch (cause) { error = cause.message; render(current()); }
|
|
}));
|
|
node.querySelectorAll('[data-row]').forEach(button => button.addEventListener('click', () => {
|
|
selected = rows.find(row => row.id === button.dataset.row); creating = false; draft = null; draftId = null; render(current());
|
|
}));
|
|
node.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async () => {
|
|
const action = button.dataset.action;
|
|
if (action === 'reload') { location.reload(); return; }
|
|
if (action === 'logout') { try { await api('logout', { method: 'POST' }); } catch {} session = null; selected = null; notice = ''; render(current()); return; }
|
|
if (action === 'password') { changeMode = true; error = ''; render(current()); return; }
|
|
if (action === 'new') { creating = true; selected = null; draft = null; draftId = null; render(current()); return; }
|
|
if (action === 'cancel') { creating = false; draft = null; draftId = null; render(current()); return; }
|
|
if (action === 'delete') { await remove(); return; }
|
|
if (['refresh', 'prev', 'next'].includes(action)) {
|
|
if (action === 'prev') page--; if (action === 'next') page++;
|
|
try { await load(); render(current()); } catch (cause) { error = cause.message; render(current()); }
|
|
}
|
|
}));
|
|
node.querySelector('.db-editor')?.addEventListener('submit', save);
|
|
node.querySelector('.db-editor textarea')?.addEventListener('input', event => { draft = event.currentTarget.value; });
|
|
node.querySelector('.db-editor input[name=id]')?.addEventListener('input', event => { draftId = event.currentTarget.value; });
|
|
}
|
|
|
|
async function save(event) {
|
|
event.preventDefault();
|
|
const form = event.currentTarget, id = form.elements.id.value.trim(), before = selected;
|
|
draft = form.elements.json.value; draftId = id;
|
|
let value;
|
|
try { value = JSON.parse(form.elements.json.value); }
|
|
catch { error = text('invalidJson'); render(current()); return; }
|
|
if (!value || Array.isArray(value) || typeof value !== 'object' || (!['profile', 'settings', 'system'].includes(kind) && value.id !== id)) {
|
|
error = text('idMismatch'); render(current()); return;
|
|
}
|
|
if (!confirm(text('saveConfirm', { id }))) return;
|
|
const method = creating ? 'POST' : 'PATCH';
|
|
try {
|
|
await api(`tables/${kind}/rows/${encodeURIComponent(id)}`, { method,
|
|
body: JSON.stringify({ baseVersion: before?.revision || 0, value }) });
|
|
creating = false; selected = null; draft = null; draftId = null; error = ''; notice = text('saved');
|
|
await load(); selected = rows.find(row => row.id === id) || null; render(current());
|
|
} catch (cause) { error = cause.message; render(current()); }
|
|
}
|
|
async function remove() {
|
|
if (!selected || !confirm(text('deleteConfirm', { id: selected.id }))) return;
|
|
try {
|
|
await api(`tables/${kind}/rows/${encodeURIComponent(selected.id)}?version=${selected.revision}`, { method: 'DELETE' });
|
|
selected = null; draft = null; draftId = null; error = ''; notice = text('deleted'); await load(); render(current());
|
|
} catch (cause) { error = cause.message; render(current()); }
|
|
}
|
|
function render(node) {
|
|
if (!node) return;
|
|
if (configured === null) {
|
|
node.innerHTML = `<div class="db-intro"><p class="sub">${text('checking')}</p></div>`;
|
|
if (!checking) {
|
|
checking = true;
|
|
api('config').then(result => { configured = result.configured; error = ''; })
|
|
.catch(cause => { configured = 'unavailable'; error = cause.message; })
|
|
.finally(() => { checking = false; render(current()); });
|
|
}
|
|
return;
|
|
}
|
|
if (configured === 'unavailable') {
|
|
node.innerHTML = `<div class="db-intro"><p class="db-error" role="alert">${escape(error)}</p><button class="secondary">${text('refresh')}</button></div>`;
|
|
node.querySelector('button').addEventListener('click', () => { configured = null; render(current()); });
|
|
return;
|
|
}
|
|
if (!session || expiresAt <= Date.now()) { session = null; login(node); }
|
|
else if (changeMode) changePassword(node);
|
|
else workspace(node);
|
|
}
|
|
window.databaseApp = render;
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = `#content-database{padding:0}.db-intro{max-width:550px;margin:35px auto;padding:0 24px}.db-intro .sub{line-height:1.6}.db-login{display:grid;gap:15px;margin-top:24px}.db-login label,.db-editor label{display:grid;gap:7px;color:var(--muted);font-size:14px}.db-login input:not([type=checkbox]),.db-editor input,.db-editor textarea{width:100%;border:1px solid var(--line);background:var(--panel2);color:var(--ink);border-radius:9px;padding:11px}.db-login input[readonly],.db-editor input[readonly]{opacity:.7}.db-warning{border:1px solid #ce865b;background:#b8673024;border-radius:10px;padding:15px;line-height:1.5}.db-warning strong{color:var(--ink)}.db-warning p{margin:7px 0 0;font-size:13px;color:var(--muted)}.db-ack{display:flex!important;align-items:flex-start;gap:9px}.db-ack input{margin-top:4px}.db-error{color:#ff9292;font-size:13px}.db-notice{color:var(--accent);font-size:13px}.db-shell{padding:22px;min-height:100%}.db-head{display:flex;justify-content:space-between;gap:16px;align-items:start}.db-head h1{margin-bottom:7px}.db-head .sub{margin:0 0 18px}.db-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:12px 0;border-top:1px solid var(--line)}.db-tables{display:flex;flex:1;gap:5px;overflow-x:auto}.db-tables button,.db-pages button{white-space:nowrap;border:1px solid var(--line);border-radius:8px;background:var(--panel2);padding:8px 10px;color:var(--ink);font-size:13px}.db-tables button.active{background:var(--accent);color:var(--bg)}.db-columns{display:grid;grid-template-columns:minmax(180px,.8fr) minmax(260px,1.3fr);gap:15px;min-height:380px}.db-list,.db-detail{min-width:0;border:1px solid var(--line);border-radius:11px;background:var(--panel2)}.db-list{display:flex;flex-direction:column}.db-list-head{display:flex;justify-content:space-between;padding:13px;border-bottom:1px solid var(--line);font-size:13px}.db-rows{flex:1;max-height:450px;overflow:auto}.db-row{display:grid;gap:3px;width:100%;border:0;border-bottom:1px solid var(--line);background:transparent;text-align:left;padding:11px 13px;color:var(--ink)}.db-row.active,.db-row:hover{background:color-mix(in srgb,var(--accent) 12%,var(--panel2))}.db-row strong{font-size:14px;overflow:hidden;text-overflow:ellipsis}.db-row small{color:var(--muted);overflow:hidden;text-overflow:ellipsis}.db-empty,.db-placeholder{padding:25px;color:var(--muted);font-size:14px}.db-pages{display:flex;justify-content:space-between;align-items:center;gap:7px;padding:10px;border-top:1px solid var(--line);font-size:12px}.db-pages button:disabled{opacity:.4}.db-detail{padding:15px}.db-editor{display:grid;gap:14px}.db-editor-head{display:flex;justify-content:space-between;gap:10px}.db-editor-head strong{overflow-wrap:anywhere}.db-editor textarea{resize:vertical;font:13px/1.5 'DM Mono',monospace;min-height:240px}.db-note,.db-foot{font-size:12px;line-height:1.5;color:var(--muted)}.db-link{border:0;background:none;color:var(--accent);padding:0;text-decoration:underline}.db-foot{margin:16px 0 0}@media(max-width:680px){.db-shell{padding:15px}.db-columns{grid-template-columns:1fr}.db-rows{max-height:210px}.db-intro{margin:20px auto;padding:0 18px}}`;
|
|
document.head.append(style);
|
|
})();
|