document.addEventListener('DOMContentLoaded', () => { // ===== Theme Toggle ===== const themeCheckbox = document.getElementById('theme-toggle-checkbox'); const themeIconLight = document.getElementById('theme-icon-light'); const themeLabelSpan = document.querySelector('.theme-toggle-label span'); function updateTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); if (themeCheckbox) { themeCheckbox.checked = theme === 'dark'; } if (themeIconLight) { themeIconLight.className = theme === 'dark' ? 'mdi mdi-weather-night' : 'mdi mdi-weather-sunny'; } if (themeLabelSpan) { themeLabelSpan.textContent = theme === 'dark' ? 'Dark Mode' : 'Light Mode'; } } // Init Theme const savedTheme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); updateTheme(savedTheme); if (themeCheckbox) { themeCheckbox.addEventListener('change', () => { const next = themeCheckbox.checked ? 'dark' : 'light'; updateTheme(next); }); } // ===== 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(); } }); // ===== Search Overlay (Spotlight Style) ===== const searchOverlay = document.getElementById('search-overlay'); const searchToggleBtn = document.getElementById('search-toggle-btn'); 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 searchForm = document.getElementById('search-form'); let searchMode = 'search'; // 'search' or 'tags' let selectedIndex = -1; let searchTimeout = null; let cachedBookmarks = null; let cachedTags = null; // Escape HTML to prevent XSS function escapeHtml(text) { if (typeof text !== 'string') return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Highlight matching text with tags function highlightMatch(text, query) { if (!query || query.length === 0) return escapeHtml(text); // Escape HTML first to prevent XSS const escapedText = 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 function fetchTags() { if (cachedTags) return cachedTags; try { const tagElements = document.querySelectorAll('.link-tag, .tag-link, [class*="tag"]'); const tagsSet = new Set(); tagElements.forEach(el => { const tagText = el.textContent.trim(); if (tagText && tagText.length > 0 && !tagText.includes('•')) { tagsSet.add(tagText); } }); // 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, 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 || '', tags: Array.from(el.querySelectorAll('.link-tag')).map(t => t.textContent.trim()), description: el.querySelector('.link-description')?.textContent || '' })); return cachedBookmarks; } } catch (e) { console.error('Failed to fetch bookmarks:', e); } return []; } // Render search results (tags or bookmarks) function renderResults(results, query, isTagMode = false) { if (!searchResults) return; if (results.length === 0) { if (query && query.length > 0) { searchResults.innerHTML = `
No results found for "${escapeHtml(query)}"
`; } else { searchResults.innerHTML = `
Start typing to see tag suggestions...
`; } return; } let html; if (isTagMode) { // Render tags html = results.slice(0, 15).map((tag, index) => { const highlightedTag = highlightMatch(escapeHtml(tag), query); return `
${highlightedTag}
`; }).join(''); } else { // Render bookmarks html = results.slice(0, 10).map((item, index) => { const highlightedTitle = highlightMatch(escapeHtml(item.title), query); return `
${highlightedTitle}
`; }).join(''); } searchResults.innerHTML = html; // Add click handlers to results searchResults.querySelectorAll('.search-result-item').forEach(item => { item.addEventListener('click', () => { if (isTagMode) { 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; } } }); }); } // Escape HTML to prevent XSS function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Perform live search async function performSearch(query) { const tags = fetchTags(); if (!query || query.length === 0) { // Show all tags when empty renderResults(tags.slice(0, 15), '', true); return; } // Filter tags with fuzzy matching const results = tags.filter(tag => fuzzyMatch(tag, query)); renderResults(results, query, true); } // 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() { searchOverlay?.classList.add('show'); selectedIndex = -1; setTimeout(() => { searchModalInput?.focus(); // Trigger initial search if there's existing text if (searchModalInput?.value) { performSearch(searchModalInput.value); } }, 100); } function closeSearch() { searchOverlay?.classList.remove('show'); selectedIndex = -1; } // Toggle search mode (tags vs search) function setSearchMode(mode) { searchMode = mode; searchTagsBtn?.classList.toggle('active', mode === 'tags'); searchAllBtn?.classList.toggle('active', mode === 'search'); if (searchModalInput) { searchModalInput.name = mode === 'tags' ? 'searchtags' : 'searchterm'; // Re-run search with new mode performSearch(searchModalInput.value); } } searchToggleBtn?.addEventListener('click', openSearch); // 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) => { // Only prevent default if not submitting if (selectedIndex >= 0) { e.preventDefault(); navigateToSelected(); } }); // 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': // If an item is selected, navigate to it if (selectedIndex >= 0 && navigateToSelected()) { e.preventDefault(); } // Otherwise, submit the form normally 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(); } }); // ===== 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)); } function closeFilterPanel() { if (!filterPanel) return; filterPanel.classList.remove('show'); filterPanel.setAttribute('aria-hidden', 'true'); filterToggleBtn?.setAttribute('aria-expanded', 'false'); } 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 = ''; if (isEmptyResults) { message = 'Nothing found.'; } else { const resultWord = resultCount === 1 ? 'result' : 'results'; if (statusPart && untaggedPart) { message = `${resultCount} ${resultWord} with status ${statusPart} ${untaggedPart}`; } else if (statusPart) { message = `${resultCount} ${resultWord} with status ${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; } }); } // ===== View Toggle (Grid/List/Compact) ===== const linksList = document.getElementById('links-list'); const viewGridBtn = document.getElementById('view-grid-btn'); const viewListBtn = document.getElementById('view-list-btn'); const viewCompactBtn = document.getElementById('view-compact-btn'); function setView(view) { if (!linksList) return; // Remove all view classes linksList.classList.remove('view-grid', 'view-list', 'view-compact'); // Remove active state from all buttons viewGridBtn?.classList.remove('active'); viewListBtn?.classList.remove('active'); viewCompactBtn?.classList.remove('active'); // Apply selected view if (view === 'list') { linksList.classList.add('view-list'); viewListBtn?.classList.add('active'); } else if (view === 'compact') { linksList.classList.add('view-compact'); viewCompactBtn?.classList.add('active'); } else { // Default to grid linksList.classList.add('view-grid'); viewGridBtn?.classList.add('active'); } localStorage.setItem('linksView', view); } // Init view from localStorage const savedView = localStorage.getItem('linksView') || 'grid'; setView(savedView); viewGridBtn?.addEventListener('click', () => setView('grid')); viewListBtn?.addEventListener('click', () => setView('list')); viewCompactBtn?.addEventListener('click', () => setView('compact')); // ===== Multi-Select Mode ===== 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'); let selectionMode = false; let selectedIds = new Set(); function updateBulkUI() { if (bulkCount) { bulkCount.textContent = selectedIds.size; } // Update link cards visual state document.querySelectorAll('.link-outer').forEach(card => { const id = card.dataset.id; if (selectedIds.has(id)) { card.classList.add('selected'); } else { card.classList.remove('selected'); } }); // Update checkboxes 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); } }); updateBulkUI(); }); // Handle checkbox clicks document.addEventListener('change', (e) => { if (e.target.classList.contains('link-checkbox')) { toggleSelection(e.target.dataset.id); } }); // Handle card clicks in selection mode document.addEventListener('click', (e) => { if (!selectionMode) return; const card = e.target.closest('.link-outer'); if (card && card.dataset.id) { // Don't toggle if clicking on actions or links if (e.target.closest('.link-actions') || e.target.closest('.link-hover-actions') || e.target.tagName === 'A') { return; } toggleSelection(card.dataset.id); } }); // Bulk actions bulkDelete?.addEventListener('click', () => { if (selectedIds.size === 0) return; if (!confirm(`Delete ${selectedIds.size} bookmark(s)?`)) return; // Submit form with selected IDs const form = document.createElement('form'); form.method = 'POST'; form.action = shaarli.basePath + '/admin/shaare/delete'; selectedIds.forEach(id => { const input = document.createElement('input'); input.type = 'hidden'; input.name = 'id[]'; input.value = id; form.appendChild(input); }); const tokenInput = document.createElement('input'); tokenInput.type = 'hidden'; tokenInput.name = 'token'; tokenInput.value = document.querySelector('input[name="token"]')?.value || ''; form.appendChild(tokenInput); document.body.appendChild(form); form.submit(); }); bulkPublic?.addEventListener('click', () => { if (selectedIds.size === 0) return; bulkVisibilityChange('public'); }); bulkPrivate?.addEventListener('click', () => { if (selectedIds.size === 0) return; bulkVisibilityChange('private'); }); function bulkVisibilityChange(visibility) { const form = document.createElement('form'); form.method = 'POST'; form.action = shaarli.basePath + '/admin/shaare/visibility'; selectedIds.forEach(id => { const input = document.createElement('input'); input.type = 'hidden'; input.name = 'id[]'; input.value = id; form.appendChild(input); }); const visInput = document.createElement('input'); visInput.type = 'hidden'; visInput.name = 'visibility'; visInput.value = visibility; form.appendChild(visInput); const tokenInput = document.createElement('input'); tokenInput.type = 'hidden'; tokenInput.name = 'token'; tokenInput.value = document.querySelector('input[name="token"]')?.value || ''; form.appendChild(tokenInput); document.body.appendChild(form); form.submit(); } // ===== Thumbnail Update ===== const thumbnailsPage = document.querySelector('.page-thumbnails'); if (thumbnailsPage) { const thumbnailPlaceholder = document.querySelector('.thumbnail-placeholder'); const thumbnailTitle = document.querySelector('.thumbnail-link-title'); const progressCurrent = document.querySelector('.progress-current'); const progressBarActual = document.querySelector('.progress-actual'); const idsInput = document.querySelector('input[name="ids"]'); if (idsInput && idsInput.value) { const thumbnailsIdList = idsInput.value.split(','); const total = thumbnailsIdList.length; let i = 0; const updateThumbnail = function (id) { fetch(shaarli.basePath + '/admin/shaare/' + id + '/update-thumbnail', { method: 'PATCH', headers: { 'Accept': 'application/json', } }) .then(response => response.json()) .then(data => { i++; if (thumbnailTitle) { thumbnailTitle.textContent = data.title || 'Processing...'; } if (thumbnailPlaceholder) { if (data.thumbnail) { thumbnailPlaceholder.innerHTML = ''; } else { thumbnailPlaceholder.innerHTML = ''; } } if (progressCurrent) { progressCurrent.textContent = i; } if (progressBarActual) { progressBarActual.style.width = ((i * 100) / total) + '%'; } if (i < total) { updateThumbnail(thumbnailsIdList[i]); } else { if (thumbnailTitle) { thumbnailTitle.textContent = 'Thumbnail update done!'; } } }) .catch(error => { console.error('Failed to update thumbnail:', error); if (thumbnailTitle) { thumbnailTitle.textContent = 'Error updating thumbnail #' + i; } // Continue with next thumbnail even if one fails i++; if (i < total) { updateThumbnail(thumbnailsIdList[i]); } }); }; // Start the update process updateThumbnail(thumbnailsIdList[0]); } } // ===== Fullscreen Description Modal ===== const descModal = document.getElementById('desc-modal'); const descModalBody = document.getElementById('desc-modal-body'); const descModalClose = document.getElementById('desc-modal-close'); function openDescModal(content) { if (descModal && descModalBody) { descModalBody.innerHTML = content; descModal.classList.add('show'); document.body.style.overflow = 'hidden'; // Prevent background scrolling } } function closeDescModal() { if (descModal) { descModal.classList.remove('show'); document.body.style.overflow = ''; } } descModalClose?.addEventListener('click', closeDescModal); descModal?.addEventListener('click', (e) => { if (e.target === descModal) { closeDescModal(); } }); // Press ESC to close description modal document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && descModal?.classList.contains('show')) { closeDescModal(); } }); // Delegate click event for dynamic buttons document.addEventListener('click', (e) => { const btn = e.target.closest('.view-desc-btn'); if (btn) { e.preventDefault(); const id = btn.dataset.id; const card = document.getElementById(id); if (card) { // Try to find description element (even if hidden in compact view) const descEl = card.querySelector('.link-description'); if (descEl) { openDescModal(descEl.innerHTML); } else { openDescModal('

