mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-25 05:58:50 -06:00
Add complete internationalization (i18n) infrastructure supporting 9 languages including full Right-to-Left (RTL) support for Arabic and Hebrew. Languages supported: - English, German, French, Spanish, Dutch, Italian, Finnish (LTR) - Arabic, Hebrew (RTL with complete layout support) Core features: * Flask-Babel configuration with locale selector * Translation files for all 9 languages (480+ strings each) * Language selector UI component in header with globe icon * User language preference storage in database * RTL CSS support with automatic layout reversal * Session and user-based language persistence Model field translation system: * Created comprehensive i18n helper utilities (app/utils/i18n_helpers.py) * 17 new Jinja2 template filters for automatic translation * Support for task statuses, priorities, project statuses, invoice statuses, payment methods, expense categories, and all model enum fields * Status badge CSS classes for consistent styling Technical implementation: * Language switching via API endpoint (POST /api/language) * Direct language switching route (GET /set-language/<lang>) * RTL detection and automatic dir="rtl" attribute * Context processors for language information in all templates * Template filters registered globally Testing and quality: * 50+ unit tests covering all i18n functionality * Tests for locale selection, language switching, RTL detection * Comprehensive test coverage for all translation features Files added: - translations/es/LC_MESSAGES/messages.po (Spanish) - translations/ar/LC_MESSAGES/messages.po (Arabic) - translations/he/LC_MESSAGES/messages.po (Hebrew) - app/utils/i18n_helpers.py (translation helper functions) - app/static/css/rtl-support.css (RTL layout support) - tests/test_i18n.py (comprehensive test suite) - scripts/audit_i18n.py (translation audit tool) Files modified: - app/config.py: Added 3 languages + RTL configuration - app/routes/user.py: Language switching endpoints - app/templates/base.html: Language selector + RTL support - app/utils/context_processors.py: Language context injection - app/__init__.py: Registered i18n template filters - scripts/extract_translations.py: Updated language list - translations/*/messages.po: Added 70+ model field translations The infrastructure is production-ready. Model enum fields now automatically translate in templates using the new filters. Flash messages and some template strings remain in English until wrapped with translation markers (tracked separately for incremental implementation).
281 lines
12 KiB
JavaScript
281 lines
12 KiB
JavaScript
// Command Palette and Keyboard Shortcuts
|
|
// Provides Ctrl/Cmd+K palette, quick nav (g d, g p, g r, g t), and timer controls
|
|
|
|
(function(){
|
|
if (window.__ttCommandsLoaded) return; // prevent double load
|
|
window.__ttCommandsLoaded = true;
|
|
|
|
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
|
|
|
// Lightweight DOM helpers
|
|
function $(sel, root){ return (root||document).querySelector(sel); }
|
|
function $all(sel, root){ return Array.from((root||document).querySelectorAll(sel)); }
|
|
|
|
function openModal(){
|
|
const el = $('#commandPaletteModal');
|
|
if (!el) return;
|
|
// If already open, just refocus the input instead of reopening
|
|
if (!el.classList.contains('hidden')) {
|
|
setTimeout(() => $('#commandPaletteInput')?.focus(), 10);
|
|
return;
|
|
}
|
|
el.classList.remove('hidden');
|
|
setTimeout(() => $('#commandPaletteInput')?.focus(), 50);
|
|
refreshCommands();
|
|
renderList();
|
|
}
|
|
|
|
function closeModal(){
|
|
const el = $('#commandPaletteModal');
|
|
if (!el) return;
|
|
el.classList.add('hidden');
|
|
clearFilter();
|
|
}
|
|
|
|
// Timer helpers
|
|
async function getActiveTimer(){
|
|
try {
|
|
const res = await fetch('/timer/status', { credentials: 'same-origin' });
|
|
if (!res.ok) return null;
|
|
const json = await res.json();
|
|
return json && json.active ? json.timer : null;
|
|
} catch(e) { return null; }
|
|
}
|
|
|
|
async function startTimerQuick(){
|
|
// Navigate to log time if no quick context; palette is for quick access, not forms
|
|
window.location.href = '/timer/manual';
|
|
}
|
|
|
|
async function stopTimerQuick(){
|
|
try {
|
|
const active = await getActiveTimer();
|
|
if (!active) { showToast(window.i18n?.messages?.noActiveTimer || 'No active timer', 'warning'); return; }
|
|
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
const res = await fetch('/timer/stop', { method: 'POST', headers: { 'X-CSRF-Token': token }, credentials: 'same-origin' });
|
|
if (res.ok) {
|
|
showToast(window.i18n?.messages?.timerStopped || 'Timer stopped', 'info');
|
|
} else {
|
|
showToast(window.i18n?.messages?.timerStopFailed || 'Failed to stop timer', 'danger');
|
|
}
|
|
} catch(e) {
|
|
showToast(window.i18n?.messages?.timerStopFailed || 'Failed to stop timer', 'danger');
|
|
}
|
|
}
|
|
|
|
// Commands registry
|
|
const registry = [];
|
|
function addCommand(cmd){ registry.push(cmd); }
|
|
function nav(href){ window.location.href = href; }
|
|
|
|
addCommand({ id: 'goto-dashboard', title: 'Go to Dashboard', hint: 'g d', keywords: 'home main', action: () => nav('/') });
|
|
addCommand({ id: 'goto-projects', title: 'Go to Projects', hint: 'g p', keywords: 'work clients', action: () => nav('/projects') });
|
|
addCommand({ id: 'goto-clients', title: 'Go to Clients', hint: '', keywords: 'work companies', action: () => nav('/clients') });
|
|
addCommand({ id: 'goto-tasks', title: 'Go to Tasks', hint: 'g t', keywords: 'work', action: () => nav('/tasks') });
|
|
addCommand({ id: 'goto-reports', title: 'Go to Reports', hint: 'g r', keywords: 'insights analytics', action: () => nav('/reports') });
|
|
addCommand({ id: 'goto-invoices', title: 'Go to Invoices', hint: '', keywords: 'billing finance', action: () => nav('/invoices') });
|
|
addCommand({ id: 'goto-analytics', title: 'Go to Analytics', hint: '', keywords: 'charts insights', action: () => nav('/analytics') });
|
|
addCommand({ id: 'open-calendar', title: 'Open Calendar', hint: '', keywords: 'day week month schedule', action: () => nav('/timer/calendar') });
|
|
addCommand({ id: 'log-time', title: 'Log Time (Manual Entry)', hint: '', keywords: 'add create', action: () => nav('/timer/manual') });
|
|
addCommand({ id: 'bulk-entry', title: 'Bulk Time Entry', hint: '', keywords: 'multi add', action: () => nav('/timer/bulk') });
|
|
addCommand({ id: 'start-timer', title: 'Start New Timer (Quick → Manual)', hint: '', keywords: 'play run', action: startTimerQuick });
|
|
addCommand({ id: 'stop-timer', title: 'Stop Timer', hint: '', keywords: 'pause end', action: stopTimerQuick });
|
|
addCommand({ id: 'goto-admin', title: 'Open Admin', hint: '', keywords: 'settings system', action: () => nav('/admin') });
|
|
addCommand({ id: 'open-profile', title: 'Open Profile', hint: '', keywords: 'account user', action: () => nav('/profile') });
|
|
addCommand({ id: 'open-help', title: 'Open Help', hint: '', keywords: 'support docs', action: () => nav('/help') });
|
|
addCommand({ id: 'open-about', title: 'Open About', hint: '', keywords: 'info version', action: () => nav('/about') });
|
|
addCommand({ id: 'toggle-theme', title: 'Toggle Theme', hint: isMac ? '⌘⇧L' : 'Ctrl+Shift+L', keywords: 'light dark', action: () => { try { document.getElementById('theme-toggle')?.click(); } catch(e) {} } });
|
|
|
|
// New Quick Wins Features
|
|
addCommand({ id: 'time-templates', title: 'Time Entry Templates', hint: '', keywords: 'quick templates saved', action: () => nav('/templates') });
|
|
addCommand({ id: 'saved-filters', title: 'Saved Filters', hint: '', keywords: 'search quick filters', action: () => nav('/filters') });
|
|
addCommand({ id: 'user-settings', title: 'User Settings', hint: '', keywords: 'preferences config options', action: () => nav('/settings') });
|
|
addCommand({ id: 'create-project', title: 'Create New Project', hint: '', keywords: 'add new', action: () => nav('/projects/create') });
|
|
addCommand({ id: 'create-task', title: 'Create New Task', hint: '', keywords: 'add new', action: () => nav('/tasks/create') });
|
|
addCommand({ id: 'create-client', title: 'Create New Client', hint: '', keywords: 'add new', action: () => nav('/clients/create') });
|
|
addCommand({ id: 'create-invoice', title: 'Create New Invoice', hint: '', keywords: 'add new billing', action: () => nav('/invoices/create') });
|
|
addCommand({ id: 'export-excel', title: 'Export Reports to Excel', hint: '', keywords: 'download export xlsx', action: () => nav('/reports/export/excel') });
|
|
addCommand({ id: 'my-tasks', title: 'My Tasks', hint: '', keywords: 'assigned work todo', action: () => nav('/tasks/my-tasks') });
|
|
|
|
// Filtering and rendering
|
|
let filtered = registry.slice();
|
|
let selectedIdx = 0;
|
|
|
|
function clearFilter(){
|
|
const input = $('#commandPaletteInput');
|
|
if (input) input.value = '';
|
|
filtered = registry.slice();
|
|
selectedIdx = 0;
|
|
}
|
|
|
|
function normalize(s){ return (s||'').toLowerCase(); }
|
|
function isMatch(cmd, q){
|
|
if (!q) return true;
|
|
const t = normalize(cmd.title);
|
|
const k = normalize(cmd.keywords);
|
|
q = normalize(q);
|
|
return t.indexOf(q) !== -1 || k.indexOf(q) !== -1;
|
|
}
|
|
|
|
async function refreshCommands(){
|
|
// Update titles that depend on state (e.g., timer)
|
|
const active = await getActiveTimer();
|
|
const stop = registry.find(c => c.id === 'stop-timer');
|
|
if (stop) stop.title = active ? `Stop Timer (${active.project_name || 'Current'})` : 'Stop Timer';
|
|
}
|
|
|
|
function renderList(){
|
|
const list = $('#commandPaletteList');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
// Ensure container has modern styling
|
|
list.className = 'flex flex-col max-h-96 overflow-y-auto divide-y divide-border-light dark:divide-border-dark';
|
|
filtered.forEach((cmd, idx) => {
|
|
const li = document.createElement('button');
|
|
li.type = 'button';
|
|
li.className = 'px-3 py-2 text-left flex justify-between items-center hover:bg-background-light dark:hover:bg-background-dark focus:outline-none focus:ring-2 focus:ring-primary';
|
|
li.setAttribute('data-idx', String(idx));
|
|
li.innerHTML = `<span class="truncate">${cmd.title}</span>${cmd.hint ? `<span class="ml-3 text-xs text-text-muted-light dark:text-text-muted-dark">${cmd.hint}</span>` : ''}`;
|
|
li.addEventListener('click', () => { closeModal(); setTimeout(() => cmd.action(), 50); });
|
|
list.appendChild(li);
|
|
});
|
|
highlightSelected();
|
|
}
|
|
|
|
function highlightSelected(){
|
|
$all('#commandPaletteList > button').forEach((el, idx) => {
|
|
const isActive = idx === selectedIdx;
|
|
el.classList.toggle('bg-background-light', isActive);
|
|
el.classList.toggle('dark:bg-background-dark', isActive);
|
|
});
|
|
}
|
|
|
|
function onInput(){
|
|
const q = $('#commandPaletteInput')?.value || '';
|
|
filtered = registry.filter(c => isMatch(c, q));
|
|
selectedIdx = 0;
|
|
renderList();
|
|
}
|
|
|
|
function onKeyDown(ev){
|
|
// Check if typing in input field or editor
|
|
if (isTypingInField(ev)) return;
|
|
|
|
// Note: ? key (Shift+/) is now handled by keyboard-shortcuts-advanced.js for shortcuts panel
|
|
// Command palette is opened with Ctrl+K
|
|
|
|
// Sequence shortcuts: g d / g p / g r / g t
|
|
sequenceHandler(ev);
|
|
}
|
|
|
|
// Key sequence handling
|
|
let seq = [];
|
|
let seqTimer = null;
|
|
function resetSeq(){ seq = []; if (seqTimer) { clearTimeout(seqTimer); seqTimer = null; } }
|
|
|
|
// Check if user is typing in input field or rich text editor
|
|
function isTypingInField(ev){
|
|
const target = ev.target;
|
|
const tag = (target.tagName || '').toLowerCase();
|
|
|
|
// Check standard inputs
|
|
if (['input','textarea','select'].includes(tag) || target.isContentEditable) {
|
|
return true;
|
|
}
|
|
|
|
// Check for rich text editors (Toast UI Editor, etc.)
|
|
const editorSelectors = [
|
|
'.toastui-editor', '.toastui-editor-contents', '.ProseMirror',
|
|
'.CodeMirror', '.ql-editor', '.tox-edit-area', '.note-editable',
|
|
'[contenteditable="true"]', '.toastui-editor-ww-container',
|
|
'.toastui-editor-md-container'
|
|
];
|
|
|
|
for (let i = 0; i < editorSelectors.length; i++) {
|
|
if (target.closest && target.closest(editorSelectors[i])) {
|
|
console.log('[Commands.js] Blocked - inside editor:', editorSelectors[i]);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function sequenceHandler(ev){
|
|
if (ev.repeat) return;
|
|
const key = ev.key.toLowerCase();
|
|
|
|
// Check if typing in any input field or editor
|
|
if (isTypingInField(ev)) {
|
|
console.log('[Commands.js] Blocked - user is typing');
|
|
resetSeq(); // Clear any partial sequence
|
|
return;
|
|
}
|
|
|
|
if (ev.ctrlKey || ev.metaKey || ev.altKey) return; // only plain keys
|
|
|
|
console.log('[Commands.js] Processing key in sequence:', key, 'current seq:', seq);
|
|
seq.push(key);
|
|
if (seq.length > 2) seq.shift();
|
|
if (seq.length === 1 && seq[0] === 'g'){
|
|
seqTimer = setTimeout(resetSeq, 1000);
|
|
return;
|
|
}
|
|
if (seq.length === 2 && seq[0] === 'g'){
|
|
const second = seq[1];
|
|
console.log('[Commands.js] Executing navigation for g +', second);
|
|
resetSeq();
|
|
if (second === 'd') return nav('/');
|
|
if (second === 'p') return nav('/projects');
|
|
if (second === 'r') return nav('/reports');
|
|
if (second === 't') return nav('/tasks');
|
|
}
|
|
}
|
|
|
|
// Modal-specific keyboard handling
|
|
document.addEventListener('keydown', (ev) => {
|
|
const modal = $('#commandPaletteModal');
|
|
if (!modal || modal.classList.contains('hidden')) return;
|
|
// If palette is already open, prevent re-opening via hotkeys and simply refocus input
|
|
if ((ev.ctrlKey || ev.metaKey) && (ev.key === '?' || ev.key === '/')) {
|
|
ev.preventDefault();
|
|
setTimeout(() => $('#commandPaletteInput')?.focus(), 10);
|
|
return;
|
|
}
|
|
if (ev.key === '?') {
|
|
ev.preventDefault();
|
|
setTimeout(() => $('#commandPaletteInput')?.focus(), 10);
|
|
return;
|
|
}
|
|
if (ev.key === 'Escape'){ ev.preventDefault(); closeModal(); return; }
|
|
if (ev.key === 'ArrowDown'){ ev.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, filtered.length - 1); highlightSelected(); return; }
|
|
if (ev.key === 'ArrowUp'){ ev.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); highlightSelected(); return; }
|
|
if (ev.key === 'Enter'){
|
|
ev.preventDefault();
|
|
const cmd = filtered[selectedIdx];
|
|
if (cmd){ closeModal(); setTimeout(() => cmd.action(), 50); }
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Global keydown to open palette and handle sequences
|
|
document.addEventListener('keydown', onKeyDown);
|
|
|
|
// Wire input events when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
const input = $('#commandPaletteInput');
|
|
if (input){ input.addEventListener('input', onInput); }
|
|
const closeBtn = $('#commandPaletteClose');
|
|
if (closeBtn){ closeBtn.addEventListener('click', closeModal); }
|
|
const help = $('#commandPaletteHelp');
|
|
if (help){
|
|
help.textContent = `Shortcuts: ${isMac ? '⌘' : 'Ctrl'}+K (Command Palette) · ${isMac ? '⌘' : 'Ctrl'}+/ (Search) · Shift+? (All Shortcuts) · g d (Dashboard) · g p (Projects) · g r (Reports) · g t (Tasks)`;
|
|
}
|
|
});
|
|
|
|
// Expose for programmatic access
|
|
window.openCommandPalette = openModal;
|
|
})();
|
|
|
|
|