flc/crewboard

Crewboard - team task board (boards, lists, cards, roles, history, drag and drop, MCP, AI extras). FastAPI + DataK3 + appid + Models.

A remix template · 1 remixes · Remix flc/crewboard

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>Crewboard</title>
<style>
:root{
  --bg:#f4f5f8;--panel:#ffffff;--panel2:#fafbfc;--ink:#172033;--ink2:#46526a;--muted:#7a849a;--line:#e4e7ee;
  --col:#eceff4;--accent:#3d63dd;--accent2:#e8eefc;--accent-ink:#fff;--danger:#cf3a28;--danger-bg:#fdecea;
  --warn:#a86400;--warn-bg:#fff3dc;--ok:#1a7f4b;--ok-bg:#e3f5ea;--shadow:0 1px 2px rgba(23,32,51,.06),0 1px 3px rgba(23,32,51,.08);
  --shadow2:0 8px 24px rgba(23,32,51,.14);--r:10px;
}
@media (prefers-color-scheme:dark){:root{
  --bg:#0f1218;--panel:#181c25;--panel2:#1d222c;--ink:#e8ebf2;--ink2:#b5bdcc;--muted:#8a93a6;--line:#2a303c;
  --col:#141820;--accent:#6d8ff0;--accent2:#232c45;--danger:#f07563;--danger-bg:#3a1c18;--warn:#f0b35a;--warn-bg:#3a2c12;
  --ok:#5fd394;--ok-bg:#14301f;--shadow:none;--shadow2:0 10px 30px rgba(0,0,0,.5)}}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;font:14px/1.45 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
button,input,select,textarea{font:inherit;color:inherit}
button{cursor:pointer;border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:.4rem .75rem;transition:background .12s,border-color .12s}
button:hover{border-color:var(--accent)}
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}
button.ghost{border-color:transparent;background:transparent}
button.ghost:hover{background:var(--col)}
button.link{border:0;background:none;padding:0;color:var(--accent)}
button:disabled{opacity:.5;cursor:default}
input,select,textarea{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:.45rem .6rem;width:100%;outline:none}
input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent2)}
kbd{font:11px ui-monospace,monospace;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 .3rem;color:var(--muted)}
.app{display:grid;grid-template-columns:248px 1fr;height:100vh}
/* sidebar */
aside{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
.brand{display:flex;align-items:center;gap:.55rem;padding:1rem 1rem .8rem;font-weight:800;font-size:1.05rem;letter-spacing:-.01em}
.logo{width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,var(--accent),#8a5cf6);display:grid;place-items:center;color:#fff}
.nav{padding:.2rem .6rem}
.nav a,.blink{display:flex;align-items:center;gap:.55rem;padding:.45rem .55rem;border-radius:8px;color:var(--ink2);text-decoration:none;font-weight:500}
.nav a:hover,.blink:hover{background:var(--col)}
.nav a.on,.blink.on{background:var(--accent2);color:var(--accent)}
.nav .count{margin-left:auto;font-size:.72rem;background:var(--danger-bg);color:var(--danger);border-radius:99px;padding:0 .45rem;font-weight:700}
.sect{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:1rem 1.15rem .35rem;font-weight:700}
.blist{flex:1;overflow:auto;padding:0 .6rem}
.bkey{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;font-size:.62rem;font-weight:800;color:#fff;flex:none}
.blink small{margin-left:auto;color:var(--muted);font-size:.7rem}
.newb{padding:.6rem;border-top:1px solid var(--line);display:flex;gap:.4rem}
.me{display:flex;align-items:center;gap:.55rem;padding:.7rem 1rem;border-top:1px solid var(--line);font-size:.82rem;color:var(--ink2)}
.me a{margin-left:auto;color:var(--muted);font-size:.78rem;white-space:nowrap}
.me span:nth-child(2){overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* main */
main{display:flex;flex-direction:column;min-width:0;min-height:0}
.top{display:flex;align-items:center;gap:.6rem;padding:.8rem 1.2rem;border-bottom:1px solid var(--line);background:var(--panel);flex-wrap:wrap}
.top h1{font-size:1.2rem;margin:0;letter-spacing:-.01em}
.role{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:.12rem .5rem;border-radius:99px;background:var(--col);color:var(--ink2)}
.role.viewer{background:var(--warn-bg);color:var(--warn)}
.grow{flex:1}
.search{position:relative;width:min(280px,100%)}
.search input{padding-left:2rem}
.search svg{position:absolute;left:.6rem;top:50%;transform:translateY(-50%);color:var(--muted)}
.avs{display:flex}
.avs .av{margin-left:-6px;border:2px solid var(--panel)}
.filters{display:flex;gap:.4rem;align-items:center;padding:.6rem 1.2rem;flex-wrap:wrap;border-bottom:1px solid var(--line);background:var(--panel2)}
.chip{border:1px solid var(--line);background:var(--panel);border-radius:99px;padding:.22rem .65rem;font-size:.78rem;color:var(--ink2);cursor:pointer;user-select:none}
.chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
.stats{margin-left:auto;font-size:.78rem;color:var(--muted)}
.board{flex:1;overflow:auto;padding:1rem 1.2rem}
.cols{display:flex;gap:.9rem;align-items:flex-start;min-height:100%}
.col{flex:0 0 288px;background:var(--col);border-radius:12px;display:flex;flex-direction:column;max-height:100%}
.colh{display:flex;align-items:center;gap:.45rem;padding:.75rem .8rem .5rem;font-weight:700;font-size:.86rem}
.colh .n{color:var(--muted);font-weight:600}
.colh .dot{width:8px;height:8px;border-radius:50%}
.colh .menu{margin-left:auto}
.cards{padding:0 .55rem .3rem;overflow-y:auto;min-height:2.5rem;flex:1}
.col.drop{outline:2px dashed var(--accent);outline-offset:-3px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:.6rem .7rem;margin-bottom:.5rem;box-shadow:var(--shadow);cursor:pointer;transition:transform .08s,box-shadow .12s,border-color .12s;position:relative}
.card:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--line));box-shadow:var(--shadow2)}
.card[draggable=true]{cursor:grab}
.card.dragging{opacity:.35;transform:rotate(1.5deg)}
.card.gap{box-shadow:0 -3px 0 var(--accent),var(--shadow)}
.card.done .ttl{color:var(--muted);text-decoration:line-through}
.lbls{display:flex;gap:.3rem;flex-wrap:wrap;margin-bottom:.4rem}
.lbl{font-size:.68rem;font-weight:700;padding:.08rem .45rem;border-radius:5px;color:#fff;letter-spacing:.02em}
.ttl{font-weight:600;line-height:1.35}
.cid{font-size:.7rem;color:var(--muted);font-weight:600;margin-top:.15rem}
.meta{display:flex;align-items:center;gap:.4rem;margin-top:.55rem;flex-wrap:wrap}
.badge{display:inline-flex;align-items:center;gap:.25rem;font-size:.72rem;font-weight:600;padding:.1rem .4rem;border-radius:5px;color:var(--ink2);background:var(--col)}
.badge.late{background:var(--danger-bg);color:var(--danger)}
.badge.soon{background:var(--warn-bg);color:var(--warn)}
.badge.ok{background:var(--ok-bg);color:var(--ok)}
.badge.block{background:var(--warn-bg);color:var(--warn)}
.av{width:24px;height:24px;border-radius:50%;display:inline-grid;place-items:center;font-size:.62rem;font-weight:800;color:#fff;flex:none}
.meta .av{margin-left:auto}
.addc{padding:.2rem .55rem .7rem}
.addc button{width:100%;text-align:left;color:var(--muted);border:0;background:transparent}
.addc button:hover{background:var(--panel);color:var(--ink)}
.addc form{display:none}
.addc.open form{display:block}.addc.open>button{display:none}
.addc textarea{resize:none;margin-bottom:.4rem}
.newcol{flex:0 0 260px;border:2px dashed var(--line);border-radius:12px;padding:.8rem;color:var(--muted);text-align:center;cursor:pointer;background:transparent}
.empty{display:grid;place-items:center;height:100%;color:var(--muted);text-align:center;padding:2rem}
.empty h2{color:var(--ink);margin:.4rem 0}
/* dialog */
dialog{border:0;border-radius:14px;padding:0;width:min(900px,96vw);max-height:92vh;background:var(--panel);color:var(--ink);box-shadow:var(--shadow2)}
dialog::backdrop{background:rgba(10,14,22,.5);backdrop-filter:blur(2px)}
.dh{display:flex;align-items:center;gap:.5rem;padding:.9rem 1.2rem;border-bottom:1px solid var(--line)}
.dbody{display:grid;grid-template-columns:1fr 240px;gap:1.2rem;padding:1.1rem 1.2rem;overflow:auto;max-height:calc(92vh - 60px)}
.dbody.one{grid-template-columns:1fr}
.ttlin{font-size:1.25rem;font-weight:700;border-color:transparent;padding:.3rem .4rem;margin-left:-.4rem}
.ttlin:hover{border-color:var(--line)}
h4{margin:1.1rem 0 .45rem;font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}
label.f{font-size:.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;display:block;margin:.7rem 0 .25rem}
.side .row{display:flex;gap:.4rem}
.check{display:flex;align-items:center;gap:.5rem;padding:.25rem 0}
.check input{width:auto}
.prog{height:6px;border-radius:99px;background:var(--col);overflow:hidden;margin:.3rem 0 .4rem}
.prog i{display:block;height:100%;background:var(--ok);border-radius:99px}
.tl{list-style:none;margin:0;padding:0}
.tl li{display:flex;gap:.6rem;padding:.45rem 0;font-size:.85rem;border-bottom:1px solid var(--line)}
.tl .when{color:var(--muted);font-size:.75rem;white-space:nowrap}
.ch{color:var(--muted);font-size:.8rem}
.ai{background:var(--accent2);border-radius:10px;padding:.7rem .8rem;margin-top:.5rem}
.toast{position:fixed;bottom:1.2rem;left:50%;transform:translateX(-50%) translateY(200%);visibility:hidden;background:var(--ink);color:var(--panel);padding:.55rem 1rem;border-radius:9px;transition:transform .2s;z-index:20;font-weight:500}
.toast.show{transform:translateX(-50%) translateY(0);visibility:visible}
.toast.err{background:var(--danger);color:#fff}
.list{list-style:none;padding:0;margin:0}
.list li{display:flex;align-items:center;gap:.6rem;padding:.6rem .2rem;border-bottom:1px solid var(--line)}
.plate{padding:1.2rem;overflow:auto}
.plate h3{display:flex;align-items:center;gap:.5rem;margin:1.2rem 0 .5rem;font-size:.9rem}
.prow{display:flex;align-items:center;gap:.7rem;padding:.6rem .8rem;background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:.45rem;cursor:pointer}
.prow:hover{border-color:var(--accent)}
.prow .ttl{flex:1}
.hint{color:var(--muted);font-size:.78rem}
.menu-btn{display:none}
@media (max-width:820px){
  .app{grid-template-columns:1fr}
  aside{position:fixed;inset:0 auto 0 0;width:260px;z-index:15;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow2)}
  aside.open{transform:none}
  .menu-btn{display:inline-block}
  .col{flex-basis:84vw;scroll-snap-align:start}
  .board{scroll-snap-type:x mandatory}
  .dbody{grid-template-columns:1fr}
  .search{width:100%;order:9}
}
</style>
</head>
<body>
<div class="app">
  <aside id="side">
    <div class="brand"><div class="logo"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="4" width="5" height="14" rx="1.5"/><rect x="10" y="4" width="5" height="9" rx="1.5"/><rect x="17" y="4" width="4" height="11" rx="1.5"/></svg></div>Crewboard</div>
    <div class="nav">
      <a href="#mine" id="nav-mine"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg>My work</a>
      <a href="#overdue" id="nav-overdue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>Overdue<span class="count" id="odc" hidden></span></a>
    </div>
    <div class="sect">Boards</div>
    <div class="blist" id="boards"></div>
    <form class="newb" onsubmit="newBoard(event)"><input id="nbt" placeholder="New board name" maxlength="80"><button class="primary" title="Create board">+</button></form>
    <div class="me"><span class="av" id="meav"></span><span id="who"></span><a href="/.dodil/auth/logout">Sign out</a></div>
  </aside>
  <main id="main"></main>
</div>
<dialog id="modal"></dialog>
<div class="toast" id="toast"></div>
<script>
const S={board:null,boards:[],me:'',filter:{who:'',label:'',late:false,q:''}};
const PAL=['#3d63dd','#0f9d76','#d9480f','#ae3ec9','#1c7ed6','#e8590c','#2f9e44','#c2255c','#5f3dc4','#0b7285'];
const hue=s=>{let h=0;for(const c of String(s))h=(h*31+c.charCodeAt(0))>>>0;return PAL[h%PAL.length]};
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const ini=e=>esc((e||'?').split('@')[0].split(/[._-]+/).filter(Boolean).map(x=>x[0]).join('').slice(0,2).toUpperCase()||'?');
const av=(e,size)=>`<span class="av" title="${esc(e)}" style="background:${hue(e)}${size?`;width:${size}px;height:${size}px`:''}">${ini(e)}</span>`;
const $=id=>document.getElementById(id);
function toast(m,err){const t=$('toast');t.textContent=m;t.className='toast show'+(err?' err':'');clearTimeout(toast.t);toast.t=setTimeout(()=>t.className='toast',3200)}
async function api(url,opt={}){
  const r=await fetch(url,{...opt,headers:{'content-type':'application/json',...(opt.headers||{})},body:opt.body&&JSON.stringify(opt.body)});
  if(r.status===401){location.href='/.dodil/auth/login?return_to=/';throw 0}
  const d=r.headers.get('content-type')?.includes('json')?await r.json():null;
  if(!r.ok){toast(d?.detail||('Error '+r.status),true);throw d}
  return d;
}
const canEdit=()=>S.board&&S.board.role!=='viewer';
const isOwner=()=>S.board&&S.board.role==='owner';
const today=()=>new Date().toISOString().slice(0,10);
function due(c){
  if(!c.due_date)return'';
  const d=Math.round((new Date(c.due_date)-new Date(today()))/864e5);
  const txt=new Date(c.due_date+'T00:00').toLocaleDateString(undefined,{month:'short',day:'numeric'});
  const cls=c.list_name===S.board?.done_list?'ok':c.overdue?'late':d<=2?'soon':'';
  return `<span class="badge ${cls}" title="Due ${esc(c.due_date)}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>${c.overdue?'Overdue · ':''}${esc(txt)}</span>`;
}
function checklist(desc){const all=(desc||'').match(/^- \[( |x)\]/gim)||[];return{n:all.length,done:all.filter(x=>/x/i.test(x)).length}}

/* ---------- sidebar ---------- */
async function loadBoards(){
  S.boards=await api('/api/boards');
  $('boards').innerHTML=S.boards.map(b=>`<a class="blink ${S.board?.id===b.id?'on':''}" href="#b/${esc(b.id)}"><span class="bkey" style="background:${hue(b.id)}">${esc(b.id.slice(0,3))}</span>${esc(b.title)}<small>${esc(b.role)}</small></a>`).join('')||'<p class="hint" style="padding:0 .6rem">No boards yet. Create one below.</p>';
  ['mine','overdue'].forEach(k=>$('nav-'+k).classList.toggle('on',location.hash==='#'+k));
  api('/api/overdue').then(o=>{const c=$('odc');c.hidden=!o.length;c.textContent=o.length}).catch(()=>{});
}
async function newBoard(e){e.preventDefault();const t=nbt.value.trim();if(!t)return;const b=await api('/api/boards',{method:'POST',body:{title:t}});nbt.value='';toast('Board created');location.hash='b/'+b.id}

/* ---------- board ---------- */
async function showBoard(id,keep){
  S.board=await api('/api/boards/'+encodeURIComponent(id));
  if(!keep)S.filter={who:'',label:'',late:false,q:''};
  loadBoards();renderBoard();
}
function visible(c){const f=S.filter;
  if(f.who&&c.assignee!==f.who)return false;
  if(f.label&&!c.labels.includes(f.label))return false;
  if(f.late&&!c.overdue)return false;
  if(f.q&&!(c.title+' '+c.description+' '+c.labels.join(' ')+' '+c.id).toLowerCase().includes(f.q))return false;
  return true}
function renderBoard(){
  const b=S.board,ed=canEdit(),f=S.filter;
  const labels=[...new Set(b.cards.flatMap(c=>c.labels))].sort();
  const shown=b.cards.filter(visible);
  const late=b.cards.filter(c=>c.overdue).length,blocked=b.cards.filter(c=>c.blocked).length;
  main.innerHTML=`<div class="top"><button class="ghost menu-btn" onclick="side.classList.toggle('open')">☰</button>
    <span class="bkey" style="background:${hue(b.id)};width:30px;height:30px">${esc(b.id.slice(0,3))}</span><h1>${esc(b.title)}</h1>
    <span class="role ${esc(b.role)}">${esc(b.role)}</span><span class="grow"></span>
    <label class="search"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg><input id="q" placeholder="Filter cards  /" value="${esc(f.q)}" oninput="S.filter.q=this.value.toLowerCase();drawCols()"></label>
    <div class="avs" title="People">${b.people.slice(0,5).map(p=>av(p.email,28)).join('')}</div>
    <button onclick="people()">Share</button><button onclick="activity()">Activity</button><button class="primary" onclick="summary()">✦ Daily summary</button></div>
  <div class="filters">
    <span class="chip ${f.who===S.me?'on':''}" onclick="S.filter.who=S.filter.who===S.me?'':S.me;renderBoard()">Assigned to me</span>
    <span class="chip ${f.late?'on':''}" onclick="S.filter.late=!S.filter.late;renderBoard()">Overdue</span>
    ${labels.map(l=>`<span class="chip ${f.label===l?'on':''}" onclick="S.filter.label=S.filter.label==='${esc(l)}'?'':'${esc(l)}';renderBoard()" style="${f.label===l?`background:${hue(l)};border-color:${hue(l)}`:''}">${esc(l)}</span>`).join('')}
    <span class="stats">${b.cards.length} cards · <b style="color:var(--danger)">${late}</b> overdue · ${blocked} blocked${ed?'':' · read-only'}</span></div>
  <div class="board"><div class="cols" id="cols"></div></div>`;
  drawCols();
}
function drawCols(){
  const b=S.board,ed=canEdit();const shown=b.cards.filter(visible);
  $('cols').innerHTML=b.lists.map((l,i)=>{const cs=shown.filter(c=>c.list_name===l);const done=l===b.done_list;
    return `<section class="col" data-list="${esc(l)}"><div class="colh"><span class="dot" style="background:${done?'var(--ok)':PAL[i%PAL.length]}"></span>${esc(l)}<span class="n">${cs.length}</span>${done?'<span class="badge ok" style="margin-left:.3rem">done</span>':''}
    ${isOwner()?`<button class="ghost menu" title="List options" onclick="listMenu('${esc(l)}')">⋯</button>`:''}</div>
    <div class="cards">${cs.map(tile).join('')}</div>
    ${ed?`<div class="addc"><button onclick="openAdd(this)">+ Add a card</button><form onsubmit="addCard(event,'${esc(l)}')"><textarea name="t" rows="2" placeholder="Card title… (Enter to add)" maxlength="200" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.form.requestSubmit()}if(event.key==='Escape')this.closest('.addc').classList.remove('open')"></textarea><div style="display:flex;gap:.4rem"><button class="primary">Add card</button><button type="button" class="ghost" onclick="this.closest('.addc').classList.remove('open')">Cancel</button></div></form></div>`:''}</section>`}).join('')
   +(isOwner()?`<button class="newcol" onclick="addList()">+ Add a list</button>`:'');
  if(ed)wireDnD();
}
function tile(c){
  const cl=checklist(c.description);const done=c.list_name===S.board.done_list;
  return `<div class="card ${done?'done':''}" draggable="${canEdit()}" data-id="${esc(c.id)}" onclick="openCard('${esc(c.id)}')">
   ${c.labels.length?`<div class="lbls">${c.labels.map(x=>`<span class="lbl" style="background:${hue(x)}">${esc(x)}</span>`).join('')}</div>`:''}
   <div class="ttl">${esc(c.title)}</div><div class="cid">${esc(c.id)}</div>
   <div class="meta">${due(c)}${cl.n?`<span class="badge ${cl.done===cl.n?'ok':''}">☑ ${cl.done}/${cl.n}</span>`:''}
   ${c.description&&!cl.n?'<span class="badge" title="Has a description">≡</span>':''}
   ${c.blocked?'<span class="badge block" title="Waiting for another card">⛔ blocked</span>':''}
   ${c.assignee?av(c.assignee):''}</div></div>`;
}
function openAdd(btn){document.querySelectorAll('.addc.open').forEach(x=>x.classList.remove('open'));const a=btn.parentElement;a.classList.add('open');a.querySelector('textarea').focus()}
async function addCard(e,l){e.preventDefault();const t=e.target.t.value.trim();if(!t)return;
  await api(`/api/boards/${S.board.id}/cards`,{method:'POST',body:{title:t,list_name:l}});await showBoard(S.board.id,true);
  const a=[...document.querySelectorAll('.col')].find(c=>c.dataset.list===l)?.querySelector('.addc');if(a){a.classList.add('open');a.querySelector('textarea').focus()}}

function wireDnD(){
  let dragId=null;const clear=()=>document.querySelectorAll('.drop,.gap').forEach(x=>x.classList.remove('drop','gap'));
  document.querySelectorAll('.card[draggable=true]').forEach(el=>{
    el.addEventListener('dragstart',e=>{dragId=el.dataset.id;el.classList.add('dragging');e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',dragId)});
    el.addEventListener('dragend',()=>{el.classList.remove('dragging');clear()});
  });
  const below=(col,y)=>[...col.querySelectorAll('.card:not(.dragging)')].find(c=>{const r=c.getBoundingClientRect();return y<r.top+r.height/2});
  document.querySelectorAll('.col').forEach(col=>{
    col.addEventListener('dragover',e=>{e.preventDefault();clear();col.classList.add('drop');const b=below(col,e.clientY);if(b)b.classList.add('gap')});
    col.addEventListener('dragleave',e=>{if(!col.contains(e.relatedTarget))col.classList.remove('drop')});
    col.addEventListener('drop',async e=>{e.preventDefault();const b=below(col,e.clientY);clear();if(!dragId)return;
      const before=b&&b.dataset.id!==dragId?b.dataset.id:null;
      // optimistic move, then confirm with the server
      const card=S.board.cards.find(c=>c.id===dragId);const from=card.list_name;
      try{const r=await api(`/api/cards/${dragId}/move`,{method:'POST',body:{list_name:col.dataset.list,before}});
        if(from!==col.dataset.list)toast(`${dragId} → ${col.dataset.list}`)}finally{showBoard(S.board.id,true)}});
  });
}

/* ---------- card dialog ---------- */
function dlg(html,one){modal.innerHTML=html;modal.querySelector('.dbody')?.classList.toggle('one',!!one);if(!modal.open)modal.showModal()}
modal.addEventListener('click',e=>{if(e.target===modal)modal.close()});
const head=(t,extra='')=>`<div class="dh">${t}<span class="grow"></span>${extra}<button class="ghost" onclick="modal.close()" title="Close (Esc)">✕</button></div>`;
async function openCard(id){
  const c=await api('/api/cards/'+id);if(!S.board||S.board.id!==c.board_id)S.board=await api('/api/boards/'+c.board_id);
  const ed=canEdit(),b=S.board,dis=ed?'':'disabled';const cl=checklist(c.description);
  const workers=b.people.filter(p=>p.role!=='viewer');
  const descText=(c.description||'').split('\n').filter(l=>!/^- \[( |x)\]/i.test(l)).join('\n').trim();
  const items=(c.description||'').split('\n').map((l,i)=>({l,i})).filter(x=>/^- \[( |x)\]/i.test(x.l));
  dlg(head(`<span class="bkey" style="background:${hue(b.id)}">${esc(b.id.slice(0,3))}</span><b>${esc(c.id)}</b><span class="hint">in ${esc(c.list_name)} · v${c.version}</span>${c.overdue?'<span class="badge late">Overdue</span>':''}${c.blocked?'<span class="badge block">Blocked</span>':''}`)+
  `<div class="dbody"><div>
    <input class="ttlin" id="ft" value="${esc(c.title)}" ${dis} maxlength="200">
    ${c.labels.length?`<div class="lbls" style="margin-top:.4rem">${c.labels.map(x=>`<span class="lbl" style="background:${hue(x)}">${esc(x)}</span>`).join('')}</div>`:''}
    <label class="f">Description</label><textarea id="fd" rows="5" ${dis} placeholder="${ed?'Add more detail…':'No description'}">${esc(descText)}</textarea>
    ${items.length?`<h4>Checklist · ${cl.done}/${cl.n}</h4><div class="prog"><i style="width:${cl.n?Math.round(100*cl.done/cl.n):0}%"></i></div>
      ${items.map(x=>`<label class="check"><input type="checkbox" ${/x/i.test(x.l.slice(3,4))?'checked':''} ${dis} onchange="toggleItem('${esc(c.id)}',${x.i},this.checked)"><span>${esc(x.l.slice(6))}</span></label>`).join('')}`:''}
    <h4>✦ AI</h4><div class="row" style="display:flex;gap:.4rem;flex-wrap:wrap"><button onclick="suggest('${esc(c.id)}')">Suggest a checklist</button><button onclick="similar('${esc(c.id)}')">Find similar cards</button></div><div id="ai"></div>
    <h4>Waiting for</h4>${c.waiting_for.length?`<ul class="list">${c.waiting_for.map(w=>`<li><span class="badge block">⛔</span><button class="link" onclick="openCard('${esc(w.id)}')">${esc(w.id)}</button> ${esc(w.title)} <span class="hint">${esc(w.list_name)}</span></li>`).join('')}</ul>`:'<p class="hint">Nothing open blocks this card.</p>'}
    <h4>History</h4><ul class="tl">${c.history.map(h=>`<li>${av(h.actor)}<div style="flex:1"><b>${esc(h.actor.split('@')[0])}</b> ${esc(h.action)} ${changes(h.changes)}</div><span class="when">${when(h.at)}</span></li>`).join('')||'<li class="hint">No changes yet.</li>'}</ul>
  </div><div class="side">
    <label class="f">List</label><select id="fl" ${dis}>${b.lists.map(l=>`<option ${l===c.list_name?'selected':''}>${esc(l)}</option>`).join('')}</select>
    <label class="f">Assignee</label><select id="fa" ${dis}><option value="">Nobody</option>${workers.map(p=>`<option ${p.email===c.assignee?'selected':''} value="${esc(p.email)}">${esc(p.email)}</option>`).join('')}</select>
    <label class="f">Due date</label><input type="date" id="fdue" value="${esc(c.due_date||'')}" ${dis}>
    <label class="f">Labels</label><input id="flb" value="${esc(c.labels.join(', '))}" ${dis} placeholder="design, urgent">
    <label class="f">Blocked by</label>${c.blocked_by.map(x=>`<div class="row" style="align-items:center;margin-bottom:.3rem"><span class="badge">${esc(x)}</span>${ed?`<button class="link" onclick="unblock('${esc(c.id)}','${esc(x)}')">remove</button>`:''}</div>`).join('')}
    ${ed?`<form class="row" onsubmit="block(event,'${esc(c.id)}')"><input name="b" placeholder="${esc(b.id)}-3"><button>Add</button></form>`:''}
    ${ed?`<div style="margin-top:1.2rem;display:grid;gap:.45rem"><button class="primary" onclick="saveCard('${esc(c.id)}',${c.version})">Save changes</button><button onclick="archive('${esc(c.id)}')">Archive card</button></div>`:'<p class="hint" style="margin-top:1rem">You are a viewer: read-only.</p>'}
  </div></div>`);
}
function when(at){if(!at)return'';const d=new Date(at.endsWith('Z')||at.includes('+')?at:at+'Z');const s=(Date.now()-d)/1000;
  return s<60?'just now':s<3600?Math.round(s/60)+' min ago':s<86400?Math.round(s/3600)+' h ago':d.toLocaleDateString()}
function changes(ch){if(!ch)return'';return '<div class="ch">'+Object.entries(ch).map(([k,[a,b]])=>`${esc(k)}: <s>${esc(fmt(a))}</s> → ${esc(fmt(b))}`).join(' · ')+'</div>'}
const fmt=v=>v===null||v===''||v===undefined?'—':Array.isArray(v)?(v.join(', ')||'—'):String(v).length>48?String(v).slice(0,48)+'…':v;
async function saveCard(id,v){
  const cur=S.board.cards.find(c=>c.id===id)||{};const items=(cur.description||'').split('\n').filter(l=>/^- \[( |x)\]/i.test(l));
  const desc=[ $('fd').value.trim(), items.join('\n') ].filter(Boolean).join('\n\n');
  const body={title:$('ft').value,description:desc,assignee:$('fa').value||null,due_date:$('fdue').value||null,labels:$('flb').value.split(',').map(x=>x.trim()).filter(Boolean)};
  await api('/api/cards/'+id,{method:'PATCH',body,headers:{'If-Match':String(v)}});
  if($('fl').value!==cur.list_name)await api(`/api/cards/${id}/move`,{method:'POST',body:{list_name:$('fl').value}});
  modal.close();toast('Saved');showBoard(S.board.id,true)}
async function toggleItem(id,line,on){const c=S.board.cards.find(x=>x.id===id)||await api('/api/cards/'+id);
  const lines=(c.description||'').split('\n');lines[line]=lines[line].replace(/^- \[( |x)\]/i,on?'- [x]':'- [ ]');
  await api('/api/cards/'+id,{method:'PATCH',body:{description:lines.join('\n')},headers:{'If-Match':String(c.version)}});
  await showBoard(S.board.id,true);openCard(id)}
async function archive(id){await api(`/api/cards/${id}/archive`,{method:'POST'});modal.close();toast(id+' archived');showBoard(S.board.id,true)}
async function block(e,id){e.preventDefault();await api(`/api/cards/${id}/blockers`,{method:'POST',body:{blocked_by:e.target.b.value}});await showBoard(S.board.id,true);openCard(id)}
async function unblock(id,x){await api(`/api/cards/${id}/blockers/${encodeURIComponent(x)}`,{method:'DELETE'});await showBoard(S.board.id,true);openCard(id)}
async function suggest(id){$('ai').innerHTML='<div class="ai hint">Dodil Models is thinking…</div>';const d=await api(`/api/cards/${id}/checklist`,{method:'POST'});
  $('ai').innerHTML=`<div class="ai"><div class="hint" style="margin-bottom:.3rem">A suggestion. Tick what to keep.</div>${d.suggested_checklist.map(x=>`<label class="check"><input type="checkbox" checked value="${esc(x)}"><span>${esc(x)}</span></label>`).join('')}
  ${canEdit()?`<button class="primary" style="margin-top:.4rem" onclick="applyList('${esc(id)}')">Add to card</button>`:''}</div>`}
async function applyList(id){const items=[...$('ai').querySelectorAll('input:checked')].map(x=>x.value);await api(`/api/cards/${id}/checklist/apply`,{method:'POST',body:{items}});await showBoard(S.board.id,true);openCard(id);toast('Checklist added')}
async function similar(id){$('ai').innerHTML='<div class="ai hint">Comparing meanings…</div>';const d=await api(`/api/cards/${id}/similar`);
  $('ai').innerHTML=`<div class="ai">${d.similar.length?d.similar.map(s=>`<div class="check"><span class="badge">${Math.round(s.score*100)}%</span><button class="link" onclick="openCard('${esc(s.id)}')">${esc(s.id)}</button> ${esc(s.title)}</div>`).join(''):'<span class="hint">No other cards yet.</span>'}</div>`}

/* ---------- board dialogs ---------- */
async function people(){const b=S.board;
  dlg(head(`<b>Share “${esc(b.title)}”</b>`)+`<div class="dbody one"><div>
  ${isOwner()?`<form class="row" style="display:flex;gap:.4rem" onsubmit="invite(event)"><input name="e" type="email" placeholder="name@company.com" required><select name="r" style="width:auto"><option value="member">Member: can edit</option><option value="viewer">Viewer: read only</option></select><button class="primary">Invite</button></form>
  <p class="hint">They sign in with this email on the board's sign-in page and see only this board.</p>`:'<p class="hint">Only the owner manages people.</p>'}
  <ul class="list">${b.people.map(p=>`<li>${av(p.email,30)}<div style="flex:1">${esc(p.email)}${p.email===S.me?' <span class="hint">(you)</span>':''}</div>
   ${isOwner()&&p.role!=='owner'?`<select style="width:auto" onchange="setRole('${esc(p.email)}',this.value)"><option ${p.role==='member'?'selected':''}>member</option><option ${p.role==='viewer'?'selected':''}>viewer</option></select><button class="ghost" title="Remove" onclick="removeMember('${esc(p.email)}')">✕</button>`:`<span class="role ${esc(p.role)}">${esc(p.role)}</span>`}</li>`).join('')}</ul></div></div>`)}
async function invite(e){e.preventDefault();await api(`/api/boards/${S.board.id}/members`,{method:'POST',body:{email:e.target.e.value,role:e.target.r.value}});await showBoard(S.board.id,true);people();toast('Invited')}
async function setRole(m,r){await api(`/api/boards/${S.board.id}/members`,{method:'POST',body:{email:m,role:r}});await showBoard(S.board.id,true);people();toast(m+' is now '+r)}
async function removeMember(m){if(!confirm('Remove '+m+'? Their cards become unassigned.'))return;await api(`/api/boards/${S.board.id}/members/${encodeURIComponent(m)}`,{method:'DELETE'});await showBoard(S.board.id,true);people()}
async function addList(){const n=prompt('Name of the new list');if(!n)return;await api(`/api/boards/${S.board.id}/lists`,{method:'POST',body:{name:n}});showBoard(S.board.id,true)}
async function listMenu(l){const n=prompt(`Rename “${l}” (leave empty to delete the list; it must be empty)`,l);if(n===null||n===l)return;
  if(n.trim()==='')await api(`/api/boards/${S.board.id}/lists/${encodeURIComponent(l)}`,{method:'DELETE'});
  else await api(`/api/boards/${S.board.id}/lists/${encodeURIComponent(l)}`,{method:'PATCH',body:{new:n}});showBoard(S.board.id,true)}
async function activity(){const a=await api(`/api/boards/${S.board.id}/activity`);
  dlg(head('<b>Activity</b>')+`<div class="dbody one"><ul class="tl">${a.map(h=>`<li>${av(h.actor)}<div style="flex:1"><b>${esc(h.actor.split('@')[0])}</b> ${esc(h.action)} ${h.card_id?`<button class="link" onclick="openCard('${esc(h.card_id)}')">${esc(h.card_id)}</button>`:''}${changes(h.changes)}</div><span class="when">${when(h.at)}</span></li>`).join('')||'<li class="hint">Nothing yet.</li>'}</ul></div>`)}
async function summary(){dlg(head('<b>✦ Daily summary</b>')+'<div class="dbody one"><p class="hint">Dodil Models is reading today’s changes…</p></div>');const s=await api(`/api/boards/${S.board.id}/summary`);
  dlg(head(`<b>✦ Daily summary</b><span class="hint">${esc(s.date)}</span>`)+`<div class="dbody one"><div>
  <div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.8rem">${Object.entries(s.counts).map(([k,v])=>`<span class="badge">${esc(k)} <b>${v}</b></span>`).join('')}<span class="badge late">overdue <b>${s.overdue.length}</b></span><span class="badge block">blocked <b>${s.blocked.length}</b></span><span class="badge">${s.changes_last_24h} changes / 24 h</span></div>
  <p style="white-space:pre-wrap;font-size:1rem;line-height:1.6">${esc(s.summary)}</p><p class="hint">Written by Dodil Models from this board’s own data. It changes nothing.</p></div></div>`)}

/* ---------- my work / overdue / search ---------- */
function prow(i){return `<div class="prow" onclick="location.hash='c/${esc(i.id)}'"><span class="bkey" style="background:${hue(i.id.split('-')[0])}">${esc(i.id.split('-')[0].slice(0,3))}</span><div class="ttl">${esc(i.title)}<div class="cid">${esc(i.id)} · ${esc(i.board||'')} · ${esc(i.list_name||'')}</div></div>${i.days_late?`<span class="badge late">${i.days_late} d late</span>`:i.due_date?`<span class="badge">${esc(i.due_date)}</span>`:''}${i.assignee?av(i.assignee):''}</div>`}
const group=(t,items,cls)=>`<h3>${t} <span class="badge ${cls||''}">${items.length}</span></h3>${items.map(prow).join('')||'<p class="hint">Nothing here.</p>'}`;
function shell(title,body){main.innerHTML=`<div class="top"><button class="ghost menu-btn" onclick="side.classList.toggle('open')">☰</button><h1>${title}</h1></div><div class="plate">${body}</div>`}
async function route(){
  side.classList.remove('open');if(modal.open)modal.close();
  const h=decodeURIComponent(location.hash.slice(1));
  if(h.startsWith('b/'))return showBoard(h.slice(2));
  if(h.startsWith('c/')){const id=h.slice(2);await showBoard(id.split('-')[0]);return openCard(id)}
  S.board=null;loadBoards();
  if(h==='mine'){const p=await api('/api/my-cards');shell('My work',`<p class="hint">Your unfinished cards on every board · ${esc(p.window)}</p>`+group('Overdue',p.overdue,'late')+group('Due this week',p.due_soon,'soon')+group('Later',p.later)+group('No due date',p.no_due_date));return}
  if(h==='overdue'){const o=await api('/api/overdue');shell('Overdue',`<p class="hint">Unfinished cards past their due date on boards you can see.</p>`+group('Overdue',o,'late'));return}
  main.innerHTML=`<div class="top"><button class="ghost menu-btn" onclick="side.classList.toggle('open')">☰</button></div><div class="empty"><div><div class="logo" style="width:56px;height:56px;margin:auto;border-radius:16px"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="4" width="5" height="14" rx="1.5"/><rect x="10" y="4" width="5" height="9" rx="1.5"/><rect x="17" y="4" width="4" height="11" rx="1.5"/></svg></div>
   <h2>Welcome to Crewboard</h2><p>Pick a board on the left, or create one.<br>Shortcuts: <kbd>/</kbd> filter · <kbd>n</kbd> new card · <kbd>Esc</kbd> close</p></div></div>`;
}
document.addEventListener('keydown',e=>{if(['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)||modal.open)return;
  if(e.key==='/'&&$('q')){e.preventDefault();$('q').focus()}
  if(e.key==='n'&&canEdit()){e.preventDefault();const b=document.querySelector('.addc>button');if(b)openAdd(b)}});
window.addEventListener('hashchange',route);
api('/api/me').then(m=>{S.me=m.email;$('who').textContent=m.email;$('meav').outerHTML=av(m.email,26);route()});
</script>
</body>
</html>