theaio/theaio-boards

Laneway, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix theaio/theaio-boards

app/boards/doors/page.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Laneway</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect x='1' y='2' width='4' height='12' rx='1' fill='%234f6bed'/%3E%3Crect x='6' y='2' width='4' height='8' rx='1' fill='%234f6bed' opacity='.7'/%3E%3Crect x='11' y='2' width='4' height='5' rx='1' fill='%234f6bed' opacity='.45'/%3E%3C/svg%3E">
<style>
:root{--bg:#f6f7f9;--panel:#fff;--col:#eef0f3;--ink:#1d2330;--muted:#6b7280;--line:#e2e5ea;
 --accent:#4f6bed;--accent-ink:#fff;--late:#c2410c;--late-bg:#fff1e8;--block:#7c3aed;--chip:#eef2ff;
 --shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08)}
@media (prefers-color-scheme:dark){:root{--bg:#14171c;--panel:#1d2128;--col:#191d23;--ink:#e6e8ec;
 --muted:#9aa1ad;--line:#2b313a;--accent:#7b91ff;--accent-ink:#0e1220;--late:#fb923c;--late-bg:#2c1d14;
 --block:#a78bfa;--chip:#262c40;--shadow:none}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
a{color:var(--accent);text-decoration:none}
button,input,select,textarea{font:inherit;color:inherit}
button{border:1px solid var(--line);background:var(--panel);border-radius:6px;padding:.35rem .7rem;cursor:pointer}
button:hover{border-color:var(--muted)}
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
button.link{border:0;background:none;padding:0;color:var(--muted)}
button.link:hover{color:var(--ink)}
input,select,textarea{border:1px solid var(--line);background:var(--panel);border-radius:6px;padding:.35rem .5rem;min-width:0}
textarea{width:100%;min-height:5rem;resize:vertical}
header{display:flex;align-items:center;gap:1.2rem;padding:.7rem 1.2rem;border-bottom:1px solid var(--line);background:var(--panel)}
.brand{display:flex;align-items:center;gap:.45rem;font-weight:650;letter-spacing:.01em;color:var(--ink)}
.brand i{display:flex;gap:2px;align-items:flex-start;height:14px}
.brand i b{width:4px;background:var(--accent);border-radius:1px}
nav{display:flex;gap:.9rem}nav a{color:var(--muted)}nav a.on{color:var(--ink);font-weight:600}
.who{margin-left:auto;color:var(--muted);display:flex;gap:.8rem;align-items:center}
main{padding:1.2rem}
h1{font-size:1.25rem;margin:0}h2{font-size:1rem;margin:1.2rem 0 .5rem}
.muted{color:var(--muted)}.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:.8rem;margin-top:1rem}
.tile{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:.9rem;box-shadow:var(--shadow);color:var(--ink)}
.tile:hover{border-color:var(--accent)}
.badge{font-size:.72rem;border-radius:999px;padding:.05rem .5rem;background:var(--chip);color:var(--muted)}
.bar{display:flex;align-items:center;gap:.8rem;flex-wrap:wrap;margin-bottom:1rem}
.bar .tools{margin-left:auto;display:flex;gap:.4rem}
.cols{display:flex;gap:.8rem;align-items:flex-start;overflow-x:auto;padding-bottom:1rem}
.col{flex:0 0 17rem;background:var(--col);border:1px solid var(--line);border-radius:10px;padding:.55rem;display:flex;flex-direction:column;gap:.45rem}
.col.over{outline:2px dashed var(--accent);outline-offset:-3px}
.colhead{display:flex;align-items:center;gap:.3rem;font-weight:600;padding:.1rem .2rem}
.colhead .n{color:var(--muted);font-weight:400}
.colhead .act{margin-left:auto;display:flex;gap:.2rem;opacity:.0;transition:opacity .15s}
.col:hover .act{opacity:1}
.cards{display:flex;flex-direction:column;gap:.45rem;min-height:1.5rem}
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;box-shadow:var(--shadow);cursor:pointer}
.card[draggable=true]{cursor:grab}.card.dragging{opacity:.4}
.card .t{margin:.1rem 0 .3rem}.card .num{color:var(--muted);font-size:.75rem}
.meta{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center;font-size:.75rem;color:var(--muted)}
.chip{background:var(--chip);border-radius:4px;padding:0 .35rem;color:var(--ink)}
.late{color:var(--late);background:var(--late-bg);border-radius:4px;padding:0 .35rem;font-weight:600}
.blocked{color:var(--block);font-weight:600}
.avatar{margin-left:auto;width:1.35rem;height:1.35rem;border-radius:50%;background:var(--accent);color:var(--accent-ink);display:grid;place-items:center;font-size:.65rem;font-weight:700;text-transform:uppercase}
.drop{height:3px;background:var(--accent);border-radius:2px}
form.inline{display:flex;gap:.35rem}form.inline input{flex:1}
.addlist{flex:0 0 15rem}
dialog{border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--ink);padding:0;width:min(44rem,94vw);box-shadow:0 10px 40px rgba(0,0,0,.25)}
dialog::backdrop{background:rgba(15,20,30,.35)}
.dlg{padding:1.1rem 1.2rem;max-height:86vh;overflow:auto}
.dlg .top{display:flex;align-items:flex-start;gap:.6rem}.dlg .top .x{margin-left:auto}
.fields{display:grid;grid-template-columns:7rem 1fr;gap:.5rem .8rem;align-items:center;margin:.8rem 0}
.fields label{color:var(--muted)}
ul.plain{list-style:none;padding:0;margin:0}
ul.plain li{padding:.35rem 0;border-bottom:1px solid var(--line)}
.hist li{font-size:.85rem}.hist .when{color:var(--muted);font-size:.75rem}
.old{text-decoration:line-through;color:var(--muted)}
.list{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:.2rem .9rem}
.empty{color:var(--muted);padding:2rem 0;text-align:center}
.err{position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--bg);padding:.55rem .9rem;border-radius:8px;display:none;max-width:90vw}
pre.summary{white-space:pre-wrap;font:inherit;margin:0}
</style>
</head>
<body>
<header>
  <a class="brand" href="#/"><i><b style="height:14px"></b><b style="height:10px;opacity:.7"></b><b style="height:6px;opacity:.45"></b></i>Laneway</a>
  <nav><a href="#/" data-nav="home">Boards</a><a href="#/mine" data-nav="mine">My cards</a><a href="#/overdue" data-nav="overdue">Overdue</a></nav>
  <div class="who"><span id="me"></span><a href="/.dodil/auth/logout">Sign out</a></div>
