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)
This commit is contained in:
+132
-56
@@ -1,22 +1,46 @@
|
||||
"""FlowDeck — Board Kanban."""
|
||||
"""FlowDeck — Board Kanban Notion-style."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
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:
|
||||
"""Get or create a board row, return columns config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
||||
@@ -32,14 +56,87 @@ def _ensure_board(owner: str, repo: str) -> dict:
|
||||
return {"id": row["id"], "columns": json.loads(row["columns_json"])}
|
||||
|
||||
|
||||
def _get_cards(board_id: int) -> list[dict]:
|
||||
"""Get all cards for a board, ordered by column + position."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM cards WHERE board_id=? ORDER BY column_name, position",
|
||||
(board_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
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)
|
||||
@@ -52,7 +149,7 @@ async def board(
|
||||
assignee: str = "",
|
||||
fragment: bool = False,
|
||||
):
|
||||
"""Kanban board for a Gitea project."""
|
||||
"""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)
|
||||
@@ -65,21 +162,27 @@ async def board(
|
||||
if assignee:
|
||||
issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee]
|
||||
|
||||
prs = [i for i in issues if i.get("pull_request")]
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
|
||||
board_info = _ensure_board(owner, repo)
|
||||
columns = board_info["columns"]
|
||||
cards = _get_cards(board_info["id"])
|
||||
# Convert to Notion-style cards
|
||||
cards = [_map_issue_to_card(i) for i in issues_only]
|
||||
|
||||
column_map: dict[str, list[dict]] = {col: [] for col in columns}
|
||||
card_by_issue = {c["gitea_issue_id"]: c for c in cards if c["gitea_issue_id"]}
|
||||
|
||||
for issue in issues_only:
|
||||
col = _issue_column(issue, columns, board_info["id"])
|
||||
issue["_col"] = col
|
||||
issue["_card"] = card_by_issue.get(issue["number"])
|
||||
column_map[col].append(issue)
|
||||
# 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
|
||||
@@ -95,43 +198,16 @@ async def board(
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from datetime import datetime
|
||||
from app.config import settings
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
now_str = datetime.now().isoformat()[:10]
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
ctx = dict(
|
||||
request=request, owner=owner, repo=repo, columns=columns,
|
||||
column_map=column_map, labels=labels_list, milestones=milestones,
|
||||
current_milestone=milestone, current_label=label, current_assignee=assignee,
|
||||
prs=prs, settings=settings, now=now_str, user=user,
|
||||
request=request, owner=owner, repo=repo,
|
||||
groups=groups, labels=labels_list, milestones=milestones,
|
||||
**sidebar,
|
||||
)
|
||||
|
||||
if fragment:
|
||||
template = env.get_template("board_fragment.html")
|
||||
else:
|
||||
template = env.get_template("board.html")
|
||||
template = env.get_template("board.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
"""Determine which column an issue belongs to."""
|
||||
# ponytail: closed = Terminé, else check label mapping
|
||||
if issue.get("state") == "closed":
|
||||
return "Terminé" if "Terminé" in columns else columns[-1]
|
||||
|
||||
# Check label-to-column mapping in DB
|
||||
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"]
|
||||
|
||||
# Default: Backlog
|
||||
return columns[0] if columns else "Backlog"
|
||||
|
||||
Reference in New Issue
Block a user