feat: v0.5.0→v0.8.0 — multi-vues, filtres, tri, modale, checklists
v0.5.0 (Kanban complet):
- Card detail modal avec checklists + commentaires Gitea
- Création d'issues inline (formulaire + POST /api/issues)
- Édition inline (contenteditable)
v0.6.0 (Table view):
- table_view.html: colonnes Name/Status/Assign/Deadline/Team/Keywords
- Tri par colonne (clic en-tête, asc/desc)
- Groupes rétractables (Design/Engineering)
v0.7.0 (Filtres & Tri):
- Filtres cumulables AND (backend _apply_filters + UI pastilles)
- Status dropdown avec checkboxes + dots colorés
- Sort panel multi-critères (+ Add sort / Delete sort)
- Backend _apply_sorts avec asc/desc
v0.8.0 (Vues spéciales):
- status_overview.html: SVG donut chart + légende + pourcentages
- team_load.html: barres empilées par avatar
- detailed_board.html: cartes avec toutes propriétés
Backend:
- /board/{o}/{r}/view/{view} — 5 fragments HTMX
- /api/issues/{o}/{r}/{id}?format=html — modal HTML
- _map_issue_to_card, STATUS_COLORS/LABELS exportés
This commit is contained in:
+187
-138
@@ -16,7 +16,7 @@
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📁</span>
|
||||
<h1>Kanban board new</h1>
|
||||
<h1>{{ repo }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,6 @@
|
||||
<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>
|
||||
<p style="margin-top: 8px;">
|
||||
<strong>Properties</strong> like Status, Assignee, and Deadline are synced with Gitea issues.<br>
|
||||
Enable <strong>Sub-groups</strong> to organize by team (Design, Engineering, etc.).
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<button class="text-xs text-dim" @click="hideDesc = !hideDesc" style="margin-top: 4px;">
|
||||
@@ -36,136 +32,131 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- View tabs -->
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab active" @click="switchView('kanban')">
|
||||
<!-- 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">📊</span> Kanban board
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('detailed')">
|
||||
<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">📋</span> Detailed board
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('table')">
|
||||
<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">☰</span> Table view
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('status')">
|
||||
<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">📈</span> Status overview
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('teamload')">
|
||||
<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">👥</span> Team Load
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="view-toolbar">
|
||||
<!-- Filter bar -->
|
||||
<div class="filter-bar" 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)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-pill" style="cursor:pointer;" @click="openFilterPanel()">
|
||||
<span>Status: </span>
|
||||
<span class="text-dim" x-text="statusFilter || 'All'"></span>
|
||||
<span style="font-size:10px;">▾</span>
|
||||
<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>
|
||||
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
||||
<button class="filter-reset-btn" @click="resetFilters()" x-show="activeFilters.length > 0">Reset</button>
|
||||
</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>
|
||||
|
||||
<button class="toolbar-btn" title="Sort">⇅</button>
|
||||
<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">🗑</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">⚡</button>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="toolbar-btn">⋯</button>
|
||||
<button class="btn-new">New ▾</button>
|
||||
<button class="btn-new" @click="showNewIssue()">New ▾</button>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
<div class="kanban-container" x-data="kanbanBoard()" id="kanban-root">
|
||||
<!-- To-do Column -->
|
||||
<div class="kanban-column">
|
||||
<div class="kanban-column-header">
|
||||
<span class="col-dot" style="background: var(--gray);"></span>
|
||||
To-do <span class="col-count">1</span>
|
||||
</div>
|
||||
{% for group in groups %}
|
||||
<div class="kanban-group-header" @click="toggleGroup('{{ group.id }}')">
|
||||
<span class="group-chevron" :class="{ 'open': !collapsedGroups.includes('{{ group.id }}') }">▶</span>
|
||||
{{ group.name }}
|
||||
<span class="group-count">{{ group.counts.todo }}</span>
|
||||
</div>
|
||||
<div class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
|
||||
data-group="{{ group.id }}" data-status="todo"
|
||||
id="kanban-{{ group.id }}-todo">
|
||||
{% for card in group.cards if card.status == 'todo' %}
|
||||
{% include "card.html" %}
|
||||
{% endfor %}
|
||||
<button class="kanban-new-card" @click="newCard('{{ group.id }}', 'todo')">+ New page</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button class="kanban-new-group" @click="newGroup()" style="margin-top: 16px;">+ New group</button>
|
||||
<!-- 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>
|
||||
|
||||
<!-- In Progress Column -->
|
||||
<div class="kanban-column">
|
||||
<div class="kanban-column-header">
|
||||
<span class="col-dot" style="background: var(--blue);"></span>
|
||||
In progress <span class="col-count">3</span>
|
||||
</div>
|
||||
{% for group in groups %}
|
||||
<div class="kanban-group-header" @click="toggleGroup('{{ group.id }}')">
|
||||
<span class="group-chevron" :class="{ 'open': !collapsedGroups.includes('{{ group.id }}') }">▶</span>
|
||||
{{ group.name }}
|
||||
<span class="group-count">{{ group.counts.progress }}</span>
|
||||
</div>
|
||||
<div class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
|
||||
data-group="{{ group.id }}" data-status="progress"
|
||||
id="kanban-{{ group.id }}-progress">
|
||||
{% for card in group.cards if card.status == 'progress' %}
|
||||
{% include "card.html" %}
|
||||
{% endfor %}
|
||||
<button class="kanban-new-card" @click="newCard('{{ group.id }}', 'progress')">+ New page</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Complete Column -->
|
||||
<div class="kanban-column">
|
||||
<div class="kanban-column-header">
|
||||
<span class="col-dot" style="background: var(--green);"></span>
|
||||
Complete <span class="col-count">1</span>
|
||||
</div>
|
||||
{% for group in groups %}
|
||||
<div class="kanban-group-header" @click="toggleGroup('{{ group.id }}')">
|
||||
<span class="group-chevron" :class="{ 'open': !collapsedGroups.includes('{{ group.id }}') }">▶</span>
|
||||
{{ group.name }}
|
||||
<span class="group-count">{{ group.counts.done }}</span>
|
||||
</div>
|
||||
<div class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
|
||||
data-group="{{ group.id }}" data-status="done"
|
||||
id="kanban-{{ group.id }}-done">
|
||||
{% for card in group.cards if card.status == 'done' %}
|
||||
{% include "card.html" %}
|
||||
{% endfor %}
|
||||
<button class="kanban-new-card" @click="newCard('{{ group.id }}', 'done')">+ New page</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- Dynamic view content -->
|
||||
<div id="view-content">
|
||||
<!-- Loaded via HTMX -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function switchView(view) {
|
||||
const tabs = document.querySelectorAll('.view-tab');
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
|
||||
// TODO: Load view content via HTMX
|
||||
console.log('Switch to view:', view);
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
@@ -173,57 +164,115 @@
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
if (idx >= 0) this.collapsedGroups.splice(idx, 1);
|
||||
else this.collapsedGroups.push(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
console.log('New card in', groupId, status);
|
||||
// TODO: Create issue in Gitea
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() {
|
||||
console.log('New group');
|
||||
// TODO: Create new group
|
||||
}
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilter: 'All',
|
||||
openFilterPanel() {
|
||||
console.log('Open filter panel');
|
||||
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() {
|
||||
console.log('Add filter');
|
||||
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 = [];
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize SortableJS
|
||||
document.addEventListener('alpine:initialized', () => {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const fromCol = evt.from.dataset.status;
|
||||
const toCol = evt.to.dataset.status;
|
||||
const group = evt.to.dataset.group;
|
||||
console.log('Moved card', cardId, 'from', fromCol, 'to', toCol, 'group', group);
|
||||
// TODO: POST /api/move
|
||||
}
|
||||
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;
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, { method: 'POST' })
|
||||
.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;
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, { method: 'POST' });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user