fix: resolve 3 critical UI bugs
CI / test (push) Failing after 4s
CI / lint (push) Failing after 3s

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
This commit is contained in:
2026-07-08 09:45:24 -04:00
parent e72c0dcbd8
commit a67f42bfe6
4 changed files with 189 additions and 279 deletions
+23 -36
View File
@@ -50,45 +50,32 @@ async def board(
milestone: str = "",
label: str = "",
assignee: str = "",
fragment: bool = False,
):
"""Kanban board for a Gitea project."""
try:
# Get issues
issues = await gitea.get_issues(owner, repo, state="all")
# Get labels
labels = await gitea.get_labels(owner, repo)
# Get milestones
labels_list = await gitea.get_labels(owner, repo)
milestones = await gitea.get_milestones(owner, repo)
# Filter by milestone
if milestone:
issues = [i for i in issues if i.get("milestone", {}).get("title") == milestone]
# Filter by label
if label:
issues = [i for i in issues if any(lbl["name"] == label for lbl in i.get("labels", []))]
# Filter by assignee
if assignee:
issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee]
# Separate PRs from issues (ponytail: PRs are also in /issues endpoint)
prs = [i for i in issues if "pull_request" in i]
issues_only = [i for i in issues if "pull_request" not in i]
# Get board config
board_info = _ensure_board(owner, repo)
columns = board_info["columns"]
cards = _get_cards(board_info["id"])
# Map issues to columns
column_map: dict[str, list[dict]] = {col: [] for col in columns}
card_by_issue = {c["gitea_issue_id"]: c for c in cards if c["gitea_issue_id"]}
for issue in issues_only:
# Determine column from issue state/labels first, then from DB position
col = _issue_column(issue, columns, board_info["id"])
issue["_col"] = col
issue["_card"] = card_by_issue.get(issue["number"])
@@ -97,37 +84,37 @@ async def board(
except Exception as e:
from httpx import HTTPStatusError
if isinstance(e, HTTPStatusError) and e.response is not None and e.response.status_code == 404:
html = '<div style="padding:48px;text-align:center">'
html += '<h2 style="color:var(--text-dim)">Projet introuvable</h2>'
html += f'<p style="color:var(--text-dim)">{owner}/{repo} n\'existe pas sur Gitea.</p>'
html += '<a href="/" class="btn btn-primary" style="margin-top:16px">Retour aux projets</a>'
html += '</div>'
return HTMLResponse(html, status_code=404)
return HTMLResponse(
'<div style="padding:48px;text-align:center">'
f'<h2 style="color:#8b949e">Projet introuvable</h2>'
f'<p style="color:#8b949e">{owner}/{repo} n\'existe pas sur Gitea.</p>'
'<a href="/" class="btn btn-primary" style="margin-top:16px">Retour aux projets</a></div>',
status_code=404)
logger.error("Board error: %s", e)
return HTMLResponse(f"<p class='error'>Erreur: {e}</p>", status_code=500)
from jinja2 import Environment, FileSystemLoader
from datetime import datetime
from app.config import settings
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("board.html")
return template.render(
request=request,
owner=owner,
repo=repo,
columns=columns,
column_map=column_map,
labels=labels,
milestones=milestones,
current_milestone=milestone,
current_label=label,
current_assignee=assignee,
prs=prs,
settings=settings,
now=datetime.now().isoformat()[:10],
now_str = datetime.now().isoformat()[:10]
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ctx = dict(
request=request, owner=owner, repo=repo, columns=columns,
column_map=column_map, labels=labels_list, milestones=milestones,
current_milestone=milestone, current_label=label, current_assignee=assignee,
prs=prs, settings=settings, now=now_str, user=user,
)
if fragment:
template = env.get_template("board_fragment.html")
else:
template = env.get_template("board.html")
return template.render(**ctx)
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
"""Determine which column an issue belongs to."""
+11 -3
View File
@@ -18,9 +18,17 @@
</a>
</div>
<div class="header-right">
<span id="user-info" hx-get="/auth/user" hx-trigger="load" hx-swap="innerHTML">
{% if user %}
<span id="user-info">
{% if user.avatar_url %}<img src="{{ user.avatar_url }}" width="20" height="20" class="avatar">{% endif %}
{{ user.login }}
<a href="/auth/logout" class="btn btn-sm">Déconnexion</a>
</span>
{% else %}
<span id="user-info">
<a href="/auth/login" class="btn btn-sm">🔑 Login</a>
</span>
{% endif %}
<button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button>
</div>
</header>
@@ -28,8 +36,7 @@
{% block content %}{% endblock %}
</main>
<!-- Card Detail Modal (global) -->
<div id="card-modal" class="modal-overlay" style="display:none" hx-target="#card-modal-content">
<div id="card-modal" class="modal-overlay" style="display:none">
<div class="modal-container">
<div class="modal-header">
<h3 id="card-modal-title">Détail</h3>
@@ -40,5 +47,6 @@
</div>
<script src="/static/js/app.js" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
+112 -240
View File
@@ -7,9 +7,7 @@
milestone: '{{ current_milestone }}',
label: '{{ current_label }}',
assignee: '{{ current_assignee }}',
showNewIssue: false,
showDetail: false,
detailIssueId: 0
showNewIssue: false
}">
<!-- Header -->
@@ -31,10 +29,8 @@
<!-- 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()">
<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">
@@ -62,8 +58,8 @@
<!-- Filters -->
<div class="board-filters">
<select class="filter-select" name="milestone_filter"
hx-get="/board/{{ owner }}/{{ repo }}"
hx-target=".board-columns"
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">
@@ -73,8 +69,8 @@
{% endfor %}
</select>
<select class="filter-select" name="label_filter"
hx-get="/board/{{ owner }}/{{ repo }}"
hx-target=".board-columns"
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">
@@ -85,77 +81,19 @@
</select>
<input type="text" name="assignee_filter" placeholder="Assigné à..."
class="filter-input"
hx-get="/board/{{ owner }}/{{ repo }}"
hx-target=".board-columns"
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" hx-get="/board/{{ owner }}/{{ repo }}"
hx-target=".board-columns" hx-swap="outerHTML"
hx-include="[name='milestone_filter'],[name='label_filter'],[name='assignee_filter']">
<button class="btn btn-sm" onclick="window.location.reload()">
🔄 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>
<!-- Columns (fragment) -->
{% include "board_fragment.html" %}
<!-- PRs section -->
{% if prs %}
@@ -175,15 +113,53 @@
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Initialize SortableJS
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSwap', initSortable);
// 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) return;
if (el._sortable) el._sortable.destroy();
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 150,
@@ -194,7 +170,6 @@
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(
@@ -203,9 +178,9 @@
);
if (!resp.ok) throw new Error('Move failed');
document.querySelectorAll('.board-column').forEach(col => {
const count = col.querySelectorAll('.card').length;
const cardCount = col.querySelectorAll('.card').length;
const badge = col.querySelector('.column-count');
if (badge) badge.textContent = count;
if (badge) badge.textContent = cardCount;
});
} catch (e) {
console.error('Move failed:', e);
@@ -216,10 +191,8 @@
});
}
function getCSRFToken() {
const match = document.cookie.match(/csrf_token=([^;]+)/);
return match ? match[1] : '';
}
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSettle', initSortable);
function openCardDetail(owner, repo, issueId) {
const modal = document.getElementById('card-modal');
@@ -227,177 +200,76 @@
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>`;
});
.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 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>`;
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 => {
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>`;
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>`;
});
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>`;
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) {
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;
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,'&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 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 toggleCheckItem(itemId, checked) {
const csrfToken = getCSRFToken();
await fetch(`/api/checklist-items/${itemId}?checked=${checked}`, {
method: 'PATCH', headers: { 'X-CSRF-Token': csrfToken }
});
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 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 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 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();
async function registerWebhook(o,r) {
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);
}
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 %}
+43
View File
@@ -0,0 +1,43 @@
<div class="board-columns" id="board-columns">
{% 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>