/* 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 } }); let result; try { result = await response.json(); } catch { throw new Error(text('unexpectedResponse')); } 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')}

${configured ? '' : ``}
${error ? `` : ''}
`; 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')}

${error ? `` : ''}
`; 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 `
${creating ? text('newRecord') : escape(record.id)}${creating ? text('newRevision') : `v${record.revision}`}

${text('editorHint')}

${creating ? `` : ``}
`; } function workspace(node) { node.innerHTML = `
D1 / ${text('ownData')}

${text('title')}

${text('directMode')}

${KINDS.map(item => ``).join('')}
${notice ? `

${escape(notice)}

` : ''}${error ? `` : ''}
${text(kind)}${text('count', { count: total })}
${rows.length ? rows.map(row => ``).join('') : `

${text('empty')}

`}
${page + 1} / ${Math.max(1, Math.ceil(total / 50))}
${editor()}

${text('reloadHint')}

`; 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 = `

${text('checking')}

`; 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 = `
`; 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); })();