</header>
<main id="main"></main>
<dialog id="dlg"><div class="dlg" id="dlgbody"></div></dialog>
<div class="err" id="err"></div>
<script>
"use strict";
const $ = (s, el = document) => el.querySelector(s);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const enc = encodeURIComponent;
let board = null, me = "";

async function api(url, opts = {}) {
  const r = await fetch(url, {headers: {"content-type": "application/json"}, ...opts,
    body: opts.body === undefined ? undefined : JSON.stringify(opts.body)});
  if (r.status === 401) { location.href = "/.dodil/auth/login?return_to=/"; throw new Error("sign in"); }
  const data = await r.json().catch(() => ({}));
  if (!r.ok) { toast(typeof data.detail === "string" ? data.detail : "Something went wrong"); throw new Error(data.detail); }
  return data;
}
function toast(msg) { const e = $("#err"); e.textContent = msg; e.style.display = "block";
  clearTimeout(toast.t); toast.t = setTimeout(() => e.style.display = "none", 4000); }
const canEdit = () => board && board.role !== "viewer";
const isOwner = () => board && board.role === "owner";

function cardMeta(c) {
  return `<div class="meta">
    ${c.labels.map(l => `<span class="chip">${esc(l)}</span>`).join("")}
    ${c.due_date ? `<span class="${c.overdue ? "late" : ""}">${c.overdue ? "overdue " : "due "}${esc(c.due_date)}</span>` : ""}
    ${c.blocked ? `<span class="blocked">blocked</span>` : ""}
    ${c.assignee ? `<span class="avatar" title="${esc(c.assignee)}">${esc(c.assignee.slice(0, 2))}</span>` : ""}
  </div>`;
}

// ---- routing --------------------------------------------------------------------------
async function route() {
  const h = location.hash.slice(1) || "/";
  document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on",
    (h === "/" && a.dataset.nav === "home") || h === "/" + a.dataset.nav));
  if (h.startsWith("/b/")) return showBoard(decodeURIComponent(h.slice(3)));
  board = null;
  if (h === "/mine") return showMine();
  if (h === "/overdue") return showOverdue();
  return showHome();
}
window.addEventListener("hashchange", route);

