contest/orbit-boards

Orbit Boards, a private, remixable team task board built with FastAPI, DataK3, AppID, Models and MCP.

A remix template · 0 remixes · Remix contest/orbit-boards

app/boards/static/index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f7f8fc">
<title>Orbit Boards</title>
<style>
:root{color-scheme:light;--ink:#1d2333;--muted:#778096;--line:#e8eaf1;--paper:#fff;--canvas:#f7f8fc;--violet:#6157d9;--violet-soft:#eeedff;--mint:#dff4ec;--amber:#fff1d7;--rose:#fff0ef;--shadow:0 12px 35px rgba(37,43,73,.08);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--canvas);color:var(--ink);font-size:14px}button,input,textarea,select{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:3px solid #a8a4ff;outline-offset:2px}.shell{min-height:100vh;display:grid;grid-template-columns:248px minmax(0,1fr)}.sidebar{background:#fff;border-right:1px solid var(--line);padding:24px 16px;display:flex;flex-direction:column;gap:24px;min-height:100vh}.brand{display:flex;align-items:center;gap:11px;padding:0 8px}.brand-mark{height:34px;width:34px;display:grid;place-items:center;border-radius:11px;background:var(--violet);color:#fff;position:relative;overflow:hidden}.brand-mark:before,.brand-mark:after{content:"";position:absolute;border:1.5px solid rgba(255,255,255,.76);border-radius:50%;width:24px;height:12px;transform:rotate(-42deg)}.brand-mark:after{width:13px;height:25px;transform:rotate(38deg)}.brand-mark span{height:5px;width:5px;background:#fff;border-radius:50%;z-index:1}.brand-name{font-size:16px;font-weight:760;letter-spacing:-.04em}.brand-sub{display:block;margin-top:2px;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--muted);font-weight:650}.workspace-label{font-size:10px;font-weight:750;letter-spacing:.14em;text-transform:uppercase;color:#969caf;padding:0 9px}.side-section{display:flex;flex-direction:column;gap:7px}.side-title-row{display:flex;justify-content:space-between;align-items:center;padding:0 8px}.side-title-row .workspace-label{padding:0}.icon-button{border:0;background:transparent;color:#8990a3;border-radius:8px;width:30px;height:30px;display:grid;place-items:center}.icon-button:hover{background:var(--violet-soft);color:var(--violet)}.board-nav{display:flex;flex-direction:column;gap:3px}.board-link{width:100%;display:flex;align-items:center;gap:10px;text-align:left;padding:10px 11px;border:0;border-radius:9px;background:transparent;color:#4d556b;font-size:13px}.board-link:hover{background:#f5f5fb}.board-link.active{background:var(--violet-soft);color:#453bb7;font-weight:650}.board-dot{height:8px;width:8px;border-radius:3px;background:#b6b5cd;flex:none}.board-link.active .board-dot{background:var(--violet)}.side-spacer{flex:1}.account-card{border-top:1px solid var(--line);padding:17px 7px 0;display:flex;align-items:center;gap:10px}.avatar{height:34px;width:34px;border-radius:50%;display:grid;place-items:center;background:#ebeaff;color:#4e46be;font-weight:750;font-size:12px}.account-title{font-weight:650;font-size:12px}.account-note{color:var(--muted);font-size:11px;margin-top:3px}.signout{margin-left:auto;color:#8990a3;text-decoration:none;font-size:11px}.main{min-width:0;padding:28px 34px 48px}.topline{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:28px}.crumb{color:var(--muted);font-size:12px}.top-actions{display:flex;align-items:center;gap:9px}.quiet-button,.primary-button,.outline-button{border-radius:9px;padding:9px 13px;border:1px solid var(--line);background:#fff;color:#465067;font-weight:620;font-size:12px;transition:background .16s,border .16s,transform .16s}.quiet-button:hover,.outline-button:hover{background:#f7f6ff;border-color:#d9d6ff}.primary-button{background:var(--violet);border-color:var(--violet);color:#fff;box-shadow:0 4px 12px rgba(97,87,217,.2)}.primary-button:hover{background:#5146cd;transform:translateY(-1px)}.primary-button:disabled,.quiet-button:disabled{opacity:.48;cursor:not-allowed;transform:none}.board-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.title-wrap h1{font-size:27px;line-height:1.2;letter-spacing:-.045em;margin:0 0 7px;font-weight:750}.subtitle{font-size:12px;color:var(--muted)}.role-badge{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:99px;background:#f0efff;color:#5148bd;font-size:10px;text-transform:uppercase;letter-spacing:.07em;font-weight:750;margin-top:4px}.role-badge:before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}.summary-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:23px 0 24px}.summary-card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:14px 16px;display:flex;justify-content:space-between;align-items:center}.summary-label{color:var(--muted);font-size:11px}.summary-value{font-size:21px;font-weight:730;letter-spacing:-.04em;margin-top:4px}.summary-icon{height:34px;width:34px;border-radius:10px;background:#f0efff;color:var(--violet);display:grid;place-items:center;font-size:15px}.summary-card.overdue .summary-icon{background:var(--rose);color:#d35d55}.summary-card.done .summary-icon{background:var(--mint);color:#267c60}.toolbar{display:flex;align-items:center;gap:9px;margin-bottom:15px;flex-wrap:wrap}.search-wrap{position:relative;flex:1;min-width:180px;max-width:350px}.search-wrap input{width:100%;height:36px;padding:0 12px 0 35px;background:#fff;border:1px solid var(--line);border-radius:9px;color:var(--ink)}.search-mark{position:absolute;left:12px;top:10px;color:#9aa0b0}.filter-select{height:36px;border:1px solid var(--line);border-radius:9px;background:#fff;color:#616a7f;padding:0 28px 0 11px;font-size:12px;max-width:170px}.board-grid{display:grid;grid-template-columns:repeat(3,minmax(255px,1fr));gap:14px;align-items:start;overflow-x:auto;padding-bottom:12px}.column{border:1px solid #eaebf2;background:#f0f1f7;border-radius:12px;min-height:245px;padding:11px;transition:background .18s,border-color .18s}.column.drop-target{background:#ecebff;border-color:#ada9f2}.column-head{display:flex;align-items:center;justify-content:space-between;padding:2px 3px 12px}.column-title{display:flex;align-items:center;gap:8px;font-weight:700;font-size:12px}.column-count{display:grid;place-items:center;min-width:20px;height:20px;border-radius:7px;background:#e5e6ef;color:#666e82;font-size:10px;font-weight:700}.column[data-list="Doing"] .column-count{background:#e7e4ff;color:#5348be}.column[data-list="Done"] .column-count{background:#def0e9;color:#2e8064}.card-stack{display:flex;flex-direction:column;gap:9px;min-height:180px}.task-card{background:#fff;border:1px solid #e8e9f0;border-radius:10px;padding:12px 12px 10px;box-shadow:0 2px 5px rgba(39,44,69,.025);cursor:pointer;transition:box-shadow .16s,transform .16s,border-color .16s}.task-card:hover{box-shadow:var(--shadow);transform:translateY(-2px);border-color:#d8d5f8}.task-card[draggable="true"]{cursor:grab}.task-card.dragging{opacity:.48}.task-title{font-weight:660;font-size:12px;line-height:1.5;color:#282e41;overflow-wrap:anywhere}.task-description{font-size:11px;color:#7b8294;line-height:1.5;margin-top:5px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.label-row{display:flex;flex-wrap:wrap;gap:5px;margin-top:10px}.label-pill{padding:4px 7px;border-radius:5px;background:#f0efff;color:#6157c4;font-size:9px;font-weight:680}.label-pill:nth-child(3n+2){background:#e8f4ef;color:#36826a}.label-pill:nth-child(3n){background:#fff1df;color:#a46b2d}.card-foot{display:flex;align-items:center;justify-content:space-between;gap:7px;margin-top:11px;color:#858c9e;font-size:10px}.card-meta{display:flex;align-items:center;gap:9px;min-width:0}.due{white-space:nowrap}.due.late{color:#cb514b;font-weight:730}.assignee-chip{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:125px}.mini-avatar{display:grid;place-items:center;width:19px;height:19px;border-radius:50%;background:#ecebff;color:#5d54c3;font-size:8px;font-weight:750;flex:none}.empty-column{border:1px dashed #d7d9e4;border-radius:9px;min-height:78px;display:grid;place-items:center;color:#959bad;font-size:11px}.empty-state{margin-top:35px;background:#fff;border:1px solid var(--line);border-radius:16px;padding:40px;text-align:center;max-width:680px}.empty-state h2{font-size:20px;margin:0 0 8px;letter-spacing:-.03em}.empty-state p{max-width:440px;margin:0 auto 20px;color:var(--muted);line-height:1.6}.empty-mark{width:50px;height:50px;border-radius:15px;background:var(--violet-soft);color:var(--violet);display:grid;place-items:center;margin:0 auto 16px;font-size:22px}.empty-filter{padding:30px;color:var(--muted);text-align:center;font-size:12px}.toast{position:fixed;right:24px;bottom:22px;background:#22283a;color:#fff;padding:11px 15px;border-radius:9px;box-shadow:var(--shadow);z-index:20;opacity:0;transform:translateY(8px);pointer-events:none;transition:all .2s;font-size:12px}.toast.show{opacity:1;transform:translateY(0)}.toast.error{background:#a83f3a}dialog{border:0;border-radius:16px;padding:0;width:min(570px,calc(100vw - 28px));max-height:90vh;box-shadow:0 25px 80px rgba(28,31,55,.22);color:var(--ink)}dialog::backdrop{background:rgba(24,28,45,.38);backdrop-filter:blur(3px)}.dialog-head{padding:20px 22px 15px;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;justify-content:space-between}.dialog-head h2{font-size:17px;letter-spacing:-.03em;margin:0}.dialog-head p{font-size:11px;color:var(--muted);margin:5px 0 0}.dialog-body{padding:19px 22px;overflow-y:auto;max-height:calc(90vh - 140px)}.dialog-actions{padding:14px 22px 19px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}.field{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}.field label{font-size:11px;font-weight:680;color:#51596d}.field input,.field textarea,.field select{border:1px solid #dfe1eb;border-radius:8px;padding:10px 11px;background:#fff;color:var(--ink);width:100%;font-size:12px}.field textarea{resize:vertical;min-height:95px;line-height:1.5}.field input:disabled,.field textarea:disabled{background:#f7f8fb;color:#6d7488}.field-hint{font-size:10px;color:#8b92a3}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.activity-list{display:flex;flex-direction:column}.activity-item{display:grid;grid-template-columns:25px 1fr;gap:10px;padding:12px 0;border-bottom:1px solid #f0f1f5}.activity-mark{height:23px;width:23px;border-radius:50%;background:#efeeff;color:#6459cc;display:grid;place-items:center;font-size:10px}.activity-text{font-size:11px;line-height:1.5;color:#51596d}.activity-text strong{color:#2e3447}.activity-time{font-size:10px;color:#959bad;margin-top:3px}.member-list{display:flex;flex-direction:column;gap:8px;margin-bottom:17px}.member-row{display:flex;align-items:center;gap:9px;padding:9px;border:1px solid #eff0f5;border-radius:9px}.member-email{font-size:11px;font-weight:630}.member-role{margin-left:auto;color:#8a91a1;font-size:10px;text-transform:capitalize}.checklist-box{background:#f6f6fc;border-radius:9px;padding:12px;margin-top:14px;font-size:11px;color:#596176}.checklist-box ul{margin:7px 0 0;padding-left:19px;line-height:1.8}.hint{font-size:11px;color:#868da0;line-height:1.6}.loading{opacity:.6;pointer-events:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media(max-width:950px){.shell{grid-template-columns:210px minmax(0,1fr)}.main{padding:24px 22px}.board-grid{grid-template-columns:repeat(3,minmax(235px,1fr))}}
@media(max-width:700px){.shell{grid-template-columns:1fr}.sidebar{min-height:auto;padding:13px 16px;display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;border-right:0;border-bottom:1px solid var(--line)}.workspace-label,.side-spacer,.account-note{display:none}.side-section{min-width:0}.side-title-row{display:none}.board-nav{flex-direction:row;overflow-x:auto;white-space:nowrap}.board-link{width:auto;padding:8px}.account-card{border:0;padding:0;justify-content:flex-end}.account-card .avatar,.account-title{display:none}.signout{margin-left:0}.main{padding:20px 16px 35px}.topline{margin-bottom:22px}.board-heading{align-items:center}.title-wrap h1{font-size:23px}.summary-row{gap:7px}.summary-card{padding:11px}.summary-label{font-size:10px}.summary-value{font-size:18px}.summary-icon{width:29px;height:29px}.board-grid{grid-template-columns:repeat(3,minmax(250px,82vw));margin-right:-16px}.toolbar{gap:7px}.search-wrap{max-width:none;flex-basis:100%}.filter-select{flex:1;max-width:none}.top-actions .quiet-button{padding:8px}.empty-state{padding:28px 18px}.form-grid{grid-template-columns:1fr}.topline .crumb{display:none}}
</style>
</head>
<body>
<div class="shell">
  <aside class="sidebar" aria-label="Workspace navigation">
    <div class="brand"><div class="brand-mark" aria-hidden="true"><span></span></div><div><span class="brand-name">Orbit</span><span class="brand-sub">Boards</span></div></div>
    <section class="side-section"><div class="side-title-row"><span class="workspace-label">Your workspace</span><button class="icon-button" id="newBoardSide" aria-label="Create board" title="Create board">+</button></div><nav id="boardNav" class="board-nav" aria-label="Boards"></nav></section>
    <div class="side-spacer"></div>
    <div class="account-card"><div class="avatar" aria-hidden="true">OB</div><div><div class="account-title">Signed in</div><div class="account-note">Dodil AppID account</div></div><a class="signout" href="/.dodil/auth/logout">Sign out</a></div>
  </aside>
  <main class="main">
    <header class="topline"><div class="crumb">Workspace <span aria-hidden="true">/</span> <strong id="crumbBoard">Boards</strong></div><div class="top-actions"><button class="quiet-button" id="activityButton" hidden>Activity</button><button class="quiet-button" id="teamButton" hidden>People</button><button class="primary-button" id="addCardButton" hidden>+ Add card</button></div></header>
    <section id="content" aria-live="polite"></section>
  </main>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>

<dialog id="cardDialog">
  <form id="cardForm">
    <div class="dialog-head"><div><h2 id="cardDialogTitle">New card</h2><p id="cardDialogHint">Add the details your team needs.</p></div><button type="button" class="icon-button" data-close="cardDialog" aria-label="Close">×</button></div>
    <div class="dialog-body">
      <div class="field"><label for="cardTitle">Task name</label><input id="cardTitle" name="title" required maxlength="240" placeholder="What needs to get done?"></div>
      <div class="field"><label for="cardDescription">Description</label><textarea id="cardDescription" name="description" maxlength="10000" placeholder="Add context, links, or a clear definition of done."></textarea></div>
      <div class="form-grid"><div class="field"><label for="cardList">List</label><select id="cardList" name="list_name"><option>To do</option><option>Doing</option><option>Done</option></select></div><div class="field"><label for="cardDue">Due date</label><input id="cardDue" name="due_date" type="date"></div></div>
      <div class="form-grid"><div class="field"><label for="cardAssignee">Assignee</label><select id="cardAssignee" name="assignee"></select></div><div class="field"><label for="cardLabels">Labels</label><input id="cardLabels" name="labels" placeholder="Design, launch, urgent"><span class="field-hint">Separate labels with commas.</span></div></div>
      <div id="checklistArea"></div>
    </div>
<div class="dialog-actions"><button type="button" class="outline-button" id="checklistButton">Suggest checklist</button><span style="flex:1"></span><button type="button" class="outline-button" data-close="cardDialog">Cancel</button><button type="submit" class="primary-button" id="saveCardButton">Save card</button></div>
</form>
</dialog>
<dialog id="boardDialog"><form id="boardForm"><div class="dialog-head"><div><h2>New board</h2><p>A focused space for one team's work.</p></div><button type="button" class="icon-button" data-close="boardDialog" aria-label="Close">×</button></div><div class="dialog-body"><div class="field"><label for="boardTitle">Board name</label><input id="boardTitle" required maxlength="120" placeholder="Product launch"><span id="boardError" role="alert" hidden style="color:#b42318;font-size:12px"></span></div></div><div class="dialog-actions"><button type="button" class="outline-button" data-close="boardDialog">Cancel</button><button type="submit" class="primary-button">Create board</button></div></form></dialog>
<dialog id="teamDialog"><form id="memberForm"><div class="dialog-head"><div><h2>People on this board</h2><p>Only the board owner can add or change access.</p></div><button type="button" class="icon-button" data-close="teamDialog" aria-label="Close">×</button></div><div class="dialog-body"><div id="memberList" class="member-list"></div><div class="form-grid"><div class="field"><label for="memberEmail">Email address</label><input id="memberEmail" type="email" required maxlength="254" placeholder="teammate@example.com"></div><div class="field"><label for="memberRole">Access</label><select id="memberRole"><option value="member">Member · can edit</option><option value="viewer">Viewer · read only</option></select></div></div></div><div class="dialog-actions"><button type="button" class="outline-button" data-close="teamDialog">Done</button><button type="submit" class="primary-button" id="inviteButton">Add person</button></div></form></dialog>
<dialog id="activityDialog"><div class="dialog-head"><div><h2>Board activity</h2><p>Recent changes recorded by Orbit Boards.</p></div><button type="button" class="icon-button" data-close="activityDialog" aria-label="Close">×</button></div><div class="dialog-body"><div id="activityList" class="activity-list"></div></div><div class="dialog-actions"><button type="button" class="outline-button" data-close="activityDialog">Close</button></div></dialog>
<script>
(()=>{
'use strict';
const LISTS=['To do','Doing','Done'];
const state={boards:[],board:null,role:null,filterText:'',filterLabel:'',filterAssignee:'',filterDue:'all',dragId:null,activeCard:null,newCard:false};
const $=selector=>document.querySelector(selector);
const content=$('#content'), nav=$('#boardNav'), toastNode=$('#toast');
const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
async function api(url,options={}){let response;try{response=await fetch(url,{...options,headers:{'content-type':'application/json',...(options.headers||{})}})}catch{throw new Error('Could not reach Orbit Boards. Check your connection and try again.')}let body={};try{body=await response.json()}catch{}if(!response.ok){const detail=typeof body.detail==='string'?body.detail:null;throw new Error(detail||(response.status>=500?'Orbit Boards is temporarily unavailable. Please try again.':`Request failed (${response.status})`))}return body}
function toast(message,isError=false){toastNode.textContent=message;toastNode.className=`toast show${isError?' error':''}`;clearTimeout(toast.timer);toast.timer=setTimeout(()=>toastNode.className='toast',3000)}
function initials(value){return String(value||'?').split('@')[0].split(/[ ._-]/).filter(Boolean).slice(0,2).map(x=>x[0].toUpperCase()).join('')||'?'}
function dateLabel(value){if(!value)return '';const d=new Date(`${value}T00:00:00`);return d.toLocaleDateString(undefined,{month:'short',day:'numeric'})}
function isLate(card){return card.due_date&&card.due_date<new Date().toISOString().slice(0,10)&&card.list_name!=='Done'}
function prettyRole(role){return role==='owner'?'Owner':role==='viewer'?'Viewer':'Member'}
async function loadBoards(preferred){state.boards=await api('/api/boards');nav.innerHTML=state.boards.map(board=>`<button class="board-link ${state.board?.id===board.id?'active':''}" data-board="${esc(board.id)}"><span class="board-dot"></span><span>${esc(board.title)}</span></button>`).join('');if(!state.boards.length){state.board=null;renderEmpty();return true}const selected=state.boards.find(board=>board.id===(preferred||state.board?.id))||state.boards[0];return await loadBoard(selected.id)}
async function loadBoard(id){try{state.board=await api(`/api/boards/${encodeURIComponent(id)}`);state.role=state.board.role;state.filterText='';state.filterLabel='';state.filterAssignee='';state.filterDue='all';$('#crumbBoard').textContent=state.board.title;$('#activityButton').hidden=false;$('#teamButton').hidden=state.role!=='owner';$('#addCardButton').hidden=state.role==='viewer';$('#addCardButton').disabled=state.role==='viewer';renderBoard();nav.querySelectorAll('.board-link').forEach(el=>el.classList.toggle('active',el.dataset.board===id));return true}catch(error){toast(error.message,true);return false}}
function renderEmpty(){ $('#crumbBoard').textContent='Boards';$('#activityButton').hidden=true;$('#teamButton').hidden=true;$('#addCardButton').hidden=true;content.innerHTML=`<div class="empty-state"><div class="empty-mark">✳</div><h2>Your workspace starts with a board</h2><p>Create a board for a project or team. Add work, set owners and due dates, then keep the next step visible.</p><button class="primary-button" id="emptyCreate">Create your first board</button></div>`;$('#emptyCreate').onclick=openBoardDialog}
function filteredCards(){let cards=state.board.cards||[];const q=state.filterText.trim().toLowerCase();return cards.filter(card=>{if(state.filterAssignee&&card.assignee!==state.filterAssignee)return false;if(q&&!`${card.title} ${card.description} ${(card.labels||[]).join(' ')} ${card.assignee||''}`.toLowerCase().includes(q))return false;if(state.filterLabel&&!card.labels.includes(state.filterLabel))return false;if(state.filterDue==='overdue'&&!isLate(card))return false;if(state.filterDue==='upcoming'&&(!card.due_date||card.due_date<new Date().toISOString().slice(0,10)))return false;return true})}
function renderBoard(){if(!state.board)return;const cards=state.board.cards||[], overdue=cards.filter(isLate).length, done=cards.filter(card=>card.list_name==='Done').length, labels=[...new Set(cards.flatMap(card=>card.labels||[]))].sort(),assignees=[...new Set(cards.map(card=>card.assignee).filter(Boolean))].sort();const canEdit=state.role!=='viewer';content.innerHTML=`<section class="board-heading"><div class="title-wrap"><h1>${esc(state.board.title)}</h1><div class="subtitle">A clear view of what's moving and what needs attention.</div><span class="role-badge">${esc(prettyRole(state.role))} access</span></div></section><section class="summary-row" aria-label="Board overview"><div class="summary-card"><div><div class="summary-label">Open work</div><div class="summary-value">${cards.filter(card=>card.list_name!=='Done').length}</div></div><div class="summary-icon">◷</div></div><div class="summary-card overdue"><div><div class="summary-label">Past due</div><div class="summary-value">${overdue}</div></div><div class="summary-icon">!</div></div><div class="summary-card done"><div><div class="summary-label">Completed</div><div class="summary-value">${done}</div></div><div class="summary-icon">✓</div></div></section><section class="toolbar" aria-label="Board filters"><label class="search-wrap"><span class="search-mark" aria-hidden="true">⌕</span><span class="sr-only">Search cards</span><input id="searchCards" type="search" placeholder="Search cards, labels, people…" value="${esc(state.filterText)}"></label><label><span class="sr-only">Filter by label</span><select id="labelFilter" class="filter-select"><option value="">All labels</option>${labels.map(label=>`<option value="${esc(label)}" ${state.filterLabel===label?'selected':''}>${esc(label)}</option>`).join('')}</select></label><label><span class="sr-only">Filter by assignee</span><select id="assigneeFilter" class="filter-select"><option value="">All assignees</option>${assignees.map(email=>`<option value="${esc(email)}" ${state.filterAssignee===email?'selected':''}>${esc(email)}</option>`).join('')}</select></label><label><span class="sr-only">Filter by due date</span><select id="dueFilter" class="filter-select"><option value="all">Any due date</option><option value="upcoming" ${state.filterDue==='upcoming'?'selected':''}>Due today or later</option><option value="overdue" ${state.filterDue==='overdue'?'selected':''}>Overdue only</option></select></label></section><section id="boardGrid" class="board-grid" aria-label="Kanban board">${LISTS.map(list=>{const visible=filteredCards().filter(card=>card.list_name===list).sort((a,b)=>(a.position-b.position)||a.id.localeCompare(b.id));const total=cards.filter(card=>card.list_name===list).length;return `<section class="column" data-list="${esc(list)}" aria-label="${esc(list)}"><header class="column-head"><div class="column-title">${esc(list)}<span class="column-count">${total}</span></div><button class="icon-button add-in-list" data-list="${esc(list)}" aria-label="Add card to ${esc(list)}" ${canEdit?'':'disabled'}>+</button></header><div class="card-stack">${visible.length?visible.map(cardMarkup).join(''):`<div class="empty-column">${cards.length?'No cards match these filters':'Drop a card here or add one'}</div>`}</div></section>`}).join('')}</section>`;
$('#searchCards').addEventListener('input',event=>{state.filterText=event.target.value;renderBoard();const input=$('#searchCards');input.focus();input.setSelectionRange(input.value.length,input.value.length)});$('#labelFilter').addEventListener('change',event=>{state.filterLabel=event.target.value;renderBoard()});$('#assigneeFilter').addEventListener('change',event=>{state.filterAssignee=event.target.value;renderBoard()});$('#dueFilter').addEventListener('change',event=>{state.filterDue=event.target.value;renderBoard()});content.querySelectorAll('.add-in-list').forEach(button=>button.addEventListener('click',()=>openCardDialog(null,button.dataset.list)));content.querySelectorAll('.task-card').forEach(card=>{card.addEventListener('click',()=>openCardDialog(card.dataset.card));card.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();openCardDialog(card.dataset.card)}});card.addEventListener('dragstart',event=>{if(!canEdit){event.preventDefault();return}state.dragId=card.dataset.card;card.classList.add('dragging');event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',state.dragId)});card.addEventListener('dragend',()=>{card.classList.remove('dragging');document.querySelectorAll('.column').forEach(col=>col.classList.remove('drop-target'))})});document.querySelectorAll('.column').forEach(column=>{column.addEventListener('dragover',event=>{if(!canEdit)return;event.preventDefault();column.classList.add('drop-target');event.dataTransfer.dropEffect='move'});column.addEventListener('dragleave',event=>{if(!column.contains(event.relatedTarget))column.classList.remove('drop-target')});column.addEventListener('drop',event=>{if(!canEdit)return;event.preventDefault();column.classList.remove('drop-target');const id=event.dataTransfer.getData('text/plain')||state.dragId;if(id){const target=document.elementFromPoint(event.clientX,event.clientY)?.closest('.task-card');const cards=[...column.querySelectorAll('.task-card')].filter(card=>card.dataset.card!==id);let position=cards.length;if(target&&target.dataset.card!==id){position=cards.indexOf(target);const rect=target.getBoundingClientRect();if(event.clientY>rect.top+rect.height/2)position++}moveCard(id,column.dataset.list,position)}})})}
function cardMarkup(card){const due=card.due_date?`<span class="due ${isLate(card)?'late':''}" title="Due ${esc(card.due_date)}">${isLate(card)?'Past due · ':''}${esc(dateLabel(card.due_date))}</span>`:'';const person=card.assignee?`<span class="assignee-chip" title="${esc(card.assignee)}"><span class="mini-avatar">${esc(initials(card.assignee))}</span>${esc(card.assignee.split('@')[0])}</span>`:'';return `<article class="task-card" data-card="${esc(card.id)}" tabindex="0" role="button" aria-label="Open ${esc(card.title)}" draggable="${state.role!=='viewer'}"><div class="task-title">${esc(card.title)}</div>${card.description?`<div class="task-description">${esc(card.description)}</div>`:''}${card.labels?.length?`<div class="label-row">${card.labels.map(label=>`<span class="label-pill">${esc(label)}</span>`).join('')}</div>`:''}<div class="card-foot"><div class="card-meta">${due}</div>${person}</div></article>`}
function openBoardDialog(){$('#boardTitle').value='';$('#boardError').hidden=true;$('#boardError').textContent='';$('#boardDialog').showModal();$('#boardTitle').focus()}
function openCardDialog(cardId,listName='To do'){const existing=cardId?state.board.cards.find(card=>card.id===cardId):null;state.activeCard=existing;state.newCard=!existing;const form=$('#cardForm');form.reset();$('#cardDialogTitle').textContent=existing?'Card details':'New card';$('#cardDialogHint').textContent=existing?'Keep the task current as it moves through the board.':'Add the details your team needs.';$('#cardTitle').value=existing?.title||'';$('#cardDescription').value=existing?.description||'';$('#cardList').value=existing?.list_name||listName;$('#cardDue').value=existing?.due_date||'';$('#cardLabels').value=(existing?.labels||[]).join(', ');const assignee=$('#cardAssignee');assignee.innerHTML='<option value="">Unassigned</option>'+state.board.members.map(member=>`<option value="${esc(member.email)}">${esc(member.email)} · ${esc(member.role)}</option>`).join('');assignee.value=existing?.assignee||'';const editable=state.role!=='viewer';form.querySelectorAll('input,textarea,select').forEach(field=>field.disabled=!editable);$('#saveCardButton').hidden=!editable;$('#checklistButton').hidden=!existing;$('#checklistArea').innerHTML='';$('#cardDialog').showModal();$('#cardTitle').focus()}
async function moveCard(id,listName,position){try{await api(`/api/cards/${encodeURIComponent(id)}/move`,{method:'POST',body:JSON.stringify({list_name:listName,position})});await loadBoard(state.board.id);toast(`Moved to ${listName}`)}catch(error){toast(error.message,true);await loadBoard(state.board.id)}}
async function openTeam(){const members=state.board.members||[];$('#memberList').innerHTML=members.map(member=>`<div class="member-row"><span class="mini-avatar">${esc(initials(member.email))}</span><span class="member-email">${esc(member.email)}</span><span class="member-role">${esc(member.role)}</span></div>`).join('');$('#inviteButton').hidden=state.role!=='owner';$('#memberForm').querySelectorAll('input,select').forEach(field=>field.disabled=state.role!=='owner');$('#teamDialog').showModal()}
async function openActivity(){const list=$('#activityList');list.innerHTML='<p class="hint">Loading activity…</p>';$('#activityDialog').showModal();try{const events=await api(`/api/boards/${encodeURIComponent(state.board.id)}/activity`);list.innerHTML=events.length?events.map(item=>`<div class="activity-item"><div class="activity-mark">↗</div><div><div class="activity-text"><strong>${esc(item.actor)}</strong> ${esc(item.action)}${item.field?` <span>(${esc(item.field)}${item.new_value!==null&&item.new_value!==undefined?`: ${esc(typeof item.new_value==='string'?item.new_value:JSON.stringify(item.new_value))}`:''})</span>`:''}</div><div class="activity-time">${item.event_at?esc(new Date(item.event_at).toLocaleString()):''}</div></div></div>`).join(''):'<p class="hint">Nothing has changed yet. Board updates will appear here.</p>'}catch(error){list.innerHTML=`<p class="hint">${esc(error.message)}</p>`}}
$('#boardNav').addEventListener('click',event=>{const button=event.target.closest('[data-board]');if(button)loadBoard(button.dataset.board)});
$('#newBoardSide').addEventListener('click',openBoardDialog);$('#addCardButton').addEventListener('click',()=>openCardDialog(null));$('#teamButton').addEventListener('click',openTeam);$('#activityButton').addEventListener('click',openActivity);
document.querySelectorAll('[data-close]').forEach(button=>button.addEventListener('click',()=>document.getElementById(button.dataset.close).close()));
let boardSubmitPending=false;$('#boardForm').addEventListener('submit',async event=>{event.preventDefault();if(boardSubmitPending)return;const button=$('#boardForm button[type="submit"]'),errorNode=$('#boardError'),title=$('#boardTitle').value.trim();errorNode.hidden=true;errorNode.textContent='';if(!title){errorNode.textContent='Enter a board name.';errorNode.hidden=false;$('#boardTitle').focus();return}boardSubmitPending=true;button.disabled=true;button.textContent='Creating…';try{const board=await api('/api/boards',{method:'POST',body:JSON.stringify({title})});$('#boardDialog').close();$('#boardForm').reset();try{const loaded=await loadBoards(board.id);toast(loaded?'Board created':'Board created, but could not open it. Reload the page to try again.',!loaded)}catch{toast('Board created, but the board list did not refresh. Reload the page.',true)}}catch(error){errorNode.textContent=error.message||'Could not create the board. Please try again.';errorNode.hidden=false;$('#boardTitle').focus()}finally{boardSubmitPending=false;button.disabled=false;button.textContent='Create board'}});
$('#cardForm').addEventListener('submit',async event=>{event.preventDefault();if(state.role==='viewer')return;const labels=$('#cardLabels').value.split(',').map(value=>value.trim()).filter(Boolean);try{if(state.newCard){await api(`/api/boards/${encodeURIComponent(state.board.id)}/cards`,{method:'POST',body:JSON.stringify({title:$('#cardTitle').value,list_name:$('#cardList').value,description:$('#cardDescription').value,assignee:$('#cardAssignee').value||null,due_date:$('#cardDue').value||null,labels})})}else{const card=state.activeCard;const updates={title:$('#cardTitle').value,description:$('#cardDescription').value,assignee:$('#cardAssignee').value||null,due_date:$('#cardDue').value||null,labels};await api(`/api/cards/${encodeURIComponent(card.id)}`,{method:'PATCH',body:JSON.stringify(updates)});if($('#cardList').value!==card.list_name)await api(`/api/cards/${encodeURIComponent(card.id)}/move`,{method:'POST',body:JSON.stringify({list_name:$('#cardList').value})})}$('#cardDialog').close();await loadBoard(state.board.id);toast(state.newCard?'Card added':'Card saved')}catch(error){toast(error.message,true)}});
$('#memberForm').addEventListener('submit',async event=>{event.preventDefault();if(state.role!=='owner')return;try{await api(`/api/boards/${encodeURIComponent(state.board.id)}/members`,{method:'POST',body:JSON.stringify({email:$('#memberEmail').value,role:$('#memberRole').value})});$('#memberEmail').value='';await loadBoard(state.board.id);await openTeam();toast('Person added to the board')}catch(error){toast(error.message,true)}});
$('#checklistButton').addEventListener('click',async()=>{if(!state.activeCard)return;const button=$('#checklistButton');button.disabled=true;button.textContent='Thinking…';try{const result=await api(`/api/cards/${encodeURIComponent(state.activeCard.id)}/checklist`,{method:'POST'});$('#checklistArea').innerHTML=`<div class="checklist-box"><strong>Suggested checklist</strong><ul>${result.suggested_checklist.map(item=>`<li>${esc(item)}</li>`).join('')}</ul></div>`}catch(error){toast(error.message,true)}finally{button.disabled=false;button.textContent='Suggest checklist'}});
$('#content').addEventListener('click',event=>{if(event.target.id==='emptyCreate')openBoardDialog()});
loadBoards().catch(error=>{content.innerHTML=`<div class="empty-state"><div class="empty-mark">!</div><h2>Could not load your boards</h2><p>${esc(error.message)}</p><button class="outline-button" onclick="location.reload()">Try again</button></div>`});
})();
</script>
</body>
</html>