rahulpawar/taskdeck
Lanebook, a team task board (boards, lists, cards, roles, history) you run from the web or from Claude/ChatGPT over MCP. FastAPI + DataK3 + appid + Models.
A remix template · 0 remixes · Remix rahulpawar/taskdeck
app/boards/doors/page.py
"""The board page: one self-contained HTML file (no build step, no outside assets)."""
PAGE = r'''<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lanebook</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%235b4cf0'/%3E%3Crect x='7' y='8' width='5' height='16' rx='2' fill='white'/%3E%3Crect x='14' y='8' width='5' height='11' rx='2' fill='white' opacity='.8'/%3E%3Crect x='21' y='8' width='5' height='7' rx='2' fill='white' opacity='.6'/%3E%3C/svg%3E">
<style>
:root{--bg:#f5f6fa;--panel:#fff;--ink:#16181d;--mute:#6b7280;--line:#e5e7eb;--accent:#5b4cf0;--accent2:#ede9fe;--red:#dc2626;--redbg:#fee2e2;--green:#059669;--col:#eef0f5;--shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08)}
*{box-sizing:border-box}body{margin:0;font:14px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:var(--ink);background:var(--bg);height:100vh;display:flex}
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:8px;padding:6px 12px;color:var(--ink)}
button:hover{border-color:#c7cbd4}button.primary{background:var(--accent);border-color:var(--accent);color:#fff}button.primary:hover{filter:brightness(1.07)}
button.ghost{border-color:transparent;background:transparent;color:var(--mute)}button.ghost:hover{background:#0000000a;color:var(--ink)}
button.danger{color:var(--red)}input,textarea,select{font:inherit;border:1px solid var(--line);border-radius:8px;padding:7px 10px;width:100%;background:#fff;color:var(--ink)}
input:focus,textarea:focus,select:focus{outline:2px solid #c4b5fd;border-color:var(--accent)}
aside{width:248px;background:#14121f;color:#d9d6f5;display:flex;flex-direction:column;padding:16px 12px;gap:4px;flex:none}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:17px;color:#fff;padding:2px 8px 14px}
.brand i{width:26px;height:26px;border-radius:7px;background:var(--accent);display:grid;place-items:center;font-style:normal;font-size:14px}
aside h4{margin:14px 8px 4px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#8a85b3;font-weight:600}
.nav{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:7px;color:#d9d6f5;cursor:pointer;user-select:none}
.nav:hover{background:#ffffff12}.nav.on{background:#ffffff1f;color:#fff}.nav small{color:#8a85b3;font-size:11px}
.pill{font-size:11px;padding:1px 7px;border-radius:99px;background:var(--red);color:#fff;font-weight:600}
aside form{display:flex;gap:6px;margin:6px 4px}aside form input{background:#ffffff14;border-color:#ffffff22;color:#fff;padding:6px 8px}
.me{margin-top:auto;padding:10px 8px 0;border-top:1px solid #ffffff1a;font-size:12px;color:#8a85b3;display:flex;justify-content:space-between;align-items:center}.me a{color:#c4b5fd}
main{flex:1;display:flex;flex-direction:column;min-width:0}
header{display:flex;align-items:center;gap:12px;padding:14px 22px;background:var(--panel);border-bottom:1px solid var(--line);flex-wrap:wrap}
header h1{font-size:19px;margin:0;font-weight:700}.role{font-size:11px;padding:2px 8px;border-radius:99px;background:var(--accent2);color:var(--accent);font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.spacer{flex:1}.avs{display:flex}.av{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-size:11px;font-weight:700;color:#fff;border:2px solid #fff;margin-left:-6px;flex:none}
.avs .av:first-child{margin-left:0}.av.sm{width:22px;height:22px;font-size:10px;margin:0;border:0}
.filters{display:flex;gap:6px;padding:10px 22px 0;flex-wrap:wrap;align-items:center}.chip{border-radius:99px;padding:4px 11px;font-size:12px;border:1px solid var(--line);background:#fff;cursor:pointer}.chip.on{background:var(--ink);color:#fff;border-color:var(--ink)}
.filters input{width:220px;padding:5px 10px;border-radius:99px;font-size:13px}
.board{flex:1;display:flex;gap:14px;padding:14px 22px 22px;overflow-x:auto;align-items:flex-start}
.col{background:var(--col);border-radius:12px;width:276px;flex:none;display:flex;flex-direction:column;max-height:100%}
.colh{display:flex;align-items:center;gap:8px;padding:11px 12px 6px;font-weight:650}.colh .n{color:var(--mute);font-weight:500;font-size:12px}.colh .done{font-size:10px;color:var(--green);border:1px solid #a7f3d0;border-radius:99px;padding:0 6px;font-weight:600}
.cards{padding:4px 8px 6px;min-height:20px;overflow-y:auto;display:flex;flex-direction:column;gap:8px}
.cards.over{background:#e0dcfb;border-radius:8px}
.card{background:var(--panel);border-radius:9px;padding:9px 11px;box-shadow:var(--shadow);cursor:pointer;border:1px solid transparent;position:relative}
.card:hover{border-color:#c4b5fd}.card.drag{opacity:.4}.card .t{font-weight:550;word-wrap:break-word}.card .num{color:var(--mute);font-size:11px;font-weight:600;margin-right:4px}
.card.isdone .t{color:var(--mute);text-decoration:line-through}
.meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:7px;font-size:12px;color:var(--mute)}
.lab{font-size:11px;padding:1px 8px;border-radius:99px;font-weight:600}
.due{font-size:11px;padding:1px 7px;border-radius:6px;background:#f3f4f6}.due.late{background:var(--redbg);color:var(--red);font-weight:700}
.blk{font-size:11px;color:#b45309;background:#fef3c7;padding:1px 7px;border-radius:6px;font-weight:600}
.meta .av{margin-left:auto}
.addc{margin:2px 8px 10px}.addc input{background:#fff}.newcol{width:270px;flex:none;background:#ffffff99;border:2px dashed #d5d8e0;border-radius:12px;padding:10px}
.empty{margin:auto;text-align:center;color:var(--mute);max-width:420px;padding:40px}.empty h2{color:var(--ink)}
.list{padding:18px 22px;overflow:auto}.list h3{margin:18px 0 8px;font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mute)}
.row{display:flex;gap:10px;align-items:center;background:#fff;border-radius:9px;padding:10px 12px;margin-bottom:7px;box-shadow:var(--shadow);cursor:pointer}.row .b{color:var(--mute);font-size:12px;margin-left:auto}
dialog{border:0;border-radius:14px;padding:0;width:min(880px,94vw);box-shadow:0 20px 60px rgba(0,0,0,.25)}dialog::backdrop{background:#0f0d1c80}
.dlg{display:grid;grid-template-columns:1fr 280px;min-height:420px}.dmain{padding:20px 22px;display:flex;flex-direction:column;gap:12px}.dside{background:#f8f9fb;padding:18px;border-left:1px solid var(--line);overflow:auto;max-height:80vh}
.dmain label{font-size:12px;color:var(--mute);font-weight:600;display:block;margin-bottom:4px}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.ttl{font-size:18px;font-weight:700;border-color:transparent;padding:4px 6px;margin-left:-6px}.ttl:hover{border-color:var(--line)}
.hist{font-size:12px;border-left:2px solid var(--line);padding:0 0 10px 10px;margin-left:4px}.hist b{font-weight:600}.hist .w{color:var(--mute);font-size:11px}
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:auto}
.ai{background:linear-gradient(135deg,#f5f3ff,#eef2ff);border:1px solid #ddd6fe;border-radius:10px;padding:10px 12px;font-size:13px;white-space:pre-wrap}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;padding:9px 16px;border-radius:9px;font-size:13px;opacity:0;transition:.2s;pointer-events:none;z-index:9}.toast.show{opacity:1}
.ro .addc,.ro .newcol{display:none}.muted{color:var(--mute);font-size:12px}
.mem{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--line)}.mem .e{flex:1}
@media(max-width:760px){aside{display:none}.dlg{grid-template-columns:1fr}.dside{border-left:0;border-top:1px solid var(--line)}}
</style></head><body>
<aside><div class="brand"><i>▤</i>Lanebook</div>
<div class="nav" data-view="plate">My week <small id="platec"></small></div>
<div class="nav" data-view="overdue">Overdue <span id="odc"></span></div>
<h4>Boards</h4><div id="blist"></div>
<form id="nb"><input id="nbt" placeholder="+ New board" maxlength="200"></form>
<div class="me"><span id="who"></span><a href="/.dodil/auth/logout">Sign out</a></div></aside>
<main id="main"></main>
<dialog id="dlg"></dialog><div class="toast" id="toast"></div>
<script>
const $=s=>document.querySelector(s), esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
let ME='', BOARDS=[], B=null, VIEW='', FILTER='all', Q='';
async function api(u,o={}){const r=await fetch(u,{headers:{'content-type':'application/json'},...o,body:o.body?JSON.stringify(o.body):undefined});
if(r.status===401){location='/.dodil/auth/login?return_to=/';throw 0}
const t=await r.text();let j;try{j=JSON.parse(t)}catch(e){j=t}
if(!r.ok){toast((j&&j.detail)||('Error '+r.status));throw j}return j}
function toast(m){const t=$('#toast');t.textContent=typeof m==='string'?m:JSON.stringify(m);t.classList.add('show');clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove('show'),2600)}
const COLORS=['#5b4cf0','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#6366f1'];
const ini=e=>{let l=e.split('@')[0];if(l.includes('+'))l=l.split('+').pop();return l.split(/[._-]/).filter(Boolean).map(x=>x[0]).join('').slice(0,2).toUpperCase()};
const hue=s=>{let h=0;for(const c of String(s))h=(h*31+c.charCodeAt(0))>>>0;return h};
const av=(e,cls='')=>e?`<span class="av ${cls}" title="${esc(e)}" style="background:${COLORS[hue(e)%COLORS.length]}">${esc(ini(e))}</span>`:'';
const LABC=[['#ede9fe','#5b21b6'],['#e0f2fe','#075985'],['#dcfce7','#166534'],['#fef3c7','#92400e'],['#fee2e2','#991b1b'],['#fce7f3','#9d174d'],['#f1f5f9','#334155']];
const lab=l=>{const c=LABC[hue(l)%LABC.length];return `<span class="lab" style="background:${c[0]};color:${c[1]}">${esc(l)}</span>`};
const fmtDue=d=>{if(!d)return'';const dt=new Date(d+'T00:00:00');return dt.toLocaleDateString(undefined,{month:'short',day:'numeric'})};
const canEdit=()=>B&&B.role!=='viewer';
async function loadBoards(){BOARDS=await api('/api/boards');
$('#blist').innerHTML=BOARDS.map(b=>`<div class="nav ${B&&B.id===b.id&&!VIEW?'on':''}" data-board="${b.id}"><span>${esc(b.title)}</span><small>${b.role}</small></div>`).join('')||'<div class="muted" style="padding:6px 9px">No boards yet</div>';
api('/api/overdue').then(o=>$('#odc').innerHTML=o.length?`<span class="pill">${o.length}</span>`:'');}
document.addEventListener('click',e=>{const n=e.target.closest('.nav');if(!n)return;if(n.dataset.board)openBoard(n.dataset.board);else showView(n.dataset.view)});
$('#nb').onsubmit=async e=>{e.preventDefault();const t=$('#nbt').value.trim();if(!t)return;const b=await api('/api/boards',{method:'POST',body:{title:t}});$('#nbt').value='';await openBoard(b.id);toast('Board created')};
async function openBoard(id){VIEW='';B=await api('/api/boards/'+id);localStorage.lb=id;renderBoard();loadBoards()}
async function refresh(){if(B){B=await api('/api/boards/'+B.id);renderBoard()}loadBoards()}
function cardHTML(c){const blocked=c.blocked_by.length;return `<div class="card ${c.done?'isdone':''}" draggable="${canEdit()}" data-id="${c.id}">
<div class="t"><span class="num">#${c.number}</span>${esc(c.title)}</div>
<div class="meta">${c.labels.map(lab).join('')}${c.due_date?`<span class="due ${c.overdue?'late':''}">${c.overdue?'Overdue · ':''}${fmtDue(c.due_date)}</span>`:''}${blocked?`<span class="blk">Blocked by ${c.blocked_by.map(b=>'#'+b.number).join(', ')}</span>`:''}${c.description?'<span title="Has a description">☰</span>':''}${av(c.assignee,'sm')}</div></div>`}
function visible(c){if(FILTER==='mine'&&c.assignee!==ME)return false;if(FILTER==='overdue'&&!c.overdue)return false;if(FILTER.startsWith('l:')&&!c.labels.includes(FILTER.slice(2)))return false;
if(Q){const h=(c.title+' '+c.description+' '+c.labels.join(' ')+' #'+c.number).toLowerCase();if(!Q.toLowerCase().split(/\s+/).every(w=>h.includes(w)))return false}return true}
function renderBoard(){const labels=[...new Set(B.lists.flatMap(l=>l.cards.flatMap(c=>c.labels)))].sort();const last=B.lists.length-1;
$('#main').innerHTML=`<header><h1>${esc(B.title)}</h1><span class="role">${B.role}</span>${B.overdue?`<span class="pill">${B.overdue} overdue</span>`:''}<div class="spacer"></div>
<div class="avs">${B.people.map(p=>av(p.email)).join('')}</div>
<button onclick="membersDlg()">${B.role==='owner'?'Share':'People'}</button><button onclick="activityDlg()">Activity</button><button class="primary" onclick="summaryDlg()">✨ Daily summary</button></header>
<div class="filters"><span class="chip ${FILTER==='all'?'on':''}" data-f="all">All cards</span><span class="chip ${FILTER==='mine'?'on':''}" data-f="mine">Assigned to me</span><span class="chip ${FILTER==='overdue'?'on':''}" data-f="overdue">Overdue</span>${labels.map(l=>`<span class="chip ${FILTER==='l:'+l?'on':''}" data-f="l:${esc(l)}">${esc(l)}</span>`).join('')}<div class="spacer"></div><input id="q" placeholder="Filter cards…" value="${esc(Q)}"></div>
<div class="board ${canEdit()?'':'ro'}">${B.lists.map((l,i)=>{const cs=l.cards.filter(visible);return `<section class="col" data-list="${l.id}"><div class="colh"><span ondblclick="renameList('${l.id}')">${esc(l.name)}</span><span class="n">${l.cards.length}</span>${i===last?'<span class="done" title="Cards here count as done">done</span>':''}</div>
<div class="cards" data-list="${l.id}">${cs.map(cardHTML).join('')}</div>
<form class="addc" data-list="${l.id}"><input placeholder="+ Add a card" maxlength="200"></form></section>`}).join('')}
<div class="newcol"><form id="nl"><input placeholder="+ Add a list" maxlength="200"></form></div></div>`;
document.querySelectorAll('.chip').forEach(c=>c.onclick=()=>{FILTER=c.dataset.f;renderBoard()});
const q=$('#q');q.oninput=()=>{Q=q.value;const p=q.selectionStart;renderBoard();const n=$('#q');n.focus();n.setSelectionRange(p,p)};
document.querySelectorAll('.addc').forEach(f=>f.onsubmit=async e=>{e.preventDefault();const v=f.querySelector('input').value.trim();if(!v)return;await api(`/api/boards/${B.id}/cards`,{method:'POST',body:{title:v,list:f.dataset.list}});await refresh();const nf=document.querySelector(`.addc[data-list="${f.dataset.list}"] input`);nf&&nf.focus()});
const nl=$('#nl');if(nl)nl.onsubmit=async e=>{e.preventDefault();const v=nl.querySelector('input').value.trim();if(!v)return;await api(`/api/boards/${B.id}/lists`,{method:'POST',body:{name:v}});refresh()};
document.querySelectorAll('.card').forEach(el=>{el.onclick=()=>cardDlg(el.dataset.id);
el.ondragstart=e=>{e.dataTransfer.setData('text/plain',el.dataset.id);el.classList.add('drag')};el.ondragend=()=>el.classList.remove('drag')});
if(canEdit())document.querySelectorAll('.cards').forEach(z=>{z.ondragover=e=>{e.preventDefault();z.classList.add('over')};z.ondragleave=()=>z.classList.remove('over');
z.ondrop=async e=>{e.preventDefault();z.classList.remove('over');const id=e.dataTransfer.getData('text/plain');const list=B.lists.find(l=>l.id===z.dataset.list);
const others=list.cards.filter(c=>c.id!==id);const els=[...z.querySelectorAll('.card')].filter(x=>x.dataset.id!==id);let idx=els.findIndex(x=>e.clientY<x.getBoundingClientRect().top+x.offsetHeight/2);
let pos;if(!others.length)pos=1;else{const ord=els.map(x=>others.find(c=>c.id===x.dataset.id)).filter(Boolean);if(idx<0||idx>=ord.length)pos=(ord.length?ord[ord.length-1].position:0)+1;else{const nxt=ord[idx].position,prv=idx>0?ord[idx-1].position:nxt-2;pos=(prv+nxt)/2}}
await api(`/api/cards/${id}/move`,{method:'POST',body:{list:z.dataset.list,position:pos}});refresh()}})}
async function renameList(id){if(!canEdit())return;const l=B.lists.find(x=>x.id===id);const n=prompt('Rename list',l.name);if(n&&n.trim()&&n!==l.name){await api(`/api/boards/${B.id}/lists/${id}`,{method:'PATCH',body:{name:n}});refresh()}}
async function cardDlg(id){const c=await api('/api/cards/'+id);const ro=!canEdit();const d=$('#dlg');const ppl=B.people.filter(p=>p.role!=='viewer');
d.innerHTML=`<div class="dlg"><div class="dmain"><div style="display:flex;gap:8px;align-items:center"><span class="num muted">#${c.number}</span><input class="ttl" id="ct" value="${esc(c.title)}" ${ro?'disabled':''}><button class="ghost" onclick="dlg.close()">✕</button></div>
<div class="grid2"><div><label>List</label><select id="cl" ${ro?'disabled':''}>${B.lists.map(l=>`<option value="${l.id}" ${l.id===c.list_id?'selected':''}>${esc(l.name)}</option>`).join('')}</select></div>
<div><label>Assignee</label><select id="ca" ${ro?'disabled':''}><option value="">Nobody</option>${ppl.map(p=>`<option ${p.email===c.assignee?'selected':''}>${esc(p.email)}</option>`).join('')}</select></div>
<div><label>Due date ${c.overdue?'<span class="due late">Overdue</span>':''}</label><input type="date" id="cd" value="${c.due_date||''}" ${ro?'disabled':''}></div>
<div><label>Labels (comma separated)</label><input id="cg" value="${esc(c.labels.join(', '))}" ${ro?'disabled':''} placeholder="design, urgent"></div></div>
<div><label>Description</label><textarea id="cx" rows="6" ${ro?'disabled':''} placeholder="Add more detail…">${esc(c.description)}</textarea></div>
<div><label>Blocked by (card numbers)</label><input id="cb" value="${c.blocked_by.map(b=>b.number).join(', ')}" ${ro?'disabled':''} placeholder="e.g. 3, 7"></div>
<div id="aiout"></div>
<div class="actions">${ro?'<span class="muted">You can view this board but not change it.</span>':`<button class="primary" id="save">Save</button><button onclick="aiChecklist('${c.id}')">✨ Make checklist</button>`}<button onclick="similar('${c.id}')">✨ Similar cards</button>${c.blocked_by.length?`<button onclick="blocking('${c.id}')">What's blocking it?</button>`:''}<div class="spacer"></div>${ro?'':`<button class="danger ghost" id="del">Delete</button>`}</div></div>
<div class="dside"><b>History</b><div style="margin-top:10px">${c.history.map(h=>`<div class="hist"><b>${esc(h.actor.split('@')[0])}</b> ${esc(h.action.replace(/^card #\d+: /,''))}<div class="w">${new Date(h.at+(h.at.endsWith('Z')||h.at.includes('+')?'':'Z')).toLocaleString()}</div></div>`).join('')||'<span class="muted">No changes yet</span>'}</div></div></div>`;
d.showModal();
if(!ro){$('#save').onclick=async()=>{const body={title:$('#ct').value,assignee:$('#ca').value||null,due_date:$('#cd').value||null,labels:$('#cg').value.split(',').map(s=>s.trim()).filter(Boolean),description:$('#cx').value};
await api('/api/cards/'+id,{method:'PATCH',body});if($('#cl').value!==c.list_id)await api(`/api/cards/${id}/move`,{method:'POST',body:{list:$('#cl').value}});
const bl=$('#cb').value.split(/[ ,]+/).filter(Boolean);if(bl.join(',')!==c.blocked_by.map(b=>b.number).join(','))await api(`/api/cards/${id}/blocked-by`,{method:'PUT',body:{blockers:bl}});
d.close();toast('Saved');refresh()};
$('#del').onclick=async()=>{if(!confirm('Delete card #'+c.number+'?'))return;await api('/api/cards/'+id,{method:'DELETE'});d.close();toast('Card deleted');refresh()}}}
async function aiChecklist(id){$('#aiout').innerHTML='<div class="ai">Asking the model for a checklist…</div>';const r=await api(`/api/cards/${id}/checklist`,{method:'POST',body:{apply:false}});
$('#aiout').innerHTML=`<div class="ai"><b>Suggested checklist</b>\n${r.suggested_checklist.map(s=>'☐ '+esc(s)).join('\n')}\n<button class="primary" style="margin-top:8px" id="applyck">Add to description</button></div>`;
$('#applyck').onclick=()=>{const x=$('#cx');x.value=(x.value?x.value+'\n\n':'')+r.suggested_checklist.map(s=>'[ ] '+s).join('\n');toast('Added — press Save to keep it')}}
async function similar(id){$('#aiout').innerHTML='<div class="ai">Searching by meaning…</div>';const r=await api(`/api/cards/${id}/similar`);$('#aiout').innerHTML=`<div class="ai"><b>Cards like this one</b>\n${r.map(c=>`#${c.number} ${esc(c.title)} — ${esc(c.board)} · ${esc(c.list)} (${Math.round(c.similarity*100)}%)`).join('\n')||'Nothing similar yet.'}</div>`}
async function blocking(id){const r=await api(`/api/cards/${id}/blocking`);$('#aiout').innerHTML=`<div class="ai"><b>${r.blocked?'Blocked by':'Nothing is blocking it'}</b>\n${r.blockers.map(b=>`${' '.repeat(b.depth-1)}#${b.number} ${esc(b.title)} — ${esc(b.list)}${b.assignee?' · '+esc(b.assignee):''}`).join('\n')}</div>`}
async function membersDlg(){const d=$('#dlg');const own=B.role==='owner';
d.innerHTML=`<div style="padding:20px 22px;max-width:560px"><div style="display:flex"><h2 style="margin:0 0 6px">People on ${esc(B.title)}</h2><div class="spacer"></div><button class="ghost" onclick="dlg.close()">✕</button></div>
<p class="muted">Owner manages people · Members edit · Viewers can only look.</p>
${B.people.map(p=>`<div class="mem">${av(p.email)}<span class="e">${esc(p.email)}${p.email===ME?' <span class="muted">(you)</span>':''}</span>${own&&p.role!=='owner'?`<select data-e="${esc(p.email)}" class="rsel" style="width:auto"><option ${p.role==='member'?'selected':''}>member</option><option ${p.role==='viewer'?'selected':''}>viewer</option></select><button class="ghost danger" data-rm="${esc(p.email)}">Remove</button>`:`<span class="role">${p.role}</span>`}</div>`).join('')}
${own?`<form id="inv" style="display:flex;gap:8px;margin-top:14px"><input id="ie" type="email" placeholder="teammate@company.com" required><select id="ir" style="width:auto"><option>member</option><option>viewer</option></select><button class="primary">Invite</button></form><p class="muted">They sign in to this app with that email and the board appears for them.</p>`:''}</div>`;
d.showModal();
if(own){$('#inv').onsubmit=async e=>{e.preventDefault();await api(`/api/boards/${B.id}/members`,{method:'POST',body:{email:$('#ie').value,role:$('#ir').value}});await refresh();membersDlg();toast('Invited')};
d.querySelectorAll('.rsel').forEach(s=>s.onchange=async()=>{await api(`/api/boards/${B.id}/members`,{method:'POST',body:{email:s.dataset.e,role:s.value}});await refresh();toast('Role changed')});
d.querySelectorAll('[data-rm]').forEach(b=>b.onclick=async()=>{if(!confirm('Remove '+b.dataset.rm+'?'))return;await api(`/api/boards/${B.id}/members/${encodeURIComponent(b.dataset.rm)}`,{method:'DELETE'});await refresh();membersDlg()})}}
async function activityDlg(){const r=await api(`/api/boards/${B.id}/activity?limit=60`);const d=$('#dlg');
d.innerHTML=`<div style="padding:20px 22px;max-height:80vh;overflow:auto"><div style="display:flex"><h2 style="margin:0 0 12px">Activity</h2><div class="spacer"></div><button class="ghost" onclick="dlg.close()">✕</button></div>${r.map(h=>`<div class="hist"><b>${esc(h.actor)}</b> ${esc(h.action)}<div class="w">${new Date(h.at+(h.at.endsWith('Z')||h.at.includes('+')?'':'Z')).toLocaleString()}</div></div>`).join('')}</div>`;d.showModal()}
async function summaryDlg(){const d=$('#dlg');d.innerHTML=`<div style="padding:20px 22px"><div style="display:flex"><h2 style="margin:0 0 12px">✨ Daily summary</h2><div class="spacer"></div><button class="ghost" onclick="dlg.close()">✕</button></div><div class="ai" id="sum">Reading the board…</div><p class="muted">Written by Dodil Models from this board's cards and recent activity.</p></div>`;d.showModal();
try{const r=await api(`/api/boards/${B.id}/summary`);$('#sum').textContent=r.summary}catch(e){$('#sum').textContent='The model did not answer; try again in a moment.'}}
function rowHTML(c){return `<div class="row" data-b="${c.board_id}" data-c="${c.id}"><span class="num muted">#${c.number}</span><span>${esc(c.title)}</span>${c.labels.map(lab).join('')}${c.due_date?`<span class="due ${c.overdue?'late':''}">${fmtDue(c.due_date)}</span>`:''}${av(c.assignee,'sm')}<span class="b">${esc(c.board)} · ${esc(c.list)}</span></div>`}
async function showView(v){VIEW=v;B=null;document.querySelectorAll('.nav').forEach(n=>n.classList.toggle('on',n.dataset.view===v));let html='';
if(v==='plate'){const p=await api('/api/plate?days=7');html=`<header><h1>My week</h1><span class="muted">${fmtDue(p.today)} – ${fmtDue(p.until)} · cards assigned to you</span></header><div class="list">
<h3>Overdue</h3>${p.overdue.map(rowHTML).join('')||'<p class="muted">Nothing overdue. Nice.</p>'}<h3>Due this week</h3>${p.due_soon.map(rowHTML).join('')||'<p class="muted">Nothing due this week.</p>'}<h3>No due date</h3>${p.no_due_date.map(rowHTML).join('')||'<p class="muted">—</p>'}</div>`}
else{const o=await api('/api/overdue');html=`<header><h1>Overdue</h1><span class="muted">unfinished cards past their due date, on every board you can see</span></header><div class="list">${o.map(rowHTML).join('')||'<p class="muted">Nothing is overdue.</p>'}</div>`}
$('#main').innerHTML=html;document.querySelectorAll('.row').forEach(r=>r.onclick=async()=>{await openBoard(r.dataset.b);cardDlg(r.dataset.c)})}
(async()=>{ME=(await api('/api/me')).email;$('#who').textContent=ME;await loadBoards();api('/api/plate').then(p=>{const n=p.overdue.length+p.due_soon.length;$('#platec').textContent=n||''});
const last=localStorage.lb&&BOARDS.find(b=>b.id===localStorage.lb);if(last||BOARDS.length)openBoard((last||BOARDS[0]).id);
else $('#main').innerHTML='<div class="empty"><h2>Welcome to Lanebook</h2><p>Create your first board on the left. It starts with To do, Doing and Done; invite people with Share; drag cards between lists.</p><p>Prefer to talk? Connect Claude or ChatGPT to <b>'+location.origin+'/mcp</b> and ask “what\u2019s on my plate this week?”</p></div>'})();
</script></body></html>'''