Some checks are pending
build-and-push / image (push) Waiting to run
- All 14 panes rewritten with rich controls: KPIs, sparklines, meter gauges, presets, history sidebars, search/filter, bulk actions, schema-driven forms, copy buttons, auto-refresh. - Backend /api/system enriched with cpu_count, rss_bytes, mem_total, mem_used_pct, loadavg[], user/system CPU times, max_rss_kb. - CSS additions: .meter/.gauge, .seg, .chip(s), .spark, .slider, .dl, .link-card, details.card-d plus utility classes. - ui.js helpers: copyToClipboard, withCopy/bindCopyButtons, sparkline, meterRow, fmtBytes/fmtNum, historyStore, inputFromSchema, collectSchemaForm. - Files pane added; iCloud duplicate '*2' files removed.
163 lines
7.4 KiB
JavaScript
163 lines
7.4 KiB
JavaScript
// panes/items.js — CRUD against /api/items with search, bulk select, export/import.
|
|
import { registerPane } from '../app.js';
|
|
import { jget, jpost, jdelete, escapeHtml } from '../lib/api.js';
|
|
import { ok, err, copyToClipboard, fmtJSON } from '../lib/ui.js';
|
|
|
|
const TPL = `
|
|
<div class="pane-head">
|
|
<div><div class="title">Items</div><div class="sub">Backend-managed records · <code>/api/items</code>.</div></div>
|
|
<div class="grow"></div>
|
|
<button class="btn btn-secondary" id="items-export">Export</button>
|
|
<button class="btn btn-secondary" id="items-import">Import</button>
|
|
<button class="btn btn-secondary" id="items-refresh">Refresh</button>
|
|
</div>
|
|
|
|
<div class="grid" style="grid-template-columns: 360px 1fr; gap: 16px;">
|
|
<div class="card">
|
|
<div class="card-title"><h2>New item</h2></div>
|
|
<form id="items-form" class="grid gap-3">
|
|
<label class="field"><span class="lbl">Name</span><input class="input" id="items-name" required maxlength="120"/></label>
|
|
<label class="field"><span class="lbl">Description</span><textarea class="input" id="items-desc" rows="4" maxlength="2000"></textarea></label>
|
|
<div class="btn-row">
|
|
<button class="btn btn-primary" type="submit">Add</button>
|
|
<button class="btn btn-ghost" type="reset">Clear</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div class="card-title"><h2 style="font-size:14px">Bulk actions</h2><span class="muted xsmall" id="items-sel-count">0 selected</span></div>
|
|
<div class="btn-row">
|
|
<button class="btn btn-secondary" id="items-sel-all">Select all</button>
|
|
<button class="btn btn-secondary" id="items-sel-none">Clear</button>
|
|
<button class="btn btn-danger" id="items-sel-del" disabled>Delete selected</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">
|
|
<h2>Records <span class="muted mono xsmall" id="items-count">—</span></h2>
|
|
<input class="input" id="items-search" placeholder="filter…" style="max-width:240px"/>
|
|
</div>
|
|
<div id="items-list" class="scroll" style="max-height:62vh">loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input type="file" id="items-file" accept=".json" style="display:none"/>
|
|
`;
|
|
|
|
let all = [];
|
|
const selected = new Set();
|
|
|
|
function row(it, checked) {
|
|
return `<div class="row" data-id="${it.id}">
|
|
<label class="check"><input type="checkbox" data-sel="${it.id}" ${checked ? 'checked' : ''}/></label>
|
|
<span class="mono muted">#${it.id}</span>
|
|
<div class="grow">
|
|
<div class="ttl"><span data-edit="name-${it.id}">${escapeHtml(it.name)}</span></div>
|
|
<div class="desc"><span data-edit="desc-${it.id}">${escapeHtml(it.description || '')}</span></div>
|
|
</div>
|
|
<button class="btn btn-ghost" data-copy="${it.id}" title="Copy as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
|
|
<span class="act" data-del="${it.id}">delete</span>
|
|
</div>`;
|
|
}
|
|
|
|
function render(host) {
|
|
const q = host.querySelector('#items-search').value.trim().toLowerCase();
|
|
const filtered = q ? all.filter(it => (it.name + ' ' + (it.description || '')).toLowerCase().includes(q)) : all;
|
|
host.querySelector('#items-count').textContent = `${filtered.length}/${all.length}`;
|
|
host.querySelector('#items-list').innerHTML = filtered.length
|
|
? filtered.map(it => row(it, selected.has(it.id))).join('')
|
|
: '<div class="muted" style="padding:16px">no items match</div>';
|
|
|
|
host.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
|
|
if (!confirm(`Delete item #${b.dataset.del}?`)) return;
|
|
try { await jdelete('/api/items/' + b.dataset.del); selected.delete(+b.dataset.del); await load(host); ok('deleted'); }
|
|
catch (e) { err(e.message); }
|
|
}));
|
|
host.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => {
|
|
const it = all.find(x => x.id === +b.dataset.copy); if (it) copyToClipboard(fmtJSON(it), 'copied');
|
|
}));
|
|
host.querySelectorAll('[data-sel]').forEach(c => c.addEventListener('change', () => {
|
|
const id = +c.dataset.sel;
|
|
if (c.checked) selected.add(id); else selected.delete(id);
|
|
updateSel(host);
|
|
}));
|
|
}
|
|
|
|
function updateSel(host) {
|
|
host.querySelector('#items-sel-count').textContent = `${selected.size} selected`;
|
|
host.querySelector('#items-sel-del').disabled = selected.size === 0;
|
|
}
|
|
|
|
async function load(host) {
|
|
try {
|
|
const j = await jget('/api/items');
|
|
all = j.items || [];
|
|
// prune stale selections
|
|
const ids = new Set(all.map(x => x.id));
|
|
for (const id of [...selected]) if (!ids.has(id)) selected.delete(id);
|
|
render(host); updateSel(host);
|
|
} catch (e) {
|
|
host.querySelector('#items-list').innerHTML = `<div class="muted" style="padding:16px">${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
registerPane('items', {
|
|
label: 'Items',
|
|
init(host) {
|
|
host.innerHTML = TPL;
|
|
host.querySelector('#items-refresh').addEventListener('click', () => load(host));
|
|
host.querySelector('#items-search').addEventListener('input', () => render(host));
|
|
host.querySelector('#items-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const name = host.querySelector('#items-name').value.trim();
|
|
const description = host.querySelector('#items-desc').value.trim();
|
|
if (!name) return;
|
|
try {
|
|
await jpost('/api/items', { name, description });
|
|
host.querySelector('#items-form').reset();
|
|
ok('added'); await load(host);
|
|
} catch (e) { err(e.message); }
|
|
});
|
|
|
|
host.querySelector('#items-sel-all').addEventListener('click', () => { all.forEach(x => selected.add(x.id)); render(host); updateSel(host); });
|
|
host.querySelector('#items-sel-none').addEventListener('click', () => { selected.clear(); render(host); updateSel(host); });
|
|
host.querySelector('#items-sel-del').addEventListener('click', async () => {
|
|
const ids = [...selected];
|
|
if (!ids.length) return;
|
|
if (!confirm(`Delete ${ids.length} item(s)?`)) return;
|
|
for (const id of ids) { try { await jdelete('/api/items/' + id); } catch (e) { err(`#${id}: ${e.message}`); } }
|
|
selected.clear(); ok(`deleted ${ids.length}`); await load(host);
|
|
});
|
|
|
|
host.querySelector('#items-export').addEventListener('click', () => {
|
|
const blob = new Blob([JSON.stringify(all, null, 2)], { type: 'application/json' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `cxwebapp-items-${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
});
|
|
host.querySelector('#items-import').addEventListener('click', () => host.querySelector('#items-file').click());
|
|
host.querySelector('#items-file').addEventListener('change', async (e) => {
|
|
const f = e.target.files?.[0]; if (!f) return;
|
|
try {
|
|
const text = await f.text();
|
|
const arr = JSON.parse(text);
|
|
if (!Array.isArray(arr)) throw new Error('expected JSON array');
|
|
let n = 0;
|
|
for (const it of arr) {
|
|
if (!it?.name) continue;
|
|
try { await jpost('/api/items', { name: it.name, description: it.description || '' }); n++; } catch {}
|
|
}
|
|
ok(`imported ${n}/${arr.length}`); await load(host);
|
|
} catch (err2) { err('import: ' + err2.message); }
|
|
e.target.value = '';
|
|
});
|
|
|
|
load(host);
|
|
},
|
|
refresh: load,
|
|
});
|