- 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
404 lines
16 KiB
HTML
404 lines
16 KiB
HTML
{% 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
|
||
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 %}
|