north-scape/relayboard
Relayboard: accessible team boards with audited actions, dependency maps, and shared web/MCP use cases.
A remix template · 0 remixes · Remix north-scape/relayboard
app/boards/doors/static/app.js
'use strict';
const $ = (id) => document.getElementById(id);
const state = {boards: [], board: null, filter: 'all', query: '', editing: null, email: null, request: 0, busy: false, checklistDraft: []};
const el = (tag, cls, text) => {const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n;};
const editable = () => state.board && state.board.role !== 'viewer';
function cardRevision(card) {
if (!card || !Number.isSafeInteger(card.revision) || card.revision < 1) throw new Error('Card revision is missing or invalid. Reload the board before editing.');
return card.revision;
}
function notice(message) {$('notice').textContent = message; $('notice').hidden = !message;}
async function api(path, method = 'GET', body) {
const response = await fetch(path, {method, credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, ...(body === undefined ? {} : {body: JSON.stringify(body)})});
let data; try {data = await response.json();} catch {throw new Error('The server returned an unexpected response. Please try again.');}
if (!response.ok) {
const detail = typeof data.detail === 'string' ? data.detail : response.status === 422 ? 'Please check the fields and try again.' : 'The request could not be completed.';
throw new Error(response.status === 401 ? 'Your session has expired. Reload the page to sign in again.' : detail);
}
return data;
}
function showDialog(id) {const d = $(id); d.querySelectorAll('.form-error').forEach(n => {n.hidden = true; n.textContent = '';}); if (!d.open) {d.dataset.generation = String(Number(d.dataset.generation || 0) + 1); d.showModal();}}
function formError(form, error) {const n = form.querySelector('.form-error'); n.textContent = error.message; n.hidden = false;}
function releaseSubmission(form, submission) {
if (form._submission !== submission) return;
delete form._submission; delete form.dataset.busy;
submission.controls.forEach(([control, disabled]) => {
control.disabled = control.dataset.asyncRead === 'true' ? Boolean(control._readToken) : disabled;
});
if (form.id === 'card-form') {
$('checklist-result').querySelectorAll('input').forEach(input => input.disabled = !editable());
$('use-checklist').disabled = !editable() || !$('checklist-result').querySelector('input:checked');
}
}
async function submit(form, action) {
if (form.dataset.busy === 'true') return;
const dialog = form.closest('dialog'); const generation = dialog?.dataset.generation;
const submission = {controls: [...form.querySelectorAll('button,input,textarea,select')].map(control => [control,control.disabled])};
form._submission = submission; form.dataset.busy = 'true'; submission.controls.forEach(([control]) => control.disabled = true);
try {await action();}
catch (e) {if (!dialog || (dialog.open && dialog.dataset.generation === generation)) formError(form,e); else notice(e.message);}
finally {releaseSubmission(form,submission);}
}
function nav() {
$('boards').replaceChildren();
state.boards.forEach(b => {const button = el('button', state.board?.id === b.id ? 'active' : '', b.title); button.title = b.title; if (state.board?.id === b.id) button.setAttribute('aria-current', 'page'); button.onclick = () => chooseBoard(b.id); $('boards').append(button);});
}
async function loadBoards(selectId) {
state.boards = await api('/api/boards'); nav();
const id = selectId || state.board?.id || state.boards[0]?.id;
if (id) await chooseBoard(id);
}
async function chooseBoard(id) {
const request = ++state.request;
$('board-content').setAttribute('aria-busy', 'true'); notice('');
try {const board = await api('/api/boards/' + encodeURIComponent(id)); if (request !== state.request) return; state.board = board; state.query = ''; $('search').value = ''; state.filter = 'all'; render(); nav();}
catch (e) {if (request === state.request) notice(e.message);}
finally {if (request === state.request) $('board-content').removeAttribute('aria-busy');}
}
async function refresh(id) {
const board = await api('/api/boards/' + encodeURIComponent(id));
if (state.board?.id === id) {state.board = board; render();}
}
function today() {return new Date().toISOString().slice(0,10);}
function isOverdue(card) {return card.is_overdue ?? Boolean(card.due_date && card.due_date < today() && card.list_name !== 'Done');}
function readableDate(value) {if (!value) return ''; const d = new Date(value + 'T12:00:00'); return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});}
function matches(card) {
const query = state.query.toLowerCase();
if (query && ![card.reference, card.title, card.description, card.assignee, ...(card.labels || [])].join(' ').toLowerCase().includes(query)) return false;
if (state.filter === 'overdue') return isOverdue(card);
if (state.filter === 'mine') {const end = new Date(today() + 'T00:00:00Z'); end.setUTCDate(end.getUTCDate() + 7); return card.assignee === state.email && card.list_name !== 'Done' && (!card.due_date || new Date(card.due_date + 'T00:00:00Z') < end);}
if (state.filter === 'week') {const end = new Date(today() + 'T00:00:00Z'); end.setUTCDate(end.getUTCDate() + 7); const due = new Date(card.due_date + 'T00:00:00Z'); return card.list_name !== 'Done' && card.due_date >= today() && due < end;}
return true;
}
function render() {
const b = state.board; if (!b) return;
$('board-title').textContent = b.title; $('breadcrumb').textContent = b.title;
$('board-subtitle').textContent = `${b.cards.length} ${b.cards.length === 1 ? 'card' : 'cards'} · A shared view of what comes next.`;
$('role').textContent = b.role; $('role').hidden = false;
['members-button','activity-button','summary-button','toolbar','board-tip'].forEach(id => $(id).hidden = false);
$('add-card').hidden = !editable();
$('all-count').textContent = b.cards.length; $('overdue-count').textContent = b.cards.filter(isOverdue).length;
document.querySelectorAll('[data-filter]').forEach(n => {const active = n.dataset.filter === state.filter; n.classList.toggle('active', active); n.setAttribute('aria-pressed', String(active));});
const columns = el('div', 'columns');
(b.lists || ['To do','Doing','Done']).forEach(list => {
const col = el('section','column'); col.setAttribute('aria-label', list);
const cards = b.cards.filter(c => c.list_name === list && matches(c));
const heading = el('h2','column-heading'); heading.append(el('span','dot'), document.createTextNode(list), el('span','count',cards.length));
if (editable()) {const add = el('button','icon-button','+'); add.setAttribute('aria-label',`Add card to ${list}`); add.onclick = () => openCard(null,list); heading.append(add);}
col.append(heading); cards.forEach(c => col.append(cardNode(c)));
if (!cards.length) col.append(el('p','column-empty',state.query || state.filter !== 'all' ? 'No matching cards' : list === 'Done' ? 'Good things take a little work.' : 'Room for the next step.'));
if (editable()) {
col.ondragover = e => {if (e.dataTransfer.types.includes('text/relay-card')) {e.preventDefault(); e.dataTransfer.dropEffect = 'move'; col.classList.add('drag-over');}};
col.ondragleave = e => {if (!col.contains(e.relatedTarget)) col.classList.remove('drag-over');};
col.ondrop = e => {e.preventDefault(); col.classList.remove('drag-over'); let dragged; try {dragged = JSON.parse(e.dataTransfer.getData('text/relay-card'));} catch {return;} const card = b.cards.find(c => c.id === dragged?.id); if (card && card.list_name !== list) moveCard({...card,revision:dragged.revision},list,dragged.boardId);};
}
columns.append(col);
});
$('board-content').replaceChildren(columns);
}
function cardNode(card) {
const boardId = state.board.id; const n = el('article','card'); n.dataset.id = card.id;
if (editable()) {n.draggable = true; n.ondragstart = e => {if (['SELECT','OPTION','INPUT'].includes(e.target.tagName)) {e.preventDefault(); return;} e.dataTransfer.setData('text/relay-card',JSON.stringify({id:card.id,revision:card.revision,boardId})); e.dataTransfer.effectAllowed = 'move'; n.classList.add('dragging');}; n.ondragend = () => {n.classList.remove('dragging'); document.querySelectorAll('.drag-over').forEach(c => c.classList.remove('drag-over'));};}
if (card.reference) n.append(el('span','card-reference',card.reference));
if (card.labels?.length) {const tags = el('div','tags'); card.labels.forEach(l => tags.append(el('span','tag',l))); n.append(tags);}
const title = el('button','card-title',card.title); title.onclick = () => openCard(card); title.setAttribute('aria-label',`${editable() ? 'Edit' : 'View'} card: ${card.title}`); n.append(title);
if (card.description) n.append(el('p','card-description',card.description));
if (card.checklist?.length) n.append(el('p','card-checklist-progress',`${card.checklist.filter(step => step.done).length}/${card.checklist.length} checklist steps complete`));
const unfinished = (card.blocked_by || []).filter(id => state.board.cards.some(c => c.id === id && c.list_name !== 'Done'));
if (unfinished.length && card.list_name !== 'Done') n.append(el('span','blocked-badge',`Blocked by ${unfinished.length} ${unfinished.length === 1 ? 'card' : 'cards'}`));
const footer = el('div','card-footer'); const due = el('span',isOverdue(card) ? 'due overdue' : 'due',card.due_date ? `${isOverdue(card) ? 'Overdue · ' : '◷ '}${readableDate(card.due_date)}` : 'No due date'); footer.append(due);
const avatar = el('span','avatar',card.assignee ? card.assignee.slice(0,2).toUpperCase() : '—'); avatar.title = card.assignee || 'Unassigned'; avatar.setAttribute('aria-label',avatar.title); footer.append(avatar); n.append(footer);
if (editable()) {const label = el('label','move-control','Move'); const select = el('select'); select.setAttribute('aria-label',`Move ${card.title} to list`); (state.board.lists || ['To do','Doing','Done']).forEach(l => {const option = el('option','',l); option.value = l; option.selected = l === card.list_name; select.append(option);}); select.onchange = () => moveCard(card,select.value,boardId); label.append(select); n.append(label);}
return n;
}
async function moveCard(card,list,bid) {
if (state.busy || !editable()) {render(); return;}
if (state.board.id !== bid || !state.board.cards.some(c => c.id === card?.id)) {notice('Card is no longer on this board. Reload before editing.'); render(); return;}
state.busy = true; notice('');
try {const expected_revision = cardRevision(card); await api(`/api/cards/${encodeURIComponent(card.id)}/move`,'POST',{list_name:list,expected_revision}); await refresh(bid);}
catch(e) {notice(e.message); render();} finally {state.busy = false;}
}
function openCard(card,list = 'To do') {
state.editing = card; state.checklistDraft = (card?.checklist || []).map(step => ({...step})); const form = $('card-form'); form.reset(); $('editor-title').textContent = card ? editable() ? 'Edit card' : 'Card details' : 'New card';
$('editor-reference').textContent = card?.reference || ''; $('editor-reference').hidden = !card?.reference;
const field = name => form.elements.namedItem(name);
const assignees = field('assignee'); assignees.replaceChildren(el('option','','Unassigned')); assignees.options[0].value = '';
const members = state.board.members || []; const emails = Array.isArray(members) ? members.filter(m => typeof m === 'string' || m.role !== 'viewer').map(m => typeof m === 'string' ? m : m.email) : Object.keys(members).filter(email => members[email] !== 'viewer');
if (card?.assignee && !emails.includes(card.assignee)) emails.push(card.assignee);
[...new Set(emails)].forEach(email => {const option = el('option','',email); option.value = email; assignees.append(option);});
const lists = field('list_name'); lists.replaceChildren(); (state.board.lists || ['To do','Doing','Done']).forEach(l => {const option = el('option','',l); option.value = l; lists.append(option);});
['title','description','assignee','due_date'].forEach(name => {field(name).value = card?.[name] || '';}); field('labels').value = (card?.labels || []).join(', '); lists.value = card?.list_name || list;
form.querySelectorAll('input,textarea,select').forEach(n => n.disabled = !editable()); lists.disabled = Boolean(card) || !editable();
$('dependency-area').hidden = !card; $('blocker-select').replaceChildren(); $('blocker-result').replaceChildren(); $('similar-result').replaceChildren();
if (card) state.board.cards.filter(c => c.id !== card.id).forEach(c => {const option = el('option','',`${c.title} (${c.list_name})`); option.value = c.id; option.selected = (card.blocked_by || []).includes(c.id); $('blocker-select').append(option);});
$('blocker-select').disabled = !editable(); $('save-blockers').hidden = !editable();
form.querySelector('[type=submit]').hidden = !editable(); $('ai-area').hidden = !card; $('checklist-result').replaceChildren();
$('task-checklist-area').hidden = !card; $('checklist-add').hidden = !editable(); $('use-checklist').hidden = true; $('use-checklist').disabled = true; $('checklist-feedback').textContent = '';
['checklist','similar','show-blockers'].forEach(id => $(id).disabled = false); $('checklist').textContent = '✳ Suggest a checklist';
renderChecklist(); showDialog('editor');
}
function editorCurrent(id, generation) {
return $('editor').open && state.editing?.id === id && $('editor').dataset.generation === generation;
}
function renderChecklist() {
const steps = state.checklistDraft; const locked = !editable() || $('card-form').dataset.busy === 'true'; $('task-checklist').replaceChildren();
$('checklist-progress').textContent = `${steps.filter(step => step.done).length} of ${steps.length} complete`;
if (!steps.length) $('task-checklist').append(el('li','small muted','No steps yet. Add one below or ask for a suggestion.'));
steps.forEach((step, index) => {
const row = el('li','checklist-step'); const done = el('input'); done.type = 'checkbox'; done.checked = step.done; done.disabled = locked;
done.setAttribute('aria-label',`Mark step ${index + 1} complete: ${step.text}`);
done.onchange = () => {if ($('card-form').dataset.busy === 'true') {done.checked = step.done; return;} step.done = done.checked; renderChecklist(); $('task-checklist').querySelectorAll('.checklist-step input[type=checkbox]')[index]?.focus();};
const content = editable() ? el('input','checklist-text') : el('span','checklist-text',step.text);
if (editable()) {content.value = step.text; content.disabled = locked; content.maxLength = 300; content.setAttribute('aria-label',`Checklist step ${index + 1}`); content.oninput = () => {if ($('card-form').dataset.busy === 'true') {content.value = step.text; return;} step.text = content.value; done.setAttribute('aria-label',`Mark step ${index + 1} complete: ${step.text}`);};}
row.classList.toggle('complete',step.done); row.append(done,content);
if (editable()) {const remove = el('button','icon-button','×'); remove.type = 'button'; remove.disabled = locked; remove.setAttribute('aria-label',`Remove checklist step ${index + 1}`); remove.onclick = () => {if ($('card-form').dataset.busy === 'true') return; steps.splice(index,1); renderChecklist(); const next = $('task-checklist').querySelectorAll('.checklist-text')[Math.min(index,steps.length - 1)]; (next || $('checklist-new-step')).focus();}; row.append(remove);}
$('task-checklist').append(row);
});
}
function addChecklistStep() {
if (!editable() || $('card-form').dataset.busy === 'true') return;
const input = $('checklist-new-step'); const text = input.value.trim();
if (!text) {input.focus(); return;}
if (state.checklistDraft.length >= 50) {formError($('card-form'),new Error('Use at most 50 checklist steps.')); return;}
state.checklistDraft.push({text,done:false}); input.value = ''; renderChecklist(); input.focus();
}
$('add-checklist-step').onclick = addChecklistStep;
$('checklist-new-step').onkeydown = e => {if (e.key === 'Enter') {e.preventDefault(); addChecklistStep();}};
$('use-checklist').onclick = () => {
if (!editable() || $('card-form').dataset.busy === 'true') return;
const selected = [...$('checklist-result').querySelectorAll('input:checked')];
const existing = new Set(state.checklistDraft.map(step => step.text.trim().toLowerCase())); const additions = [];
selected.forEach(input => {const text = input.dataset.step.trim(); const key = text.toLowerCase(); if (!existing.has(key)) {additions.push({text,done:false}); existing.add(key);}});
if (state.checklistDraft.length + additions.length > 50) {formError($('card-form'),new Error('Use at most 50 checklist steps. Select fewer suggestions.')); return;}
state.checklistDraft.push(...additions); selected.forEach(input => input.checked = false); $('use-checklist').disabled = true; renderChecklist();
$('checklist-feedback').textContent = additions.length ? `${additions.length} ${additions.length === 1 ? 'step added' : 'steps added'} to draft. Choose Save card to keep your changes.` : 'Those steps are already in this draft. Nothing was added.';
};
let pendingRemoval = null;
function showMembers() {
pendingRemoval = null; $('remove-member-form').hidden = true;
$('member-list').replaceChildren(); const members = state.board.members || [];
(Array.isArray(members) ? members : Object.entries(members).map(([email,role])=>({email,role}))).forEach(m => {
const li = el('li'); li.append(el('span','member-email',m.email),el('span','role-badge',m.role));
if (state.board.role === 'owner' && m.role !== 'owner') {
const remove = el('button','secondary remove-member','Remove'); remove.type = 'button'; remove.setAttribute('aria-label',`Remove access for ${m.email}`);
remove.onclick = () => {
if ($('remove-member-form').dataset.busy === 'true') return;
pendingRemoval = {boardId: state.board.id, email: m.email};
$('remove-member-message').textContent = `Remove ${m.email} from ${state.board.title}? They will lose board access and all of their cards on this board will become unassigned. Existing cards and activity history will be kept.`;
$('remove-member-form').querySelector('.form-error').hidden = true;
$('remove-member-form').hidden = false; $('cancel-member-removal').focus();
};
li.append(remove);
}
$('member-list').append(li);
});
$('member-form').hidden = state.board.role !== 'owner'; showDialog('people-dialog');
}
$('cancel-member-removal').onclick = () => {pendingRemoval = null; $('remove-member-form').hidden = true;};
$('remove-member-form').onsubmit = e => {
e.preventDefault(); const form = e.target; const removal = pendingRemoval;
if (!removal || state.board?.id !== removal.boardId || state.board.role !== 'owner') return;
const generation = $('people-dialog').dataset.generation;
submit(form, async () => {
await api(`/api/boards/${encodeURIComponent(removal.boardId)}/members`,'DELETE',{email:removal.email});
await refresh(removal.boardId);
if (state.board?.id === removal.boardId && $('people-dialog').open && $('people-dialog').dataset.generation === generation && pendingRemoval === removal) showMembers();
});
};
$('new-board').onclick = $('empty-create').onclick = () => {$('board-form').reset(); showDialog('board-dialog');};
$('add-card').onclick = () => openCard(null);
$('members-button').onclick = showMembers;
$('search').oninput = e => {state.query = e.target.value; render();};
document.querySelectorAll('[data-filter]').forEach(b => b.onclick = () => {state.filter = b.dataset.filter; render();});
document.querySelectorAll('dialog').forEach(d => d.addEventListener('close', () => {
d.dataset.generation = String(Number(d.dataset.generation || 0) + 1);
d.querySelectorAll('form').forEach(form => {if (form._submission) releaseSubmission(form,form._submission);});
const readIds = d.id === 'editor' ? ['checklist','similar','show-blockers'] : d.id === 'summary-dialog' ? ['summary-button'] : [];
readIds.forEach(id => {delete $(id)._readToken; $(id).disabled = false;});
}));
['checklist','similar','show-blockers','summary-button'].forEach(id => $(id).dataset.asyncRead = 'true');
document.querySelectorAll('.close-dialog').forEach(b => b.onclick = () => b.closest('dialog').close());
$('board-form').onsubmit = e => {e.preventDefault(); const form = e.target; const generation = $('board-dialog').dataset.generation; const navigation = state.request; submit(form, async () => {const result = await api('/api/boards','POST',{title: form.elements.title.value}); if ($('board-dialog').dataset.generation === generation) $('board-dialog').close(); if (state.request === navigation) await loadBoards(result.id); else {state.boards = await api('/api/boards'); nav();}});};
$('card-form').onsubmit = e => {e.preventDefault(); const form = e.target; const bid = state.board.id; const card = state.editing; const generation = $('editor').dataset.generation; submit(form, async () => {
const value = name => form.elements.namedItem(name).value;
const body = {title: value('title'),description:value('description'),assignee:value('assignee') || null,due_date:value('due_date') || null,labels:value('labels').split(',').map(s => s.trim()).filter(Boolean)};
if (!card) body.list_name = value('list_name'); else {body.expected_revision = cardRevision(card); body.checklist = state.checklistDraft.map(step => ({...step}));}
await api(card ? `/api/cards/${encodeURIComponent(card.id)}` : `/api/boards/${encodeURIComponent(bid)}/cards`,card ? 'PATCH' : 'POST',body);
if ($('editor').dataset.generation === generation) $('editor').close(); await refresh(bid);
});};
$('member-form').onsubmit = e => {e.preventDefault(); const form = e.target; const bid = state.board.id; const generation = $('people-dialog').dataset.generation; submit(form,async () => {await api(`/api/boards/${encodeURIComponent(bid)}/members`,'POST',{email:form.elements.email.value,role:form.elements.role.value}); await refresh(bid); if (state.board.id === bid && $('people-dialog').open && $('people-dialog').dataset.generation === generation) {form.reset(); showMembers();}});};
$('checklist').onclick = async () => {
const id = state.editing?.id; const button = $('checklist'); if (!id || button._readToken || $('card-form').dataset.busy === 'true') return; const generation = $('editor').dataset.generation;
const request = {}; button._readToken = request; const current = () => editorCurrent(id,generation) && button._readToken === request;
button.disabled = true; button.textContent = 'Thinking…'; $('checklist-result').replaceChildren(); $('use-checklist').hidden = true; $('checklist-feedback').textContent = '';
try {
const result = await api(`/api/cards/${encodeURIComponent(id)}/checklist`,'POST'); if (!current()) return;
result.suggested_checklist.forEach(step => {
const li = el('li'); const label = el('label'); const input = el('input'); input.type = 'checkbox'; input.checked = true; input.disabled = !editable() || $('card-form').dataset.busy === 'true'; input.dataset.step = step;
input.onchange = () => {$('use-checklist').disabled = !$('checklist-result').querySelector('input:checked');}; label.append(input,el('span','',step)); li.append(label); $('checklist-result').append(li);
});
$('use-checklist').hidden = !editable(); $('use-checklist').disabled = $('card-form').dataset.busy === 'true' || !result.suggested_checklist.length;
} catch(e) {if (current()) formError($('card-form'),e);}
finally {if (current()) {delete button._readToken; button.disabled = $('card-form').dataset.busy === 'true'; button.textContent = '✳ Suggest a checklist';}}
};
function auditValue(value, field) {
if (value == null) return field === 'assignee' ? 'Unassigned' : field === 'membership' ? 'No access' : 'Not set';
if (value === '') return 'Empty';
if (Array.isArray(value) && !value.length) return 'None';
return typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
}
function activityNode(event) {
const li = el('li','',event.action); const time = new Date(event.event_at);
li.append(el('small','',`${event.actor} · ${Number.isNaN(time.getTime()) ? event.event_at : time.toLocaleString()}`));
const changes = event.changes && typeof event.changes === 'object' && !Array.isArray(event.changes) ? Object.entries(event.changes) : [];
const fields = changes.filter(([, change]) => change && typeof change === 'object' && ('before' in change || 'after' in change));
if (fields.length) {
const details = el('details','audit-details'); details.append(el('summary','',`View ${fields.length} changed ${fields.length === 1 ? 'field' : 'fields'}`));
const values = el('dl','audit-fields');
fields.forEach(([field, change]) => {
values.append(el('dt','',field.replaceAll('_',' ')));
const row = el('dd'); row.append(el('span','audit-value',`Before: ${auditValue(change.before, field)}`), el('span','audit-value',`After: ${auditValue(change.after, field)}`)); values.append(row);
});
details.append(values); li.append(details);
}
return li;
}
let activityRequest = 0;
$('activity-button').onclick = async () => {
const bid = state.board.id; const request = ++activityRequest; const dialog = $('activity-dialog');
$('activity-list').replaceChildren(el('li','','Loading activity…')); showDialog('activity-dialog');
const generation = dialog.dataset.generation;
const current = () => request === activityRequest && state.board?.id === bid && dialog.open && dialog.dataset.generation === generation;
try {
const events = await api(`/api/boards/${encodeURIComponent(bid)}/activity`); if (!current()) return;
$('activity-list').replaceChildren(); if (!events.length) $('activity-list').append(el('li','','No activity yet.'));
events.forEach(event => $('activity-list').append(activityNode(event)));
} catch(e) {if (current()) $('activity-list').replaceChildren(el('li','',e.message));}
};
$('connect-help').onclick = () => {$('mcp-address').textContent = location.origin + '/mcp'; showDialog('help-dialog');};
loadBoards().catch(e => notice(e.message));
api('/api/me').then(me => {state.email = me.email; $('mine-filter').hidden = !me.email;}).catch(() => {});
let summaryRequest = 0;
$('summary-button').onclick = async () => {
const button = $('summary-button'); if (button._readToken) return; const token = {}; button._readToken = token; button.disabled = true;
const bid = state.board.id; const request = ++summaryRequest; $('summary-result').textContent = 'Putting your briefing together…'; showDialog('summary-dialog');
const generation = $('summary-dialog').dataset.generation;
const current = () => button._readToken === token && request === summaryRequest && state.board?.id === bid && $('summary-dialog').open && $('summary-dialog').dataset.generation === generation;
try {const result = await api(`/api/boards/${encodeURIComponent(bid)}/summary`,'POST'); if (current()) $('summary-result').textContent = result.summary;}
catch(e) {if (current()) $('summary-result').textContent = e.message;}
finally {if (button._readToken === token) {delete button._readToken; button.disabled = false;}}
};
$('similar').onclick = async () => {
const id = state.editing?.id; const button = $('similar'); if (!id || button._readToken || $('card-form').dataset.busy === 'true') return; const generation = $('editor').dataset.generation; const request = {}; button._readToken = request; const current = () => editorCurrent(id,generation) && button._readToken === request;
button.disabled = true; $('similar-result').replaceChildren();
try {const result = await api(`/api/cards/${encodeURIComponent(id)}/similar`,'POST'); if (!current()) return; if (!result.matches.length) $('similar-result').append(el('li','','No related cards found on this board.')); result.matches.forEach(m => $('similar-result').append(el('li','',`${m.card.title} · ${m.card.list_name}`)));}
catch(e) {if (current()) formError($('card-form'),e);} finally {if (current()) {delete button._readToken; button.disabled = $('card-form').dataset.busy === 'true';}}
};
$('show-blockers').onclick = async () => {
const id = state.editing?.id; const button = $('show-blockers'); if (!id || button._readToken || $('card-form').dataset.busy === 'true') return; const generation = $('editor').dataset.generation; const request = {}; button._readToken = request; const current = () => editorCurrent(id,generation) && button._readToken === request;
button.disabled = true; $('blocker-result').replaceChildren();
try {const result = await api(`/api/cards/${encodeURIComponent(id)}/blockers`); if (!current()) return; if (!result.is_blocked) $('blocker-result').append(el('li','','No unfinished blockers.')); result.blockers.forEach(c => $('blocker-result').append(el('li','',`${c.title} · ${c.list_name}`)));}
catch(e) {if (current()) formError($('card-form'),e);} finally {if (current()) {delete button._readToken; button.disabled = $('card-form').dataset.busy === 'true';}}
};
$('save-blockers').onclick = () => {
const card = state.editing; const bid = state.board.id; if (!card) return; const generation = $('editor').dataset.generation;
submit($('card-form'),async () => {
$('blocker-result').replaceChildren();
const result = await api(`/api/cards/${encodeURIComponent(card.id)}/blockers`,'POST',{blocked_by:[...$('blocker-select').selectedOptions].map(o => o.value),expected_revision:cardRevision(card)});
if (editorCurrent(card.id,generation)) state.editing = {...card,revision:result.revision,blocked_by:result.blocked_by};
await refresh(bid); if (editorCurrent(card.id,generation)) $('blocker-result').replaceChildren(el('li','','Dependencies saved.'));
});
};