Files
truss/templates/board.html
2026-08-19 01:30:58 +00:00

1007 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ header.title }}</title>
<style>
/* ------------------------------------------------------------------ */
/* Reset & base */
/* ------------------------------------------------------------------ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--border: #dfe1e6;
--text: #172b4d;
--muted: #6b778c;
--track-it: #0052cc;
--track-os: #36b37e;
--track-both: #6554c0;
--backlog: #dfe1e6;
--inprogress: #ffc400;
--inreview: #00b8d9;
--done: #36b37e;
--card-shadow: 0 1px 3px rgba(9,30,66,0.08);
--card-hover: 0 2px 6px rgba(9,30,66,0.14);
--radius: 8px;
--danger: #de350b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
/* ------------------------------------------------------------------ */
/* Flash messages */
/* ------------------------------------------------------------------ */
.flash-msg {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
z-index: 10000;
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.flash-msg.show { opacity: 1; }
.flash-msg.ok { background: #e3fcef; color: #006644; border: 1px solid #36b37e; }
.flash-msg.err { background: #ffebe6; color: #bf2600; border: 1px solid #de350b; }
/* ------------------------------------------------------------------ */
/* Header */
/* ------------------------------------------------------------------ */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 24px 32px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
header .header-left h1 {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
header h1 .logo {
background: linear-gradient(135deg, #0052cc, #6554c0);
color: #fff;
width: 32px; height: 32px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
}
header .subtitle {
color: var(--muted);
margin-top: 4px;
font-size: 0.95rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
border-radius: 5px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease;
}
.btn:active { transform: scale(0.97); }
.btn-reload { background: #ebecf0; color: var(--muted); }
.btn-reload:hover { background: #dfe1e6; }
/* ------------------------------------------------------------------ */
/* Legend bar */
/* ------------------------------------------------------------------ */
.legend-bar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 32px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
font-size: 0.85rem;
color: var(--muted);
}
.legend-bar strong { color: var(--text); }
.track-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
}
.track-it { background: var(--track-it); }
.track-os { background: var(--track-os); }
.track-both { background: var(--track-both); }
/* ------------------------------------------------------------------ */
/* Legend table */
/* ------------------------------------------------------------------ */
.legend-table-wrap {
padding: 20px 32px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.legend-table-wrap h2 {
font-size: 1rem;
margin-bottom: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
table.legend {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
table.legend th {
text-align: left;
padding: 8px 12px;
border-bottom: 2px solid var(--border);
color: var(--muted);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
table.legend td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
table.legend tr:last-child td { border-bottom: none; }
.status-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: #fff;
}
.status-backlog { background: var(--backlog); color: #42526e; }
.status-inprogress { background: var(--inprogress); color: #172b4d; }
.status-inreview { background: var(--inreview); }
.status-done { background: var(--done); }
/* ------------------------------------------------------------------ */
/* Board */
/* ------------------------------------------------------------------ */
.board {
display: flex;
gap: 20px;
padding: 24px 32px;
overflow-x: auto;
min-height: calc(100vh - 340px);
align-items: flex-start;
}
.column {
flex: 1;
min-width: 280px;
max-width: 380px;
background: #ebecf0;
border-radius: var(--radius);
padding: 12px;
display: flex;
flex-direction: column;
transition: background 0.2s ease;
}
.column.done { background: #e3fcef; }
.column.drag-over { background: #d6ece0; }
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px 8px;
}
.column-header h2 {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.column-count {
background: rgba(9,30,66,0.08);
color: var(--muted);
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
}
/* ------------------------------------------------------------------ */
/* Cards */
/* ------------------------------------------------------------------ */
.card-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 40px;
flex: 1;
}
.card {
background: var(--surface);
border-radius: 6px;
padding: 12px 14px;
box-shadow: var(--card-shadow);
cursor: grab;
transition: box-shadow 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
border-left: 3px solid var(--border);
position: relative;
user-select: none;
}
.card:active { cursor: grabbing; }
.card:hover {
box-shadow: var(--card-hover);
transform: translateY(-1px);
}
.card.dragging {
opacity: 0.4;
transform: rotate(1deg);
}
.card.checked {
opacity: 0.65;
}
.card.checked .description {
text-decoration: line-through;
text-decoration-color: var(--muted);
}
.card.track-it { border-left-color: var(--track-it); }
.card.track-os { border-left-color: var(--track-os); }
.card.track-both { border-left-color: var(--track-both); }
.card .card-meta {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
padding-right: 50px; /* room for action buttons */
}
.card .codename {
font-weight: 700;
font-size: 0.9rem;
}
.card .description {
font-size: 0.82rem;
color: var(--muted);
line-height: 1.4;
}
.card-actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.card:hover .card-actions { opacity: 1; }
.card-actions button {
width: 24px; height: 24px;
border: none;
border-radius: 3px;
background: transparent;
cursor: pointer;
font-size: 0.8rem;
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s ease, color 0.12s ease;
}
.card-actions button:hover { background: #ebecf0; color: var(--text); }
.card-actions .btn-delete:hover { background: #ffebe6; color: var(--danger); }
/* ------------------------------------------------------------------ */
/* Add-card button */
/* ------------------------------------------------------------------ */
.btn-add-card {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 10px;
margin-top: 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.btn-add-card:hover { background: rgba(9,30,66,0.06); color: var(--text); }
.btn-add-card .plus { font-size: 1.1rem; line-height: 1; }
/* ------------------------------------------------------------------ */
/* Modal */
/* ------------------------------------------------------------------ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(9,30,66,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.modal-overlay.open {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--surface);
border-radius: var(--radius);
padding: 28px;
width: 440px;
max-width: 94vw;
box-shadow: 0 8px 32px rgba(9,30,66,0.2);
}
.modal h3 {
font-size: 1.05rem;
margin-bottom: 18px;
}
.modal .field {
margin-bottom: 14px;
}
.modal label {
display: block;
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.modal input[type="text"],
.modal textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
color: var(--text);
background: #fafbfc;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.modal input[type="text"]:focus,
.modal textarea:focus {
outline: none;
border-color: var(--track-it);
box-shadow: 0 0 0 2px rgba(0,82,204,0.15);
}
.modal textarea { min-height: 70px; resize: vertical; }
.modal .field-row {
display: flex;
gap: 12px;
}
.modal .field-row .field { flex: 1; }
.modal .checkbox-field {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.modal .checkbox-field input { width: 16px; height: 16px; }
.modal .btn-row {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
.btn-primary {
background: var(--track-it);
color: #fff;
padding: 8px 18px;
}
.btn-primary:hover { background: #0043a8; }
.btn-secondary {
background: #ebecf0;
color: var(--muted);
padding: 8px 18px;
}
.btn-secondary:hover { background: #dfe1e6; }
.btn-danger {
background: var(--danger);
color: #fff;
padding: 8px 18px;
}
.btn-danger:hover { background: #bf2600; }
/* ------------------------------------------------------------------ */
/* Notes */
/* ------------------------------------------------------------------ */
.notes {
padding: 20px 32px 32px;
background: var(--surface);
border-top: 1px solid var(--border);
}
.notes h2 {
font-size: 0.95rem;
margin-bottom: 8px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.notes ul {
list-style: disc inside;
color: var(--muted);
font-size: 0.85rem;
display: flex;
flex-direction: column;
gap: 4px;
}
/* ------------------------------------------------------------------ */
/* Footer */
/* ------------------------------------------------------------------ */
footer {
text-align: center;
padding: 16px;
font-size: 0.78rem;
color: var(--muted);
}
footer a {
color: var(--track-it);
text-decoration: none;
}
/* ------------------------------------------------------------------ */
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 900px) {
.board { flex-direction: column; padding: 16px; }
.column { max-width: 100%; }
header, .legend-bar, .legend-table-wrap, .notes { padding-left: 16px; padding-right: 16px; }
}
</style>
</head>
<body>
<!-- ================================================================= -->
<!-- Header -->
<!-- ================================================================= -->
<header>
<div class="header-left">
<h1>
<span class="logo"></span>
{{ header.title }}
</h1>
<p class="subtitle">{{ header.subtitle | inline_md }}</p>
</div>
<button class="btn btn-reload" onclick="reloadBoard()" title="Reload from TODO.md">↻ Reload</button>
</header>
<!-- ================================================================= -->
<!-- Legend bar -->
<!-- ================================================================= -->
<div class="legend-bar">
<span><strong>Track:</strong></span>
<span class="track-badge track-it">IT</span> arcline.it
<span class="track-badge track-os">OS</span> arclineproject.org
<span class="track-badge track-both">IT/OS</span> shared
<span style="margin-left:auto">{{ header.flow_line }}</span>
</div>
<!-- ================================================================= -->
<!-- Codename Legend table -->
<!-- ================================================================= -->
{% if legend %}
<div class="legend-table-wrap">
<h2>Codename Legend</h2>
<table class="legend">
<thead>
<tr>
<th>Codename</th>
<th>Initiative</th>
<th>Track</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for item in legend %}
<tr>
<td><strong>{{ item.codename }}</strong></td>
<td>{{ item.initiative }}</td>
<td>
{% if item.track == "IT" %}
<span class="track-badge track-it">IT</span>
{% elif item.track == "OS" %}
<span class="track-badge track-os">OS</span>
{% elif "IT" in item.track and "OS" in item.track %}
<span class="track-badge track-both">{{ item.track }}</span>
{% else %}
{{ item.track }}
{% endif %}
</td>
<td>
{% set status_lower = item.status | lower | replace(' ', '') %}
<span class="status-pill status-{{ status_lower }}">{{ item.status }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- ================================================================= -->
<!-- Board columns -->
<!-- ================================================================= -->
<div class="board" id="board">
{% for col in columns %}
<div class="column{% if col.name == 'Done' %} done{% endif %}"
data-column="{{ col.name }}"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)"
ondrop="handleDrop(event)">
<div class="column-header">
<h2>{{ col.name }}</h2>
<span class="column-count" id="count-{{ col.name | replace(' ', '-') | lower }}">{{ col.cards|length }}</span>
</div>
<ul class="card-list" id="list-{{ col.name | replace(' ', '-') | lower }}">
{% for card in col.cards %}
<li class="card track-{{ ('both' if '/' in card.track else card.track | lower) }}{% if card.checked %} checked{% endif %}"
data-id="{{ card.id }}"
data-column="{{ col.name }}"
draggable="true"
ondragstart="handleDragStart(event)"
ondragend="handleDragEnd(event)"
onclick="openEditModal('{{ card.id }}')">
<div class="card-actions">
<button class="btn-delete" title="Delete" onclick="event.stopPropagation(); deleteCard('{{ card.id }}')"></button>
</div>
<div class="card-meta">
{% if card.track %}
{% set track_class = 'both' if '/' in card.track else card.track | lower %}
<span class="track-badge track-{{ track_class }}">{{ card.track }}</span>
{% endif %}
{% if card.codename %}
<span class="codename">{{ card.codename }}</span>
{% endif %}
</div>
<p class="description">{{ card.description }}</p>
</li>
{% endfor %}
</ul>
<button class="btn-add-card" onclick="openCreateModal('{{ col.name }}')">
<span class="plus">+</span> Add card
</button>
</div>
{% endfor %}
</div>
<!-- ================================================================= -->
<!-- Notes -->
<!-- ================================================================= -->
{% if notes %}
<div class="notes">
<h2>Notes</h2>
<ul>
{% for note in notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- ================================================================= -->
<!-- Footer -->
<!-- ================================================================= -->
<footer>
Built with Rust &amp; Minijinja ·
<a href="https://arcline.it">arcline.it</a> ·
<a href="https://arclineproject.org">arclineproject.org</a>
</footer>
<!-- ================================================================= -->
<!-- Card edit/create modal -->
<!-- ================================================================= -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<h3 id="modalTitle">Edit Card</h3>
<div class="field-row">
<div class="field">
<label for="fldTrack">Track</label>
<input type="text" id="fldTrack" placeholder="IT, OS, or IT/OS" maxlength="10">
</div>
<div class="field">
<label for="fldCodename">Codename</label>
<input type="text" id="fldCodename" placeholder="e.g. Keystone" maxlength="80">
</div>
</div>
<div class="field">
<label for="fldDescription">Description</label>
<textarea id="fldDescription" placeholder="What needs to be done?"></textarea>
</div>
<div class="checkbox-field">
<input type="checkbox" id="fldChecked">
<label for="fldChecked" style="margin-bottom:0">Done</label>
</div>
<input type="hidden" id="fldCardId">
<input type="hidden" id="fldColumn">
<div class="btn-row">
<button class="btn btn-danger" id="btnDelete" style="display:none; margin-right:auto;" onclick="deleteFromModal()">Delete</button>
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" id="btnSave" onclick="saveCard()">Save</button>
</div>
</div>
</div>
<!-- ================================================================= -->
<!-- Flash message toast -->
<!-- ================================================================= -->
<div class="flash-msg" id="flashMsg"></div>
<!-- ================================================================= -->
<!-- JavaScript -->
<!-- ================================================================= -->
<script>
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let draggedCardEl = null;
// ---------------------------------------------------------------------------
// Toast
// ---------------------------------------------------------------------------
function flash(text, ok = true) {
const el = document.getElementById('flashMsg');
el.textContent = text;
el.className = 'flash-msg ' + (ok ? 'ok' : 'err') + ' show';
clearTimeout(el._timeout);
el._timeout = setTimeout(() => el.classList.remove('show'), 2500);
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function api(url, opts = {}) {
const res = await fetch(url, opts);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json();
}
// ---------------------------------------------------------------------------
// Reload
// ---------------------------------------------------------------------------
async function reloadBoard() {
await api('/api/{{ board_slug }}/reload', { method: 'POST' });
location.reload();
}
// ---------------------------------------------------------------------------
// Update column count badge
// ---------------------------------------------------------------------------
function updateCount(columnName) {
const slug = columnName.replace(/ /g, '-').toLowerCase();
const list = document.getElementById('list-' + slug);
const badge = document.getElementById('count-' + slug);
if (badge && list) {
badge.textContent = list.children.length;
}
}
// ---------------------------------------------------------------------------
// Render card element
// ---------------------------------------------------------------------------
function cardEl(card, colName) {
const li = document.createElement('li');
const trackClass = card.track.includes('/') ? 'both' : card.track.toLowerCase();
li.className = 'card track-' + trackClass + (card.checked ? ' checked' : '');
li.setAttribute('data-id', card.id);
li.setAttribute('data-column', colName);
li.setAttribute('draggable', 'true');
li.addEventListener('dragstart', handleDragStart);
li.addEventListener('dragend', handleDragEnd);
li.addEventListener('click', () => openEditModal(card.id));
const actions = document.createElement('div');
actions.className = 'card-actions';
const delBtn = document.createElement('button');
delBtn.className = 'btn-delete';
delBtn.title = 'Delete';
delBtn.textContent = '✕';
delBtn.addEventListener('click', (e) => { e.stopPropagation(); deleteCard(card.id); });
actions.appendChild(delBtn);
li.appendChild(actions);
const meta = document.createElement('div');
meta.className = 'card-meta';
if (card.track) {
const badge = document.createElement('span');
badge.className = 'track-badge track-' + trackClass;
badge.textContent = card.track;
meta.appendChild(badge);
}
if (card.codename) {
const cn = document.createElement('span');
cn.className = 'codename';
cn.textContent = card.codename;
meta.appendChild(cn);
}
li.appendChild(meta);
const desc = document.createElement('p');
desc.className = 'description';
desc.textContent = card.description;
li.appendChild(desc);
return li;
}
// ---------------------------------------------------------------------------
// Create card
// ---------------------------------------------------------------------------
function openCreateModal(columnName) {
document.getElementById('modalTitle').textContent = 'New Card';
document.getElementById('fldCardId').value = '';
document.getElementById('fldColumn').value = columnName;
document.getElementById('fldTrack').value = '';
document.getElementById('fldCodename').value = '';
document.getElementById('fldDescription').value = '';
document.getElementById('fldChecked').checked = false;
document.getElementById('btnDelete').style.display = 'none';
document.getElementById('btnSave').textContent = 'Create';
document.getElementById('modalOverlay').classList.add('open');
}
// ---------------------------------------------------------------------------
// Edit card (from modal or click)
// ---------------------------------------------------------------------------
function openEditModal(cardId) {
const el = document.querySelector(`.card[data-id="${cardId}"]`);
if (!el) return;
const colName = el.getAttribute('data-column');
const trackEl = el.querySelector('.track-badge');
const codenameEl = el.querySelector('.codename');
const descEl = el.querySelector('.description');
document.getElementById('modalTitle').textContent = 'Edit Card';
document.getElementById('fldCardId').value = cardId;
document.getElementById('fldColumn').value = colName;
document.getElementById('fldTrack').value = trackEl ? trackEl.textContent : '';
document.getElementById('fldCodename').value = codenameEl ? codenameEl.textContent : '';
document.getElementById('fldDescription').value = descEl ? descEl.textContent : '';
document.getElementById('fldChecked').checked = el.classList.contains('checked');
document.getElementById('btnDelete').style.display = '';
document.getElementById('btnSave').textContent = 'Save';
document.getElementById('modalOverlay').classList.add('open');
}
// ---------------------------------------------------------------------------
// Save card (create or update)
// ---------------------------------------------------------------------------
async function saveCard() {
const cardId = document.getElementById('fldCardId').value;
const column = document.getElementById('fldColumn').value;
const track = document.getElementById('fldTrack').value.trim();
const codename = document.getElementById('fldCodename').value.trim();
const description = document.getElementById('fldDescription').value.trim();
const checked = document.getElementById('fldChecked').checked;
if (!description) {
flash('Description is required.', false);
return;
}
try {
if (cardId) {
// Update existing card
const result = await api(`/api/{{ board_slug }}/cards/${cardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track, codename, description, checked })
});
// Update DOM element in-place
const el = document.querySelector(`.card[data-id="${cardId}"]`);
if (el) {
const newEl = cardEl(result.card, el.getAttribute('data-column'));
el.replaceWith(newEl);
} else {
location.reload();
}
flash('Card updated.');
} else {
// Create new card
const result = await api('/api/{{ board_slug }}/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ column, track, codename, description, checked })
});
const slug = column.replace(/ /g, '-').toLowerCase();
const list = document.getElementById('list-' + slug);
if (list) {
list.appendChild(cardEl(result.card, column));
updateCount(column);
}
flash('Card created.');
}
closeModal();
} catch (err) {
flash(err.message, false);
}
}
// ---------------------------------------------------------------------------
// Delete card
// ---------------------------------------------------------------------------
async function deleteCard(cardId) {
if (!confirm('Delete this card?')) return;
try {
await api(`/api/{{ board_slug }}/cards/${cardId}`, { method: 'DELETE' });
const el = document.querySelector(`.card[data-id="${cardId}"]`);
if (el) {
const colName = el.getAttribute('data-column');
el.remove();
updateCount(colName);
}
flash('Card deleted.');
closeModal();
} catch (err) {
flash(err.message, false);
}
}
function deleteFromModal() {
const cardId = document.getElementById('fldCardId').value;
if (cardId) deleteCard(cardId);
}
// ---------------------------------------------------------------------------
// Modal management
// ---------------------------------------------------------------------------
function closeModal(e) {
if (e && e.target !== document.getElementById('modalOverlay')) return;
document.getElementById('modalOverlay').classList.remove('open');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
// ---------------------------------------------------------------------------
// Drag & drop
// ---------------------------------------------------------------------------
function handleDragStart(e) {
draggedCardEl = e.currentTarget;
e.currentTarget.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', e.currentTarget.getAttribute('data-id'));
}
function handleDragEnd(e) {
e.currentTarget.classList.remove('dragging');
draggedCardEl = null;
document.querySelectorAll('.column').forEach(col => col.classList.remove('drag-over'));
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
e.currentTarget.classList.add('drag-over');
}
function handleDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
async function handleDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
const cardId = e.dataTransfer.getData('text/plain');
const targetColumn = e.currentTarget.getAttribute('data-column');
if (!cardId || !targetColumn) return;
const cardEl = document.querySelector(`.card[data-id="${cardId}"]`);
if (!cardEl) return;
const sourceColumn = cardEl.getAttribute('data-column');
if (sourceColumn === targetColumn) return;
try {
await api(`/api/{{ board_slug }}/cards/${cardId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ column: targetColumn })
});
// Move element in DOM
const slug = targetColumn.replace(/ /g, '-').toLowerCase();
const targetList = document.getElementById('list-' + slug);
if (targetList) {
cardEl.setAttribute('data-column', targetColumn);
targetList.appendChild(cardEl);
updateCount(sourceColumn);
updateCount(targetColumn);
}
flash(`Moved to ${targetColumn}.`);
} catch (err) {
flash(err.message, false);
}
}
</script>
</body>
</html>