Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d89db8213 | ||
|
|
45283f0f8a | ||
|
|
dbda3bcf5a | ||
|
|
23e63c7f6e |
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.0 — 2026-10-11
|
||||
|
||||
« Palier 2 » : temps réel et visible dans Gitea — plus une refonte
|
||||
complète de l'interface.
|
||||
|
||||
### Ajouts
|
||||
- **Webhooks Gitea + rafraîchissement en direct** : `POST /webhooks/gitea`
|
||||
(signature HMAC-SHA256 vérifiée quand `KANBANTE_WEBHOOK_SECRET` est
|
||||
défini) invalide le cache de lecture, applique les fermetures /
|
||||
réouvertures faites dans Gitea (idempotent : l'écho de nos propres
|
||||
appels ne double jamais le journal) et pousse un événement SSE sur
|
||||
`GET /api/stream`. Le navigateur se recharge en douceur quand c'est
|
||||
sûr (onglet visible, pas de panneau ouvert, pas de saisie en cours,
|
||||
pas de drag) ; sinon une pastille « Nouvelle activité » propose
|
||||
l'actualisation.
|
||||
- **Synchro par étiquettes** : les colonnes intermédiaires sont
|
||||
reflétées dans Gitea par des étiquettes `kanban:todo`,
|
||||
`kanban:in_progress`, `kanban:review` (créées au besoin, ajout /
|
||||
retrait granulaires — les étiquettes de l'utilisateur ne sont jamais
|
||||
touchées ; backlog et done = aucune étiquette). Désactivable :
|
||||
`KANBANTE_SYNC_LABELS=0`.
|
||||
- **Boards par milestone** : `GET /board/{owner}/{repo}?milestone=<titre|id>`
|
||||
filtre le board, avec bandeau d'échéance et progression
|
||||
(issues fermées du milestone / total). Sélecteur de milestones
|
||||
au-dessus du board.
|
||||
|
||||
### Interface (refonte complète)
|
||||
- Nouveau design system : en-tête vitré sticky avec navigation en
|
||||
pastilles (état actif), halos d'ambiance, cartes projets qui se
|
||||
soulèvent au survol, barres de progression, coins plus doux,
|
||||
anneau de focus visible partout, `prefers-reduced-motion` respecté.
|
||||
- Dashboard : hero avec tuiles de synthèse (projets actifs, issues
|
||||
fermées et déplacements sur 30 jours, temps de cycle moyen — depuis
|
||||
le journal), états vides guidés pour chaque onglet, raccourci `/`
|
||||
pour la recherche (aussi sur le filtre du board).
|
||||
- Board : pastille « live » en cas d'activité externe, colonnes à
|
||||
défilement indépendant, placeholders de colonnes vides, panneau de
|
||||
notes explicité, drawer animé.
|
||||
- Cohérence : même navigation sur les 5 pages, responsive retravaillé.
|
||||
|
||||
### Tests
|
||||
- 13 tests ajoutés (65 au total) : hub SSE et générateur de flux,
|
||||
webhooks (transition, idempotence, signature, JSON invalide),
|
||||
synchro d'étiquettes, boards par milestone, marqueurs de la refonte.
|
||||
|
||||
## 0.3.0 — 2026-10-11
|
||||
|
||||
« Palier 1 » : voir ce qui se passe sur les boards.
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
- 🎯 **Drag & Drop** — glisse les issues entre colonnes ; en cas d'échec, la carte est replacée
|
||||
- 🔗 **Gitea Sync bidirectionnelle** — « Terminé » ferme l'issue dans Gitea, la ressortir la rouvre ; l'état local est réconcilié à chaque chargement
|
||||
- ➕ **Création d'issues** depuis le board, filtres texte + étiquettes, cartes liées à Gitea
|
||||
- ⚡ **Temps réel** — un webhook Gitea invalide le cache et pousse un événement SSE : le board ouvert se met à jour tout seul (en douceur, sans interrompre une saisie ni un drag)
|
||||
- 🏷️ **Visible dans Gitea** — les colonnes intermédiaires sont reflétées par des étiquettes `kanban:*` sur les issues, et un board peut se filtrer **par milestone** (échéance + progression façon sprint)
|
||||
- 🗂️ **Panneau détail** — clic sur une carte : corps markdown, commentaires, et réponse postée dans Gitea sans quitter le board
|
||||
- 📰 **Journal d'événements** — chaque mouvement est horodaté : fil d'activité (`/activity`), métriques de débit et de temps de cycle (`/api/metrics`)
|
||||
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
|
||||
@@ -97,6 +99,8 @@ sudo systemctl enable --now docker
|
||||
| `KANBANTE_SYNC_GITEA` | `1` | `0` = les déplacements ne ferment/rouvrent plus les issues Gitea |
|
||||
| `KANBANTE_WIP_LIMITS` | *(vide)* | Limites souples par colonne, ex. `in_progress:5,review:3` (affichage `n/limite`, rouge en dépassement, jamais bloquant) |
|
||||
| `KANBANTE_AGING_DAYS` | `7` | Jours dans la même colonne avant le badge « ⏳ » sur une carte (rouge au double) |
|
||||
| `KANBANTE_WEBHOOK_SECRET` | *(vide)* | Secret partagé du webhook Gitea ; si défini, la signature HMAC `X-Gitea-Signature` est exigée |
|
||||
| `KANBANTE_SYNC_LABELS` | `1` | `0` = ne plus refléter les colonnes intermédiaires en étiquettes `kanban:*` dans Gitea |
|
||||
|
||||
## Sécurité
|
||||
|
||||
@@ -146,6 +150,23 @@ kanbante/
|
||||
| POST | `/api/issues/{owner}/{repo}/{n}/comments` | Commenter l'issue dans Gitea |
|
||||
| GET | `/api/events` | Derniers événements du journal (`?repo=`, `?limit=`) |
|
||||
| GET | `/api/metrics` | Débit + temps de cycle moyen (`?repo=`, `?window=`) |
|
||||
| POST | `/webhooks/gitea` | Réception des webhooks Gitea (HMAC si secret défini) |
|
||||
| GET | `/api/stream` | Flux SSE des événements Gitea (rafraîchissement live) |
|
||||
| GET | `/board/{o}/{r}?milestone=` | Board filtré par milestone (titre ou id) |
|
||||
|
||||
## Activer le temps réel (webhook Gitea)
|
||||
|
||||
Dans chaque dépôt (ou au niveau d'une organisation) : **Réglages →
|
||||
Webhooks → Ajouter un webhook → Gitea**, avec :
|
||||
|
||||
- **URL cible** : `https://<votre-hôte-kanbante>/webhooks/gitea`
|
||||
- **Type de contenu** : `application/json`
|
||||
- **Secret** : la même valeur que `KANBANTE_WEBHOOK_SECRET`
|
||||
- **Déclencheur** : « Événements d'issue » (et « Commentaire d'issue »
|
||||
si vous voulez aussi le rafraîchissement sur commentaires externes)
|
||||
|
||||
Dès lors, fermer ou rouvrir une issue dans Gitea met à jour les boards
|
||||
Kanbante ouverts, sans rechargement manuel.
|
||||
| POST | `/api/notes` | Sauvegarder des notes |
|
||||
| GET | `/api/stats` | Statistiques JSON |
|
||||
| GET | `/api/projects` | Projets JSON (actifs / inactifs / archivés) |
|
||||
|
||||
+10
-1
@@ -23,6 +23,11 @@ Environment variables:
|
||||
warns, moves are never blocked.
|
||||
KANBANTE_AGING_DAYS Days in the same column before a card is
|
||||
flagged as aging (default 7; "stale" at 2x).
|
||||
KANBANTE_WEBHOOK_SECRET Shared secret for the Gitea webhook
|
||||
(POST /webhooks/gitea). When set, the
|
||||
X-Gitea-Signature HMAC is enforced.
|
||||
KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate
|
||||
columns to Gitea labels named kanban:<col>.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -31,7 +36,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.3.0"
|
||||
APP_VERSION = "0.4.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -81,6 +86,8 @@ class Settings:
|
||||
sync_gitea: bool = True
|
||||
wip_limits: dict | None = None
|
||||
aging_days: int = 7
|
||||
webhook_secret: str = ""
|
||||
sync_labels: bool = True
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -104,4 +111,6 @@ def get_settings() -> Settings:
|
||||
sync_gitea=_env_bool("KANBANTE_SYNC_GITEA", True),
|
||||
wip_limits=_env_wip_limits("KANBANTE_WIP_LIMITS"),
|
||||
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
|
||||
webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""),
|
||||
sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True),
|
||||
)
|
||||
|
||||
@@ -120,6 +120,10 @@ class GiteaClient:
|
||||
def _cache_clear(self) -> None:
|
||||
self._cache.clear()
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Drop every cached list response (called on webhook events)."""
|
||||
self._cache.clear()
|
||||
|
||||
# ── API ────────────────────────────────────────────────
|
||||
|
||||
async def ping(self) -> bool:
|
||||
@@ -221,6 +225,65 @@ class GiteaClient:
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
# ── Labels (kanban:* column mirroring) ─────────────────
|
||||
|
||||
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
return await self._get_all_pages(f"/repos/{owner}/{repo}/labels")
|
||||
|
||||
async def create_label(
|
||||
self, owner: str, repo: str, name: str, color: str
|
||||
) -> dict:
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/labels",
|
||||
json={"name": name, "color": color.lstrip("#")},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def ensure_label(
|
||||
self, owner: str, repo: str, name: str, color: str
|
||||
) -> dict:
|
||||
"""Return the repo label with this name, creating it if needed."""
|
||||
key = f"{owner}/{repo}"
|
||||
cache = self.__dict__.setdefault("_label_cache", {})
|
||||
if key not in cache:
|
||||
cache[key] = {l["name"]: l for l in await self.get_labels(owner, repo)}
|
||||
if name not in cache[key]:
|
||||
cache[key][name] = await self.create_label(owner, repo, name, color)
|
||||
return cache[key][name]
|
||||
|
||||
async def add_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id: int
|
||||
) -> None:
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels",
|
||||
json={"labels": [label_id]},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def remove_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id: int
|
||||
) -> None:
|
||||
r = await self.client.delete(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
# ── Milestones ─────────────────────────────────────────
|
||||
|
||||
async def get_milestones(
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[dict]:
|
||||
key = f"milestones:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
milestones = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
)
|
||||
self._cache_put(key, milestones)
|
||||
return milestones
|
||||
|
||||
async def create_issue(
|
||||
self, owner: str, repo: str, title: str, body: str = ""
|
||||
) -> dict:
|
||||
|
||||
+229
-6
@@ -16,6 +16,9 @@ FastAPI backend serving HTML + JSON API.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -24,7 +27,7 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -119,7 +122,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path == "/api/health":
|
||||
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
|
||||
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
|
||||
if request.url.path in ("/api/health", "/webhooks/gitea"):
|
||||
return await call_next(request)
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
@@ -158,6 +163,37 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
|
||||
# ── Live updates (SSE hub) ─────────────────────────────────
|
||||
|
||||
class LiveHub:
|
||||
"""Fan-out of Gitea webhook events to connected browsers (SSE)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
def subscribe(self) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
|
||||
self._subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, queue: asyncio.Queue) -> None:
|
||||
self._subscribers.discard(queue)
|
||||
|
||||
async def broadcast(self, payload: dict) -> None:
|
||||
for queue in list(self._subscribers):
|
||||
try:
|
||||
queue.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
pass # slow client: it will resync on its next load
|
||||
|
||||
@property
|
||||
def subscriber_count(self) -> int:
|
||||
return len(self._subscribers)
|
||||
|
||||
|
||||
hub = LiveHub()
|
||||
|
||||
|
||||
# ── Templates ──────────────────────────────────────────────
|
||||
|
||||
_tpl_env = Environment(
|
||||
@@ -311,12 +347,13 @@ async def dashboard(request: Request):
|
||||
COLUMN_LABELS=COLUMN_LABELS,
|
||||
COLUMN_COLORS=COLUMN_COLORS,
|
||||
active_window_days=get_settings().active_window_days,
|
||||
metrics=get_metrics(window_days=30),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str):
|
||||
"""Kanban board for a specific repo."""
|
||||
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
"""Kanban board for a specific repo (optionally one milestone)."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
all_items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||
issues = [i for i in all_items if _is_issue(i)]
|
||||
@@ -329,8 +366,47 @@ async def board(request: Request, owner: str, repo: str):
|
||||
for number in changes["closed_numbers"]:
|
||||
log_event(full_name, number, "closed", to_column="done", source="gitea")
|
||||
|
||||
# Milestone view: filter the board to one milestone (sprint-style),
|
||||
# with its deadline and completion progress.
|
||||
milestones: list[dict] = []
|
||||
selected_milestone = None
|
||||
milestone_progress = None
|
||||
display_issues = open_issues
|
||||
try:
|
||||
milestones = await gitea.get_milestones(owner, repo)
|
||||
except Exception as exc: # milestones are decoration, never fatal
|
||||
logger.debug("milestones failed for %s: %s", full_name, exc)
|
||||
milestones = []
|
||||
if milestone:
|
||||
def _in_milestone(issue: dict) -> bool:
|
||||
ms = issue.get("milestone") or {}
|
||||
return bool(ms) and (
|
||||
str(ms.get("id")) == milestone or ms.get("title") == milestone
|
||||
)
|
||||
|
||||
display_issues = [i for i in open_issues if _in_milestone(i)]
|
||||
selected_milestone = next(
|
||||
(
|
||||
m for m in milestones
|
||||
if str(m.get("id")) == milestone or m.get("title") == milestone
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected_milestone is None and display_issues:
|
||||
selected_milestone = display_issues[0].get("milestone")
|
||||
scoped = [i for i in issues if _in_milestone(i)]
|
||||
total = len(scoped)
|
||||
closed_count = sum(1 for i in scoped if i.get("state") == "closed")
|
||||
milestone_progress = {
|
||||
"total": total,
|
||||
"closed": closed_count,
|
||||
"pct": round(100 * closed_count / total) if total else 0,
|
||||
"due_on": (selected_milestone or {}).get("due_on"),
|
||||
"title": (selected_milestone or {}).get("title", milestone),
|
||||
}
|
||||
|
||||
board_state = get_board_state(full_name)
|
||||
columns = _merge_issues_with_board(open_issues, board_state)
|
||||
columns = _merge_issues_with_board(display_issues, board_state)
|
||||
|
||||
labels: dict[str, dict] = {}
|
||||
for issue in open_issues:
|
||||
@@ -356,9 +432,13 @@ async def board(request: Request, owner: str, repo: str):
|
||||
labels=sorted(labels.values(), key=lambda l: l["name"].lower()),
|
||||
notes=get_notes(full_name) or "",
|
||||
repo_info=repo_info,
|
||||
ages=_card_ages(full_name, open_issues, board_state),
|
||||
ages=_card_ages(full_name, display_issues, board_state),
|
||||
wip_limits=settings.wip_limits or {},
|
||||
aging_days=settings.aging_days,
|
||||
milestones=milestones,
|
||||
selected_milestone=selected_milestone,
|
||||
milestone_progress=milestone_progress,
|
||||
milestone_param=milestone,
|
||||
)
|
||||
|
||||
|
||||
@@ -429,6 +509,117 @@ async def focus(request: Request):
|
||||
)
|
||||
|
||||
|
||||
# ── Webhooks + live stream ─────────────────────────────────
|
||||
|
||||
def _apply_external_transition(
|
||||
full_name: str, number: int, target_column: str, event_type: str
|
||||
) -> str:
|
||||
"""Apply a close/reopen that happened in Gitea itself.
|
||||
|
||||
Idempotent by construction: when Kanbante's own API made the
|
||||
change, the local row is already in the target state and this is
|
||||
a no-op — so our own webhook echo never double-logs.
|
||||
"""
|
||||
board = get_board_state(full_name)
|
||||
current = None
|
||||
for column, cards in board.items():
|
||||
if any(c["issue_number"] == number for c in cards):
|
||||
current = column
|
||||
break
|
||||
if current is None or current == target_column:
|
||||
return "noop"
|
||||
if event_type == "reopened" and current != "done":
|
||||
return "noop" # locally open already: nothing truthful to apply
|
||||
move_issue(full_name, number, target_column)
|
||||
if event_type == "closed":
|
||||
log_event(full_name, number, "closed", to_column="done", source="webhook")
|
||||
else:
|
||||
log_event(
|
||||
full_name, number, "reopened",
|
||||
from_column="done", to_column=target_column, source="webhook",
|
||||
)
|
||||
return event_type
|
||||
|
||||
|
||||
@app.post("/webhooks/gitea")
|
||||
async def gitea_webhook(request: Request):
|
||||
"""Receive Gitea webhooks: verify HMAC, apply external close/reopen,
|
||||
invalidate the read cache and push a live event to browsers."""
|
||||
settings = get_settings()
|
||||
raw = await request.body()
|
||||
if settings.webhook_secret:
|
||||
signature = request.headers.get("x-gitea-signature", "")
|
||||
expected = hmac.new(
|
||||
settings.webhook_secret.encode(), raw, hashlib.sha256
|
||||
).hexdigest()
|
||||
if not signature or not hmac.compare_digest(signature, expected):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Invalid webhook signature"},
|
||||
status_code=401,
|
||||
)
|
||||
try:
|
||||
payload = json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Invalid JSON payload"}, status_code=400
|
||||
)
|
||||
|
||||
event = request.headers.get("x-gitea-event", "")
|
||||
action = payload.get("action", "")
|
||||
full_name = (payload.get("repository") or {}).get("full_name", "")
|
||||
issue = payload.get("issue") or {}
|
||||
number = issue.get("number")
|
||||
|
||||
handled = "ignored"
|
||||
if event == "issues" and full_name and number:
|
||||
if action == "closed":
|
||||
handled = _apply_external_transition(full_name, number, "done", "closed")
|
||||
elif action == "reopened":
|
||||
handled = _apply_external_transition(
|
||||
full_name, number, "backlog", "reopened"
|
||||
)
|
||||
|
||||
gitea.invalidate_cache()
|
||||
await hub.broadcast(
|
||||
{"repo": full_name, "event": event, "action": action, "issue": number}
|
||||
)
|
||||
return {"ok": True, "handled": handled}
|
||||
|
||||
|
||||
async def sse_event_stream(request: Request):
|
||||
"""Yield SSE frames for one subscriber until it disconnects.
|
||||
|
||||
Split out of the route so it can be unit-tested directly:
|
||||
test clients cannot reliably tear down an infinite HTTP stream.
|
||||
"""
|
||||
queue = hub.subscribe()
|
||||
try:
|
||||
yield "retry: 5000\n\n"
|
||||
while True:
|
||||
try:
|
||||
payload = await asyncio.wait_for(queue.get(), timeout=25)
|
||||
except asyncio.TimeoutError:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
yield f"event: gitea\ndata: {json.dumps(payload)}\n\n"
|
||||
finally:
|
||||
hub.unsubscribe(queue)
|
||||
|
||||
|
||||
@app.get("/api/stream")
|
||||
async def live_stream(request: Request):
|
||||
"""Server-Sent Events: browsers reload softly on Gitea activity."""
|
||||
return StreamingResponse(
|
||||
sse_event_stream(request),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/activity", response_class=HTMLResponse)
|
||||
async def activity(request: Request):
|
||||
"""Activity feed: the latest entries of the event journal."""
|
||||
@@ -517,6 +708,38 @@ async def api_move(
|
||||
exc,
|
||||
)
|
||||
result["gitea_sync"] = "failed"
|
||||
|
||||
# Mirror intermediate columns to Gitea labels (kanban:<column>) so
|
||||
# the workflow state is visible in Gitea itself, not just here.
|
||||
result["label_sync"] = "skipped"
|
||||
if settings.sync_labels and from_column != to_column:
|
||||
try:
|
||||
desired = (
|
||||
f"kanban:{to_column}"
|
||||
if to_column in ("todo", "in_progress", "review")
|
||||
else None
|
||||
)
|
||||
issue = await gitea.get_issue(owner, repo, issue_number)
|
||||
current = [
|
||||
label for label in issue.get("labels") or []
|
||||
if str(label.get("name", "")).startswith("kanban:")
|
||||
]
|
||||
for label in current:
|
||||
if label.get("name") != desired:
|
||||
await gitea.remove_issue_label(
|
||||
owner, repo, issue_number, label["id"]
|
||||
)
|
||||
if desired and all(l.get("name") != desired for l in current):
|
||||
label = await gitea.ensure_label(
|
||||
owner, repo, desired, COLUMN_COLORS[to_column]
|
||||
)
|
||||
await gitea.add_issue_label(owner, repo, issue_number, label["id"])
|
||||
result["label_sync"] = "ok"
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Label sync failed for %s#%s: %s", repo_full_name, issue_number, exc
|
||||
)
|
||||
result["label_sync"] = "failed"
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,15 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a> <span class="sep">/</span> 📰 Activité</h1>
|
||||
<nav>
|
||||
<a href="/focus" class="btn-sm">🎯 Focus</a>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/activity" class="nav-pill active">📰 Activité</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -48,5 +53,6 @@
|
||||
<footer>
|
||||
Kanbante v{{ version }} · journal d'événements
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+40
-11
@@ -13,24 +13,48 @@
|
||||
<script src="/static/js/board.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>
|
||||
<a href="/">📋 Kanbante</a>
|
||||
<span class="sep">/</span>
|
||||
<span>{{ owner }}/</span><strong>{{ repo }}</strong>
|
||||
</h1>
|
||||
<nav>
|
||||
<a href="/focus" class="btn-sm">🎯 Focus</a>
|
||||
<a href="/activity" class="btn-sm">📰 Activité</a>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
{% if repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" class="btn-sm">🔗 Gitea</a>
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% if milestones %}
|
||||
<div class="milestone-bar">
|
||||
<span class="milestone-label">🎯 Milestone</span>
|
||||
<a href="/board/{{ owner }}/{{ repo }}" class="milestone-chip{% if not milestone_param %} active{% endif %}">Toutes</a>
|
||||
{% for m in milestones %}
|
||||
<a href="/board/{{ owner }}/{{ repo }}?milestone={{ m.title|urlencode }}"
|
||||
class="milestone-chip{% if milestone_param == m.title or milestone_param == m.id|string %} active{% endif %}">{{ m.title }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if milestone_progress %}
|
||||
<div class="milestone-banner">
|
||||
<div class="milestone-info">
|
||||
<strong>🎯 {{ milestone_progress.title }}</strong>
|
||||
{% if milestone_progress.due_on %}
|
||||
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
|
||||
{% endif %}
|
||||
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
|
||||
</div>
|
||||
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
|
||||
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Toolbar: text + label filters, and issue creation (Gitea-backed) -->
|
||||
<div class="board-toolbar">
|
||||
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes...">
|
||||
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes... ( / )">
|
||||
<select id="label-filter">
|
||||
<option value="">Toutes les étiquettes</option>
|
||||
{% for label in labels %}
|
||||
@@ -56,6 +80,9 @@
|
||||
{% if limit %}data-limit="{{ limit }}" title="Limite WIP : {{ limit }}"{% endif %}>{% if limit %}{{ columns[col]|length }}/{{ limit }}{% else %}{{ columns[col]|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="column-cards" data-column="{{ col }}">
|
||||
{% if not columns[col] %}
|
||||
<p class="column-empty">Glissez une carte ici</p>
|
||||
{% endif %}
|
||||
{% for issue in columns[col] %}
|
||||
<div class="kanban-card"
|
||||
data-issue="{{ issue.number }}"
|
||||
@@ -99,6 +126,7 @@
|
||||
<!-- Notes panel -->
|
||||
<aside class="notes-panel">
|
||||
<h3>📝 Notes du projet</h3>
|
||||
<p class="notes-hint">Contexte libre, propre à Kanbante — il ne part pas dans Gitea.</p>
|
||||
<form hx-post="/api/notes" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful) showNotesSaved()">
|
||||
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
|
||||
@@ -138,6 +166,7 @@
|
||||
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>
|
||||
function showNotesSaved() {
|
||||
const toast = document.createElement('div');
|
||||
|
||||
@@ -9,16 +9,30 @@
|
||||
<script defer src="/static/vendor/alpine.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
|
||||
<nav>
|
||||
<a href="/focus" class="btn-sm">🎯 Focus</a>
|
||||
<a href="/activity" class="btn-sm">📰 Activité</a>
|
||||
<span class="badge">{{ active_repos|length }} actifs</span>
|
||||
<span class="badge secondary">{{ archived_repos|length }} archivés</span>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill active">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<h2>Bonjour 👋 Voici vos projets Gitea, en un coup d'œil.</h2>
|
||||
<p>Glissez les cartes, suivez ce qui avance, et laissez Gitea rester la source de vérité.</p>
|
||||
</div>
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile"><span class="stat-num">{{ active_repos|length }}</span><span class="stat-label">projets actifs</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ metrics.closed }}</span><span class="stat-label">issues fermées · 30 j</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ metrics.moved }}</span><span class="stat-label">déplacements · 30 j</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }} j{% else %}—{% endif %}</span><span class="stat-label">temps de cycle moyen</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main x-data="{ tab: 'active', search: '' }">
|
||||
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
|
||||
"archivé" = le vrai drapeau Gitea (repo.archived). -->
|
||||
@@ -37,7 +51,8 @@
|
||||
<!-- Search: the repo name lives in a data-* attribute (HTML-escaped
|
||||
by Jinja), never interpolated into a JS string literal. -->
|
||||
<div class="search-bar">
|
||||
<input type="text" placeholder="🔍 Filtrer les projets..." x-model="search">
|
||||
<input type="text" id="project-search" placeholder="🔍 Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
<kbd class="kbd">/</kbd>
|
||||
</div>
|
||||
|
||||
{% macro project_card(repo, archived=False) %}
|
||||
@@ -53,8 +68,17 @@
|
||||
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
||||
</h3>
|
||||
<p class="desc">{{ repo.description or '—' }}</p>
|
||||
{% if full in stats %}
|
||||
{% set total = stats[full].values()|sum %}
|
||||
{% set done_n = stats[full].get('done', 0) %}
|
||||
{% if total > 0 %}
|
||||
<div class="progress" title="{{ done_n }}/{{ total }} terminées">
|
||||
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="card-footer">
|
||||
<span>{{ repo.open_issues_count or 0 }} issues</span>
|
||||
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
||||
{% if full in stats %}
|
||||
<div class="mini-progress">
|
||||
{% for col in ['backlog','todo','in_progress','review','done'] %}
|
||||
@@ -73,19 +97,37 @@
|
||||
|
||||
<div x-show="tab === 'active'" class="project-grid">
|
||||
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
|
||||
{% if not active_repos %}
|
||||
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'inactive'" class="project-grid">
|
||||
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
|
||||
{% if not inactive_repos %}
|
||||
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'archived'" class="project-grid">
|
||||
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
|
||||
{% if not archived_repos %}
|
||||
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
||||
e.preventDefault();
|
||||
document.getElementById('project-search')?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,8 +7,15 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="error-main">
|
||||
<div class="error-box">
|
||||
|
||||
@@ -8,9 +8,15 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a> <span class="sep">/</span> 🎯 Focus</h1>
|
||||
<nav>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill active">🎯 Focus</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -58,5 +64,6 @@
|
||||
<footer>
|
||||
Kanbante v{{ version }} · vue Focus
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+498
-230
@@ -1,136 +1,251 @@
|
||||
/* ═══════════════════════════════════════════════
|
||||
Kanbante — Dark theme design system
|
||||
Kanbante — design system 0.4
|
||||
Dark, calm, modern. One accent gradient, generous
|
||||
radii, soft depth, visible focus, quiet motion.
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--surface2: #242836;
|
||||
--border: #2e3347;
|
||||
--text: #e2e4e9;
|
||||
--text2: #9498a4;
|
||||
--bg: #0b0e14;
|
||||
--bg-glow-1: rgba(124, 92, 252, 0.13);
|
||||
--bg-glow-2: rgba(91, 138, 247, 0.10);
|
||||
--surface: #141822;
|
||||
--surface-2: #1b202e;
|
||||
--surface-3: #232939;
|
||||
--border: rgba(148, 152, 164, 0.16);
|
||||
--border-strong: rgba(148, 152, 164, 0.30);
|
||||
--text: #eceef3;
|
||||
--text-2: #9aa0ae;
|
||||
--text-3: #6d7280;
|
||||
--accent: #7c5cfc;
|
||||
--accent2: #5b8af7;
|
||||
--accent-2: #5b8af7;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--radius: 10px;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,0.3);
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--shadow: 0 8px 28px rgba(0, 0, 0, 0.38);
|
||||
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
--ring: 0 0 0 3px rgba(124, 92, 252, 0.35);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(900px 480px at 12% -8%, var(--bg-glow-1), transparent 60%),
|
||||
radial-gradient(820px 460px at 88% -10%, var(--bg-glow-2), transparent 60%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
a { color: var(--accent2); text-decoration: none; }
|
||||
::selection { background: rgba(124, 92, 252, 0.45); }
|
||||
|
||||
a { color: var(--accent-2); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
/* ── App header ─────────────────────────────── */
|
||||
.app-header, header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(16, 19, 28, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 1.18rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logo:hover { text-decoration: none; }
|
||||
.logo-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 1rem;
|
||||
}
|
||||
header h1 { font-size: 1.25rem; font-weight: 600; }
|
||||
header h1 .sep { color: var(--text2); margin: 0 8px; }
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
nav { display: flex; gap: 10px; align-items: center; }
|
||||
.crumb { color: var(--text-2); font-size: 1.02rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.crumb .sep, header h1 .sep { color: var(--text-3); margin: 0 4px; }
|
||||
.crumb strong { color: var(--text); }
|
||||
header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
|
||||
.nav-pills, nav { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.nav-pill {
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-pill:hover { color: var(--text); background: var(--surface-2); text-decoration: none; }
|
||||
.nav-pill.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85));
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
.badge.secondary { background: var(--surface2); color: var(--text2); }
|
||||
.badge.secondary { background: var(--surface-2); color: var(--text-2); }
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--surface2);
|
||||
font-weight: 500;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
.btn-sm:hover { background: var(--border); text-decoration: none; }
|
||||
.btn-sm:hover { background: var(--surface-3); border-color: var(--border-strong); text-decoration: none; }
|
||||
.btn-sm:active { transform: scale(0.97); }
|
||||
|
||||
/* Tabs */
|
||||
/* ── Hero (dashboard) ───────────────────────── */
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
padding: 30px 24px 6px;
|
||||
}
|
||||
.hero-text h2 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 6px; }
|
||||
.hero-text p { color: var(--text-2); font-size: 0.95rem; max-width: 520px; }
|
||||
.stat-tiles { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.stat-tile {
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 18px;
|
||||
min-width: 118px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat-num { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.stat-label { font-size: 0.72rem; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
/* ── Tabs + search (dashboard) ──────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 16px 24px 0;
|
||||
background: var(--surface);
|
||||
gap: 6px;
|
||||
padding: 18px 24px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
|
||||
border-color: transparent;
|
||||
}
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button.active:hover { color: #fff; }
|
||||
|
||||
/* Search */
|
||||
.search-bar { padding: 12px 24px; background: var(--surface); }
|
||||
.search-bar { padding: 14px 24px 2px; display: flex; align-items: center; gap: 10px; }
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
max-width: 430px;
|
||||
padding: 11px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.92rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.search-bar input:focus { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.kbd {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-3);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.search-bar input:focus { border-color: var(--accent); }
|
||||
|
||||
/* Project Grid */
|
||||
/* ── Project cards ──────────────────────────── */
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
padding: 20px 24px 28px;
|
||||
}
|
||||
.project-card {
|
||||
background: var(--surface);
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
cursor: default;
|
||||
transition: transform 0.18s ease, border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.project-card.archived { opacity: 0.5; }
|
||||
.project-card.archived:hover { opacity: 0.8; }
|
||||
.project-card.archived { opacity: 0.55; }
|
||||
.project-card.archived:hover { opacity: 0.85; }
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -138,42 +253,136 @@ nav { display: flex; gap: 10px; align-items: center; }
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.org-tag {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
color: #c4b5fd;
|
||||
font-weight: 700;
|
||||
}
|
||||
.date { font-size: 0.75rem; color: var(--text2); }
|
||||
.project-card h3 { font-size: 1.05rem; margin-bottom: 6px; }
|
||||
.date { font-size: 0.75rem; color: var(--text-3); }
|
||||
.project-card h3 { font-size: 1.08rem; margin-bottom: 6px; letter-spacing: -0.01em; }
|
||||
.project-card h3 a { color: var(--text); }
|
||||
.project-card h3 a:hover { color: var(--accent2); }
|
||||
.project-card h3 a:hover { color: #a5c4fd; text-decoration: none; }
|
||||
.desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 14px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 2.4em;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text2);
|
||||
color: var(--text-2);
|
||||
gap: 10px;
|
||||
}
|
||||
.mini-progress { display: flex; gap: 4px; }
|
||||
.chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
.progress {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--green), #34d399);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.progress-lg { height: 10px; flex: 1; margin-bottom: 0; }
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 34px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255,255,255,0.015);
|
||||
}
|
||||
|
||||
/* ── Kanban Board ────────────────────────────── */
|
||||
/* ── Milestones (board) ─────────────────────── */
|
||||
.milestone-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 24px 0;
|
||||
}
|
||||
.milestone-label { font-size: 0.82rem; color: var(--text-2); font-weight: 600; margin-right: 2px; }
|
||||
.milestone-chip {
|
||||
padding: 6px 13px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-2);
|
||||
font-size: 0.83rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.milestone-chip:hover { color: var(--text); text-decoration: none; border-color: var(--border-strong); }
|
||||
.milestone-chip.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
|
||||
border-color: transparent;
|
||||
}
|
||||
.milestone-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 14px 24px 0;
|
||||
padding: 14px 18px;
|
||||
background: linear-gradient(120deg, rgba(124,92,252,.14), rgba(91,138,247,.10));
|
||||
border: 1px solid rgba(124,92,252,.35);
|
||||
border-radius: var(--radius);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.milestone-info { display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; font-size: 0.92rem; }
|
||||
.milestone-due, .milestone-count { color: var(--text-2); font-size: 0.85rem; }
|
||||
.milestone-pct { font-weight: 700; font-size: 1.05rem; }
|
||||
|
||||
/* ── Board toolbar ──────────────────────────── */
|
||||
.board-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 24px 4px;
|
||||
}
|
||||
.board-toolbar input[type="text"],
|
||||
.board-toolbar select {
|
||||
padding: 10px 15px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.board-toolbar select { border-radius: var(--radius-sm); }
|
||||
.board-toolbar input[type="text"]:focus,
|
||||
.board-toolbar select:focus { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.board-toolbar form { display: flex; gap: 8px; flex: 1; min-width: 260px; }
|
||||
.board-toolbar form input { flex: 1; }
|
||||
.btn-inline { margin-top: 0; padding: 10px 20px; border-radius: 999px; }
|
||||
|
||||
/* ── Kanban board ───────────────────────────── */
|
||||
.board-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -181,299 +390,358 @@ nav { display: flex; gap: 10px; align-items: center; }
|
||||
}
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
padding: 20px;
|
||||
padding: 16px 24px 22px;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
.kanban-column {
|
||||
min-width: 280px;
|
||||
max-width: 340px;
|
||||
min-width: 282px;
|
||||
max-width: 342px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
background: rgba(20, 24, 34, 0.72);
|
||||
border-radius: var(--radius);
|
||||
margin-right: 12px;
|
||||
border: 1px solid var(--border);
|
||||
scroll-snap-align: start;
|
||||
max-height: calc(100vh - 210px);
|
||||
}
|
||||
.column-header {
|
||||
padding: 14px 16px;
|
||||
padding: 13px 16px 11px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
.column-title { font-weight: 600; font-size: 0.9rem; }
|
||||
.column-title { font-weight: 650; font-size: 0.9rem; letter-spacing: 0.01em; }
|
||||
.column-count {
|
||||
background: var(--surface2);
|
||||
background: var(--surface-3);
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text2);
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.column-count.over {
|
||||
background: color-mix(in srgb, var(--red) 22%, transparent);
|
||||
color: #fca5a5;
|
||||
font-weight: 700;
|
||||
}
|
||||
.column-cards {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
padding: 4px 10px 12px;
|
||||
min-height: 90px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.column-empty {
|
||||
color: var(--text-3);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
padding: 16px 8px;
|
||||
border: 1.5px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.kanban-card {
|
||||
background: var(--surface2);
|
||||
background: linear-gradient(180deg, var(--surface-3), var(--surface-2));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 13px;
|
||||
margin-bottom: 9px;
|
||||
cursor: grab;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
transition: box-shadow 0.16s, transform 0.16s, border-color 0.16s;
|
||||
}
|
||||
.kanban-card:hover {
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.kanban-card:hover { box-shadow: var(--shadow); }
|
||||
.kanban-card:active { cursor: grabbing; }
|
||||
.kanban-card.dragging { opacity: 0.5; transform: rotate(2deg); }
|
||||
.kanban-card.dragging { opacity: 0.55; transform: rotate(2deg) scale(1.02); box-shadow: var(--shadow); }
|
||||
.card-id {
|
||||
font-size: 0.7rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
color: #c4b5fd;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-id a { color: var(--text-3); }
|
||||
.card-id a:hover { color: var(--accent-2); text-decoration: none; }
|
||||
.card-title { font-size: 0.87rem; line-height: 1.42; margin-bottom: 7px; }
|
||||
.card-labels { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
||||
.label-tag {
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text2);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.avatar { width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border-strong); }
|
||||
|
||||
.age-badge {
|
||||
background: color-mix(in srgb, var(--yellow) 18%, transparent);
|
||||
color: #fcd34d;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.age-badge.stale {
|
||||
background: color-mix(in srgb, var(--red) 20%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
.avatar { width: 20px; height: 20px; border-radius: 50%; }
|
||||
|
||||
/* Notes panel */
|
||||
.notes-panel {
|
||||
width: 280px;
|
||||
background: var(--surface);
|
||||
width: 292px;
|
||||
background: rgba(20, 24, 34, 0.72);
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.notes-panel h3 { font-size: 0.9rem; margin-bottom: 12px; }
|
||||
.notes-panel h3 { font-size: 0.92rem; margin-bottom: 6px; }
|
||||
.notes-hint { font-size: 0.78rem; color: var(--text-3); margin-bottom: 12px; line-height: 1.45; }
|
||||
.notes-panel form { display: flex; flex-direction: column; flex: 1; }
|
||||
.notes-panel textarea {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.notes-panel textarea:focus { border-color: var(--accent); }
|
||||
.notes-panel textarea:focus { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.btn-save {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 10px 18px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s;
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
transition: filter 0.15s, transform 0.1s;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.btn-save:hover { opacity: 0.9; }
|
||||
.btn-save:hover { filter: brightness(1.12); }
|
||||
.btn-save:active { transform: scale(0.98); }
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
padding: 12px 24px;
|
||||
background: var(--surface);
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text2);
|
||||
font-size: 0.79rem;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Toast notification */
|
||||
/* Toast + live pill */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--green);
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #059669, var(--green));
|
||||
color: #fff;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
animation: toast-in 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
}
|
||||
@keyframes slideIn { from { transform: translateY(20px); opacity: 0; } }
|
||||
.toast.error { background: linear-gradient(135deg, #b91c1c, var(--red)); }
|
||||
@keyframes toast-in { from { transform: translateY(16px) scale(0.96); opacity: 0; } }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
#live-pill {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
padding: 11px 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: #fff;
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
animation: toast-in 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
}
|
||||
#live-pill:hover { filter: brightness(1.12); }
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text2); }
|
||||
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
|
||||
|
||||
/* Toast error variant */
|
||||
.toast.error { background: var(--red); }
|
||||
|
||||
/* Board toolbar (filters + create) */
|
||||
.board-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
/* ── Focus view ─────────────────────────────── */
|
||||
.focus-main { padding: 26px 24px; max-width: 1120px; width: 100%; margin: 0 auto; }
|
||||
.focus-intro { color: var(--text-2); margin-bottom: 22px; font-size: 0.96rem; }
|
||||
.focus-empty {
|
||||
font-size: 1.02rem;
|
||||
padding: 36px 24px;
|
||||
text-align: center;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.board-toolbar input[type="text"],
|
||||
.board-toolbar select {
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
.board-toolbar input[type="text"]:focus,
|
||||
.board-toolbar select:focus { border-color: var(--accent); }
|
||||
.board-toolbar form { display: flex; gap: 8px; flex: 1; min-width: 260px; }
|
||||
.board-toolbar form input { flex: 1; }
|
||||
.btn-inline { margin-top: 0; padding: 9px 18px; }
|
||||
.card-id a { color: var(--text2); }
|
||||
.card-id a:hover { color: var(--accent2); }
|
||||
|
||||
/* Focus view */
|
||||
.focus-main { padding: 24px; max-width: 1100px; width: 100%; margin: 0 auto; }
|
||||
.focus-intro { color: var(--text2); margin-bottom: 20px; }
|
||||
.focus-empty { font-size: 1.05rem; padding: 32px 0; }
|
||||
.focus-group { margin-bottom: 28px; }
|
||||
.focus-group h2 { font-size: 1.05rem; margin-bottom: 12px; display: flex; gap: 10px; align-items: center; }
|
||||
.focus-group { margin-bottom: 30px; }
|
||||
.focus-group h2 { font-size: 1.06rem; margin-bottom: 12px; display: flex; gap: 10px; align-items: center; }
|
||||
.focus-group h2 a { color: var(--text); }
|
||||
.focus-group h2 a:hover { color: #a5c4fd; text-decoration: none; }
|
||||
.focus-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||
.focus-card { margin-bottom: 0; cursor: default; }
|
||||
|
||||
/* Error page */
|
||||
/* ── Error page ─────────────────────────────── */
|
||||
.error-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.error-box {
|
||||
background: var(--surface);
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
padding: 36px;
|
||||
max-width: 560px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.error-box h2 { margin-bottom: 12px; }
|
||||
.error-box p { color: var(--text2); margin-bottom: 16px; line-height: 1.5; }
|
||||
.error-box p { color: var(--text-2); margin-bottom: 16px; line-height: 1.55; }
|
||||
|
||||
/* Aging badges + WIP over-limit */
|
||||
.age-badge {
|
||||
background: color-mix(in srgb, var(--yellow) 18%, transparent);
|
||||
color: var(--yellow);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.age-badge.stale {
|
||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
color: var(--red);
|
||||
}
|
||||
.column-count.over {
|
||||
background: color-mix(in srgb, var(--red) 20%, transparent);
|
||||
color: var(--red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Card detail drawer */
|
||||
/* ── Card detail drawer ─────────────────────── */
|
||||
.detail-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
background: rgba(4, 6, 10, 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 200;
|
||||
animation: fade-in 0.18s ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
.card-detail {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: min(480px, 94vw); height: 100vh;
|
||||
width: min(500px, 94vw); height: 100dvh;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border-strong);
|
||||
z-index: 201;
|
||||
padding: 20px;
|
||||
padding: 22px;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
animation: drawer-in 0.24s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
.detail-head { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
|
||||
@keyframes drawer-in { from { transform: translateX(40px); opacity: 0; } }
|
||||
.detail-head { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
|
||||
.detail-head .btn-sm { margin-left: auto; }
|
||||
.card-detail h2 { font-size: 1.15rem; line-height: 1.35; margin-bottom: 8px; }
|
||||
.detail-meta { color: var(--text2); font-size: 0.85rem; margin-bottom: 10px; }
|
||||
.card-detail h2 { font-size: 1.18rem; line-height: 1.35; margin-bottom: 8px; letter-spacing: -0.01em; }
|
||||
.detail-meta { color: var(--text-2); font-size: 0.85rem; margin-bottom: 10px; }
|
||||
.detail-body {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 15px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
line-height: 1.6;
|
||||
margin: 12px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-body p { margin-bottom: 10px; }
|
||||
.detail-body pre { background: var(--surface2); padding: 10px; border-radius: 6px; overflow-x: auto; }
|
||||
.detail-body ul, .detail-body ol { margin: 0 0 10px 20px; }
|
||||
.detail-body pre { background: var(--surface-2); padding: 11px; border-radius: 8px; overflow-x: auto; margin-bottom: 10px; }
|
||||
.detail-body code { font-size: 0.82rem; }
|
||||
.detail-empty { color: var(--text2); font-style: italic; }
|
||||
.detail-comments-title { font-size: 0.95rem; margin: 16px 0 10px; }
|
||||
.detail-body a { word-break: break-all; }
|
||||
.detail-empty { color: var(--text-3); font-style: italic; }
|
||||
.detail-comments-title { font-size: 0.96rem; margin: 18px 0 10px; }
|
||||
.comment {
|
||||
background: var(--surface2);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 13px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.comment-head { font-size: 0.75rem; color: var(--text2); margin-bottom: 6px; }
|
||||
.comment-body { font-size: 0.88rem; line-height: 1.5; word-break: break-word; }
|
||||
.comment-head { font-size: 0.74rem; color: var(--text-3); margin-bottom: 6px; }
|
||||
.comment-body { font-size: 0.88rem; line-height: 1.55; word-break: break-word; }
|
||||
.comment-body p { margin-bottom: 8px; }
|
||||
#comment-form textarea {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 10px;
|
||||
padding: 11px;
|
||||
font-family: inherit;
|
||||
font-size: 0.88rem;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
#comment-form textarea:focus { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.detail-foot { margin-top: 14px; font-size: 0.85rem; }
|
||||
|
||||
/* Activity feed */
|
||||
/* ── Activity feed ──────────────────────────── */
|
||||
.activity-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.activity-item {
|
||||
display: flex; gap: 12px; align-items: baseline;
|
||||
background: var(--surface);
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 15px;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.activity-date { color: var(--text2); font-size: 0.78rem; margin-left: 8px; }
|
||||
.activity-item:hover { border-color: var(--border-strong); }
|
||||
.activity-date { color: var(--text-3); font-size: 0.77rem; margin-left: 8px; white-space: nowrap; }
|
||||
.activity-icon { font-size: 1rem; line-height: 1; }
|
||||
.activity-text { flex: 1; }
|
||||
.card-date { color: var(--text-3); }
|
||||
|
||||
/* Responsive */
|
||||
/* ── Responsive ─────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.hero { flex-direction: column; align-items: flex-start; }
|
||||
.kanban-column { max-height: none; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.board-container { flex-direction: column; }
|
||||
.notes-panel { width: 100%; max-height: 200px; border-left: none; border-top: 1px solid var(--border); }
|
||||
.kanban-board { flex-direction: column; }
|
||||
.kanban-column { max-width: 100%; min-width: 100%; margin-right: 0; margin-bottom: 12px; }
|
||||
.app-header, header { padding: 10px 14px; }
|
||||
.crumb { display: none; }
|
||||
.hero, .tabs, .search-bar, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
|
||||
.milestone-banner { margin-left: 14px; margin-right: 14px; }
|
||||
.board-container { flex-direction: column; overflow: visible; }
|
||||
.notes-panel { width: 100%; max-height: 240px; border-left: none; border-top: 1px solid var(--border); }
|
||||
.kanban-board { flex-direction: column; overflow-x: visible; padding: 14px; }
|
||||
.kanban-column { max-width: 100%; min-width: 100%; }
|
||||
.project-grid { grid-template-columns: 1fr; }
|
||||
.column-cards { overflow-y: visible; }
|
||||
footer { padding: 12px 14px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'dragging',
|
||||
onStart: function () { window.__kanbanteDragging = true; },
|
||||
dragClass: 'dragging',
|
||||
onEnd: function (evt) {
|
||||
window.__kanbanteDragging = false;
|
||||
if (evt.from === evt.to && evt.oldIndex === evt.newIndex) return;
|
||||
const card = evt.item;
|
||||
const formData = new FormData();
|
||||
@@ -128,6 +130,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard: "/" jumps to the filter box.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || '')) {
|
||||
e.preventDefault();
|
||||
document.getElementById('card-filter')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Card detail drawer ─────────────────────────────────
|
||||
// A click opens the panel; a drag must not. Track pointer travel.
|
||||
let downX = 0, downY = 0;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Kanbante — live updates via Server-Sent Events.
|
||||
// When Gitea reports activity (webhook -> /api/stream), reload the
|
||||
// page softly if it is safe (visible tab, no drawer open, no text
|
||||
// being typed, no drag in progress); otherwise show a pill the
|
||||
// user can click when ready.
|
||||
(function () {
|
||||
if (!window.EventSource) return;
|
||||
|
||||
var pill = null;
|
||||
var timer = null;
|
||||
|
||||
function showPill() {
|
||||
if (pill) return;
|
||||
pill = document.createElement('button');
|
||||
pill.id = 'live-pill';
|
||||
pill.type = 'button';
|
||||
pill.textContent = '🔄 Nouvelle activité Gitea — actualiser';
|
||||
pill.addEventListener('click', function () { window.location.reload(); });
|
||||
document.body.appendChild(pill);
|
||||
}
|
||||
|
||||
function safeToReload() {
|
||||
if (document.visibilityState !== 'visible') return false;
|
||||
if (window.__kanbanteDragging) return false;
|
||||
var drawer = document.getElementById('card-detail');
|
||||
if (drawer && !drawer.hidden) return false;
|
||||
var active = document.activeElement;
|
||||
if (active && (active.tagName === 'TEXTAREA' ||
|
||||
(active.tagName === 'INPUT' && active.type === 'text'))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleReload() {
|
||||
if (timer) return;
|
||||
timer = setTimeout(function () {
|
||||
timer = null;
|
||||
if (safeToReload()) window.location.reload();
|
||||
else showPill();
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
var source = new EventSource('/api/stream');
|
||||
source.addEventListener('gitea', scheduleReload);
|
||||
})();
|
||||
+51
-2
@@ -47,10 +47,10 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
|
||||
"title": title,
|
||||
"body": f"Corps de l'issue {number} en **markdown**.",
|
||||
"state": state,
|
||||
"labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [],
|
||||
"labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [],
|
||||
"assignee": None,
|
||||
"assignees": [],
|
||||
"milestone": None,
|
||||
"milestone": {"id": 1, "title": "Sprint 1"} if number in (1, 3) else None,
|
||||
"due_date": None,
|
||||
"created_at": "2026-09-01T09:00:00Z",
|
||||
"updated_at": "2026-09-02T09:00:00Z",
|
||||
@@ -79,6 +79,10 @@ class FakeGitea:
|
||||
_issue(5, "Tâche focus"),
|
||||
],
|
||||
}
|
||||
self.repo_labels: dict[str, dict[str, dict]] = {"Projets/demo": {}}
|
||||
self.label_ops: list[tuple] = []
|
||||
self._label_seq = 100
|
||||
self.cache_invalidations = 0
|
||||
self.state_changes: list[tuple] = []
|
||||
self.created: list[tuple] = []
|
||||
self.comments: dict[tuple, list[dict]] = {
|
||||
@@ -150,6 +154,51 @@ class FakeGitea:
|
||||
self.posted_comments.append((owner, repo, number, body))
|
||||
return comment
|
||||
|
||||
def invalidate_cache(self):
|
||||
self.cache_invalidations += 1
|
||||
|
||||
async def get_labels(self, owner, repo):
|
||||
return list(self.repo_labels.setdefault(f"{owner}/{repo}", {}).values())
|
||||
|
||||
async def create_label(self, owner, repo, name, color):
|
||||
self._label_seq += 1
|
||||
label = {"id": self._label_seq, "name": name, "color": color.lstrip("#")}
|
||||
self.repo_labels.setdefault(f"{owner}/{repo}", {})[name] = label
|
||||
self.label_ops.append(("create", f"{owner}/{repo}", name))
|
||||
return label
|
||||
|
||||
async def ensure_label(self, owner, repo, name, color):
|
||||
existing = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
||||
if name in existing:
|
||||
return existing[name]
|
||||
return await self.create_label(owner, repo, name, color)
|
||||
|
||||
async def add_issue_label(self, owner, repo, number, label_id):
|
||||
issue = await self.get_issue(owner, repo, number)
|
||||
label = next(
|
||||
l for l in self.repo_labels[f"{owner}/{repo}"].values()
|
||||
if l["id"] == label_id
|
||||
)
|
||||
if label not in issue["labels"]:
|
||||
issue["labels"].append(label)
|
||||
self.label_ops.append(("add", number, label["name"]))
|
||||
|
||||
async def remove_issue_label(self, owner, repo, number, label_id):
|
||||
issue = await self.get_issue(owner, repo, number)
|
||||
removed = [l for l in issue["labels"] if l["id"] == label_id]
|
||||
issue["labels"] = [l for l in issue["labels"] if l["id"] != label_id]
|
||||
for label in removed:
|
||||
self.label_ops.append(("remove", number, label["name"]))
|
||||
|
||||
async def get_milestones(self, owner, repo, state="open"):
|
||||
return [
|
||||
{
|
||||
"id": 1, "title": "Sprint 1",
|
||||
"due_on": "2026-10-20T00:00:00Z",
|
||||
"open_issues": 1, "closed_issues": 1,
|
||||
}
|
||||
]
|
||||
|
||||
async def create_issue(self, owner, repo, title, body=""):
|
||||
self._next_number += 1
|
||||
issue = _issue(self._next_number, title)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Palier 2 (0.4.0): webhooks + SSE live, label sync, milestone boards."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
from app import models
|
||||
from app.main import hub
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def _move(client, number, to_column, from_column):
|
||||
token = csrf_from(client)
|
||||
return client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": number,
|
||||
"to_column": to_column, "from_column": from_column,
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _webhook(client, payload, event="issues", secret=None, raw=None):
|
||||
body = raw if raw is not None else json.dumps(payload).encode()
|
||||
headers = {"X-Gitea-Event": event, "Content-Type": "application/json"}
|
||||
if secret is not None:
|
||||
headers["X-Gitea-Signature"] = hmac.new(
|
||||
secret.encode(), body, hashlib.sha256
|
||||
).hexdigest()
|
||||
return client.post("/webhooks/gitea", content=body, headers=headers)
|
||||
|
||||
|
||||
# ── Hub SSE ────────────────────────────────────────────────
|
||||
|
||||
def test_hub_broadcast_reaches_subscribers():
|
||||
async def scenario():
|
||||
queue = hub.subscribe()
|
||||
try:
|
||||
await hub.broadcast({"repo": "a/b", "action": "closed"})
|
||||
return await asyncio.wait_for(queue.get(), timeout=1)
|
||||
finally:
|
||||
hub.unsubscribe(queue)
|
||||
|
||||
payload = asyncio.run(scenario())
|
||||
assert payload["action"] == "closed"
|
||||
assert hub.subscriber_count == 0
|
||||
|
||||
|
||||
def test_sse_event_stream_frames_and_cleanup():
|
||||
# The generator is unit-tested directly: HTTP test clients cannot
|
||||
# reliably tear down an infinite SSE stream (verified empirically).
|
||||
from app.main import sse_event_stream
|
||||
|
||||
class FakeRequest:
|
||||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
async def scenario():
|
||||
gen = sse_event_stream(FakeRequest())
|
||||
first = await gen.__anext__()
|
||||
await hub.broadcast({"repo": "a/b", "action": "closed"})
|
||||
second = await asyncio.wait_for(gen.__anext__(), timeout=5)
|
||||
await gen.aclose()
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(scenario())
|
||||
assert first == "retry: 5000\n\n"
|
||||
assert second.startswith("event: gitea\ndata: ")
|
||||
assert '"action": "closed"' in second
|
||||
assert hub.subscriber_count == 0 # finally unsubscribed
|
||||
|
||||
|
||||
# ── Webhooks ───────────────────────────────────────────────
|
||||
|
||||
def test_webhook_closed_applies_transition_and_journals(client, fake_gitea):
|
||||
models.move_issue("Projets/demo", 1, "todo")
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
}
|
||||
r = _webhook(client, payload)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["handled"] == "closed"
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["done"]] == [1]
|
||||
events = models.get_events("Projets/demo")
|
||||
assert events[0]["event_type"] == "closed"
|
||||
assert events[0]["source"] == "webhook"
|
||||
assert fake_gitea.cache_invalidations >= 1
|
||||
|
||||
# The echo of our own API moves arrives in the same state: no-op,
|
||||
# no duplicate journal entry.
|
||||
r = _webhook(client, payload)
|
||||
assert r.json()["handled"] == "noop"
|
||||
assert len(models.get_events("Projets/demo")) == 1
|
||||
|
||||
|
||||
def test_webhook_reopened_moves_back_to_backlog(client):
|
||||
models.move_issue("Projets/demo", 1, "done")
|
||||
r = _webhook(client, {
|
||||
"action": "reopened",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
})
|
||||
assert r.json()["handled"] == "reopened"
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["backlog"]] == [1]
|
||||
|
||||
|
||||
def test_webhook_signature_enforced_when_secret_set(client, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_WEBHOOK_SECRET", "s3cret")
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
}
|
||||
assert _webhook(client, payload).status_code == 401
|
||||
assert _webhook(client, payload, secret="wrong").status_code == 401
|
||||
assert _webhook(client, payload, secret="s3cret").status_code == 200
|
||||
|
||||
|
||||
def test_webhook_invalid_json_is_400(client):
|
||||
r = _webhook(client, None, raw=b"{not json")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_webhook_ignores_unrelated_events(client):
|
||||
r = _webhook(client, {
|
||||
"action": "created",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
}, event="issue_comment")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["handled"] == "ignored"
|
||||
|
||||
|
||||
# ── Synchro par étiquettes ─────────────────────────────────
|
||||
|
||||
def test_label_sync_follows_column_moves(client, fake_gitea):
|
||||
r = _move(client, 1, "in_progress", "backlog")
|
||||
assert r.json()["label_sync"] == "ok"
|
||||
assert ("create", "Projets/demo", "kanban:in_progress") in fake_gitea.label_ops
|
||||
assert ("add", 1, "kanban:in_progress") in fake_gitea.label_ops
|
||||
|
||||
r = _move(client, 1, "review", "in_progress")
|
||||
assert r.json()["label_sync"] == "ok"
|
||||
assert ("remove", 1, "kanban:in_progress") in fake_gitea.label_ops
|
||||
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
|
||||
|
||||
r = _move(client, 1, "done", "review")
|
||||
assert r.json()["label_sync"] == "ok"
|
||||
assert ("remove", 1, "kanban:review") in fake_gitea.label_ops
|
||||
issue = fake_gitea.issues["Projets/demo"][0]
|
||||
assert not any(l["name"].startswith("kanban:") for l in issue["labels"])
|
||||
assert any(l["name"] == "bug" for l in issue["labels"]) # user's label kept
|
||||
|
||||
|
||||
def test_label_sync_can_be_disabled(client, fake_gitea, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_SYNC_LABELS", "0")
|
||||
r = _move(client, 1, "in_progress", "backlog")
|
||||
assert r.json()["label_sync"] == "skipped"
|
||||
assert fake_gitea.label_ops == []
|
||||
|
||||
|
||||
# ── Boards par milestone ───────────────────────────────────
|
||||
|
||||
def test_board_filters_by_milestone_with_progress(client):
|
||||
models.move_issue("Projets/demo", 3, "done")
|
||||
r = client.get("/board/Projets/demo?milestone=Sprint%201")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche un" in r.text # open, in Sprint 1
|
||||
assert "Tâche focus" not in r.text # open, no milestone
|
||||
assert "milestone-banner" in r.text
|
||||
assert "1/2 issues terminées" in r.text
|
||||
assert "50%" in r.text
|
||||
assert "2026-10-20" in r.text # due_on from the milestone
|
||||
|
||||
|
||||
def test_board_lists_milestone_chips(client):
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert "milestone-chip" in r.text
|
||||
assert "Sprint 1" in r.text
|
||||
assert "milestone=Sprint" in r.text
|
||||
|
||||
|
||||
def test_board_filter_by_milestone_id(client):
|
||||
r = client.get("/board/Projets/demo?milestone=1")
|
||||
assert "Tâche un" in r.text
|
||||
assert "Tâche focus" not in r.text
|
||||
|
||||
|
||||
# ── Refonte UI : marqueurs structurels ─────────────────────
|
||||
|
||||
def test_dashboard_uses_new_design_system(client):
|
||||
r = client.get("/")
|
||||
assert "app-header" in r.text
|
||||
assert "nav-pill" in r.text
|
||||
assert "stat-tile" in r.text
|
||||
assert "live.js" in r.text
|
||||
Reference in New Issue
Block a user