1865 lines
65 KiB
JavaScript

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 toggleSidebar() {
sidebar?.classList.toggle('show');
sidebarOverlay?.classList.toggle('show');
}
mobileMenuBtn?.addEventListener('click', toggleSidebar);
sidebarOverlay?.addEventListener('click', toggleSidebar);
// ===== 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;
// Highlight matching text with <mark> tags
function highlightMatch(text, query) {
if (!query || query.length === 0) return text;
// Escape special regex characters in query
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return text.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 isTyping = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
// 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.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() {
filterPanel?.classList.toggle('show');
}
function closeFilterPanel() {
filterPanel?.classList.remove('show');
}
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')) {
if (!filterPanel.contains(e.target) && e.target !== filterToggleBtn) {
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);
}
});
// ===== ReadItLater Plugin Integration =====
document.querySelectorAll('.link-plugin .readitlater-toggle').forEach(toggle => {
const iconSpan = toggle.querySelector('.readitlater-icon');
if (!iconSpan) return;
const card = toggle.closest('.link-outer');
const isUnread = card?.classList.contains('readitlater-unread');
const titleText = toggle.getAttribute('title') || '';
// Replace text content with MDI icon
const mdiIcon = document.createElement('i');
if (isUnread) {
mdiIcon.className = 'mdi mdi-eye-off';
} else {
mdiIcon.className = 'mdi mdi-eye-outline';
}
iconSpan.innerHTML = '';
iconSpan.appendChild(mdiIcon);
// Set proper tooltip
toggle.setAttribute('title', titleText || (isUnread ? 'Mark as Read' : 'Read it later'));
// Add "To Read" badge to unread cards
if (isUnread && card && !card.querySelector('.readitlater-badge')) {
const badge = document.createElement('div');
badge.className = 'readitlater-badge';
badge.innerHTML = '<i class="mdi mdi-bookmark-check"></i> To Read';
card.appendChild(badge);
}
});
// ===== Persistent Media Player (Popup Strategy) =====
// Audio plays in a separate popup window that survives page navigation.
// 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;
const lower = url.toLowerCase();
const pathname = lower.split('?')[0].split('#')[0];
for (const ext of MEDIA_EXTENSIONS) {
if (pathname.endsWith(ext)) return true;
}
if (/\/(stream|listen|live|icecast|shoutcast)(\/|$|\?)/i.test(url)) {
return true;
}
return false;
}
const playerBar = document.getElementById('media-player-bar');
const playerPlayBtn = document.getElementById('media-player-play');
const playerPlayIcon = document.getElementById('media-player-play-icon');
const playerTitle = document.getElementById('media-player-title');
const playerProgress = document.getElementById('media-player-progress');
const playerTime = document.getElementById('media-player-time');
const playerVolume = document.getElementById('media-player-volume');
const playerVolBtn = document.getElementById('media-player-vol-btn');
const playerVolIcon = document.getElementById('media-player-vol-icon');
const playerCloseBtn = document.getElementById('media-player-close');
// Reference to the popup window
let playerPopup = null;
/**
* Generate the full HTML for the popup player window.
* It is self-contained with its own styles and audio logic.
*/
function buildPopupHTML(url, title, volume) {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const escapedTitle = title.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const escapedUrl = url.replace(/'/g, "\\'").replace(/"/g, '&quot;');
return `<!DOCTYPE html>
<html data-theme="${isDark ? 'dark' : 'light'}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>♪ ${escapedTitle}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/7.2.96/css/materialdesignicons.min.css">
<style>
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-light: rgba(37,99,235,0.08);
--bg-body: #f1f5f9;
--bg-card: #ffffff;
--text-main: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border: #e2e8f0;
--danger: #ef4444;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
}
[data-theme="dark"] {
--primary: #3b82f6;
--primary-hover: #60a5fa;
--primary-light: rgba(59,130,246,0.12);
--bg-body: #0f172a;
--bg-card: #1e293b;
--text-main: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border: #334155;
--danger: #f87171;
--shadow: 0 1px 3px rgba(0,0,0,0.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
background: var(--bg-body);
color: var(--text-main);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
user-select: none;
overflow: hidden;
}
.player-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 1rem;
box-shadow: var(--shadow);
padding: 1.5rem;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.player-artwork {
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-light);
border-radius: 0.75rem;
padding: 1.5rem;
position: relative;
overflow: hidden;
}
.player-artwork i {
font-size: 3rem;
color: var(--primary);
animation: pulse-icon 2s ease-in-out infinite;
opacity: 0.6;
}
.player-artwork i.playing { animation: pulse-icon 1.5s ease-in-out infinite; opacity: 1; }
@keyframes pulse-icon {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
.live-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: var(--danger);
color: white;
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.06em;
display: none;
}
.live-badge.show { display: block; }
.player-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-main);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.player-progress-wrap { width: 100%; }
.player-progress {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 5px;
background: var(--border);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.player-progress:hover { height: 7px; }
.player-progress::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
}
.player-progress::-moz-range-thumb {
width: 14px; height: 14px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
}
.player-time {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 0.2rem;
font-variant-numeric: tabular-nums;
}
.player-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
background: transparent;
color: var(--text-secondary);
}
.ctrl-btn:hover { background: var(--primary-light); color: var(--primary); }
.ctrl-btn.sm { width: 36px; height: 36px; font-size: 1.1rem; }
.ctrl-btn.lg {
width: 52px; height: 52px;
background: var(--primary);
color: white;
font-size: 1.5rem;
}
.ctrl-btn.lg:hover { background: var(--primary-hover); transform: scale(1.06); }
.ctrl-btn.close:hover { background: rgba(239,68,68,0.1); color: var(--danger); }
.volume-wrap {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px; height: 12px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
}
</style>
</head>
<body>
<div class="player-card">
<div class="player-artwork" id="artwork">
<i class="mdi mdi-music-note" id="artwork-icon"></i>
<span class="live-badge" id="live-badge">LIVE</span>
</div>
<div class="player-title" id="title">${escapedTitle}</div>
<div class="player-progress-wrap" id="progress-wrap">
<input type="range" class="player-progress" id="progress" min="0" max="100" value="0" step="0.1">
<div class="player-time">
<span id="time-current">0:00</span>
<span id="time-total">0:00</span>
</div>
</div>
<div class="player-controls">
<button class="ctrl-btn sm" id="vol-btn" title="Mute/Unmute">
<i class="mdi mdi-volume-high" id="vol-icon"></i>
</button>
<button class="ctrl-btn lg" id="play-btn" title="Play/Pause">
<i class="mdi mdi-play" id="play-icon"></i>
</button>
<button class="ctrl-btn sm close" id="close-btn" title="Stop & Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="volume-wrap">
<i class="mdi mdi-volume-low" style="color:var(--text-muted);font-size:0.85rem;"></i>
<input type="range" class="volume-slider" id="volume" min="0" max="1" value="${volume}" step="0.01">
<i class="mdi mdi-volume-high" style="color:var(--text-muted);font-size:0.85rem;"></i>
</div>
</div>
<audio id="audio" preload="metadata"></audio>
<script>
(function() {
const audio = document.getElementById('audio');
const playBtn = document.getElementById('play-btn');
const playIcon = document.getElementById('play-icon');
const closeBtn = document.getElementById('close-btn');
const progress = document.getElementById('progress');
const progressWrap = document.getElementById('progress-wrap');
const timeCurrent = document.getElementById('time-current');
const timeTotal = document.getElementById('time-total');
const volumeSlider = document.getElementById('volume');
const volBtn = document.getElementById('vol-btn');
const volIcon = document.getElementById('vol-icon');
const artworkIcon = document.getElementById('artwork-icon');
const liveBadge = document.getElementById('live-badge');
const titleEl = document.getElementById('title');
let currentUrl = '${escapedUrl}';
let currentTitle = '${escapedTitle}';
let prevVol = ${volume};
function fmt(s) {
if (!s || isNaN(s) || !isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
function updateVolIcon(v) {
if (v <= 0) volIcon.className = 'mdi mdi-volume-off';
else if (v < 0.5) volIcon.className = 'mdi mdi-volume-medium';
else volIcon.className = 'mdi mdi-volume-high';
}
function syncToParent() {
const state = {
type: 'shaarli-player-state',
url: currentUrl,
title: currentTitle,
playing: !audio.paused,
currentTime: audio.currentTime,
duration: audio.duration,
volume: audio.volume
};
localStorage.setItem('mediaPopupState', JSON.stringify(state));
// Also notify opener if still open
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(state, '*');
}
} catch(e) {}
}
// Load and play
function loadAndPlay(url, title) {
currentUrl = url;
currentTitle = title;
titleEl.textContent = title;
document.title = '♪ ' + title;
audio.src = url;
audio.volume = parseFloat(volumeSlider.value);
audio.play().catch(function() {});
}
audio.addEventListener('play', function() {
playIcon.className = 'mdi mdi-pause';
artworkIcon.classList.add('playing');
syncToParent();
});
audio.addEventListener('pause', function() {
playIcon.className = 'mdi mdi-play';
artworkIcon.classList.remove('playing');
syncToParent();
});
audio.addEventListener('timeupdate', function() {
if (!audio.duration) return;
if (isFinite(audio.duration)) {
progress.value = (audio.currentTime / audio.duration) * 100;
timeCurrent.textContent = fmt(audio.currentTime);
}
// Sync every 2s
if (Math.floor(audio.currentTime) % 2 === 0) syncToParent();
});
audio.addEventListener('loadedmetadata', function() {
if (!isFinite(audio.duration)) {
liveBadge.classList.add('show');
progressWrap.style.display = 'none';
artworkIcon.className = 'mdi mdi-radio-tower playing';
} else {
liveBadge.classList.remove('show');
progressWrap.style.display = '';
timeTotal.textContent = fmt(audio.duration);
artworkIcon.className = 'mdi mdi-music-note';
}
});
audio.addEventListener('ended', function() {
playIcon.className = 'mdi mdi-play';
progress.value = 0;
artworkIcon.classList.remove('playing');
syncToParent();
});
playBtn.addEventListener('click', function() {
if (audio.paused) audio.play().catch(function() {});
else audio.pause();
});
closeBtn.addEventListener('click', function() {
audio.pause();
audio.src = '';
// Clear state
localStorage.removeItem('mediaPopupState');
localStorage.setItem('mediaPlayerClosed', Date.now().toString());
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage({ type: 'shaarli-player-closed' }, '*');
}
} catch(e) {}
window.close();
});
progress.addEventListener('input', function() {
if (audio.duration && isFinite(audio.duration)) {
audio.currentTime = (progress.value / 100) * audio.duration;
}
});
volumeSlider.addEventListener('input', function() {
audio.volume = volumeSlider.value;
updateVolIcon(audio.volume);
syncToParent();
});
volBtn.addEventListener('click', function() {
if (audio.volume > 0) {
prevVol = audio.volume;
audio.volume = 0;
volumeSlider.value = 0;
} else {
audio.volume = prevVol || 0.8;
volumeSlider.value = audio.volume;
}
updateVolIcon(audio.volume);
syncToParent();
});
// Listen for messages from parent window (e.g. play new track)
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'shaarli-player-load') {
loadAndPlay(e.data.url, e.data.title);
} else if (e.data && e.data.type === 'shaarli-player-toggle') {
if (audio.paused) audio.play().catch(function() {});
else audio.pause();
} else if (e.data && e.data.type === 'shaarli-player-stop') {
audio.pause();
audio.src = '';
localStorage.removeItem('mediaPopupState');
window.close();
}
});
// Notify parent that popup is ready
syncToParent();
// Start playing
loadAndPlay(currentUrl, currentTitle);
// On window close, clean up
window.addEventListener('beforeunload', function() {
localStorage.removeItem('mediaPopupState');
localStorage.setItem('mediaPlayerClosed', Date.now().toString());
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage({ type: 'shaarli-player-closed' }, '*');
}
} catch(e) {}
});
})();
</script>
</body>
</html>`;
}
/**
* Open (or re-use) the popup player window
*/
function showPlayer(url, title) {
const volume = playerVolume?.value || 0.8;
// If popup already exists and is open, send it a new track
if (playerPopup && !playerPopup.closed) {
playerPopup.postMessage({
type: 'shaarli-player-load',
url: url,
title: title
}, '*');
playerPopup.focus();
} else {
// Open a new popup window
const popupHTML = buildPopupHTML(url, title, volume);
playerPopup = window.open('', 'shaarli-media-player',
'width=460,height=380,resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no'
);
if (playerPopup) {
playerPopup.document.write(popupHTML);
playerPopup.document.close();
} else {
// Popup blocked — fallback to inline player
fallbackInlinePlay(url, title);
return;
}
}
// Update inline bar as "Now Playing" indicator
updateInlineBar(title, true);
// Save state
localStorage.setItem('mediaPlayerUrl', url);
localStorage.setItem('mediaPlayerTitle', title || url);
localStorage.setItem('mediaPlayerPlaying', 'true');
}
/**
* Fallback: Play inline if popup is blocked
*/
function fallbackInlinePlay(url, title) {
const playerAudio = document.getElementById('media-player-audio');
if (!playerBar || !playerAudio) return;
playerAudio.src = url;
playerAudio.volume = parseFloat(playerVolume?.value || 0.8);
playerAudio.play().catch(() => { });
updateInlineBar(title, true);
localStorage.setItem('mediaPlayerUrl', url);
localStorage.setItem('mediaPlayerTitle', title || url);
localStorage.setItem('mediaPlayerPlaying', 'true');
}
/**
* Update the inline "Now Playing" bar
*/
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';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
function closePlayer() {
// Close popup if open
if (playerPopup && !playerPopup.closed) {
playerPopup.postMessage({ type: 'shaarli-player-stop' }, '*');
}
// Also stop inline audio fallback
const 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 ---
playerPlayBtn?.addEventListener('click', () => {
if (playerPopup && !playerPopup.closed) {
playerPopup.postMessage({ type: 'shaarli-player-toggle' }, '*');
playerPopup.focus();
} else {
// If no popup, try to re-open with saved state
const savedUrl = localStorage.getItem('mediaPlayerUrl');
const savedTitle = localStorage.getItem('mediaPlayerTitle');
if (savedUrl) {
showPlayer(savedUrl, savedTitle || savedUrl);
}
}
});
playerCloseBtn?.addEventListener('click', closePlayer);
// --- Listen for state updates from popup via postMessage ---
window.addEventListener('message', (e) => {
if (!e.data || !e.data.type) return;
if (e.data.type === 'shaarli-player-state') {
// Sync state from popup to inline bar
updateInlineBar(e.data.title, e.data.playing);
if (playerProgress && isFinite(e.data.duration) && e.data.duration > 0) {
playerProgress.value = (e.data.currentTime / e.data.duration) * 100;
playerProgress.style.display = '';
} else if (playerProgress && !isFinite(e.data.duration)) {
playerProgress.style.display = 'none';
}
if (playerTime) {
if (!isFinite(e.data.duration)) {
playerTime.textContent = 'LIVE';
} else {
playerTime.textContent = formatTime(e.data.currentTime) + ' / ' + formatTime(e.data.duration);
}
}
if (e.data.volume !== undefined) {
updateVolIcon(e.data.volume);
if (playerVolume) playerVolume.value = e.data.volume;
}
} else if (e.data.type === 'shaarli-player-closed') {
// Popup was 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 (for cross-tab sync) ---
window.addEventListener('storage', (e) => {
if (e.key === 'mediaPopupState' && e.newValue) {
try {
const 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;
}
});
// Forward progress/volume changes from inline bar to popup
playerProgress?.addEventListener('input', () => {
if (playerPopup && !playerPopup.closed) {
// Send seek command — popup will handle
playerPopup.postMessage({
type: 'shaarli-player-seek',
value: playerProgress.value
}, '*');
}
});
playerVolume?.addEventListener('input', () => {
if (playerPopup && !playerPopup.closed) {
playerPopup.postMessage({
type: 'shaarli-player-volume',
value: playerVolume.value
}, '*');
}
updateVolIcon(playerVolume.value);
});
playerVolBtn?.addEventListener('click', () => {
if (playerPopup && !playerPopup.closed) {
playerPopup.postMessage({ type: 'shaarli-player-mute-toggle' }, '*');
}
});
// --- Inject play buttons into bookmark cards with media URLs ---
document.querySelectorAll('.link-outer').forEach(card => {
const urlEl = card.querySelector('.link-url');
const titleEl = card.querySelector('.link-title');
if (!urlEl) return;
const url = urlEl.textContent.trim();
const realUrl = titleEl?.getAttribute('href') || url;
if (isMediaUrl(url) || isMediaUrl(realUrl)) {
const actionsDiv = card.querySelector('.link-actions');
if (!actionsDiv) return;
const playBtn = document.createElement('button');
playBtn.className = 'media-play-action';
playBtn.title = 'Play media';
playBtn.innerHTML = '<i class="mdi mdi-play-circle-outline"></i>';
const openLinkBtn = actionsDiv.querySelector('a[title="Open Link"]');
if (openLinkBtn) {
actionsDiv.insertBefore(playBtn, openLinkBtn);
} else {
actionsDiv.appendChild(playBtn);
}
playBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const mediaUrl = isMediaUrl(url) ? url : realUrl;
const mediaTitle = titleEl?.textContent?.trim() || url;
showPlayer(mediaUrl, mediaTitle);
});
}
});
// --- Restore "Now Playing" bar if popup is still open ---
(function restorePlayerBar() {
const popupState = localStorage.getItem('mediaPopupState');
if (popupState) {
try {
const state = JSON.parse(popupState);
if (state.url) {
updateInlineBar(state.title, state.playing);
// Try to re-attach to existing popup
try {
const existingPopup = window.open('', 'shaarli-media-player');
if (existingPopup && existingPopup.location.href !== 'about:blank' && !existingPopup.closed) {
playerPopup = existingPopup;
}
} catch (e) { }
}
} catch (err) { }
}
})();
});