AkurAI Build
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();
}