// ---- home -----------------------------------------------------------------------------
async function showHome() {
  const boards = await api("/api/boards");
  $("#main").innerHTML = `
    <div class="bar"><h1>Your boards</h1>
      <form class="inline tools" id="nb"><input name="t" placeholder="New board title" required><button class="primary">Create board</button></form></div>
    ${boards.length ? `<div class="grid">${boards.map(b => `<a class="tile" href="#/b/${enc(b.id)}">
       <div style="font-weight:600">${esc(b.title)}</div><div class="badge" style="display:inline-block;margin-top:.4rem">${esc(b.role)}</div></a>`).join("")}</div>`
      : `<p class="empty">You are not on any board yet. Create one, or ask a board owner to invite ${esc(me)}.</p>`}`;
  $("#nb").onsubmit = async e => { e.preventDefault();
    const b = await api("/api/boards", {method: "POST", body: {title: e.target.t.value}});
    location.hash = "#/b/" + enc(b.id); };
}

function cardRows(cards, empty) {
  if (!cards.length) return `<p class="empty">${empty}</p>`;
  return `<ul class="plain list">${cards.map(c => `<li class="row">
      <a href="#/b/${enc(c.board_id)}" class="muted">${esc(c.board)}</a>
      <span class="num muted">#${c.number}</span>
      <a href="#" data-card="${esc(c.id)}">${esc(c.title)}</a>
      <span class="muted">· ${esc(c.list)}</span> ${cardMeta(c)}</li>`).join("")}</ul>`;
}
function wireCardLinks(root) {
  root.querySelectorAll("[data-card]").forEach(a => a.onclick = e => { e.preventDefault(); openCard(a.dataset.card); });
}

async function showMine(days) {
  const out = await api("/api/my-cards" + (days ? "?days=" + days : ""));
  $("#main").innerHTML = `<div class="bar"><h1>My cards</h1>
    <div class="tools"><button id="all">All open</button><button id="wk">Due this week</button></div></div>
    ${cardRows(out.cards, days ? "Nothing due in the next 7 days." : "Nothing assigned to you.")}`;
  $("#all").onclick = () => showMine(); $("#wk").onclick = () => showMine(7);
  (days ? $("#wk") : $("#all")).classList.add("primary");
  wireCardLinks($("#main"));
}

async function showOverdue() {
  const out = await api("/api/overdue");
  $("#main").innerHTML = `<div class="bar"><h1>Overdue</h1><span class="muted">today is ${esc(out.today)}</span></div>
    ${cardRows(out.cards, "Nothing is overdue on your boards.")}`;
  wireCardLinks($("#main"));
}

