dachdigitalwerksorg/kanvaflow
KanvaFlow — Remixable team task board with RBAC, drag-and-drop Kanban, dependencies, Dodil Models AI, and MCP tools.
A remix template · 0 remixes · Remix dachdigitalwerksorg/kanvaflow
app/boards/doors/web.py
"""The Web Door: JSON REST routes + KanvaFlow single-page Kanban UI.
Every route delegates strictly to `Boards` use cases in `application/use_cases.py`.
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel, Field
from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Forbidden, Invalid, Person
from boards.doors.identity import person_from
def build_router(boards: Boards) -> APIRouter:
r = APIRouter()
def me(request: Request) -> Person:
p = person_from(request.headers)
if p is None:
raise HTTPException(401, "sign in first: /.dodil/auth/login")
return p
def run(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except NotFound as exc:
raise HTTPException(404, str(exc)) from exc
except Forbidden as exc:
raise HTTPException(403, str(exc)) from exc
except Invalid as exc:
raise HTTPException(422, str(exc)) from exc
class BoardIn(BaseModel):
title: str
description: str = ""
class ListIn(BaseModel):
name: str
is_done: bool = False
class MemberIn(BaseModel):
email: str
role: str = "member"
class CardIn(BaseModel):
title: str
list_name: str = "To do"
description: str = ""
assignee: Optional[str] = None
due_date: Optional[str] = None
labels: list[str] = Field(default_factory=list)
class CardPatch(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
assignee: Optional[str] = None
due_date: Optional[str] = None
labels: Optional[list[str]] = None
checklist: Optional[list[dict]] = None
class MoveIn(BaseModel):
list_name: str
position: Optional[int] = None
class AssignIn(BaseModel):
assignee: Optional[str] = None
class BlockerIn(BaseModel):
blocked_by_id: str
class ChecklistIn(BaseModel):
apply: bool = False
@r.get("/api/me")
def whoami(request: Request):
p = me(request)
return {"email": p.email}
@r.get("/api/agenda")
def my_agenda(request: Request, days: int = Query(7, ge=1, le=60)):
return run(boards.my_agenda, me(request), days=days)
@r.get("/api/overdue")
def list_overdue(request: Request, board_id: Optional[str] = None):
return run(boards.list_overdue, me(request), board_id=board_id)
@r.get("/api/search")
def search_cards(
request: Request,
q: str = Query(..., min_length=1),
board_id: Optional[str] = None,
limit: int = Query(6, ge=1, le=20),
):
return run(boards.search_similar_cards, me(request), q, board_id=board_id, limit=limit)
@r.get("/api/blockers")
def what_blocks(request: Request, target: str = Query(..., min_length=1), board_id: Optional[str] = None):
return run(boards.what_is_blocking, me(request), target, board_id=board_id)
@r.get("/api/boards")
def my_boards(request: Request):
return run(boards.my_boards, me(request))
@r.post("/api/boards", status_code=201)
def create_board(body: BoardIn, request: Request):
return run(boards.create_board, me(request), body.title, body.description)
@r.get("/api/boards/{board_id}")
def show_board(board_id: str, request: Request):
return run(boards.show_board, me(request), board_id)
@r.get("/api/boards/{board_id}/activity")
def board_activity(board_id: str, request: Request, limit: int = Query(40, ge=1, le=200)):
return run(boards.board_activity, me(request), board_id, limit=limit)
@r.post("/api/boards/{board_id}/summary")
def summarize_board(board_id: str, request: Request):
return run(boards.summarize_board, me(request), board_id)
@r.post("/api/boards/{board_id}/lists", status_code=201)
def add_list(board_id: str, body: ListIn, request: Request):
return run(boards.add_list, me(request), board_id, body.name, body.is_done)
@r.delete("/api/boards/{board_id}/lists/{list_name}")
def remove_list(board_id: str, list_name: str, request: Request):
return run(boards.remove_list, me(request), board_id, list_name)
@r.post("/api/boards/{board_id}/members", status_code=201)
def add_member(board_id: str, body: MemberIn, request: Request):
return run(boards.add_member, me(request), board_id, body.email, body.role)
@r.delete("/api/boards/{board_id}/members/{email}")
def remove_member(board_id: str, email: str, request: Request):
return run(boards.remove_member, me(request), board_id, email)
@r.post("/api/boards/{board_id}/cards", status_code=201)
def add_card(board_id: str, body: CardIn, request: Request):
return run(
boards.add_card,
me(request),
board_id,
body.title,
list_name=body.list_name,
description=body.description,
assignee=body.assignee,
due_date=body.due_date,
labels=body.labels,
)
@r.patch("/api/cards/{card_id}")
def update_card(card_id: str, body: CardPatch, request: Request):
fields = body.model_dump(exclude_unset=True)
return run(boards.update_card, me(request), card_id, **fields)
@r.post("/api/cards/{card_id}/assign")
def assign_card(card_id: str, body: AssignIn, request: Request):
return run(boards.assign_card, me(request), card_id, body.assignee)
@r.post("/api/cards/{card_id}/move")
def move_card(card_id: str, body: MoveIn, request: Request):
return run(boards.move_card, me(request), card_id, body.list_name, position=body.position)
@r.delete("/api/cards/{card_id}")
def delete_card(card_id: str, request: Request):
return run(boards.delete_card, me(request), card_id)
@r.get("/api/cards/{card_id}/history")
def card_history(card_id: str, request: Request):
return run(boards.card_history, me(request), card_id)
@r.post("/api/cards/{card_id}/blockers", status_code=201)
def add_blocker(card_id: str, body: BlockerIn, request: Request):
return run(boards.add_blocker, me(request), card_id, body.blocked_by_id)
@r.delete("/api/cards/{card_id}/blockers/{blocked_by_id}")
def remove_blocker(card_id: str, blocked_by_id: str, request: Request):
return run(boards.remove_blocker, me(request), card_id, blocked_by_id)
@r.post("/api/cards/{card_id}/checklist")
def suggest_checklist(card_id: str, request: Request, body: Optional[ChecklistIn] = None):
apply_flag = bool(body.apply) if body else False
return run(boards.suggest_checklist, me(request), card_id, apply=apply_flag)
@r.post("/", include_in_schema=False)
def ignite_self_check():
"""Used by `dodil ignite invoke <app>` to verify live DataK3 credentials & schema."""
return boards.self_check()
@r.get("/", response_class=HTMLResponse)
def page(request: Request):
if person_from(request.headers) is None:
return RedirectResponse("/.dodil/auth/login?return_to=/", status_code=303)
return PAGE
return r
PAGE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KanvaFlow — Team Boards on Dodil</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #263349;
--card-bg: #ffffff;
--col-bg: #f1f5f9;
--text: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
--accent: #4f46e5;
--accent-hover: #4338ca;
--emerald: #059669;
--danger: #dc2626;
--amber: #d97706;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
background: #f8fafc; color: var(--text); min-height: 100vh; display: flex; flex-direction: column;
}
/* TOPBAR */
.topbar {
background: var(--bg); color: #fff; height: 58px; padding: 0 20px;
display: flex; align-items: center; justify-content: space-between; gap: 14px;
box-shadow: 0 2px 10px rgba(15,23,42,0.18); position: sticky; top: 0; z-index: 40;
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.05rem; letter-spacing: -0.01em; }
.brand-icon {
width: 30px; height: 30px; border-radius: 7px;
background: linear-gradient(135deg, #6366f1, #10b981);
display: grid; place-items: center; font-weight: 800; font-size: 0.9rem; color: #fff;
}
.search-wrap { flex: 1; max-width: 420px; display: flex; gap: 6px; }
.search-wrap input {
width: 100%; background: var(--surface); border: 1px solid #334155; color: #fff;
padding: 7px 12px; border-radius: 6px; font-size: 0.85rem;
}
.search-wrap input::placeholder { color: #94a3b8; }
.top-right { display: flex; align-items: center; gap: 10px; font-size: 0.83rem; }
.user-pill { background: var(--surface); padding: 5px 11px; border-radius: 99px; color: #cbd5e1; border: 1px solid #334155; }
.btn {
border: none; border-radius: 6px; padding: 7px 13px; font-size: 0.83rem; font-weight: 600;
cursor: pointer; transition: all 0.15s ease; display: inline-flex; align-items: center; gap: 6px;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-ghost { background: #fff; color: var(--text); border: 1px solid var(--border); }
.btn-ghost:hover { background: #f1f5f9; }
.btn-dark { background: var(--surface); color: #e2e8f0; border: 1px solid #334155; }
.btn-dark:hover { background: var(--surface-2); }
.btn-danger { background: #fee2e2; color: var(--danger); }
.btn-sm { padding: 4px 9px; font-size: 0.76rem; }
/* LAYOUT */
.workspace { display: flex; flex: 1; min-height: calc(100vh - 58px); }
.sidebar {
width: 250px; background: #fff; border-right: 1px solid var(--border);
padding: 16px; display: flex; flex-direction: column; gap: 14px; flex-shrink: 0;
}
.side-title { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.board-nav { list-style: none; display: flex; flex-direction: column; gap: 4px; }
.board-nav li button {
width: 100%; text-align: left; background: transparent; border: none; padding: 9px 10px;
border-radius: 6px; cursor: pointer; font-size: 0.88rem; font-weight: 500;
display: flex; justify-content: space-between; align-items: center; color: var(--text);
}
.board-nav li button:hover { background: #f1f5f9; }
.board-nav li button.active { background: #eef2ff; color: var(--accent); font-weight: 700; }
.role-tag { font-size: 0.68rem; padding: 2px 6px; border-radius: 4px; background: #e2e8f0; color: #334155; text-transform: uppercase; }
.new-board-box { display: flex; flex-direction: column; gap: 6px; padding-top: 10px; border-top: 1px solid var(--border); }
.input {
width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px;
font-size: 0.86rem; font-family: inherit; background: #fff;
}
/* MAIN STAGE */
.stage { flex: 1; padding: 20px 24px; overflow-x: auto; display: flex; flex-direction: column; gap: 16px; }
.board-header {
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;
background: #fff; padding: 14px 18px; border-radius: 10px; border: 1px solid var(--border);
}
.board-title-wrap h1 { font-size: 1.3rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.board-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.banner {
background: #eef2ff; border: 1px solid #c7d2fe; color: #1e1b4b;
padding: 14px 16px; border-radius: 8px; font-size: 0.88rem; line-height: 1.5;
display: none; justify-content: space-between; align-items: flex-start; gap: 12px;
}
/* KANBAN COLUMNS */
.columns { display: flex; gap: 16px; align-items: flex-start; overflow-x: auto; padding-bottom: 24px; flex: 1; }
.column {
width: 305px; flex-shrink: 0; background: var(--col-bg); border-radius: 10px;
border: 1px solid var(--border); padding: 12px; display: flex; flex-direction: column; gap: 10px;
max-height: calc(100vh - 190px);
}
.column.drag-over { outline: 2px dashed var(--accent); background: #eef2ff; }
.col-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; font-size: 0.88rem; padding: 2px 4px; }
.col-count { background: #cbd5e1; color: #1e293b; border-radius: 99px; padding: 1px 8px; font-size: 0.74rem; }
.card-list { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; min-height: 38px; padding: 2px; }
.card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px;
padding: 11px 12px; box-shadow: 0 1px 2px rgba(15,23,42,0.04); cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.card:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(15,23,42,0.08); border-color: #cbd5e1; }
.card.dragging { opacity: 0.45; }
.card-labels { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
.label-chip {
font-size: 0.68rem; font-weight: 700; padding: 2px 7px; border-radius: 99px;
background: #e0e7ff; color: #3730a3;
}
.card-title { font-weight: 600; font-size: 0.9rem; line-height: 1.35; margin-bottom: 7px; word-break: break-word; }
.card-badges { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; font-size: 0.73rem; color: var(--muted); }
.badge-overdue { background: #fee2e2; color: var(--danger); font-weight: 700; padding: 2px 6px; border-radius: 4px; }
.badge-blocked { background: #fef3c7; color: var(--amber); font-weight: 700; padding: 2px 6px; border-radius: 4px; }
.badge-due { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }
.badge-user { margin-left: auto; background: #ecfdf5; color: var(--emerald); font-weight: 600; padding: 2px 7px; border-radius: 99px; }
/* MODALS */
.backdrop {
position: fixed; inset: 0; background: rgba(15,23,42,0.55); z-index: 80;
display: none; align-items: center; justify-content: center; padding: 18px;
}
.backdrop.open { display: flex; }
.modal {
background: #fff; border-radius: 12px; width: 100%; max-width: 680px; max-height: 90vh;
overflow-y: auto; padding: 22px 24px; box-shadow: 0 24px 60px rgba(15,23,42,0.3);
display: flex; flex-direction: column; gap: 14px;
}
.modal-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field label { display: block; font-size: 0.75rem; font-weight: 700; color: var(--muted); text-transform: uppercase; margin-bottom: 4px; }
.checklist-box, .history-box { background: #f8fafc; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.cl-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 0.86rem; }
.hist-item { font-size: 0.78rem; color: var(--muted); padding: 4px 0; border-bottom: 1px solid #eef2f7; }
.hist-item strong { color: var(--text); }
@media (max-width: 780px) {
.workspace { flex-direction: column; }
.sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
.grid-2 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-icon">K</span>
<span>KanvaFlow</span>
</div>
<form class="search-wrap" onsubmit="runSearch(event)">
<input id="searchInput" placeholder="Search cards by meaning (Dodil Models AI)..." aria-label="Semantic search">
<button class="btn btn-dark" type="submit">Search</button>
</form>
<div class="top-right">
<button class="btn btn-dark" onclick="openAgenda()">My Week</button>
<span class="user-pill" id="mePill">...</span>
<a class="btn btn-dark" href="/.dodil/auth/logout" style="text-decoration:none;">Sign out</a>
</div>
</header>
<div class="workspace">
<aside class="sidebar">
<div class="side-title">Your Boards</div>
<ul class="board-nav" id="boardNav"></ul>
<form class="new-board-box" onsubmit="createBoard(event)">
<div class="side-title">Create Board</div>
<input class="input" id="newBoardTitle" placeholder="e.g. Q4 Product Launch" required>
<input class="input" id="newBoardDesc" placeholder="Short description (optional)">
<button class="btn btn-primary" type="submit" style="justify-content:center;">+ New Board</button>
</form>
<div style="margin-top:auto; font-size:0.75rem; color:var(--muted); border-top:1px solid var(--border); padding-top:10px;">
<strong>Claude / ChatGPT MCP:</strong><br>
Connect to <code id="mcpUrl">/mcp</code>
</div>
</aside>
<main class="stage">
<div id="infoBanner" class="banner">
<div id="infoBannerText"></div>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('infoBanner').style.display='none'">Close</button>
</div>
<div id="boardContainer"></div>
</main>
</div>
<!-- CARD DETAIL MODAL -->
<div class="backdrop" id="cardModal">
<div class="modal" id="cardModalBody"></div>
</div>
<!-- TEAM / ACTIVITY / AGENDA MODAL -->
<div class="backdrop" id="auxModal">
<div class="modal" id="auxModalBody"></div>
</div>
<script>
let STATE = { me: '', boards: [], current: null, overdueOnly: false, dragCardId: null };
document.getElementById('mcpUrl').textContent = location.origin + '/mcp';
async function api(url, opts = {}) {
const res = await fetch(url, {
headers: { 'content-type': 'application/json', ...(opts.headers || {}) },
...opts,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || ('HTTP ' + res.status));
return data;
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function showBanner(html) {
const b = document.getElementById('infoBanner');
document.getElementById('infoBannerText').innerHTML = html;
b.style.display = 'flex';
}
async function boot() {
try {
const me = await api('/api/me');
STATE.me = me.email;
document.getElementById('mePill').textContent = me.email;
await refreshBoards();
} catch (err) {
showBanner('<strong>Error:</strong> ' + esc(err.message));
}
}
async function refreshBoards(selectId = null) {
STATE.boards = await api('/api/boards');
const nav = document.getElementById('boardNav');
if (!STATE.boards.length) {
nav.innerHTML = '<li style="font-size:0.83rem;color:#64748b;">No boards yet — create your first board below.</li>';
document.getElementById('boardContainer').innerHTML =
'<div class="board-header"><div><h2>Welcome to KanvaFlow</h2><p style="color:#64748b;font-size:0.9rem;margin-top:4px;">Create a board in the left sidebar or ask Claude/ChatGPT over MCP to create one for you.</p></div></div>';
return;
}
const targetId = selectId || (STATE.current && STATE.boards.some(b => b.id === STATE.current.id) ? STATE.current.id : STATE.boards[0].id);
await loadBoard(targetId);
}
function renderSidebar() {
const nav = document.getElementById('boardNav');
nav.innerHTML = STATE.boards.map(b => `
<li>
<button class="${STATE.current && STATE.current.id === b.id ? 'active' : ''}" onclick="loadBoard('${esc(b.id)}')">
<span>${esc(b.title)}${b.overdue_count ? ' <span style="color:#dc2626;">●</span>' : ''}</span>
<span class="role-tag">${esc(b.role)}</span>
</button>
</li>
`).join('');
}
async function createBoard(e) {
e.preventDefault();
const titleEl = document.getElementById('newBoardTitle');
const descEl = document.getElementById('newBoardDesc');
try {
const created = await api('/api/boards', {
method: 'POST',
body: JSON.stringify({ title: titleEl.value, description: descEl.value })
});
titleEl.value = ''; descEl.value = '';
await refreshBoards(created.id);
} catch (err) { alert(err.message); }
}
async function loadBoard(id) {
STATE.current = await api('/api/boards/' + encodeURIComponent(id));
renderSidebar();
renderBoard();
}
function canEdit() {
return STATE.current && (STATE.current.role === 'owner' || STATE.current.role === 'member');
}
function renderBoard() {
const b = STATE.current;
if (!b) return;
const editable = canEdit();
const overdueTotal = b.cards.filter(c => c.overdue).length;
const colsHtml = b.lists.map(lst => {
let cards = b.cards.filter(c => c.list_name === lst.name);
if (STATE.overdueOnly) cards = cards.filter(c => c.overdue);
const cardsHtml = cards.map(c => {
const doneSteps = (c.checklist || []).filter(i => i.done).length;
const totalSteps = (c.checklist || []).length;
return `
<div class="card" ${editable ? 'draggable="true"' : ''}
ondragstart="onDragStart(event, '${esc(c.id)}')"
onclick="openCard('${esc(c.id)}')">
${c.labels && c.labels.length ? `<div class="card-labels">${c.labels.map(l => `<span class="label-chip">${esc(l)}</span>`).join('')}</div>` : ''}
<div class="card-title">${esc(c.title)}</div>
<div class="card-badges">
<span style="font-family:monospace;">#${esc(c.id.slice(0,4))}</span>
${c.overdue ? `<span class="badge-overdue">OVERDUE ${esc(c.due_date)}</span>` : (c.due_date ? `<span class="badge-due">Due ${esc(c.due_date)}</span>` : '')}
${c.is_blocked ? `<span class="badge-blocked">BLOCKED (${c.unresolved_blockers.length})</span>` : ''}
${totalSteps ? `<span class="badge-due">✓ ${doneSteps}/${totalSteps}</span>` : ''}
${c.assignee ? `<span class="badge-user">${esc(c.assignee.split('@')[0])}</span>` : ''}
</div>
</div>
`;
}).join('');
return `
<section class="column"
ondragover="onDragOver(event)"
ondragleave="onDragLeave(event)"
ondrop="onDrop(event, '${esc(lst.name)}')">
<div class="col-head">
<span>${esc(lst.name)} ${lst.is_done ? '✓' : ''}</span>
<span class="col-count">${cards.length}</span>
</div>
<div class="card-list">${cardsHtml}</div>
${editable ? `
<form onsubmit="quickAddCard(event, '${esc(lst.name)}')" style="display:flex;gap:6px;margin-top:4px;">
<input class="input" name="title" placeholder="+ Add card..." required style="padding:6px 9px;font-size:0.82rem;">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
</form>
` : ''}
</section>
`;
}).join('');
document.getElementById('boardContainer').innerHTML = `
<div class="board-header">
<div class="board-title-wrap">
<h1>${esc(b.title)} <span class="role-tag">${esc(b.role)}</span></h1>
${b.description ? `<p style="font-size:0.85rem;color:#64748b;margin-top:2px;">${esc(b.description)}</p>` : ''}
</div>
<div class="board-actions">
<button class="btn ${STATE.overdueOnly ? 'btn-danger' : 'btn-ghost'}" onclick="toggleOverdue()">
Overdue (${overdueTotal})
</button>
<button class="btn btn-ghost" onclick="runBoardSummary()">✨ AI Daily Summary</button>
<button class="btn btn-ghost" onclick="openTeamModal()">Team (${b.members.length})</button>
<button class="btn btn-ghost" onclick="openActivityModal()">Activity</button>
${editable ? `<button class="btn btn-primary" onclick="promptNewList()">+ Add List</button>` : ''}
</div>
</div>
<div class="columns">${colsHtml}</div>
`;
}
function toggleOverdue() { STATE.overdueOnly = !STATE.overdueOnly; renderBoard(); }
function onDragStart(e, cardId) {
STATE.dragCardId = cardId;
e.dataTransfer.effectAllowed = 'move';
e.currentTarget.classList.add('dragging');
}
function onDragOver(e) {
if (!canEdit()) return;
e.preventDefault();
e.currentTarget.classList.add('drag-over');
}
function onDragLeave(e) { e.currentTarget.classList.remove('drag-over'); }
async function onDrop(e, listName) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
if (!canEdit() || !STATE.dragCardId) return;
const cid = STATE.dragCardId;
STATE.dragCardId = null;
try {
await api('/api/cards/' + encodeURIComponent(cid) + '/move', {
method: 'POST',
body: JSON.stringify({ list_name: listName })
});
await loadBoard(STATE.current.id);
await refreshBoards(STATE.current.id);
} catch (err) { alert(err.message); }
}
async function quickAddCard(e, listName) {
e.preventDefault();
const title = e.target.title.value.trim();
if (!title) return;
e.target.title.value = '';
try {
await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/cards`, {
method: 'POST',
body: JSON.stringify({ title, list_name: listName })
});
await refreshBoards(STATE.current.id);
} catch (err) { alert(err.message); }
}
async function promptNewList() {
const name = prompt('New list column name (e.g. Review, Blocked, QA):');
if (!name || !name.trim()) return;
const isDone = confirm('Treat cards in "' + name.trim() + '" as completed (Done)? Click OK for Yes, Cancel for No.');
try {
await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/lists`, {
method: 'POST',
body: JSON.stringify({ name: name.trim(), is_done: isDone })
});
await loadBoard(STATE.current.id);
} catch (err) { alert(err.message); }
}
async function runBoardSummary() {
if (!STATE.current) return;
showBanner('<em>Generating daily standup summary via Dodil Models (kimi-k2.6)...</em>');
try {
const res = await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/summary`, { method: 'POST' });
showBanner(`<div><strong>✨ AI Daily Summary — ${esc(res.board_title)}:</strong><br>${esc(res.summary)}</div>`);
} catch (err) {
showBanner('<strong>AI Summary Error:</strong> ' + esc(err.message));
}
}
async function runSearch(e) {
e.preventDefault();
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
try {
const items = await api('/api/search?q=' + encodeURIComponent(q));
if (!items.length) {
showBanner(`<strong>Search "${esc(q)}":</strong> No matching cards found across your boards.`);
return;
}
const rows = items.map(c =>
`<a href="#" onclick="jumpToCard('${esc(c.board_id)}','${esc(c.id)}');return false;" style="color:#4338ca;font-weight:600;">${esc(c.title)}</a> <small>(${esc(c.board_title)} • ${esc(c.list_name)} • score ${c.similarity})</small>`
).join('<br>');
showBanner(`<div><strong>Semantic Search Results for "${esc(q)}":</strong><br>${rows}</div>`);
} catch (err) { alert(err.message); }
}
async function jumpToCard(boardId, cardId) {
await loadBoard(boardId);
await openCard(cardId);
}
async function openCard(cardId) {
const b = STATE.current;
const c = b.cards.find(x => x.id === cardId);
if (!c) return;
const editable = canEdit();
const hist = await api(`/api/cards/${encodeURIComponent(cardId)}/history`);
const otherCards = b.cards.filter(x => x.id !== c.id && !(c.blocked_by || []).includes(x.id));
const blockersHtml = (c.blocked_by || []).map(bid => {
const bc = b.cards.find(x => x.id === bid);
const label = bc ? `${bc.title} (${bc.list_name})` : bid;
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:0.84rem;">
<span>⛔ ${esc(label)}</span>
${editable ? `<button class="btn btn-danger btn-sm" onclick="removeBlocker('${esc(c.id)}','${esc(bid)}')">Unlink</button>` : ''}
</div>`;
}).join('') || '<div style="font-size:0.82rem;color:#64748b;">No blockers linked.</div>';
const checklistHtml = (c.checklist || []).map((item, idx) => `
<label class="cl-row">
<input type="checkbox" ${item.done ? 'checked' : ''} ${editable ? '' : 'disabled'} onchange="toggleCheck('${esc(c.id)}', ${idx}, this.checked)">
<span style="${item.done ? 'text-decoration:line-through;color:#64748b;' : ''}">${esc(item.text)}</span>
</label>
`).join('') || '<div style="font-size:0.82rem;color:#64748b;">No checklist steps yet.</div>';
document.getElementById('cardModalBody').innerHTML = `
<div class="modal-head">
<div>
<span style="font-family:monospace;font-size:0.78rem;color:#64748b;">Card #${esc(c.id)}</span>
<h3 style="font-size:1.15rem;">${esc(c.title)}</h3>
</div>
<button class="btn btn-ghost btn-sm" onclick="closeModal('cardModal')">✕</button>
</div>
<form onsubmit="saveCardDetails(event, '${esc(c.id)}')" style="display:flex;flex-direction:column;gap:10px;">
<div class="field">
<label>Title</label>
<input class="input" name="title" value="${esc(c.title)}" ${editable ? '' : 'disabled'} required>
</div>
<div class="field">
<label>Description</label>
<textarea class="input" name="description" rows="3" ${editable ? '' : 'disabled'} placeholder="Notes, acceptance criteria...">${esc(c.description)}</textarea>
</div>
<div class="grid-2">
<div class="field">
<label>List</label>
<select class="input" name="list_name" ${editable ? '' : 'disabled'}>
${b.lists.map(l => `<option value="${esc(l.name)}" ${l.name === c.list_name ? 'selected' : ''}>${esc(l.name)}</option>`).join('')}
</select>
</div>
<div class="field">
<label>Assignee (Email)</label>
<input class="input" name="assignee" list="memberEmails" value="${esc(c.assignee || '')}" ${editable ? '' : 'disabled'} placeholder="member@org.io">
<datalist id="memberEmails">${b.members.map(m => `<option value="${esc(m.email)}">`).join('')}</datalist>
</div>
<div class="field">
<label>Due Date</label>
<input class="input" type="date" name="due_date" value="${esc(c.due_date || '')}" ${editable ? '' : 'disabled'}>
</div>
<div class="field">
<label>Labels (comma-separated)</label>
<input class="input" name="labels" value="${esc((c.labels || []).join(', '))}" ${editable ? '' : 'disabled'} placeholder="ops, urgent, launch">
</div>
</div>
${editable ? `<div style="display:flex;justify-content:space-between;gap:8px;">
<button class="btn btn-primary" type="submit">Save Changes</button>
<button class="btn btn-danger" type="button" onclick="deleteCard('${esc(c.id)}')">Delete Card</button>
</div>` : ''}
</form>
<div class="checklist-box">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<strong style="font-size:0.84rem;">Checklist</strong>
${editable ? `<button class="btn btn-ghost btn-sm" onclick="aiChecklist('${esc(c.id)}')">✨ AI Generate Steps</button>` : ''}
</div>
${checklistHtml}
${editable ? `
<form onsubmit="addCheckItem(event, '${esc(c.id)}')" style="display:flex;gap:6px;margin-top:8px;">
<input class="input" name="step" placeholder="Add step..." style="padding:5px 8px;font-size:0.82rem;" required>
<button class="btn btn-ghost btn-sm" type="submit">Add</button>
</form>
` : ''}
</div>
<div class="checklist-box">
<strong style="font-size:0.84rem;display:block;margin-bottom:6px;">Dependencies (Blocked By)</strong>
${blockersHtml}
${editable && otherCards.length ? `
<form onsubmit="addBlocker(event, '${esc(c.id)}')" style="display:flex;gap:6px;margin-top:8px;">
<select class="input" name="blocked_by_id" style="padding:5px 8px;font-size:0.82rem;">
${otherCards.map(o => `<option value="${esc(o.id)}">${esc(o.title)} (${esc(o.list_name)})</option>`).join('')}
</select>
<button class="btn btn-ghost btn-sm" type="submit">+ Link Blocker</button>
</form>
` : ''}
</div>
<div class="history-box">
<strong style="font-size:0.84rem;display:block;margin-bottom:6px;">Card Audit History</strong>
${hist.map(h => `<div class="hist-item"><strong>${esc(h.actor)}</strong> ${esc(h.action)} <span style="float:right;">${esc((h.event_at || '').slice(0,19).replace('T',' '))}</span></div>`).join('') || '<div style="font-size:0.8rem;color:#64748b;">No history yet.</div>'}
</div>
`;
document.getElementById('cardModal').classList.add('open');
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
async function saveCardDetails(e, cardId) {
e.preventDefault();
const f = e.target;
const labels = f.labels.value.split(',').map(s => s.trim()).filter(Boolean);
try {
await api('/api/cards/' + encodeURIComponent(cardId), {
method: 'PATCH',
body: JSON.stringify({
title: f.title.value,
description: f.description.value,
assignee: f.assignee.value,
due_date: f.due_date.value,
labels
})
});
if (f.list_name.value !== STATE.current.cards.find(x => x.id === cardId).list_name) {
await api('/api/cards/' + encodeURIComponent(cardId) + '/move', {
method: 'POST',
body: JSON.stringify({ list_name: f.list_name.value })
});
}
closeModal('cardModal');
await refreshBoards(STATE.current.id);
} catch (err) { alert(err.message); }
}
async function toggleCheck(cardId, idx, checked) {
const c = STATE.current.cards.find(x => x.id === cardId);
const next = (c.checklist || []).map((it, i) => i === idx ? { ...it, done: checked } : it);
await api('/api/cards/' + encodeURIComponent(cardId), { method: 'PATCH', body: JSON.stringify({ checklist: next }) });
await loadBoard(STATE.current.id);
await openCard(cardId);
}
async function addCheckItem(e, cardId) {
e.preventDefault();
const c = STATE.current.cards.find(x => x.id === cardId);
const next = [...(c.checklist || []), { text: e.target.step.value.trim(), done: false }];
await api('/api/cards/' + encodeURIComponent(cardId), { method: 'PATCH', body: JSON.stringify({ checklist: next }) });
await loadBoard(STATE.current.id);
await openCard(cardId);
}
async function aiChecklist(cardId) {
try {
await api(`/api/cards/${encodeURIComponent(cardId)}/checklist`, {
method: 'POST',
body: JSON.stringify({ apply: true })
});
await loadBoard(STATE.current.id);
await openCard(cardId);
} catch (err) { alert('AI Checklist error: ' + err.message); }
}
async function addBlocker(e, cardId) {
e.preventDefault();
try {
await api(`/api/cards/${encodeURIComponent(cardId)}/blockers`, {
method: 'POST',
body: JSON.stringify({ blocked_by_id: e.target.blocked_by_id.value })
});
await loadBoard(STATE.current.id);
await openCard(cardId);
} catch (err) { alert(err.message); }
}
async function removeBlocker(cardId, blockerId) {
try {
await api(`/api/cards/${encodeURIComponent(cardId)}/blockers/${encodeURIComponent(blockerId)}`, { method: 'DELETE' });
await loadBoard(STATE.current.id);
await openCard(cardId);
} catch (err) { alert(err.message); }
}
async function deleteCard(cardId) {
if (!confirm('Delete this card permanently?')) return;
await api('/api/cards/' + encodeURIComponent(cardId), { method: 'DELETE' });
closeModal('cardModal');
await refreshBoards(STATE.current.id);
}
function openTeamModal() {
const b = STATE.current;
const isOwner = b.role === 'owner';
document.getElementById('auxModalBody').innerHTML = `
<div class="modal-head">
<h3>Team & Permissions — ${esc(b.title)}</h3>
<button class="btn btn-ghost btn-sm" onclick="closeModal('auxModal')">✕</button>
</div>
<div>
${b.members.map(m => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:0.88rem;">
<span><strong>${esc(m.email)}</strong></span>
<span style="display:flex;gap:8px;align-items:center;">
<span class="role-tag">${esc(m.role)}</span>
${isOwner && m.role !== 'owner' ? `<button class="btn btn-danger btn-sm" onclick="removeMember('${esc(m.email)}')">Remove</button>` : ''}
</span>
</div>
`).join('')}
</div>
${isOwner ? `
<form onsubmit="inviteMember(event)" style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;">
<input class="input" name="email" type="email" placeholder="teammate@company.com" required style="flex:1;">
<select class="input" name="role" style="width:130px;">
<option value="member">member (edit)</option>
<option value="viewer">viewer (read)</option>
</select>
<button class="btn btn-primary" type="submit">Invite</button>
</form>
` : '<p style="font-size:0.82rem;color:#64748b;">Only the board owner can invite or remove members.</p>'}
`;
document.getElementById('auxModal').classList.add('open');
}
async function inviteMember(e) {
e.preventDefault();
try {
await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/members`, {
method: 'POST',
body: JSON.stringify({ email: e.target.email.value, role: e.target.role.value })
});
await loadBoard(STATE.current.id);
openTeamModal();
} catch (err) { alert(err.message); }
}
async function removeMember(email) {
try {
await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/members/${encodeURIComponent(email)}`, { method: 'DELETE' });
await loadBoard(STATE.current.id);
openTeamModal();
} catch (err) { alert(err.message); }
}
async function openActivityModal() {
const items = await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/activity?limit=50`);
document.getElementById('auxModalBody').innerHTML = `
<div class="modal-head">
<h3>Board Activity Log — ${esc(STATE.current.title)}</h3>
<button class="btn btn-ghost btn-sm" onclick="closeModal('auxModal')">✕</button>
</div>
<div class="history-box">
${items.map(h => `<div class="hist-item"><strong>${esc(h.actor)}</strong> ${esc(h.action)} <span style="float:right;">${esc((h.event_at || '').slice(0,19).replace('T',' '))}</span></div>`).join('') || '<div>No activity recorded yet.</div>'}
</div>
`;
document.getElementById('auxModal').classList.add('open');
}
async function openAgenda() {
const ag = await api('/api/agenda?days=7');
const sec = (label, arr) => `<div style="margin-bottom:10px;"><strong style="font-size:0.84rem;">${label} (${arr.length})</strong>` +
(arr.length ? arr.map(c => `<div style="font-size:0.84rem;padding:4px 0;border-bottom:1px solid #f1f5f9;">
<a href="#" onclick="closeModal('auxModal');jumpToCard('${esc(c.board_id)}','${esc(c.id)}');return false;" style="color:#4338ca;font-weight:600;">${esc(c.title)}</a>
<span style="color:#64748b;"> — ${esc(c.board_title)} (${esc(c.list_name)}${c.due_date ? ', due ' + esc(c.due_date) : ''})</span>
</div>`).join('') : '<div style="font-size:0.8rem;color:#94a3b8;">None</div>') + '</div>';
document.getElementById('auxModalBody').innerHTML = `
<div class="modal-head">
<h3>My Plate This Week (${esc(ag.person)})</h3>
<button class="btn btn-ghost btn-sm" onclick="closeModal('auxModal')">✕</button>
</div>
${sec('🔴 Overdue', ag.overdue)}
${sec('📅 Due Within 7 Days', ag.due_this_week)}
${sec('⚡ In Progress (Doing)', ag.doing)}
${sec('📋 Other Assigned Cards', ag.backlog_assigned)}
`;
document.getElementById('auxModal').classList.add('open');
}
boot();
</script>
</body>
</html>
"""