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
+66
View File
@@ -0,0 +1,66 @@
// =====================================================================
// Accessibility helpers for Shaarli Pro modals and overlays.
// =====================================================================
(function (root) {
'use strict';
const FOCUSABLE_SELECTORS = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
/**
* Return a focusable element inside a container.
*/
function getFocusable(container, first) {
if (!container) return null;
const elements = Array.prototype.slice.call(container.querySelectorAll(FOCUSABLE_SELECTORS));
return first ? elements[0] : elements[elements.length - 1];
}
/**
* Move focus to the first focusable element inside a container.
*/
function focusFirst(container) {
const el = getFocusable(container, true);
if (el) el.focus();
}
/**
* Keep keyboard focus inside a container while it is open.
* Returns a cleanup function that removes the listener.
*/
function trapFocus(container) {
if (!container) return function () {};
function handleKey(e) {
if (e.key !== 'Tab') return;
const first = getFocusable(container, true);
const last = getFocusable(container, false);
if (!first || !last) return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
container.addEventListener('keydown', handleKey);
return function () {
container.removeEventListener('keydown', handleKey);
};
}
root.ShaarliProA11y = {
focusFirst: focusFirst,
trapFocus: trapFocus,
getFocusable: getFocusable
};
}(window));
+275
View File
@@ -0,0 +1,275 @@
// =====================================================================
// Bulk actions (multi-select) for Shaarli Pro.
// =====================================================================
document.addEventListener('DOMContentLoaded', () => {
'use strict';
const i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
const selectModeBtn = document.getElementById('select-mode-btn');
const bulkActionsBar = document.getElementById('bulk-actions-bar');
const bulkCount = document.getElementById('bulk-count');
const bulkSelectAll = document.getElementById('bulk-select-all');
const bulkCancel = document.getElementById('bulk-cancel');
const bulkDelete = document.getElementById('bulk-delete');
const bulkPublic = document.getElementById('bulk-public');
const bulkPrivate = document.getElementById('bulk-private');
const bulkModal = document.getElementById('bulk-confirm-modal');
const bulkModalTitle = document.getElementById('bulk-modal-title');
const bulkModalSubtitle = document.getElementById('bulk-modal-subtitle');
const bulkModalList = document.getElementById('bulk-modal-list');
const bulkModalConfirm = document.getElementById('bulk-modal-confirm');
const bulkModalCancel = document.getElementById('bulk-modal-cancel');
const bulkModalClose = document.getElementById('bulk-modal-close');
let selectionMode = false;
let selectedIds = new Set();
let pendingBulkAction = null;
let bulkFocusCleanup = null;
const a11y = window.ShaarliProA11y || { focusFirst: function () {}, trapFocus: function () { return function () {}; } };
const BULK_ACTION_COPY = {
delete: {
title: (count) => i18n.t('Are you sure to delete {count} link{plural}?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be irretrievably deleted:'),
confirm: (count) => i18n.t('DELETE {count} LINK{plural}', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-delete'
},
public: {
title: (count) => i18n.t('Are you sure to set {count} link{plural} public?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be set as public:'),
confirm: (count) => i18n.t('SET {count} LINK{plural} PUBLIC', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-public'
},
private: {
title: (count) => i18n.t('Are you sure to set {count} link{plural} private?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be set as private:'),
confirm: (count) => i18n.t('SET {count} LINK{plural} PRIVATE', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-private'
}
};
function updateBulkUI() {
if (bulkCount) {
bulkCount.textContent = selectedIds.size;
}
document.querySelectorAll('.link-outer').forEach(card => {
const id = card.dataset.id;
if (selectedIds.has(id)) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
document.querySelectorAll('.link-checkbox').forEach(cb => {
cb.checked = selectedIds.has(cb.dataset.id);
});
}
function enterSelectionMode() {
selectionMode = true;
document.body.classList.add('selection-mode');
bulkActionsBar?.classList.add('show');
selectModeBtn?.classList.add('active');
}
function exitSelectionMode() {
selectionMode = false;
selectedIds.clear();
document.body.classList.remove('selection-mode');
bulkActionsBar?.classList.remove('show');
selectModeBtn?.classList.remove('active');
updateBulkUI();
}
function toggleSelection(id) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
if (selectedIds.size > 0 && !selectionMode) {
enterSelectionMode();
} else if (selectedIds.size === 0 && selectionMode) {
exitSelectionMode();
}
updateBulkUI();
}
selectModeBtn?.addEventListener('click', () => {
if (selectionMode) {
exitSelectionMode();
} else {
enterSelectionMode();
}
});
bulkCancel?.addEventListener('click', exitSelectionMode);
bulkSelectAll?.addEventListener('click', () => {
document.querySelectorAll('.link-outer').forEach(card => {
if (card.dataset.id) {
selectedIds.add(card.dataset.id);
}
});
if (!selectionMode && selectedIds.size > 0) {
enterSelectionMode();
}
updateBulkUI();
});
document.addEventListener('change', (e) => {
if (e.target.classList.contains('link-checkbox')) {
toggleSelection(e.target.dataset.id);
}
});
document.addEventListener('click', (e) => {
if (!selectionMode) return;
const card = e.target.closest('.link-outer');
if (card && card.dataset.id) {
if (e.target.closest('.link-actions') ||
e.target.closest('.link-hover-actions') ||
e.target.tagName === 'A' ||
e.target.tagName === 'INPUT' ||
e.target.closest('label')) {
return;
}
toggleSelection(card.dataset.id);
}
});
function getSelectedIds() {
return Array.from(selectedIds);
}
function populateBulkModalList(ids) {
if (!bulkModalList) return;
bulkModalList.innerHTML = '';
ids.forEach((id) => {
const card = document.querySelector(`.link-outer[data-id="${id}"]`);
const title = card?.querySelector('.link-title')?.textContent?.trim() ||
card?.querySelector('.link-url')?.textContent?.trim() ||
i18n.t('Untitled link');
const item = document.createElement('div');
item.className = 'bulk-modal-item';
const idSpan = document.createElement('span');
idSpan.className = 'bulk-modal-item-id';
idSpan.textContent = `#${id}`;
const titleSpan = document.createElement('span');
titleSpan.className = 'bulk-modal-item-title';
titleSpan.textContent = title;
item.appendChild(idSpan);
item.appendChild(titleSpan);
bulkModalList.appendChild(item);
});
}
function openBulkModal(actionType) {
if (!bulkModal || selectedIds.size === 0) return;
const meta = BULK_ACTION_COPY[actionType];
if (!meta) return;
const ids = getSelectedIds();
pendingBulkAction = actionType;
if (bulkModalTitle) {
bulkModalTitle.textContent = meta.title(ids.length);
}
if (bulkModalSubtitle) {
bulkModalSubtitle.textContent = meta.subtitle;
}
if (bulkModalConfirm) {
bulkModalConfirm.textContent = meta.confirm(ids.length);
bulkModalConfirm.className = `bulk-modal-btn bulk-modal-confirm ${meta.confirmClass}`;
}
populateBulkModalList(ids);
bulkModal.classList.add('show');
bulkModal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
a11y.focusFirst(bulkModal);
bulkFocusCleanup = a11y.trapFocus(bulkModal);
}
function closeBulkModal() {
if (!bulkModal) return;
pendingBulkAction = null;
bulkModal.classList.remove('show');
bulkModal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (typeof bulkFocusCleanup === 'function') {
bulkFocusCleanup();
bulkFocusCleanup = null;
}
}
function buildIdsParam(ids) {
return ids.map(id => encodeURIComponent(id)).join('+');
}
function executeBulkAction() {
if (!pendingBulkAction || selectedIds.size === 0) return;
const ids = getSelectedIds();
const token = (window.shaarli?.token || '').trim() ||
document.querySelector('input[name="token"]')?.value || '';
if (!token) {
alert(i18n.t('Missing security token. Please refresh the page and try again.'));
return;
}
const basePath = window.shaarli?.basePath || '';
const idsParam = buildIdsParam(ids);
let targetUrl = '';
if (pendingBulkAction === 'delete') {
targetUrl = `${basePath}/admin/shaare/delete?id=${idsParam}&token=${encodeURIComponent(token)}`;
} else {
const visibility = pendingBulkAction === 'public' ? 'public' : 'private';
targetUrl = `${basePath}/admin/shaare/visibility?token=${encodeURIComponent(token)}&newVisibility=${visibility}&id=${idsParam}`;
}
closeBulkModal();
window.location.href = targetUrl;
}
bulkDelete?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('delete');
});
bulkPublic?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('public');
});
bulkPrivate?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('private');
});
bulkModalConfirm?.addEventListener('click', executeBulkAction);
bulkModalCancel?.addEventListener('click', closeBulkModal);
bulkModalClose?.addEventListener('click', closeBulkModal);
bulkModal?.addEventListener('click', (event) => {
if (event.target === bulkModal) {
closeBulkModal();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && bulkModal?.classList.contains('show')) {
closeBulkModal();
}
});
});
+95
View File
@@ -0,0 +1,95 @@
// =====================================================================
// Navigation helpers for Shaarli Pro:
// - Add note/todo shortcut buttons in the sidebar.
// - Mobile sidebar toggle.
// =====================================================================
document.addEventListener('DOMContentLoaded', () => {
'use strict';
// ===== Add Note Button Handler (Android convention) =====
const addNoteBtn = document.querySelector('.sidebar-add-note');
if (addNoteBtn) {
addNoteBtn.addEventListener('click', (e) => {
e.preventDefault();
if (!window.ShaarItRules) {
// eslint-disable-next-line no-console
console.warn('[shaarit] ShaarItRules not available, cannot generate note URL');
return;
}
const basePath = addNoteBtn.getAttribute('data-base-path') || '';
const noteUrl = window.ShaarItRules.generateNoteUrl();
const redirectUrl = `${basePath}/admin/shaare?post=${encodeURIComponent(noteUrl)}&tags=note`;
window.location.href = redirectUrl;
});
}
// ===== Add Todo Button Handler (Android convention) =====
const addTodoBtn = document.querySelector('.sidebar-add-todo');
if (addTodoBtn) {
addTodoBtn.addEventListener('click', (e) => {
e.preventDefault();
if (!window.ShaarItRules) {
// eslint-disable-next-line no-console
console.warn('[shaarit] ShaarItRules not available, cannot generate todo URL');
return;
}
const basePath = addTodoBtn.getAttribute('data-base-path') || '';
const todoUrl = window.ShaarItRules.generateTodoUrl();
const redirectUrl = `${basePath}/admin/shaare?post=${encodeURIComponent(todoUrl)}&tags=todo&title=%E2%9C%85%20`;
window.location.href = redirectUrl;
});
}
// ===== Mobile Sidebar Toggle =====
const sidebar = document.getElementById('sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
function isMobileViewport() {
return window.matchMedia('(max-width: 1100px)').matches;
}
function openSidebar() {
if (!sidebar || !sidebarOverlay) return;
sidebar.classList.add('show');
sidebarOverlay.classList.add('show');
mobileMenuBtn?.setAttribute('aria-expanded', 'true');
sidebarOverlay.setAttribute('aria-hidden', 'false');
if (isMobileViewport()) {
document.body.style.overflow = 'hidden';
}
}
function closeSidebar() {
if (!sidebar || !sidebarOverlay) return;
sidebar.classList.remove('show');
sidebarOverlay.classList.remove('show');
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
sidebarOverlay.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function toggleSidebar() {
if (sidebar?.classList.contains('show')) {
closeSidebar();
return;
}
openSidebar();
}
mobileMenuBtn?.addEventListener('click', toggleSidebar);
sidebarOverlay?.addEventListener('click', closeSidebar);
sidebar?.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => {
if (isMobileViewport()) {
closeSidebar();
}
});
});
window.addEventListener('resize', () => {
if (!isMobileViewport()) {
closeSidebar();
}
});
});
+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;
}
});
}
});
+122
View File
@@ -0,0 +1,122 @@
// =====================================================================
// Theme configuration and dark/light toggle for Shaarli Pro.
// Keeps theme/mode preferences client-side without creating bookmarks.
// Used by the dark/light switch AND the theme picker (tools.html).
// =====================================================================
(function () {
'use strict';
const STORAGE_KEY = 'shaarit_themes_config';
function readConfig() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object') ? parsed : null;
} catch (e) {
return null;
}
}
function writeConfig(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
return true;
} catch (e) {
return false;
}
}
// Merge partial updates into the persisted config and save to localStorage.
function save(partial) {
const base = readConfig() || {};
const config = Object.assign(
{ version: 2, themes: [], default: 'DEFAULT', mode: 'dark' },
base,
partial || {}
);
const ok = writeConfig(config);
return Promise.resolve({ ok, config });
}
// Reads the current persisted config from localStorage (no write).
function load() {
return Promise.resolve(readConfig());
}
// Kept for backward compatibility with older callers.
function findCandidates() {
return Promise.resolve([]);
}
window.ShaaritThemeConfig = { save, load, findCandidates };
})();
document.addEventListener('DOMContentLoaded', () => {
'use strict';
const themeCheckbox = document.getElementById('theme-toggle-checkbox');
const themeIconLight = document.getElementById('theme-icon-light');
const themeLabelSpan = document.querySelector('.theme-toggle-label span');
const i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
function checkThemeRestrictions() {
const themeId = localStorage.getItem('shaarit_theme_id') || 'DEFAULT';
const darkOnlyThemes = ['LINEAR', 'SPOTIFY', 'NOTION', 'DISCORD', 'DRACULA', 'ONE_DARK_PRO', 'TOKYO_NIGHT', 'NORD', 'NIGHT_OWL', 'ANTHRACITE', 'CYBERPUNK', 'NAVY_ELEGANCE', 'EARTHY'];
return darkOnlyThemes.indexOf(themeId) !== -1;
}
function updateTheme(theme) {
// Enforce dark mode if the theme only supports dark
if (checkThemeRestrictions()) {
theme = 'dark';
}
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
if (themeCheckbox) {
themeCheckbox.checked = theme === 'dark';
// Disable the checkbox if restricted to dark mode
themeCheckbox.disabled = checkThemeRestrictions();
themeCheckbox.closest('.theme-switch').style.opacity = themeCheckbox.disabled ? '0.5' : '1';
}
if (themeIconLight) {
themeIconLight.className = theme === 'dark' ? 'mdi mdi-weather-night' : 'mdi mdi-weather-sunny';
}
if (themeLabelSpan) {
themeLabelSpan.textContent = theme === 'dark' ? i18n.t('Dark Mode') : i18n.t('Light Mode');
}
}
async function syncModeToBookmark(mode) {
try {
if (!window.ShaaritThemeConfig) return;
await window.ShaaritThemeConfig.save({ mode });
} catch (e) {
// eslint-disable-next-line no-console
console.error('[shaarit] Failed to sync mode to bookmark', e);
}
}
// Handle themeChanged event fired by tools.html
window.addEventListener('themeChanged', () => {
updateTheme(localStorage.getItem('theme') || 'dark');
});
// Init Theme
const savedTheme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
updateTheme(savedTheme);
if (themeCheckbox) {
themeCheckbox.addEventListener('change', () => {
if (checkThemeRestrictions()) return; // Extra safety
const next = themeCheckbox.checked ? 'dark' : 'light';
updateTheme(next);
// Sync mode to bookmark in background
syncModeToBookmark(next);
});
}
});