feat: Kanbante v0.1.0 — Kanban Git-integrated PM tool
- Dashboard with all Gitea projects (active/archived) - Kanban board per project (Backlog → À faire → En cours → Révision → Terminé) - Drag & drop issues between columns (SortableJS) - Project notes panel - Gitea API integration (read issues, repos) - SQLite local state for board positions - Dark theme design system - Docker-ready (Dockerfile + docker-compose.yml) - FastAPI + Jinja2 + HTMX + Alpine.js stack
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
*.egg-info/
|
||||||
|
/data/
|
||||||
|
.kanbante/
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY static/ ./static/
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
ENV KANBANTE_DB_PATH=/data/kanbante.db
|
||||||
|
ENV GITEA_URL=https://git.dracodev.net
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Kanbante
|
||||||
|
|
||||||
|
**Kanban project manager integrated with Gitea** — lightweight, self-hosted, dark theme.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📋 **Dashboard** — tous tes projets Gitea en un coup d'œil (actifs / archivés)
|
||||||
|
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → Révision → Terminé
|
||||||
|
- 🎯 **Drag & Drop** — glisse les issues entre colonnes
|
||||||
|
- 🔗 **Gitea Sync** — les issues Gitea deviennent des cartes Kanban
|
||||||
|
- 📝 **Notes** — bloc-notes par projet
|
||||||
|
- 🌙 **Dark theme** — conçu pour les longues sessions
|
||||||
|
- 🐳 **Docker** — un seul conteneur, déploiement en 30 secondes
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installer les dépendances
|
||||||
|
cd kanbante
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# 2. Configurer les variables d'environnement
|
||||||
|
export GITEA_URL=https://git.dracodev.net
|
||||||
|
export GITEA_TOKEN=ton_token_gitea
|
||||||
|
|
||||||
|
# 3. Lancer
|
||||||
|
uvicorn app.main:app --reload --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir http://localhost:8080
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "GITEA_TOKEN=ton_token" > .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
kanbante/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI app (routes + API)
|
||||||
|
│ ├── gitea_client.py # Client API Gitea
|
||||||
|
│ ├── models.py # SQLite (état du board local)
|
||||||
|
│ └── templates/ # Jinja2 (dashboard.html, board.html)
|
||||||
|
├── static/
|
||||||
|
│ ├── css/style.css # Design system dark theme
|
||||||
|
│ └── js/board.js # Drag & drop (SortableJS)
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Méthode | Route | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| GET | `/` | Dashboard tous les projets |
|
||||||
|
| GET | `/board/{owner}/{repo}` | Kanban board |
|
||||||
|
| POST | `/api/move` | Déplacer une issue |
|
||||||
|
| POST | `/api/notes` | Sauvegarder des notes |
|
||||||
|
| GET | `/api/stats` | Statistiques JSON |
|
||||||
|
| GET | `/api/projects` | Projets JSON |
|
||||||
|
| GET | `/api/health` | Health check |
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Gitea API client for Kanbante.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(os.path.expanduser("~/workspace/.env"))
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient:
|
||||||
|
"""Lightweight Gitea API client for Kanbante."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.base_url = os.getenv("GITEA_URL", "https://git.dracodev.net")
|
||||||
|
self.token = os.getenv("GITEA_TOKEN", "")
|
||||||
|
self.client = httpx.Client(
|
||||||
|
base_url=f"{self.base_url}/api/v1",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {self.token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get(self, path: str, **params: Any) -> dict | list:
|
||||||
|
r = self.client.get(path, params=params)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def get_user_repos(self, username: str = "bruno") -> list[dict]:
|
||||||
|
"""Get all repos for a user."""
|
||||||
|
return self._get(f"/users/{username}/repos", limit=100)
|
||||||
|
|
||||||
|
def search_repos(self, uid: int = 1) -> list[dict]:
|
||||||
|
"""Search all repos visible to user."""
|
||||||
|
result = self._get("/repos/search", uid=uid, limit=200)
|
||||||
|
if isinstance(result, dict) and "data" in result:
|
||||||
|
return result["data"]
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
def get_org_repos(self, org: str) -> list[dict]:
|
||||||
|
"""Get repos for an organization."""
|
||||||
|
return self._get(f"/orgs/{org}/repos", limit=50)
|
||||||
|
|
||||||
|
def get_repo_issues(
|
||||||
|
self, owner: str, repo: str, state: str = "all"
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get issues for a repo."""
|
||||||
|
return self._get(
|
||||||
|
f"/repos/{owner}/{repo}/issues",
|
||||||
|
state=state,
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_all_issues(
|
||||||
|
self, repos: list[dict], state: str = "open"
|
||||||
|
) -> dict[str, list[dict]]:
|
||||||
|
"""Get issues for multiple repos, keyed by full_name."""
|
||||||
|
issues_by_repo: dict[str, list[dict]] = {}
|
||||||
|
for repo in repos:
|
||||||
|
full_name = repo["full_name"]
|
||||||
|
owner, name = full_name.split("/", 1)
|
||||||
|
try:
|
||||||
|
issues = self.get_repo_issues(owner, name, state=state)
|
||||||
|
issues_by_repo[full_name] = issues
|
||||||
|
except Exception:
|
||||||
|
issues_by_repo[full_name] = []
|
||||||
|
return issues_by_repo
|
||||||
|
|
||||||
|
def get_user(self) -> dict:
|
||||||
|
"""Get authenticated user info."""
|
||||||
|
return self._get("/user")
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Kanbante — Kanban project manager integrated with Gitea.
|
||||||
|
FastAPI backend serving HTML + JSON API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Form
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.gitea_client import GiteaClient
|
||||||
|
from app.models import (
|
||||||
|
init_db,
|
||||||
|
get_board_state,
|
||||||
|
move_issue,
|
||||||
|
set_notes,
|
||||||
|
get_notes,
|
||||||
|
KANBAN_COLUMNS,
|
||||||
|
get_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates as _Jinja2Templates
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
# ── App setup ──────────────────────────────────────────────
|
||||||
|
BASE = Path(__file__).resolve().parent.parent
|
||||||
|
app = FastAPI(title="Kanbante", version="0.1.0")
|
||||||
|
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
||||||
|
gitea = GiteaClient()
|
||||||
|
|
||||||
|
# ── Templates (workaround Starlette 1.x + Jinja2 cache bug) ──
|
||||||
|
_tpl_env = Environment(
|
||||||
|
loader=FileSystemLoader(str(BASE / "app" / "templates")),
|
||||||
|
autoescape=select_autoescape(["html"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(name: str, **context) -> HTMLResponse:
|
||||||
|
"""Render a Jinja2 template directly (bypasses Starlette's buggy caching)."""
|
||||||
|
template = _tpl_env.get_template(name)
|
||||||
|
html = template.render(**context)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
# Column display names
|
||||||
|
COLUMN_LABELS = {
|
||||||
|
"backlog": "📥 Backlog",
|
||||||
|
"todo": "📋 À faire",
|
||||||
|
"in_progress": "🚧 En cours",
|
||||||
|
"review": "🔍 Révision",
|
||||||
|
"done": "✅ Terminé",
|
||||||
|
}
|
||||||
|
|
||||||
|
COLUMN_COLORS = {
|
||||||
|
"backlog": "#6b7280",
|
||||||
|
"todo": "#3b82f6",
|
||||||
|
"in_progress": "#f59e0b",
|
||||||
|
"review": "#8b5cf6",
|
||||||
|
"done": "#10b981",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _is_active(repo: dict) -> bool:
|
||||||
|
"""A repo is active if updated in 2026."""
|
||||||
|
return repo.get("updated_at", "")[:4] == "2026"
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_issues_with_board(issues: list[dict], board: dict[str, list[dict]]) -> dict[str, list[dict]]:
|
||||||
|
"""Merge Gitea issues into kanban columns based on board state."""
|
||||||
|
columns = {col: [] for col in KANBAN_COLUMNS}
|
||||||
|
|
||||||
|
# Build a lookup of board state by issue number
|
||||||
|
issue_positions: dict[int, str] = {}
|
||||||
|
for col_name, cards in board.items():
|
||||||
|
for card in cards:
|
||||||
|
issue_positions[card["issue_number"]] = col_name
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
num = issue["number"]
|
||||||
|
col = issue_positions.get(num, "backlog")
|
||||||
|
columns[col].append(issue)
|
||||||
|
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes: Pages ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
"""Main dashboard — all projects at a glance."""
|
||||||
|
repos = gitea.search_repos()
|
||||||
|
active = [r for r in repos if _is_active(r)]
|
||||||
|
inactive = [r for r in repos if not _is_active(r)]
|
||||||
|
|
||||||
|
stats = get_stats()
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"active_repos": list(active),
|
||||||
|
"inactive_repos": list(inactive),
|
||||||
|
"stats": {k: dict(v) for k, v in stats.items()},
|
||||||
|
"COLUMN_LABELS": dict(COLUMN_LABELS),
|
||||||
|
"COLUMN_COLORS": dict(COLUMN_COLORS),
|
||||||
|
}
|
||||||
|
return render_template("dashboard.html", **ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
|
||||||
|
async def board(request: Request, owner: str, repo: str):
|
||||||
|
"""Kanban board for a specific repo."""
|
||||||
|
full_name = f"{owner}/{repo}"
|
||||||
|
issues = gitea.get_repo_issues(owner, repo, state="open")
|
||||||
|
board_state = get_board_state(full_name)
|
||||||
|
columns = _merge_issues_with_board(issues, board_state)
|
||||||
|
|
||||||
|
notes = get_notes(full_name) or ""
|
||||||
|
|
||||||
|
# Get repo info
|
||||||
|
repo_info = {}
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
client = gitea.client
|
||||||
|
r = client.get(f"/repos/{owner}/{repo}")
|
||||||
|
if r.status_code == 200:
|
||||||
|
repo_info = r.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render_template("board.html",
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
full_name=full_name,
|
||||||
|
columns=columns,
|
||||||
|
column_labels=COLUMN_LABELS,
|
||||||
|
column_colors=COLUMN_COLORS,
|
||||||
|
notes=notes,
|
||||||
|
repo_info=repo_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes: API ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.post("/api/move")
|
||||||
|
async def api_move(
|
||||||
|
repo_full_name: str = Form(...),
|
||||||
|
issue_number: int = Form(...),
|
||||||
|
to_column: str = Form(...),
|
||||||
|
):
|
||||||
|
"""Move an issue to a new column."""
|
||||||
|
if to_column not in KANBAN_COLUMNS:
|
||||||
|
return {"ok": False, "error": "Invalid column"}
|
||||||
|
move_issue(repo_full_name, issue_number, to_column)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/notes")
|
||||||
|
async def api_notes(
|
||||||
|
repo_full_name: str = Form(...),
|
||||||
|
note: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Save project notes."""
|
||||||
|
set_notes(repo_full_name, note)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats")
|
||||||
|
async def api_stats():
|
||||||
|
"""Get project stats."""
|
||||||
|
return get_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects")
|
||||||
|
async def api_projects():
|
||||||
|
"""Get all projects (JSON)."""
|
||||||
|
repos = gitea.search_repos()
|
||||||
|
return {
|
||||||
|
"active": [r for r in repos if _is_active(r)],
|
||||||
|
"inactive": [r for r in repos if not _is_active(r)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "version": "0.1.0"}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
SQLite models for Kanbante — lightweight local state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DB_PATH = os.path.expanduser("~/.kanbante/kanbante.db")
|
||||||
|
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> sqlite3.Connection:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
conn = get_db()
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS board_state (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
repo_full_name TEXT NOT NULL,
|
||||||
|
issue_number INTEGER NOT NULL,
|
||||||
|
column_name TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(repo_full_name, issue_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
repo_full_name TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(repo_full_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_board_repo
|
||||||
|
ON board_state(repo_full_name, column_name);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_board_state(repo_full_name: str) -> dict[str, list[dict]]:
|
||||||
|
"""Return board columns with their issues."""
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT issue_number, column_name, updated_at FROM board_state WHERE repo_full_name = ?",
|
||||||
|
(repo_full_name,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
|
||||||
|
for row in rows:
|
||||||
|
columns[row["column_name"]].append(
|
||||||
|
{
|
||||||
|
"issue_number": row["issue_number"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def move_issue(repo_full_name: str, issue_number: int, to_column: str) -> None:
|
||||||
|
"""Move an issue to a different column."""
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO board_state (repo_full_name, issue_number, column_name, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(repo_full_name, issue_number)
|
||||||
|
DO UPDATE SET column_name = excluded.column_name, updated_at = excluded.updated_at""",
|
||||||
|
(repo_full_name, issue_number, to_column),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def set_notes(repo_full_name: str, note: str) -> None:
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO project_notes (repo_full_name, note)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(repo_full_name)
|
||||||
|
DO UPDATE SET note = excluded.note""",
|
||||||
|
(repo_full_name, note),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_notes(repo_full_name: str) -> str | None:
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT note FROM project_notes WHERE repo_full_name = ?",
|
||||||
|
(repo_full_name,),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row["note"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats() -> dict:
|
||||||
|
"""Get overall stats across all projects."""
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT repo_full_name, column_name, COUNT(*) as cnt FROM board_state GROUP BY repo_full_name, column_name"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
projects: dict[str, dict[str, int]] = {}
|
||||||
|
for row in rows:
|
||||||
|
name = row["repo_full_name"]
|
||||||
|
if name not in projects:
|
||||||
|
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
|
||||||
|
projects[name][row["column_name"]] = row["cnt"]
|
||||||
|
return projects
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<!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>{{ repo }} — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="https://unpkg.com/[email protected]"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js"></script>
|
||||||
|
<script src="/static/js/board.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>
|
||||||
|
<a href="/">📋 Kanbante</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>{{ owner }}/</span><strong>{{ repo }}</strong>
|
||||||
|
</h1>
|
||||||
|
<nav>
|
||||||
|
{% if repo_info.html_url %}
|
||||||
|
<a href="{{ repo_info.html_url }}" target="_blank" class="btn-sm">🔗 Gitea</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="board-container">
|
||||||
|
<div class="kanban-board">
|
||||||
|
{% for col in ['backlog','todo','in_progress','review','done'] %}
|
||||||
|
<div class="kanban-column"
|
||||||
|
data-column="{{ col }}">
|
||||||
|
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
|
||||||
|
<span class="column-title">{{ column_labels[col] }}</span>
|
||||||
|
<span class="column-count">{{ columns[col]|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="column-cards" data-column="{{ col }}">
|
||||||
|
{% for issue in columns[col] %}
|
||||||
|
<div class="kanban-card"
|
||||||
|
data-issue="{{ issue.number }}"
|
||||||
|
data-repo="{{ full_name }}"
|
||||||
|
draggable="true">
|
||||||
|
<div class="card-id">#{{ issue.number }}</div>
|
||||||
|
<div class="card-title">{{ issue.title }}</div>
|
||||||
|
{% if issue.labels %}
|
||||||
|
<div class="card-labels">
|
||||||
|
{% for label in issue.labels %}
|
||||||
|
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
|
||||||
|
{{ label.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-meta">
|
||||||
|
{% if issue.assignee %}
|
||||||
|
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
|
||||||
|
{% endif %}
|
||||||
|
<span class="card-date">{{ issue.created_at[:10] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes panel -->
|
||||||
|
<aside class="notes-panel">
|
||||||
|
<h3>📝 Notes du projet</h3>
|
||||||
|
<form hx-post="/api/notes" hx-swap="none">
|
||||||
|
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
|
||||||
|
<textarea name="note" rows="10"
|
||||||
|
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
|
||||||
|
<button type="submit" class="btn-save">💾 Sauvegarder</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>{{ full_name }}</span>
|
||||||
|
{% if repo_info.updated_at %}
|
||||||
|
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!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>Kanbante — Dashboard</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="https://unpkg.com/[email protected]"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
|
||||||
|
<nav>
|
||||||
|
<span class="badge">{{ active_repos|length }} actifs</span>
|
||||||
|
<span class="badge secondary">{{ inactive_repos|length }} archivés</span>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main x-data="{ tab: 'active', search: '' }">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button :class="{ active: tab === 'active' }" @click="tab = 'active'">
|
||||||
|
🟢 Actifs ({{ active_repos|length }})
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">
|
||||||
|
⚪ Archivés ({{ inactive_repos|length }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" placeholder="🔍 Filtrer les projets..." x-model="search">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Projects -->
|
||||||
|
<div x-show="tab === 'active'" class="project-grid">
|
||||||
|
{% for repo in active_repos %}
|
||||||
|
{% set full = repo.full_name %}
|
||||||
|
<div class="project-card"
|
||||||
|
x-show="search === '' || '{{ repo.name.lower() }}{{ repo.full_name.lower() }}'.includes(search.toLowerCase())">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="org-tag">{{ repo.owner.login }}</span>
|
||||||
|
<span class="date">{{ repo.updated_at[:10] }}</span>
|
||||||
|
</div>
|
||||||
|
<h3>
|
||||||
|
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
||||||
|
</h3>
|
||||||
|
<p class="desc">{{ repo.description or '—' }}</p>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span>{{ repo.open_issues_count or 0 }} issues</span>
|
||||||
|
{% if full in stats %}
|
||||||
|
<div class="mini-progress">
|
||||||
|
{% for col in ['backlog','todo','in_progress','review','done'] %}
|
||||||
|
{% set n = stats[full].get(col, 0) %}
|
||||||
|
{% if n > 0 %}
|
||||||
|
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
|
||||||
|
{{ n }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inactive Projects -->
|
||||||
|
<div x-show="tab === 'inactive'" class="project-grid">
|
||||||
|
{% for repo in inactive_repos %}
|
||||||
|
<div class="project-card archived"
|
||||||
|
x-show="search === '' || '{{ repo.name.lower() }}'.includes(search.toLowerCase())">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="org-tag">{{ repo.owner.login }}</span>
|
||||||
|
<span class="date">{{ repo.updated_at[:10] }}</span>
|
||||||
|
</div>
|
||||||
|
<h3>{{ repo.name }}</h3>
|
||||||
|
<p class="desc">{{ repo.description or '—' }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Kanbante v0.1 · {{ active_repos|length + inactive_repos|length }} projets sur Gitea
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
kanbante:
|
||||||
|
build: .
|
||||||
|
container_name: kanbante
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- GITEA_URL=${GITEA_URL:-https://git.dracodev.net}
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||||
|
- KANBANTE_DB_PATH=/data/kanbante.db
|
||||||
|
volumes:
|
||||||
|
- kanbante_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
kanbante_data:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[project]
|
||||||
|
name = "kanbante"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Kanban project manager integrated with Gitea"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"jinja2>=3.1,<3.1.6",
|
||||||
|
"httpx>=0.28",
|
||||||
|
"python-dotenv>=1.0",
|
||||||
|
"python-multipart>=0.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
Kanbante — Dark theme design system
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--surface: #1a1d27;
|
||||||
|
--surface2: #242836;
|
||||||
|
--border: #2e3347;
|
||||||
|
--text: #e2e4e9;
|
||||||
|
--text2: #9498a4;
|
||||||
|
--accent: #7c5cfc;
|
||||||
|
--accent2: #5b8af7;
|
||||||
|
--green: #10b981;
|
||||||
|
--yellow: #f59e0b;
|
||||||
|
--red: #ef4444;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 2px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent2); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 1.25rem; font-weight: 600; }
|
||||||
|
header h1 .sep { color: var(--text2); margin: 0 8px; }
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
nav { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--accent)20;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.badge.secondary { background: var(--surface2); color: var(--text2); }
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { background: var(--border); text-decoration: none; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 24px 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.tabs button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.tabs button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.tabs button:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-bar { padding: 12px 24px; background: var(--surface); }
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.search-bar input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Project Grid */
|
||||||
|
.project-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.project-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.project-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.project-card.archived { opacity: 0.5; }
|
||||||
|
.project-card.archived:hover { opacity: 0.8; }
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.org-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.date { font-size: 0.75rem; color: var(--text2); }
|
||||||
|
.project-card h3 { font-size: 1.05rem; margin-bottom: 6px; }
|
||||||
|
.project-card h3 a { color: var(--text); }
|
||||||
|
.project-card h3 a:hover { color: var(--accent2); }
|
||||||
|
.desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text2);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.mini-progress { display: flex; gap: 4px; }
|
||||||
|
.chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kanban Board ────────────────────────────── */
|
||||||
|
.board-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.kanban-board {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.kanban-column {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 340px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-right: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.column-header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
}
|
||||||
|
.column-title { font-weight: 600; font-size: 0.9rem; }
|
||||||
|
.column-count {
|
||||||
|
background: var(--surface2);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.column-cards {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.kanban-card {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: box-shadow 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.kanban-card:hover { box-shadow: var(--shadow); }
|
||||||
|
.kanban-card:active { cursor: grabbing; }
|
||||||
|
.kanban-card.dragging { opacity: 0.5; transform: rotate(2deg); }
|
||||||
|
.card-id {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.card-labels { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
||||||
|
.label-tag {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.avatar { width: 20px; height: 20px; border-radius: 50%; }
|
||||||
|
|
||||||
|
/* Notes panel */
|
||||||
|
.notes-panel {
|
||||||
|
width: 280px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.notes-panel h3 { font-size: 0.9rem; margin-bottom: 12px; }
|
||||||
|
.notes-panel textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.notes-panel textarea:focus { border-color: var(--accent); }
|
||||||
|
.btn-save {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-save:hover { opacity: 0.9; }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text2);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast notification */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--green);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
@keyframes slideIn { from { transform: translateY(20px); opacity: 0; } }
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text2); }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.board-container { flex-direction: column; }
|
||||||
|
.notes-panel { width: 100%; max-height: 200px; border-left: none; border-top: 1px solid var(--border); }
|
||||||
|
.kanban-board { flex-direction: column; }
|
||||||
|
.kanban-column { max-width: 100%; min-width: 100%; margin-right: 0; margin-bottom: 12px; }
|
||||||
|
.project-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Kanbante — Kanban board drag & drop
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const columns = document.querySelectorAll('.column-cards');
|
||||||
|
|
||||||
|
columns.forEach(col => {
|
||||||
|
new Sortable(col, {
|
||||||
|
group: 'kanban',
|
||||||
|
animation: 200,
|
||||||
|
ghostClass: 'dragging',
|
||||||
|
dragClass: 'dragging',
|
||||||
|
onEnd: function(evt) {
|
||||||
|
const card = evt.item;
|
||||||
|
const toColumn = evt.to.dataset.column;
|
||||||
|
const issueNumber = card.dataset.issue;
|
||||||
|
const repoFullName = card.dataset.repo;
|
||||||
|
|
||||||
|
// Update column count
|
||||||
|
updateCounts();
|
||||||
|
|
||||||
|
// Save to backend
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('repo_full_name', repoFullName);
|
||||||
|
formData.append('issue_number', issueNumber);
|
||||||
|
formData.append('to_column', toColumn);
|
||||||
|
|
||||||
|
fetch('/api/move', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
if (data.ok) {
|
||||||
|
showToast('✅ Déplacé vers ' + toColumn.replace('_', ' '));
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Move failed:', err);
|
||||||
|
showToast('❌ Erreur de déplacement');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
document.querySelectorAll('.kanban-column').forEach(col => {
|
||||||
|
const colName = col.querySelector('.column-cards').dataset.column;
|
||||||
|
const count = col.querySelectorAll('.kanban-card').length;
|
||||||
|
const badge = col.querySelector('.column-count');
|
||||||
|
if (badge) badge.textContent = count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast';
|
||||||
|
toast.textContent = msg;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 2500);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user