Files
flowdeck/app/templates/board.html
T
bruno 5a124d1328
CI / test (push) Failing after 5s
CI / lint (push) Failing after 4s
v0.3.0: OAuth2, CSRF, rate limiting, issue CRUD, webhooks, card detail, checklists
- OAuth2 Gitea (login/callback/logout) with fallback admin mode
- Session management with signed cookies (itsdangerous, 7 days)
- CSRF protection middleware on all POST/PUT/PATCH/DELETE
- Rate limiting in-memory (60 req/min per IP)
- Issue creation from FlowDeck (POST /api/issues/{owner}/{repo})
- Inline issue editing (PATCH /api/issues/{owner}/{repo}/{issue_id})
- Card detail modal (double-click): description, labels, comments
- Checklists with toggleable items (CRUD endpoints + UI)
- Priority + due date editable on cards
- Webhook receiver (POST /api/webhook) with HMAC-SHA256
- Auto-register/status webhooks for repos
- GiteaClient: create_issue, update_issue, get_comments, webhooks, collaborators
- New DB tables: users, user_tokens, checklists, checklist_items
- CSS: modal, forms, checklists, comments, PR section
2026-07-08 09:23:57 -04:00

404 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block content %}
<div class="board"
x-data="{
owner: '{{ owner }}',
repo: '{{ repo }}',
milestone: '{{ current_milestone }}',
label: '{{ current_label }}',
assignee: '{{ current_assignee }}',
showNewIssue: false,
showDetail: false,
detailIssueId: 0
}">
<!-- 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="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" @click="showNewIssue = !showNewIssue">+ Issue</button>
</div>
</div>
<!-- New Issue Form -->
<div class="new-issue-form" x-show="showNewIssue" x-transition>
<h4>Nouvelle issue dans {{ owner }}/{{ repo }}</h4>
<form id="new-issue-form" hx-post="/api/issues/{{ owner }}/{{ repo }}"
hx-target=".board-columns" hx-swap="outerHTML"
hx-include="[name='milestone_filter'],[name='label_filter'],[name='assignee_filter']"
hx-on--after-request="showNewIssue=false; this.reset()">
<input type="text" name="title" placeholder="Titre de l'issue..." required class="form-input">
<textarea name="body" placeholder="Description (markdown)..." rows="3" class="form-input"></textarea>
<div class="form-row">
<select name="labels" class="form-select">
<option value="">Label</option>
{% for lbl in labels %}
<option value="{{ lbl.id }}">🏷 {{ lbl.name }}</option>
{% endfor %}
</select>
<select name="milestone" class="form-select">
<option value="">Milestone</option>
{% for m in milestones %}
<option value="{{ m.id }}">📅 {{ m.title }}</option>
{% endfor %}
</select>
<input type="text" name="assignee" placeholder="Assigné à (login)" class="form-input" style="flex:1">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Créer</button>
<button type="button" class="btn" @click="showNewIssue = false">Annuler</button>
</div>
</form>
</div>
<!-- Filters -->
<div class="board-filters">
<select class="filter-select" name="milestone_filter"
hx-get="/board/{{ owner }}/{{ repo }}"
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 }}"
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 }}"
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" hx-get="/board/{{ owner }}/{{ repo }}"
hx-target=".board-columns" hx-swap="outerHTML"
hx-include="[name='milestone_filter'],[name='label_filter'],[name='assignee_filter']">
🔄 Rafraîchir
</button>
</div>
<!-- Columns -->
<div class="board-columns" id="board-columns"
hx-trigger="cardMoved from:body"
hx-get="/board/{{ owner }}/{{ repo }}?milestone={{ current_milestone }}&label={{ current_label }}&assignee={{ current_assignee }}"
hx-target=".board-columns"
hx-swap="outerHTML">
{% for col in columns %}
<div class="board-column"
data-column="{{ col }}"
id="col-{{ loop.index }}">
<div class="column-header">
<span class="column-title">{{ col }}</span>
<span class="column-count">{{ column_map[col]|length }}</span>
</div>
<div class="column-cards sortable"
data-column="{{ col }}"
data-owner="{{ owner }}"
data-repo="{{ repo }}">
{% for issue in column_map[col] %}
<div class="card"
data-issue-id="{{ issue.number }}"
data-column="{{ col }}"
draggable="true"
ondblclick="openCardDetail('{{ owner }}','{{ repo }}',{{ issue.number }})">
<div class="card-labels">
{% for lbl in issue.labels %}
<span class="card-label" style="background:#{{ lbl.color or '666' }}33; color:#{{ lbl.color or 'ccc' }}">{{ lbl.name }}</span>
{% endfor %}
</div>
<div class="card-title">
<a href="{{ issue.html_url }}" target="_blank" onclick="event.stopPropagation()">#{{ issue.number }}</a>
<span class="card-title-text">{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}</span>
</div>
<div class="card-meta">
{% if issue.assignee %}
<span class="card-assignee" title="{{ issue.assignee.login }}">
<img src="{{ issue.assignee.avatar_url }}" alt="" width="16" height="16" class="avatar">
{{ issue.assignee.login }}
</span>
{% endif %}
{% if issue.milestone %}
<span class="card-milestone">📅 {{ issue.milestone.title }}</span>
{% endif %}
</div>
{% if issue._card and issue._card.priority %}
<div class="card-priority priority-{{ issue._card.priority|lower }}">{{ issue._card.priority }}</div>
{% endif %}
{% if issue._card and issue._card.due_date %}
<div class="card-due {% if issue._card.due_date < now %}overdue{% endif %}">
⏰ {{ issue._card.due_date[:10] }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</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>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<script>
// Initialize SortableJS
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSwap', initSortable);
function initSortable() {
document.querySelectorAll('.sortable').forEach(el => {
if (el._sortable) return;
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 150,
ghostClass: 'card-ghost',
onEnd: async function(evt) {
const card = evt.item;
const issueId = card.dataset.issueId;
const newColumn = evt.to.dataset.column;
const owner = evt.to.dataset.owner;
const repo = evt.to.dataset.repo;
const csrfToken = getCSRFToken();
try {
const resp = await fetch(
`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`,
{ method: 'POST', headers: { 'X-CSRF-Token': csrfToken } }
);
if (!resp.ok) throw new Error('Move failed');
document.querySelectorAll('.board-column').forEach(col => {
const count = col.querySelectorAll('.card').length;
const badge = col.querySelector('.column-count');
if (badge) badge.textContent = count;
});
} catch (e) {
console.error('Move failed:', e);
window.location.reload();
}
}
});
});
}
function getCSRFToken() {
const match = document.cookie.match(/csrf_token=([^;]+)/);
return match ? match[1] : '';
}
function openCardDetail(owner, repo, issueId) {
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 || {};
const comments = data.comments || [];
const checklists = data.checklists || [];
const card = data.card || {};
let html = '';
// Issue header
html += `<div class="detail-section">`;
html += `<span class="detail-state badge ${issue.state}">${issue.state}</span> `;
html += `<strong>${escHtml(issue.title || '')}</strong>`;
html += `</div>`;
// Meta
html += `<div class="detail-meta">`;
const labels = issue.labels || [];
if (labels.length) html += labels.map(l => `<span class="card-label" style="background:#${l.color || '666'}33;color:#${l.color || 'ccc'}">${escHtml(l.name)}</span>`).join(' ');
if (issue.milestone) html += `<span>📅 ${escHtml(issue.milestone.title)}</span>`;
if (issue.assignee) html += `<span>👤 ${escHtml(issue.assignee.login)}</span>`;
html += `</div>`;
// Priority + Due date (editable)
html += `<div class="detail-editable">`;
html += `<label>Priorité: <select onchange="updateCardMeta('${owner}','${repo}',${issue.number},this.value,'')" class="form-select-sm">`;
['Low','Medium','High','Urgent'].forEach(p => {
html += `<option value="${p}" ${(card.priority||'Medium')===p?'selected':''}>${p}</option>`;
});
html += `</select></label> `;
html += `<label>Échéance: <input type="date" value="${card.due_date||''}" onchange="updateCardMeta('${owner}','${repo}',${issue.number},'',this.value)" class="form-input-sm"></label>`;
html += `</div>`;
// Description
html += `<div class="detail-section">`;
html += `<h4>Description</h4>`;
html += `<div class="markdown-body">${escHtml(issue.body || '')}</div>`;
html += `</div>`;
// Checklists
html += `<div class="detail-section">`;
html += `<h4>Checklists</h4>`;
checklists.forEach(cl => {
html += `<div class="checklist">`;
html += `<strong>${escHtml(cl.title)}</strong>`;
html += `<ul class="checklist-items">`;
(cl.items || []).forEach(item => {
html += `<li>`;
html += `<input type="checkbox" ${item.checked?'checked':''} onchange="toggleCheckItem(${item.id},this.checked)" class="checklist-check">`;
html += `<span class="${item.checked?'checked':''}">${escHtml(item.content)}</span>`;
html += `<button onclick="deleteCheckItem(${item.id})" class="btn-del" title="Supprimer">×</button>`;
html += `</li>`;
});
html += `</ul>`;
html += `<div class="add-item">`;
html += `<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}',${issue.number})">`;
html += `<button onclick="addCheckItem(${cl.id},'${owner}','${repo}',${issue.number})" class="btn btn-sm">+</button>`;
html += `</div>`;
html += `</div>`;
});
if (!checklists.length) {
html += `<button onclick="createChecklist('${owner}','${repo}',${issue.number})" class="btn btn-sm">+ Nouvelle checklist</button>`;
}
html += `</div>`;
// Comments
html += `<div class="detail-section">`;
html += `<h4>Commentaires (${comments.length})</h4>`;
comments.forEach(c => {
html += `<div class="comment">`;
html += `<img src="${c.user?.avatar_url||''}" width="24" height="24" class="avatar">`;
html += `<strong>${escHtml(c.user?.login||'')}</strong>`;
html += `<span class="comment-date">${(c.created_at||'').substr(0,10)}</span>`;
html += `<div class="comment-body">${escHtml(c.body||'')}</div>`;
html += `</div>`;
});
html += `</div>`;
// Actions
html += `<div class="detail-actions">`;
html += `<a href="${issue.html_url||''}" target="_blank" class="btn btn-sm">🔗 Ouvrir dans Gitea</a>`;
html += `<button onclick="editIssueInline('${owner}','${repo}',${issue.number})" class="btn btn-sm">✏️ Éditer</button>`;
html += `<button onclick="closeModal()" class="btn btn-sm">Fermer</button>`;
html += `</div>`;
return html;
}
function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
async function updateCardMeta(owner, repo, issueId, priority, dueDate) {
const csrfToken = getCSRFToken();
await fetch(`/api/card/${owner}/${repo}/${issueId}?priority=${priority}&due_date=${dueDate}`, {
method: 'POST', headers: { 'X-CSRF-Token': csrfToken }
});
}
async function toggleCheckItem(itemId, checked) {
const csrfToken = getCSRFToken();
await fetch(`/api/checklist-items/${itemId}?checked=${checked}`, {
method: 'PATCH', headers: { 'X-CSRF-Token': csrfToken }
});
}
async function deleteCheckItem(itemId) {
const csrfToken = getCSRFToken();
await fetch(`/api/checklist-items/${itemId}`, {
method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken }
});
// Refresh modal
const parts = document.getElementById('card-modal-title').textContent.split(' — ');
const issueNum = parts[0].replace('#','');
const ownerRepo = parts[1] || '';
const [ow, rp] = ownerRepo.split('/');
if (ow && rp) openCardDetail(ow, rp, parseInt(issueNum));
}
async function addCheckItem(checklistId, owner, repo, issueId) {
const input = document.getElementById(`new-item-${checklistId}`);
if (!input || !input.value.trim()) return;
const csrfToken = getCSRFToken();
await fetch(`/api/checklist-items/${owner}/${repo}/${issueId}/${checklistId}?content=${encodeURIComponent(input.value)}`, {
method: 'POST', headers: { 'X-CSRF-Token': csrfToken }
});
openCardDetail(owner, repo, issueId);
}
async function createChecklist(owner, repo, issueId) {
const csrfToken = getCSRFToken();
await fetch(`/api/checklists/${owner}/${repo}/${issueId}`, {
method: 'POST', headers: { 'X-CSRF-Token': csrfToken }
});
openCardDetail(owner, repo, issueId);
}
async function editIssueInline(owner, repo, issueId) {
const newTitle = prompt('Nouveau titre:', '');
if (!newTitle) return;
const csrfToken = getCSRFToken();
await fetch(`/api/issues/${owner}/${repo}/${issueId}?title=${encodeURIComponent(newTitle)}`, {
method: 'PATCH', headers: { 'X-CSRF-Token': csrfToken }
});
closeModal();
window.location.reload();
}
async function registerWebhook(owner, repo) {
const csrfToken = getCSRFToken();
try {
const resp = await fetch(`/api/webhook/register/${owner}/${repo}`, {
method: 'POST', headers: { 'X-CSRF-Token': csrfToken }
});
const data = await resp.json();
alert(data.status === 'already_registered' ? 'Webhook déjà enregistré ✅' : 'Webhook enregistré ✅');
} catch(e) {
alert('Erreur: ' + e);
}
}
</script>
{% endblock %}