CI / build (push) Failing after 6m2s
Introduce a Node build system for minified CSS/JS with content hashes, add PWA support, i18n helpers, accessibility improvements, and more translatable strings. Move theme configuration from bookmarks to localStorage, add Playwright tests, and set up CI workflows.
767 lines
30 KiB
JavaScript
767 lines
30 KiB
JavaScript
// =====================================================================
|
|
// 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 <mark> 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, '<mark>$1</mark>');
|
|
}
|
|
|
|
// 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 = `
|
|
<div class="search-no-results">
|
|
No ${scopeLabel} found for "<strong>${utils.escapeHtml(query)}</strong>"
|
|
</div>
|
|
`;
|
|
} else {
|
|
searchResults.innerHTML = `
|
|
<div class="search-results-empty">
|
|
<span class="search-results-hint">${getSearchHint(mode)}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
let html;
|
|
if (mode === 'tags') {
|
|
// Render tags
|
|
html = results.slice(0, 15).map((tag, index) => {
|
|
const highlightedTag = highlightMatch(utils.escapeHtml(tag), query);
|
|
return `
|
|
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
|
|
data-index="${index}"
|
|
data-tag="${utils.escapeHtml(tag)}">
|
|
<div class="search-result-item-content">
|
|
<i class="mdi mdi-tag-outline search-result-icon"></i>
|
|
<span class="search-result-text">${highlightedTag}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).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 `<span class="search-result-tag">${highlightMatch(tagValue, query)}</span>`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
|
|
data-index="${index}"
|
|
data-url="${utils.escapeHtml(item.url)}"
|
|
data-id="${item.id}">
|
|
<div class="search-result-item-content">
|
|
<i class="mdi ${typeIcon} search-result-icon"></i>
|
|
<div class="search-result-main">
|
|
<span class="search-result-text">${highlightedTitle}</span>
|
|
<span class="search-result-kind">${typeLabel}</span>
|
|
</div>
|
|
</div>
|
|
${highlightedDescription ? `<div class="search-result-sub">${highlightedDescription}</div>` : ''}
|
|
${highlightedTags ? `<div class="search-result-tags">${highlightedTags}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}).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 = '<strong>private</strong>';
|
|
} else if (isPublicActive) {
|
|
statusPart = '<strong>public</strong>';
|
|
}
|
|
|
|
if (isUntaggedActive) {
|
|
untaggedPart = '<strong>without any tag</strong>';
|
|
}
|
|
|
|
// 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 = `
|
|
<div class="filter-info-content">
|
|
<span>${message}</span>
|
|
</div>
|
|
<button type="button" class="filter-clear-btn" id="filter-clear-btn" title="${i18n.t('Clear filters')}">
|
|
<i class="mdi mdi-close"></i>
|
|
<span>${i18n.t('Clear')}</span>
|
|
</button>
|
|
`;
|
|
|
|
// 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;
|
|
}
|
|
});
|
|
}
|
|
});
|