sawera/sawera-boards

Sawera Boards - a team task board for the Dodil Boards Challenge. Boards, lists, cards, roles, activity log, MCP actions and AI extras. FastAPI + DataK3 + appid + Models + MCP.

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

app/boards/doors/page.py

"""The board page: one self-contained HTML file, no build step, no framework.

The look follows the classic kanban board people already know — researched
against real Trello boards and current redesign studies: a deep blue app bar,
a board header with the title, member stack and board stats as pills, 272px
light lists (#f1f2f4, 12px radius), white cards (8px radius, soft shadow) with
pastel label pills, small due badges and member avatars, and a card dialog with
quiet grey section labels. It is Sawera Boards' own design in that language —
no Trello name, logo or artwork.

Behaviour notes: every destructive or renaming action uses this page's own
dialogs and inline editors (never the browser's native prompt/confirm), so the
flow is identical for people and for automated checks. After each change the
page re-reads the board until the change shows (the store can lag a moment),
with a small "Syncing…" pill while it waits, so nobody repeats an action that
already worked. It talks to the same JSON routes Claude uses through MCP, so
what a person can do here is exactly what their role allows anywhere.
"""

PAGE = r"""<!doctype html><meta charset=utf-8><title>Sawera Boards</title>
<meta name=viewport content="width=device-width,initial-scale=1">
<style>
:root{--ink:#172b4d;--mut:#5e6c84;--blue:#0079bf;--bar:#026aa7;--list:#f1f2f4;
--primary:#0c66e4;--green:#5aac44;--green2:#49852e;--red:#eb5a46;--reddark:#ae2e24;
--line:#dfe1e6;--hov:rgba(9,30,66,.08);--chip:rgba(255,255,255,.24)}
*{box-sizing:border-box}
body{font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
margin:0;color:var(--ink);background:linear-gradient(180deg,var(--blue),#0067a3);
background-attachment:fixed;min-height:100vh}
header{display:flex;align-items:center;gap:.7rem;height:48px;padding:0 .8rem;color:#fff;
background:var(--bar);position:sticky;top:0;z-index:20}
header .logo{font-weight:800;font-size:1.08rem;letter-spacing:.2px;white-space:nowrap;display:flex;align-items:center;gap:.45rem}
header .logo .mark{background:rgba(255,255,255,.28);border-radius:5px;padding:.02rem .34rem;font-weight:800}
header nav{margin-left:auto;display:flex;gap:.6rem;align-items:center;font-size:.92rem}
header a{color:#e4f0f6;text-decoration:none}
header form{display:flex;gap:.4rem}
header input{background:rgba(255,255,255,.28);border:0;color:#fff;border-radius:6px;padding:.4rem .6rem}
header input::placeholder{color:#d8ecf7}
header input:focus{background:#fff;color:var(--ink);outline:none}
button{font:inherit;background:var(--primary);color:#fff;border:0;border-radius:6px;
padding:.42rem .8rem;cursor:pointer;font-weight:600}
button:hover{filter:brightness(1.08)}
button.ghost{background:var(--chip);color:#fff}
button.ghost:hover{background:rgba(255,255,255,.36)}
button.plain{background:transparent;color:var(--mut);padding:.2rem .45rem;font-weight:400}
button.plain:hover{background:var(--hov);color:var(--ink);filter:none}
button.danger{background:var(--reddark)}
input,select,textarea{font:inherit;padding:.42rem .55rem;border:1px solid var(--line);
border-radius:6px;background:#fff;color:var(--ink)}
main{padding:0 .2rem 1rem}
.subbar{display:flex;flex-wrap:wrap;gap:.45rem;align-items:center;padding:.55rem .7rem 0}
.subbar .boards{display:flex;gap:.4rem;flex-wrap:wrap}
.subbar .boards button{background:var(--chip);color:#fff;font-weight:600}
.subbar .boards button.on{background:#fff;color:var(--bar)}
.subbar form{display:flex;gap:.4rem;margin-left:auto}
.subbar input{background:rgba(255,255,255,.28);border:0;color:#fff}
.subbar input::placeholder{color:#d8ecf7}
.subbar form button{background:#fff;color:var(--bar)}
.bar{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;padding:.6rem .7rem .55rem;color:#fff}
.bar h2{margin:0;font-size:1.3rem;font-weight:700;padding:.1rem .4rem}
.chip{background:var(--chip);color:#fff;border-radius:6px;padding:.22rem .65rem;font-size:.9rem;white-space:nowrap}
.chip.role{font-size:.82rem}
.bar .sp{flex:1}
.stack{display:inline-flex;align-items:center;margin:0 .15rem}
.stack .av{margin-left:-7px;border:2px solid rgba(255,255,255,.55)}
.stack .av:first-child{margin-left:0}
.cols{display:flex;gap:.75rem;overflow-x:auto;align-items:flex-start;padding:.15rem .7rem 1rem}
.col{flex:0 0 272px;width:272px;background:var(--list);border-radius:12px;
padding:.35rem .4rem .45rem;box-shadow:0 1px 2px rgba(9,30,66,.2);
display:flex;flex-direction:column;max-height:calc(100vh - 13.5rem);position:relative}
.lhead{display:flex;align-items:center;gap:.3rem;padding:.1rem .15rem .35rem;position:relative}
.lname{font-weight:600;font-size:.95rem;padding:.22rem .4rem;flex:1;overflow-wrap:anywhere}
.lcount{color:var(--mut);font-size:.86rem;padding-right:.15rem}
.lhead .dots{font-weight:700;letter-spacing:.05rem}
.lrename{flex:1;font-weight:600;padding:.22rem .4rem}
.pop{position:absolute;top:2rem;right:0;background:#fff;border-radius:8px;z-index:30;
box-shadow:0 8px 24px rgba(9,30,66,.3);padding:.3rem;min-width:11.5rem}
.pop button{display:block;width:100%;text-align:left;background:transparent;color:var(--ink);
font-weight:400;padding:.42rem .6rem;border-radius:5px}
.pop button:hover{background:var(--hov);filter:none}
.pop button.red{color:var(--reddark)}
.drop{overflow-y:auto;padding:.05rem .15rem;min-height:2.2rem}
.empty{color:var(--mut);font-size:.88rem;text-align:center;padding:.55rem 0}
.card{background:#fff;border-radius:8px;padding:.42rem .55rem .45rem;margin:0 0 .5rem;
box-shadow:0 1px 1px rgba(9,30,66,.25);cursor:pointer}
.card:hover{background:#f4f5f7}
.card[draggable=true]{cursor:grab}.card:active{cursor:grabbing}
.card .t{color:var(--ink);font-size:.94rem;overflow-wrap:anywhere}
.card.over{box-shadow:0 -3px 0 var(--blue)}
.labs{display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.28rem}
.lab{border-radius:99px;padding:.05rem .55rem;font-size:.72rem;font-weight:700}
.badges{display:flex;align-items:center;gap:.45rem;margin-top:.34rem;font-size:.8rem;color:var(--mut);min-height:1.2rem}
.due{background:#eaecf0;color:#42526e;border-radius:4px;padding:.1rem .45rem}
.due.red{background:var(--red);color:#fff;font-weight:600}
.blk{color:var(--reddark);font-weight:700}
.cmt{color:var(--mut)}
.av{margin-left:auto;width:1.55rem;height:1.55rem;border-radius:50%;color:#fff;font-size:.66rem;
font-weight:700;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}
.av.sm{width:1.4rem;height:1.4rem;font-size:.6rem;margin:0}
.badges .av{margin-left:auto}
.memrow .av,.cmtrow .av,.cmtform .av{margin:0}
.addopen{width:100%;text-align:left;background:transparent;color:#42526e;border-radius:8px;
padding:.5rem .6rem;font-weight:400}
.addopen:hover{background:var(--hov);color:var(--ink);filter:none}
.addc{margin-top:.3rem}
.addc textarea{width:100%;border:0;border-radius:8px;box-shadow:0 1px 1px rgba(9,30,66,.25);
padding:.5rem .6rem;resize:vertical}
.addc input{width:100%}
.addc .row2{display:flex;gap:.4rem;margin-top:.4rem;align-items:center}
.err{color:var(--reddark);font-size:.86rem;margin-top:.35rem}
.col.ghostcol{background:rgba(255,255,255,.24);box-shadow:none}
.col.ghostcol .openbtn{width:100%;text-align:left;background:transparent;color:#fff;font-size:.95rem;padding:.55rem .7rem}
.col.ghostcol form{background:var(--list);border-radius:12px;padding:.45rem .5rem}
footer{color:rgba(255,255,255,.85);font-size:.84rem;text-align:center;padding:0 1rem 1.4rem}
dialog{border:0;border-radius:12px;padding:1.15rem 1.3rem;width:min(44rem,94vw);
background:#f4f5f7;box-shadow:0 18px 50px rgba(0,0,0,.35);color:var(--ink)}
dialog::backdrop{background:rgba(9,30,66,.55)}
dialog h3{font-size:1.25rem;font-weight:600}
dialog label{display:block;font-size:.74rem;color:var(--mut);margin:.7rem 0 .25rem;
font-weight:700;text-transform:uppercase;letter-spacing:.04em}
dialog input,dialog textarea,dialog select{width:100%;background:#fff}
.row{display:flex;gap:.7rem}.row>div{flex:1}
#cfm{width:min(25rem,92vw);background:#fff}
#cfm p{margin:.3rem 0 1rem}
#cfm .btns{display:flex;gap:.5rem;justify-content:flex-end}
.panel{background:#fff;border-radius:12px;padding:.9rem 1.05rem;margin:0 .7rem .9rem;
box-shadow:0 1px 2px rgba(9,30,66,.25);max-width:64rem}
.panel h3{margin:.05rem 0 .55rem;font-size:1.02rem}
.act{font-size:.92rem;color:var(--ink);padding:.14rem 0}.act small{color:var(--mut)}
#summary{border-left:4px solid #f2d600}
.plate{padding:0 .7rem}
.plate .card{cursor:pointer;max-width:46rem}
.viewtitle{color:#fff;padding:.4rem .7rem 0;margin:.4rem 0}
.hide{display:none}
small.hint{color:var(--mut)}
.memrow{display:flex;align-items:center;gap:.55rem;padding:.3rem 0}
.memrow .role{color:var(--mut);font-size:.86rem}
.csec{margin-top:1.05rem;border-top:1px solid var(--line);padding-top:.75rem}
.csec h4{margin:.1rem 0 .6rem;font-size:.98rem}
.cmtrow{display:flex;gap:.55rem;margin:.6rem 0;align-items:flex-start}
.cmtrow .who{font-weight:700;font-size:.88rem}
.cmtrow .when{color:var(--mut);font-size:.8rem;font-weight:400}
.cmtrow .body{background:#fff;border-radius:8px;padding:.45rem .65rem;box-shadow:0 1px 0 rgba(9,30,66,.15);
font-size:.93rem;overflow-wrap:anywhere;margin-top:.15rem}
.cmtform{display:flex;gap:.55rem;align-items:flex-start;margin-top:.7rem}
.cmtform textarea{flex:1;resize:vertical}
#sync{position:fixed;left:.9rem;bottom:.9rem;background:rgba(23,43,77,.94);color:#fff;
padding:.4rem .85rem;border-radius:99px;font-size:.88rem;z-index:40;box-shadow:0 4px 14px rgba(9,30,66,.35)}
</style>
<header><div class=logo><span class=mark>▦</span>Sawera Boards</div>
<nav><form onsubmit="return doSearch(event)"><input id=sq placeholder="Search cards by meaning" size=22><button class=ghost>Search</button></form>
<button class=ghost id=plateBtn>My plate</button><span id=od></span>
<span id=who></span><a href="/.dodil/auth/logout">Sign out</a></nav></header>
<main>
<div class=subbar>
  <div class=boards id=boards></div>
  <form id=nb><input id=nbt placeholder="New board title" size=16>
  <button>Create board</button></form>
</div>
<div id=searchOut></div>
<div id=summary class="panel hide"></div>
<div id=view></div>
</main>
<footer>Sawera Boards — a team task board built on Dodil · DataK3 storage · Ignite hosting · Dodil Models AI · one MCP endpoint for Claude &amp; ChatGPT</footer>

<dialog id=dlg></dialog>
<dialog id=cfm></dialog>
<div id=sync class=hide>⟳ Syncing…</div>

<script>
const j=async(u,o={})=>{const r=await fetch(u,{headers:{'content-type':'application/json'},...o});
 const b=await r.json().catch(()=>({}));if(!r.ok)throw new Error(b.detail||r.status);return b};
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
let BOARDS=[],CUR=null,ME='';
const canEdit=()=>CUR&&(CUR.role==='owner'||CUR.role==='member');
const isOwner=()=>CUR&&CUR.role==='owner';

async function boot(){
  ME=(await j('/api/me')).email; who.textContent=ME;
  await loadBoards(); const od=await j('/api/overdue');
  document.getElementById('od').innerHTML=od.length?`<span class=chip style="background:var(--red)">${od.length} overdue</span>`:'';
  if(BOARDS.length)select(BOARDS[0].id);
}
async function loadBoards(){BOARDS=await j('/api/boards');
  boards.innerHTML=BOARDS.map(b=>`<button data-b="${b.id}" class="${CUR&&CUR.id===b.id?'on':''}"
   onclick="select('${b.id}')">${esc(b.title)}</button>`).join('')||'<small style="color:#fff">No boards yet — create one.</small>';}
async function select(id){CUR=await j('/api/boards/'+id);await loadBoards();render();}

/* The store can lag a moment behind a change. mutate() runs the change, then
   re-reads the board until its signature moves, so the screen never shows a
   stale state that invites a repeated click. */
function sig(){return CUR?JSON.stringify([CUR.title,CUR.role,
  CUR.lists.map(l=>l.id+':'+l.name),CUR.cards.map(c=>c.id+':'+c.title+':'+c.list_id+':'+(c.comments||0)),
  Object.keys(CUR.members||{}).length]):''}
async function mutate(fn){const before=sig();const pill=document.getElementById('sync');
  try{await fn()}catch(err){alert(err.message);throw err}
  if(!CUR)return;
  pill.classList.remove('hide');
  try{for(let i=0;i<5;i++){await select(CUR.id);if(sig()!==before)break;await sleep(600)}}
  finally{pill.classList.add('hide')}}
async function refresh(){if(CUR)await select(CUR.id)}

/* In-page dialogs (never the browser's native prompt/confirm). */
function confirmDlg(msg,okLabel){return new Promise(res=>{const d=document.getElementById('cfm');
  d.innerHTML=`<h3 style="margin:.1rem 0 .4rem;font-size:1.05rem">Are you sure?</h3><p>${esc(msg)}</p>
   <div class=btns><button class=ghost style="background:#e2e4e6;color:var(--ink)" id=cfmNo>Cancel</button>
   <button class=danger id=cfmOk>${esc(okLabel||'Delete')}</button></div>`;
  const done=v=>{d.close();res(v)};
  d.querySelector('#cfmOk').onclick=()=>done(true);
  d.querySelector('#cfmNo').onclick=()=>done(false);
  d.oncancel=()=>res(false);d.showModal();})}

const PALETTE=['#61bd4f','#f2d600','#ff9f1a','#eb5a46','#c377e0','#0079bf','#00c2e0','#51e898','#ff78cb','#344563'];
function hue(s){let h=0;for(const ch of s)h=(h*31+ch.charCodeAt(0))>>>0;return PALETTE[h%PALETTE.length]}
function labText(bg){return bg==='#f2d600'||bg==='#51e898'||bg==='#00c2e0'||bg==='#ff9f1a'?'#172b4d':'#fff'}
function initials(email){const p=(email||'').split('@')[0].split(/[._-]+/).filter(Boolean);
  return ((p[0]||'?')[0]+(p.length>1?p[1][0]:'')).toUpperCase()}
function avatar(email,sm){return email?`<span class="av${sm?' sm':''}" style="background:${hue(email)}" title="${esc(email)}">${esc(initials(email))}</span>`:''}
function cardHTML(c){const due=c.due_date?`<span class="due ${c.overdue?'red':''}">🕐 ${c.due_date}${c.overdue?' · overdue':''}</span>`:'';
  const blk=c.blocked?'<span class=blk>⛔ blocked</span>':'';
  const cmt=c.comments?`<span class=cmt>💬 ${c.comments}</span>`:'';
  const labs=c.labels.length?`<div class=labs>${c.labels.map(l=>`<span class=lab style="background:${hue(l)};color:${labText(hue(l))}">${esc(l)}</span>`).join('')}</div>`:'';
  return `<div class="card" draggable="${canEdit()}" data-id="${c.id}" data-list="${c.list_id}"
   onclick="openCard('${c.id}')">${labs}<div class=t>${esc(c.title)}</div>
   <div class=badges>${due}${blk}${cmt}${avatar(c.assignee)}</div></div>`;}
function render(){
  const v=document.getElementById('view');
  if(!CUR){v.innerHTML='';return}
  const nOv=CUR.cards.filter(c=>c.overdue).length, nBlk=CUR.cards.filter(c=>c.blocked).length;
  const stack=[CUR.owner,...CUR.members.map(m=>m.email)];
  const mem=`<div class=panel><h3>People on this board</h3>
    <div class=memrow>${avatar(CUR.owner,1)}<span>${esc(CUR.owner)}</span><span class=role>owner</span></div>
    ${CUR.members.map(m=>`<div class=memrow>${avatar(m.email,1)}<span>${esc(m.email)}</span><span class=role>${m.role}</span>
      ${isOwner()?`<button class=plain onclick="rmMember('${esc(m.email)}')">remove</button>`:''}</div>`).join('')}
    ${isOwner()?`<form onsubmit="addMember(event)" style="margin-top:.5rem;display:flex;gap:.4rem">
     <input name=em placeholder="email" size=24><select name=ro><option>member<option>viewer</select>
     <button>Add</button></form>`:''}</div>`;
  v.innerHTML=`<div class=bar><h2>${esc(CUR.title)}</h2><span class="chip role">your role: ${CUR.role}</span>
    <span class=chip>${CUR.cards.length} cards</span>
    ${nOv?`<span class=chip>🕐 ${nOv} overdue</span>`:''}
    ${nBlk?`<span class=chip>⛔ ${nBlk} blocked</span>`:''}
    <span class=stack>${stack.map(e=>avatar(e)).join('')}</span>
    <span class=sp></span>
    <button class=ghost onclick="showActivity()">Activity</button>
    <button class=ghost onclick="showSummary()">Daily summary</button>
    ${isOwner()?`<button class=ghost onclick="delBoard()">Delete board</button>`:''}</div>
   <div class=cols>${CUR.lists.map(l=>{const cs=CUR.cards.filter(c=>c.list_id===l.id);
     return `<div class=col data-list="${l.id}"><div class=lhead><span class=lname>${esc(l.name)}</span>
      <span class=lcount>${cs.length}</span>
      ${canEdit()?`<button class="plain dots" title="List actions" onclick="listMenu(this,'${l.id}')">···</button>
      <span class=popwrap></span>`:''}</div>
      <div class=drop data-list="${l.id}">${cs.map(cardHTML).join('')}${cs.length?'':'<div class=empty>No cards yet</div>'}</div>
      ${canEdit()?`<button class=addopen onclick="openComposer(this)">+ Add a card</button>
      <form class="addc hide" onsubmit="addCard(event,'${l.id}')">
       <textarea name=t rows=2 placeholder="Enter a title for this card"></textarea>
       <div class="err hide"></div>
       <div class=row2><button>Add card</button>
       <button type=button class=plain onclick="closeComposer(this)">✕</button></div></form>`:''}</div>`}).join('')}
   ${canEdit()?`<div class="col ghostcol"><button class=openbtn onclick="openComposer(this)">+ Add another list</button>
     <form class="addc hide" onsubmit="addList(event)">
     <input name=t placeholder="List name"><div class="err hide"></div>
     <div class=row2><button>Add list</button>
     <button type=button class=plain onclick="closeComposer(this)">✕</button></div></form></div>`:''}
   </div>${mem}<div id=act></div>`;
  wireDnD();
}
function listMenu(btn,lid){const wrap=btn.parentElement.querySelector('.popwrap');
  const open=wrap.innerHTML.trim();document.querySelectorAll('.popwrap').forEach(w=>w.innerHTML='');
  if(open)return;
  wrap.innerHTML=`<span class=pop><button onclick="renListStart('${lid}')">Rename list</button>
   <button class=red onclick="delList('${lid}')">Delete list</button></span>`;
  const close=e=>{if(!wrap.contains(e.target)){wrap.innerHTML='';document.removeEventListener('click',close)}};
  setTimeout(()=>document.addEventListener('click',close),0);}
function renListStart(lid){const l=CUR.lists.find(x=>x.id===lid);if(!l)return;
  document.querySelectorAll('.popwrap').forEach(w=>w.innerHTML='');
  const head=document.querySelector(`.col[data-list="${lid}"] .lhead`);if(!head)return;
  head.innerHTML=`<input class=lrename value="${esc(l.name)}"><span class=lcount></span>`;
  const inp=head.querySelector('.lrename');inp.focus();inp.select();
  let done=false;
  const save=async()=>{if(done)return;done=true;const n=inp.value.trim();
    if(n&&n!==l.name){await mutate(()=>j(`/api/boards/${CUR.id}/lists/${lid}`,{method:'PATCH',body:JSON.stringify({name:n})}))}
    else refresh()};
  inp.addEventListener('keydown',e=>{if(e.key==='Enter')save();if(e.key==='Escape'){done=true;refresh()}});
  inp.addEventListener('blur',save);}
function openComposer(btn){const host=btn.parentElement;const f=host.querySelector('.addc');btn.classList.add('hide');
  f.classList.remove('hide');const i=f.querySelector('textarea,input');if(i)i.focus()}
function closeComposer(btn){const col=btn.closest('.col');const f=btn.closest('.addc');
  f.classList.add('hide');const b=col.querySelector('.addopen,.openbtn');if(b)b.classList.remove('hide')}
function formHint(form,msg){const e=form.querySelector('.err');if(e){e.textContent=msg;e.classList.remove('hide')}}

function wireDnD(){if(!canEdit())return;let drag=null;
  document.querySelectorAll('.card').forEach(el=>{
    el.addEventListener('dragstart',e=>{drag=el.dataset.id;e.dataTransfer.effectAllowed='move'});
    el.addEventListener('dragend',()=>{drag=null;document.querySelectorAll('.over').forEach(x=>x.classList.remove('over'))});});
  document.querySelectorAll('.drop').forEach(zone=>{
    zone.addEventListener('dragover',e=>{e.preventDefault();
      const after=[...zone.querySelectorAll('.card:not([data-id="'+drag+'"])')]
        .find(el=>e.clientY<el.getBoundingClientRect().top+el.offsetHeight/2);
      zone.querySelectorAll('.over').forEach(x=>x.classList.remove('over'));
      if(after)after.classList.add('over');zone.dataset.after=after?after.dataset.id:'';});
    zone.addEventListener('drop',async e=>{e.preventDefault();if(!drag)return;
      const cards=[...zone.querySelectorAll('.card')].map(x=>x.dataset.id);
      let idx=zone.dataset.after?cards.indexOf(zone.dataset.after):cards.length;
      const from=cards.indexOf(drag);if(from>-1&&from<idx)idx--;
      const moved=drag;drag=null;
      await mutate(()=>j(`/api/cards/${moved}/move`,{method:'POST',body:JSON.stringify(
        {list_id:zone.dataset.list,index:idx})}));});});}
async function addCard(e,lid){e.preventDefault();const f=e.target;const t=f.t.value.trim();
  if(!t){formHint(f,'Enter a title for this card first.');f.t.focus();return}
  await mutate(()=>j(`/api/boards/${CUR.id}/cards`,{method:'POST',body:JSON.stringify({title:t,list_id:lid})}));}
async function addList(e){e.preventDefault();const f=e.target;const t=f.t.value.trim();
  if(!t){formHint(f,'Give the list a name first.');f.t.focus();return}
  await mutate(()=>j(`/api/boards/${CUR.id}/lists`,{method:'POST',body:JSON.stringify({name:t})}));}
async function delList(lid){document.querySelectorAll('.popwrap').forEach(w=>w.innerHTML='');
  const l=CUR.lists.find(x=>x.id===lid);
  if(!await confirmDlg(`Delete the list "${l?l.name:''}"? It must be empty — cards in it stay safe only if there are none.`,'Delete list'))return;
  await mutate(()=>j(`/api/boards/${CUR.id}/lists/${lid}`,{method:'DELETE'}));}
async function addMember(e){e.preventDefault();const f=e.target;
  await mutate(()=>j(`/api/boards/${CUR.id}/members`,{method:'POST',body:JSON.stringify({email:f.em.value,role:f.ro.value})}));}
async function rmMember(email){
  if(!await confirmDlg(`Remove ${email} from this board? They will no longer see it.`,'Remove'))return;
  await mutate(()=>j(`/api/boards/${CUR.id}/members/${encodeURIComponent(email)}`,{method:'DELETE'}));}
async function delBoard(){
  if(!await confirmDlg(`Delete the board "${CUR.title}"? Every list, card and comment on it goes too. This cannot be undone.`,'Delete board'))return;
  const id=CUR.id;CUR=null;
  await j(`/api/boards/${id}`,{method:'DELETE'});
  await loadBoards();
  if(BOARDS.length)select(BOARDS[0].id);else{document.getElementById('view').innerHTML='';}}
async function showActivity(){const a=await j(`/api/boards/${CUR.id}/activity`);
  document.getElementById('act').innerHTML=`<div class=panel><h3>Activity</h3>
   ${a.map(e=>`<div class=act>${esc(e.actor)} ${esc(e.action)} <small>${esc((e.at||'').replace('T',' ').slice(0,16))}</small></div>`).join('')||'Nothing yet.'}</div>`;
  document.getElementById('act').scrollIntoView()}
async function doSearch(e){e.preventDefault();const q=document.getElementById('sq').value.trim();if(!q)return false;
  const out=document.getElementById('searchOut');
  const bn=id=>(BOARDS.find(b=>b.id===id)||{}).title||'';
  const draw=(r,note)=>{out.innerHTML=`<div class=panel><h3>Search: ${esc(q)}</h3>${r.map(c=>
    `<div class=act><a href="#" onclick="gotoCard('${c.board_id}','${c.id}');return false">${esc(c.title)}</a>
     <small>${esc(bn(c.board_id))} · ${esc(c.list_name)}${c.score?` · meaning match ${c.score}`:' · word match'}</small></div>`).join('')||'No cards found.'}${note}</div>`};
  out.innerHTML='<div class=panel>Searching…</div>';
  try{const fast=await j('/api/search?semantic=0&q='+encodeURIComponent(q));
   draw(fast,'<div class=act><small class=hint>⟳ Searching by meaning…</small></div>');
   out.scrollIntoView();
   const full=await j('/api/search?q='+encodeURIComponent(q));
   draw(full,'');
  }catch(err){out.innerHTML=`<div class=panel>${esc(err.message)}</div>`}
  out.scrollIntoView();return false}
async function gotoCard(bid,cid){await select(bid);openCard(cid)}
async function showSummary(){const s=await j(`/api/boards/${CUR.id}/summary`,{method:'POST'});
  const el=document.getElementById('summary');el.classList.remove('hide');
  el.innerHTML='<h3>Daily summary</h3>'+esc(s.summary);el.scrollIntoView()}
async function loadComments(id){const box=document.getElementById('clist');if(!box)return;
  try{const cs=await j(`/api/cards/${id}/comments`);
   box.innerHTML=cs.map(c=>`<div class=cmtrow>${avatar(c.author,1)}<div>
     <div><span class=who>${esc(c.author)}</span> <span class=when>· ${esc((c.at||'').replace('T',' ').slice(0,16))}</span></div>
     <div class=body>${esc(c.body)}</div></div></div>`).join('')||'<small class=hint>No comments yet.</small>';
  }catch(err){box.innerHTML='<small class=hint>Comments could not be loaded.</small>'}}
async function postComment(id){const ta=document.getElementById('f_comment');const t=ta.value.trim();if(!t)return;
  try{await j(`/api/cards/${id}/comments`,{method:'POST',body:JSON.stringify({body:t})});ta.value='';
   loadComments(id);mutate(async()=>{})}catch(err){alert(err.message)}}
function openCard(id){const c=CUR.cards.find(x=>x.id===id);if(!c)return;const d=document.getElementById('dlg');
  const others=CUR.cards.filter(x=>x.id!==id);
  const dis=canEdit()?'':'disabled';
  d.innerHTML=`<form method=dialog><button class=plain style="float:right">✕</button></form>
   <h3 style="margin:.2rem 0">${esc(c.title)}</h3>
   <small class=hint>in list ${esc(c.list_name)}</small>
   <label>Title</label><input id=f_title value="${esc(c.title)}" ${dis}>
   <label>Description</label><textarea id=f_desc rows=3 ${dis}>${esc(c.description)}</textarea>
   <div class=row><div><label>List</label><select id=f_list ${dis}>
     ${CUR.lists.map(l=>`<option value="${l.id}" ${l.id===c.list_id?'selected':''}>${esc(l.name)}</option>`).join('')}</select></div>
    <div><label>Due date</label><input id=f_due type=date value="${c.due_date||''}" ${dis}></div></div>
   <div class=row><div><label>Assignee (email)</label><input id=f_asg value="${esc(c.assignee||'')}" ${dis}></div>
    <div><label>Labels (comma separated)</label><input id=f_lab value="${esc(c.labels.join(', '))}" ${dis}></div></div>
   <label>Blocked by</label><div>${others.length?others.map(o=>`<label style="display:inline-block;margin:.15rem .5rem .15rem 0;color:var(--ink);font-weight:400;text-transform:none;letter-spacing:0;font-size:.9rem">
     <input type=checkbox class=f_blk value="${o.id}" ${c.blocked_by.some(b=>b.id===o.id)?'checked':''} ${dis}> ${esc(o.title)}</label>`).join(''):'<small class=hint>No other cards.</small>'}</div>
   ${canEdit()?`<div style="margin-top:1rem;display:flex;gap:.5rem">
     <button onclick="saveCard('${id}')">Save</button>
     <button class=ghost style="background:#e2e4e6;color:var(--ink)" onclick="aiList('${id}')">AI checklist</button>
     <button class=ghost style="background:#e2e4e6;color:var(--ink)" onclick="blocking('${id}')">What is blocking this?</button>
     <button class=plain style="margin-left:auto;color:var(--reddark)" onclick="delCard('${id}')">Delete card</button></div>`:''}
   <div id=aiout style="margin-top:.8rem"></div>
   <div class=csec><h4>💬 Comments</h4><div id=clist><small class=hint>Loading…</small></div>
   ${canEdit()?`<div class=cmtform>${avatar(ME,1)}<textarea id=f_comment rows=2 placeholder="Write a comment"></textarea></div>
   <div style="margin-top:.45rem;text-align:right"><button onclick="postComment('${id}')">Comment</button></div>`
   :'<small class=hint>Viewers can read comments but not add them.</small>'}</div>`;
  d.showModal();loadComments(id);}
async function saveCard(id){const body={title:f_title.value,description:f_desc.value,
   assignee:f_asg.value,due_date:f_due.value,
   labels:f_lab.value.split(',').map(s=>s.trim()).filter(Boolean)};
  const targetList=f_list.value;
  const card=CUR.cards.find(x=>x.id===id);
  await mutate(async()=>{await j(`/api/cards/${id}`,{method:'PATCH',body:JSON.stringify(body)});
   const blockers=[...document.querySelectorAll('.f_blk:checked')].map(x=>x.value);
   await j(`/api/cards/${id}/blockers`,{method:'POST',body:JSON.stringify({blocker_ids:blockers})});
   if(targetList!==card.list_id)
     await j(`/api/cards/${id}/move`,{method:'POST',body:JSON.stringify({list_id:targetList})});});
  document.getElementById('dlg').close();}
async function delCard(id){
  if(!await confirmDlg('Delete this card? Its comments go with it.','Delete card'))return;
  document.getElementById('dlg').close();
  await mutate(()=>j(`/api/cards/${id}`,{method:'DELETE'}));}
async function aiList(id){const r=await j(`/api/cards/${id}/checklist`,{method:'POST'});
  aiout.innerHTML='<b>Suggested checklist</b><ul>'+r.suggested_checklist.map(s=>`<li>${esc(s)}</li>`).join('')+'</ul>'}
async function blocking(id){const r=await j(`/api/cards/${id}/blocking`);
  aiout.innerHTML=r.blocking.length?'<b>Blocking this card:</b><ul>'+
   r.blocking.map(b=>`<li>${esc(b.title)} <small>(${esc(b.list_name)})</small></li>`).join('')+'</ul>'
   :'<b>Nothing is blocking this card.</b>'}
document.getElementById('plateBtn').onclick=async()=>{
  const mine=await j('/api/my-cards');const v=document.getElementById('view');CUR=null;
  document.querySelectorAll('#boards button').forEach(b=>b.classList.remove('on'));
  v.innerHTML=`<h2 class=viewtitle>My plate</h2><div class=plate>${mine.map(c=>{const b=BOARDS.find(x=>x.id===c.board_id);
    return `<div class=card onclick="select('${c.board_id}')"><div class=t>${esc(c.title)}</div>
    <div class=badges><span>${esc(b?b.title:'')}</span><span>${esc(c.list_name)}</span>
    ${c.due_date?`<span class="due ${c.overdue?'red':''}">🕐 ${c.due_date}${c.overdue?' · overdue':''}</span>`:''}
    ${c.blocked?'<span class=blk>⛔ blocked</span>':''}${c.comments?`<span class=cmt>💬 ${c.comments}</span>`:''}</div></div>`}).join('')||'<p style="color:#fff">Nothing assigned to you.</p>'}</div>`};
nb.onsubmit=async e=>{e.preventDefault();const t=nbt.value.trim();if(!t)return;
  const b=await j('/api/boards',{method:'POST',body:JSON.stringify({title:t})});nbt.value='';
  await loadBoards();select(b.id)};
boot().catch(e=>{document.getElementById('view').innerHTML='<p style="color:#fff">'+esc(e.message)+'</p>'});
</script>"""