No description available.

'); } } } }); // ===== QR Code Plugin Modal ===== const qrcodeModal = document.getElementById('qrcode-modal'); const qrcodeModalBody = document.getElementById('qrcode-modal-body'); const qrcodeModalClose = document.getElementById('qrcode-modal-close'); function openQrcodeModal(permalink, title) { if (!qrcodeModal || !qrcodeModalBody) return; // Show loading state qrcodeModalBody.innerHTML = `
Generating QR Code...
`; qrcodeModal.classList.add('show'); document.body.style.overflow = 'hidden'; // Generate QR code using the qr.js library (loaded by the Shaarli qrcode plugin) function renderQR() { if (typeof qr !== 'undefined') { const image = qr.image({ size: 8, value: permalink }); if (image) { qrcodeModalBody.innerHTML = ''; image.style.maxWidth = '100%'; image.style.borderRadius = '0.5rem'; image.style.background = 'white'; image.style.padding = '0.75rem'; qrcodeModalBody.appendChild(image); if (title) { const titleDiv = document.createElement('div'); titleDiv.className = 'qrcode-modal-title'; titleDiv.textContent = title; qrcodeModalBody.appendChild(titleDiv); } } else { qrcodeModalBody.innerHTML = `
Failed to generate QR Code
`; } } else { // qr.js library not yet loaded — load it dynamically const basePath = document.querySelector('input[name="js_base_path"]')?.value || ''; const script = document.createElement('script'); script.src = basePath + '/plugins/qrcode/qr-1.1.3.min.js'; document.body.appendChild(script); setTimeout(() => renderQR(), 300); } } renderQR(); } function closeQrcodeModal() { if (qrcodeModal) { qrcodeModal.classList.remove('show'); document.body.style.overflow = ''; } } qrcodeModalClose?.addEventListener('click', closeQrcodeModal); qrcodeModal?.addEventListener('click', (e) => { if (e.target === qrcodeModal) closeQrcodeModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && qrcodeModal?.classList.contains('show')) closeQrcodeModal(); }); // ===== Transform QR code plugin icons ===== document.querySelectorAll('.link-plugin .linkqrcode, .link-plugin img.qrcode').forEach(el => { // The QR code plugin injects:
const img = el.tagName === 'IMG' ? el : el.querySelector('img.qrcode'); if (!img) return; const permalink = img.dataset.permalink || ''; const parentLink = img.closest('a') || img.parentElement; // Replace img with MDI icon const icon = document.createElement('i'); icon.className = 'mdi mdi-qrcode'; if (parentLink.tagName === 'A' || parentLink.classList.contains('linkqrcode')) { // Wrap in a clickable element if not already const btn = document.createElement('a'); btn.href = '#'; btn.className = 'qrcode-trigger'; btn.title = 'QR Code'; btn.dataset.permalink = permalink; btn.appendChild(icon); // Replace the whole linkqrcode div or img with our button const wrapper = el.classList.contains('linkqrcode') ? el : el.closest('.linkqrcode') || el; wrapper.replaceWith(btn); } }); // Click handler for QR code icons document.addEventListener('click', (e) => { const trigger = e.target.closest('.qrcode-trigger, .link-plugin img.qrcode'); if (!trigger) return; e.preventDefault(); e.stopPropagation(); const permalink = trigger.dataset?.permalink || trigger.querySelector('img')?.dataset?.permalink || ''; const card = trigger.closest('.link-outer'); const title = card?.querySelector('.link-title')?.textContent?.trim() || permalink; if (permalink) { openQrcodeModal(permalink, title); } }); // ===== Read It Later (tag-based, no plugin dependency) ===== const READ_IT_LATER_TAG = 'readitlater'; const READ_IT_LATER_ALIASES = ['readitlater', 'readlater', 'toread']; const normalizeTagValue = (tagValue) => (tagValue || '').trim().toLowerCase(); const isReadItLaterTag = (tagValue) => READ_IT_LATER_ALIASES.includes(normalizeTagValue(tagValue)); function getReadItLaterEditUrl(card) { if (!card) return ''; const id = card.dataset.id; if (id) { return `${shaarli.basePath}/admin/shaare/${encodeURIComponent(id)}`; } const editLink = card.querySelector('.link-actions a[href*="/admin/shaare/"]:not([href*="/pin"]):not([href*="/delete"])'); return editLink ? editLink.href : ''; } function collectBookmarkFormData(form) { const formData = new URLSearchParams(); const inputs = form.querySelectorAll('input, textarea, select'); inputs.forEach((input) => { if (!input.name || input.disabled) return; if (input.type === 'checkbox') { if (input.checked) { formData.append(input.name, input.value || 'on'); } return; } if (input.type === 'radio' && !input.checked) { return; } formData.append(input.name, input.value || ''); }); return formData; } async function updateReadItLaterTag(editUrl, enableTag) { const editResponse = await fetch(editUrl, { method: 'GET', credentials: 'same-origin', }); if (!editResponse.ok) { throw new Error('Unable to load bookmark edit form.'); } const html = await editResponse.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const form = doc.querySelector('form[name="linkform"]'); if (!form) { throw new Error('Bookmark edit form not found.'); } const formData = collectBookmarkFormData(form); const existingTags = (formData.get('lf_tags') || '') .split(/[\s,]+/) .map((tag) => tag.trim()) .filter(Boolean) .filter((tag) => !isReadItLaterTag(tag)); if (enableTag) { existingTags.push(READ_IT_LATER_TAG); } formData.set('lf_tags', existingTags.join(' ')); formData.set('save_edit', '1'); const submitResponse = await fetch(form.action, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: formData.toString(), credentials: 'same-origin', }); if (!submitResponse.ok) { throw new Error('Unable to save bookmark tags.'); } } function syncReadItLaterTagPill(card, isActive) { const tagList = card.querySelector('.link-tag-list'); if (!tagList) return; const existingPill = Array.from(tagList.querySelectorAll('.link-tag')).find((tagEl) => { const rawTag = tagEl.dataset.tag || tagEl.querySelector('.link-tag-link')?.textContent || tagEl.textContent || ''; return normalizeTagValue(rawTag) === READ_IT_LATER_TAG; }); if (!isActive) { existingPill?.remove(); return; } if (existingPill) return; const pill = document.createElement('span'); pill.className = 'link-tag'; pill.dataset.tag = READ_IT_LATER_TAG; const link = document.createElement('a'); link.className = 'link-tag-link'; link.href = `${shaarli.basePath}/add-tag/${encodeURIComponent(READ_IT_LATER_TAG)}`; link.textContent = READ_IT_LATER_TAG; pill.appendChild(link); if (shaarli.isAuth) { const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'tag-remove-btn'; removeBtn.dataset.tag = READ_IT_LATER_TAG; removeBtn.setAttribute('aria-label', 'Supprimer le tag readitlater'); removeBtn.title = 'Supprimer'; removeBtn.textContent = '×'; pill.appendChild(removeBtn); } tagList.appendChild(pill); } function syncReadItLaterCardUI(card, isActive) { if (!card) return; card.classList.toggle('readitlater-active', isActive); let badge = card.querySelector('.link-readlater-badge'); if (isActive && !badge) { badge = document.createElement('div'); badge.className = 'link-readlater-badge'; badge.textContent = 'Read Later'; card.appendChild(badge); } else if (!isActive && badge) { badge.remove(); } const toggleBtn = card.querySelector('.readitlater-toggle-btn'); if (toggleBtn) { const titleText = isActive ? 'Retirer de Read It Later' : 'Ajouter a Read It Later'; toggleBtn.classList.toggle('active', isActive); toggleBtn.dataset.active = isActive ? '1' : '0'; toggleBtn.setAttribute('title', titleText); toggleBtn.setAttribute('aria-label', titleText); const icon = toggleBtn.querySelector('i'); if (icon) { icon.className = `mdi ${isActive ? 'mdi-bookmark-clock' : 'mdi-bookmark-clock-outline'}`; } } syncReadItLaterTagPill(card, isActive); } document.querySelectorAll('.link-outer').forEach((card) => { const cardTags = Array.from(card.querySelectorAll('.link-tag')).map((tagEl) => { const rawTag = tagEl.dataset.tag || tagEl.querySelector('.link-tag-link')?.textContent || tagEl.textContent || ''; return normalizeTagValue(rawTag); }); const isActive = cardTags.some(isReadItLaterTag) || card.classList.contains('readitlater-active') || card.classList.contains('readitlater-unread'); syncReadItLaterCardUI(card, isActive); }); document.addEventListener('click', async (event) => { const toggleBtn = event.target.closest('.readitlater-toggle-btn'); if (!toggleBtn) return; event.preventDefault(); event.stopPropagation(); const card = toggleBtn.closest('.link-outer'); if (!card) return; const editUrl = getReadItLaterEditUrl(card); if (!editUrl) return; const isActive = card.classList.contains('readitlater-active') || toggleBtn.dataset.active === '1'; const nextState = !isActive; if (toggleBtn.disabled) return; toggleBtn.disabled = true; toggleBtn.classList.add('is-loading'); try { await updateReadItLaterTag(editUrl, nextState); syncReadItLaterCardUI(card, nextState); } catch (error) { console.error('Failed to toggle readitlater tag:', error); alert('Impossible de mettre a jour Read It Later pour ce bookmark.'); } finally { toggleBtn.disabled = false; toggleBtn.classList.remove('is-loading'); } }); // ===== Bookmark Editor Form (Markdown + Tags UI) ===== function initBookmarkEditorForms() { const forms = document.querySelectorAll('.bookmark-editor-form[data-batch-mode="0"]'); if (!forms.length) return; const normalizeTag = (value) => value.trim().replace(/\s+/g, '-'); forms.forEach((form) => { form.classList.add('is-enhanced'); const descriptionSource = form.querySelector('.bookmark-editor-source'); const editorMount = form.querySelector('.bookmark-markdown-editor'); const hiddenTagsInput = form.querySelector('.bookmark-tags-hidden-input'); const tagsInputContainer = form.querySelector('.bookmark-tags-input'); const tagsList = form.querySelector('.bookmark-tags-list'); const tagsTextInput = form.querySelector('.bookmark-tags-text-input'); const readLaterCheckbox = form.querySelector('.bookmark-toggle-readlater'); const noteCheckbox = form.querySelector('.bookmark-toggle-note'); const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark'; // Markdown editor if (descriptionSource && editorMount && window.toastui && window.toastui.Editor) { const previewStyle = window.innerWidth < 992 ? 'tab' : 'vertical'; let sourceSyncTimer = null; const markdownEditor = new window.toastui.Editor({ el: editorMount, height: '320px', initialEditType: 'markdown', previewStyle, initialValue: descriptionSource.value || '', placeholder: descriptionSource.getAttribute('placeholder') || '', theme: isDarkTheme ? 'dark' : undefined, usageStatistics: false, }); // Shaarli metadata script updates the textarea value asynchronously. // Mirror that value into Toast UI only if the editor is still empty. sourceSyncTimer = window.setInterval(() => { const sourceValue = (descriptionSource.value || '').trim(); if (!sourceValue) return; const editorValue = (markdownEditor.getMarkdown() || '').trim(); if (!editorValue) { markdownEditor.setMarkdown(descriptionSource.value || '', false); } window.clearInterval(sourceSyncTimer); sourceSyncTimer = null; }, 250); form.addEventListener('submit', () => { if (sourceSyncTimer) { window.clearInterval(sourceSyncTimer); sourceSyncTimer = null; } descriptionSource.value = markdownEditor.getMarkdown(); }); } else if (editorMount) { editorMount.style.display = 'none'; if (descriptionSource) { descriptionSource.style.display = 'block'; } } // Pill tags if (!hiddenTagsInput || !tagsList || !tagsTextInput) return; const availableTags = (hiddenTagsInput.getAttribute('data-tag-options') || '') .split(',') .map((tag) => normalizeTag(tag)) .filter(Boolean); const getSuggestionList = () => availableTags .filter((tag) => !tags.some((existing) => existing.toLowerCase() === tag.toLowerCase())); let tagsAutocomplete = null; const refreshAutocomplete = () => { if (!tagsAutocomplete) return; tagsAutocomplete.list = getSuggestionList(); }; if (tagsInputContainer) { tagsInputContainer.addEventListener('click', () => tagsTextInput.focus()); } let tags = hiddenTagsInput.value .split(/[\s,]+/) .map((tag) => normalizeTag(tag)) .filter(Boolean); const updateHiddenTags = () => { hiddenTagsInput.value = tags.join(' '); }; const syncReadLaterCheckbox = () => { if (!readLaterCheckbox) return; readLaterCheckbox.checked = tags.some((tag) => /^(readitlater|readlater|toread)$/i.test(tag)); }; const syncNoteCheckbox = () => { if (!noteCheckbox) return; noteCheckbox.checked = tags.some((tag) => /^note$/i.test(tag)); }; const addTag = (rawTag) => { const tag = normalizeTag(rawTag); if (!tag) return; const exists = tags.some((existing) => existing.toLowerCase() === tag.toLowerCase()); if (!exists) { tags.push(tag); updateHiddenTags(); syncReadLaterCheckbox(); syncNoteCheckbox(); renderTags(); refreshAutocomplete(); } }; const removeTag = (tagToRemove) => { tags = tags.filter((tag) => tag.toLowerCase() !== tagToRemove.toLowerCase()); updateHiddenTags(); syncReadLaterCheckbox(); syncNoteCheckbox(); renderTags(); refreshAutocomplete(); }; const commitInputValue = () => { const value = tagsTextInput.value; if (!value.trim()) return; value .split(/[\s,]+/) .map((tag) => tag.trim()) .filter(Boolean) .forEach(addTag); tagsTextInput.value = ''; refreshAutocomplete(); }; function renderTags() { tagsList.innerHTML = ''; tags.forEach((tag) => { const pill = document.createElement('span'); pill.className = 'bookmark-tag-pill'; pill.setAttribute('data-tag', tag); const label = document.createElement('span'); label.className = 'bookmark-tag-label'; label.textContent = tag; const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'bookmark-tag-remove'; removeBtn.setAttribute('aria-label', `Remove tag ${tag}`); removeBtn.innerHTML = ''; removeBtn.addEventListener('click', () => removeTag(tag)); pill.appendChild(label); pill.appendChild(removeBtn); tagsList.appendChild(pill); }); } tagsTextInput.addEventListener('keydown', (event) => { const hasOpenAutocomplete = Boolean(tagsAutocomplete && tagsAutocomplete.ul && !tagsAutocomplete.ul.hasAttribute('hidden')); const shouldCommit = event.key === ' ' || event.key === ',' || (event.key === 'Enter' && !hasOpenAutocomplete); if (shouldCommit) { event.preventDefault(); commitInputValue(); return; } if (event.key === 'Backspace' && !tagsTextInput.value && tags.length) { removeTag(tags[tags.length - 1]); } }); tagsTextInput.addEventListener('blur', commitInputValue); tagsTextInput.addEventListener('paste', (event) => { const pasted = event.clipboardData ? event.clipboardData.getData('text') : ''; if (!pasted) return; event.preventDefault(); pasted .split(/[\s,]+/) .map((tag) => tag.trim()) .filter(Boolean) .forEach(addTag); tagsTextInput.value = ''; }); if (readLaterCheckbox) { readLaterCheckbox.addEventListener('change', () => { tags = tags.filter((tag) => !/^(readitlater|readlater|toread)$/i.test(tag)); if (readLaterCheckbox.checked) { tags.push('readitlater'); } updateHiddenTags(); syncNoteCheckbox(); renderTags(); refreshAutocomplete(); }); } if (noteCheckbox) { noteCheckbox.addEventListener('change', () => { tags = tags.filter((tag) => !/^note$/i.test(tag)); if (noteCheckbox.checked) { tags.push('note'); } updateHiddenTags(); syncReadLaterCheckbox(); renderTags(); refreshAutocomplete(); }); } if (window.Awesomplete && availableTags.length) { tagsAutocomplete = new window.Awesomplete(tagsTextInput, { list: getSuggestionList(), minChars: 1, maxItems: 8, autoFirst: true, sort: false, filter: window.Awesomplete.FILTER_CONTAINS, }); tagsTextInput.addEventListener('input', () => { refreshAutocomplete(); tagsAutocomplete.evaluate(); }); tagsTextInput.addEventListener('awesomplete-selectcomplete', commitInputValue); } form.addEventListener('submit', commitInputValue); updateHiddenTags(); syncReadLaterCheckbox(); syncNoteCheckbox(); renderTags(); refreshAutocomplete(); }); } function initBatchCreationFlow() { const batchToggleBtn = document.querySelector('.button-batch-addform'); const batchForm = document.querySelector('form.batch-addform'); if (batchToggleBtn && batchForm) { batchToggleBtn.addEventListener('click', () => { const isHidden = batchForm.classList.contains('hidden'); batchForm.classList.toggle('hidden'); batchToggleBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false'); if (isHidden) { const urlsField = batchForm.querySelector('#urls'); urlsField?.focus(); } }); } const batchPage = document.querySelector('.page-edit-link-batch'); if (!batchPage) return; const progressOverlay = document.getElementById('progress-overlay'); const progressCurrentEl = batchPage.querySelector('.progress-current'); const progressTotalEl = batchPage.querySelector('.progress-total'); const progressActualEl = batchPage.querySelector('.progress-actual'); const saveAllButtons = batchPage.querySelectorAll('button[name="save_edit_batch"]'); const getActiveBatchForms = () => Array.from( batchPage.querySelectorAll('.bookmark-editor-form[data-batch-mode="1"]') ); const updateProgress = (current, total) => { if (progressCurrentEl) { progressCurrentEl.textContent = String(current); } if (progressTotalEl) { progressTotalEl.textContent = String(total); } if (progressActualEl) { const percentage = total > 0 ? (current * 100) / total : 0; progressActualEl.style.width = `${percentage}%`; } }; const initializeProgressTotal = () => { const total = getActiveBatchForms().length; updateProgress(0, total); }; initializeProgressTotal(); batchPage.addEventListener('click', (event) => { const cancelBtn = event.target.closest('button[name="cancel-batch-link"]'); if (!cancelBtn) return; event.preventDefault(); const editForm = cancelBtn.closest('.editlinkform'); editForm?.remove(); initializeProgressTotal(); }); async function saveFormsSequentially(forms) { const total = forms.length; let current = 0; updateProgress(0, total); progressOverlay?.classList.remove('hidden'); for (const form of forms) { const formData = new FormData(form); try { const response = await fetch(form.action || `${shaarli.basePath}/admin/shaare`, { method: 'POST', body: formData, credentials: 'same-origin', }); if (!response.ok) { console.error('Batch save failed with status', response.status); } } catch (error) { console.error('Batch save request failed', error); } current += 1; updateProgress(current, total); } window.location.href = `${shaarli.basePath}/`; } saveAllButtons.forEach((button) => { button.addEventListener('click', async (event) => { event.preventDefault(); const forms = getActiveBatchForms(); if (!forms.length) return; saveAllButtons.forEach((btn) => { btn.disabled = true; }); await saveFormsSequentially(forms); }); }); } initBookmarkEditorForms(); initBatchCreationFlow(); // ===== Persistent Media Player (Popup via Blob URL) ===== // Audio plays in a separate popup window that survives page navigation. // The popup HTML is generated as a Blob URL (no server file needed). // Communication via BroadcastChannel API. // The inline bar serves as a "Now Playing" indicator and control relay. const MEDIA_EXTENSIONS = [ '.mp3', '.mp4', '.ogg', '.webm', '.m3u8', '.flac', '.wav', '.aac', '.m4a', '.opus', '.wma', '.oga', '.m3u', '.pls' ]; function isMediaUrl(url) { if (!url) return false; var lower = url.toLowerCase(); var pathname = lower.split('?')[0].split('#')[0]; for (var i = 0; i < MEDIA_EXTENSIONS.length; i++) { if (pathname.endsWith(MEDIA_EXTENSIONS[i])) return true; } if (/\/(stream|listen|live|icecast|shoutcast)(\/|$|\?)/i.test(url)) { return true; } return false; } var playerBar = document.getElementById('media-player-bar'); var playerPlayBtn = document.getElementById('media-player-play'); var playerPlayIcon = document.getElementById('media-player-play-icon'); var playerTitle = document.getElementById('media-player-title'); var playerProgress = document.getElementById('media-player-progress'); var playerTime = document.getElementById('media-player-time'); var playerVolume = document.getElementById('media-player-volume'); var playerVolBtn = document.getElementById('media-player-vol-btn'); var playerVolIcon = document.getElementById('media-player-vol-icon'); var playerCloseBtn = document.getElementById('media-player-close'); // Reference to the popup window var playerPopup = null; // BroadcastChannel for reliable cross-window communication var playerChannel = null; try { playerChannel = new BroadcastChannel('shaarli-media-player'); } catch (e) { // BroadcastChannel not supported } function buildPlayerHTML() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const theme = isDark ? 'dark' : 'light'; var lines = []; lines.push(''); lines.push(''); lines.push(''); lines.push(''); lines.push(''); lines.push('♪ Shaarli Player'); lines.push(''); lines.push(''); lines.push(''); lines.push(''); lines.push(''); lines.push('
'); lines.push('
'); lines.push(' '); lines.push(' LIVE'); lines.push('
'); lines.push('
Loading...
'); lines.push('
'); lines.push(' '); lines.push('
'); lines.push(' 0:00'); lines.push(' 0:00'); lines.push('
'); lines.push('
'); lines.push('
'); lines.push(' '); lines.push(' '); lines.push(' '); lines.push('
'); lines.push('
'); lines.push(' '); lines.push(' '); lines.push(' '); lines.push('
'); lines.push('
'); lines.push(''); lines.push(''); lines.push(''); lines.push(''); lines.push(''); lines.push('