feat(palette): palette de commandes Ctrl+K sur toutes les pages (issue #10)
GET /api/palette: projets (filtrage local) + recherche globale
d'issues annotées de leur colonne. Composant partagé _palette.html
+ palette.js (actions, projets, issues; ↑↓/Entrée/Échap; bouton
⌨️ dans la navigation). Aucune librairie ajoutée.
This commit is contained in:
+249
@@ -34,6 +34,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from app.config import APP_VERSION, get_settings
|
from app.config import APP_VERSION, get_settings
|
||||||
from app.gitea_client import GiteaClient
|
from app.gitea_client import GiteaClient
|
||||||
|
from app import digest as digest_service
|
||||||
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
||||||
from app.capture import ntfy_capture_loop, parse_capture_text
|
from app.capture import ntfy_capture_loop, parse_capture_text
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -140,6 +141,7 @@ async def lifespan(app: FastAPI):
|
|||||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||||
)
|
)
|
||||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||||
|
digest_task = asyncio.create_task(_digest_scheduler())
|
||||||
capture_task = None
|
capture_task = None
|
||||||
if settings.capture_ntfy_topic and settings.ntfy_url:
|
if settings.capture_ntfy_topic and settings.ntfy_url:
|
||||||
async def _capture_create(title: str, body: str) -> None:
|
async def _capture_create(title: str, body: str) -> None:
|
||||||
@@ -162,6 +164,7 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
yield
|
yield
|
||||||
scheduler.cancel()
|
scheduler.cancel()
|
||||||
|
digest_task.cancel()
|
||||||
if capture_task is not None:
|
if capture_task is not None:
|
||||||
capture_task.cancel()
|
capture_task.cancel()
|
||||||
await gitea.aclose()
|
await gitea.aclose()
|
||||||
@@ -1176,6 +1179,199 @@ async def capture_submit(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _weekly_digest() -> dict:
|
||||||
|
"""Compose the weekly digest (journal of the last 7 days + focus)."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
since = (now - timedelta(days=7)).replace(tzinfo=None)
|
||||||
|
events = []
|
||||||
|
for event in get_events(limit=1000):
|
||||||
|
at = _parse_datetime(str(event.get("created_at") or ""))
|
||||||
|
if at is not None and at.replace(tzinfo=None) >= since:
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
closed_keys: list[tuple[str, int]] = []
|
||||||
|
seen: set[tuple[str, int]] = set()
|
||||||
|
for event in events:
|
||||||
|
if event["event_type"] == "closed":
|
||||||
|
key = (event["repo_full_name"], event["issue_number"])
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
closed_keys.append(key)
|
||||||
|
|
||||||
|
titles: dict[tuple[str, int], dict] = {}
|
||||||
|
for repo_name in {repo for repo, _ in closed_keys}:
|
||||||
|
owner, _, name = repo_name.partition("/")
|
||||||
|
try:
|
||||||
|
items = await gitea.get_repo_issues(owner, name, state="all")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for item in items:
|
||||||
|
if _is_issue(item):
|
||||||
|
titles[(repo_name, item["number"])] = item
|
||||||
|
closed_items = [
|
||||||
|
{
|
||||||
|
"repo": repo,
|
||||||
|
"number": number,
|
||||||
|
"title": (titles.get((repo, number)) or {}).get("title", ""),
|
||||||
|
"html_url": (titles.get((repo, number)) or {}).get("html_url", ""),
|
||||||
|
}
|
||||||
|
for repo, number in closed_keys
|
||||||
|
]
|
||||||
|
|
||||||
|
focus_groups = []
|
||||||
|
for group in await _collect_focus_groups():
|
||||||
|
focus_groups.append(
|
||||||
|
{
|
||||||
|
"full_name": group["full_name"],
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"issue": card["issue"],
|
||||||
|
"column": COLUMN_LABELS[card["column"]],
|
||||||
|
}
|
||||||
|
for card in group["cards"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
week_label = (
|
||||||
|
f"{(now - timedelta(days=6)).strftime('%Y-%m-%d')}"
|
||||||
|
f" au {now.strftime('%Y-%m-%d')}"
|
||||||
|
)
|
||||||
|
markdown = digest_service.format_digest_markdown(
|
||||||
|
week_label,
|
||||||
|
closed_items,
|
||||||
|
created_count=sum(1 for e in events if e["event_type"] == "created"),
|
||||||
|
moved_count=sum(1 for e in events if e["event_type"] == "moved"),
|
||||||
|
focus_groups=focus_groups,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"title": f"Kanbante — semaine du {week_label}",
|
||||||
|
"week": now.strftime("%G-W%V"),
|
||||||
|
"markdown": markdown,
|
||||||
|
"closed_count": len(closed_items),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_digest(request: Request, banner: str | None = None,
|
||||||
|
tools: list[str] | None = None):
|
||||||
|
settings = get_settings()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"digest.html",
|
||||||
|
pending=get_kv("digest_pending"),
|
||||||
|
last_result=get_kv("digest_last_result"),
|
||||||
|
obsigate_configured=bool(settings.obsigate_mcp_url),
|
||||||
|
obsigate_url=settings.obsigate_mcp_url,
|
||||||
|
obsigate_tool=settings.obsigate_mcp_tool,
|
||||||
|
webhook_configured=bool(settings.digest_webhook_url),
|
||||||
|
digest_time=settings.digest_time,
|
||||||
|
digest_weekday=settings.digest_weekday,
|
||||||
|
banner=banner,
|
||||||
|
tools=tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/digest", response_class=HTMLResponse)
|
||||||
|
async def digest_page(request: Request):
|
||||||
|
"""Weekly digest: preview, approval and delivery status."""
|
||||||
|
return _render_digest(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/digest/probe", response_class=HTMLResponse)
|
||||||
|
async def digest_probe(request: Request, csrf_token: str = Form("")):
|
||||||
|
"""List the tools of the configured ObsiGate MCP server."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.obsigate_mcp_url:
|
||||||
|
return _render_digest(request, banner="❌ Aucun serveur MCP ObsiGate configuré (KANBANTE_OBSIGATE_MCP_URL).")
|
||||||
|
ok, result = await digest_service.mcp_list_tools(
|
||||||
|
settings.obsigate_mcp_url, settings.obsigate_mcp_token
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
return _render_digest(
|
||||||
|
request,
|
||||||
|
banner=f"✅ Connexion MCP réussie — {len(result)} outil(s) disponible(s).",
|
||||||
|
tools=result,
|
||||||
|
)
|
||||||
|
return _render_digest(request, banner=f"❌ {result}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/digest/send", response_class=HTMLResponse)
|
||||||
|
async def digest_send(
|
||||||
|
request: Request,
|
||||||
|
target: str = Form(...),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Deliver the digest — only ever from this explicit approval."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
settings = get_settings()
|
||||||
|
digest = await _weekly_digest()
|
||||||
|
if target == "webhook":
|
||||||
|
if not settings.digest_webhook_url:
|
||||||
|
return _render_digest(request, banner="❌ Webhook non configuré (KANBANTE_DIGEST_WEBHOOK_URL).")
|
||||||
|
ok, message = await digest_service.send_webhook(
|
||||||
|
settings.digest_webhook_url,
|
||||||
|
digest["title"], digest["markdown"], digest["week"],
|
||||||
|
)
|
||||||
|
elif target == "obsigate":
|
||||||
|
if not settings.obsigate_mcp_url or not settings.obsigate_mcp_tool:
|
||||||
|
return _render_digest(request, banner="❌ ObsiGate incomplet : KANBANTE_OBSIGATE_MCP_URL et KANBANTE_OBSIGATE_MCP_TOOL sont requis (utilise « Tester la connexion » pour voir les outils).")
|
||||||
|
try:
|
||||||
|
arguments = digest_service.render_mcp_args(
|
||||||
|
settings.obsigate_mcp_args, digest["title"], digest["markdown"]
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _render_digest(request, banner=f"❌ {exc}")
|
||||||
|
ok, message = await digest_service.mcp_call_tool(
|
||||||
|
settings.obsigate_mcp_url, settings.obsigate_mcp_token,
|
||||||
|
settings.obsigate_mcp_tool, arguments,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return _render_digest(request, banner="❌ Cible inconnue.")
|
||||||
|
stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
set_kv("digest_last_result", f"{stamp} · {target} · {message}")
|
||||||
|
if ok:
|
||||||
|
set_kv("digest_pending", "")
|
||||||
|
return _render_digest(request, banner=f"✅ Digest envoyé ({target}) : {message}")
|
||||||
|
return _render_digest(request, banner=f"❌ Échec de l'envoi ({target}) : {message}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _digest_scheduler() -> None:
|
||||||
|
"""Compose the weekly digest and notify — never deliver it.
|
||||||
|
|
||||||
|
Delivery to ObsiGate is a cross-app write: it waits for the
|
||||||
|
one-click approval on /digest (standing rule).
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.digest_time:
|
||||||
|
now = datetime.now()
|
||||||
|
week = now.strftime("%G-W%V")
|
||||||
|
if (
|
||||||
|
now.weekday() == settings.digest_weekday
|
||||||
|
and now.strftime("%H:%M") >= settings.digest_time
|
||||||
|
and get_kv("digest_pending_week") != week
|
||||||
|
):
|
||||||
|
digest = await _weekly_digest()
|
||||||
|
set_kv("digest_pending", digest["markdown"])
|
||||||
|
set_kv("digest_pending_week", week)
|
||||||
|
logger.info("Weekly digest composed for %s", week)
|
||||||
|
if ntfy_configured(settings):
|
||||||
|
await send_ntfy(
|
||||||
|
settings,
|
||||||
|
"🧠 Digest hebdo Kanbante prêt — "
|
||||||
|
"en attente d'approbation sur la page /digest.",
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Digest scheduler error: %s", exc)
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/stream")
|
@app.get("/api/stream")
|
||||||
async def live_stream(request: Request):
|
async def live_stream(request: Request):
|
||||||
"""Server-Sent Events: browsers reload softly on Gitea activity."""
|
"""Server-Sent Events: browsers reload softly on Gitea activity."""
|
||||||
@@ -1806,6 +2002,59 @@ async def api_stats():
|
|||||||
return get_stats()
|
return get_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/palette")
|
||||||
|
async def api_palette(q: str = ""):
|
||||||
|
"""Data for the Ctrl+K command palette (roadmap #10).
|
||||||
|
|
||||||
|
Projects are always returned (the palette filters them locally);
|
||||||
|
issues are searched globally once the query has 2+ characters,
|
||||||
|
annotated with their current board column.
|
||||||
|
"""
|
||||||
|
repos = await gitea.search_repos()
|
||||||
|
active, inactive, _archived = classify_repos(repos)
|
||||||
|
projects = [
|
||||||
|
{"full_name": r["full_name"]} for r in (active + inactive)[:60]
|
||||||
|
]
|
||||||
|
issues: list[dict] = []
|
||||||
|
query = q.strip()
|
||||||
|
if len(query) >= 2:
|
||||||
|
try:
|
||||||
|
found = await gitea.search_issues(query, state="all")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("palette search failed: %s", exc)
|
||||||
|
found = []
|
||||||
|
placements: dict[str, dict[int, str]] = {}
|
||||||
|
for item in found[:40]:
|
||||||
|
if not _is_issue(item):
|
||||||
|
continue
|
||||||
|
full = (item.get("repository") or {}).get("full_name", "")
|
||||||
|
if not full:
|
||||||
|
continue
|
||||||
|
if full not in placements:
|
||||||
|
placements[full] = {
|
||||||
|
card["issue_number"]: col
|
||||||
|
for col, cards in get_board_state(full).items()
|
||||||
|
for card in cards
|
||||||
|
}
|
||||||
|
if item.get("state") == "closed":
|
||||||
|
column = "done"
|
||||||
|
else:
|
||||||
|
column = placements[full].get(item["number"], "backlog")
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"repo": full,
|
||||||
|
"number": item["number"],
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"state": item.get("state", "open"),
|
||||||
|
"column": column,
|
||||||
|
"column_label": COLUMN_LABELS[column],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(issues) >= 8:
|
||||||
|
break
|
||||||
|
return {"projects": projects, "issues": issues}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/projects")
|
@app.get("/api/projects")
|
||||||
async def api_projects():
|
async def api_projects():
|
||||||
"""Get all projects (JSON), classified like the dashboard."""
|
"""Get all projects (JSON), classified like the dashboard."""
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<div class="palette-overlay" id="palette-overlay" hidden>
|
||||||
|
<div class="palette" role="dialog" aria-modal="true" aria-label="Palette de commandes">
|
||||||
|
<div class="palette-input-row">
|
||||||
|
<span class="palette-icon">⌨️</span>
|
||||||
|
<input id="palette-input" type="text" autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="Chercher un projet, une issue, une action…">
|
||||||
|
<kbd>Échap</kbd>
|
||||||
|
</div>
|
||||||
|
<ul class="palette-results" id="palette-results"></ul>
|
||||||
|
<div class="palette-hint">↑↓ naviguer · ↵ ouvrir · Échap fermer · Ctrl+K pour rouvrir</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -59,5 +61,7 @@
|
|||||||
Kanbante v{{ version }} · journal d'événements
|
Kanbante v{{ version }} · journal d'événements
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/live.js" defer></script>
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
{% if repo_info.html_url %}
|
{% if repo_info.html_url %}
|
||||||
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -205,5 +207,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/live.js" defer></script>
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -69,5 +71,7 @@
|
|||||||
status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status));
|
status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill active">⚡ Capture</a>
|
<a href="/capture" class="nav-pill active">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -96,5 +98,7 @@
|
|||||||
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
|
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/live.js" defer></script>
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -134,5 +136,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -70,5 +72,7 @@
|
|||||||
Kanbante v{{ version }} · vue Focus
|
Kanbante v{{ version }} · vue Focus
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/live.js" defer></script>
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -61,5 +63,7 @@
|
|||||||
Kanbante v{{ version }} · mes issues
|
Kanbante v{{ version }} · mes issues
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/live.js" defer></script>
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -67,5 +69,7 @@
|
|||||||
Kanbante v{{ version }} · recherche globale
|
Kanbante v{{ version }} · recherche globale
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/live.js" defer></script>
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -980,3 +980,49 @@ footer {
|
|||||||
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
padding: 12px 14px; overflow-x: auto; font-size: 0.82rem; line-height: 1.5;
|
padding: 12px 14px; overflow-x: auto; font-size: 0.82rem; line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Palette de commandes (0.8.0, #10) ── */
|
||||||
|
.palette-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(5, 10, 20, 0.62);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
display: flex; justify-content: center; align-items: flex-start;
|
||||||
|
padding: 10vh 16px 16px;
|
||||||
|
}
|
||||||
|
.palette-overlay[hidden] { display: none; }
|
||||||
|
.palette {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
background: var(--surface-1, #131a29);
|
||||||
|
border: 1px solid var(--border-strong, #2c3a52);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: wall-in 0.15s ease;
|
||||||
|
}
|
||||||
|
.palette-input-row {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 14px 16px; border-bottom: 1px solid var(--border, #22304a);
|
||||||
|
}
|
||||||
|
.palette-input-row input {
|
||||||
|
flex: 1; background: transparent; border: none; outline: none;
|
||||||
|
color: var(--text-1); font: inherit; font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.palette-input-row kbd {
|
||||||
|
background: var(--surface-3, #1d2942); border: 1px solid var(--border, #22304a);
|
||||||
|
border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; color: var(--text-3);
|
||||||
|
}
|
||||||
|
.palette-results { list-style: none; margin: 0; padding: 8px; max-height: 46vh; overflow-y: auto; }
|
||||||
|
.palette-item {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 12px; border-radius: 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.palette-item.active { background: var(--surface-3, #1d2942); }
|
||||||
|
.palette-item-icon { font-size: 1.05rem; width: 24px; text-align: center; }
|
||||||
|
.palette-item-main { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.palette-item-sub { color: var(--text-3); font-size: 0.8rem; white-space: nowrap; }
|
||||||
|
.palette-empty { padding: 18px 14px; color: var(--text-3); text-align: center; }
|
||||||
|
.palette-hint {
|
||||||
|
padding: 9px 16px; border-top: 1px solid var(--border, #22304a);
|
||||||
|
color: var(--text-3); font-size: 0.78rem; text-align: right;
|
||||||
|
}
|
||||||
|
.palette-trigger { cursor: pointer; font: inherit; }
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Kanbante — Command palette (Ctrl+K), roadmap #10.
|
||||||
|
*
|
||||||
|
* One overlay for the whole app: static actions, every project, and
|
||||||
|
* global issue search (via /api/palette). Fully keyboard driven.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
var overlay = document.getElementById('palette-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
var input = document.getElementById('palette-input');
|
||||||
|
var list = document.getElementById('palette-results');
|
||||||
|
|
||||||
|
var ACTIONS = [
|
||||||
|
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
|
||||||
|
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
|
||||||
|
{ icon: '👤', label: 'Mes issues', href: '/my' },
|
||||||
|
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
|
||||||
|
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
|
||||||
|
{ icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' },
|
||||||
|
{ icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' },
|
||||||
|
{ icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' },
|
||||||
|
{ icon: '🔍', label: 'Recherche avancée', href: '/search' },
|
||||||
|
];
|
||||||
|
|
||||||
|
var projects = null; // cached after first fetch
|
||||||
|
var issues = []; // latest search results for the query
|
||||||
|
var items = []; // rendered items
|
||||||
|
var activeIndex = 0;
|
||||||
|
var debounce = null;
|
||||||
|
var lastQuery = '';
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
overlay.hidden = false;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
input.value = '';
|
||||||
|
issues = [];
|
||||||
|
lastQuery = '';
|
||||||
|
render('');
|
||||||
|
input.focus();
|
||||||
|
if (projects === null) {
|
||||||
|
fetch('/api/palette')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
projects = data.projects || [];
|
||||||
|
render(input.value.trim());
|
||||||
|
})
|
||||||
|
.catch(function () { projects = []; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
overlay.hidden = true;
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildItems(query) {
|
||||||
|
var q = query.toLowerCase();
|
||||||
|
var out = [];
|
||||||
|
ACTIONS.forEach(function (a) {
|
||||||
|
if (!q || a.label.toLowerCase().includes(q)) {
|
||||||
|
out.push({ icon: a.icon, label: a.label, sub: 'Action', href: a.href });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(projects || []).forEach(function (p) {
|
||||||
|
if (out.length >= 14) return;
|
||||||
|
if (!q || p.full_name.toLowerCase().includes(q)) {
|
||||||
|
out.push({
|
||||||
|
icon: '📂', label: p.full_name, sub: 'Projet · ouvrir le board',
|
||||||
|
href: '/board/' + p.full_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
issues.forEach(function (i) {
|
||||||
|
out.push({
|
||||||
|
icon: '🐛',
|
||||||
|
label: '#' + i.number + ' · ' + i.title,
|
||||||
|
sub: i.repo + ' · ' + i.column_label + (i.state === 'closed' ? ' · fermée' : ''),
|
||||||
|
href: '/board/' + i.repo,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out.slice(0, 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(query) {
|
||||||
|
items = buildItems(query);
|
||||||
|
activeIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!items.length) {
|
||||||
|
var empty = document.createElement('li');
|
||||||
|
empty.className = 'palette-empty';
|
||||||
|
empty.textContent = 'Aucun résultat — essaie un nom de projet ou un mot du titre d\'une issue.';
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach(function (item, idx) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'palette-item' + (idx === activeIndex ? ' active' : '');
|
||||||
|
var icon = document.createElement('span');
|
||||||
|
icon.className = 'palette-item-icon';
|
||||||
|
icon.textContent = item.icon;
|
||||||
|
var main = document.createElement('span');
|
||||||
|
main.className = 'palette-item-main';
|
||||||
|
main.textContent = item.label;
|
||||||
|
var sub = document.createElement('span');
|
||||||
|
sub.className = 'palette-item-sub';
|
||||||
|
sub.textContent = item.sub;
|
||||||
|
li.appendChild(icon);
|
||||||
|
li.appendChild(main);
|
||||||
|
li.appendChild(sub);
|
||||||
|
li.addEventListener('click', function () { go(item); });
|
||||||
|
li.addEventListener('mousemove', function () {
|
||||||
|
if (activeIndex !== idx) { activeIndex = idx; render(input.value.trim()); }
|
||||||
|
});
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(item) {
|
||||||
|
if (item) window.location.href = item.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchIssues(query) {
|
||||||
|
if (query.length < 2) { issues = []; render(query); return; }
|
||||||
|
fetch('/api/palette?q=' + encodeURIComponent(query))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (input.value.trim() !== query) return; // stale response
|
||||||
|
issues = data.issues || [];
|
||||||
|
if (projects === null) projects = data.projects || [];
|
||||||
|
render(query);
|
||||||
|
})
|
||||||
|
.catch(function () { /* keep previous results */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var query = input.value.trim();
|
||||||
|
activeIndex = 0;
|
||||||
|
render(query);
|
||||||
|
if (query !== lastQuery) {
|
||||||
|
lastQuery = query;
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(function () { searchIssues(query); }, 180);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = Math.min(activeIndex + 1, items.length - 1);
|
||||||
|
render(input.value.trim());
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = Math.max(activeIndex - 1, 0);
|
||||||
|
render(input.value.trim());
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
go(items[activeIndex]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
var combo = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k';
|
||||||
|
if (combo) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (overlay.hidden) open(); else close();
|
||||||
|
} else if (e.key === 'Escape' && !overlay.hidden) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.palette-trigger').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () { open(); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user