// ===================================================================== // Search overlay and filter panel for Shaarli Pro. // ===================================================================== document.addEventListener('DOMContentLoaded', () => { 'use strict'; const utils = window.ShaarliProUtils || { escapeHtml: function (text) { if (text === null || text === undefined) return ''; const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; }, isTagHidden: function () { return false; }, filterHiddenTags: function (tags) { return tags || []; } }; const i18n = window.ShaarliProI18n || { t: function (k) { return k; } }; const a11y = window.ShaarliProA11y || { focusFirst: function () {}, trapFocus: function () { return function () {}; } }; let searchFocusCleanup = null; let filterFocusCleanup = null; // ===== Search Overlay (Spotlight Style) ===== try { const searchOverlay = document.getElementById('search-overlay'); const searchToggleBtns = document.querySelectorAll('[data-search-toggle]'); const searchModalInput = document.getElementById('search-modal-input'); const searchResults = document.getElementById('search-results'); const searchTagsBtn = document.getElementById('search-tags-btn'); const searchAllBtn = document.getElementById('search-all-btn'); const searchNotesBtn = document.getElementById('search-notes-btn'); const searchForm = document.getElementById('search-form'); let searchMode = 'search'; // 'search' | 'tags' | 'notes' let selectedIndex = -1; let searchTimeout = null; let cachedBookmarks = null; let cachedTags = null; // Highlight matching text with tags function highlightMatch(text, query) { if (!query || query.length === 0) return utils.escapeHtml(text); // Escape HTML first to prevent XSS const escapedText = utils.escapeHtml(text); // Escape special regex characters in query const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${escapedQuery})`, 'gi'); return escapedText.replace(regex, '$1'); } // Fuzzy search - matches substring anywhere in text function fuzzyMatch(text, query) { if (!query || query.length === 0) return true; return text.toLowerCase().includes(query.toLowerCase()); } // Fetch all unique tags from the page (excluding hidden tags) function fetchTags() { if (cachedTags) return cachedTags; try { const tagsSet = new Set(); const addTag = (value) => { if (typeof value !== 'string') return; const cleaned = value.trim(); if (!cleaned || cleaned.includes('•')) return; // Skip hidden tags if (utils.isTagHidden(cleaned)) return; tagsSet.add(cleaned); }; // Prefer explicit tag data attributes to avoid polluted text values (e.g. remove buttons) document.querySelectorAll('.link-tag[data-tag], .tag-item[data-tag], [data-tag-name]').forEach((el) => { addTag(el.getAttribute('data-tag') || el.getAttribute('data-tag-name')); }); // Fallback selectors with reliable text content document.querySelectorAll('.link-tag-link, .tag-link, .cloud-tag').forEach((el) => { addTag(el.textContent); }); // Convert to array and sort alphabetically cachedTags = Array.from(tagsSet).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); return cachedTags; } catch (e) { console.error('Failed to fetch tags:', e); return []; } } // Fetch bookmarks for live search async function fetchBookmarks() { if (cachedBookmarks) return cachedBookmarks; try { // Try to get bookmarks from page (if already loaded) const linkElements = document.querySelectorAll('.link-outer'); if (linkElements.length > 0) { cachedBookmarks = Array.from(linkElements).map(el => ({ id: el.dataset.id, isNote: (el.querySelector('.link-title')?.getAttribute('title') || '').toLowerCase().includes('note'), title: el.querySelector('.link-title a')?.textContent || el.querySelector('.link-title')?.textContent || '', url: el.querySelector('.link-url a')?.href || el.querySelector('.link-title a')?.href || '', // Filter out hidden tags tags: utils.filterHiddenTags( Array.from(el.querySelectorAll('.link-tag[data-tag]')).map(t => (t.getAttribute('data-tag') || '').trim()).filter(Boolean) ), description: el.querySelector('.link-description')?.textContent || '' })); return cachedBookmarks; } } catch (e) { console.error('Failed to fetch bookmarks:', e); } return []; } function getSearchHint(mode) { var i18n = window.ShaarliProI18n || { t: function (k) { return k; } }; if (mode === 'tags') { return i18n.t('Search in tags...'); } if (mode === 'notes') { return i18n.t('Search in notes...'); } return i18n.t('Search in bookmarks and notes...'); } // Render search results (tags, global bookmarks, or notes) function renderResults(results, query, mode = 'search') { if (!searchResults) return; if (results.length === 0) { if (query && query.length > 0) { const scopeLabel = mode === 'tags' ? 'tags' : (mode === 'notes' ? 'notes' : 'bookmarks/notes'); searchResults.innerHTML = `
No ${scopeLabel} found for "${utils.escapeHtml(query)}"
`; } else { searchResults.innerHTML = `
${getSearchHint(mode)}
`; } return; } let html; if (mode === 'tags') { // Render tags html = results.slice(0, 15).map((tag, index) => { const highlightedTag = highlightMatch(utils.escapeHtml(tag), query); return `
${highlightedTag}
`; }).join(''); } else { // Render bookmarks or notes html = results.slice(0, 12).map((item, index) => { const highlightedTitle = highlightMatch(utils.escapeHtml(item.title), query); const highlightedDescription = item.description ? highlightMatch(utils.escapeHtml(item.description), query) : ''; const typeLabel = item.isNote ? 'note' : 'bookmark'; const typeIcon = item.isNote ? 'mdi-note-text-outline' : 'mdi-bookmark-outline'; const highlightedTags = (item.tags || []).slice(0, 4).map((tag) => { const tagValue = utils.escapeHtml(tag); return `${highlightMatch(tagValue, query)}`; }).join(''); return `
${highlightedTitle} ${typeLabel}
${highlightedDescription ? `
${highlightedDescription}
` : ''} ${highlightedTags ? `
${highlightedTags}
` : ''}
`; }).join(''); } searchResults.innerHTML = html; // Add click handlers to results searchResults.querySelectorAll('.search-result-item').forEach(item => { item.addEventListener('click', () => { if (mode === 'tags') { const tag = item.dataset.tag; if (tag) { // Navigate to tag search window.location.href = shaarli.basePath + '/?searchtags=' + encodeURIComponent(tag); } } else { const url = item.dataset.url; if (url) { window.location.href = url; } } }); }); } // Perform live search async function performSearch(query) { const normalizedQuery = (query || '').trim(); if (searchMode === 'tags') { const tags = fetchTags(); if (!normalizedQuery) { renderResults(tags.slice(0, 15), '', 'tags'); return; } const tagResults = tags.filter(tag => fuzzyMatch(tag, normalizedQuery)); renderResults(tagResults, normalizedQuery, 'tags'); return; } const bookmarks = await fetchBookmarks(); const searchPool = searchMode === 'notes' ? bookmarks.filter(item => item.isNote) : bookmarks; if (!normalizedQuery) { renderResults(searchPool.slice(0, 12), '', searchMode); return; } const results = searchPool.filter((item) => { const haystack = [ item.title, item.url, item.description, (item.tags || []).join(' ') ].join(' ').toLowerCase(); return haystack.includes(normalizedQuery.toLowerCase()); }); renderResults(results, normalizedQuery, searchMode); } // Update selected result function updateSelection(newIndex) { const items = searchResults?.querySelectorAll('.search-result-item'); if (!items || items.length === 0) return; // Clamp index if (newIndex < 0) newIndex = items.length - 1; if (newIndex >= items.length) newIndex = 0; selectedIndex = newIndex; items.forEach((item, index) => { item.classList.toggle('selected', index === selectedIndex); }); // Scroll into view items[selectedIndex]?.scrollIntoView({ block: 'nearest' }); } // Navigate to selected result function navigateToSelected() { const selected = searchResults?.querySelector('.search-result-item.selected'); if (selected) { // Check if it's a tag const tag = selected.dataset.tag; if (tag) { window.location.href = shaarli.basePath + '/?searchtags=' + encodeURIComponent(tag); return true; } // Otherwise check for URL const url = selected.dataset.url; if (url) { window.location.href = url; return true; } } return false; } function openSearch(mode = 'search') { searchOverlay?.classList.add('show'); selectedIndex = -1; setTimeout(() => { setSearchMode(mode); searchModalInput?.focus(); searchFocusCleanup = a11y.trapFocus(searchOverlay); }, 100); } function closeSearch() { searchOverlay?.classList.remove('show'); selectedIndex = -1; if (typeof searchFocusCleanup === 'function') { searchFocusCleanup(); searchFocusCleanup = null; } } function submitSearchByMode() { const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : ''; const query = (searchModalInput?.value || '').trim(); if (selectedIndex >= 0 && navigateToSelected()) { return; } if (!query) { if (searchMode === 'tags') { window.location.href = `${basePath}/tags/cloud`; return; } window.location.href = `${basePath}/`; return; } if (searchMode === 'tags') { window.location.href = `${basePath}/?searchtags=${encodeURIComponent(query)}`; return; } if (searchMode === 'notes') { window.location.href = `${basePath}/?searchterm=${encodeURIComponent(query)}&searchtags=note`; return; } window.location.href = `${basePath}/?searchterm=${encodeURIComponent(query)}`; } // Toggle search mode (tags vs search) function setSearchMode(mode) { if (!['search', 'tags', 'notes'].includes(mode)) { mode = 'search'; } searchMode = mode; searchAllBtn?.classList.toggle('active', mode === 'search'); searchTagsBtn?.classList.toggle('active', mode === 'tags'); searchNotesBtn?.classList.toggle('active', mode === 'notes'); searchAllBtn?.setAttribute('aria-pressed', String(mode === 'search')); searchTagsBtn?.setAttribute('aria-pressed', String(mode === 'tags')); searchNotesBtn?.setAttribute('aria-pressed', String(mode === 'notes')); searchAllBtn?.setAttribute('aria-selected', String(mode === 'search')); searchTagsBtn?.setAttribute('aria-selected', String(mode === 'tags')); searchNotesBtn?.setAttribute('aria-selected', String(mode === 'notes')); if (searchModalInput) { searchModalInput.name = mode === 'tags' ? 'searchtags' : 'searchterm'; searchModalInput.placeholder = getSearchHint(mode); // Re-run search with new mode performSearch(searchModalInput.value); } } searchToggleBtns.forEach((btn) => { btn.addEventListener('click', () => openSearch('search')); }); // Close search on overlay click searchOverlay?.addEventListener('click', (e) => { if (e.target === searchOverlay) { closeSearch(); } }); // Search mode toggle buttons searchTagsBtn?.addEventListener('click', (e) => { e.preventDefault(); setSearchMode('tags'); }); searchAllBtn?.addEventListener('click', (e) => { e.preventDefault(); setSearchMode('search'); }); searchNotesBtn?.addEventListener('click', (e) => { e.preventDefault(); setSearchMode('notes'); }); searchForm?.addEventListener('submit', (e) => { e.preventDefault(); submitSearchByMode(); }); // Live search on input searchModalInput?.addEventListener('input', (e) => { const query = e.target.value; // Debounce search clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { selectedIndex = -1; performSearch(query); }, 150); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { const isSearchOpen = searchOverlay?.classList.contains('show'); const target = e.target; const isTyping = Boolean( target && ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable || (typeof target.closest === 'function' && target.closest('.toastui-editor-defaultUI, .toastui-editor-main, .toastui-editor-contents, .CodeMirror')) ) ); // Handle ESC - always close search/filter if (e.key === 'Escape') { if (isSearchOpen) { closeSearch(); e.preventDefault(); } closeFilterPanel(); return; } // If search is open, handle navigation if (isSearchOpen && e.target === searchModalInput) { switch (e.key) { case 'ArrowDown': e.preventDefault(); updateSelection(selectedIndex + 1); break; case 'ArrowUp': e.preventDefault(); updateSelection(selectedIndex - 1); break; case 'Enter': e.preventDefault(); submitSearchByMode(); break; } return; } // S to open search (when not typing) if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 's' || e.key === 'S')) { e.preventDefault(); openSearch('search'); } // T to open search directly in tags mode (when not typing) if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 't' || e.key === 'T')) { e.preventDefault(); openSearch('tags'); } // N to open search directly in notes mode (when not typing) if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 'n' || e.key === 'N')) { e.preventDefault(); openSearch('notes'); } }); } catch (error) { console.error('Search overlay initialization failed:', error); } // ===== Filter Panel ===== const filterToggleBtn = document.getElementById('filter-toggle-btn'); const filterPanel = document.getElementById('filter-panel'); const filterCloseBtn = document.getElementById('filter-close-btn'); const filterPrivate = document.getElementById('filter-private'); const filterPublic = document.getElementById('filter-public'); const filterUntagged = document.getElementById('filter-untagged'); function toggleFilterPanel() { if (!filterPanel) return; const isOpen = filterPanel.classList.toggle('show'); filterPanel.setAttribute('aria-hidden', String(!isOpen)); filterToggleBtn?.setAttribute('aria-expanded', String(isOpen)); if (isOpen) { a11y.focusFirst(filterPanel); filterFocusCleanup = a11y.trapFocus(filterPanel); } else { if (typeof filterFocusCleanup === 'function') { filterFocusCleanup(); filterFocusCleanup = null; } } } function closeFilterPanel() { if (!filterPanel) return; filterPanel.classList.remove('show'); filterPanel.setAttribute('aria-hidden', 'true'); filterToggleBtn?.setAttribute('aria-expanded', 'false'); if (typeof filterFocusCleanup === 'function') { filterFocusCleanup(); filterFocusCleanup = null; } } filterToggleBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFilterPanel(); }); filterCloseBtn?.addEventListener('click', closeFilterPanel); // Close filter when clicking outside document.addEventListener('click', (e) => { if (filterPanel?.classList.contains('show')) { const clickedToggle = filterToggleBtn && (e.target === filterToggleBtn || filterToggleBtn.contains(e.target)); if (!filterPanel.contains(e.target) && !clickedToggle) { closeFilterPanel(); } } }); // Handle filter toggle switches // Logic: // - Visibility: all (neither checked), private (only private checked), public (only public checked) // - Untagged: can be combined with any visibility function applyFilters() { const isPrivate = filterPrivate?.checked || false; const isPublic = filterPublic?.checked || false; const isUntagged = filterUntagged?.checked || false; let basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : ''; // Get current filter state from server-side rendered variables const currentVisibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : ''; const currentUntagged = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) || false; let url = basePath + '/'; // Determine desired visibility let desiredVisibility = 'all'; if (isPrivate) { desiredVisibility = 'private'; } else if (isPublic) { desiredVisibility = 'public'; } // Build URL based on desired state if (desiredVisibility === 'private') { url = basePath + '/admin/visibility/private'; } else if (desiredVisibility === 'public') { url = basePath + '/admin/visibility/public'; } else { // visibility = all - need to clear visibility if it was set if (currentVisibility && currentVisibility !== '') { url = basePath + '/admin/visibility/all'; } else if (isUntagged !== currentUntagged) { // Untagged state changed - /untagged-only works as toggle url = basePath + '/untagged-only'; } else { url = basePath + '/'; } } window.location.href = url; } // Clear all filters - go back to showing all bookmarks function clearAllFilters() { const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : ''; const currentVisibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : ''; const currentUntagged = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) || false; // If visibility is set, clear it first if (currentVisibility && currentVisibility !== '') { window.location.href = basePath + '/admin/visibility/all'; } else if (currentUntagged) { // Toggle untagged off using /untagged-only window.location.href = basePath + '/untagged-only'; } else { window.location.href = basePath + '/'; } } // Private and public are mutually exclusive filterPrivate?.addEventListener('change', (e) => { if (e.target.checked && filterPublic) { filterPublic.checked = false; } applyFilters(); }); filterPublic?.addEventListener('change', (e) => { if (e.target.checked && filterPrivate) { filterPrivate.checked = false; } applyFilters(); }); filterUntagged?.addEventListener('change', () => { applyFilters(); }); // Initialize filter states from server-side variables (set in includes.html) (function initFilterStates() { // Get filter state from server-side rendered variables const visibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : ''; const untaggedonly = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) ? shaarli.untaggedonly : false; // Detect active filters from server variables let isPrivateActive = visibility === 'private'; let isPublicActive = visibility === 'public'; let isUntaggedActive = untaggedonly === true; // Set checkbox states if (filterPrivate && isPrivateActive) { filterPrivate.checked = true; } if (filterPublic && isPublicActive) { filterPublic.checked = true; } if (filterUntagged && isUntaggedActive) { filterUntagged.checked = true; } const hasActiveFilter = isPrivateActive || isPublicActive || isUntaggedActive; // Add/update filter indicator badge on the filter button if (filterToggleBtn && hasActiveFilter) { filterToggleBtn.classList.add('has-active-filter'); // Add badge indicator if not exists if (!filterToggleBtn.querySelector('.filter-badge')) { const badge = document.createElement('span'); badge.className = 'filter-badge'; filterToggleBtn.appendChild(badge); } } // Create and display the filter info banner if (hasActiveFilter && !document.getElementById('filter-info-banner')) { // Get result count from page - try multiple sources let resultCount = 0; const pagingStats = document.querySelector('.paging-stats strong:last-child'); const linkCount = document.querySelectorAll('.link-outer').length; if (pagingStats) { resultCount = parseInt(pagingStats.textContent) || 0; } else if (linkCount > 0) { resultCount = linkCount; } // Check if no results (empty state) const emptyState = document.querySelector('.empty-state'); const isEmptyResults = emptyState !== null || resultCount === 0; // Build the message like the examples: // "5 results without any tag" // "5 results with status private without any tag" // "1 result with status public without any tag" let statusPart = ''; let untaggedPart = ''; if (isPrivateActive) { statusPart = 'private'; } else if (isPublicActive) { statusPart = 'public'; } if (isUntaggedActive) { untaggedPart = 'without any tag'; } // Build the message let message = ''; var i18n = window.ShaarliProI18n || { t: function (k) { return k; } }; if (isEmptyResults) { message = i18n.t('Nothing found.'); } else { const resultWord = resultCount === 1 ? i18n.t('result') : i18n.t('results'); const withStatus = i18n.t('with status'); if (statusPart && untaggedPart) { message = `${resultCount} ${resultWord} ${withStatus} ${statusPart} ${untaggedPart}`; } else if (statusPart) { message = `${resultCount} ${resultWord} ${withStatus} ${statusPart}`; } else if (untaggedPart) { message = `${resultCount} ${resultWord} ${untaggedPart}`; } } if (message) { // Get base path safely const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : ''; const banner = document.createElement('div'); banner.id = 'filter-info-banner'; banner.className = 'filter-info-banner' + (isEmptyResults ? ' empty-results' : ''); banner.innerHTML = `
${message}
`; // Try to find the best insertion point const contentToolbar = document.querySelector('.content-toolbar'); const linklist = document.getElementById('linklist'); const emptyStateDiv = document.querySelector('.empty-state'); // Insert after the content-toolbar (pagination) if (contentToolbar && contentToolbar.parentNode) { contentToolbar.parentNode.insertBefore(banner, contentToolbar.nextSibling); } else if (emptyStateDiv && emptyStateDiv.parentNode) { emptyStateDiv.parentNode.insertBefore(banner, emptyStateDiv); } else if (linklist) { linklist.insertBefore(banner, linklist.firstChild); } else { } // Add click handler to clear button document.getElementById('filter-clear-btn')?.addEventListener('click', () => { // Clear based on what was active if (isPrivateActive || isPublicActive) { window.location.href = basePath + '/admin/visibility/all'; } else if (isUntaggedActive) { // Toggle untagged off window.location.href = basePath + '/untagged-only'; } else { window.location.href = basePath + '/'; } }); } } })(); // Handle links per page options // Handle custom value form submission const filterInput = document.querySelector('.filter-input'); if (filterInput) { filterInput.closest('form')?.addEventListener('submit', (e) => { e.preventDefault(); const value = filterInput.value; if (value && value > 0) { window.location.href = shaarli.basePath + '/links-per-page?nb=' + value; } }); } });