// ---- board ----------------------------------------------------------------------------
async function showBoard(id) {
  board = await api("/api/boards/" + enc(id));
  const edit = canEdit();
  $("#main").innerHTML = `
    <div class="bar"><h1>${esc(board.title)}</h1><span class="badge">${esc(board.role)}</span>
      ${board.overdue ? `<span class="late">${board.overdue} overdue</span>` : ""}
      <div class="tools"><button id="sum">Daily summary</button><button id="blk">What's blocked</button>
      <button id="mem">People (${board.members.length})</button></div></div>
    <div class="cols">${board.lists.map((l, i) => `
      <section class="col" data-list="${esc(l.id)}">
        <div class="colhead"><span>${esc(l.name)}</span><span class="n">${l.cards.length}</span>
          ${edit ? `<span class="act">
            ${i > 0 ? `<button class="link" title="Move left" data-mvl="${i}">←</button>` : ""}
            ${i < board.lists.length - 1 ? `<button class="link" title="Move right" data-mvl="${i + 2}">→</button>` : ""}
            <button class="link" title="Rename" data-ren>✎</button></span>` : ""}</div>
        <div class="cards">${l.cards.map(c => `
          <article class="card" draggable="${edit}" data-id="${esc(c.id)}">
            <div class="num">#${c.number}</div><div class="t">${esc(c.title)}</div>${cardMeta(c)}</article>`).join("")}</div>
        ${edit ? `<form class="inline addcard"><input name="t" placeholder="Add a card" required><button>Add</button></form>` : ""}
      </section>`).join("")}
      ${edit ? `<section class="addlist"><form class="inline" id="nl"><input name="n" placeholder="New list" required><button>Add list</button></form></section>` : ""}
    </div>`;
  $("#sum").onclick = summary; $("#blk").onclick = blocking; $("#mem").onclick = people;
  document.querySelectorAll(".card").forEach(c => c.onclick = () => openCard(c.dataset.id));
  if (!edit) return;
  $("#nl").onsubmit = async e => { e.preventDefault();
    await api(`/api/boards/${enc(board.id)}/lists`, {method: "POST", body: {name: e.target.n.value}}); showBoard(board.id); };
  document.querySelectorAll(".col").forEach(col => {
    const lid = col.dataset.list;
    $(".addcard", col).onsubmit = async e => { e.preventDefault();
      await api(`/api/boards/${enc(board.id)}/cards`, {method: "POST", body: {title: e.target.t.value, list_name: lid}}); showBoard(board.id); };
    col.querySelectorAll("[data-mvl]").forEach(b => b.onclick = async () => {
      await api(`/api/boards/${enc(board.id)}/lists/${enc(lid)}`, {method: "PATCH", body: {position: +b.dataset.mvl}}); showBoard(board.id); });
    $("[data-ren]", col).onclick = async () => {
      const name = prompt("Rename list", board.lists.find(l => l.id === lid).name);
      if (name) { await api(`/api/boards/${enc(board.id)}/lists/${enc(lid)}`, {method: "PATCH", body: {name}}); showBoard(board.id); } };
    wireDrop(col);
  });
  document.querySelectorAll(".card[draggable=true]").forEach(c => {
    c.addEventListener("dragstart", e => { e.dataTransfer.setData("text/plain", c.dataset.id); e.dataTransfer.effectAllowed = "move"; c.classList.add("dragging"); });
    c.addEventListener("dragend", () => c.classList.remove("dragging"));
  });
}

// Native HTML5 drag and drop: a line shows where the card lands; drop sends list + position.
function wireDrop(col) {
  const zone = $(".cards", col);
  const marker = document.createElement("div"); marker.className = "drop";
  const indexAt = y => [...zone.querySelectorAll(".card:not(.dragging)")]
    .filter(c => y > c.getBoundingClientRect().top + c.offsetHeight / 2).length;
  col.addEventListener("dragover", e => { e.preventDefault(); col.classList.add("over");
    const i = indexAt(e.clientY), cards = zone.querySelectorAll(".card:not(.dragging)");
    cards[i] ? zone.insertBefore(marker, cards[i]) : zone.appendChild(marker); });
  col.addEventListener("dragleave", e => { if (!col.contains(e.relatedTarget)) { col.classList.remove("over"); marker.remove(); } });
  col.addEventListener("drop", async e => { e.preventDefault(); col.classList.remove("over"); marker.remove();
    const id = e.dataTransfer.getData("text/plain"); if (!id) return;
    await api(`/api/cards/${enc(id)}/move`, {method: "POST", body: {list_name: col.dataset.list, position: indexAt(e.clientY) + 1}})
      .finally(() => showBoard(board.id)); });
}

function dialog(html) { $("#dlgbody").innerHTML = html; const d = $("#dlg"); if (!d.open) d.showModal();
  d.querySelectorAll("[data-close]").forEach(b => b.onclick = () => d.close()); return d; }
$("#dlg").addEventListener("close", route);
const head = t => `<div class="top"><h1>${t}</h1><button class="link x" data-close>✕</button></div>`;

async function summary() {
  dialog(head("Daily summary") + `<p class="muted">Writing…</p>`);
  const s = await api(`/api/boards/${enc(board.id)}/summary`);
  dialog(head(`Daily summary · ${esc(s.board)}`) + (s.note ? `<p class="muted">${esc(s.note)}</p>` : "") + `<pre class="summary">${esc(s.summary)}</pre>`);
}

async function blocking() {
  const b = await api(`/api/boards/${enc(board.id)}/blocking`);
  dialog(head("What's blocked") + (b.blocked.length ? `<ul class="plain">${b.blocked.map(x => `<li>
     <a href="#" data-card="${esc(x.card.id)}">#${x.card.number} ${esc(x.card.title)}</a>
     <span class="muted">waits on</span> ${x.waiting_on.map(w => `<a href="#" data-card="${esc(w.id)}">#${w.number} ${esc(w.title)}</a> <span class="muted">(${esc(w.list)}${w.assignee ? ", " + esc(w.assignee) : ""})</span>`).join(", ")}</li>`).join("")}</ul>`
    : `<p class="empty">Nothing on this board is waiting on another card.</p>`));
  wireCardLinks($("#dlgbody"));
}

