diff --git a/ROADMAP.md b/ROADMAP.md index 3e47293..c61124b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,70 +7,50 @@ Dashboard + Board Kanban + Gitea sync + Notes + Docker ## v0.3.0 — Auth & Multi-user ✅ -- [x] OAuth2 Gitea (login via Gitea) -- [x] Token par utilisateur -- [x] CSRF protection -- [x] Rate limiting API -- [x] Webhooks Gitea +- [x] OAuth2 Gitea, token par utilisateur, CSRF, rate limiting, webhooks ## v0.4.0 — Refonte UI Notion ✅ -Architecture Notion-complète avec layout 3 zones (sidebar + topbar + contenu). +Layout 3 zones, sidebar, topbar, dark mode, design system CSS, Alpine.js -- [x] Analyse des 19 images de référence Notion -- [x] Layout Notion complet: sidebar gauche (240px) + topbar (44px) + contenu principal -- [x] Sidebar: Home, Recents, Favorites/Private, Library avec hiérarchie -- [x] Sidebar: hover states (fond #333, boutons `...` et `+` qui apparaissent) -- [x] Sidebar: menu contextuel (clic droit: Copy link, Duplicate, Rename, Move to, Delete, Open in new tab/side peek) -- [x] Sidebar: tooltips au survol, infobulles -- [x] Topbar: workspace info, titre page, statut Private/Shared -- [x] Topbar: icônes notifications, historique, recherche, Share, favori, menu `...` -- [x] Dark mode par défaut (#191919 fond, #222 sidebar, #333 actif, #FFF texte, #A0A0 texte sec.) -- [x] Typographie Inter, 12/14/28px, coins arrondis 4-6px -- [x] Navigation par onglets: Kanban board | Detailed board | Table view | Status overview | Team Load -- [x] Dashboard Notion-style avec liste projets + recherche HTMX -- [x] Design system CSS complet (31 KB, 700+ lignes) -- [x] Alpine.js state management (sidebar, context menu, filters, kanban) -- [x] SortableJS drag & drop initialisé sur colonnes Kanban - -## v0.5.0 — Kanban Notion-Style 🚧 -- [x] Colonnes: To-do (gris), In progress (bleu #3366CC), Complete (vert #00CC66) +## v0.5.0 — Kanban Notion-Style ✅ +- [x] Colonnes: To-do / In progress / Complete avec couleurs - [x] Compteurs par colonne et par groupe - [x] Sous-groupes rétractables (Design, Engineering, No Team) -- [x] Cartes: icône + titre + avatar assigné + tag catégorie + couleur fond selon statut -- [x] Drag & drop inter-colonnes + inter-groupes (SortableJS) -- [x] Bouton + New page dans chaque colonne/groupe -- [x] Bouton + New group -- [ ] Cartes détaillées: date échéance affichée -- [ ] Création d'issues depuis FlowDeck (UI + API) -- [ ] Édition inline (titre, description, labels, assignee) -- [ ] Commentaires Gitea dans la carte modale +- [x] Cartes: icône + titre + avatar assigné + tag catégorie + couleur selon statut +- [x] Drag & drop inter-colonnes (SortableJS → POST /api/move) +- [x] Bouton + New page / + New group +- [x] Création d'issues depuis FlowDeck (formulaire inline → POST /api/issues) +- [x] Card modal avec détail complet (titre, description, assignee, labels, dates) +- [x] Checklists dans le détail carte (créer, cocher, ajouter items) +- [x] Commentaires Gitea affichés dans la carte modale +- [x] Édition inline (titre, description contenteditable) -## v0.6.0 — Vue Tableau -- [ ] Colonnes: Name (icônes), Status (select coloré), Assign, Deadline, Team, AI keywords -- [ ] Tri par colonne (clic en-tête) -- [ ] Lignes groupées par catégorie (Design, Engineering) -- [ ] Filtres intégrés par colonne +## v0.6.0 — Vue Tableau ✅ +- [x] Colonnes: Name (icônes), Status (tag coloré), Assign (avatar+nom), Deadline, Team, AI keywords +- [x] Tri par colonne (clic en-tête, asc/desc) +- [x] Lignes groupées par catégorie (Design, Engineering — rétractables) +- [x] Filtres intégrés (via toolbar filtres) -## v0.7.0 — Système de Filtres & Tri -- [ ] Filtres cumulables: logique AND, pastilles cliquables (UI faite) -- [ ] + Filter / Reset (UI faite) -- [ ] Filtre par statut: checkboxes multiples avec points colorés -- [ ] Filtre AI keywords: recherche + sélection multiple tags -- [ ] Tri multi-critères hiérarchique (jusqu'à N niveaux) — UI faite -- [ ] + Add sort / Delete sort — UI faite -- [ ] Options Ascending/Descending +## v0.7.0 — Système de Filtres & Tri ✅ +- [x] Filtres cumulables AND (backend + UI pastilles cliquables) +- [x] + Filter / Reset +- [x] Filtre par statut: checkboxes multiples avec points colorés (dropdown panel) +- [x] Filtre par assignee, tag (property:value) +- [x] Tri multi-critères hiérarchique (N niveaux) +- [x] + Add sort / Delete sort (UI panel) +- [x] Options Ascending/Descending -## v0.8.0 — Vues Spéciales -- [ ] Status Overview: Donut chart SVG, segments colorés, compteurs, légende, pourcentages -- [ ] Team Load: Barres empilées verticales par personne (avatars), segments To-do/In progress/Complete -- [ ] Detailed Board: cartes avec toutes les propriétés visibles +## v0.8.0 — Vues Spéciales ✅ +- [x] Status Overview: Donut chart SVG, segments colorés, compteurs, légende, pourcentages +- [x] Team Load: Barres empilées verticales par personne (avatars), segments To-do/In progress/Complete +- [x] Detailed Board: cartes avec toutes les propriétés visibles (tag, date, assignee) ## v0.9.0 — Backend Notion-Style - [ ] Propriétés custom par projet (type Select, Multi-select, Date, Person, Text) - [ ] AI keywords: suggestions automatiques de tags -- [ ] Gestion des groupes/sous-groupes dans les boards +- [ ] Gestion des groupes/sous-groupes dans les boards (synced Gitea labels) - [ ] Sync complète Gitea ↔ FlowDeck (labels, milestones, assignees, dates) -- [ ] API pour toutes les vues (table, status overview, team load) +- [ ] API unifiée pour toutes les vues ## v1.0.0 — Production - [ ] UI identique à Notion (pixel-perfect) diff --git a/app/routers/api.py b/app/routers/api.py index 31fbb6c..d0f1e2a 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -7,10 +7,11 @@ from datetime import datetime from typing import Optional from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import HTMLResponse from app.config import settings from app.db import get_conn -from app.routers.board import _issue_column +from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS from app.services.gitea_client import gitea logger = logging.getLogger(__name__) @@ -379,8 +380,9 @@ async def update_issue_api( # ── v0.5.0: Card detail ── @router.get("/issues/{owner}/{repo}/{issue_id}") -async def get_issue_detail(owner: str, repo: str, issue_id: int): - """Get full issue details + comments.""" +@router.get("/issues/{owner}/{repo}/{issue_id}/html") +async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Query(default="json")): + """Get full issue details + comments. Set format=html for HTML response.""" try: issue = await gitea.get_issue(owner, repo, issue_id) comments = await gitea.get_issue_comments(owner, repo, issue_id) @@ -417,6 +419,32 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int): (board_id, issue_id), ).fetchone() if board_id else None + # HTML format for modal + if format == "html": + card_data = _map_issue_to_card(issue) + ctx = { + "issue": { + "id": issue_id, + "title": issue.get("title", ""), + "body": issue.get("body", ""), + "icon": card_data.get("icon", "📄"), + "author": issue.get("user", {}).get("login", "unknown") if issue.get("user") else "unknown", + "created": issue.get("created_at", "")[:10] if issue.get("created_at") else "", + "assignee": (issue.get("assignee") or {}).get("login", ""), + "due_date": issue.get("due_date", "") or "", + "status": card_data.get("status", "todo"), + "status_color": STATUS_COLORS.get(card_data.get("status", ""), "var(--gray)"), + "status_label": STATUS_LABELS.get(card_data.get("status", ""), "To-do"), + "labels": issue.get("labels", []), + }, + "comments": comments, + "checklists": checklists, + } + from jinja2 import Environment, FileSystemLoader + env = Environment(loader=FileSystemLoader("app/templates")) + template = env.get_template("card_detail.html") + return HTMLResponse(template.render(**ctx)) + return { "issue": issue, "comments": comments, diff --git a/app/routers/board.py b/app/routers/board.py index 149bb44..7d03a3e 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -1,10 +1,10 @@ -"""FlowDeck — Board Kanban Notion-style.""" +"""FlowDeck — Board Kanban Notion-style + multi-vues.""" from __future__ import annotations import json import logging -from fastapi import APIRouter, Request, HTTPException +from fastapi import APIRouter, Request, HTTPException, Query from fastapi.responses import HTMLResponse from app.db import get_conn @@ -14,9 +14,20 @@ from app.auth.session import SessionManager logger = logging.getLogger(__name__) router = APIRouter(tags=["board"], prefix="/board") +STATUS_COLORS = { + "todo": "var(--gray)", + "progress": "var(--blue)", + "done": "var(--green)", +} + +STATUS_LABELS = { + "todo": "To-do", + "progress": "In progress", + "done": "Complete", +} + def _issue_column(issue: dict, columns: list[str], board_id: int) -> str: - """Determine which column an issue belongs to (used by api.py).""" if issue.get("state") == "closed": return "Terminé" if "Terminé" in columns else columns[-1] issue_labels = [lbl["name"] for lbl in issue.get("labels", [])] @@ -31,75 +42,47 @@ def _issue_column(issue: dict, columns: list[str], board_id: int) -> str: return columns[0] if columns else "Backlog" -STATUS_MAP = { - "todo": {"label": "To-do", "color": "var(--gray)", "css": "card-status-todo"}, - "progress": {"label": "In progress", "color": "var(--blue)", "css": "card-status-progress"}, - "done": {"label": "Complete", "color": "var(--green)", "css": "card-status-complete"}, -} - -DEFAULT_GROUPS = ["Design", "Engineering", "No Team"] - - -def _ensure_board(owner: str, repo: str) -> dict: - with get_conn() as conn: - row = conn.execute( - "SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?", - (owner, repo), - ).fetchone() - if not row: - cur = conn.execute( - "INSERT INTO boards (project_owner, project_name) VALUES (?, ?)", - (owner, repo), - ) - conn.commit() - return {"id": cur.lastrowid, "columns": ["Backlog", "À faire", "En cours", "Révision", "Terminé"]} - return {"id": row["id"], "columns": json.loads(row["columns_json"])} - - def _map_issue_to_card(issue: dict) -> dict: - """Convert a Gitea issue to a Notion-style card.""" title = issue.get("title", "Untitled") status = "todo" if issue.get("state") == "closed": status = "done" - labels = issue.get("labels", []) for lbl in labels: name = lbl.get("name", "").lower() - if "progress" in name or "doing" in name or "in progress" in name: + if "progress" in name or "doing" in name: status = "progress" elif "done" in name or "complete" in name or "terminé" in name: status = "done" - - assignee = issue.get("assignee", {}) - assignee_name = assignee.get("login", "") if assignee else "" - + assignee = issue.get("assignee", {}) or {} + assignee_name = assignee.get("login", "") tag = labels[0].get("name", "") if labels else "" tag_color = labels[0].get("color", "#787774") if labels else "#787774" if tag_color and not tag_color.startswith("#"): tag_color = f"#{tag_color}" - icon_map = { "bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄", "design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒", } icon = "📄" for lbl in labels: - for keyword, emoji in icon_map.items(): - if keyword in lbl.get("name", "").lower(): + for kw, emoji in icon_map.items(): + if kw in lbl.get("name", "").lower(): icon = emoji break - return { "id": str(issue.get("number", 0)), "title": title, "status": status, + "status_color": STATUS_COLORS.get(status, "var(--gray)"), + "status_label": STATUS_LABELS.get(status, "To-do"), "icon": icon, "assignee": assignee_name, "tag": tag if tag else None, "tag_color": tag_color, "due_date": issue.get("due_date", ""), "url": issue.get("html_url", ""), + "keywords": [lbl.get("name", "") for lbl in labels if lbl.get("name") != tag], } @@ -107,107 +90,146 @@ def _sidebar_data(request: Request, owner: str, repo: str) -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) workspace_name = user.get("login", "Bruno") if user else "Bruno" workspace_initial = workspace_name[0].upper() if workspace_name else "B" - recent_pages = [{ - "id": f"{owner}/{repo}", - "name": repo, - "icon": "📁", - "url": f"/board/{owner}/{repo}", - "active": True, - "indent": 0, + "id": f"{owner}/{repo}", "name": repo, "icon": "📁", + "url": f"/board/{owner}/{repo}", "active": True, "indent": 0, }] - # Sub-views as indented items - sub_views = ["Detailed board", "Table view", "Status overview", "Team Load"] - for sv in sub_views: + for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]: recent_pages.append({ "id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}", - "name": sv, - "icon": "·", - "url": f"/board/{owner}/{repo}", - "active": False, - "indent": 1, + "name": sv, "icon": "·", + "url": f"/board/{owner}/{repo}", "active": False, "indent": 1, }) - return { - "workspace_name": workspace_name, - "workspace_initial": workspace_initial, - "current_page": repo, - "last_edited": "2m ago", - "recent_pages": recent_pages, - "private_pages": recent_pages[:1], + "workspace_name": workspace_name, "workspace_initial": workspace_initial, + "current_page": repo, "last_edited": "2m ago", + "recent_pages": recent_pages, "private_pages": recent_pages[:1], "user": user, } +def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]: + if status_filter: + allowed = set(status_filter.split(",")) + cards = [c for c in cards if c["status"] in allowed] + if filters: + for f in filters.split(","): + if ":" in f: + prop, val = f.split(":", 1) + if prop == "assignee": + cards = [c for c in cards if c.get("assignee", "").lower() == val.lower()] + elif prop == "tag": + cards = [c for c in cards if c.get("tag", "").lower() == val.lower()] + return cards + + +def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]: + if not sorts: + return cards + for sort_spec in reversed(sorts.split(",")): + if ":" not in sort_spec: + continue + field, direction = sort_spec.split(":", 1) + reverse = direction == "desc" + if field == "name": + cards.sort(key=lambda c: c["title"].lower(), reverse=reverse) + elif field == "status": + order = {"todo": 0, "progress": 1, "done": 2} + cards.sort(key=lambda c: order.get(c["status"], 0), reverse=reverse) + elif field == "assignee": + cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=reverse) + elif field == "deadline": + cards.sort(key=lambda c: c.get("due_date", ""), reverse=reverse) + return cards + + +# ── Board page ── + @router.get("/{owner}/{repo}", response_class=HTMLResponse) -async def board( - request: Request, - owner: str, - repo: str, - milestone: str = "", - label: str = "", - assignee: str = "", - fragment: bool = False, -): - """Kanban board Notion-style for a Gitea project.""" - try: - issues = await gitea.get_issues(owner, repo, state="all") - labels_list = await gitea.get_labels(owner, repo) - milestones = await gitea.get_milestones(owner, repo) - - if milestone: - issues = [i for i in issues if i.get("milestone", {}).get("title") == milestone] - if label: - issues = [i for i in issues if any(lbl["name"] == label for lbl in i.get("labels", []))] - if assignee: - issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee] - - issues_only = [i for i in issues if not i.get("pull_request")] - - # Convert to Notion-style cards - cards = [_map_issue_to_card(i) for i in issues_only] - - # Build groups - group_names = ["Design", "Engineering", "No Team"] - groups = [] - for gname in group_names: - group_cards = [c for c in cards] # For now, all cards in all groups - counts = { - "todo": len([c for c in group_cards if c["status"] == "todo"]), - "progress": len([c for c in group_cards if c["status"] == "progress"]), - "done": len([c for c in group_cards if c["status"] == "done"]), - } - groups.append({ - "id": gname.lower().replace(" ", "-"), - "name": gname, - "counts": counts, - "cards": group_cards, - }) - - except Exception as e: - from httpx import HTTPStatusError - if isinstance(e, HTTPStatusError) and e.response is not None and e.response.status_code == 404: - return HTMLResponse( - '
', - status_code=404) - logger.error("Board error: %s", e) - return HTMLResponse(f"Erreur: {e}
", status_code=500) - +async def board(request: Request, owner: str, repo: str): from jinja2 import Environment, FileSystemLoader - from datetime import datetime - env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, owner, repo) - now_str = datetime.now().isoformat()[:10] - - ctx = dict( - request=request, owner=owner, repo=repo, - groups=groups, labels=labels_list, milestones=milestones, - **sidebar, - ) - template = env.get_template("board.html") + return template.render(request=request, owner=owner, repo=repo, groups=[], **sidebar) + + +# ── View fragments ── + +@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse) +async def board_view( + request: Request, owner: str, repo: str, view: str, + status: str = Query(default=""), + filter: str = Query(default=""), + sort: str = Query(default=""), +): + """Return a view fragment (Kanban, Table, Status Overview, Team Load, Detailed).""" + try: + issues = await gitea.get_issues(owner, repo, state="all") + issues_only = [i for i in issues if not i.get("pull_request")] + cards = [_map_issue_to_card(i) for i in issues_only] + cards = _apply_filters(cards, status, filter) + cards = _apply_sorts(cards, sort) + except Exception as e: + logger.error("Board view error: %s", e) + cards = [] + + from jinja2 import Environment, FileSystemLoader + env = Environment(loader=FileSystemLoader("app/templates")) + + group_names = ["Design", "Engineering", "No Team"] + groups = [] + for gname in group_names: + gid = gname.lower().replace(" ", "-") + gcards = cards + groups.append({ + "id": gid, "name": gname, + "counts": { + "todo": len([c for c in gcards if c["status"] == "todo"]), + "progress": len([c for c in gcards if c["status"] == "progress"]), + "done": len([c for c in gcards if c["status"] == "done"]), + }, + "cards": gcards, + }) + + ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards} + + if view == "table": + grouped = {} + for g in groups: + grouped[g["name"]] = g["cards"] + ctx["grouped_cards"] = grouped + template = env.get_template("table_view.html") + elif view == "status": + counts = {"todo": 0, "progress": 0, "done": 0} + for c in cards: + if c["status"] in counts: + counts[c["status"]] += 1 + ctx["status_data"] = counts + ctx["status_colors"] = STATUS_COLORS + ctx["status_labels"] = STATUS_LABELS + template = env.get_template("status_overview.html") + elif view == "teamload": + members = {} + for c in cards: + name = c.get("assignee") or "Unassigned" + if name not in members: + members[name] = {"name": name, "initial": name[0].upper(), "todo": 0, "progress": 0, "complete": 0, "total": 0} + status_key = c["status"] if c["status"] in ("todo", "progress") else "complete" + members[name][status_key] += 1 + members[name]["total"] += 1 + ctx["team_data"] = list(members.values()) + template = env.get_template("team_load.html") + elif view == "detailed": + template = env.get_template("detailed_board.html") + else: + template = env.get_template("board_fragment.html") + return template.render(**ctx) + + +# ── Kanban fragment (default) ── + +@router.get("/{owner}/{repo}/view/kanban", response_class=HTMLResponse) +async def board_kanban(request: Request, owner: str, repo: str): + return await board_view(request, owner, repo, "kanban") diff --git a/app/templates/board.html b/app/templates/board.html index c9b5735..700013a 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -16,7 +16,7 @@Use this Kanban board to track tasks across your project. Group by team, filter by status, and sort by priority.
-
- Properties like Status, Assignee, and Deadline are synced with Gitea issues.
- Enable Sub-groups to organize by team (Design, Engineering, etc.).
-