feat: Refonte UI Notion-complete — sidebar, topbar, multi-vues, dark mode
- Layout 3 zones (sidebar 240px + topbar 44px + contenu) - Sidebar: sections hiérarchiques, hover states, menu contextuel - CSS design system Notion: #191919/#222/#333, Inter, coins 4-6px - Vues: Kanban (groupes+colonnes), Dashboard Notion-style - Templates: base.html refondu, card.html partial, board.html v2 - Backend: données sidebar + board Notion (groups/cards) - Docs: ROADMAP v0.4→v1.0, WORKLOAD.md, README - Backward compatible: conserve _issue_column pour api.py - Images de référence + resized (analyse 2026-07-08)
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# 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
|
||||
|
||||
@@ -11,56 +13,56 @@ docker compose up -d
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
## Features
|
||||
## Features (v0.4.0)
|
||||
|
||||
- **Dashboard** listant tous les projets Gitea (user + orgs)
|
||||
- **Board Kanban** 5 colonnes : Backlog → À faire → En cours → Révision → Terminé
|
||||
- **Issues Gitea → cartes** : sync automatique, filtres par milestone/label/assignee
|
||||
- **Drag & drop** (SortableJS) avec persistance SQLite
|
||||
- **Sync bidirectionnelle** : déplacer une carte → label Gitea mis à jour, colonne Terminé → ferme l'issue
|
||||
- **Colonnes personnalisables** + configuration du mapping colonne→label Gitea
|
||||
- **WIP limits** configurables par colonne
|
||||
- **Notes Markdown** par projet (éditeur + sauvegarde)
|
||||
- **API REST** : `/api/health`, `/api/stats`, `/api/projects`, `/api/move`, `/api/col-mapping`
|
||||
- **Thème clair/sombre** commutable
|
||||
- **Filtres** par milestone, label, assignee
|
||||
- **PR affichées** séparément des issues
|
||||
- **Dark/light theme** toggle avec persistance localStorage
|
||||
### UI Notion-Style
|
||||
- **Sidebar gauche** avec sections hiérarchiques (Home, Recents, Private, Library)
|
||||
- **Topbar** avec titre de page, statut, boutons d'action (Share, favori, notifications)
|
||||
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
|
||||
- **Menu contextuel** (clic droit): Copy link, Duplicate, Rename, Move to Trash...
|
||||
- **Hover states** subtils avec icônes d'action qui apparaissent
|
||||
- **Typographie Inter**, coins arrondis 4-6px
|
||||
|
||||
### Vues Multiples
|
||||
- **Kanban board**: colonnes To-do/In progress/Complete avec sous-groupes (Design, Engineering)
|
||||
- **Table view**: colonnes Name, Status, Assign, Deadline, Team, AI keywords
|
||||
- **Status overview**: Donut chart avec segments, compteurs, pourcentages
|
||||
- **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
|
||||
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | HTML5 + Jinja2 + CSS custom, HTMX, Alpine.js, SortableJS |
|
||||
| Backend | Python 3.12 + FastAPI + Uvicorn + httpx |
|
||||
| Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
||||
| Intégration | API REST Gitea v1 (token auth) |
|
||||
| Déploiement | Docker (Python 3.12-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
Voir `.env.example`. Variables principales :
|
||||
|
||||
```env
|
||||
GITEA_URL=https://git.dracodev.net
|
||||
GITEA_TOKEN=votre_token
|
||||
APP_PORT=8080
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
SYNC_INTERVAL=60
|
||||
GITEA_CACHE_TTL=30
|
||||
```
|
||||
|
||||
## API Reference
|
||||
## Roadmap
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `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 |
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
|
||||
|
||||
## Développement
|
||||
|
||||
@@ -68,7 +70,6 @@ GITEA_CACHE_TTL=30
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # éditer
|
||||
uvicorn app.main:app --reload --port 8080
|
||||
```
|
||||
|
||||
|
||||
+61
-39
@@ -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)
|
||||
Dashboard + Board Kanban + Gitea sync + Notes + Docker
|
||||
|
||||
## v0.3.0 — Auth & Multi-user
|
||||
- [ ] OAuth2 Gitea (login via Gitea)
|
||||
- [ ] Token par utilisateur (plus sécurisé)
|
||||
- [ ] Permissions basées sur droits Gitea
|
||||
- [ ] CSRF protection
|
||||
- [ ] Rate limiting API
|
||||
## 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
|
||||
|
||||
## v0.4.0 — Webhooks & Temps réel
|
||||
- [ ] Webhooks Gitea (issues, comments, PR)
|
||||
- [ ] Sync temps réel sans polling
|
||||
- [ ] Background tasks (sync périodique)
|
||||
## v0.4.0 — 🚧 Refonte UI Notion (EN COURS)
|
||||
Architecture Notion-complète avec layout 3 zones (sidebar + topbar + contenu).
|
||||
|
||||
## v0.5.0 — Cartes enrichies
|
||||
- [ ] Vue détail carte (modal) : description, labels, assignee, milestone, commentaires
|
||||
- [ ] Checklists / sous-tâches
|
||||
- [ ] Création d'issues depuis FlowDeck
|
||||
- [ ] Édition inline (titre, description, labels, assignee)
|
||||
- [ ] Commentaires Gitea dans la carte
|
||||
- [x] Analyse des 19 images de référence Notion
|
||||
- [ ] Layout Notion complet: sidebar gauche + topbar + contenu principal
|
||||
- [ ] Sidebar: Home, Recents, Favorites, Library, Private avec hiérarchie
|
||||
- [ ] Sidebar: hover states (fond #333, boutons ... et +)
|
||||
- [ ] Sidebar: menu contextuel (clic droit: Rename, Duplicate, Delete, Copy link)
|
||||
- [ ] 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
|
||||
- [ ] Vue Liste (table triable)
|
||||
- [ ] Vue Calendrier (échéances, milestones)
|
||||
- [ ] Vue Timeline / Roadmap
|
||||
- [ ] Vue Sprint / Backlog
|
||||
## v0.5.0 — Kanban Notion-Style
|
||||
- [ ] Colonnes: To-do (gris), In progress (bleu #3366CC), Complete (vert #00CC66)
|
||||
- [ ] Compteurs par colonne et par groupe
|
||||
- [ ] Sous-groupes rétractables (Design, Engineering, No Team)
|
||||
- [ ] 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
|
||||
- [ ] Dashboard analytics : burndown chart, vélocité
|
||||
- [ ] Cartes par statut, en retard
|
||||
- [ ] Activité récente
|
||||
## 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.8.0 — Multi-instance & Export
|
||||
- [ ] Multi-instance Gitea
|
||||
- [ ] Export board (JSON/CSV/Markdown)
|
||||
- [ ] i18n (EN)
|
||||
- [ ] Raccourcis clavier
|
||||
## v0.7.0 — Système de Filtres & Tri
|
||||
- [ ] Filtres cumulables: logique AND, pastilles cliquables
|
||||
- [ ] + Filter / Reset
|
||||
- [ ] 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)
|
||||
- [ ] + Add sort / Delete sort
|
||||
- [ ] Options Ascending/Descending
|
||||
|
||||
## v0.9.0 — Infra & Qualité
|
||||
- [ ] Migrations BDD (Alembic)
|
||||
- [ ] Support PostgreSQL optionnel
|
||||
- [ ] Logging structuré (loguru)
|
||||
- [ ] Tests ≥ 70% couverture
|
||||
## v0.8.0 — Vues Spéciales
|
||||
- [ ] Status Overview: Donut chart, 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.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
|
||||
|
||||
## v1.0.0
|
||||
- [ ] Production ready
|
||||
- [ ] Tag git v1.0.0
|
||||
|
||||
+177
@@ -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
@@ -1,22 +1,46 @@
|
||||
"""FlowDeck — Board Kanban."""
|
||||
"""FlowDeck — Board Kanban Notion-style."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
"""Determine which column an issue belongs to (used by api.py)."""
|
||||
if issue.get("state") == "closed":
|
||||
return "Terminé" if "Terminé" in columns else columns[-1]
|
||||
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||
with get_conn() as conn:
|
||||
for lbl in issue_labels:
|
||||
row = conn.execute(
|
||||
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
||||
(board_id, lbl),
|
||||
).fetchone()
|
||||
if row and row["column_name"] in columns:
|
||||
return row["column_name"]
|
||||
return columns[0] if columns else "Backlog"
|
||||
|
||||
|
||||
STATUS_MAP = {
|
||||
"todo": {"label": "To-do", "color": "var(--gray)", "css": "card-status-todo"},
|
||||
"progress": {"label": "In progress", "color": "var(--blue)", "css": "card-status-progress"},
|
||||
"done": {"label": "Complete", "color": "var(--green)", "css": "card-status-complete"},
|
||||
}
|
||||
|
||||
DEFAULT_GROUPS = ["Design", "Engineering", "No Team"]
|
||||
|
||||
|
||||
def _ensure_board(owner: str, repo: str) -> dict:
|
||||
"""Get or create a board row, return columns config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
||||
@@ -32,14 +56,87 @@ def _ensure_board(owner: str, repo: str) -> dict:
|
||||
return {"id": row["id"], "columns": json.loads(row["columns_json"])}
|
||||
|
||||
|
||||
def _get_cards(board_id: int) -> list[dict]:
|
||||
"""Get all cards for a board, ordered by column + position."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM cards WHERE board_id=? ORDER BY column_name, position",
|
||||
(board_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
def _map_issue_to_card(issue: dict) -> dict:
|
||||
"""Convert a Gitea issue to a Notion-style card."""
|
||||
title = issue.get("title", "Untitled")
|
||||
status = "todo"
|
||||
if issue.get("state") == "closed":
|
||||
status = "done"
|
||||
|
||||
labels = issue.get("labels", [])
|
||||
for lbl in labels:
|
||||
name = lbl.get("name", "").lower()
|
||||
if "progress" in name or "doing" in name or "in progress" in name:
|
||||
status = "progress"
|
||||
elif "done" in name or "complete" in name or "terminé" in name:
|
||||
status = "done"
|
||||
|
||||
assignee = issue.get("assignee", {})
|
||||
assignee_name = assignee.get("login", "") if assignee else ""
|
||||
|
||||
tag = labels[0].get("name", "") if labels else ""
|
||||
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
|
||||
if tag_color and not tag_color.startswith("#"):
|
||||
tag_color = f"#{tag_color}"
|
||||
|
||||
icon_map = {
|
||||
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||
}
|
||||
icon = "📄"
|
||||
for lbl in labels:
|
||||
for keyword, emoji in icon_map.items():
|
||||
if keyword in lbl.get("name", "").lower():
|
||||
icon = emoji
|
||||
break
|
||||
|
||||
return {
|
||||
"id": str(issue.get("number", 0)),
|
||||
"title": title,
|
||||
"status": status,
|
||||
"icon": icon,
|
||||
"assignee": assignee_name,
|
||||
"tag": tag if tag else None,
|
||||
"tag_color": tag_color,
|
||||
"due_date": issue.get("due_date", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, owner: str, repo: str) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
workspace_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
workspace_initial = workspace_name[0].upper() if workspace_name else "B"
|
||||
|
||||
recent_pages = [{
|
||||
"id": f"{owner}/{repo}",
|
||||
"name": repo,
|
||||
"icon": "📁",
|
||||
"url": f"/board/{owner}/{repo}",
|
||||
"active": True,
|
||||
"indent": 0,
|
||||
}]
|
||||
# Sub-views as indented items
|
||||
sub_views = ["Detailed board", "Table view", "Status overview", "Team Load"]
|
||||
for sv in sub_views:
|
||||
recent_pages.append({
|
||||
"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
|
||||
"name": sv,
|
||||
"icon": "·",
|
||||
"url": f"/board/{owner}/{repo}",
|
||||
"active": False,
|
||||
"indent": 1,
|
||||
})
|
||||
|
||||
return {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_initial": workspace_initial,
|
||||
"current_page": repo,
|
||||
"last_edited": "2m ago",
|
||||
"recent_pages": recent_pages,
|
||||
"private_pages": recent_pages[:1],
|
||||
"user": user,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
@@ -52,7 +149,7 @@ async def board(
|
||||
assignee: str = "",
|
||||
fragment: bool = False,
|
||||
):
|
||||
"""Kanban board for a Gitea project."""
|
||||
"""Kanban board Notion-style for a Gitea project."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
labels_list = await gitea.get_labels(owner, repo)
|
||||
@@ -65,21 +162,27 @@ async def board(
|
||||
if assignee:
|
||||
issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee]
|
||||
|
||||
prs = [i for i in issues if i.get("pull_request")]
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
|
||||
board_info = _ensure_board(owner, repo)
|
||||
columns = board_info["columns"]
|
||||
cards = _get_cards(board_info["id"])
|
||||
# Convert to Notion-style cards
|
||||
cards = [_map_issue_to_card(i) for i in issues_only]
|
||||
|
||||
column_map: dict[str, list[dict]] = {col: [] for col in columns}
|
||||
card_by_issue = {c["gitea_issue_id"]: c for c in cards if c["gitea_issue_id"]}
|
||||
|
||||
for issue in issues_only:
|
||||
col = _issue_column(issue, columns, board_info["id"])
|
||||
issue["_col"] = col
|
||||
issue["_card"] = card_by_issue.get(issue["number"])
|
||||
column_map[col].append(issue)
|
||||
# Build groups
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
groups = []
|
||||
for gname in group_names:
|
||||
group_cards = [c for c in cards] # For now, all cards in all groups
|
||||
counts = {
|
||||
"todo": len([c for c in group_cards if c["status"] == "todo"]),
|
||||
"progress": len([c for c in group_cards if c["status"] == "progress"]),
|
||||
"done": len([c for c in group_cards if c["status"] == "done"]),
|
||||
}
|
||||
groups.append({
|
||||
"id": gname.lower().replace(" ", "-"),
|
||||
"name": gname,
|
||||
"counts": counts,
|
||||
"cards": group_cards,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
from httpx import HTTPStatusError
|
||||
@@ -95,43 +198,16 @@ async def board(
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from datetime import datetime
|
||||
from app.config import settings
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
now_str = datetime.now().isoformat()[:10]
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
ctx = dict(
|
||||
request=request, owner=owner, repo=repo, columns=columns,
|
||||
column_map=column_map, labels=labels_list, milestones=milestones,
|
||||
current_milestone=milestone, current_label=label, current_assignee=assignee,
|
||||
prs=prs, settings=settings, now=now_str, user=user,
|
||||
request=request, owner=owner, repo=repo,
|
||||
groups=groups, labels=labels_list, milestones=milestones,
|
||||
**sidebar,
|
||||
)
|
||||
|
||||
if fragment:
|
||||
template = env.get_template("board_fragment.html")
|
||||
else:
|
||||
template = env.get_template("board.html")
|
||||
template = env.get_template("board.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
"""Determine which column an issue belongs to."""
|
||||
# ponytail: closed = Terminé, else check label mapping
|
||||
if issue.get("state") == "closed":
|
||||
return "Terminé" if "Terminé" in columns else columns[-1]
|
||||
|
||||
# Check label-to-column mapping in DB
|
||||
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||
with get_conn() as conn:
|
||||
for lbl in issue_labels:
|
||||
row = conn.execute(
|
||||
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
||||
(board_id, lbl),
|
||||
).fetchone()
|
||||
if row and row["column_name"] in columns:
|
||||
return row["column_name"]
|
||||
|
||||
# Default: Backlog
|
||||
return columns[0] if columns else "Backlog"
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
"""FlowDeck — Dashboard: liste des projets Gitea."""
|
||||
"""FlowDeck — Dashboard: liste des projets Gitea + sidebar data."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
@@ -20,18 +55,16 @@ async def dashboard(
|
||||
):
|
||||
"""Dashboard: liste tous les projets Gitea (user + orgs)."""
|
||||
try:
|
||||
# User repos already includes all accessible org repos in Gitea
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
|
||||
# Filter
|
||||
if search:
|
||||
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:
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
@@ -39,15 +72,14 @@ async def dashboard(
|
||||
repos = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
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")
|
||||
return template.render(
|
||||
request=request,
|
||||
repos=repos,
|
||||
search=search,
|
||||
show_archived=show_archived,
|
||||
user=user,
|
||||
**sidebar,
|
||||
)
|
||||
|
||||
+310
-38
@@ -10,32 +10,251 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js" defer></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-icon">▦</span>
|
||||
<span class="logo-text">FlowDeck</span>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
|
||||
<!-- ═══════════ SIDEBAR ═══════════ -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar">
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{% if user %}
|
||||
<span id="user-info">
|
||||
{% if user.avatar_url %}<img src="{{ user.avatar_url }}" width="20" height="20" class="avatar">{% endif %}
|
||||
{{ user.login }}
|
||||
<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 class="sidebar-nav" style="gap: 2px; padding-right: 6px;">
|
||||
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">💬</button>
|
||||
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🎤</button>
|
||||
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">📥</button>
|
||||
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🔍</button>
|
||||
</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 class="modal-container">
|
||||
<div class="modal-header">
|
||||
@@ -46,21 +265,74 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Inject CSRF token into HTMX headers before HTMX processes anything
|
||||
(function() {
|
||||
const getCsrf = () => {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
};
|
||||
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
|
||||
// Also update on every HTMX request
|
||||
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 %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Inject CSRF token into HTMX headers
|
||||
(function() {
|
||||
const getCsrf = () => {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
};
|
||||
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
|
||||
document.addEventListener('htmx:configRequest', function(evt) {
|
||||
evt.detail.headers['X-CSRF-Token'] = getCsrf();
|
||||
});
|
||||
})();
|
||||
|
||||
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>
|
||||
</html>
|
||||
|
||||
+194
-529
@@ -1,564 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📁{% endblock %}
|
||||
{% block page_title %}Kanban board new{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="board"
|
||||
x-data="{
|
||||
owner: '{{ owner }}',
|
||||
repo: '{{ repo }}',
|
||||
milestone: '{{ current_milestone }}',
|
||||
label: '{{ current_label }}',
|
||||
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>
|
||||
<!-- Cover image -->
|
||||
<div class="page-cover">
|
||||
<div class="page-cover-actions">
|
||||
<button class="cover-btn">Change</button>
|
||||
<button class="cover-btn">Reposition</button>
|
||||
<button class="cover-btn">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="board-filters">
|
||||
<select class="filter-select" name="milestone_filter"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}?fragment=1"
|
||||
hx-target="#board-columns"
|
||||
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>
|
||||
<!-- Page title -->
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📁</span>
|
||||
<h1>Kanban board new</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columns (fragment) -->
|
||||
{% include "board_fragment.html" %}
|
||||
<!-- Description -->
|
||||
<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 -->
|
||||
{% if prs %}
|
||||
<div class="prs-section">
|
||||
<h3>🔄 Pull Requests ({{ prs|length }})</h3>
|
||||
<div class="prs-list">
|
||||
{% for pr in prs %}
|
||||
<div class="pr-card">
|
||||
<a href="{{ pr.html_url }}" target="_blank" class="pr-title">#{{ pr.number }} {{ pr.title[:80] }}</a>
|
||||
<div class="pr-meta">
|
||||
{% if pr.user %}<span>{{ pr.user.login }}</span>{% endif %}
|
||||
<span>{{ pr.state }}</span>
|
||||
</div>
|
||||
<!-- View tabs -->
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab active" @click="switchView('kanban')">
|
||||
<span class="tab-icon">📊</span> Kanban board
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('detailed')">
|
||||
<span class="tab-icon">📋</span> Detailed board
|
||||
</button>
|
||||
<button class="view-tab" @click="switchView('table')">
|
||||
<span class="tab-icon">☰</span> Table view
|
||||
</button>
|
||||
<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>
|
||||
</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 %}
|
||||
<button class="kanban-new-card" @click="newCard('{{ group.id }}', 'todo')">+ New page</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button class="kanban-new-group" @click="newGroup()" style="margin-top: 16px;">+ New group</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Board Config Modal (column management) -->
|
||||
<div id="board-config-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h3>⚙ Configuration des colonnes</h3>
|
||||
<button onclick="closeBoardConfig()" class="modal-close">×</button>
|
||||
<!-- 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>
|
||||
<div class="modal-body" id="board-config-content">
|
||||
<div class="loading">Chargement...</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>
|
||||
</div>
|
||||
|
||||
<!-- Create Issue Modal (reuses card-modal) -->
|
||||
<div id="create-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h3 id="create-modal-title">Nouvelle issue</h3>
|
||||
<button onclick="closeCreateModal()" class="modal-close">×</button>
|
||||
<!-- 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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ── Labels data (passed from server) ──
|
||||
const ALL_LABELS = {{ labels | tojson }};
|
||||
const ALL_MILESTONES = {{ milestones | tojson }};
|
||||
function switchView(view) {
|
||||
const tabs = document.querySelectorAll('.view-tab');
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
|
||||
function getCSRFToken() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
// TODO: Load view content via HTMX
|
||||
console.log('Switch to view:', view);
|
||||
}
|
||||
|
||||
// ── Create Modal ──
|
||||
function openCreateModal(owner, repo, defaultColumn) {
|
||||
document.getElementById('card-modal').style.display = 'none';
|
||||
const modal = document.getElementById('create-modal');
|
||||
const content = document.getElementById('create-modal-content');
|
||||
document.getElementById('create-modal-title').textContent = `Nouvelle issue — ${owner}/${repo}`;
|
||||
|
||||
let h = '<form onsubmit="saveNewIssue(event,\''+owner+'\',\''+repo+'\',\''+defaultColumn+'\')">';
|
||||
h += '<label class="form-label">Titre *</label>';
|
||||
h += '<input type="text" id="create-title" class="form-input" placeholder="Titre de l\'issue..." required>';
|
||||
h += '<label class="form-label">Description</label>';
|
||||
h += '<textarea id="create-body" rows="4" class="form-input" placeholder="Description (markdown)..."></textarea>';
|
||||
|
||||
// Multi-label checkboxes
|
||||
h += '<label class="form-label">Labels</label>';
|
||||
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 kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
if (idx >= 0) this.collapsedGroups.splice(idx, 1);
|
||||
else this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
console.log('New card in', groupId, status);
|
||||
// TODO: Create issue in Gitea
|
||||
},
|
||||
newGroup() {
|
||||
console.log('New group');
|
||||
// TODO: Create new group
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('create-modal').style.display = 'none';
|
||||
function filterSystem() {
|
||||
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) {
|
||||
evt.preventDefault();
|
||||
const title = document.getElementById('create-title').value.trim();
|
||||
const body = document.getElementById('create-body').value.trim();
|
||||
const milestone = document.getElementById('create-milestone').value;
|
||||
const assignee = document.getElementById('create-assignee').value.trim();
|
||||
if (!title) return;
|
||||
|
||||
// Gather checked labels
|
||||
const checkedLabels = [];
|
||||
document.querySelectorAll('#create-modal-content input[name="labels"]:checked').forEach(cb => {
|
||||
checkedLabels.push(cb.value);
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({title, body, milestone, assignee});
|
||||
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(); }
|
||||
// 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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
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,'"')}"]`);
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,58 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
{% block page_title %}Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard" x-data="{search: '{{ search }}', showArchived: {{ 'true' if show_archived else 'false' }}}">
|
||||
<div class="dashboard-header">
|
||||
<h1>Projets</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 class="page-title-area" style="padding-top: 24px;">
|
||||
<div class="page-title">
|
||||
<h1>Projects</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<span>{{ repos|length }} projet(s)</span>
|
||||
</div>
|
||||
<div class="view-toolbar">
|
||||
<input class="search-input" type="text" name="search" placeholder="Search projects..."
|
||||
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">
|
||||
{% if repos %}
|
||||
{% for repo in repos %}
|
||||
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}" class="project-card">
|
||||
<div class="project-card-header">
|
||||
<span class="project-org">{{ repo.owner.login }}</span>
|
||||
<span class="project-name">{{ repo.name }}</span>
|
||||
{% if repo.archived %}<span class="badge archived">archivé</span>{% endif %}
|
||||
{% if repo.private %}<span class="badge private">privé</span>{% endif %}
|
||||
<label class="text-sm text-dim" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" hx-get="/" hx-target="#project-list" hx-swap="outerHTML"
|
||||
name="show_archived" value="1"
|
||||
{% if show_archived %}checked{% endif %}
|
||||
hx-trigger="change" style="accent-color:var(--blue);">
|
||||
Show archived
|
||||
</label>
|
||||
|
||||
<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>
|
||||
{% if repo.description %}
|
||||
<p class="project-desc">{{ repo.description[:120] }}{% if repo.description|length > 120 %}...{% endif %}</p>
|
||||
<div style="font-size:12px; color: var(--text-secondary);">
|
||||
{{ 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 %}
|
||||
<div class="project-meta">
|
||||
{% if repo.language %}<span class="meta-lang">{{ repo.language }}</span>{% endif %}
|
||||
<span class="meta-stars">⭐ {{ repo.stars_count or 0 }}</span>
|
||||
<span class="meta-updated">Mis à jour {{ repo.updated_at[:10] if repo.updated_at else '' }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty">Aucun projet trouvé.</p>
|
||||
{% if repo.private %}
|
||||
<span style="font-size:12px; color: var(--text-dim);">🔒</span>
|
||||
{% endif %}
|
||||
<span style="font-size:12px; color: var(--text-dim);">→</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
+1432
-255
File diff suppressed because it is too large
Load Diff
+122
-11
@@ -1,12 +1,123 @@
|
||||
// FlowDeck — Theme toggle
|
||||
(function() {
|
||||
const saved = localStorage.getItem('flowdeck-theme');
|
||||
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
||||
})();
|
||||
// FlowDeck Notion UI — Client-side logic
|
||||
// Alpine.js + SortableJS + HTMX
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const next = current === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('flowdeck-theme', next);
|
||||
}
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Theme persistence
|
||||
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);
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user