Files
flowdeck/app/templates/board.html
T
bruno e707becbf8
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Successful in 4m17s
FlowDeck CI / docker (push) Successful in 41s
feat(nav): partial HTMX navigation — swap main content without full reload
Intercept internal links and load only .main-wrapper via htmx.ajax, keeping the sidebar/header shell in place. Adds history pushState/popstate handling, sidebar active-state resync and document title update. Moves page script blocks inside #main-content so they run on swap, and makes editor/board scripts idempotent. Routes navigateTo/breadcrumb/library/local-workspace through window.fdNavigate and fixes the duplicated navigateTo definition.
2026-09-12 21:26:56 -04:00

302 lines
12 KiB
HTML

{% extends "base.html" %}
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
{% block page_title %}Kanban board new{% endblock %}
{% block title_prefix %}Kanban board new{% endblock %}
{% block content %}
<!-- Cover image -->
<div class="page-cover">
<div class="page-cover-actions">
<button class="cover-btn">Change</button>
<button class="cover-btn">Reposition</button>
<button class="cover-btn">↓</button>
</div>
</div>
<!-- Page title -->
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">{{ fd_icon("folder",24) }}</span>
<h1>{{ repo }}</h1>
</div>
</div>
<!-- Description -->
<div class="page-description" x-data="{ hideDesc: false }">
<template x-if="!hideDesc">
<div>
<p>Use this Kanban board to track tasks across your project. Group by team, filter by status, and sort by priority.</p>
</div>
</template>
<button class="text-xs text-dim" @click="hideDesc = !hideDesc" style="margin-top: 4px;">
<span x-text="hideDesc ? 'Show description ▾' : 'Hide description ▴'"></span>
</button>
</div>
<!-- View tabs with HTMX -->
<div class="view-tabs" id="view-tabs"
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
hx-trigger="load" hx-target="#view-content" hx-swap="innerHTML">
<button class="view-tab active" data-view="kanban"
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">{{ fd_icon("bar-chart",14) }}</span> Kanban board
</button>
<button class="view-tab" data-view="detailed"
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">{{ fd_icon("list",14) }}</span> Detailed board
</button>
<button class="view-tab" data-view="table"
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">{{ fd_icon("align-left",14) }}</span> Table view
</button>
<button class="view-tab" data-view="status"
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">{{ fd_icon("trending-up",14) }}</span> Status overview
</button>
<button class="view-tab" data-view="teamload"
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">{{ fd_icon("users",14) }}</span> Team Load
</button>
</div>
<!-- Toolbar -->
<div class="view-toolbar" x-data="filterSystem()">
<template x-for="(f, i) in activeFilters" :key="i">
<div class="filter-pill">
<span x-text="f.property + ': ' + f.value"></span>
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
</div>
</template>
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
<span>Status: </span>
<span class="text-dim" x-text="statusFilterLabel"></span>
<span style="font-size:10px;">▾</span>
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
@click.stop>
<template x-for="s in statusOptions" :key="s.value">
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
<span x-show="statusFilters.includes(s.value)">✓</span>
</span>
<span class="option-color-dot" :style="'background:' + s.color"></span>
<span x-text="s.label"></span>
</div>
</template>
</div>
</div>
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
<div class="view-toolbar-spacer"></div>
<div style="position:relative;" x-data="sortSystem()">
<button class="toolbar-btn" @click="showSortPanel = !showSortPanel" title="Sort">⇅</button>
<div class="dropdown-panel" :class="{ visible: showSortPanel }" style="right:0; left:auto;" @click.stop>
<div class="sort-list">
<template x-for="(s, i) in sorts" :key="i">
<div class="sort-item">
<span class="sort-field">
<span x-text="s.field"></span>
</span>
<span class="sort-direction" @click="toggleDir(i)">
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
</span>
<button class="sort-delete" @click="removeSort(i)" title="Delete">{{ fd_icon("trash",14) }}</button>
</div>
</template>
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
<button class="dropdown-option" @click="clearSorts()" x-show="sorts.length > 0">Delete sort</button>
</div>
</div>
</div>
<button class="toolbar-btn" title="Properties">{{ fd_icon("zap",14) }}</button>
<button class="toolbar-btn" title="Search">{{ fd_icon("search",14) }}</button>
<button class="toolbar-btn">⋯</button>
<button class="btn-new" @click="showNewIssue()">New ▾</button>
</div>
<!-- New Issue Form (hidden) -->
<div id="new-issue-form" style="display:none; padding: 12px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border);"
x-data="newIssueForm()">
<div style="display:flex; gap:12px; align-items:center;">
<input type="text" placeholder="Issue title..." x-model="title"
style="flex:1; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px;
padding:8px 12px; color:var(--text-primary); font-size:14px; outline:none;"
@keydown.enter="create()">
<select x-model="status" style="background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px;
padding:8px; color:var(--text-primary); font-size:13px;">
<option value="todo">To-do</option>
<option value="progress">In progress</option>
<option value="done">Done</option>
</select>
<button class="btn-new" @click="create()">Create</button>
<button class="toolbar-btn" @click="hide()">✕</button>
</div>
</div>
<!-- Dynamic view content -->
<div id="view-content">
<!-- Loaded via HTMX -->
</div>
{% endblock %}
{% block scripts %}
<script>
var owner = '{{ owner }}';
var repo = '{{ repo }}';
var initialView = '{{ initial_view }}';
// Auto-switch to view from URL param
document.addEventListener('DOMContentLoaded', function() {
if (initialView && initialView !== 'kanban') {
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
if (tab) tab.click();
}
});
function setActiveTab(el) {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
}
function kanbanBoard() {
return {
collapsedGroups: [],
toggleGroup(id) {
const idx = this.collapsedGroups.indexOf(id);
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
},
newCard(groupId, status) {
document.getElementById('new-issue-form').style.display = 'block';
document.querySelector('#new-issue-form').__x.$data.status = status;
},
newGroup() { console.log('New group'); }
};
}
function filterSystem() {
return {
activeFilters: [],
statusFilters: [],
showStatusMenu: false,
statusOptions: [
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
{ value: 'done', label: 'Complete', color: 'var(--green)' },
],
get statusFilterLabel() {
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
},
toggleStatus(val) {
const idx = this.statusFilters.indexOf(val);
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
},
addFilter() {
const prop = prompt('Filter by property (status, assignee, label):');
if (!prop) return;
const val = prompt('Value:');
if (!val) return;
this.activeFilters.push({ property: prop, value: val });
this.refreshView();
},
removeFilter(i) { this.activeFilters.splice(i, 1); },
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
refreshView() {
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
}
};
}
function sortSystem() {
return {
sorts: [],
showSortPanel: false,
addSort() {
const field = prompt('Sort by (name, status, assignee, deadline):');
if (!field) return;
this.sorts.push({ field, dir: 'asc' });
this.applySorts();
},
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
clearSorts() { this.sorts = []; this.applySorts(); },
applySorts() {
const activeTab = document.querySelector('.view-tab.active');
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
}
};
}
function newIssueForm() {
return {
title: '', status: 'todo',
create() {
if (!this.title.trim()) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(r => r.json())
.then(data => {
this.title = '';
this.hide();
// Refresh current view
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
});
},
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
show() { document.getElementById('new-issue-form').style.display = 'block'; }
};
}
function showNewIssue() {
const form = document.getElementById('new-issue-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
// Init SortableJS after HTMX swaps
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.target.id === 'view-content') {
document.querySelectorAll('.kanban-cards').forEach(el => {
if (el._sortable) el._sortable.destroy();
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 200,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: function(evt) {
const cardId = evt.item.dataset.cardId;
const toStatus = evt.to.dataset.status;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(() => {
// ponytail: refresh current view after move
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
});
}
});
});
}
});
</script>
{% endblock %}