async function people() {
  const own = isOwner();
  dialog(head("People") + `<ul class="plain">${board.members.map(m => `<li class="row"><span>${esc(m.email)}</span>
      <span style="margin-left:auto"></span>
      ${own && m.role !== "owner" ? `<select data-role="${esc(m.email)}">${["member", "viewer"].map(r => `<option ${r === m.role ? "selected" : ""}>${r}</option>`).join("")}</select>
        <button class="link" data-rm="${esc(m.email)}">Remove</button>` : `<span class="badge">${esc(m.role)}</span>`}</li>`).join("")}</ul>
    ${own ? `<h2>Invite</h2><form class="inline" id="inv"><input name="e" type="email" placeholder="name@company.com" required>
      <select name="r"><option>member</option><option>viewer</option></select><button class="primary">Invite</button></form>
      <p class="muted">They see this board as soon as they sign in with that email. Members edit; viewers only read.</p>`
      : `<p class="muted">Only the owner manages people.</p>`}`);
  if (!own) return;
  const base = `/api/boards/${enc(board.id)}/members`;
  const again = async () => { board = await api("/api/boards/" + enc(board.id)); people(); };
  $("#inv").onsubmit = async e => { e.preventDefault(); await api(base, {method: "POST", body: {email: e.target.e.value, role: e.target.r.value}}); again(); };
  document.querySelectorAll("[data-role]").forEach(s => s.onchange = async () => { await api(`${base}/${enc(s.dataset.role)}`, {method: "PATCH", body: {role: s.value}}); again(); });
  document.querySelectorAll("[data-rm]").forEach(b => b.onclick = async () => {
    if (confirm(`Remove ${b.dataset.rm} from ${board.title}?`)) { await api(`${base}/${enc(b.dataset.rm)}`, {method: "DELETE"}); again(); } });
}

