2789 lines
113 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <mark> 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, '<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
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 = `
<div class="search-no-results">
No results found for "<strong>${escapeHtml(query)}</strong>"
</div>
`;
} else {
searchResults.innerHTML = `
<div class="search-results-empty">
<span class="search-results-hint">Start typing to see tag suggestions...</span>
</div>
`;
}
return;
}
let html;
if (isTagMode) {
// Render tags
html = results.slice(0, 15).map((tag, index) => {
const highlightedTag = highlightMatch(escapeHtml(tag), query);
return `
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
data-index="${index}"
data-tag="${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
html = results.slice(0, 10).map((item, index) => {
const highlightedTitle = highlightMatch(escapeHtml(item.title), query);
return `
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
data-index="${index}"
data-url="${escapeHtml(item.url)}"
data-id="${item.id}">
<div class="search-result-item-content">
<i class="mdi mdi-bookmark-outline search-result-icon"></i>
<span class="search-result-text">${highlightedTitle}</span>
</div>
</div>
`;
}).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 = '<strong>private</strong>';
} else if (isPublicActive) {
statusPart = '<strong>public</strong>';
}
if (isUntaggedActive) {
untaggedPart = '<strong>without any tag</strong>';
}
// 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 = `
<div class="filter-info-content">
<span>${message}</span>
</div>
<button type="button" class="filter-clear-btn" id="filter-clear-btn" title="Clear filters">
<i class="mdi mdi-close"></i>
<span>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;
}
});
}
// ===== 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 = '<img title="Current thumbnail" src="' + data.thumbnail + '" style="max-width: 100%; height: auto;"/>';
} 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('<p class="text-muted">No description available.</p>');
}
}
}
});
// ===== 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 = `
<div style="padding:2rem;color:var(--text-muted);">Generating QR Code...</div>
`;
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 = `<div style="padding:1rem;color:var(--text-muted);">Failed to generate QR Code</div>`;
}
} 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: <div class="linkqrcode"><img data-permalink="URL" class="qrcode" ...></div>
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 = '<i class="mdi mdi-close" aria-hidden="true"></i>';
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('<!DOCTYPE html>');
lines.push('<html data-theme="' + theme + '">');
lines.push('<head>');
lines.push('<meta charset="utf-8">');
lines.push('<meta name="viewport" content="width=device-width, initial-scale=1.0">');
lines.push('<title>♪ Shaarli Player</title>');
lines.push('<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/7.2.96/css/materialdesignicons.min.css">');
lines.push('<style>');
lines.push(':root {');
lines.push(' --primary: #2563eb;');
lines.push(' --primary-hover: #1d4ed8;');
lines.push(' --primary-light: rgba(37,99,235,0.08);');
lines.push(' --bg-body: #f1f5f9;');
lines.push(' --bg-card: #ffffff;');
lines.push(' --text-main: #1e293b;');
lines.push(' --text-secondary: #64748b;');
lines.push(' --text-muted: #94a3b8;');
lines.push(' --border: #e2e8f0;');
lines.push(' --danger: #ef4444;');
lines.push(' --shadow: 0 1px 3px rgba(0,0,0,0.1);');
lines.push('}');
lines.push('[data-theme="dark"] {');
lines.push(' --primary: #3b82f6;');
lines.push(' --primary-hover: #60a5fa;');
lines.push(' --primary-light: rgba(59,130,246,0.12);');
lines.push(' --bg-body: #0f172a;');
lines.push(' --bg-card: #1e293b;');
lines.push(' --text-main: #e2e8f0;');
lines.push(' --text-secondary: #94a3b8;');
lines.push(' --text-muted: #64748b;');
lines.push(' --border: #334155;');
lines.push(' --danger: #f87171;');
lines.push(' --shadow: 0 1px 3px rgba(0,0,0,0.3);');
lines.push('}');
lines.push('* { box-sizing: border-box; margin: 0; padding: 0; }');
lines.push('body {');
lines.push(' font-family: -apple-system, BlinkMacSystemFont, \'Inter\', \'Segoe UI\', sans-serif;');
lines.push(' background: var(--bg-body);');
lines.push(' color: var(--text-main);');
lines.push(' display: flex;');
lines.push(' flex-direction: column;');
lines.push(' align-items: center;');
lines.push(' justify-content: center;');
lines.push(' min-height: 100vh;');
lines.push(' padding: 1rem;');
lines.push(' user-select: none;');
lines.push(' overflow: hidden;');
lines.push('}');
lines.push('.player-card {');
lines.push(' background: var(--bg-card);');
lines.push(' border: 1px solid var(--border);');
lines.push(' border-radius: 1rem;');
lines.push(' box-shadow: var(--shadow);');
lines.push(' padding: 1.5rem;');
lines.push(' width: 100%;');
lines.push(' max-width: 420px;');
lines.push(' display: flex;');
lines.push(' flex-direction: column;');
lines.push(' gap: 1rem;');
lines.push('}');
lines.push('.player-artwork {');
lines.push(' display: flex;');
lines.push(' align-items: center;');
lines.push(' justify-content: center;');
lines.push(' background: var(--primary-light);');
lines.push(' border-radius: 0.75rem;');
lines.push(' padding: 1.5rem;');
lines.push(' position: relative;');
lines.push(' overflow: hidden;');
lines.push('}');
lines.push('.player-artwork i {');
lines.push(' font-size: 3rem;');
lines.push(' color: var(--primary);');
lines.push(' animation: pulse-icon 2s ease-in-out infinite;');
lines.push(' opacity: 0.6;');
lines.push('}');
lines.push('.player-artwork i.playing {');
lines.push(' animation: pulse-icon 1.5s ease-in-out infinite;');
lines.push(' opacity: 1;');
lines.push('}');
lines.push('@keyframes pulse-icon {');
lines.push(' 0%, 100% { transform: scale(1); }');
lines.push(' 50% { transform: scale(1.08); }');
lines.push('}');
lines.push('.live-badge {');
lines.push(' position: absolute;');
lines.push(' top: 0.5rem;');
lines.push(' right: 0.5rem;');
lines.push(' background: var(--danger);');
lines.push(' color: white;');
lines.push(' font-size: 0.65rem;');
lines.push(' font-weight: 700;');
lines.push(' padding: 0.15rem 0.5rem;');
lines.push(' border-radius: 999px;');
lines.push(' text-transform: uppercase;');
lines.push(' letter-spacing: 0.06em;');
lines.push(' display: none;');
lines.push('}');
lines.push('.live-badge.show { display: block; }');
lines.push('.player-title {');
lines.push(' font-size: 0.9rem;');
lines.push(' font-weight: 600;');
lines.push(' color: var(--text-main);');
lines.push(' text-align: center;');
lines.push(' overflow: hidden;');
lines.push(' text-overflow: ellipsis;');
lines.push(' white-space: nowrap;');
lines.push(' max-width: 100%;');
lines.push('}');
lines.push('.player-progress-wrap { width: 100%; }');
lines.push('.player-progress {');
lines.push(' -webkit-appearance: none;');
lines.push(' appearance: none;');
lines.push(' width: 100%;');
lines.push(' height: 5px;');
lines.push(' background: var(--border);');
lines.push(' border-radius: 3px;');
lines.push(' outline: none;');
lines.push(' cursor: pointer;');
lines.push('}');
lines.push('.player-progress:hover { height: 7px; }');
lines.push('.player-progress::-webkit-slider-thumb {');
lines.push(' -webkit-appearance: none;');
lines.push(' width: 14px; height: 14px;');
lines.push(' border-radius: 50%;');
lines.push(' background: var(--primary);');
lines.push(' cursor: pointer;');
lines.push(' border: 2px solid white;');
lines.push(' box-shadow: 0 1px 4px rgba(0,0,0,0.2);');
lines.push('}');
lines.push('.player-progress::-moz-range-thumb {');
lines.push(' width: 14px; height: 14px;');
lines.push(' border-radius: 50%;');
lines.push(' background: var(--primary);');
lines.push(' cursor: pointer;');
lines.push(' border: 2px solid white;');
lines.push(' box-shadow: 0 1px 4px rgba(0,0,0,0.2);');
lines.push('}');
lines.push('.player-time {');
lines.push(' display: flex;');
lines.push(' justify-content: space-between;');
lines.push(' font-size: 0.7rem;');
lines.push(' color: var(--text-muted);');
lines.push(' margin-top: 0.2rem;');
lines.push(' font-variant-numeric: tabular-nums;');
lines.push('}');
lines.push('.player-controls {');
lines.push(' display: flex;');
lines.push(' align-items: center;');
lines.push(' justify-content: center;');
lines.push(' gap: 0.75rem;');
lines.push('}');
lines.push('.ctrl-btn {');
lines.push(' display: flex;');
lines.push(' align-items: center;');
lines.push(' justify-content: center;');
lines.push(' border: none;');
lines.push(' border-radius: 50%;');
lines.push(' cursor: pointer;');
lines.push(' transition: all 0.2s ease;');
lines.push(' background: transparent;');
lines.push(' color: var(--text-secondary);');
lines.push('}');
lines.push('.ctrl-btn:hover { background: var(--primary-light); color: var(--primary); }');
lines.push('.ctrl-btn.sm { width: 36px; height: 36px; font-size: 1.1rem; }');
lines.push('.ctrl-btn.lg {');
lines.push(' width: 52px; height: 52px;');
lines.push(' background: var(--primary);');
lines.push(' color: white;');
lines.push(' font-size: 1.5rem;');
lines.push('}');
lines.push('.ctrl-btn.lg:hover { background: var(--primary-hover); transform: scale(1.06); }');
lines.push('.ctrl-btn.close:hover { background: rgba(239,68,68,0.1); color: var(--danger); }');
lines.push('.volume-wrap {');
lines.push(' display: flex;');
lines.push(' align-items: center;');
lines.push(' gap: 0.4rem;');
lines.push(' width: 100%;');
lines.push('}');
lines.push('.volume-slider {');
lines.push(' -webkit-appearance: none;');
lines.push(' appearance: none;');
lines.push(' flex: 1;');
lines.push(' height: 4px;');
lines.push(' background: var(--border);');
lines.push(' border-radius: 2px;');
lines.push(' outline: none;');
lines.push(' cursor: pointer;');
lines.push('}');
lines.push('.volume-slider::-webkit-slider-thumb {');
lines.push(' -webkit-appearance: none;');
lines.push(' width: 12px; height: 12px;');
lines.push(' border-radius: 50%;');
lines.push(' background: var(--primary);');
lines.push(' cursor: pointer;');
lines.push('}');
lines.push('.volume-slider::-moz-range-thumb {');
lines.push(' width: 12px; height: 12px;');
lines.push(' border-radius: 50%;');
lines.push(' background: var(--primary);');
lines.push(' cursor: pointer;');
lines.push('}');
lines.push('.no-media {');
lines.push(' text-align: center;');
lines.push(' color: var(--text-muted);');
lines.push(' padding: 2rem;');
lines.push(' font-size: 0.9rem;');
lines.push('}');
lines.push('.no-media i {');
lines.push(' font-size: 3rem;');
lines.push(' display: block;');
lines.push(' margin-bottom: 1rem;');
lines.push(' opacity: 0.4;');
lines.push('}');
lines.push('</style>');
lines.push('</head>');
lines.push('<body>');
lines.push('');
lines.push('<div class="player-card" id="player-card">');
lines.push(' <div class="player-artwork" id="artwork">');
lines.push(' <i class="mdi mdi-music-note" id="artwork-icon"></i>');
lines.push(' <span class="live-badge" id="live-badge">LIVE</span>');
lines.push(' </div>');
lines.push(' <div class="player-title" id="title">Loading...</div>');
lines.push(' <div class="player-progress-wrap" id="progress-wrap">');
lines.push(' <input type="range" class="player-progress" id="progress" min="0" max="100" value="0" step="0.1">');
lines.push(' <div class="player-time">');
lines.push(' <span id="time-current">0:00</span>');
lines.push(' <span id="time-total">0:00</span>');
lines.push(' </div>');
lines.push(' </div>');
lines.push(' <div class="player-controls">');
lines.push(' <button class="ctrl-btn sm" id="vol-btn" title="Mute/Unmute">');
lines.push(' <i class="mdi mdi-volume-high" id="vol-icon"></i>');
lines.push(' </button>');
lines.push(' <button class="ctrl-btn lg" id="play-btn" title="Play/Pause">');
lines.push(' <i class="mdi mdi-play" id="play-icon"></i>');
lines.push(' </button>');
lines.push(' <button class="ctrl-btn sm close" id="close-btn" title="Stop & Close">');
lines.push(' <i class="mdi mdi-close"></i>');
lines.push(' </button>');
lines.push(' </div>');
lines.push(' <div class="volume-wrap">');
lines.push(' <i class="mdi mdi-volume-low" style="color:var(--text-muted);font-size:0.85rem;"></i>');
lines.push(' <input type="range" class="volume-slider" id="volume" min="0" max="1" value="0.8" step="0.01">');
lines.push(' <i class="mdi mdi-volume-high" style="color:var(--text-muted);font-size:0.85rem;"></i>');
lines.push(' </div>');
lines.push('</div>');
lines.push('');
lines.push('<div class="no-media" id="no-media" style="display:none;">');
lines.push(' <i class="mdi mdi-music-note-off"></i>');
lines.push(' No media selected. Click play on a bookmark to start.');
lines.push('</div>');
lines.push('');
lines.push('<audio id="audio" preload="metadata"></audio>');
lines.push('');
lines.push('<script>');
lines.push('(function() {');
lines.push(' // --- DOM refs ---');
lines.push(' var audio = document.getElementById(\'audio\');');
lines.push(' var playBtn = document.getElementById(\'play-btn\');');
lines.push(' var playIcon = document.getElementById(\'play-icon\');');
lines.push(' var closeBtn = document.getElementById(\'close-btn\');');
lines.push(' var progress = document.getElementById(\'progress\');');
lines.push(' var progressWrap = document.getElementById(\'progress-wrap\');');
lines.push(' var timeCurrent = document.getElementById(\'time-current\');');
lines.push(' var timeTotal = document.getElementById(\'time-total\');');
lines.push(' var volumeSlider = document.getElementById(\'volume\');');
lines.push(' var volBtn = document.getElementById(\'vol-btn\');');
lines.push(' var volIcon = document.getElementById(\'vol-icon\');');
lines.push(' var artworkIcon = document.getElementById(\'artwork-icon\');');
lines.push(' var liveBadge = document.getElementById(\'live-badge\');');
lines.push(' var titleEl = document.getElementById(\'title\');');
lines.push(' var playerCard = document.getElementById(\'player-card\');');
lines.push(' var noMediaEl = document.getElementById(\'no-media\');');
lines.push('');
lines.push(' var currentUrl = \'\';');
lines.push(' var currentTitle = \'\';');
lines.push(' var prevVol = 0.8;');
lines.push('');
lines.push(' // --- Apply theme ---');
lines.push(' var savedTheme = localStorage.getItem(\'shaarliTheme\') || \'light\';');
lines.push(' document.documentElement.setAttribute(\'data-theme\', savedTheme);');
lines.push('');
lines.push(' // --- BroadcastChannel for communication with main page ---');
lines.push(' var channel = null;');
lines.push(' try {');
lines.push(' channel = new BroadcastChannel(\'shaarli-media-player\');');
lines.push(' } catch(e) {');
lines.push(' // BroadcastChannel not supported, fallback to localStorage events');
lines.push(' }');
lines.push('');
lines.push(' // --- Helpers ---');
lines.push(' function fmt(s) {');
lines.push(' if (!s || isNaN(s) || !isFinite(s)) return \'0:00\';');
lines.push(' var m = Math.floor(s / 60);');
lines.push(' var sec = Math.floor(s % 60);');
lines.push(' return m + \':\' + (sec < 10 ? \'0\' : \'\') + sec;');
lines.push(' }');
lines.push('');
lines.push(' function updateVolIcon(v) {');
lines.push(' if (v <= 0) volIcon.className = \'mdi mdi-volume-off\';');
lines.push(' else if (v < 0.5) volIcon.className = \'mdi mdi-volume-medium\';');
lines.push(' else volIcon.className = \'mdi mdi-volume-high\';');
lines.push(' }');
lines.push('');
lines.push(' function broadcastState() {');
lines.push(' var state = {');
lines.push(' type: \'player-state\',');
lines.push(' url: currentUrl,');
lines.push(' title: currentTitle,');
lines.push(' playing: !audio.paused,');
lines.push(' currentTime: audio.currentTime,');
lines.push(' duration: audio.duration,');
lines.push(' volume: audio.volume');
lines.push(' };');
lines.push(' // Save to localStorage for page reloads');
lines.push(' localStorage.setItem(\'mediaPopupState\', JSON.stringify(state));');
lines.push(' // Broadcast via channel');
lines.push(' if (channel) {');
lines.push(' try { channel.postMessage(state); } catch(e) {}');
lines.push(' }');
lines.push(' }');
lines.push('');
lines.push(' function loadAndPlay(url, title) {');
lines.push(' if (!url) return;');
lines.push(' currentUrl = url;');
lines.push(' currentTitle = title || url;');
lines.push(' titleEl.textContent = currentTitle;');
lines.push(' document.title = \'\\u266A \' + currentTitle;');
lines.push(' audio.src = url;');
lines.push(' audio.volume = parseFloat(volumeSlider.value);');
lines.push(' audio.play().catch(function() {});');
lines.push('');
lines.push(' playerCard.style.display = \'\';');
lines.push(' noMediaEl.style.display = \'none\';');
lines.push('');
lines.push(' // Update localStorage keys (for the main page bar)');
lines.push(' localStorage.setItem(\'mediaPlayerUrl\', url);');
lines.push(' localStorage.setItem(\'mediaPlayerTitle\', currentTitle);');
lines.push(' localStorage.setItem(\'mediaPlayerPlaying\', \'true\');');
lines.push(' }');
lines.push('');
lines.push(' // --- Audio events ---');
lines.push(' audio.addEventListener(\'play\', function() {');
lines.push(' playIcon.className = \'mdi mdi-pause\';');
lines.push(' artworkIcon.classList.add(\'playing\');');
lines.push(' broadcastState();');
lines.push(' });');
lines.push('');
lines.push(' audio.addEventListener(\'pause\', function() {');
lines.push(' playIcon.className = \'mdi mdi-play\';');
lines.push(' artworkIcon.classList.remove(\'playing\');');
lines.push(' broadcastState();');
lines.push(' });');
lines.push('');
lines.push(' audio.addEventListener(\'timeupdate\', function() {');
lines.push(' if (!audio.duration) return;');
lines.push(' if (isFinite(audio.duration)) {');
lines.push(' progress.value = (audio.currentTime / audio.duration) * 100;');
lines.push(' timeCurrent.textContent = fmt(audio.currentTime);');
lines.push(' }');
lines.push(' // Sync every 2 seconds');
lines.push(' if (Math.floor(audio.currentTime) % 2 === 0) broadcastState();');
lines.push(' });');
lines.push('');
lines.push(' audio.addEventListener(\'loadedmetadata\', function() {');
lines.push(' if (!isFinite(audio.duration)) {');
lines.push(' liveBadge.classList.add(\'show\');');
lines.push(' progressWrap.style.display = \'none\';');
lines.push(' artworkIcon.className = \'mdi mdi-radio-tower playing\';');
lines.push(' } else {');
lines.push(' liveBadge.classList.remove(\'show\');');
lines.push(' progressWrap.style.display = \'\';');
lines.push(' timeTotal.textContent = fmt(audio.duration);');
lines.push(' artworkIcon.className = \'mdi mdi-music-note\';');
lines.push(' if (!audio.paused) artworkIcon.classList.add(\'playing\');');
lines.push(' }');
lines.push(' });');
lines.push('');
lines.push(' audio.addEventListener(\'ended\', function() {');
lines.push(' playIcon.className = \'mdi mdi-play\';');
lines.push(' progress.value = 0;');
lines.push(' artworkIcon.classList.remove(\'playing\');');
lines.push(' broadcastState();');
lines.push(' });');
lines.push('');
lines.push(' audio.addEventListener(\'error\', function() {');
lines.push(' titleEl.textContent = \'Error loading media\';');
lines.push(' playIcon.className = \'mdi mdi-play\';');
lines.push(' artworkIcon.classList.remove(\'playing\');');
lines.push(' });');
lines.push('');
lines.push(' // --- Player controls ---');
lines.push(' playBtn.addEventListener(\'click\', function() {');
lines.push(' if (audio.paused) audio.play().catch(function() {});');
lines.push(' else audio.pause();');
lines.push(' });');
lines.push('');
lines.push(' closeBtn.addEventListener(\'click\', function() {');
lines.push(' audio.pause();');
lines.push(' audio.src = \'\';');
lines.push(' localStorage.removeItem(\'mediaPopupState\');');
lines.push(' localStorage.removeItem(\'mediaPlayerUrl\');');
lines.push(' localStorage.removeItem(\'mediaPlayerTitle\');');
lines.push(' localStorage.removeItem(\'mediaPlayerPlaying\');');
lines.push(' if (channel) {');
lines.push(' try { channel.postMessage({ type: \'player-closed\' }); } catch(e) {}');
lines.push(' }');
lines.push(' window.close();');
lines.push(' });');
lines.push('');
lines.push(' progress.addEventListener(\'input\', function() {');
lines.push(' if (audio.duration && isFinite(audio.duration)) {');
lines.push(' audio.currentTime = (progress.value / 100) * audio.duration;');
lines.push(' }');
lines.push(' });');
lines.push('');
lines.push(' volumeSlider.addEventListener(\'input\', function() {');
lines.push(' audio.volume = parseFloat(volumeSlider.value);');
lines.push(' updateVolIcon(audio.volume);');
lines.push(' broadcastState();');
lines.push(' });');
lines.push('');
lines.push(' volBtn.addEventListener(\'click\', function() {');
lines.push(' if (audio.volume > 0) {');
lines.push(' prevVol = audio.volume;');
lines.push(' audio.volume = 0;');
lines.push(' volumeSlider.value = 0;');
lines.push(' } else {');
lines.push(' audio.volume = prevVol || 0.8;');
lines.push(' volumeSlider.value = audio.volume;');
lines.push(' }');
lines.push(' updateVolIcon(audio.volume);');
lines.push(' broadcastState();');
lines.push(' });');
lines.push('');
lines.push(' // --- Listen for commands from main page via BroadcastChannel ---');
lines.push(' if (channel) {');
lines.push(' channel.addEventListener(\'message\', function(e) {');
lines.push(' var msg = e.data;');
lines.push(' if (!msg || !msg.type) return;');
lines.push('');
lines.push(' if (msg.type === \'player-load\') {');
lines.push(' loadAndPlay(msg.url, msg.title);');
lines.push(' } else if (msg.type === \'player-toggle\') {');
lines.push(' if (audio.paused) audio.play().catch(function() {});');
lines.push(' else audio.pause();');
lines.push(' } else if (msg.type === \'player-stop\') {');
lines.push(' audio.pause();');
lines.push(' audio.src = \'\';');
lines.push(' localStorage.removeItem(\'mediaPopupState\');');
lines.push(' localStorage.removeItem(\'mediaPlayerUrl\');');
lines.push(' localStorage.removeItem(\'mediaPlayerTitle\');');
lines.push(' localStorage.removeItem(\'mediaPlayerPlaying\');');
lines.push(' if (channel) {');
lines.push(' try { channel.postMessage({ type: \'player-closed\' }); } catch(e) {}');
lines.push(' }');
lines.push(' window.close();');
lines.push(' } else if (msg.type === \'player-seek\') {');
lines.push(' if (audio.duration && isFinite(audio.duration)) {');
lines.push(' audio.currentTime = (msg.value / 100) * audio.duration;');
lines.push(' }');
lines.push(' } else if (msg.type === \'player-volume\') {');
lines.push(' audio.volume = parseFloat(msg.value);');
lines.push(' volumeSlider.value = msg.value;');
lines.push(' updateVolIcon(audio.volume);');
lines.push(' } else if (msg.type === \'player-mute-toggle\') {');
lines.push(' if (audio.volume > 0) {');
lines.push(' prevVol = audio.volume;');
lines.push(' audio.volume = 0;');
lines.push(' volumeSlider.value = 0;');
lines.push(' } else {');
lines.push(' audio.volume = prevVol || 0.8;');
lines.push(' volumeSlider.value = audio.volume;');
lines.push(' }');
lines.push(' updateVolIcon(audio.volume);');
lines.push(' broadcastState();');
lines.push(' }');
lines.push(' });');
lines.push(' }');
lines.push('');
lines.push(' // --- Also listen for localStorage changes (fallback for no BroadcastChannel) ---');
lines.push(' window.addEventListener(\'storage\', function(e) {');
lines.push(' if (e.key === \'mediaPopupState\' && e.newValue) {');
lines.push(' try {');
lines.push(' var state = JSON.parse(e.newValue);');
lines.push(' if (state.url) {');
lines.push(' loadAndPlay(state.url, state.title);');
lines.push(' }');
lines.push(' } catch(err) {}');
lines.push(' } else if (e.key === \'mediaPlayerClosed\') {');
lines.push(' if (playerBar) playerBar.classList.remove(\'show\');');
lines.push(' playerPopup = null;');
lines.push(' }');
lines.push(' });');
lines.push('');
lines.push(' // --- Clean up on window close ---');
lines.push(' window.addEventListener(\'beforeunload\', function() {');
lines.push(' localStorage.removeItem(\'mediaPopupState\');');
lines.push(' if (channel) {');
lines.push(' try { channel.postMessage({ type: \'player-closed\' }); } catch(e) {}');
lines.push(' }');
lines.push(' });');
lines.push('');
lines.push(' // --- Initial load: read config from localStorage ---');
lines.push(' var initUrl = localStorage.getItem(\'mediaPlayerUrl\');');
lines.push(' var initTitle = localStorage.getItem(\'mediaPlayerTitle\');');
lines.push('');
lines.push(' if (initUrl) {');
lines.push(' loadAndPlay(initUrl, initTitle);');
lines.push(' } else {');
lines.push(' playerCard.style.display = \'none\';');
lines.push(' noMediaEl.style.display = \'\';');
lines.push(' }');
lines.push('');
lines.push(' // Announce that the popup is ready');
lines.push(' if (channel) {');
lines.push(' try { channel.postMessage({ type: \'player-ready\' }); } catch(e) {}');
lines.push(' }');
lines.push('})();');
lines.push('<\/script>');
lines.push('</body>');
lines.push('</html>');
lines.push('');
return lines.join('\n');
}
/**
* Open (or re-use) the popup player window using a Blob URL.
* Blob URLs share the parent page's origin, so BroadcastChannel
* and localStorage work seamlessly - and no server file is needed.
* Communication via BroadcastChannel API.
* The inline bar serves as a "Now Playing" indicator and control relay.
*/
function showPlayer(url, title) {
// Save config to localStorage - the popup reads it on load
localStorage.setItem('mediaPlayerUrl', url);
localStorage.setItem('mediaPlayerTitle', title || url);
localStorage.setItem('mediaPlayerPlaying', 'true');
// If popup already exists and is open, send it a new track
if (playerPopup && !playerPopup.closed) {
if (playerChannel) {
playerChannel.postMessage({ type: 'player-load', url: url, title: title });
}
playerPopup.focus();
} else {
// Generate HTML and create Blob URL
var html = buildPlayerHTML();
var blob = new Blob([html], { type: 'text/html' });
var blobUrl = URL.createObjectURL(blob);
playerPopup = window.open(
blobUrl,
'shaarli-media-player',
'width=460,height=420,resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no'
);
// Revoke the blob URL after a short delay (popup already loaded)
setTimeout(function () { URL.revokeObjectURL(blobUrl); }, 5000);
if (!playerPopup) {
// Popup was blocked - fallback to inline player
URL.revokeObjectURL(blobUrl);
fallbackInlinePlay(url, title);
return;
}
}
// Update inline bar as "Now Playing" indicator
updateInlineBar(title, true);
}
/**
* Fallback: Play inline if popup is blocked
*/
function fallbackInlinePlay(url, title) {
var playerAudio = document.getElementById('media-player-audio');
if (!playerBar || !playerAudio) return;
playerAudio.src = url;
playerAudio.volume = parseFloat(playerVolume ? playerVolume.value : 0.8);
playerAudio.play().catch(function () { });
updateInlineBar(title, true);
}
function updateInlineBar(title, playing) {
if (!playerBar) return;
if (playerTitle) playerTitle.textContent = title || 'No media';
if (playerPlayIcon) {
playerPlayIcon.className = playing ? 'mdi mdi-pause' : 'mdi mdi-play';
}
playerBar.classList.add('show');
}
function formatTime(seconds) {
if (!seconds || isNaN(seconds) || !isFinite(seconds)) return '0:00';
var m = Math.floor(seconds / 60);
var s = Math.floor(seconds % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
function closePlayer() {
if (playerChannel) {
playerChannel.postMessage({ type: 'player-stop' });
}
if (playerPopup && !playerPopup.closed) {
try { playerPopup.close(); } catch (e) { }
}
var playerAudio = document.getElementById('media-player-audio');
if (playerAudio) {
playerAudio.pause();
playerAudio.src = '';
}
if (playerBar) playerBar.classList.remove('show');
localStorage.removeItem('mediaPlayerUrl');
localStorage.removeItem('mediaPlayerTitle');
localStorage.removeItem('mediaPlayerPosition');
localStorage.removeItem('mediaPlayerPlaying');
localStorage.removeItem('mediaPopupState');
playerPopup = null;
}
function updateVolIcon(vol) {
if (!playerVolIcon) return;
if (vol <= 0) {
playerVolIcon.className = 'mdi mdi-volume-off';
} else if (vol < 0.5) {
playerVolIcon.className = 'mdi mdi-volume-medium';
} else {
playerVolIcon.className = 'mdi mdi-volume-high';
}
}
// --- Inline bar controls relay to popup via BroadcastChannel ---
if (playerPlayBtn) {
playerPlayBtn.addEventListener('click', function () {
if (playerChannel) {
playerChannel.postMessage({ type: 'player-toggle' });
}
if (!playerPopup || playerPopup.closed) {
var savedUrl = localStorage.getItem('mediaPlayerUrl');
var savedTitle = localStorage.getItem('mediaPlayerTitle');
if (savedUrl) {
showPlayer(savedUrl, savedTitle || savedUrl);
}
}
});
}
if (playerCloseBtn) {
playerCloseBtn.addEventListener('click', closePlayer);
}
if (playerProgress) {
playerProgress.addEventListener('input', function () {
if (playerChannel) {
playerChannel.postMessage({ type: 'player-seek', value: playerProgress.value });
}
});
}
if (playerVolume) {
playerVolume.addEventListener('input', function () {
if (playerChannel) {
playerChannel.postMessage({ type: 'player-volume', value: playerVolume.value });
}
updateVolIcon(playerVolume.value);
});
}
if (playerVolBtn) {
playerVolBtn.addEventListener('click', function () {
if (playerChannel) {
playerChannel.postMessage({ type: 'player-mute-toggle' });
}
});
}
// --- Listen for state updates from popup via BroadcastChannel ---
if (playerChannel) {
playerChannel.addEventListener('message', function (e) {
var msg = e.data;
if (!msg || !msg.type) return;
if (msg.type === 'player-state') {
updateInlineBar(msg.title, msg.playing);
if (playerProgress && isFinite(msg.duration) && msg.duration > 0) {
playerProgress.value = (msg.currentTime / msg.duration) * 100;
playerProgress.style.display = '';
} else if (playerProgress && !isFinite(msg.duration)) {
playerProgress.style.display = 'none';
}
if (playerTime) {
if (!isFinite(msg.duration)) {
playerTime.textContent = 'LIVE';
} else {
playerTime.textContent = formatTime(msg.currentTime) + ' / ' + formatTime(msg.duration);
}
}
if (msg.volume !== undefined) {
updateVolIcon(msg.volume);
if (playerVolume) playerVolume.value = msg.volume;
}
} else if (msg.type === 'player-closed') {
if (playerBar) playerBar.classList.remove('show');
localStorage.removeItem('mediaPlayerUrl');
localStorage.removeItem('mediaPlayerTitle');
localStorage.removeItem('mediaPlayerPlaying');
localStorage.removeItem('mediaPopupState');
playerPopup = null;
}
});
}
// --- Also listen for localStorage changes (fallback) ---
window.addEventListener('storage', function (e) {
if (e.key === 'mediaPopupState' && e.newValue) {
try {
var state = JSON.parse(e.newValue);
updateInlineBar(state.title, state.playing);
} catch (err) { }
} else if (e.key === 'mediaPlayerClosed') {
if (playerBar) playerBar.classList.remove('show');
playerPopup = null;
}
});
// --- Inject play buttons into bookmark cards with media URLs ---
document.querySelectorAll('.link-outer').forEach(function (card) {
var urlEl = card.querySelector('.link-url');
var titleEl = card.querySelector('.link-title');
if (!urlEl) return;
var url = urlEl.textContent.trim();
var realUrl = (titleEl && titleEl.getAttribute('href')) || url;
if (isMediaUrl(url) || isMediaUrl(realUrl)) {
var actionsDiv = card.querySelector('.link-actions');
if (!actionsDiv) return;
var playBtn = document.createElement('button');
playBtn.className = 'media-play-action';
playBtn.title = 'Play media';
playBtn.innerHTML = '<i class="mdi mdi-play-circle-outline"></i>';
var openLinkBtn = actionsDiv.querySelector('a[title="Open Link"]');
if (openLinkBtn) {
actionsDiv.insertBefore(playBtn, openLinkBtn);
} else {
actionsDiv.appendChild(playBtn);
}
playBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var mediaUrl = isMediaUrl(url) ? url : realUrl;
var mediaTitle = (titleEl && titleEl.textContent) ? titleEl.textContent.trim() : url;
showPlayer(mediaUrl, mediaTitle);
});
}
});
// --- Restore "Now Playing" bar if popup is still open ---
(function restorePlayerBar() {
var popupState = localStorage.getItem('mediaPopupState');
if (popupState) {
try {
var state = JSON.parse(popupState);
if (state.url) {
updateInlineBar(state.title, state.playing);
}
} catch (err) { }
}
})();
// --- Daily calendar range picker ---
(function initDailyCalendar() {
var calendarToggle = document.getElementById('daily-calendar-toggle');
var calendarPanel = document.getElementById('daily-calendar-panel');
var calendarWrap = calendarPanel ? calendarPanel.closest('.daily-calendar-wrap') : null;
var calendarMonth = document.getElementById('daily-calendar-month');
var calendarWeekdays = document.getElementById('daily-calendar-weekdays');
var calendarGrid = document.getElementById('daily-calendar-grid');
var calendarPrev = document.getElementById('daily-calendar-prev');
var calendarNext = document.getElementById('daily-calendar-next');
var calendarStart = document.getElementById('daily-calendar-start');
var calendarEnd = document.getElementById('daily-calendar-end');
var calendarCancel = document.getElementById('daily-calendar-cancel');
var calendarApply = document.getElementById('daily-calendar-apply');
var dateDisplay = document.getElementById('daily-date-display');
var shortcuts = document.querySelectorAll('.daily-calendar-shortcut');
if (!calendarToggle || !calendarPanel || !calendarGrid || !calendarWeekdays || !calendarMonth) return;
var MONTH_NAMES = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
var DAY_NAMES = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
var state = {
viewDate: new Date(),
startDate: null,
endDate: null,
hoverDate: null
};
function getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year, month) {
var day = new Date(year, month, 1).getDay();
return day === 0 ? 6 : day - 1;
}
function formatDate(date) {
if (!date) return '';
return new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }).format(date);
}
function isSameDay(d1, d2) {
if (!d1 || !d2) return false;
return d1.getDate() === d2.getDate() &&
d1.getMonth() === d2.getMonth() &&
d1.getFullYear() === d2.getFullYear();
}
function isDateBetween(date, start, end) {
if (!start || !end || !date) return false;
return date > start && date < end;
}
function normalizeDate(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function updateSummary() {
if (calendarStart) calendarStart.textContent = state.startDate ? formatDate(state.startDate) : '-';
if (calendarEnd) calendarEnd.textContent = state.endDate ? formatDate(state.endDate) : '-';
if (calendarApply) calendarApply.disabled = !(state.startDate && state.endDate);
if (dateDisplay) {
if (state.startDate && state.endDate) {
if (isSameDay(state.startDate, state.endDate)) {
dateDisplay.textContent = formatDate(state.startDate);
} else {
dateDisplay.textContent = formatDate(state.startDate) + ' - ' + formatDate(state.endDate);
}
}
}
}
function renderWeekdays() {
calendarWeekdays.innerHTML = DAY_NAMES.map(function(day) {
return '<span>' + day + '</span>';
}).join('');
}
function renderDays() {
var year = state.viewDate.getFullYear();
var month = state.viewDate.getMonth();
var daysInMonth = getDaysInMonth(year, month);
var firstDay = getFirstDayOfMonth(year, month);
calendarMonth.textContent = MONTH_NAMES[month] + ' ' + year;
calendarGrid.innerHTML = '';
// Empty slots
for (var i = 0; i < firstDay; i++) {
var empty = document.createElement('span');
empty.className = 'daily-calendar-empty';
calendarGrid.appendChild(empty);
}
// Days
for (var day = 1; day <= daysInMonth; day++) {
var currentDate = new Date(year, month, day);
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = day;
btn.className = 'daily-calendar-day';
var isSelectedStart = isSameDay(currentDate, state.startDate);
var isSelectedEnd = isSameDay(currentDate, state.endDate);
var rangeEnd = state.endDate || state.hoverDate;
var isInRange = rangeEnd && isDateBetween(currentDate, state.startDate, rangeEnd);
// Apply classes like React version
if (isSelectedStart && isSelectedEnd) {
btn.classList.add('is-range-start', 'is-range-end', 'is-range-single');
} else if (isSelectedStart) {
btn.classList.add('is-range-start');
} else if (isSelectedEnd) {
btn.classList.add('is-range-end');
} else if (isInRange) {
btn.classList.add('is-in-range');
}
// Event handlers with closure
(function(d, y, m) {
btn.addEventListener('mouseenter', function() {
if (state.startDate && !state.endDate) {
var newHoverDate = new Date(y, m, d);
if (!state.hoverDate || state.hoverDate.getTime() !== newHoverDate.getTime()) {
state.hoverDate = newHoverDate;
renderDays();
}
}
});
btn.addEventListener('click', function(e) {
e.stopPropagation();
var clickedDate = normalizeDate(new Date(y, m, d));
console.log('Date clicked:', clickedDate);
console.log('Before click - Start:', state.startDate, 'End:', state.endDate);
if (!state.startDate || (state.startDate && state.endDate)) {
// Start new selection
state.startDate = clickedDate;
state.endDate = null;
console.log('New selection started - Start:', state.startDate, 'End:', state.endDate);
} else if (state.startDate && !state.endDate) {
// Complete selection
if (clickedDate < state.startDate) {
state.endDate = state.startDate;
state.startDate = clickedDate;
console.log('Range completed (reverse) - Start:', state.startDate, 'End:', state.endDate);
} else {
state.endDate = clickedDate;
console.log('Range completed - Start:', state.startDate, 'End:', state.endDate);
}
}
state.hoverDate = null;
updateSummary();
renderDays();
});
})(day, year, month);
calendarGrid.appendChild(btn);
}
}
function setOpen(isOpen) {
calendarPanel.classList.toggle('is-open', isOpen);
calendarPanel.setAttribute('aria-hidden', String(!isOpen));
if (calendarWrap) calendarWrap.classList.toggle('is-open', isOpen);
if (calendarToggle) calendarToggle.setAttribute('aria-expanded', String(isOpen));
}
function selectPreset(days) {
var end = normalizeDate(new Date());
var start = normalizeDate(new Date());
start.setDate(end.getDate() - days);
state.startDate = start;
state.endDate = end;
state.viewDate = new Date(end.getFullYear(), end.getMonth(), 1);
updateSummary();
renderDays();
}
// Event listeners
calendarToggle.addEventListener('click', function() {
var willOpen = !calendarPanel.classList.contains('is-open');
setOpen(willOpen);
});
document.addEventListener('click', function(event) {
if (!calendarPanel.classList.contains('is-open')) return;
if (calendarWrap && !calendarWrap.contains(event.target) && event.target !== calendarToggle) {
setOpen(false);
}
});
calendarPanel.addEventListener('click', function(event) {
event.stopPropagation();
});
if (calendarPrev) {
calendarPrev.addEventListener('click', function() {
state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() - 1, 1);
renderDays();
});
}
if (calendarNext) {
calendarNext.addEventListener('click', function() {
state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() + 1, 1);
renderDays();
});
}
shortcuts.forEach(function(shortcut) {
shortcut.addEventListener('click', function() {
var range = parseInt(this.dataset.range, 10);
selectPreset(range);
});
});
if (calendarCancel) {
calendarCancel.addEventListener('click', function() {
setOpen(false);
});
}
if (calendarApply) {
calendarApply.addEventListener('click', function() {
if (!state.startDate || !state.endDate) return;
setOpen(false);
// Navigate to selected range
var startFormatted = state.startDate.toISOString().split('T')[0].replace(/-/g, '');
var endFormatted = state.endDate.toISOString().split('T')[0].replace(/-/g, '');
var url = window.location.pathname + '?start=' + startFormatted + '&end=' + endFormatted;
console.log('Navigating to range URL:', url);
window.location.href = url;
});
}
// Initialize
renderWeekdays();
updateSummary();
renderDays();
})();
});