const BASE_PATH = document.querySelector('meta[name="base-path"]')?.content ?? '';
const API_BASE = window.location.origin + BASE_PATH;
// ── Fetch ─────────────────────────────────────────────────────────────────────
async function fetchTasks() {
const res = await fetch(`${API_BASE}/api/tasks`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function fetchTemplates() {
const res = await fetch(`${API_BASE}/api/templates`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// ── Render ────────────────────────────────────────────────────────────────────
function formatDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function createTaskCard(task) {
const card = document.createElement('div');
card.className = 'task-card';
card.dataset.taskId = task.id;
// Header: name + state badge
const header = document.createElement('div');
header.className = 'task-card-header';
const name = document.createElement('span');
name.className = 'task-name';
name.textContent = task.name;
const badge = document.createElement('span');
badge.className = 'state-badge';
badge.dataset.state = task.state;
badge.textContent = task.state.replace(/_/g, ' ');
header.append(name, badge);
card.appendChild(header);
// Meta: priority + created_at
const meta = document.createElement('div');
meta.className = 'task-meta';
if (task.priority) {
const prio = document.createElement('span');
prio.textContent = task.priority;
meta.appendChild(prio);
}
if (task.created_at) {
const when = document.createElement('span');
when.textContent = formatDate(task.created_at);
meta.appendChild(when);
}
if (meta.children.length) card.appendChild(meta);
// Description (truncated via CSS)
if (task.description) {
const desc = document.createElement('div');
desc.className = 'task-description';
desc.textContent = task.description;
card.appendChild(desc);
}
// Footer: Run button (only for PENDING / FAILED)
if (task.state === 'PENDING' || task.state === 'FAILED') {
const footer = document.createElement('div');
footer.className = 'task-card-footer';
const btn = document.createElement('button');
btn.className = 'btn-run';
btn.textContent = 'Run';
btn.addEventListener('click', (e) => {
e.stopPropagation();
handleRun(task.id, btn, footer);
});
footer.appendChild(btn);
card.appendChild(footer);
}
card.addEventListener('click', () => openTaskPanel(task.id));
return card;
}
// ── Filter ────────────────────────────────────────────────────────────────────
const HIDE_STATES = new Set(['COMPLETED', 'FAILED']);
let showHiddenFold = false;
function filterTasks(tasks, hideCompletedFailed = false) {
if (!hideCompletedFailed) return tasks;
return tasks.filter(t => !HIDE_STATES.has(t.state));
}
function getHideCompletedFailed() {
const stored = localStorage.getItem('hideCompletedFailed');
return stored === null ? true : stored === 'true';
}
function setHideCompletedFailed(val) {
localStorage.setItem('hideCompletedFailed', String(val));
}
function updateToggleButton() {
const btn = document.getElementById('btn-toggle-completed');
if (!btn) return;
btn.textContent = getHideCompletedFailed()
? 'Show completed/failed'
: 'Hide completed/failed';
}
function renderTaskList(tasks) {
const container = document.querySelector('.task-list');
if (!tasks || tasks.length === 0) {
container.innerHTML = '
No tasks found.
';
return;
}
const hide = getHideCompletedFailed();
const visible = filterTasks(tasks, hide);
const hiddenCount = tasks.length - visible.length;
// Replace contents with task cards
container.innerHTML = '';
for (const task of visible) {
container.appendChild(createTaskCard(task));
}
if (hiddenCount > 0) {
const info = document.createElement('button');
info.className = 'hidden-tasks-info';
const arrow = showHiddenFold ? '▼' : '▶';
info.textContent = `${arrow} ${hiddenCount} hidden task${hiddenCount === 1 ? '' : 's'}`;
info.addEventListener('click', () => {
showHiddenFold = !showHiddenFold;
renderTaskList(tasks);
});
container.appendChild(info);
if (showHiddenFold) {
const fold = document.createElement('div');
fold.className = 'hidden-tasks-fold';
const hiddenTasks = tasks.filter(t => HIDE_STATES.has(t.state));
for (const task of hiddenTasks) {
fold.appendChild(createTaskCard(task));
}
container.appendChild(fold);
}
}
}
function createTemplateCard(tmpl) {
const card = document.createElement('div');
card.className = 'template-card';
const name = document.createElement('div');
name.className = 'template-name';
name.textContent = tmpl.name;
card.appendChild(name);
if (tmpl.description) {
const desc = document.createElement('div');
desc.className = 'template-description';
desc.textContent = tmpl.description;
card.appendChild(desc);
}
if (tmpl.tags && tmpl.tags.length > 0) {
const tagsEl = document.createElement('div');
tagsEl.className = 'template-tags';
for (const tag of tmpl.tags) {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.textContent = tag;
tagsEl.appendChild(chip);
}
card.appendChild(tagsEl);
}
const footer = document.createElement('div');
footer.className = 'template-card-footer';
const delBtn = document.createElement('button');
delBtn.className = 'btn-danger btn-sm';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', () => deleteTemplate(tmpl.id));
footer.appendChild(delBtn);
card.appendChild(footer);
return card;
}
function renderTemplateList(templates) {
const container = document.querySelector('.template-list');
if (!templates || templates.length === 0) {
container.innerHTML = 'No templates yet.
';
return;
}
container.innerHTML = '';
for (const tmpl of templates) {
container.appendChild(createTemplateCard(tmpl));
}
}
// ── Run action ────────────────────────────────────────────────────────────────
async function runTask(taskId) {
const res = await fetch(`${API_BASE}/api/tasks/${taskId}/run`, { method: 'POST' });
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
throw new Error(msg);
}
return res.json();
}
async function handleRun(taskId, btn, footer) {
btn.disabled = true;
btn.textContent = 'Queuing…';
// Remove any previous error
const prev = footer.querySelector('.task-error');
if (prev) prev.remove();
try {
await runTask(taskId);
// Refresh list immediately so state flips to QUEUED
const tasks = await fetchTasks();
renderTaskList(tasks);
} catch (err) {
btn.disabled = false;
btn.textContent = 'Run';
const errEl = document.createElement('span');
errEl.className = 'task-error';
errEl.textContent = `Failed to queue: ${err.message}`;
footer.appendChild(errEl);
}
}
// ── Delete template ────────────────────────────────────────────────────────────
async function deleteTemplate(id) {
if (!window.confirm('Delete this template?')) return;
const res = await fetch(`${API_BASE}/api/templates/${id}`, { method: 'DELETE' });
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
alert(`Failed to delete: ${msg}`);
return;
}
const templates = await fetchTemplates();
renderTemplateList(templates);
}
// ── Polling ───────────────────────────────────────────────────────────────────
async function poll() {
try {
const tasks = await fetchTasks();
renderTaskList(tasks);
} catch {
document.querySelector('.task-list').innerHTML =
'Could not reach server.
';
}
}
function startPolling(intervalMs = 10_000) {
poll();
setInterval(poll, intervalMs);
}
// ── Elaborate (Draft with AI) ─────────────────────────────────────────────────
async function elaborateTask(prompt) {
const res = await fetch(`${API_BASE}/api/tasks/elaborate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try { const body = await res.json(); msg = body.error || msg; } catch {}
throw new Error(msg);
}
return res.json();
}
// ── Task modal ────────────────────────────────────────────────────────────────
function openTaskModal() {
document.getElementById('task-modal').showModal();
}
function closeTaskModal() {
document.getElementById('task-modal').close();
document.getElementById('task-form').reset();
document.getElementById('elaborate-prompt').value = '';
}
async function createTask(formData) {
const body = {
name: formData.get('name'),
description: '',
claude: {
model: formData.get('model'),
instructions: formData.get('instructions'),
working_dir: formData.get('working_dir'),
max_budget_usd: parseFloat(formData.get('max_budget_usd')),
},
timeout: formData.get('timeout'),
priority: formData.get('priority'),
tags: [],
};
const res = await fetch(`${API_BASE}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
closeTaskModal();
const tasks = await fetchTasks();
renderTaskList(tasks);
}
// ── Template modal ────────────────────────────────────────────────────────────
function openTemplateModal() {
document.getElementById('template-modal').showModal();
}
function closeTemplateModal() {
document.getElementById('template-modal').close();
document.getElementById('template-form').reset();
}
async function saveTemplate(formData) {
const splitTrim = val => val.split(',').map(s => s.trim()).filter(Boolean);
const body = {
name: formData.get('name'),
description: formData.get('description'),
claude: {
model: formData.get('model'),
instructions: formData.get('instructions'),
working_dir: formData.get('working_dir'),
max_budget_usd: parseFloat(formData.get('max_budget_usd')),
allowed_tools: splitTrim(formData.get('allowed_tools') || ''),
},
timeout: formData.get('timeout'),
priority: formData.get('priority'),
tags: splitTrim(formData.get('tags') || ''),
};
const res = await fetch(`${API_BASE}/api/templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
closeTemplateModal();
const templates = await fetchTemplates();
renderTemplateList(templates);
}
// ── Task side panel ───────────────────────────────────────────────────────────
// Format Go's task.Duration JSON value {"Duration": } to human string.
function formatDurationNs(timeout) {
const ns = timeout && timeout.Duration;
if (!ns) return '—';
const secs = ns / 1e9;
if (secs < 60) return `${secs.toFixed(1)}s`;
const mins = Math.floor(secs / 60);
const remSecs = Math.floor(secs % 60);
if (mins < 60) return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`;
const hrs = Math.floor(mins / 60);
const remMins = mins % 60;
return remMins > 0 ? `${hrs}h ${remMins}m` : `${hrs}h`;
}
function formatDateLong(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function openTaskPanel(taskId) {
const panel = document.getElementById('task-panel');
const backdrop = document.getElementById('task-panel-backdrop');
const content = document.getElementById('task-panel-content');
document.getElementById('task-panel-title').textContent = 'Task Details';
content.innerHTML = '';
const loading = document.createElement('div');
loading.className = 'panel-loading';
loading.textContent = 'Loading…';
content.appendChild(loading);
backdrop.hidden = false;
panel.classList.add('open');
Promise.all([
fetch(`${API_BASE}/api/tasks/${taskId}`).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}),
fetch(`${API_BASE}/api/tasks/${taskId}/executions`).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}),
]).then(([task, executions]) => {
renderTaskPanel(task, executions);
}).catch(err => {
content.innerHTML = '';
const errEl = document.createElement('div');
errEl.className = 'panel-fetch-error';
errEl.textContent = `Failed to load: ${err.message}`;
content.appendChild(errEl);
});
}
function closeTaskPanel() {
document.getElementById('task-panel').classList.remove('open');
document.getElementById('task-panel-backdrop').hidden = true;
}
function makeSection(title) {
const section = document.createElement('div');
section.className = 'panel-section';
const hdr = document.createElement('div');
hdr.className = 'panel-section-title';
hdr.textContent = title;
section.appendChild(hdr);
return section;
}
function makeMetaItem(label, valueText, opts = {}) {
const item = document.createElement('div');
item.className = 'meta-item' + (opts.fullWidth ? ' full-width' : '');
const lbl = document.createElement('div');
lbl.className = 'meta-label';
lbl.textContent = label;
item.appendChild(lbl);
if (opts.badge) {
const badge = document.createElement('span');
badge.className = 'state-badge';
badge.dataset.state = valueText;
badge.textContent = valueText.replace(/_/g, ' ');
item.appendChild(badge);
} else if (opts.code) {
const pre = document.createElement('pre');
pre.className = 'panel-code';
pre.textContent = valueText;
item.appendChild(pre);
} else if (opts.tags) {
const wrap = document.createElement('div');
if (opts.tags.length > 0) {
wrap.className = 'panel-tags';
for (const tag of opts.tags) {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.textContent = tag;
wrap.appendChild(chip);
}
} else {
wrap.className = 'meta-value muted';
wrap.textContent = '—';
}
item.appendChild(wrap);
} else {
const val = document.createElement('div');
val.className = 'meta-value' + (opts.mono ? ' mono' : '') + (opts.muted ? ' muted' : '');
val.textContent = valueText || '—';
item.appendChild(val);
}
return item;
}
function renderTaskPanel(task, executions) {
document.getElementById('task-panel-title').textContent = task.name;
const content = document.getElementById('task-panel-content');
content.innerHTML = '';
// ── Overview ──
const overview = makeSection('Overview');
const overviewGrid = document.createElement('div');
overviewGrid.className = 'meta-grid';
overviewGrid.append(
makeMetaItem('State', task.state, { badge: true }),
makeMetaItem('Priority', task.priority),
makeMetaItem('Created', formatDateLong(task.created_at)),
makeMetaItem('Updated', formatDateLong(task.updated_at)),
makeMetaItem('ID', task.id, { fullWidth: true, mono: true }),
);
if (task.parent_task_id) {
overviewGrid.append(makeMetaItem('Parent Task', task.parent_task_id, { fullWidth: true, mono: true }));
}
if (task.tags && task.tags.length >= 0) {
overviewGrid.append(makeMetaItem('Tags', '', { fullWidth: true, tags: task.tags || [] }));
}
if (task.description) {
overviewGrid.append(makeMetaItem('Description', task.description, { fullWidth: true }));
}
overview.appendChild(overviewGrid);
content.appendChild(overview);
// ── Claude Config ──
const c = task.claude || {};
const claudeSection = makeSection('Claude Config');
const claudeGrid = document.createElement('div');
claudeGrid.className = 'meta-grid';
claudeGrid.append(
makeMetaItem('Model', c.model),
makeMetaItem('Max Budget', c.max_budget_usd != null ? `$${c.max_budget_usd.toFixed(2)}` : '—'),
makeMetaItem('Working Dir', c.working_dir),
makeMetaItem('Permission Mode', c.permission_mode || 'default'),
);
if (c.allowed_tools && c.allowed_tools.length > 0) {
claudeGrid.append(makeMetaItem('Allowed Tools', c.allowed_tools.join(', '), { fullWidth: true }));
}
if (c.disallowed_tools && c.disallowed_tools.length > 0) {
claudeGrid.append(makeMetaItem('Disallowed Tools', c.disallowed_tools.join(', '), { fullWidth: true }));
}
if (c.instructions) {
claudeGrid.append(makeMetaItem('Instructions', c.instructions, { fullWidth: true, code: true }));
}
if (c.system_prompt_append) {
claudeGrid.append(makeMetaItem('System Prompt Append', c.system_prompt_append, { fullWidth: true, code: true }));
}
claudeSection.appendChild(claudeGrid);
content.appendChild(claudeSection);
// ── Execution Settings ──
const settingsSection = makeSection('Execution Settings');
const settingsGrid = document.createElement('div');
settingsGrid.className = 'meta-grid';
settingsGrid.append(
makeMetaItem('Timeout', formatDurationNs(task.timeout)),
makeMetaItem('Retry Attempts', String(task.retry ? task.retry.max_attempts : 1)),
makeMetaItem('Backoff', task.retry ? task.retry.backoff : '—'),
);
if (task.depends_on && task.depends_on.length > 0) {
settingsGrid.append(makeMetaItem('Depends On', task.depends_on.join(', '), { fullWidth: true, mono: true }));
}
settingsSection.appendChild(settingsGrid);
content.appendChild(settingsSection);
// ── Executions ──
const execSection = makeSection('Executions');
if (!executions || executions.length === 0) {
const none = document.createElement('div');
none.className = 'meta-value muted';
none.textContent = 'No executions yet.';
execSection.appendChild(none);
} else {
const list = document.createElement('div');
list.className = 'executions-list';
// Newest first
for (const exec of [...executions].reverse()) {
const row = document.createElement('div');
row.className = 'execution-row';
const shortId = document.createElement('span');
shortId.className = 'execution-id';
shortId.textContent = exec.ID ? exec.ID.slice(0, 8) : '—';
row.appendChild(shortId);
const badge = document.createElement('span');
badge.className = 'state-badge';
badge.dataset.state = exec.Status || '';
badge.textContent = (exec.Status || '—').replace(/_/g, ' ');
row.appendChild(badge);
const times = document.createElement('span');
times.className = 'execution-times';
const start = exec.StartTime ? formatDate(exec.StartTime) : '?';
const end = exec.EndTime && exec.EndTime !== '0001-01-01T00:00:00Z' ? formatDate(exec.EndTime) : '…';
times.textContent = `${start} → ${end}`;
row.appendChild(times);
if (exec.CostUSD != null && exec.CostUSD > 0) {
const cost = document.createElement('span');
cost.className = 'execution-cost';
cost.textContent = `$${exec.CostUSD.toFixed(4)}`;
row.appendChild(cost);
}
const exitEl = document.createElement('span');
exitEl.className = 'execution-exit';
exitEl.textContent = `exit: ${exec.ExitCode ?? '—'}`;
row.appendChild(exitEl);
const logsBtn = document.createElement('button');
logsBtn.className = 'btn-view-logs';
logsBtn.textContent = 'View Logs';
logsBtn.addEventListener('click', () => handleViewLogs(exec.ID));
row.appendChild(logsBtn);
list.appendChild(row);
}
execSection.appendChild(list);
}
content.appendChild(execSection);
}
async function handleViewLogs(execId) {
const modal = document.getElementById('logs-modal');
const body = document.getElementById('logs-modal-body');
document.getElementById('logs-modal-title').textContent = `Execution ${execId.slice(0, 8)}`;
body.innerHTML = 'Loading…
';
modal.showModal();
try {
const res = await fetch(`${API_BASE}/api/executions/${execId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const exec = await res.json();
body.innerHTML = '';
const grid = document.createElement('div');
grid.className = 'meta-grid';
const entries = [
['ID', exec.ID, { fullWidth: true, mono: true }],
['Status', exec.Status, { badge: true }],
['Exit Code', String(exec.ExitCode ?? '—'), {}],
['Cost', exec.CostUSD > 0 ? `$${exec.CostUSD.toFixed(4)}` : '—', {}],
['Start', formatDateLong(exec.StartTime), {}],
['End', exec.EndTime && !exec.EndTime.startsWith('0001-') ? formatDateLong(exec.EndTime) : '—', {}],
['Error', exec.ErrorMsg || '—', { fullWidth: true }],
['Stdout', exec.StdoutPath || '—', { fullWidth: true, mono: true }],
['Stderr', exec.StderrPath || '—', { fullWidth: true, mono: true }],
];
for (const [label, value, opts] of entries) {
grid.appendChild(makeMetaItem(label, value, opts));
}
body.appendChild(grid);
} catch (err) {
body.innerHTML = `Failed to load: ${err.message}
`;
}
}
// ── Tab switching ─────────────────────────────────────────────────────────────
function switchTab(name) {
// Update tab button active state
document.querySelectorAll('.tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === name);
});
// Show/hide panels
document.querySelectorAll('[data-panel]').forEach(panel => {
if (panel.dataset.panel === name) {
panel.removeAttribute('hidden');
} else {
panel.setAttribute('hidden', '');
}
});
// Show/hide the header New Task button (only relevant on tasks tab)
document.getElementById('btn-new-task').style.display =
name === 'tasks' ? '' : 'none';
if (name === 'templates') {
fetchTemplates().then(renderTemplateList).catch(() => {
document.querySelector('.template-list').innerHTML =
'Could not reach server.
';
});
}
}
// ── Boot ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
updateToggleButton();
document.getElementById('btn-toggle-completed').addEventListener('click', async () => {
setHideCompletedFailed(!getHideCompletedFailed());
updateToggleButton();
await poll();
});
startPolling();
// Side panel close
document.getElementById('btn-close-panel').addEventListener('click', closeTaskPanel);
document.getElementById('task-panel-backdrop').addEventListener('click', closeTaskPanel);
// Execution logs modal close
document.getElementById('btn-close-logs').addEventListener('click', () => {
document.getElementById('logs-modal').close();
});
// Tab bar
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// Task modal
document.getElementById('btn-new-task').addEventListener('click', openTaskModal);
document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal);
// Draft with AI button
const btnElaborate = document.getElementById('btn-elaborate');
btnElaborate.addEventListener('click', async () => {
const prompt = document.getElementById('elaborate-prompt').value.trim();
if (!prompt) {
const form = document.getElementById('task-form');
// Remove previous error
const prev = form.querySelector('.form-error');
if (prev) prev.remove();
const errEl = document.createElement('p');
errEl.className = 'form-error';
errEl.textContent = 'Please enter a description before drafting.';
form.querySelector('.elaborate-section').appendChild(errEl);
return;
}
btnElaborate.disabled = true;
btnElaborate.textContent = 'Drafting…';
// Remove any previous errors or banners
const form = document.getElementById('task-form');
form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove());
try {
const result = await elaborateTask(prompt);
// Populate form fields
const f = document.getElementById('task-form');
if (result.name)
f.querySelector('[name="name"]').value = result.name;
if (result.claude && result.claude.instructions)
f.querySelector('[name="instructions"]').value = result.claude.instructions;
if (result.claude && result.claude.working_dir)
f.querySelector('[name="working_dir"]').value = result.claude.working_dir;
if (result.claude && result.claude.model)
f.querySelector('[name="model"]').value = result.claude.model;
if (result.claude && result.claude.max_budget_usd != null)
f.querySelector('[name="max_budget_usd"]').value = result.claude.max_budget_usd;
if (result.timeout)
f.querySelector('[name="timeout"]').value = result.timeout;
if (result.priority) {
const sel = f.querySelector('[name="priority"]');
if ([...sel.options].some(o => o.value === result.priority)) {
sel.value = result.priority;
}
}
// Show success banner
const banner = document.createElement('p');
banner.className = 'elaborate-banner';
banner.textContent = 'AI draft ready — review and submit.';
document.getElementById('task-form').querySelector('.elaborate-section').appendChild(banner);
} catch (err) {
const errEl = document.createElement('p');
errEl.className = 'form-error';
errEl.textContent = `Elaboration failed: ${err.message}`;
document.getElementById('task-form').querySelector('.elaborate-section').appendChild(errEl);
} finally {
btnElaborate.disabled = false;
btnElaborate.textContent = 'Draft with AI ✦';
}
});
document.getElementById('task-form').addEventListener('submit', async e => {
e.preventDefault();
// Remove any previous error
const prev = e.target.querySelector('.form-error');
if (prev) prev.remove();
const btn = e.submitter;
btn.disabled = true;
btn.textContent = 'Creating…';
try {
await createTask(new FormData(e.target));
} catch (err) {
const errEl = document.createElement('p');
errEl.className = 'form-error';
errEl.textContent = err.message;
e.target.appendChild(errEl);
} finally {
btn.disabled = false;
btn.textContent = 'Create & Queue';
}
});
// Template modal
document.getElementById('btn-new-template').addEventListener('click', openTemplateModal);
document.getElementById('btn-cancel-template').addEventListener('click', closeTemplateModal);
document.getElementById('template-form').addEventListener('submit', async e => {
e.preventDefault();
// Remove any previous error
const prev = e.target.querySelector('.form-error');
if (prev) prev.remove();
const btn = e.submitter;
btn.disabled = true;
btn.textContent = 'Saving…';
try {
await saveTemplate(new FormData(e.target));
} catch (err) {
const errEl = document.createElement('p');
errEl.className = 'form-error';
errEl.textContent = err.message;
e.target.appendChild(errEl);
} finally {
btn.disabled = false;
btn.textContent = 'Save Template';
}
});
});