feat(database): add account-scoped database manager
This commit is contained in:
1 parent
1606c41f43
commit
33e80a566e
18 files changed
+444
-25
No files matched your search
+2
-2
@@ -3,9 +3,9 @@
|
||||
const DRAFT_KEY = 'lifeos_today_draft_v1';
|
||||
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
|
||||
const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||
const APP_VERSION = '1.13.0';
|
||||
const APP_VERSION = '1.14.0';
|
||||
window.LifeOSVersion = APP_VERSION;
|
||||
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal'];
|
||||
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal', 'database'];
|
||||
const t = (key, values) => LifeI18n.t(key, values);
|
||||
|
||||
function readDraft() {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/* 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, 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 && path !== 'login') { 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>${text('title')}</h1><p class="sub">${text('loginBody')}</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>${text('password')}<input name="password" type="password" autocomplete="off" required minlength="20"></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">${text('login')}</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 {
|
||||
const result = await api('login', { method: 'POST', body: JSON.stringify({
|
||||
username: form.elements.username.value, password: form.elements.password.value,
|
||||
acknowledged: form.elements.acknowledged.checked
|
||||
}) });
|
||||
form.elements.password.value = '';
|
||||
session = result.token; expiresAt = result.expiresAt; notice = text('connected');
|
||||
await load(); render(current());
|
||||
} catch (cause) { error = cause.message; render(current()); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
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><button class="secondary" data-action="logout">${text('logout')}</button></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 === '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 (!session || expiresAt <= Date.now()) { session = null; login(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);
|
||||
})();
|
||||
@@ -110,7 +110,7 @@
|
||||
window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); };
|
||||
window.systemApp = container => {
|
||||
const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn');
|
||||
container.innerHTML = `<div class="eyebrow">${t('system.eyebrow')}</div><h1>${t('system.title')}</h1><div class="settings"><section><div class="eyebrow">${t('system.appearance')}</div><div class="choices">${['dark','light','auto'].map(item => `<button class="chip ${state.theme === item ? 'selected' : ''}" onclick="theme('${item}')">${t(`system.theme.${item}`)}</button>`).join('')}</div></section><section><div class="eyebrow">${t('system.language')}</div><p class="sub">${detected}</p><div class="choices"><button class="chip ${preference === 'auto' ? 'selected' : ''}" onclick="window.setLanguage('auto')">${t('system.auto')}</button><button class="chip ${preference === 'en' ? 'selected' : ''}" onclick="window.setLanguage('en')">${t('system.languageEnglish')}</button><button class="chip ${preference === 'zh' ? 'selected' : ''}" onclick="window.setLanguage('zh')">${t('system.languageChinese')}</button></div></section><section><div class="eyebrow">${t('system.data')}</div><div class="actions"><button class="primary" onclick="exportData()">${t('system.export')}</button><button class="secondary" onclick="document.querySelector('#importer').click()">${t('system.import')}</button><button class="danger" onclick="resetConfirm()">${t('system.reset')}</button><input id="importer" class="hidden" type="file" accept=".json" onchange="readImport(this)"></div></section><section><div class="eyebrow">${t('system.storage')}</div><p>${t('system.local')}<br>${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}</p></section><section><div class="eyebrow">${t('system.dataStorage')}</div><p>${t('system.stored')}<br>${t('system.backups')}</p><p>${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `<br><b style="color:var(--accent)">${t('system.recommended')}</b>` : ''}</p></section><section><div class="eyebrow">${t('system.version')}</div><p>${t('system.release',{version:window.LifeOSVersion||'1.13.0'})}</p></section></div>`;
|
||||
container.innerHTML = `<div class="eyebrow">${t('system.eyebrow')}</div><h1>${t('system.title')}</h1><div class="settings"><section><div class="eyebrow">${t('system.appearance')}</div><div class="choices">${['dark','light','auto'].map(item => `<button class="chip ${state.theme === item ? 'selected' : ''}" onclick="theme('${item}')">${t(`system.theme.${item}`)}</button>`).join('')}</div></section><section><div class="eyebrow">${t('system.language')}</div><p class="sub">${detected}</p><div class="choices"><button class="chip ${preference === 'auto' ? 'selected' : ''}" onclick="window.setLanguage('auto')">${t('system.auto')}</button><button class="chip ${preference === 'en' ? 'selected' : ''}" onclick="window.setLanguage('en')">${t('system.languageEnglish')}</button><button class="chip ${preference === 'zh' ? 'selected' : ''}" onclick="window.setLanguage('zh')">${t('system.languageChinese')}</button></div></section><section><div class="eyebrow">${t('system.data')}</div><div class="actions"><button class="primary" onclick="exportData()">${t('system.export')}</button><button class="secondary" onclick="document.querySelector('#importer').click()">${t('system.import')}</button><button class="danger" onclick="resetConfirm()">${t('system.reset')}</button><input id="importer" class="hidden" type="file" accept=".json" onchange="readImport(this)"></div></section><section><div class="eyebrow">${t('system.storage')}</div><p>${t('system.local')}<br>${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}</p></section><section><div class="eyebrow">${t('system.dataStorage')}</div><p>${t('system.stored')}<br>${t('system.backups')}</p><p>${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `<br><b style="color:var(--accent)">${t('system.recommended')}</b>` : ''}</p></section><section><div class="eyebrow">${t('system.version')}</div><p>${t('system.release',{version:window.LifeOSVersion||'1.14.0'})}</p></section></div>`;
|
||||
};
|
||||
window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = `<div class="modal"><div class="modalbox"><div class="eyebrow">${t('dialog.backup')}</div><h2>${t('dialog.ready')}</h2><p class="sub">${t('dialog.exported',{date:short(data.exportedAt.slice(0,10))})}<br>${t('dialog.memories',{count:data.memories.length})}<br>${t('dialog.museumItems',{count:data.museum.length})}<br>${t('dialog.goals',{count:board.goals?.length || 0})}<br>${t('dialog.tasks',{count:board.tasks?.length || 0})}</p><div class="actions"><button class="primary" onclick="applyImport('merge')">${t('dialog.merge')}</button><button class="danger" onclick="replaceConfirm()">${t('dialog.replaceAll')}</button><button class="secondary" onclick="modal.innerHTML=''">${t('common.cancel')}</button></div></div></div>`; };
|
||||
window.replaceConfirm = () => { rootModal().innerHTML = `<div class="modal"><div class="modalbox"><div class="eyebrow">${t('dialog.final')}</div><h2>${t('dialog.replaceQuestion')}</h2><p class="sub">${t('dialog.replaceWarning')}</p><div class="actions"><button class="danger" onclick="applyImport('replace')">${t('dialog.replace')}</button><button class="secondary" onclick="importPreview()">${t('common.cancel')}</button></div></div></div>`; };
|
||||
|
||||
Loaded 3 of 18 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user