feat: FlowDeck v0.2.0 — Kanban Gitea intégré
- Dashboard projets Gitea (user + orgs) - Board Kanban 5 colonnes avec drag & drop (SortableJS) - Sync bidirectionnelle colonnes ↔ labels Gitea - Filtres milestone/label/assignee - Notes Markdown par projet - API REST (/api/health, /api/move, /api/col-mapping…) - Colonnes custom, WIP limits - Thème clair/sombre - Cache Gitea TTL - Docker + docker-compose - Tests pytest - Docs: README, ARCHITECTURE, CHANGELOG, ROADMAP
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="https://unpkg.com/[email protected]" defer></script>
|
||||
<script src="https://unpkg.com/[email protected]" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-icon">▦</span>
|
||||
<span class="logo-text">FlowDeck</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="board"
|
||||
x-data="{
|
||||
owner: '{{ owner }}',
|
||||
repo: '{{ repo }}',
|
||||
milestone: '{{ current_milestone }}',
|
||||
label: '{{ current_label }}',
|
||||
assignee: '{{ current_assignee }}'
|
||||
}">
|
||||
|
||||
<!-- 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">
|
||||
<a href="/notes/{{ owner }}/{{ repo }}" class="btn btn-sm">📝 Notes</a>
|
||||
<a href="{{ settings.gitea_url }}/{{ owner }}/{{ repo }}/issues/new" target="_blank" class="btn btn-sm btn-primary">+ Issue</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="board-filters">
|
||||
<select x-model="milestone" @change="window.location='?milestone='+milestone+'&label='+label+'&assignee='+assignee" class="filter-select">
|
||||
<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 x-model="label" @change="window.location='?milestone='+milestone+'&label='+label+'&assignee='+assignee" class="filter-select">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Columns -->
|
||||
<div class="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">
|
||||
<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">#{{ issue.number }}</a>
|
||||
{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ponytail: initialize SortableJS on each column
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.sortable').forEach(el => {
|
||||
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;
|
||||
|
||||
// Optimistic: move card in DOM is already done
|
||||
|
||||
// Persist to backend
|
||||
try {
|
||||
await fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Update column counts
|
||||
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);
|
||||
// ponytail: reload on error
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="dashboard" x-data="{search: '{{ search }}', showArchived: {{ 'true' if show_archived else 'false' }}}">
|
||||
<div class="dashboard-header">
|
||||
<h1>Projets</h1>
|
||||
<div class="dashboard-controls">
|
||||
<input
|
||||
type="text"
|
||||
x-model="search"
|
||||
placeholder="Rechercher un projet..."
|
||||
class="search-input"
|
||||
hx-get="/?show_archived=${showArchived}"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target=".project-list"
|
||||
hx-include="[name='search'], [name='show_archived']"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="show_archived" x-model="showArchived"
|
||||
hx-get="/"
|
||||
hx-trigger="change"
|
||||
hx-target=".project-list"
|
||||
hx-include="[name='search'], [name='show_archived']">
|
||||
<span>Archivés</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<span>{{ repos|length }} projet(s)</span>
|
||||
</div>
|
||||
|
||||
<div class="project-list">
|
||||
{% if repos %}
|
||||
{% for repo in repos %}
|
||||
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}" class="project-card">
|
||||
<div class="project-card-header">
|
||||
<span class="project-org">{{ repo._org or repo.owner.login }}</span>
|
||||
<span class="project-name">{{ repo.name }}</span>
|
||||
{% if repo.archived %}<span class="badge archived">archivé</span>{% endif %}
|
||||
{% if repo.private %}<span class="badge private">privé</span>{% endif %}
|
||||
</div>
|
||||
{% if repo.description %}
|
||||
<p class="project-desc">{{ repo.description[:120] }}{% if repo.description|length > 120 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
<div class="project-meta">
|
||||
{% if repo.language %}<span class="meta-lang">{{ repo.language }}</span>{% endif %}
|
||||
<span class="meta-stars">⭐ {{ repo.stars_count or 0 }}</span>
|
||||
<span class="meta-updated">Mis à jour {{ repo.updated_at[:10] if repo.updated_at else '' }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty">Aucun projet trouvé.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="notes-page">
|
||||
<div class="board-header">
|
||||
<div class="board-breadcrumb">
|
||||
<a href="/" class="back-link">← Projets</a>
|
||||
<span class="separator">/</span>
|
||||
<a href="/board/{{ owner }}/{{ repo }}" class="back-link">{{ owner }}/{{ repo }}</a>
|
||||
<span class="separator">/</span>
|
||||
<strong>Notes</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notes-editor">
|
||||
<form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML">
|
||||
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
|
||||
<div class="notes-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if content %}
|
||||
<div class="notes-preview">
|
||||
<h3>Aperçu</h3>
|
||||
<div class="markdown-body">{{ content }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user