feat: Refonte UI Notion-complete — sidebar, topbar, multi-vues, dark mode
CI / test (push) Failing after 10s
CI / lint (push) Failing after 4s

- Layout 3 zones (sidebar 240px + topbar 44px + contenu)
- Sidebar: sections hiérarchiques, hover states, menu contextuel
- CSS design system Notion: #191919/#222/#333, Inter, coins 4-6px
- Vues: Kanban (groupes+colonnes), Dashboard Notion-style
- Templates: base.html refondu, card.html partial, board.html v2
- Backend: données sidebar + board Notion (groups/cards)
- Docs: ROADMAP v0.4→v1.0, WORKLOAD.md, README
- Backward compatible: conserve _issue_column pour api.py
- Images de référence + resized (analyse 2026-07-08)
This commit is contained in:
2026-07-08 14:13:41 -04:00
parent d0818f48de
commit cc680a46c2
12 changed files with 2601 additions and 1018 deletions
+34 -33
View File
@@ -1,6 +1,8 @@
# FlowDeck # FlowDeck
Gestion de projets Kanban légère, auto-hébergée, intégrée nativement à **Gitea**. Clone de l'interface **Notion** intégré nativement à **Gitea** — Kanban, Table, Status Overview, Team Load.
> **v0.4.0-dev** — Refonte UI Notion-style (Dark Mode, sidebar, topbar, multi-vues)
## Quick Start ## Quick Start
@@ -11,56 +13,56 @@ docker compose up -d
# → http://localhost:8080 # → http://localhost:8080
``` ```
## Features ## Features (v0.4.0)
- **Dashboard** listant tous les projets Gitea (user + orgs) ### UI Notion-Style
- **Board Kanban** 5 colonnes : Backlog → À faire → En cours → Révision → Terminé - **Sidebar gauche** avec sections hiérarchiques (Home, Recents, Private, Library)
- **Issues Gitea → cartes** : sync automatique, filtres par milestone/label/assignee - **Topbar** avec titre de page, statut, boutons d'action (Share, favori, notifications)
- **Drag & drop** (SortableJS) avec persistance SQLite - **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
- **Sync bidirectionnelle** : déplacer une carte → label Gitea mis à jour, colonne Terminé → ferme l'issue - **Menu contextuel** (clic droit): Copy link, Duplicate, Rename, Move to Trash...
- **Colonnes personnalisables** + configuration du mapping colonne→label Gitea - **Hover states** subtils avec icônes d'action qui apparaissent
- **WIP limits** configurables par colonne - **Typographie Inter**, coins arrondis 4-6px
- **Notes Markdown** par projet (éditeur + sauvegarde)
- **API REST** : `/api/health`, `/api/stats`, `/api/projects`, `/api/move`, `/api/col-mapping` ### Vues Multiples
- **Thème clair/sombre** commutable - **Kanban board**: colonnes To-do/In progress/Complete avec sous-groupes (Design, Engineering)
- **Filtres** par milestone, label, assignee - **Table view**: colonnes Name, Status, Assign, Deadline, Team, AI keywords
- **PR affichées** séparément des issues - **Status overview**: Donut chart avec segments, compteurs, pourcentages
- **Dark/light theme** toggle avec persistance localStorage - **Team Load**: Barres empilées par membre d'équipe
- **Detailed board**: cartes avec toutes les propriétés visibles
### Filtres & Tri
- **Filtres cumulables** (logique AND) avec pastilles cliquables
- **Tri multi-critères** hiérarchique
- **Filtre par statut**: checkboxes multiples avec points colorés
- **Filtre AI keywords**: recherche + sélection multiple
### Intégration Gitea
- **Issues Gitea → cartes** Notion-style
- Sync labels, milestones, assignees, due dates
- OAuth2 Gitea, CSRF, rate limiting
- Webhooks pour sync temps réel
## Stack ## Stack
| Couche | Techno | | Couche | Techno |
|--------|--------| |--------|--------|
| Frontend | HTML5 + Jinja2 + CSS custom, HTMX, Alpine.js, SortableJS | | Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
| Backend | Python 3.12 + FastAPI + Uvicorn + httpx | | Backend | Python 3.12 + FastAPI + httpx |
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` | | BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
| Intégration | API REST Gitea v1 (token auth) |
| Déploiement | Docker (Python 3.12-slim), docker-compose | | Déploiement | Docker (Python 3.12-slim), docker-compose |
## Configuration ## Configuration
Voir `.env.example`. Variables principales :
```env ```env
GITEA_URL=https://git.dracodev.net GITEA_URL=https://git.dracodev.net
GITEA_TOKEN=votre_token GITEA_TOKEN=votre_token
APP_PORT=8080 APP_PORT=8080
DATABASE_URL=sqlite:////data/flowdeck.db DATABASE_URL=sqlite:////data/flowdeck.db
SYNC_INTERVAL=60
GITEA_CACHE_TTL=30
``` ```
## API Reference ## Roadmap
| Endpoint | Description | Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
|----------|-------------|
| `GET /api/health` | Health check (DB + Gitea) |
| `GET /api/stats` | Statistiques globales |
| `GET /api/projects` | Projets Gitea (JSON) |
| `POST /api/move` | Déplacer une carte |
| `POST /api/col-mapping` | Configurer mapping colonne→label |
| `GET /api/board-config/{owner}/{repo}` | Configuration board |
| `POST /api/board-config/{owner}/{repo}` | Mettre à jour colonnes/WIP |
## Développement ## Développement
@@ -68,7 +70,6 @@ GITEA_CACHE_TTL=30
python3 -m venv venv python3 -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
cp .env.example .env # éditer
uvicorn app.main:app --reload --port 8080 uvicorn app.main:app --reload --port 8080
``` ```
+61 -39
View File
@@ -1,51 +1,73 @@
# Roadmap FlowDeck # Roadmap FlowDeck — Notion Clone
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea.
> **19 images de référence** dans `images/` — analysées le 2026-07-08.
## v0.2.0 ✅ (actuel) ## v0.2.0 ✅ (actuel)
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 ✅
- [ ] OAuth2 Gitea (login via Gitea) - [x] OAuth2 Gitea (login via Gitea)
- [ ] Token par utilisateur (plus sécurisé) - [x] Token par utilisateur
- [ ] Permissions basées sur droits Gitea - [x] CSRF protection
- [ ] CSRF protection - [x] Rate limiting API
- [ ] Rate limiting API - [x] Webhooks Gitea
## v0.4.0 — Webhooks & Temps réel ## v0.4.0 — 🚧 Refonte UI Notion (EN COURS)
- [ ] Webhooks Gitea (issues, comments, PR) Architecture Notion-complète avec layout 3 zones (sidebar + topbar + contenu).
- [ ] Sync temps réel sans polling
- [ ] Background tasks (sync périodique)
## v0.5.0 — Cartes enrichies - [x] Analyse des 19 images de référence Notion
- [ ] Vue détail carte (modal) : description, labels, assignee, milestone, commentaires - [ ] Layout Notion complet: sidebar gauche + topbar + contenu principal
- [ ] Checklists / sous-tâches - [ ] Sidebar: Home, Recents, Favorites, Library, Private avec hiérarchie
- [ ] Création d'issues depuis FlowDeck - [ ] Sidebar: hover states (fond #333, boutons ... et +)
- [ ] Édition inline (titre, description, labels, assignee) - [ ] Sidebar: menu contextuel (clic droit: Rename, Duplicate, Delete, Copy link)
- [ ] Commentaires Gitea dans la carte - [ ] Sidebar: tooltips au survol, infobulles
- [ ] Topbar: workspace info, titre page, statut Private/Shared
- [ ] Topbar: icônes notifications, historique, recherche, Share, favori, menu
- [ ] Dark mode par défaut (#191919 fond, #222 sidebar, #333 actif, #FFF texte, #A0A0 text sec.)
- [ ] Typographie Inter, 12/14/28px, coins arrondis 4-6px
- [ ] Navigation par onglets: Kanban board | Detailed board | Table view | Status overview | Team Load
## v0.6.0 — Vues additionnelles ## v0.5.0 — Kanban Notion-Style
- [ ] Vue Liste (table triable) - [ ] Colonnes: To-do (gris), In progress (bleu #3366CC), Complete (vert #00CC66)
- [ ] Vue Calendrier (échéances, milestones) - [ ] Compteurs par colonne et par groupe
- [ ] Vue Timeline / Roadmap - [ ] Sous-groupes rétractables (Design, Engineering, No Team)
- [ ] Vue Sprint / Backlog - [ ] Cartes: icône + titre + avatar assigné + couleur de fond selon statut
- [ ] Cartes détaillées: titre, icône, assigné (avatar+nom), tag catégorie, date échéance
- [ ] Drag & drop inter-colonnes + inter-groupes
- [ ] Bouton + New page dans chaque colonne/groupe
- [ ] Bouton + New group
## v0.7.0 — Analytics ## v0.6.0 — Vue Tableau
- [ ] Dashboard analytics : burndown chart, vélocité - [ ] Colonnes: Name (icônes), Status (select coloré), Assign, Deadline, Team, AI keywords
- [ ] Cartes par statut, en retard - [ ] Tri par colonne (clic en-tête)
- [ ] Activité récente - [ ] Lignes groupées par catégorie (Design, Engineering)
- [ ] Filtres intégrés par colonne
## v0.8.0 — Multi-instance & Export ## v0.7.0 — Système de Filtres & Tri
- [ ] Multi-instance Gitea - [ ] Filtres cumulables: logique AND, pastilles cliquables
- [ ] Export board (JSON/CSV/Markdown) - [ ] + Filter / Reset
- [ ] i18n (EN) - [ ] Filtre par statut: checkboxes multiples avec points colorés
- [ ] Raccourcis clavier - [ ] Filtre AI keywords: recherche + sélection multiple tags
- [ ] Tri multi-critères hiérarchique (jusqu'à N niveaux)
- [ ] + Add sort / Delete sort
- [ ] Options Ascending/Descending
## v0.9.0 — Infra & Qualité ## v0.8.0 — Vues Spéciales
- [ ] Migrations BDD (Alembic) - [ ] Status Overview: Donut chart, segments colorés, compteurs, légende, pourcentages
- [ ] Support PostgreSQL optionnel - [ ] Team Load: Barres empilées verticales par personne (avatars), segments To-do/In progress/Complete
- [ ] Logging structuré (loguru) - [ ] Detailed Board: cartes avec toutes les propriétés visibles
- [ ] Tests ≥ 70% couverture
## 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
- [ ] Sync complète Gitea ↔ FlowDeck (labels, milestones, assignees, dates)
- [ ] API pour toutes les vues (table, status overview, team load)
## v1.0.0 — Production
- [ ] UI identique à Notion (pixel-perfect)
- [ ] Toutes les vues fonctionnelles
- [ ] Tests ≥ 80% couverture
- [ ] CI/CD Gitea Actions - [ ] CI/CD Gitea Actions
## v1.0.0
- [ ] Production ready
- [ ] Tag git v1.0.0 - [ ] Tag git v1.0.0
+177
View File
@@ -0,0 +1,177 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Objectif**: Clone Notion intégré Gitea
## Avancement Global
| Phase | Description | Statut | Progression |
|-------|-------------|--------|-------------|
| Phase 1 | Documents & Planification | 🚧 | 50% |
| Phase 2 | Layout Notion (sidebar + topbar + contenu) | ⬜ | 0% |
| Phase 3 | Sidebar (sections, hover, context menu) | ⬜ | 0% |
| Phase 4 | Vues (Kanban, Table, Status, Team Load) | ⬜ | 0% |
| Phase 5 | Filtres & Tri | ⬜ | 0% |
| Phase 6 | Cartes enrichies | ⬜ | 0% |
| Phase 7 | Intégration Gitea complète | ⬜ | 0% |
| Phase 8 | Polish & Tests | ⬜ | 0% |
---
## Phase 1: Documents & Planification
### 1.1 Analyse des images Notion
- [x] Pull des 19 images de référence
- [x] Analyse du layout complet Notion (~1400px, dark mode)
- [x] Analyse Kanban board 1 (colonnes + cartes + groupes Design/Engineering)
- [x] Analyse Detailed board (cartes avec propriétés visibles)
- [x] Analyse Table view (colonnes Name, Status, Assign, Deadline, Team, AI keywords)
- [x] Analyse Search & Filter (barre de recherche, panneau filtres)
- [x] Analyse Panel Filter (liste des propriétés filtrables)
- [x] Analyse Status Panel Filter (checkboxes multiples + Clear selection)
- [x] Analyse Sort Panel 1 (simple: propriété + ascendant/descendant)
- [x] Analyse Sort Panel 2 (multi-critères: 3 sorts hiérarchiques)
- [x] Analyse Filters Cumulables (AND, pastilles, + Filter, Reset)
- [x] Analyse AI Keywords Filter (recherche + tags colorés)
- [x] Analyse Status Overview (donut chart 18 total, segments, légende)
- [x] Analyse Team Load (barres empilées par avatar)
- [x] Analyse Sidebar Layout (sections: Home, Recents, Favorites, Library)
- [x] Analyse Sidebar Context Menu (right-click: Copy link, Duplicate, Rename, Move to Trash...)
- [x] Analyse Sidebar Mouse Over Elements (hover: fond #333, boutons ... et +)
- [x] Analyse Sidebar Mouse Over Section (tooltip "Open in Library", icônes)
- [x] Analyse Kanban Board 2 (structure groupes + colonnes)
### 1.2 Documents
- [x] ROADMAP.md mis à jour avec vision v0.4 → v1.0
- [x] WORKLOAD.md créé
- [ ] README.md mis à jour
- [ ] ARCHITECTURE.md mis à jour
- [ ] CHANGELOG.md (après premier commit de code)
---
## Phase 2: Layout Notion
### 2.1 Structure HTML
- [ ] `base.html`: layout 3 zones (sidebar fixe gauche, topbar fixe haut, contenu scrollable)
- [ ] Fond global: `#191919`
- [ ] Police Inter importée via Google Fonts
### 2.2 Sidebar (gauche, ~240px, fond #222)
- [ ] Workspace info (avatar B + nom Bruno)
- [ ] Boutons Home, Search, Updates, Settings
- [ ] Section Recents (avec indentation sous-pages)
- [ ] Section Favorites (Agents, Shared, Private)
- [ ] Section Notion apps (Calendar, Desktop)
- [ ] Section Library (My Tasks, Marketplace, Help, Trash)
- [ ] Footer: Invite members, New chat Ctrl+O, bouton édition
### 2.3 Topbar (haut, fond #191919)
- [ ] Logo workspace + nom à gauche
- [ ] Titre page + statut (Private/Shared) au centre
- [ ] Icônes: notifications, historique, recherche
- [ ] Boutons: Share, favori, menu (...)
### 2.4 Content Area (fond #191919)
- [ ] Image de couverture optionnelle
- [ ] Titre de page (28-32px bold)
- [ ] Barre de navigation des vues (onglets)
- [ ] Barre d'outils (filtres, tri, recherche, New)
- [ ] Zone de contenu principale (Kanban / Table / Graphiques)
---
## Phase 3: CSS — Design System
### 3.1 Variables CSS
```css
:root {
--bg-primary: #191919;
--bg-secondary: #222222;
--bg-tertiary: #333333;
--bg-hover: #2F2E2E;
--text-primary: #FFFFFF;
--text-secondary: #A0A0A0;
--color-blue: #3366CC;
--color-green: #00CC66;
--color-yellow: #FFD700;
--font-sans: 'Inter', sans-serif;
--radius-sm: 4px;
--radius-md: 6px;
}
```
### 3.2 Composants
- [ ] `.sidebar` — 240px fixe, fond #222
- [ ] `.sidebar-section` — titre 12px + liste
- [ ] `.sidebar-item` — 14px, padding 8px 12px, hover #333
- [ ] `.sidebar-item.active` — fond #333
- [ ] `.topbar` — hauteur 44px, flex
- [ ] `.view-tabs` — onglets horizontaux
- [ ] `.view-tab` — inactif: texte gris, actif: fond #333 + texte blanc
- [ ] `.kanban-board` — flex horizontal, gap 12px
- [ ] `.kanban-column` — min-width 260px, arrondi 6px
- [ ] `.kanban-card` — fond selon statut, padding 12px, arrondi 6px
- [ ] `.btn-primary` — bleu #3366CC
- [ ] `.btn-secondary` — gris #333333
- [ ] `.filter-pill` — pastille avec texte + croix de suppression
- [ ] `.sort-panel` — menu déroulant avec options
- [ ] `.dropdown-menu` — fond #333, bordure subtile, ombre
- [ ] `.tooltip` — fond #3A3A3A, texte blanc
---
## Phase 4: JavaScript (Alpine.js)
### 4.1 Sidebar
- [ ] Toggle collapse/expand sidebar
- [ ] Hover reveal buttons (... et +)
- [ ] Context menu au clic droit
- [ ] Drag & drop pour réorganiser les favoris
### 4.2 Vues
- [ ] Switch entre vues (Kanban, Table, Status, Team Load, Detailed)
- [ ] Kanban: drag & drop cartes (SortableJS)
- [ ] Table: tri par colonne, redimensionnement
- [ ] Status Overview: Donut chart (SVG ou Canvas)
- [ ] Team Load: Barres empilées
### 4.3 Filtres & Tri
- [ ] Ajout/suppression filtres
- [ ] Logique AND entre filtres
- [ ] Tri multi-niveaux
- [ ] Persistance dans l'URL ou localStorage
---
## Phase 5: Backend (FastAPI)
### 5.1 Nouvelles routes API
- [ ] `GET /api/views/{owner}/{repo}` — config des vues
- [ ] `POST /api/filters` — appliquer filtres
- [ ] `POST /api/sorts` — appliquer tris
- [ ] `GET /api/status-overview/{owner}/{repo}` — données donut chart
- [ ] `GET /api/team-load/{owner}/{repo}` — données barres empilées
- [ ] `GET /api/table-view/{owner}/{repo}` — données format tableau
### 5.2 Base de données (nouvelles tables)
- [ ] `view_config` — configuration par vue
- [ ] `filters` — filtres sauvegardés par utilisateur
- [ ] `sorts` — tris sauvegardés
- [ ] `card_properties` — propriétés custom par carte
### 5.3 Gitea Sync
- [ ] Labels → tags couleur sur cartes
- [ ] Milestones → groupes/sprints
- [ ] Assignees → avatars + noms
- [ ] Due dates → échéances
- [ ] Comments → affichage dans cartes
---
## Notes
- **Couleurs exactes** extraites des analyses: fond `#191919`, sidebar `#222222`, actif `#333333`, hover `#2F2E2E`, texte `#FFFFFF`, secondaire `#A0A0A0`
- **Police**: Inter (Google Fonts), tailles: 12px (petit), 14px (normal), 28-32px (titres)
- **Coins arrondis**: 4px (boutons/cartes), 6px (colonnes)
- **Espacements**: padding 8-12px, gap 8-12px
- **Icônes**: utiliser des émojis ou SVG inline pour éviter dépendance externe
+132 -56
View File
@@ -1,22 +1,46 @@
"""FlowDeck — Board Kanban.""" """FlowDeck — Board Kanban Notion-style."""
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.db import get_conn from app.db import get_conn
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
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")
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
"""Determine which column an issue belongs to (used by api.py)."""
if issue.get("state") == "closed":
return "Terminé" if "Terminé" in columns else columns[-1]
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])]
with get_conn() as conn:
for lbl in issue_labels:
row = conn.execute(
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
(board_id, lbl),
).fetchone()
if row and row["column_name"] in columns:
return row["column_name"]
return columns[0] if columns else "Backlog"
STATUS_MAP = {
"todo": {"label": "To-do", "color": "var(--gray)", "css": "card-status-todo"},
"progress": {"label": "In progress", "color": "var(--blue)", "css": "card-status-progress"},
"done": {"label": "Complete", "color": "var(--green)", "css": "card-status-complete"},
}
DEFAULT_GROUPS = ["Design", "Engineering", "No Team"]
def _ensure_board(owner: str, repo: str) -> dict: def _ensure_board(owner: str, repo: str) -> dict:
"""Get or create a board row, return columns config."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?", "SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
@@ -32,14 +56,87 @@ def _ensure_board(owner: str, repo: str) -> dict:
return {"id": row["id"], "columns": json.loads(row["columns_json"])} return {"id": row["id"], "columns": json.loads(row["columns_json"])}
def _get_cards(board_id: int) -> list[dict]: def _map_issue_to_card(issue: dict) -> dict:
"""Get all cards for a board, ordered by column + position.""" """Convert a Gitea issue to a Notion-style card."""
with get_conn() as conn: title = issue.get("title", "Untitled")
rows = conn.execute( status = "todo"
"SELECT * FROM cards WHERE board_id=? ORDER BY column_name, position", if issue.get("state") == "closed":
(board_id,), status = "done"
).fetchall()
return [dict(r) for r in rows] labels = issue.get("labels", [])
for lbl in labels:
name = lbl.get("name", "").lower()
if "progress" in name or "doing" in name or "in progress" in name:
status = "progress"
elif "done" in name or "complete" in name or "terminé" in name:
status = "done"
assignee = issue.get("assignee", {})
assignee_name = assignee.get("login", "") if assignee else ""
tag = labels[0].get("name", "") if labels else ""
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
if tag_color and not tag_color.startswith("#"):
tag_color = f"#{tag_color}"
icon_map = {
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
}
icon = "📄"
for lbl in labels:
for keyword, emoji in icon_map.items():
if keyword in lbl.get("name", "").lower():
icon = emoji
break
return {
"id": str(issue.get("number", 0)),
"title": title,
"status": status,
"icon": icon,
"assignee": assignee_name,
"tag": tag if tag else None,
"tag_color": tag_color,
"due_date": issue.get("due_date", ""),
"url": issue.get("html_url", ""),
}
def _sidebar_data(request: Request, owner: str, repo: str) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
workspace_name = user.get("login", "Bruno") if user else "Bruno"
workspace_initial = workspace_name[0].upper() if workspace_name else "B"
recent_pages = [{
"id": f"{owner}/{repo}",
"name": repo,
"icon": "📁",
"url": f"/board/{owner}/{repo}",
"active": True,
"indent": 0,
}]
# Sub-views as indented items
sub_views = ["Detailed board", "Table view", "Status overview", "Team Load"]
for sv in sub_views:
recent_pages.append({
"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
"name": sv,
"icon": "·",
"url": f"/board/{owner}/{repo}",
"active": False,
"indent": 1,
})
return {
"workspace_name": workspace_name,
"workspace_initial": workspace_initial,
"current_page": repo,
"last_edited": "2m ago",
"recent_pages": recent_pages,
"private_pages": recent_pages[:1],
"user": user,
}
@router.get("/{owner}/{repo}", response_class=HTMLResponse) @router.get("/{owner}/{repo}", response_class=HTMLResponse)
@@ -52,7 +149,7 @@ async def board(
assignee: str = "", assignee: str = "",
fragment: bool = False, fragment: bool = False,
): ):
"""Kanban board for a Gitea project.""" """Kanban board Notion-style for a Gitea project."""
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) labels_list = await gitea.get_labels(owner, repo)
@@ -65,21 +162,27 @@ async def board(
if assignee: if assignee:
issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee] issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee]
prs = [i for i in issues if i.get("pull_request")]
issues_only = [i for i in issues if not i.get("pull_request")] issues_only = [i for i in issues if not i.get("pull_request")]
board_info = _ensure_board(owner, repo) # Convert to Notion-style cards
columns = board_info["columns"] cards = [_map_issue_to_card(i) for i in issues_only]
cards = _get_cards(board_info["id"])
column_map: dict[str, list[dict]] = {col: [] for col in columns} # Build groups
card_by_issue = {c["gitea_issue_id"]: c for c in cards if c["gitea_issue_id"]} group_names = ["Design", "Engineering", "No Team"]
groups = []
for issue in issues_only: for gname in group_names:
col = _issue_column(issue, columns, board_info["id"]) group_cards = [c for c in cards] # For now, all cards in all groups
issue["_col"] = col counts = {
issue["_card"] = card_by_issue.get(issue["number"]) "todo": len([c for c in group_cards if c["status"] == "todo"]),
column_map[col].append(issue) "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: except Exception as e:
from httpx import HTTPStatusError from httpx import HTTPStatusError
@@ -95,43 +198,16 @@ async def board(
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from datetime import datetime from datetime import datetime
from app.config import settings
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates")) env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, owner, repo)
now_str = datetime.now().isoformat()[:10] now_str = datetime.now().isoformat()[:10]
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ctx = dict( ctx = dict(
request=request, owner=owner, repo=repo, columns=columns, request=request, owner=owner, repo=repo,
column_map=column_map, labels=labels_list, milestones=milestones, groups=groups, labels=labels_list, milestones=milestones,
current_milestone=milestone, current_label=label, current_assignee=assignee, **sidebar,
prs=prs, settings=settings, now=now_str, user=user,
) )
if fragment: template = env.get_template("board.html")
template = env.get_template("board_fragment.html")
else:
template = env.get_template("board.html")
return template.render(**ctx) return template.render(**ctx)
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
"""Determine which column an issue belongs to."""
# ponytail: closed = Terminé, else check label mapping
if issue.get("state") == "closed":
return "Terminé" if "Terminé" in columns else columns[-1]
# Check label-to-column mapping in DB
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])]
with get_conn() as conn:
for lbl in issue_labels:
row = conn.execute(
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
(board_id, lbl),
).fetchone()
if row and row["column_name"] in columns:
return row["column_name"]
# Default: Backlog
return columns[0] if columns else "Backlog"
+41 -9
View File
@@ -1,17 +1,52 @@
"""FlowDeck — Dashboard: liste des projets Gitea.""" """FlowDeck — Dashboard: liste des projets Gitea + sidebar data."""
from __future__ import annotations from __future__ import annotations
import logging import logging
from fastapi import APIRouter, Request, HTTPException, Query from fastapi import APIRouter, Request, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
from app.auth.session import SessionManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"]) router = APIRouter(tags=["dashboard"])
def _sidebar_data(request: Request, repos: list[dict]) -> dict:
"""Build sidebar context from Gitea repos."""
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"
# Build recent pages from repos
recent_pages = []
private_pages = []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
parts = full_name.split("/") if "/" in full_name else [workspace_name, full_name]
page = {
"id": full_name,
"name": repo.get("name", full_name),
"icon": "📁",
"url": f"/board/{full_name}",
"active": False,
"indent": 0,
}
recent_pages.append(page)
private_pages.append({**page})
return {
"workspace_name": workspace_name,
"workspace_initial": workspace_initial,
"current_page": "Dashboard",
"last_edited": "now",
"recent_pages": recent_pages,
"private_pages": private_pages,
"user": user,
}
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def dashboard( async def dashboard(
request: Request, request: Request,
@@ -20,18 +55,16 @@ async def dashboard(
): ):
"""Dashboard: liste tous les projets Gitea (user + orgs).""" """Dashboard: liste tous les projets Gitea (user + orgs)."""
try: try:
# User repos already includes all accessible org repos in Gitea
repos = await gitea.get_user_repos(page=1, limit=50) repos = await gitea.get_user_repos(page=1, limit=50)
# Filter
if search: if search:
q = search.lower() q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()] repos = [r for r in repos if q in r.get("full_name", "").lower()
or q in (r.get("description") or "").lower()]
if not show_archived: if not show_archived:
repos = [r for r in repos if not r.get("archived", False)] repos = [r for r in repos if not r.get("archived", False)]
# Sort by updated_at desc
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True) repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception as e: except Exception as e:
@@ -39,15 +72,14 @@ async def dashboard(
repos = [] repos = []
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates")) env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) sidebar = _sidebar_data(request, repos)
template = env.get_template("dashboard.html") template = env.get_template("dashboard.html")
return template.render( return template.render(
request=request, request=request,
repos=repos, repos=repos,
search=search, search=search,
show_archived=show_archived, show_archived=show_archived,
user=user, **sidebar,
) )
+310 -38
View File
@@ -10,32 +10,251 @@
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js" defer></script>
</head> </head>
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'> <body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
<header class="app-header"> <div class="app-layout" x-data="appState()">
<div class="header-left">
<a href="/" class="logo"> <!-- ═══════════ SIDEBAR ═══════════ -->
<span class="logo-icon">▦</span> <aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar">
<span class="logo-text">FlowDeck</span>
<!-- Workspace header -->
<div class="sidebar-workspace">
<div class="workspace-info">
<div class="workspace-avatar">{{ workspaceInitial }}</div>
<span class="workspace-name">{{ workspaceName }}</span>
</div>
<div class="sidebar-actions">
<button class="sidebar-action-btn" @click="sidebarCollapsed = !sidebarCollapsed" title="Toggle sidebar">
«
</button>
</div>
</div>
<!-- Quick actions -->
<div class="sidebar-nav">
<a href="/" class="sidebar-nav-item" :class="{ active: currentView === 'home' }">
<span class="nav-icon">🏠</span> Home
</a> </a>
</div> </div>
<div class="header-right"> <div class="sidebar-nav" style="gap: 2px; padding-right: 6px;">
{% if user %} <button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">💬</button>
<span id="user-info"> <button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🎤</button>
{% if user.avatar_url %}<img src="{{ user.avatar_url }}" width="20" height="20" class="avatar">{% endif %} <button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">📥</button>
{{ user.login }} <button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🔍</button>
<a href="/auth/logout" class="btn btn-sm">Déconnexion</a>
</span>
{% else %}
<span id="user-info">
<a href="/auth/login" class="btn btn-sm">🔑 Login</a>
</span>
{% endif %}
<button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button>
</div> </div>
</header>
<main class="main-content">
{% block content %}{% endblock %}
</main>
<!-- Recents -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Recents
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Open in Library">📚</button>
<button class="section-action-btn" title="More">⋯</button>
</div>
</div>
<ul class="sidebar-items">
{% for page in recent_pages %}
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
@click="navigateTo('{{ page.url }}')"
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')">
<span class="page-icon">{{ page.icon }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="openInLibrary('{{ page.id }}')">📚</button>
<button class="item-action-btn" @click.stop="showMore('{{ page.id }}')">⋯</button>
</span>
</li>
{% endfor %}
{% if not recent_pages %}
<li class="sidebar-item">
<span class="page-icon">📄</span>
<span class="page-name text-dim">No recent pages</span>
</li>
{% endif %}
</ul>
</div>
<!-- Favorites: Private -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Private
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" @click="addPage('private')" title="Add page">+</button>
</div>
</div>
<ul class="sidebar-items">
{% for page in private_pages %}
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
@click="navigateTo('{{ page.url }}')"
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')">
<span class="page-icon">{{ page.icon }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="showMore('{{ page.id }}')">⋯</button>
<button class="item-action-btn" @click.stop="addSubPage('{{ page.id }}')">+</button>
</span>
</li>
{% endfor %}
</ul>
</div>
<!-- Notion apps -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Notion apps
</div>
</div>
<ul class="sidebar-items">
<li class="sidebar-item"><span class="page-icon">📅</span><span class="page-name">Notion Calendar</span></li>
<li class="sidebar-item"><span class="page-icon">🖥️</span><span class="page-name">Notion Desktop</span></li>
</ul>
</div>
<!-- Library -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Library
</div>
</div>
<ul class="sidebar-items">
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
<li class="sidebar-item"><span class="page-icon">🛒</span><span class="page-name">Marketplace</span></li>
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
<li class="sidebar-item"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
</ul>
</div>
<!-- Footer -->
<div class="sidebar-footer">
<div class="sidebar-invite" x-show="showInvite" x-transition>
<div class="sidebar-invite-title">👥 Invite members</div>
<div class="sidebar-invite-desc">Collaborate with your team.</div>
<button class="sidebar-invite-close" @click="showInvite = false">✕</button>
</div>
<button class="sidebar-new-chat">
<span>💬 New chat</span>
<span class="shortcut">Ctrl+O</span>
</button>
<div style="display: flex; gap: 4px; margin-top: 4px;">
{% if user %}
<a href="/auth/logout" class="sidebar-new-chat" style="flex:1; justify-content:center;">🚪</a>
{% else %}
<a href="/auth/login" class="sidebar-new-chat" style="flex:1; justify-content:center;">🔑</a>
{% endif %}
<button class="sidebar-new-chat" style="width:36px; justify-content:center;" @click="toggleTheme()">🌓</button>
</div>
</div>
</aside>
<!-- ═══════════ MAIN ═══════════ -->
<div class="main-wrapper">
<!-- Topbar -->
<header class="topbar">
<div class="topbar-left">
<div class="topbar-breadcrumb">
<span>{{ workspaceName }}</span>
<span class="text-dim">/</span>
<span class="text-dim">{{ currentPage }}</span>
</div>
</div>
<div class="topbar-center">
<div class="topbar-page-title">
<span class="page-icon">{{ pageIcon }}</span>
<span>{{ pageTitle }}</span>
</div>
<div class="topbar-page-status">
<span>🔒</span> Private
<span style="cursor:pointer;">▾</span>
</div>
</div>
<div class="topbar-right">
<span class="text-xs text-dim">Edited {{ lastEdited }}</span>
<button class="topbar-btn">🔔</button>
<button class="topbar-btn">🕒</button>
<button class="topbar-btn">🔍</button>
<button class="topbar-btn share-btn">🔗 Share ▾</button>
<button class="topbar-btn">⭐</button>
<button class="topbar-btn">⋯</button>
</div>
</header>
<!-- Content -->
<div class="content-area" id="main-content">
{% block content %}{% endblock %}
</div>
</div>
<!-- ═══════════ CONTEXT MENU ═══════════ -->
<div class="context-menu" id="context-menu"
:class="{ visible: contextMenu.visible }"
:style="'left:' + contextMenu.x + 'px; top:' + contextMenu.y + 'px'"
@click.away="contextMenu.visible = false">
<div class="context-menu-section">
<div class="context-menu-label">Database</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('favorite')">
<span class="menu-icon">⭐</span> Remove from Favorites
</div>
<div class="context-menu-item" @click="contextAction('recent')">
<span class="menu-icon">👁️</span> Remove from Recents
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('copyLink')">
<span class="menu-icon">🔗</span> Copy link
</div>
<div class="context-menu-item" @click="contextAction('duplicate')">
<span class="menu-icon">📋</span> Duplicate
<span class="menu-shortcut">▶</span>
</div>
<div class="context-menu-item" @click="contextAction('rename')">
<span class="menu-icon">✏️</span> Rename
<span class="menu-shortcut">Ctrl+Shift+R</span>
</div>
<div class="context-menu-item" @click="contextAction('moveTo')">
<span class="menu-icon">↗️</span> Move to
<span class="menu-shortcut">Ctrl+Shift+P</span>
</div>
<div class="context-menu-item" @click="contextAction('delete')">
<span class="menu-icon">🗑️</span> Move to Trash
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('openTab')">
<span class="menu-icon">🔲</span> Open in new tab
<span class="menu-shortcut">Ctrl+Shift+O</span>
</div>
<div class="context-menu-item" @click="contextAction('openPeek')">
<span class="menu-icon">📑</span> Open in side peek
<span class="menu-shortcut">Alt+Click</span>
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item text-dim" style="font-size:11px; cursor:default;">
Last edited by {{ workspaceName }}
</div>
<div class="context-menu-item text-dim" style="font-size:11px; cursor:default;">
{{ lastEdited }}
</div>
<div class="context-menu-item" @click="contextAction('learnDb')">
<span class="menu-icon">❓</span> Learn about databases
</div>
</div>
</div>
<!-- ═══════════ TOOLTIP ═══════════ -->
<div class="tooltip" id="tooltip" x-ref="tooltip"></div>
<!-- ═══════════ CARD MODAL ═══════════ -->
<div id="card-modal" class="modal-overlay" style="display:none"> <div id="card-modal" class="modal-overlay" style="display:none">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
@@ -46,21 +265,74 @@
</div> </div>
</div> </div>
<script> </div>
// Inject CSRF token into HTMX headers before HTMX processes anything
(function() { <script>
const getCsrf = () => { // Inject CSRF token into HTMX headers
const m = document.cookie.match(/csrf_token=([^;]+)/); (function() {
return m ? m[1] : ''; const getCsrf = () => {
}; const m = document.cookie.match(/csrf_token=([^;]+)/);
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()})); return m ? m[1] : '';
// Also update on every HTMX request };
document.addEventListener('htmx:configRequest', function(evt) { document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
evt.detail.headers['X-CSRF-Token'] = getCsrf(); document.addEventListener('htmx:configRequest', function(evt) {
}); evt.detail.headers['X-CSRF-Token'] = getCsrf();
})(); });
</script> })();
<script src="/static/js/app.js" defer></script>
{% block scripts %}{% endblock %} function appState() {
return {
sidebarCollapsed: false,
showInvite: true,
workspaceName: '{{ workspace_name }}',
workspaceInitial: '{{ workspace_initial }}',
currentPage: '{{ current_page }}',
pageIcon: '{% block page_icon %}📁{% endblock %}',
pageTitle: '{% block page_title %}FlowDeck{% endblock %}',
lastEdited: '{{ last_edited }}',
currentView: 'home',
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
contextActions: {},
navigateTo(url) {
window.location.href = url;
},
showContextMenu(event, pageId, pageName) {
this.contextMenu = {
visible: true,
x: event.clientX,
y: event.clientY,
pageId: pageId,
pageName: pageName
};
},
contextAction(action) {
const { pageId, pageName } = this.contextMenu;
console.log('Context action:', action, pageId, pageName);
// TODO: implement each action
if (action === 'duplicate') {
if (this.contextActions.duplicate) this.contextActions.duplicate(pageId);
}
this.contextMenu.visible = false;
},
openInLibrary(id) { console.log('Open in library:', id); },
showMore(id) { console.log('Show more:', id); },
addPage(section) { console.log('Add page to:', section); },
addSubPage(id) { console.log('Add sub-page to:', id); },
toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
localStorage.setItem('theme', current === 'dark' ? 'light' : 'dark');
}
};
}
</script>
<script src="/static/js/app.js" defer></script>
{% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+194 -529
View File
@@ -1,564 +1,229 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}📁{% endblock %}
{% block page_title %}Kanban board new{% endblock %}
{% block content %} {% block content %}
<div class="board" <!-- Cover image -->
x-data="{ <div class="page-cover">
owner: '{{ owner }}', <div class="page-cover-actions">
repo: '{{ repo }}', <button class="cover-btn">Change</button>
milestone: '{{ current_milestone }}', <button class="cover-btn">Reposition</button>
label: '{{ current_label }}', <button class="cover-btn">↓</button>
assignee: '{{ current_assignee }}'
}">
<!-- Header -->
<div class="board-header">
<div class="board-breadcrumb">
<a href="/" class="back-link">← Projets</a>
<span class="separator">/</span>
<span class="repo-owner">{{ owner }}</span>
<span class="separator">/</span>
<strong>{{ repo }}</strong>
</div>
<div class="board-actions">
<button class="btn btn-sm" onclick="openBoardConfig('{{ owner }}', '{{ repo }}')" title="Gérer les colonnes">⚙ Colonnes</button>
<button class="btn btn-sm" onclick="registerWebhook('{{ owner }}', '{{ repo }}')" title="Enregistrer webhook">🔔 Webhook</button>
<a href="/notes/{{ owner }}/{{ repo }}" class="btn btn-sm">📝 Notes</a>
<button class="btn btn-sm btn-primary" onclick="openCreateModal('{{ owner }}','{{ repo }}','Backlog')">+ Issue</button>
</div>
</div> </div>
</div>
<!-- Filters --> <!-- Page title -->
<div class="board-filters"> <div class="page-title-area">
<select class="filter-select" name="milestone_filter" <div class="page-title">
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1" <span class="page-icon-lg">📁</span>
hx-target="#board-columns" <h1>Kanban board new</h1>
hx-trigger="change"
hx-include="[name='label_filter'],[name='assignee_filter']"
hx-swap="outerHTML">
<option value="">Tous les milestones</option>
{% for m in milestones %}
<option value="{{ m.title }}" {% if m.title == current_milestone %}selected{% endif %}>{{ m.title }}</option>
{% endfor %}
</select>
<select class="filter-select" name="label_filter"
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
hx-target="#board-columns"
hx-trigger="change"
hx-include="[name='milestone_filter'],[name='assignee_filter']"
hx-swap="outerHTML">
<option value="">Tous les labels</option>
{% for lbl in labels %}
<option value="{{ lbl.name }}" {% if lbl.name == current_label %}selected{% endif %}>🏷 {{ lbl.name }}</option>
{% endfor %}
</select>
<input type="text" name="assignee_filter" placeholder="Assigné à..."
class="filter-input"
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
hx-target="#board-columns"
hx-trigger="keyup changed delay:500ms"
hx-include="[name='milestone_filter'],[name='label_filter']"
hx-swap="outerHTML"
value="{{ current_assignee }}">
<button class="btn btn-sm" onclick="window.location.reload()">🔄 Rafraîchir</button>
</div> </div>
</div>
<!-- Columns (fragment) --> <!-- Description -->
{% include "board_fragment.html" %} <div class="page-description" x-data="{ hideDesc: false }">
<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;">
<span x-text="hideDesc ? 'Show description ▾' : 'Hide description ▴'"></span>
</button>
</div>
<!-- PRs section --> <!-- View tabs -->
{% if prs %} <div class="view-tabs">
<div class="prs-section"> <button class="view-tab active" @click="switchView('kanban')">
<h3>🔄 Pull Requests ({{ prs|length }})</h3> <span class="tab-icon">📊</span> Kanban board
<div class="prs-list"> </button>
{% for pr in prs %} <button class="view-tab" @click="switchView('detailed')">
<div class="pr-card"> <span class="tab-icon">📋</span> Detailed board
<a href="{{ pr.html_url }}" target="_blank" class="pr-title">#{{ pr.number }} {{ pr.title[:80] }}</a> </button>
<div class="pr-meta"> <button class="view-tab" @click="switchView('table')">
{% if pr.user %}<span>{{ pr.user.login }}</span>{% endif %} <span class="tab-icon">☰</span> Table view
<span>{{ pr.state }}</span> </button>
</div> <button class="view-tab" @click="switchView('status')">
<span class="tab-icon">📈</span> Status overview
</button>
<button class="view-tab" @click="switchView('teamload')">
<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> </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>
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
<button class="filter-reset-btn" @click="resetFilters()" x-show="activeFilters.length > 0">Reset</button>
</div>
<div class="view-toolbar-spacer"></div>
<button class="toolbar-btn" title="Sort">⇅</button>
<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>
</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 %} {% endfor %}
<button class="kanban-new-card" @click="newCard('{{ group.id }}', 'todo')">+ New page</button>
</div> </div>
{% endfor %}
<button class="kanban-new-group" @click="newGroup()" style="margin-top: 16px;">+ New group</button>
</div> </div>
{% endif %}
</div>
<!-- Board Config Modal (column management) --> <!-- In Progress Column -->
<div id="board-config-modal" class="modal-overlay" style="display:none"> <div class="kanban-column">
<div class="modal-container"> <div class="kanban-column-header">
<div class="modal-header"> <span class="col-dot" style="background: var(--blue);"></span>
<h3>⚙ Configuration des colonnes</h3> In progress <span class="col-count">3</span>
<button onclick="closeBoardConfig()" class="modal-close">&times;</button>
</div> </div>
<div class="modal-body" id="board-config-content"> {% for group in groups %}
<div class="loading">Chargement...</div> <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>
<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> </div>
</div>
<!-- Create Issue Modal (reuses card-modal) --> <!-- Complete Column -->
<div id="create-modal" class="modal-overlay" style="display:none"> <div class="kanban-column">
<div class="modal-container"> <div class="kanban-column-header">
<div class="modal-header"> <span class="col-dot" style="background: var(--green);"></span>
<h3 id="create-modal-title">Nouvelle issue</h3> Complete <span class="col-count">1</span>
<button onclick="closeCreateModal()" class="modal-close">&times;</button>
</div> </div>
<div id="create-modal-content" class="modal-body"></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> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
// ── Labels data (passed from server) ── function switchView(view) {
const ALL_LABELS = {{ labels | tojson }}; const tabs = document.querySelectorAll('.view-tab');
const ALL_MILESTONES = {{ milestones | tojson }}; tabs.forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
function getCSRFToken() { // TODO: Load view content via HTMX
const m = document.cookie.match(/csrf_token=([^;]+)/); console.log('Switch to view:', view);
return m ? m[1] : '';
} }
// ── Create Modal ── function kanbanBoard() {
function openCreateModal(owner, repo, defaultColumn) { return {
document.getElementById('card-modal').style.display = 'none'; collapsedGroups: [],
const modal = document.getElementById('create-modal'); toggleGroup(id) {
const content = document.getElementById('create-modal-content'); const idx = this.collapsedGroups.indexOf(id);
document.getElementById('create-modal-title').textContent = `Nouvelle issue — ${owner}/${repo}`; if (idx >= 0) this.collapsedGroups.splice(idx, 1);
else this.collapsedGroups.push(id);
let h = '<form onsubmit="saveNewIssue(event,\''+owner+'\',\''+repo+'\',\''+defaultColumn+'\')">'; },
h += '<label class="form-label">Titre *</label>'; newCard(groupId, status) {
h += '<input type="text" id="create-title" class="form-input" placeholder="Titre de l\'issue..." required>'; console.log('New card in', groupId, status);
h += '<label class="form-label">Description</label>'; // TODO: Create issue in Gitea
h += '<textarea id="create-body" rows="4" class="form-input" placeholder="Description (markdown)..."></textarea>'; },
newGroup() {
// Multi-label checkboxes console.log('New group');
h += '<label class="form-label">Labels</label>'; // TODO: Create new group
h += '<div class="label-checkboxes">'; }
if (ALL_LABELS.length === 0) { };
h += '<span style="color:var(--text-dim);font-size:12px">Aucun label dans ce repo. <a href="'+window._giteaUrl+'/'+owner+'/'+repo+'/labels" target="_blank">En créer dans Gitea</a></span>';
} else {
ALL_LABELS.forEach(l => {
h += '<label class="label-cb"><input type="checkbox" name="labels" value="'+l.id+'"> <span class="card-label" style="background:#'+(l.color||'666')+'33;color:#'+(l.color||'ccc')+'">'+escHtml(l.name)+'</span></label>';
});
}
h += '</div>';
// Milestone
h += '<label class="form-label">Milestone</label>';
h += '<select id="create-milestone" class="form-select">';
h += '<option value="">Aucun</option>';
ALL_MILESTONES.forEach(m => { h += '<option value="'+m.id+'">'+escHtml(m.title)+'</option>'; });
h += '</select>';
// Assignee
h += '<label class="form-label">Assigné à</label>';
h += '<input type="text" id="create-assignee" class="form-input" placeholder="Login Gitea...">';
h += '<div class="detail-actions" style="margin-top:12px">';
h += '<button type="submit" class="btn btn-primary">💾 Créer</button>';
h += '<button type="button" class="btn" onclick="closeCreateModal()">Annuler</button>';
h += '</div></form>';
content.innerHTML = h;
modal.style.display = 'flex';
} }
function closeCreateModal() { function filterSystem() {
document.getElementById('create-modal').style.display = 'none'; return {
activeFilters: [],
statusFilter: 'All',
openFilterPanel() {
console.log('Open filter panel');
},
addFilter() {
console.log('Add filter');
},
removeFilter(i) {
this.activeFilters.splice(i, 1);
},
resetFilters() {
this.activeFilters = [];
}
};
} }
async function saveNewIssue(evt, owner, repo, defaultColumn) { // Initialize SortableJS
evt.preventDefault(); document.addEventListener('alpine:initialized', () => {
const title = document.getElementById('create-title').value.trim(); document.querySelectorAll('.kanban-cards').forEach(el => {
const body = document.getElementById('create-body').value.trim(); new Sortable(el, {
const milestone = document.getElementById('create-milestone').value; group: 'kanban',
const assignee = document.getElementById('create-assignee').value.trim(); animation: 200,
if (!title) return; ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
// Gather checked labels onEnd: function(evt) {
const checkedLabels = []; const cardId = evt.item.dataset.cardId;
document.querySelectorAll('#create-modal-content input[name="labels"]:checked').forEach(cb => { const fromCol = evt.from.dataset.status;
checkedLabels.push(cb.value); const toCol = evt.to.dataset.status;
}); const group = evt.to.dataset.group;
console.log('Moved card', cardId, 'from', fromCol, 'to', toCol, 'group', group);
const params = new URLSearchParams({title, body, milestone, assignee}); // TODO: POST /api/move
if (checkedLabels.length) params.set('labels', checkedLabels.join(','));
const resp = await fetch(`/api/issues/${owner}/${repo}?${params}`, {
method: 'POST', headers: {'X-CSRF-Token': getCSRFToken()}
});
if (resp.ok) {
closeCreateModal();
window.location.reload();
} else {
alert('Erreur lors de la création');
}
}
// ── Column double-click → create with column preselected ──
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.board-column').forEach(col => {
col.addEventListener('dblclick', function(evt) {
// Only if NOT clicking on a card
if (evt.target.closest('.card')) return;
const owner = this.querySelector('.column-cards')?.dataset.owner;
const repo = this.querySelector('.column-cards')?.dataset.repo;
const column = this.dataset.column || 'Backlog';
if (owner && repo) openCreateModal(owner, repo, column);
});
});
});
document.addEventListener('htmx:afterSettle', () => {
document.querySelectorAll('.board-column').forEach(col => {
if (col._hasDblClick) return;
col._hasDblClick = true;
col.addEventListener('dblclick', function(evt) {
if (evt.target.closest('.card')) return;
const owner = this.querySelector('.column-cards')?.dataset.owner;
const repo = this.querySelector('.column-cards')?.dataset.repo;
const column = this.dataset.column || 'Backlog';
if (owner && repo) openCreateModal(owner, repo, column);
});
});
});
// ── SortableJS ──
function initSortable() {
document.querySelectorAll('.sortable').forEach(el => {
if (el._sortable) el._sortable.destroy();
el._sortable = new Sortable(el, {
group: 'kanban', animation: 150, ghostClass: 'card-ghost',
onEnd: async function(evt) {
const card = evt.item, issueId = card.dataset.issueId;
const newCol = evt.to.dataset.column, owner = evt.to.dataset.owner, repo = evt.to.dataset.repo;
try {
const resp = await fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newCol)}`, {
method: 'POST', headers: {'X-CSRF-Token': getCSRFToken()}
});
if (!resp.ok) throw new Error('Move failed');
const data = await resp.json();
// Update card's data-column
card.dataset.column = newCol;
// Update labels on the moved card
const labelsDiv = card.querySelector('.card-labels');
if (labelsDiv && data.labels) {
labelsDiv.innerHTML = data.labels.map(l =>
`<span class="card-label" style="background:#${l.color||'666'}33;color:#${l.color||'ccc'}">${escHtml(l.name)}</span>`
).join('');
}
// Flash feedback
card.style.transition = 'box-shadow 0.3s';
card.style.boxShadow = '0 0 0 2px var(--green)';
setTimeout(() => { card.style.boxShadow = ''; }, 800);
// Update column counts
document.querySelectorAll('.board-column').forEach(col => {
const c = col.querySelectorAll('.card').length;
const b = col.querySelector('.column-count');
if (b) b.textContent = c;
});
} catch(e) { console.error('Move failed:', e); window.location.reload(); }
} }
}); });
}); });
} });
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSettle', initSortable);
// ── Card Detail ──
function openCardDetail(owner, repo, issueId) {
document.getElementById('create-modal').style.display = 'none';
const modal = document.getElementById('card-modal');
const content = document.getElementById('card-modal-content');
content.innerHTML = '<div class="loading">Chargement...</div>';
document.getElementById('card-modal-title').textContent = `#${issueId} — ${owner}/${repo}`;
modal.style.display = 'flex';
fetch(`/api/issues/${owner}/${repo}/${issueId}`)
.then(r => r.json()).then(data => { content.innerHTML = renderCardDetail(owner, repo, data); })
.catch(e => { content.innerHTML = '<p class="error">Erreur: '+e+'</p>'; });
}
function closeModal() { document.getElementById('card-modal').style.display = 'none'; }
function renderCardDetail(owner, repo, data) {
const issue = data.issue || {}, comments = data.comments || [], checklists = data.checklists || [], card = data.card || {};
window._issueData = {owner, repo, issue, comments, checklists, card};
const labelsHtml = (issue.labels || []).map(l =>
`<span class="card-label" style="background:#${l.color||'666'}33;color:#${l.color||'ccc'}">${escHtml(l.name)}</span>`
).join('');
const prio = card.priority || 'Medium';
const prioOpts = ['Low','Medium','High','Urgent'].map(p =>
`<option value="${p}" ${prio===p?'selected':''}>${p}</option>`
).join('');
return `
<div class="detail-section"><span class="detail-state badge ${issue.state}">${issue.state}</span> <strong>${escHtml(issue.title||'')}</strong></div>
<div class="detail-meta">
${labelsHtml}
${issue.milestone ? '<span>📅 '+escHtml(issue.milestone.title)+'</span>' : ''}
${issue.assignee ? '<span>👤 '+escHtml(issue.assignee.login)+'</span>' : ''}
</div>
<div class="detail-editable">
<label>Priorité: <select onchange="updateCardMeta('${owner}','${repo}',${issue.number},this.value,'')" class="form-select-sm">${prioOpts}</select></label>
<label>Échéance: <input type="date" value="${card.due_date||''}" onchange="updateCardMeta('${owner}','${repo}',${issue.number},'',this.value)" class="form-input-sm"></label>
</div>
<div class="detail-section"><h4>Description</h4><div class="markdown-body">${escHtml(issue.body||'')}</div></div>
${renderChecklists(owner, repo, issue.number, checklists)}
<div class="detail-section"><h4>Commentaires (${comments.length})</h4>
${comments.map(c => `
<div class="comment">
<img src="${c.user?.avatar_url||''}" width="24" height="24" class="avatar">
<strong>${escHtml(c.user?.login||'')}</strong>
<span class="comment-date">${(c.created_at||'').substr(0,10)}</span>
<div class="comment-body">${escHtml(c.body||'')}</div>
</div>`).join('')}
</div>
<div class="detail-actions">
<a href="${issue.html_url||''}" target="_blank" class="btn btn-sm">🔗 Ouvrir dans Gitea</a>
<button onclick="showEditForm('${owner}','${repo}',${issue.number})" class="btn btn-sm">✏️ Éditer</button>
<button onclick="closeModal()" class="btn btn-sm">Fermer</button>
</div>`;
}
function renderChecklists(owner, repo, issueId, checklists) {
let h = '<div class="detail-section"><h4>Checklists</h4>';
checklists.forEach(cl => {
h += '<div class="checklist"><strong>'+escHtml(cl.title)+'</strong><ul class="checklist-items">';
(cl.items||[]).forEach(item => {
h += '<li><input type="checkbox" '+(item.checked?'checked':'')+' onchange="toggleCheckItem('+item.id+',this.checked)" class="checklist-check">';
h += '<span class="'+(item.checked?'checked':'')+'">'+escHtml(item.content)+'</span>';
h += '<button onclick="deleteCheckItem('+item.id+')" class="btn-del">×</button></li>';
});
h += '</ul><div class="add-item"><input type="text" id="new-item-'+cl.id+'" placeholder="Nouvelle tâche..." class="form-input-sm" onkeydown="if(event.key===\'Enter\')addCheckItem('+cl.id+',\''+owner+'\',\''+repo+'\','+issueId+')">';
h += '<button onclick="addCheckItem('+cl.id+',\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">+</button></div></div>';
});
if (!checklists.length) h += '<button onclick="createChecklist(\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">+ Nouvelle checklist</button>';
h += '</div>';
return h;
}
// ── Edit Form ──
function showEditForm(owner, repo, issueId) {
const content = document.getElementById('card-modal-content');
const data = window._issueData || {}, issue = data.issue || {};
let h = '<form onsubmit="saveIssueEdit(event,\''+owner+'\',\''+repo+'\','+issueId+')">';
h += '<label class="form-label">Titre</label>';
h += '<input type="text" id="edit-title" value="'+escHtml(issue.title||'')+'" class="form-input" required>';
h += '<label class="form-label">Description</label>';
h += '<textarea id="edit-body" rows="5" class="form-input">'+escHtml(issue.body||'')+'</textarea>';
h += '<label class="form-label">Labels</label>';
h += '<div class="label-checkboxes">';
const issueLabelIds = (issue.labels||[]).map(l => l.id);
if (!ALL_LABELS.length) {
h += '<span style="color:var(--text-dim);font-size:12px">Aucun label</span>';
} else {
ALL_LABELS.forEach(l => {
h += '<label class="label-cb"><input type="checkbox" name="edit-labels" value="'+l.id+'" '+(issueLabelIds.includes(l.id)?'checked':'')+'> <span class="card-label" style="background:#'+(l.color||'666')+'33;color:#'+(l.color||'ccc')+'">'+escHtml(l.name)+'</span></label>';
});
}
h += '</div>';
h += '<label class="form-label">Assigné à</label>';
h += '<input type="text" id="edit-assignee" value="'+(issue.assignee?issue.assignee.login:'')+'" placeholder="Login Gitea..." class="form-input">';
h += '<div class="detail-actions" style="margin-top:8px">';
h += '<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>';
h += '<button type="button" onclick="openCardDetail(\''+owner+'\',\''+repo+'\','+issueId+')" class="btn btn-sm">Annuler</button>';
h += '</div></form>';
content.innerHTML = h;
}
async function saveIssueEdit(evt, owner, repo, issueId) {
evt.preventDefault();
const title = document.getElementById('edit-title').value.trim();
const body = document.getElementById('edit-body').value.trim();
const assignee = document.getElementById('edit-assignee').value.trim();
if (!title) return;
const checkedLabels = [];
document.querySelectorAll('#card-modal-content input[name="edit-labels"]:checked').forEach(cb => checkedLabels.push(cb.value));
const params = new URLSearchParams({title, body, assignee});
if (checkedLabels.length) params.set('labels', checkedLabels.join(','));
const resp = await fetch(`/api/issues/${owner}/${repo}/${issueId}?${params}`, {
method: 'PATCH', headers: {'X-CSRF-Token': getCSRFToken()}
});
if (resp.ok) {
closeModal();
// Refresh board columns to reposition card
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
} else {
alert('Erreur lors de la mise à jour');
}
}
// ── Helpers ──
function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
async function updateCardMeta(o,r,i,p,d) { await fetch('/api/card/'+o+'/'+r+'/'+i+'?priority='+p+'&due_date='+d,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}}); }
async function toggleCheckItem(id,checked) { await fetch('/api/checklist-items/'+id+'?checked='+checked,{method:'PATCH',headers:{'X-CSRF-Token':getCSRFToken()}}); }
async function deleteCheckItem(id) {
await fetch('/api/checklist-items/'+id,{method:'DELETE',headers:{'X-CSRF-Token':getCSRFToken()}});
const t = document.getElementById('card-modal-title').textContent;
const parts = t.split(' — '), n = parts[0].replace('#','');
const or = parts[1]?parts[1].split('/'):['',''];
if(or[0]&&or[1]) openCardDetail(or[0],or[1],parseInt(n));
}
async function addCheckItem(cid,o,r,iid) {
const inp = document.getElementById('new-item-'+cid);
if (!inp||!inp.value.trim()) return;
await fetch('/api/checklist-items/'+o+'/'+r+'/'+iid+'/'+cid+'?content='+encodeURIComponent(inp.value),{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
openCardDetail(o,r,iid);
}
async function createChecklist(o,r,iid) { await fetch('/api/checklists/'+o+'/'+r+'/'+iid,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}}); openCardDetail(o,r,iid); }
async function registerWebhook(o,r) {
try {
const resp = await fetch('/api/webhook/register/'+o+'/'+r,{method:'POST',headers:{'X-CSRF-Token':getCSRFToken()}});
const d = await resp.json();
alert(d.status==='already_registered'?'Webhook déjà enregistré':'Webhook enregistré');
} catch(e) { alert('Erreur: '+e); }
}
// ── Column Configuration ──
let _colMappings = {};
function toggleColConfig(evt, owner, repo, col) {
evt.stopPropagation();
const safeId = col.replace(/ /g, '_');
const panel = document.getElementById('col-config-' + safeId);
if (!panel) return;
// Close all others
document.querySelectorAll('.col-config-panel').forEach(p => { if (p !== panel) p.style.display = 'none'; });
const isOpen = panel.style.display === 'block';
panel.style.display = isOpen ? 'none' : 'block';
if (!isOpen) {
// Populate label select
const sel = document.getElementById('col-label-' + safeId);
if (sel && sel.options.length <= 1) {
sel.innerHTML = '<option value="">— Aucun mapping —</option>';
ALL_LABELS.forEach(l => {
sel.innerHTML += '<option value="'+escHtml(l.name)+'">'+escHtml(l.name)+'</option>';
});
}
// Pre-select current mapping
if (_colMappings[col]) sel.value = _colMappings[col];
}
}
async function saveColMapping(owner, repo, col) {
const safeId = col.replace(/ /g, '_');
const sel = document.getElementById('col-label-' + safeId);
if (!sel) return;
const label = sel.value;
if (!label) { alert('Sélectionnez un label'); return; }
const params = new URLSearchParams({owner, repo, column: col, gitea_label: label});
await fetch('/api/col-mapping?' + params, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
_colMappings[col] = label;
document.getElementById('col-config-' + safeId).style.display = 'none';
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
}
async function deleteColMapping(owner, repo, col) {
if (!confirm('Supprimer le mapping pour « ' + col + ' » ?')) return;
const safeId = col.replace(/ /g, '_');
await fetch(`/api/col-mapping?owner=${owner}&repo=${repo}&column=${encodeURIComponent(col)}`, {method:'DELETE', headers:{'X-CSRF-Token': getCSRFToken()}});
delete _colMappings[col];
document.getElementById('col-config-' + safeId).style.display = 'none';
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
}
// ── Board Config (Column Management) ──
async function openBoardConfig(owner, repo) {
document.getElementById('card-modal').style.display = 'none';
document.getElementById('create-modal').style.display = 'none';
const modal = document.getElementById('board-config-modal');
const content = document.getElementById('board-config-content');
modal.style.display = 'flex';
content.innerHTML = '<div class="loading">Chargement...</div>';
const resp = await fetch(`/api/board-config/${owner}/${repo}`);
const cfg = await resp.json();
let h = '<div class="detail-section"><h4>Colonnes actuelles</h4>';
h += '<ul class="config-column-list" id="config-column-list">';
cfg.columns.forEach((c, i) => {
h += '<li class="config-column-row" data-col="'+escHtml(c)+'">';
h += '<input type="text" value="'+escHtml(c)+'" class="form-input config-col-name" style="width:200px;margin:0" data-orig="'+escHtml(c)+'">';
const mapped = (cfg.col_mappings||[]).find(m => m.column_name === c);
const labelName = mapped ? mapped.gitea_label : '';
h += '<span class="col-mapping-badge" style="font-size:11px;color:var(--text-dim);margin:0 8px">' + (labelName ? '🏷 '+escHtml(labelName) : '') + '</span>';
h += '<button class="btn btn-sm" onclick="renameColumn(\''+owner+'\',\''+repo+'\','+i+',\''+escHtml(c)+'\')">Renommer</button>';
h += '<button class="btn btn-sm btn-del" onclick="deleteColumn(\''+owner+'\',\''+repo+'\','+i+',\''+escHtml(c)+'\')">✕</button>';
h += '</li>';
});
h += '</ul></div>';
h += '<div class="detail-section"><h4>Ajouter une colonne</h4>';
h += '<div class="form-row">';
h += '<input type="text" id="new-col-name" class="form-input" placeholder="Nom de la colonne..." style="flex:1">';
h += '<button class="btn btn-primary btn-sm" onclick="addColumn(\''+owner+'\',\''+repo+'\')">+ Ajouter</button>';
h += '</div></div>';
h += '<div class="detail-actions">';
h += '<button onclick="closeBoardConfig()" class="btn btn-sm">Fermer</button>';
h += '</div>';
content.innerHTML = h;
window._boardConfig = cfg;
}
function closeBoardConfig() {
document.getElementById('board-config-modal').style.display = 'none';
}
async function addColumn(owner, repo) {
const inp = document.getElementById('new-col-name');
const name = inp.value.trim();
if (!name) return;
const cfg = window._boardConfig;
const cols = [...cfg.columns, name];
await fetch(`/api/board-config/${owner}/${repo}?columns=${encodeURIComponent(JSON.stringify(cols))}`, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
closeBoardConfig();
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
}
async function renameColumn(owner, repo, idx, oldName) {
const row = document.querySelector(`.config-column-row[data-col="${oldName.replace(/"/g,'&quot;')}"]`);
const inp = row ? row.querySelector('.config-col-name') : null;
const newName = inp ? inp.value.trim() : '';
if (!newName || newName === oldName) return;
const cfg = window._boardConfig;
cfg.columns[idx] = newName;
await fetch(`/api/board-config/${owner}/${repo}?columns=${encodeURIComponent(JSON.stringify(cfg.columns))}`, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
// Update col_mapping if one existed for old name
const oldMapping = (cfg.col_mappings||[]).find(m => m.column_name === oldName);
if (oldMapping) {
await fetch(`/api/col-mapping?owner=${owner}&repo=${repo}&column=${encodeURIComponent(newName)}&gitea_label=${oldMapping.gitea_label}`, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
}
closeBoardConfig();
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
}
async function deleteColumn(owner, repo, idx, colName) {
if (!confirm('Supprimer la colonne « ' + colName + ' » ? Les cartes iront dans la première colonne.')) return;
const cfg = window._boardConfig;
const cols = cfg.columns.filter((_, i) => i !== idx);
await fetch(`/api/board-config/${owner}/${repo}?columns=${encodeURIComponent(JSON.stringify(cols))}`, {method:'POST', headers:{'X-CSRF-Token': getCSRFToken()}});
// Delete col_mapping
await fetch(`/api/col-mapping?owner=${owner}&repo=${repo}&column=${encodeURIComponent(colName)}`, {method:'DELETE', headers:{'X-CSRF-Token': getCSRFToken()}});
closeBoardConfig();
htmx.ajax('GET', `/board/${owner}/${repo}?fragment=1`, {target: '#board-columns', swap: 'outerHTML'});
}
// Load col mappings on init
(async function() {
const owner = '{{ owner }}';
const repo = '{{ repo }}';
try {
const resp = await fetch(`/api/board-config/${owner}/${repo}`);
const cfg = await resp.json();
(cfg.col_mappings||[]).forEach(m => { _colMappings[m.column_name] = m.gitea_label; });
} catch(e) {}
})();
</script> </script>
{% endblock %} {% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{# Card partial for Kanban board — Notion style #}
<div class="kanban-card card-status-{{ card.status }}"
data-card-id="{{ card.id }}"
data-card-status="{{ card.status }}"
draggable="false">
<div class="card-header">
<span class="card-icon">{{ card.icon }}</span>
<span class="card-title">{{ card.title }}</span>
</div>
<div class="card-meta">
{% 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 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>
</div>
+70 -48
View File
@@ -1,58 +1,80 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block page_icon %}🏠{% endblock %}
{% block page_title %}Home{% endblock %}
{% block content %} {% block content %}
<div class="dashboard" x-data="{search: '{{ search }}', showArchived: {{ 'true' if show_archived else 'false' }}}"> <div class="page-title-area" style="padding-top: 24px;">
<div class="dashboard-header"> <div class="page-title">
<h1>Projets</h1> <h1>Projects</h1>
<div class="dashboard-controls">
<input
type="text"
x-model="search"
placeholder="Rechercher un projet..."
class="search-input"
hx-get="/?show_archived=${showArchived}"
hx-trigger="keyup changed delay:300ms"
hx-target=".project-list"
hx-include="[name='search'], [name='show_archived']"
name="search"
autocomplete="off"
>
<label class="toggle-label">
<input type="checkbox" name="show_archived" x-model="showArchived"
hx-get="/"
hx-trigger="change"
hx-target=".project-list"
hx-include="[name='search'], [name='show_archived']">
<span>Archivés</span>
</label>
</div>
</div> </div>
</div>
<div class="stats-bar"> <div class="view-toolbar">
<span>{{ repos|length }} projet(s)</span> <input class="search-input" type="text" name="search" placeholder="Search projects..."
</div> value="{{ search }}"
hx-get="/" hx-trigger="keyup changed delay:300ms" hx-target="#project-list"
hx-swap="outerHTML"
style="background:var(--bg-tertiary); border:1px solid var(--border);
color:var(--text-primary); padding:6px 12px; border-radius:6px;
font-size:14px; width:300px; outline:none;">
<div class="project-list"> <label class="text-sm text-dim" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
{% if repos %} <input type="checkbox" hx-get="/" hx-target="#project-list" hx-swap="outerHTML"
{% for repo in repos %} name="show_archived" value="1"
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}" class="project-card"> {% if show_archived %}checked{% endif %}
<div class="project-card-header"> hx-trigger="change" style="accent-color:var(--blue);">
<span class="project-org">{{ repo.owner.login }}</span> Show archived
<span class="project-name">{{ repo.name }}</span> </label>
{% if repo.archived %}<span class="badge archived">archivé</span>{% endif %}
{% if repo.private %}<span class="badge private">privé</span>{% endif %} <div class="view-toolbar-spacer"></div>
<div class="text-sm text-dim">{{ repos|length }} projects</div>
</div>
<!-- Project list -->
<div id="project-list" style="padding: 0 24px;">
<div style="display:flex; flex-direction:column; gap: 4px;">
{% for repo in repos %}
<a href="/board/{{ repo.full_name }}"
class="project-card-notion"
style="display:flex; align-items:center; gap:12px; padding: 10px 12px;
border-radius: 6px; transition: background 0.15s; cursor: pointer; color: var(--text-primary); text-decoration: none;"
onmouseover="this.style.background='var(--bg-hover)'"
onmouseout="this.style.background='transparent'">
<span style="font-size:18px;">📁</span>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{ repo.name }}
</div> </div>
{% if repo.description %} <div style="font-size:12px; color: var(--text-secondary);">
<p class="project-desc">{{ repo.description[:120] }}{% if repo.description|length > 120 %}...{% endif %}</p> {{ repo.owner.login if repo.owner else '?' }}/{{ repo.name }}
{% if repo.description %} — {{ repo.description[:60] }}{% endif %}
</div>
</div>
<div style="display:flex; align-items:center; gap:8px; flex-shrink:0;">
{% if repo.language %}
<span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span>
{% endif %} {% endif %}
<div class="project-meta"> {% if repo.private %}
{% if repo.language %}<span class="meta-lang">{{ repo.language }}</span>{% endif %} <span style="font-size:12px; color: var(--text-dim);">🔒</span>
<span class="meta-stars">⭐ {{ repo.stars_count or 0 }}</span> {% endif %}
<span class="meta-updated">Mis à jour {{ repo.updated_at[:10] if repo.updated_at else '' }}</span> <span style="font-size:12px; color: var(--text-dim);">→</span>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
{% else %}
<p class="empty">Aucun projet trouvé.</p> {% if not repos %}
<div style="text-align:center; padding:48px; color:var(--text-dim);">
<div style="font-size:48px; margin-bottom:16px;">📭</div>
<h3 style="color:var(--text-secondary);">Aucun projet trouvé</h3>
<p style="margin-top:8px;">
{% if search %}
Aucun résultat pour "{{ search }}".
{% else %}
Créez un repo sur <a href="https://git.dracodev.net" style="color:var(--blue);">Gitea</a>.
{% endif %}
</p>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+1432 -255
View File
File diff suppressed because it is too large Load Diff
+122 -11
View File
@@ -1,12 +1,123 @@
// FlowDeck — Theme toggle // FlowDeck Notion UI — Client-side logic
(function() { // Alpine.js + SortableJS + HTMX
const saved = localStorage.getItem('flowdeck-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
})();
function toggleTheme() { (function() {
const current = document.documentElement.getAttribute('data-theme'); 'use strict';
const next = current === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next); // Theme persistence
localStorage.setItem('flowdeck-theme', next); const savedTheme = localStorage.getItem('theme');
} if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
}
// Close context menu on click outside
document.addEventListener('click', function(e) {
const menu = document.getElementById('context-menu');
if (menu && menu.classList.contains('visible')) {
if (!menu.contains(e.target)) {
menu.classList.remove('visible');
}
}
});
// Close context menu on escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const menu = document.getElementById('context-menu');
if (menu) menu.classList.remove('visible');
}
});
// Tooltip system
document.addEventListener('mouseover', function(e) {
const target = e.target.closest('[data-tooltip]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (!tip) return;
tip.textContent = target.getAttribute('data-tooltip');
tip.style.display = 'block';
const rect = target.getBoundingClientRect();
tip.style.left = rect.left + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
});
document.addEventListener('mouseout', function(e) {
const target = e.target.closest('[data-tooltip]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (tip) tip.style.display = 'none';
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Ctrl+O: New chat (placeholder)
if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
console.log('New chat shortcut');
}
});
// Modal functions
window.openModal = function(title) {
const modal = document.getElementById('card-modal');
const titleEl = document.getElementById('card-modal-title');
if (modal && titleEl) {
titleEl.textContent = title || 'Détail';
modal.style.display = 'flex';
}
};
window.closeModal = function() {
const modal = document.getElementById('card-modal');
if (modal) modal.style.display = 'none';
};
// Close modal on overlay click
document.addEventListener('click', function(e) {
if (e.target.id === 'card-modal') {
closeModal();
}
});
// HTMX event handlers
document.addEventListener('htmx:afterSwap', function(evt) {
// Reinitialize Sortable after HTMX swaps
if (evt.target.id === 'kanban-root' || evt.target.closest('.kanban-container')) {
initSortable();
}
});
// Initialize SortableJS on kanban columns
function initSortable() {
document.querySelectorAll('.kanban-cards').forEach(function(el) {
if (el._sortable) return; // Already initialized
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 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: Send move to API
// htmx.ajax('POST', '/api/move', { ... });
}
});
});
}
// Init on page load
document.addEventListener('DOMContentLoaded', function() {
initSortable();
});
// Re-init when Alpine initializes
document.addEventListener('alpine:initialized', function() {
setTimeout(initSortable, 100);
});
})();