Files
TimeTracker/app/static/js/command-palette.js
T
Dries Peeters 4eabe3cabd feat(ui): add global Ctrl/Cmd+K command palette
Adds a Tailwind-styled global command palette (Ctrl/Cmd+K) with fuzzy search and a Start Timer flow that picks a project then POSTs /api/timer/start with CSRF.
Updates docs to reflect the new shortcut.
2026-04-26 09:03:54 +02:00

405 lines
11 KiB
JavaScript

import { commandScore } from 'https://cdn.jsdelivr.net/npm/cmdk@1.1.1/dist/command-score.mjs';
(() => {
if (window.__ttCommandPaletteLoaded) return;
window.__ttCommandPaletteLoaded = true;
const isMac = (() => {
try {
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent);
} catch (_) {
return false;
}
})();
// Swap Ctrl badge to ⌘ on macOS for the sidebar hint.
try {
if (isMac) {
document.querySelectorAll('#sidebar kbd').forEach((kbd) => {
if (kbd.textContent && kbd.textContent.trim().toLowerCase() === 'ctrl') kbd.textContent = '⌘';
});
}
} catch (_) {}
const sel = {
root: '#ttCommandPalette',
input: '#ttCommandPaletteInput',
list: '#ttCommandPaletteList',
close: '#ttCommandPaletteClose',
subheader: '#ttCommandPaletteSubheader',
};
const $ = (s, r = document) => r.querySelector(s);
const $all = (s, r = document) => Array.from(r.querySelectorAll(s));
const root = $(sel.root);
if (!root) return;
const input = $(sel.input);
const list = $(sel.list);
const closeBtn = $(sel.close);
const subheader = $(sel.subheader);
const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content || '';
const urls = {
dashboard: root.dataset.dashboardUrl || '/',
reports: root.dataset.reportsUrl || '/reports',
clients: root.dataset.clientsUrl || '/clients',
newTimeEntry: root.dataset.newTimeEntryUrl || '/timer/manual',
newProject: root.dataset.newProjectUrl || '/projects/create',
newInvoice: root.dataset.newInvoiceUrl || '/invoices/create',
};
const state = {
open: false,
mode: 'commands', // 'commands' | 'projects'
selected: 0,
query: '',
lastFocus: null,
projects: null,
commands: [],
filtered: [],
};
function showToast(message, level = 'info') {
try {
if (typeof window.showToast === 'function') {
window.showToast(message, level);
return;
}
} catch (_) {}
// Fallback: minimal alert-style feedback
try {
// eslint-disable-next-line no-alert
alert(message);
} catch (_) {}
}
function normalize(s) {
return String(s || '').trim().toLowerCase();
}
function fuzzyRank(haystack, query) {
const q = normalize(query);
if (!q) return 0;
const h = normalize(haystack);
try {
return commandScore(h, q);
} catch (_) {
// Very small fallback if commandScore import fails
return h.includes(q) ? 1 : 0;
}
}
function isTyping(ev) {
return window.TimeTracker && window.TimeTracker.isTyping ? window.TimeTracker.isTyping(ev) : false;
}
function openPalette() {
if (!root.classList.contains('hidden')) {
setTimeout(() => input?.focus(), 10);
return;
}
state.lastFocus = document.activeElement;
state.open = true;
state.mode = 'commands';
state.query = '';
state.selected = 0;
if (input) input.value = '';
if (subheader) subheader.classList.add('hidden');
root.classList.remove('hidden');
setTimeout(() => input?.focus(), 30);
applyFilter();
render();
}
function closePalette() {
state.open = false;
root.classList.add('hidden');
state.query = '';
state.selected = 0;
state.mode = 'commands';
if (input) input.value = '';
if (subheader) {
subheader.textContent = '';
subheader.classList.add('hidden');
}
try {
if (state.lastFocus && typeof state.lastFocus.focus === 'function') state.lastFocus.focus();
} catch (_) {}
}
function nav(href) {
window.location.href = href;
}
async function fetchProjects() {
if (state.projects) return state.projects;
const res = await fetch('/api/projects', { credentials: 'same-origin' });
if (!res.ok) throw new Error('Failed to load projects');
const json = await res.json();
const projects = Array.isArray(json.projects) ? json.projects : [];
state.projects = projects;
return projects;
}
async function startTimerWithProject(projectId) {
const res = await fetch('/api/timer/start', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken(),
},
body: JSON.stringify({ project_id: projectId }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.success === false) {
throw new Error(json.error || json.message || 'Failed to start timer');
}
return json;
}
function setMode(mode, headerText = '') {
state.mode = mode;
state.selected = 0;
state.query = '';
if (input) input.value = '';
if (subheader) {
if (headerText) {
subheader.textContent = headerText;
subheader.classList.remove('hidden');
} else {
subheader.textContent = '';
subheader.classList.add('hidden');
}
}
applyFilter();
render();
setTimeout(() => input?.focus(), 10);
}
function buildCommands() {
state.commands = [
{
id: 'start-timer',
title: 'Start Timer',
keywords: 'timer start track',
hint: isMac ? '⌘K' : 'Ctrl+K',
action: async () => {
try {
await fetchProjects();
setMode('projects', 'Start Timer → Select a project');
} catch (e) {
showToast(e.message || 'Failed to load projects', 'danger');
}
},
},
{ id: 'new-time-entry', title: 'New Time Entry', keywords: 'manual log add', action: () => nav(urls.newTimeEntry) },
{ id: 'new-project', title: 'New Project', keywords: 'project create add', action: () => nav(urls.newProject) },
{ id: 'new-invoice', title: 'New Invoice', keywords: 'invoice billing create', action: () => nav(urls.newInvoice) },
{ id: 'goto-dashboard', title: 'Go to Dashboard', keywords: 'home overview main', action: () => nav(urls.dashboard) },
{ id: 'goto-reports', title: 'Go to Reports', keywords: 'analytics insights', action: () => nav(urls.reports) },
{ id: 'goto-clients', title: 'Go to Clients', keywords: 'crm customers companies', action: () => nav(urls.clients) },
];
}
function getActiveItems() {
if (state.mode === 'projects') {
const projects = state.projects || [];
return projects.map((p) => ({
id: `project:${p.id}`,
title: p.name || `Project #${p.id}`,
keywords: `${p.name || ''} ${p.client_name || ''}`.trim(),
hint: p.client_name ? String(p.client_name) : '',
action: async () => {
try {
await startTimerWithProject(p.id);
closePalette();
showToast('Timer started', 'info');
} catch (e) {
showToast(e.message || 'Failed to start timer', 'danger');
}
},
}));
}
return state.commands;
}
function applyFilter() {
const items = getActiveItems();
const q = state.query;
if (!q) {
state.filtered = items.slice();
state.selected = Math.min(state.selected, Math.max(0, state.filtered.length - 1));
return;
}
const scored = items
.map((it) => {
const haystack = `${it.title} ${it.keywords || ''}`;
const score = fuzzyRank(haystack, q);
return { it, score };
})
.filter((x) => x.score > 0);
scored.sort((a, b) => b.score - a.score);
state.filtered = scored.map((x) => x.it);
state.selected = 0;
}
function highlightSelected() {
$all('[data-tt-cp-idx]', list).forEach((el) => {
const idx = Number(el.getAttribute('data-tt-cp-idx') || '0');
const active = idx === state.selected;
el.classList.toggle('bg-background-light', active);
el.classList.toggle('dark:bg-background-dark', active);
});
}
function renderEmpty(message) {
list.innerHTML = `
<div class="px-3 py-8 text-center text-sm text-text-muted-light dark:text-text-muted-dark">
${message}
</div>
`;
}
function render() {
if (!list) return;
const items = state.filtered || [];
if (!items.length) {
if (state.mode === 'projects') renderEmpty('No projects found.');
else renderEmpty('No commands found.');
return;
}
list.innerHTML = '';
items.forEach((cmd, idx) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute('data-tt-cp-idx', String(idx));
btn.className =
'px-3 py-2 text-left flex items-center justify-between gap-3 hover:bg-background-light dark:hover:bg-background-dark focus:outline-none focus:ring-2 focus:ring-primary';
const left = document.createElement('div');
left.className = 'min-w-0 flex-1';
left.innerHTML = `<div class="truncate">${cmd.title}</div>`;
const right = document.createElement('div');
right.className = 'shrink-0 text-xs text-text-muted-light dark:text-text-muted-dark flex items-center gap-2';
if (cmd.hint) {
right.innerHTML = `<span class="truncate">${cmd.hint}</span>`;
}
btn.appendChild(left);
btn.appendChild(right);
btn.addEventListener('click', () => {
closePalette();
setTimeout(() => cmd.action?.(), 30);
});
list.appendChild(btn);
});
highlightSelected();
}
function onInput() {
state.query = input?.value || '';
applyFilter();
render();
}
function onPaletteKeydown(ev) {
if (!state.open) return;
if (ev.key === 'Escape') {
ev.preventDefault();
closePalette();
return;
}
if (ev.key === 'ArrowDown') {
ev.preventDefault();
state.selected = Math.min(state.selected + 1, Math.max(0, (state.filtered?.length || 1) - 1));
highlightSelected();
return;
}
if (ev.key === 'ArrowUp') {
ev.preventDefault();
state.selected = Math.max(state.selected - 1, 0);
highlightSelected();
return;
}
if (ev.key === 'Enter') {
ev.preventDefault();
const cmd = state.filtered?.[state.selected];
if (cmd) {
closePalette();
setTimeout(() => cmd.action?.(), 30);
}
}
}
function onGlobalKeydown(ev) {
const combo = (ev.ctrlKey || ev.metaKey) && (ev.key === 'k' || ev.key === 'K');
if (combo) {
ev.preventDefault();
openPalette();
return;
}
if (!state.open) return;
// When palette is open, keep focus in the input.
if (ev.key === '/' && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
setTimeout(() => input?.focus(), 10);
}
}
function onBackdropClick(e) {
const shouldClose = e.target?.hasAttribute?.('data-tt-cp-close');
if (shouldClose) closePalette();
}
function onRootClick(e) {
const btn = e.target.closest?.('#ttCommandPaletteClose');
if (btn) {
e.preventDefault();
closePalette();
}
}
function init() {
buildCommands();
state.filtered = state.commands.slice();
document.addEventListener('keydown', onGlobalKeydown);
document.addEventListener('keydown', (ev) => {
if (!state.open) return;
// Allow navigation even if the input isn't focused.
if (!isTyping(ev)) onPaletteKeydown(ev);
});
root.addEventListener('click', onRootClick);
root.addEventListener('click', onBackdropClick);
input?.addEventListener('input', onInput);
closeBtn?.addEventListener('click', (e) => {
e.preventDefault();
closePalette();
});
window.openCommandPalette = openPalette;
}
init();
})();