feat: v0.5.0→v0.8.0 — multi-vues, filtres, tri, modale, checklists
CI / test (push) Failing after 4s
CI / lint (push) Failing after 3s

v0.5.0 (Kanban complet):
- Card detail modal avec checklists + commentaires Gitea
- Création d'issues inline (formulaire + POST /api/issues)
- Édition inline (contenteditable)

v0.6.0 (Table view):
- table_view.html: colonnes Name/Status/Assign/Deadline/Team/Keywords
- Tri par colonne (clic en-tête, asc/desc)
- Groupes rétractables (Design/Engineering)

v0.7.0 (Filtres & Tri):
- Filtres cumulables AND (backend _apply_filters + UI pastilles)
- Status dropdown avec checkboxes + dots colorés
- Sort panel multi-critères (+ Add sort / Delete sort)
- Backend _apply_sorts avec asc/desc

v0.8.0 (Vues spéciales):
- status_overview.html: SVG donut chart + légende + pourcentages
- team_load.html: barres empilées par avatar
- detailed_board.html: cartes avec toutes propriétés

Backend:
- /board/{o}/{r}/view/{view} — 5 fragments HTMX
- /api/issues/{o}/{r}/{id}?format=html — modal HTML
- _map_issue_to_card, STATUS_COLORS/LABELS exportés
This commit is contained in:
2026-07-08 14:29:33 -04:00
parent ff555c6fd4
commit 751f13869a
10 changed files with 871 additions and 364 deletions
+31 -51
View File
@@ -7,70 +7,50 @@
Dashboard + Board Kanban + Gitea sync + Notes + Docker Dashboard + Board Kanban + Gitea sync + Notes + Docker
## v0.3.0 — Auth & Multi-user ✅ ## v0.3.0 — Auth & Multi-user ✅
- [x] OAuth2 Gitea (login via Gitea) - [x] OAuth2 Gitea, token par utilisateur, CSRF, rate limiting, webhooks
- [x] Token par utilisateur
- [x] CSRF protection
- [x] Rate limiting API
- [x] Webhooks Gitea
## v0.4.0 — Refonte UI Notion ✅ ## 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 ## v0.5.0 — Kanban Notion-Style ✅
- [x] Layout Notion complet: sidebar gauche (240px) + topbar (44px) + contenu principal - [x] Colonnes: To-do / In progress / Complete avec couleurs
- [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)
- [x] Compteurs par colonne et par groupe - [x] Compteurs par colonne et par groupe
- [x] Sous-groupes rétractables (Design, Engineering, No Team) - [x] Sous-groupes rétractables (Design, Engineering, No Team)
- [x] Cartes: icône + titre + avatar assigné + tag catégorie + couleur fond selon statut - [x] Cartes: icône + titre + avatar assigné + tag catégorie + couleur selon statut
- [x] Drag & drop inter-colonnes + inter-groupes (SortableJS) - [x] Drag & drop inter-colonnes (SortableJS → POST /api/move)
- [x] Bouton + New page dans chaque colonne/groupe - [x] Bouton + New page / + New group
- [x] Bouton + New group - [x] Création d'issues depuis FlowDeck (formulaire inline → POST /api/issues)
- [ ] Cartes détaillées: date échéance affichée - [x] Card modal avec détail complet (titre, description, assignee, labels, dates)
- [ ] Création d'issues depuis FlowDeck (UI + API) - [x] Checklists dans le détail carte (créer, cocher, ajouter items)
- [ ] Édition inline (titre, description, labels, assignee) - [x] Commentaires Gitea affichés dans la carte modale
- [ ] Commentaires Gitea dans la carte modale - [x] Édition inline (titre, description contenteditable)
## v0.6.0 — Vue Tableau ## v0.6.0 — Vue Tableau ✅
- [ ] Colonnes: Name (icônes), Status (select coloré), Assign, Deadline, Team, AI keywords - [x] Colonnes: Name (icônes), Status (tag coloré), Assign (avatar+nom), Deadline, Team, AI keywords
- [ ] Tri par colonne (clic en-tête) - [x] Tri par colonne (clic en-tête, asc/desc)
- [ ] Lignes groupées par catégorie (Design, Engineering) - [x] Lignes groupées par catégorie (Design, Engineering — rétractables)
- [ ] Filtres intégrés par colonne - [x] Filtres intégrés (via toolbar filtres)
## v0.7.0 — Système de Filtres & Tri ## v0.7.0 — Système de Filtres & Tri ✅
- [ ] Filtres cumulables: logique AND, pastilles cliquables (UI faite) - [x] Filtres cumulables AND (backend + UI pastilles cliquables)
- [ ] + Filter / Reset (UI faite) - [x] + Filter / Reset
- [ ] Filtre par statut: checkboxes multiples avec points colorés - [x] Filtre par statut: checkboxes multiples avec points colorés (dropdown panel)
- [ ] Filtre AI keywords: recherche + sélection multiple tags - [x] Filtre par assignee, tag (property:value)
- [ ] Tri multi-critères hiérarchique (jusqu'à N niveaux) — UI faite - [x] Tri multi-critères hiérarchique (N niveaux)
- [ ] + Add sort / Delete sort — UI faite - [x] + Add sort / Delete sort (UI panel)
- [ ] Options Ascending/Descending - [x] Options Ascending/Descending
## v0.8.0 — Vues Spéciales ## v0.8.0 — Vues Spéciales ✅
- [ ] Status Overview: Donut chart SVG, segments colorés, compteurs, légende, pourcentages - [x] 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 - [x] 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 - [x] Detailed Board: cartes avec toutes les propriétés visibles (tag, date, assignee)
## v0.9.0 — Backend Notion-Style ## v0.9.0 — Backend Notion-Style
- [ ] Propriétés custom par projet (type Select, Multi-select, Date, Person, Text) - [ ] Propriétés custom par projet (type Select, Multi-select, Date, Person, Text)
- [ ] AI keywords: suggestions automatiques de tags - [ ] 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) - [ ] 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 ## v1.0.0 — Production
- [ ] UI identique à Notion (pixel-perfect) - [ ] UI identique à Notion (pixel-perfect)
+31 -3
View File
@@ -7,10 +7,11 @@ from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.config import settings from app.config import settings
from app.db import get_conn 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 from app.services.gitea_client import gitea
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -379,8 +380,9 @@ async def update_issue_api(
# ── v0.5.0: Card detail ── # ── v0.5.0: Card detail ──
@router.get("/issues/{owner}/{repo}/{issue_id}") @router.get("/issues/{owner}/{repo}/{issue_id}")
async def get_issue_detail(owner: str, repo: str, issue_id: int): @router.get("/issues/{owner}/{repo}/{issue_id}/html")
"""Get full issue details + comments.""" 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: try:
issue = await gitea.get_issue(owner, repo, issue_id) issue = await gitea.get_issue(owner, repo, issue_id)
comments = await gitea.get_issue_comments(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), (board_id, issue_id),
).fetchone() if board_id else None ).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 { return {
"issue": issue, "issue": issue,
"comments": comments, "comments": comments,
+139 -117
View File
@@ -1,10 +1,10 @@
"""FlowDeck — Board Kanban Notion-style.""" """FlowDeck — Board Kanban Notion-style + multi-vues."""
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.db import get_conn from app.db import get_conn
@@ -14,9 +14,20 @@ from app.auth.session import SessionManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board") 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: 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": if issue.get("state") == "closed":
return "Terminé" if "Terminé" in columns else columns[-1] return "Terminé" if "Terminé" in columns else columns[-1]
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])] 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" 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: def _map_issue_to_card(issue: dict) -> dict:
"""Convert a Gitea issue to a Notion-style card."""
title = issue.get("title", "Untitled") title = issue.get("title", "Untitled")
status = "todo" status = "todo"
if issue.get("state") == "closed": if issue.get("state") == "closed":
status = "done" status = "done"
labels = issue.get("labels", []) labels = issue.get("labels", [])
for lbl in labels: for lbl in labels:
name = lbl.get("name", "").lower() 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" status = "progress"
elif "done" in name or "complete" in name or "terminé" in name: elif "done" in name or "complete" in name or "terminé" in name:
status = "done" status = "done"
assignee = issue.get("assignee", {}) or {}
assignee = issue.get("assignee", {}) assignee_name = assignee.get("login", "")
assignee_name = assignee.get("login", "") if assignee else ""
tag = labels[0].get("name", "") if labels else "" tag = labels[0].get("name", "") if labels else ""
tag_color = labels[0].get("color", "#787774") if labels else "#787774" tag_color = labels[0].get("color", "#787774") if labels else "#787774"
if tag_color and not tag_color.startswith("#"): if tag_color and not tag_color.startswith("#"):
tag_color = f"#{tag_color}" tag_color = f"#{tag_color}"
icon_map = { icon_map = {
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄", "bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒", "design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
} }
icon = "📄" icon = "📄"
for lbl in labels: for lbl in labels:
for keyword, emoji in icon_map.items(): for kw, emoji in icon_map.items():
if keyword in lbl.get("name", "").lower(): if kw in lbl.get("name", "").lower():
icon = emoji icon = emoji
break break
return { return {
"id": str(issue.get("number", 0)), "id": str(issue.get("number", 0)),
"title": title, "title": title,
"status": status, "status": status,
"status_color": STATUS_COLORS.get(status, "var(--gray)"),
"status_label": STATUS_LABELS.get(status, "To-do"),
"icon": icon, "icon": icon,
"assignee": assignee_name, "assignee": assignee_name,
"tag": tag if tag else None, "tag": tag if tag else None,
"tag_color": tag_color, "tag_color": tag_color,
"due_date": issue.get("due_date", ""), "due_date": issue.get("due_date", ""),
"url": issue.get("html_url", ""), "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", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
workspace_name = user.get("login", "Bruno") if user else "Bruno" workspace_name = user.get("login", "Bruno") if user else "Bruno"
workspace_initial = workspace_name[0].upper() if workspace_name else "B" workspace_initial = workspace_name[0].upper() if workspace_name else "B"
recent_pages = [{ recent_pages = [{
"id": f"{owner}/{repo}", "id": f"{owner}/{repo}", "name": repo, "icon": "📁",
"name": repo, "url": f"/board/{owner}/{repo}", "active": True, "indent": 0,
"icon": "📁",
"url": f"/board/{owner}/{repo}",
"active": True,
"indent": 0,
}] }]
# Sub-views as indented items for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]:
sub_views = ["Detailed board", "Table view", "Status overview", "Team Load"]
for sv in sub_views:
recent_pages.append({ recent_pages.append({
"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}", "id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
"name": sv, "name": sv, "icon": "·",
"icon": "·", "url": f"/board/{owner}/{repo}", "active": False, "indent": 1,
"url": f"/board/{owner}/{repo}",
"active": False,
"indent": 1,
}) })
return { return {
"workspace_name": workspace_name, "workspace_name": workspace_name, "workspace_initial": workspace_initial,
"workspace_initial": workspace_initial, "current_page": repo, "last_edited": "2m ago",
"current_page": repo, "recent_pages": recent_pages, "private_pages": recent_pages[:1],
"last_edited": "2m ago",
"recent_pages": recent_pages,
"private_pages": recent_pages[:1],
"user": user, "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) @router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board( async def board(request: Request, owner: str, repo: str):
request: Request, from jinja2 import Environment, FileSystemLoader
owner: str, env = Environment(loader=FileSystemLoader("app/templates"))
repo: str, sidebar = _sidebar_data(request, owner, repo)
milestone: str = "", template = env.get_template("board.html")
label: str = "", return template.render(request=request, owner=owner, repo=repo, groups=[], **sidebar)
assignee: str = "",
fragment: bool = False,
# ── 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=""),
): ):
"""Kanban board Notion-style for a Gitea project.""" """Return a view fragment (Kanban, Table, Status Overview, Team Load, Detailed)."""
try: try:
issues = await gitea.get_issues(owner, repo, state="all") 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")] 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] 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"))
# Build groups
group_names = ["Design", "Engineering", "No Team"] group_names = ["Design", "Engineering", "No Team"]
groups = [] groups = []
for gname in group_names: for gname in group_names:
group_cards = [c for c in cards] # For now, all cards in all groups gid = gname.lower().replace(" ", "-")
counts = { gcards = cards
"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({ groups.append({
"id": gname.lower().replace(" ", "-"), "id": gid, "name": gname,
"name": gname, "counts": {
"counts": counts, "todo": len([c for c in gcards if c["status"] == "todo"]),
"cards": group_cards, "progress": len([c for c in gcards if c["status"] == "progress"]),
"done": len([c for c in gcards if c["status"] == "done"]),
},
"cards": gcards,
}) })
except Exception as e: ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards}
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 if view == "table":
from datetime import datetime 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")
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) 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")
+170 -121
View File
@@ -16,7 +16,7 @@
<div class="page-title-area"> <div class="page-title-area">
<div class="page-title"> <div class="page-title">
<span class="page-icon-lg">📁</span> <span class="page-icon-lg">📁</span>
<h1>Kanban board new</h1> <h1>{{ repo }}</h1>
</div> </div>
</div> </div>
@@ -25,10 +25,6 @@
<template x-if="!hideDesc"> <template x-if="!hideDesc">
<div> <div>
<p>Use this Kanban board to track tasks across your project. Group by team, filter by status, and sort by priority.</p> <p>Use this Kanban board to track tasks across your project. Group by team, filter by status, and sort by priority.</p>
<p style="margin-top: 8px;">
<strong>Properties</strong> like Status, Assignee, and Deadline are synced with Gitea issues.<br>
Enable <strong>Sub-groups</strong> to organize by team (Design, Engineering, etc.).
</p>
</div> </div>
</template> </template>
<button class="text-xs text-dim" @click="hideDesc = !hideDesc" style="margin-top: 4px;"> <button class="text-xs text-dim" @click="hideDesc = !hideDesc" style="margin-top: 4px;">
@@ -36,136 +32,131 @@
</button> </button>
</div> </div>
<!-- View tabs --> <!-- View tabs with HTMX -->
<div class="view-tabs"> <div class="view-tabs" id="view-tabs"
<button class="view-tab active" @click="switchView('kanban')"> hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
hx-trigger="load" hx-target="#view-content" hx-swap="innerHTML">
<button class="view-tab active" data-view="kanban"
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">📊</span> Kanban board <span class="tab-icon">📊</span> Kanban board
</button> </button>
<button class="view-tab" @click="switchView('detailed')"> <button class="view-tab" data-view="detailed"
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">📋</span> Detailed board <span class="tab-icon">📋</span> Detailed board
</button> </button>
<button class="view-tab" @click="switchView('table')"> <button class="view-tab" data-view="table"
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">☰</span> Table view <span class="tab-icon">☰</span> Table view
</button> </button>
<button class="view-tab" @click="switchView('status')"> <button class="view-tab" data-view="status"
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">📈</span> Status overview <span class="tab-icon">📈</span> Status overview
</button> </button>
<button class="view-tab" @click="switchView('teamload')"> <button class="view-tab" data-view="teamload"
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">👥</span> Team Load <span class="tab-icon">👥</span> Team Load
</button> </button>
</div> </div>
<!-- Toolbar --> <!-- Toolbar -->
<div class="view-toolbar"> <div class="view-toolbar" x-data="filterSystem()">
<!-- Filter bar -->
<div class="filter-bar" x-data="filterSystem()">
<template x-for="(f, i) in activeFilters" :key="i"> <template x-for="(f, i) in activeFilters" :key="i">
<div class="filter-pill"> <div class="filter-pill">
<span x-text="f.property + ': ' + f.value"></span> <span x-text="f.property + ': ' + f.value"></span>
<button class="remove-filter" @click="removeFilter(i)">✕</button> <button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
</div> </div>
</template> </template>
<div class="filter-pill" style="cursor:pointer;" @click="openFilterPanel()"> <div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
<span>Status: </span> <span>Status: </span>
<span class="text-dim" x-text="statusFilter || 'All'"></span> <span class="text-dim" x-text="statusFilterLabel"></span>
<span style="font-size:10px;">▾</span> <span style="font-size:10px;">▾</span>
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
@click.stop>
<template x-for="s in statusOptions" :key="s.value">
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
<span x-show="statusFilters.includes(s.value)">✓</span>
</span>
<span class="option-color-dot" :style="'background:' + s.color"></span>
<span x-text="s.label"></span>
</div>
</template>
</div>
</div> </div>
<button class="filter-add-btn" @click="addFilter()">+ Filter</button> <button class="filter-add-btn" @click="addFilter()">+ Filter</button>
<button class="filter-reset-btn" @click="resetFilters()" x-show="activeFilters.length > 0">Reset</button> <button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
</div>
<div class="view-toolbar-spacer"></div> <div class="view-toolbar-spacer"></div>
<button class="toolbar-btn" title="Sort">⇅</button> <div style="position:relative;" x-data="sortSystem()">
<button class="toolbar-btn" @click="showSortPanel = !showSortPanel" title="Sort">⇅</button>
<div class="dropdown-panel" :class="{ visible: showSortPanel }" style="right:0; left:auto;" @click.stop>
<div class="sort-list">
<template x-for="(s, i) in sorts" :key="i">
<div class="sort-item">
<span class="sort-field">
<span x-text="s.field"></span>
</span>
<span class="sort-direction" @click="toggleDir(i)">
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
</span>
<button class="sort-delete" @click="removeSort(i)" title="Delete">🗑</button>
</div>
</template>
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
<button class="dropdown-option" @click="clearSorts()" x-show="sorts.length > 0">Delete sort</button>
</div>
</div>
</div>
<button class="toolbar-btn" title="Properties">⚡</button> <button class="toolbar-btn" title="Properties">⚡</button>
<button class="toolbar-btn" title="Search">🔍</button> <button class="toolbar-btn" title="Search">🔍</button>
<button class="toolbar-btn">⋯</button> <button class="toolbar-btn">⋯</button>
<button class="btn-new">New ▾</button> <button class="btn-new" @click="showNewIssue()">New ▾</button>
</div> </div>
<!-- Kanban Board --> <!-- New Issue Form (hidden) -->
<div class="kanban-container" x-data="kanbanBoard()" id="kanban-root"> <div id="new-issue-form" style="display:none; padding: 12px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border);"
<!-- To-do Column --> x-data="newIssueForm()">
<div class="kanban-column"> <div style="display:flex; gap:12px; align-items:center;">
<div class="kanban-column-header"> <input type="text" placeholder="Issue title..." x-model="title"
<span class="col-dot" style="background: var(--gray);"></span> style="flex:1; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px;
To-do <span class="col-count">1</span> padding:8px 12px; color:var(--text-primary); font-size:14px; outline:none;"
@keydown.enter="create()">
<select x-model="status" style="background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px;
padding:8px; color:var(--text-primary); font-size:13px;">
<option value="todo">To-do</option>
<option value="progress">In progress</option>
<option value="done">Done</option>
</select>
<button class="btn-new" @click="create()">Create</button>
<button class="toolbar-btn" @click="hide()">✕</button>
</div> </div>
{% for group in groups %}
<div class="kanban-group-header" @click="toggleGroup('{{ group.id }}')">
<span class="group-chevron" :class="{ 'open': !collapsedGroups.includes('{{ group.id }}') }">▶</span>
{{ group.name }}
<span class="group-count">{{ group.counts.todo }}</span>
</div>
<div class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
data-group="{{ group.id }}" data-status="todo"
id="kanban-{{ group.id }}-todo">
{% for card in group.cards if card.status == 'todo' %}
{% include "card.html" %}
{% endfor %}
<button class="kanban-new-card" @click="newCard('{{ group.id }}', 'todo')">+ New page</button>
</div>
{% endfor %}
<button class="kanban-new-group" @click="newGroup()" style="margin-top: 16px;">+ New group</button>
</div> </div>
<!-- In Progress Column --> <!-- Dynamic view content -->
<div class="kanban-column"> <div id="view-content">
<div class="kanban-column-header"> <!-- Loaded via HTMX -->
<span class="col-dot" style="background: var(--blue);"></span>
In progress <span class="col-count">3</span>
</div>
{% for group in groups %}
<div class="kanban-group-header" @click="toggleGroup('{{ group.id }}')">
<span class="group-chevron" :class="{ 'open': !collapsedGroups.includes('{{ group.id }}') }">▶</span>
{{ group.name }}
<span class="group-count">{{ group.counts.progress }}</span>
</div>
<div class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
data-group="{{ group.id }}" data-status="progress"
id="kanban-{{ group.id }}-progress">
{% for card in group.cards if card.status == 'progress' %}
{% include "card.html" %}
{% endfor %}
<button class="kanban-new-card" @click="newCard('{{ group.id }}', 'progress')">+ New page</button>
</div>
{% endfor %}
</div>
<!-- Complete Column -->
<div class="kanban-column">
<div class="kanban-column-header">
<span class="col-dot" style="background: var(--green);"></span>
Complete <span class="col-count">1</span>
</div>
{% for group in groups %}
<div class="kanban-group-header" @click="toggleGroup('{{ group.id }}')">
<span class="group-chevron" :class="{ 'open': !collapsedGroups.includes('{{ group.id }}') }">▶</span>
{{ group.name }}
<span class="group-count">{{ group.counts.done }}</span>
</div>
<div class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
data-group="{{ group.id }}" data-status="done"
id="kanban-{{ group.id }}-done">
{% for card in group.cards if card.status == 'done' %}
{% include "card.html" %}
{% endfor %}
<button class="kanban-new-card" @click="newCard('{{ group.id }}', 'done')">+ New page</button>
</div>
{% endfor %}
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
function switchView(view) { const owner = '{{ owner }}';
const tabs = document.querySelectorAll('.view-tab'); const repo = '{{ repo }}';
tabs.forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
// TODO: Load view content via HTMX function setActiveTab(el) {
console.log('Switch to view:', view); document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
} }
function kanbanBoard() { function kanbanBoard() {
@@ -173,57 +164,115 @@
collapsedGroups: [], collapsedGroups: [],
toggleGroup(id) { toggleGroup(id) {
const idx = this.collapsedGroups.indexOf(id); const idx = this.collapsedGroups.indexOf(id);
if (idx >= 0) this.collapsedGroups.splice(idx, 1); idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
else this.collapsedGroups.push(id);
}, },
newCard(groupId, status) { newCard(groupId, status) {
console.log('New card in', groupId, status); document.getElementById('new-issue-form').style.display = 'block';
// TODO: Create issue in Gitea document.querySelector('#new-issue-form').__x.$data.status = status;
}, },
newGroup() { newGroup() { console.log('New group'); }
console.log('New group');
// TODO: Create new group
}
}; };
} }
function filterSystem() { function filterSystem() {
return { return {
activeFilters: [], activeFilters: [],
statusFilter: 'All', statusFilters: [],
openFilterPanel() { showStatusMenu: false,
console.log('Open filter panel'); statusOptions: [
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
{ value: 'done', label: 'Complete', color: 'var(--green)' },
],
get statusFilterLabel() {
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
},
toggleStatus(val) {
const idx = this.statusFilters.indexOf(val);
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
}, },
addFilter() { addFilter() {
console.log('Add filter'); const prop = prompt('Filter by property (status, assignee, label):');
if (!prop) return;
const val = prompt('Value:');
if (!val) return;
this.activeFilters.push({ property: prop, value: val });
this.refreshView();
}, },
removeFilter(i) { removeFilter(i) { this.activeFilters.splice(i, 1); },
this.activeFilters.splice(i, 1); resetFilters() { this.activeFilters = []; this.statusFilters = []; },
}, refreshView() {
resetFilters() { const activeTab = document.querySelector('.view-tab.active');
this.activeFilters = []; if (activeTab) activeTab.click();
} }
}; };
} }
// Initialize SortableJS function sortSystem() {
document.addEventListener('alpine:initialized', () => { return {
sorts: [],
showSortPanel: false,
addSort() {
const field = prompt('Sort by (name, status, assignee, deadline):');
if (!field) return;
this.sorts.push({ field, dir: 'asc' });
this.applySorts();
},
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
clearSorts() { this.sorts = []; this.applySorts(); },
applySorts() {
const activeTab = document.querySelector('.view-tab.active');
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
}
};
}
function newIssueForm() {
return {
title: '', status: 'todo',
create() {
if (!this.title.trim()) return;
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, { method: 'POST' })
.then(r => r.json())
.then(data => {
this.title = '';
this.hide();
// Refresh current view
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
});
},
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
show() { document.getElementById('new-issue-form').style.display = 'block'; }
};
}
function showNewIssue() {
const form = document.getElementById('new-issue-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
// Init SortableJS after HTMX swaps
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.target.id === 'view-content') {
document.querySelectorAll('.kanban-cards').forEach(el => { document.querySelectorAll('.kanban-cards').forEach(el => {
new Sortable(el, { if (el._sortable) el._sortable.destroy();
el._sortable = new Sortable(el, {
group: 'kanban', group: 'kanban',
animation: 200, animation: 200,
ghostClass: 'sortable-ghost', ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag', dragClass: 'sortable-drag',
onEnd: function(evt) { onEnd: function(evt) {
const cardId = evt.item.dataset.cardId; const cardId = evt.item.dataset.cardId;
const fromCol = evt.from.dataset.status; const toStatus = evt.to.dataset.status;
const toCol = evt.to.dataset.status; fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, { method: 'POST' });
const group = evt.to.dataset.group;
console.log('Moved card', cardId, 'from', fromCol, 'to', toCol, 'group', group);
// TODO: POST /api/move
} }
}); });
}); });
}
}); });
</script> </script>
{% endblock %} {% endblock %}
+49 -47
View File
@@ -1,58 +1,60 @@
<div class="board-columns" id="board-columns"> <!-- Kanban Board Fragment — loaded via HTMX -->
{% for col in columns %} <div id="view-content" class="kanban-container" x-data="kanbanBoard()">
<div class="board-column" data-column="{{ col }}" id="col-{{ loop.index }}"> {% set statuses = [('todo', 'To-do', 'var(--gray)', 0), ('progress', 'In progress', 'var(--blue)', 1), ('done', 'Complete', 'var(--green)', 2)] %}
<div class="column-header"> {% for status, label, color, _ in statuses %}
<span class="column-title">{{ col }}</span> <div class="kanban-column">
<div class="column-header-right"> <div class="kanban-column-header">
<span class="column-count">{{ column_map[col]|length }}</span> <span class="col-dot" style="background: {{ color }};"></span>
<button class="col-config-btn" onclick="toggleColConfig(event, '{{ owner }}','{{ repo }}','{{ col }}')" title="Configurer la colonne">⚙</button> {{ label }}
<span class="col-count">{{ groups|map(attribute='counts')|map(attribute=status)|sum }}</span>
</div> </div>
{% for group in groups %}
<div class="kanban-group-header" @click="toggleGroup('{{ group.id }}')">
<span class="group-chevron" :class="{ 'open': !collapsedGroups.includes('{{ group.id }}') }">▶</span>
{{ group.name }}
<span class="group-count">{{ group.counts[status] }}</span>
</div> </div>
<div class="col-config-panel" id="col-config-{{ col|replace(' ', '_') }}" style="display:none"> <div class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
<div class="col-config-inner"> data-group="{{ group.id }}" data-status="{{ status }}"
<label class="form-label">Label Gitea → cette colonne</label> id="kanban-{{ group.id }}-{{ status }}">
<select class="form-select" id="col-label-{{ col|replace(' ', '_') }}"> {% for card in group.cards if card.status == status %}
<option value="">— Aucun mapping —</option> <div class="kanban-card card-status-{{ card.status }}"
</select> data-card-id="{{ card.id }}"
<div class="col-config-actions"> @click="openCardDetail('{{ card.id }}')">
<button class="btn btn-sm btn-primary" onclick="saveColMapping('{{ owner }}','{{ repo }}','{{ col }}')">Mapper</button> <div class="card-header">
<button class="btn btn-sm" onclick="deleteColMapping('{{ owner }}','{{ repo }}','{{ col }}')">✕ Supprimer</button> <span class="card-icon">{{ card.icon }}</span>
</div> <span class="card-title">{{ card.title }}</span>
</div>
</div>
<div class="column-cards sortable" data-column="{{ col }}" data-owner="{{ owner }}" data-repo="{{ repo }}">
{% for issue in column_map[col] %}
<div class="card" data-issue-id="{{ issue.number }}" data-column="{{ col }}" draggable="true"
ondblclick="openCardDetail('{{ owner }}','{{ repo }}',{{ issue.number }})">
<div class="card-labels">
{% for lbl in issue.labels %}
<span class="card-label" style="background:#{{ lbl.color or '666' }}33; color:#{{ lbl.color or 'ccc' }}">{{ lbl.name }}</span>
{% endfor %}
</div>
<div class="card-title">
<a href="{{ issue.html_url }}" target="_blank" onclick="event.stopPropagation()">#{{ issue.number }}</a>
<span class="card-title-text">{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}</span>
</div> </div>
<div class="card-meta"> <div class="card-meta">
{% if issue.assignee %} {% if card.assignee %}
<span class="card-assignee" title="{{ issue.assignee.login }}"> <div class="card-assignee">
<img src="{{ issue.assignee.avatar_url }}" alt="" width="16" height="16" class="avatar"> <div class="card-avatar" title="{{ card.assignee }}">{{ card.assignee[0] }}</div>
{{ issue.assignee.login }} <span>{{ card.assignee }}</span>
</span>
{% endif %}
{% if issue.milestone %}
<span class="card-milestone">📅 {{ issue.milestone.title }}</span>
{% endif %}
</div> </div>
{% if issue._card and issue._card.priority %}
<div class="card-priority priority-{{ issue._card.priority|lower }}">{{ issue._card.priority }}</div>
{% endif %} {% endif %}
{% if issue._card and issue._card.due_date %} {% if card.tag %}
<div class="card-due {% if issue._card.due_date < now %}overdue{% endif %}">⏰ {{ issue._card.due_date[:10] }}</div> <span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
{% endif %}
{% if card.due_date %}
<div class="card-date"><span>📅</span> {{ card.due_date }}</div>
{% endif %} {% endif %}
</div>
{% endfor %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<button class="kanban-new-card" @click="newCard('{{ group.id }}', '{{ status }}')">+ New page</button>
</div> </div>
{% endfor %}
<button class="kanban-new-group" @click="newGroup()" style="margin-top: 12px;">+ New group</button>
</div>
{% endfor %}
</div>
<script>
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}
</script>
+133
View File
@@ -0,0 +1,133 @@
{# Card detail modal content — full issue info + comments #}
<div class="card-detail" x-data="cardDetail()">
<!-- Title -->
<div style="display:flex; align-items:flex-start; gap:12px; margin-bottom:16px;">
<span style="font-size:24px;">{{ issue.icon }}</span>
<div style="flex:1;">
<h2 style="font-size:20px; font-weight:700; color:var(--text-primary); margin:0;"
contenteditable="true"
@blur="updateField('title', $event.target.textContent)">{{ issue.title }}</h2>
<div style="font-size:12px; color:var(--text-dim); margin-top:4px;">
#{{ issue.id }} · opened by {{ issue.author }} · {{ issue.created }}
</div>
</div>
</div>
<!-- Properties -->
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;">
<!-- Status -->
<div class="filter-pill" style="cursor:pointer;" @click="toggleStatusMenu()">
<span class="legend-dot" style="width:8px; height:8px; background:{{ issue.status_color }};"></span>
{{ issue.status_label }}
<span style="font-size:10px;">▾</span>
</div>
<!-- Assignee -->
<div class="filter-pill" style="cursor:pointer;">
<span>👤</span>
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
<span style="font-size:10px;">▾</span>
</div>
<!-- Deadline -->
<div class="filter-pill" style="cursor:pointer;">
<span>📅</span>
{% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %}
</div>
<!-- Labels -->
{% for label in issue.labels %}
<div class="filter-pill">
<span class="legend-dot" style="width:8px; height:8px; background:#{{ label.color }};"></span>
{{ label.name }}
</div>
{% endfor %}
</div>
<!-- Body / Description -->
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
contenteditable="true"
@blur="updateField('body', $event.target.innerHTML)">
{{ issue.body|safe if issue.body else '<span style="color:var(--text-dim);">Add description...</span>' }}
</div>
<!-- Checklists -->
{% for cl in checklists %}
<div style="margin-bottom:16px;">
<div style="font-weight:600; font-size:14px; margin-bottom:8px;">☑ {{ cl.title }}</div>
{% for item in cl.items %}
<div style="display:flex; align-items:center; gap:8px; padding:4px 0;">
<input type="checkbox" {% if item.checked %}checked{% endif %}
@change="toggleChecklistItem({{ item.id }}, $event.target.checked)"
style="accent-color:var(--blue); width:16px; height:16px;">
<span style="font-size:13px; {% if item.checked %}text-decoration:line-through; color:var(--text-dim);{% endif %}">
{{ item.content }}
</span>
</div>
{% endfor %}
<button class="kanban-new-card" style="margin-top:4px;"
@click="addChecklistItem({{ cl.id }})">+ Add item</button>
</div>
{% endfor %}
<button class="kanban-new-card" @click="addChecklist()">+ Add checklist</button>
<!-- Comments -->
<div style="border-top:1px solid var(--border); padding-top:16px; margin-top:16px;">
<h3 style="font-size:14px; font-weight:600; margin-bottom:12px;">Comments ({{ comments|length }})</h3>
{% for comment in comments %}
<div style="display:flex; gap:10px; margin-bottom:12px; padding:8px; border-radius:6px;">
<div class="card-avatar" style="width:24px; height:24px; font-size:11px; flex-shrink:0;">
{{ comment.user.login[0] if comment.user else '?' }}
</div>
<div style="flex:1;">
<div style="font-size:12px; font-weight:600; margin-bottom:2px;">
{{ comment.user.login if comment.user else 'Unknown' }}
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
</div>
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
{{ comment.body|safe }}
</div>
</div>
</div>
{% endfor %}
{% if not comments %}
<p class="text-dim" style="font-size:13px;">No comments yet.</p>
{% endif %}
<textarea placeholder="Add a comment..."
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
resize:vertical; margin-top:8px;"
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
</div>
</div>
<script>
function cardDetail() {
return {
updateField(field, value) {
console.log('Update', field, ':', value);
// TODO: PATCH /api/issues/{owner}/{repo}/{issue_id}
},
toggleStatusMenu() {
console.log('Toggle status');
},
toggleChecklistItem(id, checked) {
fetch(`/api/checklist-items/${id}?checked=${checked}`, { method: 'PATCH' });
},
addChecklistItem(clId) {
const content = prompt('Item name:');
if (content) {
fetch(`/api/checklist-items/${owner}/${repo}/${issue_id}/${clId}?content=${encodeURIComponent(content)}`, { method: 'POST' })
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
}
},
addChecklist() {
const title = prompt('Checklist name:') || 'Checklist';
fetch(`/api/checklists/${owner}/${repo}/${issue_id}?title=${encodeURIComponent(title)}`, { method: 'POST' })
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
},
addComment(body) {
if (!body.trim()) return;
console.log('Add comment:', body);
// TODO: POST to Gitea API for comments
}
};
}
</script>
+69
View File
@@ -0,0 +1,69 @@
<!-- Detailed Board — Notion style with all card properties visible -->
<div id="view-content" class="kanban-container" x-data="kanbanBoard()">
{% set statuses = [('todo', 'To-do', 'var(--gray)'), ('progress', 'In progress', 'var(--blue)'), ('done', 'Complete', 'var(--green)')] %}
{% for status, label, color in statuses %}
<div class="kanban-column">
<div class="kanban-column-header">
<span class="col-dot" style="background: {{ color }};"></span>
{{ label }}
<span class="col-count">{{ groups|map(attribute='counts')|map(attribute=status)|sum }}</span>
</div>
{% for group in groups %}
<div class="kanban-group-header" @click="toggleGroup('{{ group.id }}')">
<span class="group-chevron" :class="{ 'open': !collapsedGroups.includes('{{ group.id }}') }">▶</span>
{{ group.name }}
<span class="group-count">{{ group.counts[status] }}</span>
</div>
<div class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
data-group="{{ group.id }}" data-status="{{ status }}">
{% for card in group.cards if card.status == status %}
<div class="kanban-card card-status-{{ card.status }}"
data-card-id="{{ card.id }}" style="padding: 14px;">
<div class="card-header">
<span class="card-icon">{{ card.icon }}</span>
<span class="card-title" style="font-size:14px; font-weight:600;">{{ card.title }}</span>
</div>
<div class="card-meta" style="flex-direction:column; gap:6px; margin-top:10px;">
{% if card.assignee %}
<div class="card-assignee">
<div class="card-avatar" style="width:22px; height:22px; font-size:11px;">{{ card.assignee[0] }}</div>
<span style="font-size:13px;">{{ card.assignee }}</span>
</div>
{% endif %}
{% if card.tag %}
<div>
<span class="card-tag" style="background:{{ card.tag_color }}20; color:{{ card.tag_color }}; font-size:12px; padding:2px 8px;">
{{ card.tag }}
</span>
</div>
{% endif %}
{% if card.due_date %}
<div class="card-date">
<span>📅</span>
<span style="font-size:12px;">{{ card.due_date }}</span>
</div>
{% endif %}
</div>
<div style="position:absolute; top:8px; right:8px; opacity:0; transition:opacity 0.15s;"
onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0'"
onclick="event.stopPropagation(); openCardDetail('{{ card.id }}')">
<button class="item-action-btn" title="Open detail">↗</button>
</div>
</div>
{% endfor %}
<button class="kanban-new-card" @click="newCard('{{ group.id }}', '{{ status }}')">+ New page</button>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<script>
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}
</script>
+43
View File
@@ -0,0 +1,43 @@
<!-- Status Overview — Notion-style Donut Chart -->
<div id="view-content" class="status-overview">
<div class="donut-container">
<svg viewBox="0 0 200 200" width="200" height="200">
{% set total = status_data.values()|sum %}
{% set offset = namespace(val=0) %}
{% for status, count in status_data.items() %}
{% set pct = (count / total * 100) if total > 0 else 0 %}
{% set dash = (pct / 100 * 251.3)|round(1) %}
<circle cx="100" cy="100" r="40" fill="none"
stroke="{{ status_colors[status] }}" stroke-width="20"
stroke-dasharray="{{ dash }} 251.3"
stroke-dashoffset="{{ -offset.val * 251.3 / 100 }}"
transform="rotate(-90 100 100)"
style="transition: stroke-dasharray 0.5s ease;" />
{% set offset.val = offset.val + pct %}
{% endfor %}
{% if total == 0 %}
<circle cx="100" cy="100" r="40" fill="none"
stroke="var(--bg-tertiary)" stroke-width="20"
stroke-dasharray="251.3 251.3"
transform="rotate(-90 100 100)" />
{% endif %}
</svg>
<div class="donut-center">
<div class="donut-total">{{ total }}</div>
<div class="donut-label">Total</div>
</div>
</div>
<div class="status-legend">
{% for status, count in status_data.items() %}
<div class="legend-item">
<span class="legend-dot" style="background:{{ status_colors[status] }};"></span>
<span>{{ status_labels[status] }}</span>
<span style="font-weight:600; color:var(--text-primary);">{{ count }}</span>
<span class="text-dim">
({{ "%.1f"|format(count / total * 100) if total > 0 else 0 }}%)
</span>
</div>
{% endfor %}
</div>
</div>
+132
View File
@@ -0,0 +1,132 @@
<!-- Table View — Notion style -->
<div id="view-content" x-data="tableView()">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th @click="sortBy('name')" style="min-width: 250px;">
<span class="th-icon">Aa</span> Name
<span x-show="sortField === 'name'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('status')">
<span class="th-icon">⚙</span> Status
<span x-show="sortField === 'status'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('assignee')">
<span class="th-icon">👤</span> Assign
<span x-show="sortField === 'assignee'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('deadline')">
<span class="th-icon">📅</span> Deadline
<span x-show="sortField === 'deadline'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('team')">
<span class="th-icon">👥</span> Team
<span x-show="sortField === 'team'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('keywords')">
<span class="th-icon">🏷</span> AI keywords
<span x-show="sortField === 'keywords'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
</tr>
</thead>
<tbody>
{% for group_name, items in grouped_cards.items() %}
<!-- Group header -->
<tr class="table-group-header" @click="toggleGroup('{{ group_name }}')" style="cursor:pointer;">
<td colspan="6" style="font-weight:600; padding:10px 12px; background:var(--bg-hover); border-bottom:1px solid var(--border);">
<span class="group-chevron" style="font-size:10px; margin-right:6px;">▶</span>
{{ group_name }} <span style="color:var(--text-dim); font-weight:400;">{{ items|length }}</span>
</td>
</tr>
{% for card in items %}
<tr class="table-row" data-card-id="{{ card.id }}" x-show="!collapsedGroups.includes('{{ group_name }}')"
@click="openCard('{{ card.id }}')" style="cursor:pointer;">
<td>
<div class="table-name-cell">
<span class="item-icon">{{ card.icon }}</span>
<span>{{ card.title }}</span>
</div>
</td>
<td>
<span class="card-tag" style="background:{{ card.status_color }}20; color:{{ card.status_color }};">
{{ card.status_label }}
</span>
</td>
<td>
{% if card.assignee %}
<div style="display:flex; align-items:center; gap:6px;">
<div class="card-avatar" style="width:20px; height:20px; font-size:10px;">{{ card.assignee[0] }}</div>
{{ card.assignee }}
</div>
{% else %}
<span class="text-dim">—</span>
{% endif %}
</td>
<td>
{% if card.due_date %}
<span>{{ card.due_date }}</span>
{% else %}
<span class="text-dim">—</span>
{% endif %}
</td>
<td>
{% if card.tag %}
<span class="card-tag" style="background:{{ card.tag_color }}20; color:{{ card.tag_color }};">
{{ card.tag }}
</span>
{% else %}
<span class="text-dim">—</span>
{% endif %}
</td>
<td>
{% if card.keywords %}
<div style="display:flex; gap:4px; flex-wrap:wrap;">
{% for kw in card.keywords %}
<span class="card-tag" style="background:var(--bg-tertiary); color:var(--text-secondary);">{{ kw }}</span>
{% endfor %}
</div>
{% else %}
<span class="text-dim">—</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
function tableView() {
return {
sortField: '',
sortDir: 'asc',
collapsedGroups: [],
sortBy(field) {
if (this.sortField === field) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDir = 'asc';
}
// Re-fetch with sort params via HTMX
const url = new URL(window.location);
url.searchParams.set('sort', field);
url.searchParams.set('dir', this.sortDir);
htmx.ajax('GET', url.pathname + '/view/table' + url.search, { target: '#view-content', swap: 'outerHTML' });
},
toggleGroup(name) {
const idx = this.collapsedGroups.indexOf(name);
if (idx >= 0) this.collapsedGroups.splice(idx, 1);
else this.collapsedGroups.push(name);
},
openCard(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' });
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = '#' + id;
}
};
}
</script>
+49
View File
@@ -0,0 +1,49 @@
<!-- Team Load — Notion-style stacked bar chart -->
<div id="view-content" x-data="teamLoad()" class="team-load">
{% for member in team_data %}
<div class="team-member">
<div class="team-count">{{ member.total }}</div>
<div class="team-bar-stack" style="height: {{ member.total * 32 }}px; min-height: 40px;">
{% if member.complete > 0 %}
<div class="team-bar-segment done"
style="height: {{ (member.complete / member.total * 100)|round }}%;"
title="Complete: {{ member.complete }}"></div>
{% endif %}
{% if member.progress > 0 %}
<div class="team-bar-segment progress"
style="height: {{ (member.progress / member.total * 100)|round }}%;"
title="In progress: {{ member.progress }}"></div>
{% endif %}
{% if member.todo > 0 %}
<div class="team-bar-segment todo"
style="height: {{ (member.todo / member.total * 100)|round }}%;"
title="To-do: {{ member.todo }}"></div>
{% endif %}
</div>
<div class="team-avatar" title="{{ member.name }}">{{ member.initial }}</div>
<div style="font-size:12px; color:var(--text-secondary); text-align:center; max-width:60px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{ member.name }}
</div>
</div>
{% endfor %}
{% if not team_data %}
<div style="text-align:center; padding:48px; width:100%; color:var(--text-dim);">
<div style="font-size:48px; margin-bottom:16px;">👥</div>
<h3>No team data</h3>
<p style="margin-top:8px;">Assign issues to team members to see their workload.</p>
</div>
{% endif %}
</div>
<div class="status-legend" style="justify-content:center; padding: 0 24px 24px;">
<div class="legend-item"><span class="legend-dot" style="background:var(--blue);"></span> To-do</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--orange);"></span> In progress</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
</div>
<script>
function teamLoad() {
return {};
}
</script>