Initial commit
This commit is contained in:
commit
983cee0320
322 changed files with 57174 additions and 0 deletions
319
app/static/js/search.js
Normal file
319
app/static/js/search.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Global Search Component
|
||||
// Provides unified search across all app content and features
|
||||
let searchTimeout;
|
||||
let currentSearchQuery = '';
|
||||
|
||||
// Initialize global search
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initGlobalSearch();
|
||||
});
|
||||
|
||||
function initGlobalSearch() {
|
||||
const searchBtn = document.getElementById('global-search-btn');
|
||||
const searchModal = document.getElementById('global-search-modal');
|
||||
const searchInput = document.getElementById('global-search-input');
|
||||
const searchResults = document.getElementById('global-search-results');
|
||||
const searchClose = document.getElementById('global-search-close');
|
||||
|
||||
if (!searchBtn || !searchModal) return;
|
||||
|
||||
// Open search modal
|
||||
searchBtn?.addEventListener('click', () => {
|
||||
searchModal.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
searchModal.classList.add('opacity-100');
|
||||
searchInput?.focus();
|
||||
}, 10);
|
||||
});
|
||||
|
||||
// Close search modal
|
||||
searchClose?.addEventListener('click', closeSearchModal);
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) {
|
||||
closeSearchModal();
|
||||
}
|
||||
|
||||
// Open search with Ctrl+K or Cmd+K
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
searchBtn?.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
searchModal?.addEventListener('click', (e) => {
|
||||
if (e.target === searchModal) {
|
||||
closeSearchModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search input
|
||||
searchInput?.addEventListener('input', (e) => {
|
||||
const query = e.target.value.trim();
|
||||
|
||||
// Clear previous timeout
|
||||
clearTimeout(searchTimeout);
|
||||
|
||||
// Show loading state
|
||||
if (query.length >= 2) {
|
||||
searchResults.innerHTML = '<div class="p-4 text-center text-text-muted dark:text-[#92adc9]">Searching...</div>';
|
||||
|
||||
// Debounce search
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(query);
|
||||
}, 300);
|
||||
} else if (query.length === 0) {
|
||||
showSearchPlaceholder();
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="p-4 text-center text-text-muted dark:text-[#92adc9]" data-translate="search.minChars">Type at least 2 characters to search</div>';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard navigation
|
||||
searchInput?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const firstResult = searchResults.querySelector('[data-search-result]');
|
||||
firstResult?.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeSearchModal() {
|
||||
const searchModal = document.getElementById('global-search-modal');
|
||||
const searchInput = document.getElementById('global-search-input');
|
||||
|
||||
searchModal?.classList.remove('opacity-100');
|
||||
setTimeout(() => {
|
||||
searchModal?.classList.add('hidden');
|
||||
searchInput.value = '';
|
||||
showSearchPlaceholder();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function showSearchPlaceholder() {
|
||||
const searchResults = document.getElementById('global-search-results');
|
||||
searchResults.innerHTML = `
|
||||
<div class="p-8 text-center">
|
||||
<span class="material-symbols-outlined text-5xl text-text-muted dark:text-[#92adc9] opacity-50 mb-3">search</span>
|
||||
<p class="text-text-muted dark:text-[#92adc9] text-sm" data-translate="search.placeholder">Search for transactions, documents, categories, or features</p>
|
||||
<p class="text-text-muted dark:text-[#92adc9] text-xs mt-2" data-translate="search.hint">Press Ctrl+K to open search</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function performSearch(query) {
|
||||
currentSearchQuery = query;
|
||||
const searchResults = document.getElementById('global-search-results');
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/api/search/?q=${encodeURIComponent(query)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
displaySearchResults(response);
|
||||
} else {
|
||||
searchResults.innerHTML = `<div class="p-4 text-center text-red-500">${response.message}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
searchResults.innerHTML = '<div class="p-4 text-center text-red-500" data-translate="search.error">Search failed. Please try again.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function displaySearchResults(response) {
|
||||
const searchResults = document.getElementById('global-search-results');
|
||||
const results = response.results;
|
||||
const userLang = localStorage.getItem('language') || 'en';
|
||||
|
||||
if (response.total_results === 0) {
|
||||
searchResults.innerHTML = `
|
||||
<div class="p-8 text-center">
|
||||
<span class="material-symbols-outlined text-5xl text-text-muted dark:text-[#92adc9] opacity-50 mb-3">search_off</span>
|
||||
<p class="text-text-muted dark:text-[#92adc9]" data-translate="search.noResults">No results found for "${response.query}"</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="flex flex-col divide-y divide-border-light dark:divide-[#233648]">';
|
||||
|
||||
// Features
|
||||
if (results.features && results.features.length > 0) {
|
||||
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.features">Features</h3><div class="flex flex-col gap-2">';
|
||||
results.features.forEach(feature => {
|
||||
const name = userLang === 'ro' ? feature.name_ro : feature.name;
|
||||
const desc = userLang === 'ro' ? feature.description_ro : feature.description;
|
||||
html += `
|
||||
<a href="${feature.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<span class="material-symbols-outlined text-primary text-xl">${feature.icon}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-text-main dark:text-white">${name}</div>
|
||||
<div class="text-xs text-text-muted dark:text-[#92adc9]">${desc}</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-text-muted text-sm">arrow_forward</span>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Expenses
|
||||
if (results.expenses && results.expenses.length > 0) {
|
||||
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.expenses">Expenses</h3><div class="flex flex-col gap-2">';
|
||||
results.expenses.forEach(expense => {
|
||||
const date = new Date(expense.date).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
|
||||
const ocrBadge = expense.ocr_match ? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="search.ocrMatch">OCR Match</span>' : '';
|
||||
html += `
|
||||
<a href="${expense.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${expense.category_color}20">
|
||||
<span class="material-symbols-outlined text-lg" style="color: ${expense.category_color}">receipt</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-text-main dark:text-white">${expense.description}</div>
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
|
||||
<span>${expense.category_name}</span>
|
||||
<span>•</span>
|
||||
<span>${date}</span>
|
||||
${ocrBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-text-main dark:text-white">${formatCurrency(expense.amount, expense.currency)}</div>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Documents
|
||||
if (results.documents && results.documents.length > 0) {
|
||||
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.documents">Documents</h3><div class="flex flex-col gap-2">';
|
||||
results.documents.forEach(doc => {
|
||||
const date = new Date(doc.created_at).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
|
||||
const ocrBadge = doc.ocr_match ? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="search.ocrMatch">OCR Match</span>' : '';
|
||||
const fileIcon = doc.file_type === 'PDF' ? 'picture_as_pdf' : 'image';
|
||||
html += `
|
||||
<button onclick="openDocumentFromSearch(${doc.id}, '${doc.file_type}', '${escapeHtml(doc.filename)}')" data-search-result tabindex="0" class="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary text-left">
|
||||
<span class="material-symbols-outlined text-primary text-xl">${fileIcon}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-text-main dark:text-white truncate">${doc.filename}</div>
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
|
||||
<span>${doc.file_type}</span>
|
||||
<span>•</span>
|
||||
<span>${date}</span>
|
||||
${ocrBadge}
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-text-muted text-sm">visibility</span>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Categories
|
||||
if (results.categories && results.categories.length > 0) {
|
||||
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.categories">Categories</h3><div class="flex flex-col gap-2">';
|
||||
results.categories.forEach(category => {
|
||||
html += `
|
||||
<a href="${category.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${category.color}20">
|
||||
<span class="material-symbols-outlined text-lg" style="color: ${category.color}">${category.icon}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-text-main dark:text-white">${category.name}</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-text-muted text-sm">arrow_forward</span>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Recurring Expenses
|
||||
if (results.recurring && results.recurring.length > 0) {
|
||||
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.recurring">Recurring</h3><div class="flex flex-col gap-2">';
|
||||
results.recurring.forEach(rec => {
|
||||
const nextDue = new Date(rec.next_due_date).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
|
||||
const statusBadge = rec.is_active
|
||||
? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="recurring.active">Active</span>'
|
||||
: '<span class="text-xs bg-gray-100 dark:bg-gray-800/30 text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded" data-translate="recurring.inactive">Inactive</span>';
|
||||
html += `
|
||||
<a href="${rec.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${rec.category_color}20">
|
||||
<span class="material-symbols-outlined text-lg" style="color: ${rec.category_color}">repeat</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-text-main dark:text-white">${rec.name}</div>
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
|
||||
<span>${rec.category_name}</span>
|
||||
<span>•</span>
|
||||
<span data-translate="recurring.nextDue">Next:</span>
|
||||
<span>${nextDue}</span>
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-text-main dark:text-white">${formatCurrency(rec.amount, rec.currency)}</div>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
searchResults.innerHTML = html;
|
||||
|
||||
// Apply translations
|
||||
if (window.applyTranslations) {
|
||||
window.applyTranslations();
|
||||
}
|
||||
|
||||
// Handle keyboard navigation between results
|
||||
const resultElements = searchResults.querySelectorAll('[data-search-result]');
|
||||
resultElements.forEach((element, index) => {
|
||||
element.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
resultElements[index + 1]?.focus();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (index === 0) {
|
||||
document.getElementById('global-search-input')?.focus();
|
||||
} else {
|
||||
resultElements[index - 1]?.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Open document viewer from search
|
||||
function openDocumentFromSearch(docId, fileType, filename) {
|
||||
// Close search modal
|
||||
closeSearchModal();
|
||||
|
||||
// Navigate to documents page and open viewer
|
||||
if (window.location.pathname !== '/documents') {
|
||||
// Store document to open after navigation
|
||||
sessionStorage.setItem('openDocumentId', docId);
|
||||
sessionStorage.setItem('openDocumentType', fileType);
|
||||
sessionStorage.setItem('openDocumentName', filename);
|
||||
window.location.href = '/documents';
|
||||
} else {
|
||||
// Already on documents page, open directly
|
||||
if (typeof viewDocument === 'function') {
|
||||
viewDocument(docId, fileType, filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to escape HTML
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue