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:
Spark
2026-10-11 12:46:54 -04:00
parent 39b751df56
commit 0417d0593f
12 changed files with 517 additions and 0 deletions
+46
View File
@@ -980,3 +980,49 @@ footer {
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;
}
/* ── 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; }
+178
View File
@@ -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(); });
});
})();