// ---- card detail ----------------------------------------------------------------------
async function openCard(id) {
  const c = await api("/api/cards/" + enc(id));
  if (!board || board.id !== c.board_id) board = await api("/api/boards/" + enc(c.board_id));
  const edit = c.role !== "viewer", url = "/api/cards/" + enc(c.id);
  const assignable = board.members.filter(m => m.role !== "viewer");
  const others = board.lists.flatMap(l => l.cards).filter(x => x.id !== c.id && !c.blocked_by.some(b => b.number === x.number));
  const hist = h => h.field === "comment" ? `<b>${esc(h.who)}</b> commented: ${esc(h.new)}`
    : h.field === "created" ? `<b>${esc(h.who)}</b> created this card: ${esc(h.new)}`
    : `<b>${esc(h.who)}</b> changed <b>${esc(h.field)}</b>: ${h.old ? `<span class="old">${esc(h.old)}</span> → ` : ""}${esc(h.new || "(none)")}`;
  dialog(`<div class="top"><div><div class="muted">#${c.number} · ${esc(c.board)} · ${esc(c.list)}</div>
      ${edit ? `<input id="ti" value="${esc(c.title)}" style="font-size:1.1rem;font-weight:600;width:100%;margin-top:.2rem">` : `<h1>${esc(c.title)}</h1>`}</div>
      <button class="link x" data-close>✕</button></div>
    <div class="fields">
      <label>Assignee</label>${edit ? `<select id="as"><option value="">nobody</option>${assignable.map(m => `<option ${m.email === c.assignee ? "selected" : ""}>${esc(m.email)}</option>`).join("")}</select>` : `<span>${esc(c.assignee || "nobody")}</span>`}
      <label>Due</label>${edit ? `<input id="du" type="date" value="${esc(c.due_date || "")}">` : `<span>${esc(c.due_date || "no date")}</span>`}
      <label>Labels</label>${edit ? `<input id="la" value="${esc(c.labels.join(", "))}" placeholder="comma, separated">` : `<span>${c.labels.map(l => `<span class="chip">${esc(l)}</span>`).join(" ") || "none"}</span>`}
      <label>Status</label><span>${c.overdue ? `<span class="late">overdue</span>` : c.done ? "done" : "open"} ${c.blocked ? `<span class="blocked">· blocked</span>` : ""}</span>
    </div>
    ${edit ? `<textarea id="de" placeholder="Description">${esc(c.description)}</textarea>
      <div class="row" style="margin-top:.5rem"><button class="primary" id="save">Save</button>
      <button id="ck">Suggest checklist</button><button id="sim">Find similar</button></div>`
      : `<p>${esc(c.description) || `<span class="muted">No description.</span>`}</p><div class="row"><button id="sim">Find similar</button></div>`}
    <div id="extra"></div>
    <h2>Blocked by</h2>
    ${c.blocked_by.length ? `<ul class="plain">${c.blocked_by.map(b => `<li class="row">#${b.number} ${esc(b.title)} <span class="muted">· ${esc(b.list)}${b.done ? " ✓" : ""}</span>
       ${edit ? `<button class="link" style="margin-left:auto" data-unb="${b.number}">Remove</button>` : ""}</li>`).join("")}</ul>` : `<p class="muted">Nothing.</p>`}
    ${edit && others.length ? `<form class="inline" id="blk"><select name="b">${others.map(x => `<option value="${x.number}">#${x.number} ${esc(x.title)}</option>`).join("")}</select><button>Add blocker</button></form>` : ""}
    ${c.blocking.length ? `<p class="muted">Blocks: ${c.blocking.map(b => `#${b.number} ${esc(b.title)}`).join(", ")}</p>` : ""}
    <h2>History</h2>
    ${edit ? `<form class="inline" id="cm"><input name="t" placeholder="Write a comment" required><button>Comment</button></form>` : ""}
    <ul class="plain hist">${c.history.slice().reverse().map(h => `<li>${hist(h)}<div class="when">${esc(h.at.replace("T", " "))}</div></li>`).join("")}</ul>`);
  const again = () => openCard(c.id);
  $("#sim").onclick = async () => { $("#extra").innerHTML = `<p class="muted">Searching by meaning…</p>`;
    const s = await api(url + "/similar").catch(() => ({similar: []}));
    $("#extra").innerHTML = `<h2>Similar cards</h2>` + (s.similar.length ? `<ul class="plain">${s.similar.map(x =>
      `<li><a href="#" data-card="${esc(x.id)}">#${x.number} ${esc(x.title)}</a> <span class="muted">· ${esc(x.board)} · ${esc(x.list)} · ${Math.round(x.similarity * 100)}%</span></li>`).join("")}</ul>` : `<p class="muted">Nothing similar.</p>`);
    wireCardLinks($("#extra")); };
  if (!edit) return;
  $("#save").onclick = async () => {
    await api(url, {method: "PATCH", body: {title: $("#ti").value, description: $("#de").value, assignee: $("#as").value,
      due_date: $("#du").value, labels: $("#la").value.split(",").map(s => s.trim()).filter(Boolean)}}); again(); };
  $("#ck").onclick = async () => { $("#extra").innerHTML = `<p class="muted">Thinking…</p>`;
    const s = await api(url + "/checklist", {method: "POST"}).catch(() => null); if (!s) { $("#extra").innerHTML = ""; return; }
    $("#extra").innerHTML = `<h2>Suggested checklist</h2><ul>${s.suggested_checklist.map(x => `<li>${esc(x)}</li>`).join("")}</ul><button id="keep">Add to description</button>`;
    $("#keep").onclick = () => { $("#de").value = ($("#de").value.trim() + "\n\n" + s.suggested_checklist.map(x => "- [ ] " + x).join("\n")).trim(); $("#extra").innerHTML = ""; }; };
  $("#cm").onsubmit = async e => { e.preventDefault(); await api(url + "/comments", {method: "POST", body: {text: e.target.t.value}}); again(); };
  if ($("#blk")) $("#blk").onsubmit = async e => { e.preventDefault(); await api(url + "/blockers", {method: "POST", body: {blocked_by: e.target.b.value}}); again(); };
  document.querySelectorAll("[data-unb]").forEach(b => b.onclick = async () => { await api(`${url}/blockers/${b.dataset.unb}`, {method: "DELETE"}); again(); });
}

api("/api/me").then(m => { me = m.email; $("#me").textContent = me; route(); });
</script>
</body>
</html>