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 = "", milestone: str = "",
label: str = "", label: str = "",
assignee: str = "", assignee: str = "",
fragment: bool = False,
): ):
"""Kanban board for a Gitea project.""" """Kanban board for a Gitea project."""
try: try:
# Get issues
issues = await gitea.get_issues(owner, repo, state="all") issues = await gitea.get_issues(owner, repo, state="all")
labels_list = await gitea.get_labels(owner, repo)
# Get labels
labels = await gitea.get_labels(owner, repo)
# Get milestones
milestones = await gitea.get_milestones(owner, repo) milestones = await gitea.get_milestones(owner, repo)
# Filter by milestone
if milestone: if milestone:
issues = [i for i in issues if i.get("milestone", {}).get("title") == milestone] issues = [i for i in issues if i.get("milestone", {}).get("title") == milestone]
# Filter by label
if label: if label:
issues = [i for i in issues if any(lbl["name"] == label for lbl in i.get("labels", []))] issues = [i for i in issues if any(lbl["name"] == label for lbl in i.get("labels", []))]
# Filter by assignee
if assignee: if assignee:
issues = [i for i in issues if i.get("assignee", {}).get("login") == 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] prs = [i for i in issues if "pull_request" in i]
issues_only = [i for i in issues if "pull_request" not in i] issues_only = [i for i in issues if "pull_request" not in i]
# Get board config
board_info = _ensure_board(owner, repo) board_info = _ensure_board(owner, repo)
columns = board_info["columns"] columns = board_info["columns"]
cards = _get_cards(board_info["id"]) cards = _get_cards(board_info["id"])
# Map issues to columns
column_map: dict[str, list[dict]] = {col: [] for col in 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"]} card_by_issue = {c["gitea_issue_id"]: c for c in cards if c["gitea_issue_id"]}
for issue in issues_only: for issue in issues_only:
# Determine column from issue state/labels first, then from DB position
col = _issue_column(issue, columns, board_info["id"]) col = _issue_column(issue, columns, board_info["id"])
issue["_col"] = col issue["_col"] = col
issue["_card"] = card_by_issue.get(issue["number"]) issue["_card"] = card_by_issue.get(issue["number"])
@@ -97,37 +84,37 @@ async def board(
except Exception as e: except Exception as e:
from httpx import HTTPStatusError from httpx import HTTPStatusError
if isinstance(e, HTTPStatusError) and e.response is not None and e.response.status_code == 404: if isinstance(e, HTTPStatusError) and e.response is not None and e.response.status_code == 404:
html = '<div style="padding:48px;text-align:center">' return HTMLResponse(
html += '<h2 style="color:var(--text-dim)">Projet introuvable</h2>' '<div style="padding:48px;text-align:center">'
html += f'<p style="color:var(--text-dim)">{owner}/{repo} n\'existe pas sur Gitea.</p>' f'<h2 style="color:#8b949e">Projet introuvable</h2>'
html += '<a href="/" class="btn btn-primary" style="margin-top:16px">Retour aux projets</a>' f'<p style="color:#8b949e">{owner}/{repo} n\'existe pas sur Gitea.</p>'
html += '</div>' '<a href="/" class="btn btn-primary" style="margin-top:16px">Retour aux projets</a></div>',
return HTMLResponse(html, status_code=404) status_code=404)
logger.error("Board error: %s", e) logger.error("Board error: %s", e)
return HTMLResponse(f"<p class='error'>Erreur: {e}</p>", status_code=500) return HTMLResponse(f"<p class='error'>Erreur: {e}</p>", status_code=500)
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from datetime import datetime from datetime import datetime
from app.config import settings from app.config import settings
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates")) env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("board.html") now_str = datetime.now().isoformat()[:10]
return template.render( user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
request=request,
owner=owner, ctx = dict(
repo=repo, request=request, owner=owner, repo=repo, columns=columns,
columns=columns, column_map=column_map, labels=labels_list, milestones=milestones,
column_map=column_map, current_milestone=milestone, current_label=label, current_assignee=assignee,
labels=labels, prs=prs, settings=settings, now=now_str, user=user,
milestones=milestones,
current_milestone=milestone,
current_label=label,
current_assignee=assignee,
prs=prs,
settings=settings,
now=datetime.now().isoformat()[:10],
) )
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: def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
"""Determine which column an issue belongs to.""" """Determine which column an issue belongs to."""
+11 -3
View File
@@ -18,9 +18,17 @@
</a> </a>
</div> </div>
<div class="header-right"> <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> <a href="/auth/login" class="btn btn-sm">🔑 Login</a>
</span> </span>
{% endif %}
<button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button> <button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button>
</div> </div>
</header> </header>
@@ -28,8 +36,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- Card Detail Modal (global) --> <div id="card-modal" class="modal-overlay" style="display:none">
<div id="card-modal" class="modal-overlay" style="display:none" hx-target="#card-modal-content">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h3 id="card-modal-title">Détail</h3> <h3 id="card-modal-title">Détail</h3>
@@ -40,5 +47,6 @@
</div> </div>
<script src="/static/js/app.js" defer></script> <script src="/static/js/app.js" defer></script>
{% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+112 -240
View File
@@ -7,9 +7,7 @@
milestone: '{{ current_milestone }}', milestone: '{{ current_milestone }}',
label: '{{ current_label }}', label: '{{ current_label }}',
assignee: '{{ current_assignee }}', assignee: '{{ current_assignee }}',
showNewIssue: false, showNewIssue: false
showDetail: false,
detailIssueId: 0
}"> }">
<!-- Header --> <!-- Header -->
@@ -31,10 +29,8 @@
<!-- New Issue Form --> <!-- New Issue Form -->
<div class="new-issue-form" x-show="showNewIssue" x-transition> <div class="new-issue-form" x-show="showNewIssue" x-transition>
<h4>Nouvelle issue dans {{ owner }}/{{ repo }}</h4> <h4>Nouvelle issue dans {{ owner }}/{{ repo }}</h4>
<form id="new-issue-form" hx-post="/api/issues/{{ owner }}/{{ repo }}" <form id="new-issue-form" onsubmit="handleNewIssue(event, '{{ owner }}', '{{ repo }}')">
hx-target=".board-columns" hx-swap="outerHTML" <input type="hidden" name="csrf_token" class="csrf-token-field">
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"> <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> <textarea name="body" placeholder="Description (markdown)..." rows="3" class="form-input"></textarea>
<div class="form-row"> <div class="form-row">
@@ -62,8 +58,8 @@
<!-- Filters --> <!-- Filters -->
<div class="board-filters"> <div class="board-filters">
<select class="filter-select" name="milestone_filter" <select class="filter-select" name="milestone_filter"
hx-get="/board/{{ owner }}/{{ repo }}" hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
hx-target=".board-columns" hx-target="#board-columns"
hx-trigger="change" hx-trigger="change"
hx-include="[name='label_filter'],[name='assignee_filter']" hx-include="[name='label_filter'],[name='assignee_filter']"
hx-swap="outerHTML"> hx-swap="outerHTML">
@@ -73,8 +69,8 @@
{% endfor %} {% endfor %}
</select> </select>
<select class="filter-select" name="label_filter" <select class="filter-select" name="label_filter"
hx-get="/board/{{ owner }}/{{ repo }}" hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
hx-target=".board-columns" hx-target="#board-columns"
hx-trigger="change" hx-trigger="change"
hx-include="[name='milestone_filter'],[name='assignee_filter']" hx-include="[name='milestone_filter'],[name='assignee_filter']"
hx-swap="outerHTML"> hx-swap="outerHTML">
@@ -85,77 +81,19 @@
</select> </select>
<input type="text" name="assignee_filter" placeholder="Assigné à..." <input type="text" name="assignee_filter" placeholder="Assigné à..."
class="filter-input" class="filter-input"
hx-get="/board/{{ owner }}/{{ repo }}" hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
hx-target=".board-columns" hx-target="#board-columns"
hx-trigger="keyup changed delay:500ms" hx-trigger="keyup changed delay:500ms"
hx-include="[name='milestone_filter'],[name='label_filter']" hx-include="[name='milestone_filter'],[name='label_filter']"
hx-swap="outerHTML" hx-swap="outerHTML"
value="{{ current_assignee }}"> value="{{ current_assignee }}">
<button class="btn btn-sm" hx-get="/board/{{ owner }}/{{ repo }}" <button class="btn btn-sm" onclick="window.location.reload()">
hx-target=".board-columns" hx-swap="outerHTML"
hx-include="[name='milestone_filter'],[name='label_filter'],[name='assignee_filter']">
🔄 Rafraîchir 🔄 Rafraîchir
</button> </button>
</div> </div>
<!-- Columns --> <!-- Columns (fragment) -->
<div class="board-columns" id="board-columns" {% include "board_fragment.html" %}
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 --> <!-- PRs section -->
{% if prs %} {% if prs %}
@@ -175,15 +113,53 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %}
{% block scripts %}
<script> <script>
// Initialize SortableJS // CSRF helper — set CSRF on all forms after load
document.addEventListener('DOMContentLoaded', initSortable); function refreshCsrfFields() {
document.addEventListener('htmx:afterSwap', initSortable); 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() { function initSortable() {
document.querySelectorAll('.sortable').forEach(el => { document.querySelectorAll('.sortable').forEach(el => {
if (el._sortable) return; if (el._sortable) el._sortable.destroy();
el._sortable = new Sortable(el, { el._sortable = new Sortable(el, {
group: 'kanban', group: 'kanban',
animation: 150, animation: 150,
@@ -194,7 +170,6 @@
const newColumn = evt.to.dataset.column; const newColumn = evt.to.dataset.column;
const owner = evt.to.dataset.owner; const owner = evt.to.dataset.owner;
const repo = evt.to.dataset.repo; const repo = evt.to.dataset.repo;
const csrfToken = getCSRFToken(); const csrfToken = getCSRFToken();
try { try {
const resp = await fetch( const resp = await fetch(
@@ -203,9 +178,9 @@
); );
if (!resp.ok) throw new Error('Move failed'); if (!resp.ok) throw new Error('Move failed');
document.querySelectorAll('.board-column').forEach(col => { document.querySelectorAll('.board-column').forEach(col => {
const count = col.querySelectorAll('.card').length; const cardCount = col.querySelectorAll('.card').length;
const badge = col.querySelector('.column-count'); const badge = col.querySelector('.column-count');
if (badge) badge.textContent = count; if (badge) badge.textContent = cardCount;
}); });
} catch (e) { } catch (e) {
console.error('Move failed:', e); console.error('Move failed:', e);
@@ -216,10 +191,8 @@
}); });
} }
function getCSRFToken() { document.addEventListener('DOMContentLoaded', initSortable);
const match = document.cookie.match(/csrf_token=([^;]+)/); document.addEventListener('htmx:afterSettle', initSortable);
return match ? match[1] : '';
}
function openCardDetail(owner, repo, issueId) { function openCardDetail(owner, repo, issueId) {
const modal = document.getElementById('card-modal'); const modal = document.getElementById('card-modal');
@@ -227,177 +200,76 @@
content.innerHTML = '<div class="loading">Chargement...</div>'; content.innerHTML = '<div class="loading">Chargement...</div>';
document.getElementById('card-modal-title').textContent = `#${issueId} — ${owner}/${repo}`; document.getElementById('card-modal-title').textContent = `#${issueId} — ${owner}/${repo}`;
modal.style.display = 'flex'; modal.style.display = 'flex';
fetch(`/api/issues/${owner}/${repo}/${issueId}`) fetch(`/api/issues/${owner}/${repo}/${issueId}`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => { content.innerHTML = renderCardDetail(owner, repo, data); })
content.innerHTML = renderCardDetail(owner, repo, data); .catch(e => { content.innerHTML = `<p class="error">Erreur: ${e}</p>`; });
})
.catch(e => {
content.innerHTML = `<p class="error">Erreur: ${e}</p>`;
});
} }
function closeModal() { function closeModal() { document.getElementById('card-modal').style.display = 'none'; }
document.getElementById('card-modal').style.display = 'none';
}
function renderCardDetail(owner, repo, data) { function renderCardDetail(owner, repo, data) {
const issue = data.issue || {}; const issue = data.issue || {}, comments = data.comments || [], checklists = data.checklists || [], card = data.card || {};
const comments = data.comments || []; let h = '';
const checklists = data.checklists || []; h += `<div class="detail-section"><span class="detail-state badge ${issue.state}">${issue.state}</span> <strong>${escHtml(issue.title||'')}</strong></div>`;
const card = data.card || {}; 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>`);
let html = ''; if (issue.milestone) h += `<span>📅 ${escHtml(issue.milestone.title)}</span>`;
if (issue.assignee) h += `<span>👤 ${escHtml(issue.assignee.login)}</span>`;
// Issue header h += `</div>`;
html += `<div class="detail-section">`; h += `<div class="detail-editable"><label>Priorité: <select onchange="updateCardMeta('${owner}','${repo}',${issue.number},this.value,'')" class="form-select-sm">`;
html += `<span class="detail-state badge ${issue.state}">${issue.state}</span> `; ['Low','Medium','High','Urgent'].forEach(p => h += `<option value="${p}" ${(card.priority||'Medium')===p?'selected':''}>${p}</option>`);
html += `<strong>${escHtml(issue.title || '')}</strong>`; 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>`;
html += `</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>`;
// 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 => { checklists.forEach(cl => {
html += `<div class="checklist">`; h += `<div class="checklist"><strong>${escHtml(cl.title)}</strong><ul class="checklist-items">`;
html += `<strong>${escHtml(cl.title)}</strong>`; (cl.items||[]).forEach(item => {
html += `<ul class="checklist-items">`; h += `<li><input type="checkbox" ${item.checked?'checked':''} onchange="toggleCheckItem(${item.id},this.checked)" class="checklist-check">`;
(cl.items || []).forEach(item => { h += `<span class="${item.checked?'checked':''}">${escHtml(item.content)}</span>`;
html += `<li>`; h += `<button onclick="deleteCheckItem(${item.id})" class="btn-del">×</button></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>`; 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})">`;
html += `<div class="add-item">`; h += `<button onclick="addCheckItem(${cl.id},'${owner}','${repo}',${issue.number})" class="btn btn-sm">+</button></div></div>`;
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) { if (!checklists.length) h += `<button onclick="createChecklist('${owner}','${repo}',${issue.number})" class="btn btn-sm">+ Nouvelle checklist</button>`;
html += `<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>`;
html += `</div>`; 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>`;
// Comments h += `<div class="detail-actions"><a href="${issue.html_url||''}" target="_blank" class="btn btn-sm">🔗 Ouvrir dans Gitea</a>`;
html += `<div class="detail-section">`; h += `<button onclick="editIssueInline('${owner}','${repo}',${issue.number})" class="btn btn-sm">✏️ Éditer</button>`;
html += `<h4>Commentaires (${comments.length})</h4>`; h += `<button onclick="closeModal()" class="btn btn-sm">Fermer</button></div>`;
comments.forEach(c => { return h;
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;'); } function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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 updateCardMeta(owner, repo, issueId, priority, dueDate) { async function toggleCheckItem(id,checked) { await fetch(`/api/checklist-items/${id}?checked=${checked}`,{method:'PATCH',headers:{'X-CSRF-Token':getCSRFToken()}}); }
const csrfToken = getCSRFToken(); async function deleteCheckItem(id) {
await fetch(`/api/card/${owner}/${repo}/${issueId}?priority=${priority}&due_date=${dueDate}`, { await fetch(`/api/checklist-items/${id}`,{method:'DELETE',headers:{'X-CSRF-Token':getCSRFToken()}});
method: 'POST', headers: { 'X-CSRF-Token': csrfToken } 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) {
async function toggleCheckItem(itemId, checked) { const inp=document.getElementById(`new-item-${cid}`);
const csrfToken = getCSRFToken(); if(!inp||!inp.value.trim())return;
await fetch(`/api/checklist-items/${itemId}?checked=${checked}`, { await fetch(`/api/checklist-items/${o}/${r}/${issueId}/${cid}?content=${encodeURIComponent(inp.value)}`,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
method: 'PATCH', headers: { 'X-CSRF-Token': csrfToken } 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 deleteCheckItem(itemId) { async function editIssueInline(o,r,issueId) {
const csrfToken = getCSRFToken(); const t=prompt('Nouveau titre:',''); if(!t)return;
await fetch(`/api/checklist-items/${itemId}`, { await fetch(`/api/issues/${o}/${r}/${issueId}?title=${encodeURIComponent(t)}`,{method:'PATCH',headers:{'X-CSRF-Token':getCSRFToken()}});
method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } closeModal(); window.location.reload();
});
// 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 registerWebhook(o,r) {
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 { try {
const resp = await fetch(`/api/webhook/register/${owner}/${repo}`, { const resp=await fetch(`/api/webhook/register/${o}/${r}`,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
method: 'POST', headers: { 'X-CSRF-Token': csrfToken } const d=await resp.json();
}); alert(d.status==='already_registered'?'Webhook deja enregistre':'Webhook enregistre');
const data = await resp.json(); }catch(e){alert('Erreur: '+e);}
alert(data.status === 'already_registered' ? 'Webhook déjà enregistré ✅' : 'Webhook enregistré ✅');
} catch(e) {
alert('Erreur: ' + e);
}
} }
</script> </script>
{% endblock %} {% 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>