Add asset pipeline and professionalize theme
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.
This commit is contained in:
2026-08-09 09:25:03 -04:00
parent 10af62dd6c
commit f753b27542
68 changed files with 4743 additions and 1989 deletions
+766
View File
@@ -0,0 +1,766 @@
// =====================================================================
// 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;
}
});
}
});