Files
flowdeck/app/routers/board.py
T
bruno cc680a46c2
CI / test (push) Failing after 10s
CI / lint (push) Failing after 4s
feat: Refonte UI Notion-complete — sidebar, topbar, multi-vues, dark mode
- Layout 3 zones (sidebar 240px + topbar 44px + contenu)
- Sidebar: sections hiérarchiques, hover states, menu contextuel
- CSS design system Notion: #191919/#222/#333, Inter, coins 4-6px
- Vues: Kanban (groupes+colonnes), Dashboard Notion-style
- Templates: base.html refondu, card.html partial, board.html v2
- Backend: données sidebar + board Notion (groups/cards)
- Docs: ROADMAP v0.4→v1.0, WORKLOAD.md, README
- Backward compatible: conserve _issue_column pour api.py
- Images de référence + resized (analyse 2026-07-08)
2026-07-08 14:13:41 -04:00

214 lines
7.6 KiB
Python

"""FlowDeck — Board Kanban Notion-style."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
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", [])]
with get_conn() as conn:
for lbl in issue_labels:
row = conn.execute(
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
(board_id, lbl),
).fetchone()
if row and row["column_name"] in columns:
return row["column_name"]
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:
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 ""
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():
icon = emoji
break
return {
"id": str(issue.get("number", 0)),
"title": title,
"status": status,
"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", ""),
}
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,
}]
# Sub-views as indented items
sub_views = ["Detailed board", "Table view", "Status overview", "Team Load"]
for sv in sub_views:
recent_pages.append({
"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
"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],
"user": user,
}
@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(
'<div style="padding:48px;text-align:center">'
f'<h2 style="color:#8b949e">Projet introuvable</h2>'
f'<p style="color:#8b949e">{owner}/{repo} n\'existe pas sur Gitea.</p>'
'<a href="/" class="btn btn-primary" style="margin-top:16px">Retour aux projets</a></div>',
status_code=404)
logger.error("Board error: %s", e)
return HTMLResponse(f"<p class='error'>Erreur: {e}</p>", status_code=500)
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(**ctx)