/* 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 = `
LIFE OS / D1
${configured ? text('title') : text('setupTitle')}
${configured ? text('loginBody') : text('setupBody')}
`;
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 = `LIFE OS / D1
${text('changePassword')}
`;
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 `${text('selectRow')}
`;
const record = selected, initial = creating ? (['profile', 'settings', 'system'].includes(kind) ? {} : { id: '' }) : record.value;
return ``;
}
function workspace(node) {
node.innerHTML = `
${notice ? `
${escape(notice)}
` : ''}${error ? `
${escape(error)}
` : ''}
${text(kind)}${text('count', { count: total })}
${rows.length ? rows.map(row => `
`).join('') : `
${text('empty')}
`}
${page + 1} / ${Math.max(1, Math.ceil(total / 50))}
`;
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 = ``;
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 = `${escape(error)}
`;
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);
})();