From 3732b5cb45e4eb8514bdfb9c199f06652863207c Mon Sep 17 00:00:00 2001 From: Hermes-Deepin Date: Tue, 7 Jul 2026 23:24:15 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Kanbante=20v0.1.0=20=E2=80=94=20Kanban?= =?UTF-8?q?=20Git-integrated=20PM=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 7 + Dockerfile | 20 +++ README.md | 70 ++++++++ app/__init__.py | 0 app/gitea_client.py | 76 ++++++++ app/main.py | 192 ++++++++++++++++++++ app/models.py | 119 ++++++++++++ app/templates/board.html | 84 +++++++++ app/templates/dashboard.html | 90 ++++++++++ docker-compose.yml | 16 ++ pyproject.toml | 22 +++ static/css/style.css | 339 +++++++++++++++++++++++++++++++++++ static/js/board.js | 59 ++++++ 13 files changed, 1094 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/gitea_client.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/templates/board.html create mode 100644 app/templates/dashboard.html create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 static/css/style.css create mode 100644 static/js/board.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3945315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.venv/ +.env +*.egg-info/ +/data/ +.kanbante/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..817ab46 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fa33b9 --- /dev/null +++ b/README.md @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/gitea_client.py b/app/gitea_client.py new file mode 100644 index 0000000..f040391 --- /dev/null +++ b/app/gitea_client.py @@ -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") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..bbf8dac --- /dev/null +++ b/app/main.py @@ -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"} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..7f87686 --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/templates/board.html b/app/templates/board.html new file mode 100644 index 0000000..ce232a8 --- /dev/null +++ b/app/templates/board.html @@ -0,0 +1,84 @@ + + + + + + {{ repo }} — Kanbante + + + + + + +
+

+ 📋 Kanbante + / + {{ owner }}/{{ repo }} +

+ +
+ +
+
+ {% for col in ['backlog','todo','in_progress','review','done'] %} +
+
+ {{ column_labels[col] }} + {{ columns[col]|length }} +
+
+ {% for issue in columns[col] %} +
+
#{{ issue.number }}
+
{{ issue.title }}
+ {% if issue.labels %} +
+ {% for label in issue.labels %} + + {{ label.name }} + + {% endfor %} +
+ {% endif %} +
+ {% if issue.assignee %} + {{ issue.assignee.login }} + {% endif %} + {{ issue.created_at[:10] }} +
+
+ {% endfor %} +
+
+ {% endfor %} +
+ + + +
+ + + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..de240b0 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,90 @@ + + + + + + Kanbante — Dashboard + + + + + + +
+

📋 Kanbante

+ +
+ +
+ +
+ + +
+ + + + + +
+ {% for repo in active_repos %} + {% set full = repo.full_name %} +
+
+ {{ repo.owner.login }} + {{ repo.updated_at[:10] }} +
+

+ {{ repo.name }} +

+

{{ repo.description or '—' }}

+ +
+ {% endfor %} +
+ + +
+ {% for repo in inactive_repos %} +
+
+ {{ repo.owner.login }} + {{ repo.updated_at[:10] }} +
+

{{ repo.name }}

+

{{ repo.description or '—' }}

+
+ {% endfor %} +
+
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c049ecc --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..55a7539 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..ff783a9 --- /dev/null +++ b/static/css/style.css @@ -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; } +} diff --git a/static/js/board.js b/static/js/board.js new file mode 100644 index 0000000..17d837e --- /dev/null +++ b/static/js/board.js @@ -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); + } +});