1007 lines
31 KiB
HTML
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 & 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>
|
|
|