feat: Refonte UI Notion-complete — sidebar, topbar, multi-vues, dark mode
- Layout 3 zones (sidebar 240px + topbar 44px + contenu) - Sidebar: sections hiérarchiques, hover states, menu contextuel - CSS design system Notion: #191919/#222/#333, Inter, coins 4-6px - Vues: Kanban (groupes+colonnes), Dashboard Notion-style - Templates: base.html refondu, card.html partial, board.html v2 - Backend: données sidebar + board Notion (groups/cards) - Docs: ROADMAP v0.4→v1.0, WORKLOAD.md, README - Backward compatible: conserve _issue_column pour api.py - Images de référence + resized (analyse 2026-07-08)
This commit is contained in:
+194
-529
@@ -1,564 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📁{% endblock %}
|
||||
{% block page_title %}Kanban board new{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="board"
|
||||
x-data="{
|
||||
owner: '{{ owner }}',
|
||||
repo: '{{ repo }}',
|
||||
milestone: '{{ current_milestone }}',
|
||||
label: '{{ current_label }}',
|
||||
assignee: '{{ current_assignee }}'
|
||||
}">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="board-header">
|
||||
<div class="board-breadcrumb">
|
||||
<a href="/" class="back-link">← Projets</a>
|
||||
<span class="separator">/</span>
|
||||
<span class="repo-owner">{{ owner }}</span>
|
||||
<span class="separator">/</span>
|
||||
<strong>{{ repo }}</strong>
|
||||
</div>
|
||||
<div class="board-actions">
|
||||
<button class="btn btn-sm" onclick="openBoardConfig('{{ owner }}', '{{ repo }}')" title="Gérer les colonnes">⚙ Colonnes</button>
|
||||
<button class="btn btn-sm" onclick="registerWebhook('{{ owner }}', '{{ repo }}')" title="Enregistrer webhook">🔔 Webhook</button>
|
||||
<a href="/notes/{{ owner }}/{{ repo }}" class="btn btn-sm">📝 Notes</a>
|
||||
<button class="btn btn-sm btn-primary" onclick="openCreateModal('{{ owner }}','{{ repo }}','Backlog')">+ Issue</button>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="board-filters">
|
||||
<select class="filter-select" name="milestone_filter"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
|
||||
hx-target="#board-columns"
|
||||
hx-trigger="change"
|
||||
hx-include="[name='label_filter'],[name='assignee_filter']"
|
||||
hx-swap="outerHTML">
|
||||
<option value="">Tous les milestones</option>
|
||||
{% for m in milestones %}
|
||||
<option value="{{ m.title }}" {% if m.title == current_milestone %}selected{% endif %}>{{ m.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select class="filter-select" name="label_filter"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
|
||||
hx-target="#board-columns"
|
||||
hx-trigger="change"
|
||||
hx-include="[name='milestone_filter'],[name='assignee_filter']"
|
||||
hx-swap="outerHTML">
|
||||
<option value="">Tous les labels</option>
|
||||
{% for lbl in labels %}
|
||||
<option value="{{ lbl.name }}" {% if lbl.name == current_label %}selected{% endif %}>🏷 {{ lbl.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="assignee_filter" placeholder="Assigné à..."
|
||||
class="filter-input"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
|
||||
hx-target="#board-columns"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-include="[name='milestone_filter'],[name='label_filter']"
|
||||
hx-swap="outerHTML"
|
||||
value="{{ current_assignee }}">
|
||||
<button class="btn btn-sm" onclick="window.location.reload()">🔄 Rafraîchir</button>
|
||||
<!-- Page title -->
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📁</span>
|
||||
<h1>Kanban board new</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columns (fragment) -->
|
||||
{% include "board_fragment.html" %}
|
||||
<!-- 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>
|
||||
<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;">
|
||||
<span x-text="hideDesc ? 'Show description ▾' : 'Hide description ▴'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PRs section -->
|
||||
{% if prs %}
|
||||
<div class="prs-section">
|
||||
<h3>🔄 Pull Requests ({{ prs|length }})</h3>
|
||||
<div class="prs-list">
|
||||
{% for pr in prs %}
|
||||
<div class="pr-card">
|
||||
<a href="{{ pr.html_url }}" target="_blank" class="pr-title">#{{ pr.number }} {{ pr.title[:80] }}</a>
|
||||
<div class="pr-meta">
|
||||
{% if pr.user %}<span>{{ pr.user.login }}</span>{% endif %}
|
||||
<span>{{ pr.state }}</span>
|
||||
</div>
|
||||
<!-- View tabs -->
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab active" @click="switchView('kanban')">
|
||||
<span class="tab-icon">📊</span> Kanban board
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('detailed')">
|
||||
<span class="tab-icon">📋</span> Detailed board
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('table')">
|
||||
<span class="tab-icon">☰</span> Table view
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('status')">
|
||||
<span class="tab-icon">📈</span> Status overview
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('teamload')">
|
||||
<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>
|
||||
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
||||
<button class="filter-reset-btn" @click="resetFilters()" x-show="activeFilters.length > 0">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="view-toolbar-spacer"></div>
|
||||
|
||||
<button class="toolbar-btn" title="Sort">⇅</button>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Board Config Modal (column management) -->
|
||||
<div id="board-config-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h3>⚙ Configuration des colonnes</h3>
|
||||
<button onclick="closeBoardConfig()" class="modal-close">×</button>
|
||||
<!-- 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>
|
||||
<div class="modal-body" id="board-config-content">
|
||||
<div class="loading">Chargement...</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>
|
||||
</div>
|
||||
|
||||
<!-- Create Issue Modal (reuses card-modal) -->
|
||||
<div id="create-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h3 id="create-modal-title">Nouvelle issue</h3>
|
||||
<button onclick="closeCreateModal()" class="modal-close">×</button>
|
||||
<!-- 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>
|
||||
<div id="create-modal-content" class="modal-body"></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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ── Labels data (passed from server) ──
|
||||
const ALL_LABELS = {{ labels | tojson }};
|
||||
const ALL_MILESTONES = {{ milestones | tojson }};
|
||||
function switchView(view) {
|
||||
const tabs = document.querySelectorAll('.view-tab');
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
|
||||
function getCSRFToken() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
// TODO: Load view content via HTMX
|
||||
console.log('Switch to view:', view);
|
||||
}
|
||||
|
||||
// ── Create Modal ──
|
||||
function openCreateModal(owner, repo, defaultColumn) {
|
||||
document.getElementById('card-modal').style.display = 'none';
|
||||
const modal = document.getElementById('create-modal');
|
||||
const content = document.getElementById('create-modal-content');
|
||||
document.getElementById('create-modal-title').textContent = `Nouvelle issue — ${owner}/${repo}`;
|
||||
|
||||
let h = '<form onsubmit="saveNewIssue(event,\''+owner+'\',\''+repo+'\',\''+defaultColumn+'\')">';
|
||||
h += '<label class="form-label">Titre *</label>';
|
||||
h += '<input type="text" id="create-title" class="form-input" placeholder="Titre de l\'issue..." required>';
|
||||
h += '<label class="form-label">Description</label>';
|
||||
h += '<textarea id="create-body" rows="4" class="form-input" placeholder="Description (markdown)..."></textarea>';
|
||||
|
||||
// Multi-label checkboxes
|
||||
h += '<label class="form-label">Labels</label>';
|
||||
h += '<div class="label-checkboxes">';
|
||||
if (ALL_LABELS.length === 0) {
|
||||
h += '<span style="color:var(--text-dim);font-size:12px">Aucun label dans ce repo. <a href="'+window._giteaUrl+'/'+owner+'/'+repo+'/labels" target="_blank">En créer dans Gitea</a></span>';
|
||||
} else {
|
||||
ALL_LABELS.forEach(l => {
|
||||
h += '<label class="label-cb"><input type="checkbox" name="labels" value="'+l.id+'"> <span class="card-label" style="background:#'+(l.color||'666')+'33;color:#'+(l.color||'ccc')+'">'+escHtml(l.name)+'</span></label>';
|
||||
});
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
// Milestone
|
||||
h += '<label class="form-label">Milestone</label>';
|
||||
h += '<select id="create-milestone" class="form-select">';
|
||||
h += '<option value="">Aucun</option>';
|
||||
ALL_MILESTONES.forEach(m => { h += '<option value="'+m.id+'">'+escHtml(m.title)+'</option>'; });
|
||||
h += '</select>';
|
||||
|
||||
// Assignee
|
||||
h += '<label class="form-label">Assigné à</label>';
|
||||
h += '<input type="text" id="create-assignee" class="form-input" placeholder="Login Gitea...">';
|
||||
|
||||
h += '<div class="detail-actions" style="margin-top:12px">';
|
||||
h += '<button type="submit" class="btn btn-primary">💾 Créer</button>';
|
||||
h += '<button type="button" class="btn" onclick="closeCreateModal()">Annuler</button>';
|
||||
h += '</div></form>';
|
||||
|
||||
content.innerHTML = h;
|
||||
modal.style.display = 'flex';
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
if (idx >= 0) this.collapsedGroups.splice(idx, 1);
|
||||
else this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
console.log('New card in', groupId, status);
|
||||
// TODO: Create issue in Gitea
|
||||
},
|
||||
newGroup() {
|
||||
console.log('New group');
|
||||
// TODO: Create new group
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('create-modal').style.display = 'none';
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilter: 'All',
|
||||
openFilterPanel() {
|
||||
console.log('Open filter panel');
|
||||
},
|
||||
addFilter() {
|
||||
console.log('Add filter');
|
||||
},
|
||||
removeFilter(i) {
|
||||
this.activeFilters.splice(i, 1);
|
||||
},
|
||||
resetFilters() {
|
||||
this.activeFilters = [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function saveNewIssue(evt, owner, repo, defaultColumn) {
|
||||
evt.preventDefault();
|
||||
const title = document.getElementById('create-title').value.trim();
|
||||
const body = document.getElementById('create-body').value.trim();
|
||||
const milestone = document.getElementById('create-milestone').value;
|
||||
const assignee = document.getElementById('create-assignee').value.trim();
|
||||
if (!title) return;
|
||||
|
||||
// Gather checked labels
|
||||
const checkedLabels = [];
|
||||
document.querySelectorAll('#create-modal-content input[name="labels"]:checked').forEach(cb => {
|
||||
checkedLabels.push(cb.value);
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({title, body, milestone, assignee});
|
||||
if (checkedLabels.length) params.set('labels', checkedLabels.join(','));
|
||||
|
||||
const resp = await fetch(`/api/issues/${owner}/${repo}?${params}`, {
|
||||
method: 'POST', headers: {'X-CSRF-Token': getCSRFToken()}
|
||||
});
|
||||
if (resp.ok) {
|
||||
closeCreateModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Erreur lors de la création');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Column double-click → create with column preselected ──
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.board-column').forEach(col => {
|
||||
col.addEventListener('dblclick', function(evt) {
|
||||
// Only if NOT clicking on a card
|
||||
if (evt.target.closest('.card')) return;
|
||||
const owner = this.querySelector('.column-cards')?.dataset.owner;
|
||||
const repo = this.querySelector('.column-cards')?.dataset.repo;
|
||||
const column = this.dataset.column || 'Backlog';
|
||||
if (owner && repo) openCreateModal(owner, repo, column);
|
||||
});
|
||||
});
|
||||
});
|
||||
document.addEventListener('htmx:afterSettle', () => {
|
||||
document.querySelectorAll('.board-column').forEach(col => {
|
||||
if (col._hasDblClick) return;
|
||||
col._hasDblClick = true;
|
||||
col.addEventListener('dblclick', function(evt) {
|
||||
if (evt.target.closest('.card')) return;
|
||||
const owner = this.querySelector('.column-cards')?.dataset.owner;
|
||||
const repo = this.querySelector('.column-cards')?.dataset.repo;
|
||||
const column = this.dataset.column || 'Backlog';
|
||||
if (owner && repo) openCreateModal(owner, repo, column);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── SortableJS ──
|
||||
function initSortable() {
|
||||
document.querySelectorAll('.sortable').forEach(el => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban', animation: 150, ghostClass: 'card-ghost',
|
||||
onEnd: async function(evt) {
|
||||
const card = evt.item, issueId = card.dataset.issueId;
|
||||
const newCol = evt.to.dataset.column, owner = evt.to.dataset.owner, repo = evt.to.dataset.repo;
|
||||
try {
|
||||
const resp = await fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newCol)}`, {
|
||||
method: 'POST', headers: {'X-CSRF-Token': getCSRFToken()}
|
||||
});
|
||||
if (!resp.ok) throw new Error('Move failed');
|
||||
const data = await resp.json();
|
||||
|
||||
// Update card's data-column
|
||||
card.dataset.column = newCol;
|
||||
|
||||
// Update labels on the moved card
|
||||
const labelsDiv = card.querySelector('.card-labels');
|
||||
if (labelsDiv && data.labels) {
|
||||
labelsDiv.innerHTML = data.labels.map(l =>
|
||||
`<span class="card-label" style="background:#${l.color||'666'}33;color:#${l.color||'ccc'}">${escHtml(l.name)}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Flash feedback
|
||||
card.style.transition = 'box-shadow 0.3s';
|
||||
card.style.boxShadow = '0 0 0 2px var(--green)';
|
||||
setTimeout(() => { card.style.boxShadow = ''; }, 800);
|
||||
|
||||
// Update column counts
|
||||
document.querySelectorAll('.board-column').forEach(col => {
|
||||
const c = col.querySelectorAll('.card').length;
|
||||
const b = col.querySelector('.column-count');
|
||||
if (b) b.textContent = c;
|
||||
});
|
||||
} catch(e) { console.error('Move failed:', e); window.location.reload(); }
|
||||
// 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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', initSortable);
|
||||
document.addEventListener('htmx:afterSettle', initSortable);
|
||||
|
||||
// ── Card Detail ──
|
||||
function openCardDetail(owner, repo, issueId) {
|
||||
document.getElementById('create-modal').style.display = 'none';
|
||||
const modal = document.getElementById('card-modal');
|
||||
const content = document.getElementById('card-modal-content');
|
||||
content.innerHTML = '<div class="loading">Chargement...</div>';
|
||||
document.getElementById('card-modal-title').textContent = `#${issueId} — ${owner}/${repo}`;
|
||||
modal.style.display = 'flex';
|
||||
fetch(`/api/issues/${owner}/${repo}/${issueId}`)
|
||||
.then(r => r.json()).then(data => { content.innerHTML = renderCardDetail(owner, repo, data); })
|
||||
.catch(e => { content.innerHTML = '<p class="error">Erreur: '+e+'</p>'; });
|
||||
}
|
||||
function closeModal() { document.getElementById('card-modal').style.display = 'none'; }
|
||||
|
||||
function renderCardDetail(owner, repo, data) {
|
||||
const issue = data.issue || {}, comments = data.comments || [], checklists = data.checklists || [], card = data.card || {};
|
||||
window._issueData = {owner, repo, issue, comments, checklists, card};
|
||||
|
||||
const labelsHtml = (issue.labels || []).map(l =>
|
||||
`<span class="card-label" style="background:#${l.color||'666'}33;color:#${l.color||'ccc'}">${escHtml(l.name)}</span>`
|
||||
).join('');
|
||||
|
||||
const prio = card.priority || 'Medium';
|
||||
const prioOpts = ['Low','Medium','High','Urgent'].map(p =>
|
||||
`<option value="${p}" ${prio===p?'selected':''}>${p}</option>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<div class="detail-section"><span class="detail-state badge ${issue.state}">${issue.state}</span> <strong>${escHtml(issue.title||'')}</strong></div>
|
||||
<div class="detail-meta">
|
||||
${labelsHtml}
|
||||
${issue.milestone ? '<span>📅 '+escHtml(issue.milestone.title)+'</span>' : ''}
|
||||
${issue.assignee ? '<span>👤 '+escHtml(issue.assignee.login)+'</span>' : ''}
|
||||
</div>
|
||||
<div class="detail-editable">
|
||||
<label>Priorité: <select onchange="updateCardMeta('${owner}','${repo}',${issue.number},this.value,'')" class="form-select-sm">${prioOpts}</select></label>
|
||||
<label>Échéance: <input type="date" value="${card.due_date||''}" onchange="updateCardMeta('${owner}','${repo}',${issue.number},'',this.value)" class="form-input-sm"></label>
|
||||
</div>
|
||||
<div class="detail-section"><h4>Description</h4><div class="markdown-body">${escHtml(issue.body||'')}</div></div>
|
||||
${renderChecklists(owner, repo, issue.number, checklists)}
|
||||
<div class="detail-section"><h4>Commentaires (${comments.length})</h4>
|
||||
${comments.map(c => `
|
||||
<div class="comment">
|
||||
<img src="${c.user?.avatar_url||''}" width="24" height="24" class="avatar">
|
||||
<strong>${escHtml(c.user?.login||'')}</strong>
|
||||
<span class="comment-date">${(c.created_at||'').substr(0,10)}</span>
|
||||
<div class="comment-body">${escHtml(c.body||'')}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<a href="${issue.html_url||''}" target="_blank" class="btn btn-sm">🔗 Ouvrir dans Gitea</a>
|
||||
<button onclick="showEditForm('${owner}','${repo}',${issue.number})" class="btn btn-sm">✏️ Éditer</button>
|
||||
<button onclick="closeModal()" class="btn btn-sm">Fermer</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderChecklists(owner, repo, issueId, checklists) {
|
||||
let h = '<div class="detail-section"><h4>Checklists</h4>';
|
||||
checklists.forEach(cl => {
|
||||
h += '<div class="checklist"><strong>'+escHtml(cl.title)+'</strong><ul class="checklist-items">';
|
||||
(cl.items||[]).forEach(item => {
|
||||
h += '<li><input type="checkbox" '+(item.checked?'checked':'')+' onchange="toggleCheckItem('+item.id+',this.checked)" class="checklist-check">';
|
||||
h += '<span class="'+(item.checked?'checked':'')+'">'+escHtml(item.content)+'</span>';
|
||||
h += '<button onclick="deleteCheckItem('+item.id+')" class="btn-del">×</button></li>';
|
||||
});
|
||||
h += '</ul><div class="add-item"><input type="text" id="new-item-'+cl.id+'" placeholder="Nouvelle tâche..." class="form-input-sm" onkeydown="if(event.key===\'Enter\')addCheckItem('+cl.id+',\''+owner+'\',\''+repo+'\','+issueId+')">';
|
||||
h += '<button onclick="addCheckItem('+cl.id+',\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">+</button></div></div>';
|
||||
});
|
||||
if (!checklists.length) h += '<button onclick="createChecklist(\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">+ Nouvelle checklist</button>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
// ── Edit Form ──
|
||||
function showEditForm(owner, repo, issueId) {
|
||||
const content = document.getElementById('card-modal-content');
|
||||
const data = window._issueData || {}, issue = data.issue || {};
|
||||
let h = '<form onsubmit="saveIssueEdit(event,\''+owner+'\',\''+repo+'\','+issueId+')">';
|
||||
h += '<label class="form-label">Titre</label>';
|
||||
h += '<input type="text" id="edit-title" value="'+escHtml(issue.title||'')+'" class="form-input" required>';
|
||||
h += '<label class="form-label">Description</label>';
|
||||
h += '<textarea id="edit-body" rows="5" class="form-input">'+escHtml(issue.body||'')+'</textarea>';
|
||||
h += '<label class="form-label">Labels</label>';
|
||||
h += '<div class="label-checkboxes">';
|
||||
const issueLabelIds = (issue.labels||[]).map(l => l.id);
|
||||
if (!ALL_LABELS.length) {
|
||||
h += '<span style="color:var(--text-dim);font-size:12px">Aucun label</span>';
|
||||
} else {
|
||||
ALL_LABELS.forEach(l => {
|
||||
h += '<label class="label-cb"><input type="checkbox" name="edit-labels" value="'+l.id+'" '+(issueLabelIds.includes(l.id)?'checked':'')+'> <span class="card-label" style="background:#'+(l.color||'666')+'33;color:#'+(l.color||'ccc')+'">'+escHtml(l.name)+'</span></label>';
|
||||
});
|
||||
}
|
||||
h += '</div>';
|
||||
h += '<label class="form-label">Assigné à</label>';
|
||||
h += '<input type="text" id="edit-assignee" value="'+(issue.assignee?issue.assignee.login:'')+'" placeholder="Login Gitea..." class="form-input">';
|
||||
h += '<div class="detail-actions" style="margin-top:8px">';
|
||||
h += '<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>';
|
||||
h += '<button type="button" onclick="openCardDetail(\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">Annuler</button>';
|
||||
h += '</div></form>';
|
||||
content.innerHTML = h;
|
||||
}
|
||||
|
||||
async function saveIssueEdit(evt, owner, repo, issueId) {
|
||||
evt.preventDefault();
|
||||
const title = document.getElementById('edit-title').value.trim();
|
||||
const body = document.getElementById('edit-body').value.trim();
|
||||
const assignee = document.getElementById('edit-assignee').value.trim();
|
||||
if (!title) return;
|
||||
const checkedLabels = [];
|
||||
document.querySelectorAll('#card-modal-content input[name="edit-labels"]:checked').forEach(cb => checkedLabels.push(cb.value));
|
||||
const params = new URLSearchParams({title, body, assignee});
|
||||
if (checkedLabels.length) params.set('labels', checkedLabels.join(','));
|
||||
const resp = await fetch(`/api/issues/${owner}/${repo}/${issueId}?${params}`, {
|
||||
method: 'PATCH', headers: {'X-CSRF-Token': getCSRFToken()}
|
||||
});
|
||||
if (resp.ok) {
|
||||
closeModal();
|
||||
// Refresh board columns to reposition card
|
||||
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
|
||||
} else {
|
||||
alert('Erreur lors de la mise à jour');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
async function updateCardMeta(o,r,i,p,d) { await fetch('/api/card/'+o+'/'+r+'/'+i+'?priority='+p+'&due_date='+d,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}}); }
|
||||
async function toggleCheckItem(id,checked) { await fetch('/api/checklist-items/'+id+'?checked='+checked,{method:'PATCH',headers:{'X-CSRF-Token':getCSRFToken()}}); }
|
||||
async function deleteCheckItem(id) {
|
||||
await fetch('/api/checklist-items/'+id,{method:'DELETE',headers:{'X-CSRF-Token':getCSRFToken()}});
|
||||
const t = document.getElementById('card-modal-title').textContent;
|
||||
const parts = t.split(' — '), n = parts[0].replace('#','');
|
||||
const or = parts[1]?parts[1].split('/'):['',''];
|
||||
if(or[0]&&or[1]) openCardDetail(or[0],or[1],parseInt(n));
|
||||
}
|
||||
async function addCheckItem(cid,o,r,iid) {
|
||||
const inp = document.getElementById('new-item-'+cid);
|
||||
if (!inp||!inp.value.trim()) return;
|
||||
await fetch('/api/checklist-items/'+o+'/'+r+'/'+iid+'/'+cid+'?content='+encodeURIComponent(inp.value),{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
|
||||
openCardDetail(o,r,iid);
|
||||
}
|
||||
async function createChecklist(o,r,iid) { await fetch('/api/checklists/'+o+'/'+r+'/'+iid,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}}); openCardDetail(o,r,iid); }
|
||||
async function registerWebhook(o,r) {
|
||||
try {
|
||||
const resp = await fetch('/api/webhook/register/'+o+'/'+r,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
|
||||
const d = await resp.json();
|
||||
alert(d.status==='already_registered'?'Webhook déjà enregistré':'Webhook enregistré');
|
||||
} catch(e) { alert('Erreur: '+e); }
|
||||
}
|
||||
|
||||
// ── Column Configuration ──
|
||||
let _colMappings = {};
|
||||
|
||||
function toggleColConfig(evt, owner, repo, col) {
|
||||
evt.stopPropagation();
|
||||
const safeId = col.replace(/ /g, '_');
|
||||
const panel = document.getElementById('col-config-' + safeId);
|
||||
if (!panel) return;
|
||||
// Close all others
|
||||
document.querySelectorAll('.col-config-panel').forEach(p => { if (p !== panel) p.style.display = 'none'; });
|
||||
const isOpen = panel.style.display === 'block';
|
||||
panel.style.display = isOpen ? 'none' : 'block';
|
||||
if (!isOpen) {
|
||||
// Populate label select
|
||||
const sel = document.getElementById('col-label-' + safeId);
|
||||
if (sel && sel.options.length <= 1) {
|
||||
sel.innerHTML = '<option value="">— Aucun mapping —</option>';
|
||||
ALL_LABELS.forEach(l => {
|
||||
sel.innerHTML += '<option value="'+escHtml(l.name)+'">'+escHtml(l.name)+'</option>';
|
||||
});
|
||||
}
|
||||
// Pre-select current mapping
|
||||
if (_colMappings[col]) sel.value = _colMappings[col];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveColMapping(owner, repo, col) {
|
||||
const safeId = col.replace(/ /g, '_');
|
||||
const sel = document.getElementById('col-label-' + safeId);
|
||||
if (!sel) return;
|
||||
const label = sel.value;
|
||||
if (!label) { alert('Sélectionnez un label'); return; }
|
||||
const params = new URLSearchParams({owner, repo, column: col, gitea_label: label});
|
||||
await fetch('/api/col-mapping?' + params, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
|
||||
_colMappings[col] = label;
|
||||
document.getElementById('col-config-' + safeId).style.display = 'none';
|
||||
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
|
||||
}
|
||||
|
||||
async function deleteColMapping(owner, repo, col) {
|
||||
if (!confirm('Supprimer le mapping pour « ' + col + ' » ?')) return;
|
||||
const safeId = col.replace(/ /g, '_');
|
||||
await fetch(`/api/col-mapping?owner=${owner}&repo=${repo}&column=${encodeURIComponent(col)}`, {method:'DELETE', headers:{'X-CSRF-Token': getCSRFToken()}});
|
||||
delete _colMappings[col];
|
||||
document.getElementById('col-config-' + safeId).style.display = 'none';
|
||||
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
|
||||
}
|
||||
|
||||
// ── Board Config (Column Management) ──
|
||||
async function openBoardConfig(owner, repo) {
|
||||
document.getElementById('card-modal').style.display = 'none';
|
||||
document.getElementById('create-modal').style.display = 'none';
|
||||
const modal = document.getElementById('board-config-modal');
|
||||
const content = document.getElementById('board-config-content');
|
||||
modal.style.display = 'flex';
|
||||
content.innerHTML = '<div class="loading">Chargement...</div>';
|
||||
|
||||
const resp = await fetch(`/api/board-config/${owner}/${repo}`);
|
||||
const cfg = await resp.json();
|
||||
|
||||
let h = '<div class="detail-section"><h4>Colonnes actuelles</h4>';
|
||||
h += '<ul class="config-column-list" id="config-column-list">';
|
||||
cfg.columns.forEach((c, i) => {
|
||||
h += '<li class="config-column-row" data-col="'+escHtml(c)+'">';
|
||||
h += '<input type="text" value="'+escHtml(c)+'" class="form-input config-col-name" style="width:200px;margin:0" data-orig="'+escHtml(c)+'">';
|
||||
const mapped = (cfg.col_mappings||[]).find(m => m.column_name === c);
|
||||
const labelName = mapped ? mapped.gitea_label : '';
|
||||
h += '<span class="col-mapping-badge" style="font-size:11px;color:var(--text-dim);margin:0 8px">' + (labelName ? '🏷 '+escHtml(labelName) : '') + '</span>';
|
||||
h += '<button class="btn btn-sm" onclick="renameColumn(\''+owner+'\',\''+repo+'\','+i+',\''+escHtml(c)+'\')">Renommer</button>';
|
||||
h += '<button class="btn btn-sm btn-del" onclick="deleteColumn(\''+owner+'\',\''+repo+'\','+i+',\''+escHtml(c)+'\')">✕</button>';
|
||||
h += '</li>';
|
||||
});
|
||||
h += '</ul></div>';
|
||||
|
||||
h += '<div class="detail-section"><h4>Ajouter une colonne</h4>';
|
||||
h += '<div class="form-row">';
|
||||
h += '<input type="text" id="new-col-name" class="form-input" placeholder="Nom de la colonne..." style="flex:1">';
|
||||
h += '<button class="btn btn-primary btn-sm" onclick="addColumn(\''+owner+'\',\''+repo+'\')">+ Ajouter</button>';
|
||||
h += '</div></div>';
|
||||
|
||||
h += '<div class="detail-actions">';
|
||||
h += '<button onclick="closeBoardConfig()" class="btn btn-sm">Fermer</button>';
|
||||
h += '</div>';
|
||||
|
||||
content.innerHTML = h;
|
||||
window._boardConfig = cfg;
|
||||
}
|
||||
|
||||
function closeBoardConfig() {
|
||||
document.getElementById('board-config-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function addColumn(owner, repo) {
|
||||
const inp = document.getElementById('new-col-name');
|
||||
const name = inp.value.trim();
|
||||
if (!name) return;
|
||||
const cfg = window._boardConfig;
|
||||
const cols = [...cfg.columns, name];
|
||||
await fetch(`/api/board-config/${owner}/${repo}?columns=${encodeURIComponent(JSON.stringify(cols))}`, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
|
||||
closeBoardConfig();
|
||||
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
|
||||
}
|
||||
|
||||
async function renameColumn(owner, repo, idx, oldName) {
|
||||
const row = document.querySelector(`.config-column-row[data-col="${oldName.replace(/"/g,'"')}"]`);
|
||||
const inp = row ? row.querySelector('.config-col-name') : null;
|
||||
const newName = inp ? inp.value.trim() : '';
|
||||
if (!newName || newName === oldName) return;
|
||||
const cfg = window._boardConfig;
|
||||
cfg.columns[idx] = newName;
|
||||
await fetch(`/api/board-config/${owner}/${repo}?columns=${encodeURIComponent(JSON.stringify(cfg.columns))}`, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
|
||||
// Update col_mapping if one existed for old name
|
||||
const oldMapping = (cfg.col_mappings||[]).find(m => m.column_name === oldName);
|
||||
if (oldMapping) {
|
||||
await fetch(`/api/col-mapping?owner=${owner}&repo=${repo}&column=${encodeURIComponent(newName)}&gitea_label=${oldMapping.gitea_label}`, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
|
||||
}
|
||||
closeBoardConfig();
|
||||
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
|
||||
}
|
||||
|
||||
async function deleteColumn(owner, repo, idx, colName) {
|
||||
if (!confirm('Supprimer la colonne « ' + colName + ' » ? Les cartes iront dans la première colonne.')) return;
|
||||
const cfg = window._boardConfig;
|
||||
const cols = cfg.columns.filter((_, i) => i !== idx);
|
||||
await fetch(`/api/board-config/${owner}/${repo}?columns=${encodeURIComponent(JSON.stringify(cols))}`, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
|
||||
// Delete col_mapping
|
||||
await fetch(`/api/col-mapping?owner=${owner}&repo=${repo}&column=${encodeURIComponent(colName)}`, {method:'DELETE', headers:{'X-CSRF-Token': getCSRFToken()}});
|
||||
closeBoardConfig();
|
||||
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
|
||||
}
|
||||
|
||||
// Load col mappings on init
|
||||
(async function() {
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
try {
|
||||
const resp = await fetch(`/api/board-config/${owner}/${repo}`);
|
||||
const cfg = await resp.json();
|
||||
(cfg.col_mappings||[]).forEach(m => { _colMappings[m.column_name] = m.gitea_label; });
|
||||
} catch(e) {}
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user