feat: v0.5.0→v0.8.0 — multi-vues, filtres, tri, modale, checklists
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:
+31
-51
@@ -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)
|
||||
|
||||
+31
-3
@@ -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,
|
||||
|
||||
+149
-127
@@ -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(
|
||||
'<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)
|
||||
|
||||
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")
|
||||
|
||||
+187
-138
@@ -16,7 +16,7 @@
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📁</span>
|
||||
<h1>Kanban board new</h1>
|
||||
<h1>{{ repo }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,6 @@
|
||||
<template x-if="!hideDesc">
|
||||
<div>
|
||||
<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>
|
||||
</template>
|
||||
<button class="text-xs text-dim" @click="hideDesc = !hideDesc" style="margin-top: 4px;">
|
||||
@@ -36,136 +32,131 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- View tabs -->
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab active" @click="switchView('kanban')">
|
||||
<!-- View tabs with HTMX -->
|
||||
<div class="view-tabs" id="view-tabs"
|
||||
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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="view-toolbar">
|
||||
<!-- Filter bar -->
|
||||
<div class="filter-bar" x-data="filterSystem()">
|
||||
<template x-for="(f, i) in activeFilters" :key="i">
|
||||
<div class="filter-pill">
|
||||
<span x-text="f.property + ': ' + f.value"></span>
|
||||
<button class="remove-filter" @click="removeFilter(i)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-pill" style="cursor:pointer;" @click="openFilterPanel()">
|
||||
<span>Status: </span>
|
||||
<span class="text-dim" x-text="statusFilter || 'All'"></span>
|
||||
<span style="font-size:10px;">▾</span>
|
||||
<div class="view-toolbar" x-data="filterSystem()">
|
||||
<template x-for="(f, i) in activeFilters" :key="i">
|
||||
<div class="filter-pill">
|
||||
<span x-text="f.property + ': ' + f.value"></span>
|
||||
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
|
||||
<span>Status: </span>
|
||||
<span class="text-dim" x-text="statusFilterLabel"></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>
|
||||
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
||||
<button class="filter-reset-btn" @click="resetFilters()" x-show="activeFilters.length > 0">Reset</button>
|
||||
</div>
|
||||
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
||||
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
|
||||
|
||||
<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="Search">🔍</button>
|
||||
<button class="toolbar-btn">⋯</button>
|
||||
<button class="btn-new">New ▾</button>
|
||||
<button class="btn-new" @click="showNewIssue()">New ▾</button>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
<div class="kanban-container" x-data="kanbanBoard()" id="kanban-root">
|
||||
<!-- To-do Column -->
|
||||
<div class="kanban-column">
|
||||
<div class="kanban-column-header">
|
||||
<span class="col-dot" style="background: var(--gray);"></span>
|
||||
To-do <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.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>
|
||||
<!-- New Issue Form (hidden) -->
|
||||
<div id="new-issue-form" style="display:none; padding: 12px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border);"
|
||||
x-data="newIssueForm()">
|
||||
<div style="display:flex; gap:12px; align-items:center;">
|
||||
<input type="text" placeholder="Issue title..." x-model="title"
|
||||
style="flex:1; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px;
|
||||
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>
|
||||
|
||||
<!-- In Progress Column -->
|
||||
<div class="kanban-column">
|
||||
<div class="kanban-column-header">
|
||||
<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>
|
||||
<!-- Dynamic view content -->
|
||||
<div id="view-content">
|
||||
<!-- Loaded via HTMX -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function switchView(view) {
|
||||
const tabs = document.querySelectorAll('.view-tab');
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
|
||||
// TODO: Load view content via HTMX
|
||||
console.log('Switch to view:', view);
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
@@ -173,57 +164,115 @@
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
if (idx >= 0) this.collapsedGroups.splice(idx, 1);
|
||||
else this.collapsedGroups.push(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
console.log('New card in', groupId, status);
|
||||
// TODO: Create issue in Gitea
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() {
|
||||
console.log('New group');
|
||||
// TODO: Create new group
|
||||
}
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilter: 'All',
|
||||
openFilterPanel() {
|
||||
console.log('Open filter panel');
|
||||
statusFilters: [],
|
||||
showStatusMenu: false,
|
||||
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() {
|
||||
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) {
|
||||
this.activeFilters.splice(i, 1);
|
||||
},
|
||||
resetFilters() {
|
||||
this.activeFilters = [];
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize SortableJS
|
||||
document.addEventListener('alpine:initialized', () => {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const fromCol = evt.from.dataset.status;
|
||||
const toCol = evt.to.dataset.status;
|
||||
const group = evt.to.dataset.group;
|
||||
console.log('Moved card', cardId, 'from', fromCol, 'to', toCol, 'group', group);
|
||||
// TODO: POST /api/move
|
||||
}
|
||||
function sortSystem() {
|
||||
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 => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, { method: 'POST' });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,58 +1,60 @@
|
||||
<div class="board-columns" id="board-columns">
|
||||
{% for col in columns %}
|
||||
<div class="board-column" data-column="{{ col }}" id="col-{{ loop.index }}">
|
||||
<div class="column-header">
|
||||
<span class="column-title">{{ col }}</span>
|
||||
<div class="column-header-right">
|
||||
<span class="column-count">{{ column_map[col]|length }}</span>
|
||||
<button class="col-config-btn" onclick="toggleColConfig(event, '{{ owner }}','{{ repo }}','{{ col }}')" title="Configurer la colonne">⚙</button>
|
||||
</div>
|
||||
<!-- Kanban Board Fragment — loaded via HTMX -->
|
||||
<div id="view-content" class="kanban-container" x-data="kanbanBoard()">
|
||||
{% set statuses = [('todo', 'To-do', 'var(--gray)', 0), ('progress', 'In progress', 'var(--blue)', 1), ('done', 'Complete', 'var(--green)', 2)] %}
|
||||
{% 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>
|
||||
<div class="col-config-panel" id="col-config-{{ col|replace(' ', '_') }}" style="display:none">
|
||||
<div class="col-config-inner">
|
||||
<label class="form-label">Label Gitea → cette colonne</label>
|
||||
<select class="form-select" id="col-label-{{ col|replace(' ', '_') }}">
|
||||
<option value="">— Aucun mapping —</option>
|
||||
</select>
|
||||
<div class="col-config-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="saveColMapping('{{ owner }}','{{ repo }}','{{ col }}')">Mapper</button>
|
||||
<button class="btn btn-sm" onclick="deleteColMapping('{{ owner }}','{{ repo }}','{{ col }}')">✕ Supprimer</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[status] }}</span>
|
||||
</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 class="kanban-cards" x-show="!collapsedGroups.includes('{{ group.id }}')"
|
||||
data-group="{{ group.id }}" data-status="{{ status }}"
|
||||
id="kanban-{{ group.id }}-{{ status }}">
|
||||
{% for card in group.cards if card.status == status %}
|
||||
<div class="kanban-card card-status-{{ card.status }}"
|
||||
data-card-id="{{ card.id }}"
|
||||
@click="openCardDetail('{{ card.id }}')">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">{{ card.icon }}</span>
|
||||
<span class="card-title">{{ card.title }}</span>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
{% if issue.assignee %}
|
||||
<span class="card-assignee" title="{{ issue.assignee.login }}">
|
||||
<img src="{{ issue.assignee.avatar_url }}" alt="" width="16" height="16" class="avatar">
|
||||
{{ issue.assignee.login }}
|
||||
</span>
|
||||
{% if card.assignee %}
|
||||
<div class="card-assignee">
|
||||
<div class="card-avatar" title="{{ card.assignee }}">{{ card.assignee[0] }}</div>
|
||||
<span>{{ card.assignee }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if issue.milestone %}
|
||||
<span class="card-milestone">📅 {{ issue.milestone.title }}</span>
|
||||
{% if card.tag %}
|
||||
<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 %}
|
||||
</div>
|
||||
{% if issue._card and issue._card.priority %}
|
||||
<div class="card-priority priority-{{ issue._card.priority|lower }}">{{ issue._card.priority }}</div>
|
||||
{% endif %}
|
||||
{% if issue._card and issue._card.due_date %}
|
||||
<div class="card-due {% if issue._card.due_date < now %}overdue{% endif %}">⏰ {{ issue._card.due_date[:10] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button class="kanban-new-card" @click="newCard('{{ group.id }}', '{{ status }}')">+ New page</button>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user