2789 lines
113 KiB
JavaScript
2789 lines
113 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 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();
|
||
})();
|
||
});
|