Menu
Public repository / master
AkurAI-Framework / site/frontend/admin.js
Latest change c78c5f453208c5008730fff38de1f8fa1ec46219 - feat(site): built-in Admin UI for the auto-generated REST API by Ólafur Búi Ólafsson
// Admin UI — native ES module, no bundler. Drives the auto-generated REST API
// (GET /api/collections + per-collection record endpoints) with plain fetch + DOM.
"use strict";
const state = {
collections: [], // [{ name, fields:[{name,type,required,embed}], endpoints }]
current: null, // collection object currently selected
records: [], // records for the current collection (newest first)
search: "", // active search query
editingId: null, // id of the row being inline-edited, or null
};
// ── tiny DOM helper ─────────────────────────────────────────────
function h(tag, props, ...kids) {
const el = document.createElement(tag);
if (props) {
for (const [k, v] of Object.entries(props)) {
if (v == null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "text") el.textContent = v;
else if (k === "html") el.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
else if (k.startsWith("aria") || k === "role" || k === "for") el.setAttribute(k === "for" ? "for" : k.replace(/^aria([A-Z])/, (_, c) => "aria-" + c.toLowerCase()), v);
else el.setAttribute(k, v);
}
}
for (const kid of kids.flat()) {
if (kid == null || kid === false) continue;
el.appendChild(typeof kid === "string" ? document.createTextNode(kid) : kid);
}
return el;
}
const main = () => document.getElementById("admin-main");
// ── data fetching ───────────────────────────────────────────────
async function apiJson(url, opts) {
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
if (text) {
try { data = JSON.parse(text); } catch { /* non-JSON body */ }
}
return { res, data };
}
async function loadCollections() {
try {
const { res, data } = await apiJson("/api/collections", { headers: { accept: "application/json" } });
if (!res.ok) throw new Error(`status ${res.status}`);
state.collections = (data && data.collections) || [];
console.log(`[admin] loaded ${state.collections.length} collections`);
renderSidebar();
if (!state.collections.length) renderEmptyCollections();
} catch (e) {
console.log(`[admin] failed to load collections: ${e.message}`);
renderSidebar(e.message);
main().replaceChildren(
h("div", { class: "alert alert-danger" }, h("strong", { text: "Couldn't load collections. " }), `(${e.message})`)
);
}
}
async function loadRecords() {
if (!state.current) return;
const name = state.current.name;
const qs = state.search ? `?search=${encodeURIComponent(state.search)}` : "";
try {
const { res, data } = await apiJson(`/api/collections/${encodeURIComponent(name)}/records${qs}`, {
headers: { accept: "application/json" },
});
if (!res.ok) throw new Error(`status ${res.status}`);
state.records = Array.isArray(data) ? data : [];
console.log(`[admin] loaded ${state.records.length} records for ${name}`);
} catch (e) {
state.records = [];
console.log(`[admin] failed to load records for ${name}: ${e.message}`);
}
renderRecords();
}
// ── sidebar ─────────────────────────────────────────────────────
function renderSidebar(errorMsg) {
const ul = document.getElementById("admin-collection-list");
if (!ul) return;
ul.replaceChildren();
if (errorMsg) {
ul.appendChild(h("li", { class: "muted small" }, "Error loading collections."));
return;
}
if (!state.collections.length) {
ul.appendChild(h("li", { class: "muted small" }, "None declared."));
return;
}
for (const c of state.collections) {
const active = state.current && state.current.name === c.name;
const btn = h("button", {
type: "button",
class: "admin-collection-btn" + (active ? " is-active" : ""),
"aria-current": active ? "true" : null,
onclick: () => selectCollection(c.name),
}, c.name);
ul.appendChild(h("li", null, btn));
}
}
function renderEmptyCollections() {
main().replaceChildren(
h("div", { class: "empty" },
h("div", { class: "empty-icon", "aria-hidden": "true" }, "∅"),
h("p", { class: "empty-title" }, "No collections declared"),
h("p", { class: "empty-text" },
"Define collections in ", h("code", null, "backend/collections.toml"), " to use the admin. ",
h("a", { href: "/docs/auto-api" }, "Read the auto-API docs →")),
)
);
}
function selectCollection(name) {
state.current = state.collections.find((c) => c.name === name) || null;
state.search = "";
state.editingId = null;
console.log(`[admin] selected collection ${name}`);
renderSidebar();
renderCollectionShell();
loadRecords();
}
// ── field input helpers ─────────────────────────────────────────
function fieldType(f) {
return String(f.type || "text").toLowerCase();
}
function makeInput(field, value, idPrefix) {
const t = fieldType(field);
const id = `${idPrefix}-${field.name}`;
if (t === "bool") {
return h("input", {
type: "checkbox", id, "data-field": field.name, "data-type": t,
checked: value === true || value === "true" ? "checked" : null,
});
}
if (t === "int" || t === "float") {
return h("input", {
type: "number", class: "input", id, "data-field": field.name, "data-type": t,
step: t === "float" ? "any" : "1",
value: value == null ? "" : String(value),
"aria-required": field.required ? "true" : null,
});
}
// text (default)
return h("input", {
type: "text", class: "input", id, "data-field": field.name, "data-type": t,
value: value == null ? "" : String(value),
"aria-required": field.required ? "true" : null,
});
}
// Read a typed value from an input element. Returns { include, value }.
function readInput(input) {
const t = input.getAttribute("data-type");
if (t === "bool") return { include: true, value: input.checked };
const raw = input.value.trim();
if (raw === "") return { include: false, value: null };
if (t === "int") {
const n = parseInt(raw, 10);
return { include: true, value: Number.isNaN(n) ? raw : n };
}
if (t === "float") {
const n = parseFloat(raw);
return { include: true, value: Number.isNaN(n) ? raw : n };
}
return { include: true, value: raw };
}
// ── collection shell (form + toolbar + table mount point) ───────
function renderCollectionShell() {
const c = state.current;
const fieldBadges = c.fields.map((f) =>
h("span", { class: "badge badge-muted" }, `${f.name}: ${fieldType(f)}${f.required ? " *" : ""}`));
// New-record form
const formErr = h("div", { class: "alert alert-danger", id: "admin-create-error", style: "display:none;margin-bottom:14px" });
const grid = h("div", { class: "admin-form-grid" });
for (const f of c.fields) {
const labelText = h("span", { class: f.required ? "field-required" : null }, f.name);
if (fieldType(f) === "bool") {
grid.appendChild(
h("div", { class: "field" },
h("label", { class: "inline", for: `new-${f.name}` }, makeInput(f, false, "new"), labelText))
);
} else {
grid.appendChild(
h("div", { class: "field" },
h("label", { for: `new-${f.name}` }, labelText),
makeInput(f, "", "new"),
h("span", { class: "field-hint" }, fieldType(f)))
);
}
}
const form = h("form", {
class: "card stack", id: "admin-create-form", style: "margin-bottom:22px",
onsubmit: onCreate,
},
h("h3", { style: "margin:0" }, "New record"),
formErr,
grid,
h("div", null, h("button", { type: "submit", class: "btn btn-primary" }, "Create record")),
);
// Toolbar: search
const searchInput = h("input", {
type: "search", class: "input admin-search", id: "admin-search",
placeholder: "Search records…", "aria-label": `Search ${c.name}`, value: state.search,
});
let debounce;
searchInput.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(() => {
state.search = searchInput.value.trim();
state.editingId = null;
loadRecords();
}, 250);
});
const toolbar = h("div", { class: "admin-toolbar" },
h("div", null,
h("h2", { style: "margin:0" }, c.name),
h("p", { class: "muted small", style: "margin:4px 0 0" }, "Records (newest first)")),
h("div", { class: "search" },
h("span", { class: "search-icon", "aria-hidden": "true" }, "⌕"),
searchInput),
);
main().replaceChildren(
h("div", { class: "btn-row", style: "flex-wrap:wrap;gap:6px;margin-bottom:16px" }, fieldBadges),
form,
toolbar,
h("div", { class: "admin-table-wrap", id: "admin-records" }),
);
}
// ── records table ───────────────────────────────────────────────
function fmtCell(field, value) {
if (value == null) return "—";
if (fieldType(field) === "bool") return value ? "true" : "false";
return String(value);
}
function fmtCreated(sec) {
if (sec == null) return "—";
const d = new Date(Number(sec) * 1000);
return Number.isNaN(d.getTime()) ? String(sec) : d.toLocaleString();
}
function renderRecords() {
const mount = document.getElementById("admin-records");
if (!mount) return;
const c = state.current;
if (!state.records.length) {
mount.replaceChildren(
h("div", { class: "empty" },
h("div", { class: "empty-icon", "aria-hidden": "true" }, "○"),
h("p", { class: "empty-title" }, state.search ? "No matching records" : "No records yet"),
h("p", { class: "empty-text" }, state.search ? "Try a different search." : "Create the first one with the form above."))
);
return;
}
const cols = ["id", "created", ...c.fields.map((f) => f.name)];
const thead = h("thead", null,
h("tr", null,
...cols.map((col) => h("th", null, col)),
h("th", null, "Actions")));
const tbody = h("tbody");
for (const rec of state.records) {
tbody.appendChild(rec.id === state.editingId ? editRow(rec) : viewRow(rec));
}
mount.replaceChildren(h("table", { class: "table" }, thead, tbody));
}
function viewRow(rec) {
const c = state.current;
return h("tr", null,
h("td", null, String(rec.id)),
h("td", { class: "muted small" }, fmtCreated(rec.created)),
...c.fields.map((f) => h("td", null, fmtCell(f, rec[f.name]))),
h("td", null,
h("div", { class: "admin-actions" },
h("button", { type: "button", class: "btn btn-sm btn-secondary", onclick: () => { state.editingId = rec.id; renderRecords(); } }, "Edit"),
h("button", { type: "button", class: "btn btn-sm btn-ghost", onclick: () => onDelete(rec) }, "Delete"))),
);
}
function editRow(rec) {
const c = state.current;
const idPrefix = `edit-${rec.id}`;
const err = h("td", { colspan: String(c.fields.length + 3), class: "field-error", style: "display:none;color:var(--danger)", id: `${idPrefix}-error` });
const cells = h("tr", { "data-edit-id": String(rec.id) },
h("td", null, String(rec.id)),
h("td", { class: "muted small" }, fmtCreated(rec.created)),
...c.fields.map((f) => h("td", null, makeInput(f, rec[f.name], idPrefix))),
h("td", null,
h("div", { class: "admin-actions" },
h("button", { type: "button", class: "btn btn-sm btn-primary", onclick: () => onSaveEdit(rec) }, "Save"),
h("button", { type: "button", class: "btn btn-sm btn-ghost", onclick: () => { state.editingId = null; renderRecords(); } }, "Cancel"))),
);
// Append an error row right after for inline PATCH validation feedback.
const frag = document.createDocumentFragment();
frag.appendChild(cells);
frag.appendChild(h("tr", { id: `${idPrefix}-error-row`, style: "display:none" }, err));
return frag;
}
// ── mutations ───────────────────────────────────────────────────
async function onCreate(event) {
event.preventDefault();
const errBox = document.getElementById("admin-create-error");
errBox.style.display = "none";
errBox.textContent = "";
const form = event.currentTarget;
const body = {};
form.querySelectorAll("[data-field]").forEach((input) => {
const { include, value } = readInput(input);
if (include) body[input.getAttribute("data-field")] = value;
});
try {
const { res, data } = await apiJson(`/api/collections/${encodeURIComponent(state.current.name)}/records`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify(body),
});
if (res.status === 201) {
console.log(`[admin] created record id=${data && data.id}`);
form.reset();
state.search = "";
const searchEl = document.getElementById("admin-search");
if (searchEl) searchEl.value = "";
loadRecords();
return;
}
const msg = (data && data.error) || `unexpected status ${res.status}`;
errBox.textContent = msg;
errBox.style.display = "block";
console.log(`[admin] create failed: ${msg}`);
} catch (e) {
errBox.textContent = e.message;
errBox.style.display = "block";
console.log(`[admin] create error: ${e.message}`);
}
}
async function onSaveEdit(rec) {
const idPrefix = `edit-${rec.id}`;
const errRow = document.getElementById(`${idPrefix}-error-row`);
const errCell = document.getElementById(`${idPrefix}-error`);
const row = main().querySelector(`tr[data-edit-id="${rec.id}"]`);
if (!row) return;
// Build a patch of only the changed fields.
const patch = {};
row.querySelectorAll("[data-field]").forEach((input) => {
const name = input.getAttribute("data-field");
const { include, value } = readInput(input);
const next = include ? value : null;
const prev = rec[name] == null ? null : rec[name];
if (JSON.stringify(next) !== JSON.stringify(prev)) patch[name] = next;
});
if (Object.keys(patch).length === 0) {
state.editingId = null;
renderRecords();
return;
}
try {
const { res, data } = await apiJson(`/api/collections/${encodeURIComponent(state.current.name)}/records/${rec.id}`, {
method: "PATCH",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify(patch),
});
if (res.ok) {
console.log(`[admin] updated record id=${rec.id}`);
state.editingId = null;
loadRecords();
return;
}
const msg = (data && data.error) || `unexpected status ${res.status}`;
if (errCell && errRow) { errCell.textContent = msg; errRow.style.display = ""; }
console.log(`[admin] update failed: ${msg}`);
} catch (e) {
if (errCell && errRow) { errCell.textContent = e.message; errRow.style.display = ""; }
console.log(`[admin] update error: ${e.message}`);
}
}
async function onDelete(rec) {
if (!window.confirm(`Delete record #${rec.id} from "${state.current.name}"? This cannot be undone.`)) return;
try {
const res = await fetch(`/api/collections/${encodeURIComponent(state.current.name)}/records/${rec.id}`, {
method: "DELETE",
});
if (res.status === 204 || res.ok) {
console.log(`[admin] deleted record id=${rec.id}`);
if (state.editingId === rec.id) state.editingId = null;
loadRecords();
return;
}
console.log(`[admin] delete failed for id=${rec.id}: status ${res.status}`);
} catch (e) {
console.log(`[admin] delete error for id=${rec.id}: ${e.message}`);
}
}
// ── boot ────────────────────────────────────────────────────────
function init() {
if (!document.getElementById("admin-collection-list")) return; // not the admin page
console.log("[admin] init");
loadCollections();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}