1. Refresh button no longer doubles header - Added ?fragment=1 param to filter/refresh HTMX requests - Board route returns board_fragment.html (columns only) for fragment requests - Refresh button uses window.location.reload() for full page reload 2. No more raw JSON in header - base.html: user info rendered server-side via template variable - board.py: decodes session cookie, passes user to template - Dashboard/board templates show Login/Deconnexion based on auth state 3. New issue form works - Replaced HTMX post with JS fetch (handleNewIssue) - Adds CSRF token to form submission - Reloads page on successful creation - Cleaner error handling
276 lines
13 KiB
HTML
276 lines
13 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
|
||
}">
|
||
|
||
<!-- 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" onsubmit="handleNewIssue(event, '{{ owner }}', '{{ repo }}')">
|
||
<input type="hidden" name="csrf_token" class="csrf-token-field">
|
||
<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 }}?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>
|
||
</div>
|
||
|
||
<!-- Columns (fragment) -->
|
||
{% include "board_fragment.html" %}
|
||
|
||
<!-- 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>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
// CSRF helper — set CSRF on all forms after load
|
||
function refreshCsrfFields() {
|
||
const csrf = getCSRFToken();
|
||
document.querySelectorAll('.csrf-token-field').forEach(f => f.value = csrf);
|
||
}
|
||
document.addEventListener('DOMContentLoaded', refreshCsrfFields);
|
||
|
||
function getCSRFToken() {
|
||
const match = document.cookie.match(/csrf_token=([^;]+)/);
|
||
return match ? match[1] : '';
|
||
}
|
||
|
||
async function handleNewIssue(evt, owner, repo) {
|
||
evt.preventDefault();
|
||
const form = evt.target;
|
||
const title = form.title.value.trim();
|
||
if (!title) return;
|
||
const body = form.body.value.trim();
|
||
const labels = form.labels.value;
|
||
const milestone = form.milestone.value;
|
||
const assignee = form.assignee.value.trim();
|
||
const csrf = getCSRFToken();
|
||
|
||
const params = new URLSearchParams({ title, body, labels, milestone, assignee });
|
||
try {
|
||
const resp = await fetch(`/api/issues/${owner}/${repo}?${params}`, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRF-Token': csrf }
|
||
});
|
||
if (resp.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
alert('Erreur lors de la création');
|
||
}
|
||
} catch(e) {
|
||
alert('Erreur: ' + e);
|
||
}
|
||
}
|
||
|
||
// Initialize 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;
|
||
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 cardCount = col.querySelectorAll('.card').length;
|
||
const badge = col.querySelector('.column-count');
|
||
if (badge) badge.textContent = cardCount;
|
||
});
|
||
} catch (e) {
|
||
console.error('Move failed:', e);
|
||
window.location.reload();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', initSortable);
|
||
document.addEventListener('htmx:afterSettle', initSortable);
|
||
|
||
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 || {}, comments = data.comments || [], checklists = data.checklists || [], card = data.card || {};
|
||
let h = '';
|
||
h += `<div class="detail-section"><span class="detail-state badge ${issue.state}">${issue.state}</span> <strong>${escHtml(issue.title||'')}</strong></div>`;
|
||
h += `<div class="detail-meta">`;
|
||
(issue.labels||[]).forEach(l => h += `<span class="card-label" style="background:#${l.color||'666'}33;color:#${l.color||'ccc'}">${escHtml(l.name)}</span>`);
|
||
if (issue.milestone) h += `<span>📅 ${escHtml(issue.milestone.title)}</span>`;
|
||
if (issue.assignee) h += `<span>👤 ${escHtml(issue.assignee.login)}</span>`;
|
||
h += `</div>`;
|
||
h += `<div class="detail-editable"><label>Priorité: <select onchange="updateCardMeta('${owner}','${repo}',${issue.number},this.value,'')" class="form-select-sm">`;
|
||
['Low','Medium','High','Urgent'].forEach(p => h += `<option value="${p}" ${(card.priority||'Medium')===p?'selected':''}>${p}</option>`);
|
||
h += `</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>`;
|
||
h += `<div class="detail-section"><h4>Description</h4><div class="markdown-body">${escHtml(issue.body||'')}</div></div>`;
|
||
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 tache..." class="form-input-sm" onkeydown="if(event.key==='Enter')addCheckItem(${cl.id},'${owner}','${repo}',${issue.number})">`;
|
||
h += `<button onclick="addCheckItem(${cl.id},'${owner}','${repo}',${issue.number})" class="btn btn-sm">+</button></div></div>`;
|
||
});
|
||
if (!checklists.length) h += `<button onclick="createChecklist('${owner}','${repo}',${issue.number})" class="btn btn-sm">+ Nouvelle checklist</button>`;
|
||
h += `</div>`;
|
||
h += `<div class="detail-section"><h4>Commentaires (${comments.length})</h4>`;
|
||
comments.forEach(c => h += `<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>`);
|
||
h += `</div>`;
|
||
h += `<div class="detail-actions"><a href="${issue.html_url||''}" target="_blank" class="btn btn-sm">🔗 Ouvrir dans Gitea</a>`;
|
||
h += `<button onclick="editIssueInline('${owner}','${repo}',${issue.number})" class="btn btn-sm">✏️ Éditer</button>`;
|
||
h += `<button onclick="closeModal()" class="btn btn-sm">Fermer</button></div>`;
|
||
return h;
|
||
}
|
||
|
||
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
async function updateCardMeta(o,r,i,priority,dueDate) { await fetch(`/api/card/${o}/${r}/${i}?priority=${priority}&due_date=${dueDate}`,{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 parts=document.getElementById('card-modal-title').textContent.split(' — ');
|
||
const [ow,rp]=parts[1]?parts[1].split('/'):['',''];
|
||
if(ow&&rp) openCardDetail(ow,rp,parseInt(parts[0].replace('#','')));
|
||
}
|
||
async function addCheckItem(cid,o,r,issueId) {
|
||
const inp=document.getElementById(`new-item-${cid}`);
|
||
if(!inp||!inp.value.trim())return;
|
||
await fetch(`/api/checklist-items/${o}/${r}/${issueId}/${cid}?content=${encodeURIComponent(inp.value)}`,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
|
||
openCardDetail(o,r,issueId);
|
||
}
|
||
async function createChecklist(o,r,issueId) { await fetch(`/api/checklists/${o}/${r}/${issueId}`,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}}); openCardDetail(o,r,issueId); }
|
||
async function editIssueInline(o,r,issueId) {
|
||
const t=prompt('Nouveau titre:',''); if(!t)return;
|
||
await fetch(`/api/issues/${o}/${r}/${issueId}?title=${encodeURIComponent(t)}`,{method:'PATCH',headers:{'X-CSRF-Token':getCSRFToken()}});
|
||
closeModal(); window.location.reload();
|
||
}
|
||
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 deja enregistre':'Webhook enregistre');
|
||
}catch(e){alert('Erreur: '+e);}
|
||
}
|
||
</script>
|
||
{% endblock %}
|