CI / build (push) Failing after 6m2s
Introduce a Node build system for minified CSS/JS with content hashes, add PWA support, i18n helpers, accessibility improvements, and more translatable strings. Move theme configuration from bookmarks to localStorage, add Playwright tests, and set up CI workflows.
2354 lines
100 KiB
JavaScript
2354 lines
100 KiB
JavaScript
// =====================================================================
|
||
// Core page interactions for Shaarli Pro.
|
||
// Theme handling lives in js/modules/theme.js.
|
||
// =====================================================================
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
|
||
|
||
// ===== Hidden Tags Management =====
|
||
const HIDDEN_TAGS_STORAGE_KEY = 'shaarli_hidden_tags';
|
||
|
||
// Default system tags that are hidden by default
|
||
// Harmonisé avec ShaarIt Android (PRESET_SYSTEM_TAGS).
|
||
const DEFAULT_HIDDEN_TAGS = [
|
||
'note',
|
||
'shaarli-note',
|
||
'todo',
|
||
'shaarli-todo',
|
||
'shaarli-pin',
|
||
'note-color-*',
|
||
'notebg-*',
|
||
'notefilter-*',
|
||
'font-*',
|
||
'readitlater',
|
||
'brain-dump',
|
||
'shaarli-archive'
|
||
];
|
||
|
||
// Get hidden tags from localStorage
|
||
function getHiddenTags() {
|
||
try {
|
||
const stored = localStorage.getItem(HIDDEN_TAGS_STORAGE_KEY);
|
||
if (stored) {
|
||
return JSON.parse(stored);
|
||
}
|
||
} catch (e) {
|
||
console.error('Error loading hidden tags:', e);
|
||
}
|
||
return [...DEFAULT_HIDDEN_TAGS];
|
||
}
|
||
|
||
// Check if a tag matches a wildcard pattern
|
||
function matchesWildcard(pattern, tag) {
|
||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
|
||
return regex.test(tag);
|
||
}
|
||
|
||
// Check if a tag should be hidden
|
||
function isTagHidden(tag) {
|
||
if (!tag) return false;
|
||
const normalizedTag = tag.toLowerCase().trim();
|
||
const hiddenTags = getHiddenTags();
|
||
|
||
return hiddenTags.some(hiddenTag => {
|
||
const normalizedHidden = hiddenTag.toLowerCase().trim();
|
||
if (normalizedHidden.includes('*')) {
|
||
return matchesWildcard(normalizedHidden, normalizedTag);
|
||
}
|
||
return normalizedHidden === normalizedTag;
|
||
});
|
||
}
|
||
|
||
// Filter out hidden tags from a list
|
||
function filterHiddenTags(tags) {
|
||
if (!Array.isArray(tags)) return [];
|
||
return tags.filter(tag => !isTagHidden(tag));
|
||
}
|
||
|
||
// Hide tag elements in the DOM
|
||
function hideHiddenTagElements() {
|
||
// Hide tag chips in bookmark cards
|
||
document.querySelectorAll('.link-tag[data-tag]').forEach(el => {
|
||
const tag = el.getAttribute('data-tag');
|
||
if (isTagHidden(tag)) {
|
||
el.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Hide tags in tag list
|
||
document.querySelectorAll('#tagListContainer .list-group-item[data-tag-name]').forEach(el => {
|
||
const tagName = el.getAttribute('data-tag-name');
|
||
if (isTagHidden(tagName)) {
|
||
el.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Hide tags in tag cloud
|
||
document.querySelectorAll('.tag-item[data-tag]').forEach(el => {
|
||
const tag = el.getAttribute('data-tag');
|
||
if (isTagHidden(tag)) {
|
||
el.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Update visible tag count in tag list
|
||
const visibleTagCountEl = document.getElementById('visibleTagCount');
|
||
if (visibleTagCountEl) {
|
||
const visibleTags = document.querySelectorAll('#tagListContainer .list-group-item:not([style*="display: none"])');
|
||
visibleTagCountEl.textContent = visibleTags.length;
|
||
}
|
||
}
|
||
|
||
// Run on page load
|
||
hideHiddenTagElements();
|
||
|
||
// Set up MutationObserver to handle dynamically added tags
|
||
const tagObserver = new MutationObserver((mutations) => {
|
||
let shouldFilter = false;
|
||
mutations.forEach((mutation) => {
|
||
if (mutation.type === 'childList') {
|
||
mutation.addedNodes.forEach((node) => {
|
||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||
// Check if added node contains tags
|
||
if (node.matches?.('.link-tag[data-tag], .tag-item[data-tag], [data-tag-name], .list-group-item[data-tag-name]') ||
|
||
node.querySelector?.('.link-tag[data-tag], .tag-item[data-tag], [data-tag-name], .list-group-item[data-tag-name]')) {
|
||
shouldFilter = true;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
if (shouldFilter) {
|
||
hideHiddenTagElements();
|
||
}
|
||
});
|
||
|
||
// Start observing once DOM is ready
|
||
tagObserver.observe(document.body, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
|
||
// Expose utilities used by extracted modules
|
||
window.ShaarliProUtils = {
|
||
escapeHtml: function (text) {
|
||
if (text === null || text === undefined) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = String(text);
|
||
return div.innerHTML;
|
||
},
|
||
isTagHidden: isTagHidden,
|
||
filterHiddenTags: filterHiddenTags
|
||
};
|
||
|
||
// ===== 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'));
|
||
|
||
|
||
// ===== 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 || i18n.t('Processing...');
|
||
}
|
||
|
||
if (thumbnailPlaceholder) {
|
||
thumbnailPlaceholder.innerHTML = '';
|
||
if (data.thumbnail) {
|
||
const img = document.createElement('img');
|
||
img.title = i18n.t('Current thumbnail');
|
||
img.src = data.thumbnail;
|
||
img.style.maxWidth = '100%';
|
||
img.style.height = 'auto';
|
||
thumbnailPlaceholder.appendChild(img);
|
||
}
|
||
}
|
||
|
||
if (progressCurrent) {
|
||
progressCurrent.textContent = i;
|
||
}
|
||
|
||
if (progressBarActual) {
|
||
progressBarActual.style.width = ((i * 100) / total) + '%';
|
||
}
|
||
|
||
if (i < total) {
|
||
updateThumbnail(thumbnailsIdList[i]);
|
||
} else {
|
||
if (thumbnailTitle) {
|
||
thumbnailTitle.textContent = i18n.t('Thumbnail update done!');
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Failed to update thumbnail:', error);
|
||
if (thumbnailTitle) {
|
||
thumbnailTitle.textContent = i18n.t('Error updating thumbnail #{index}', { index: 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(contentNode) {
|
||
if (descModal && descModalBody) {
|
||
descModalBody.innerHTML = '';
|
||
if (contentNode) {
|
||
descModalBody.appendChild(contentNode);
|
||
}
|
||
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.cloneNode(true));
|
||
} else {
|
||
const emptyMsg = document.createElement('p');
|
||
emptyMsg.className = 'text-muted';
|
||
emptyMsg.textContent = i18n.t('No description available.');
|
||
openDescModal(emptyMsg);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// ===== 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;
|
||
|
||
function setStatusMessage(text) {
|
||
qrcodeModalBody.innerHTML = '';
|
||
const div = document.createElement('div');
|
||
div.style.padding = '2rem';
|
||
div.style.color = 'var(--text-muted)';
|
||
div.textContent = text;
|
||
qrcodeModalBody.appendChild(div);
|
||
}
|
||
|
||
// Show loading state
|
||
setStatusMessage(i18n.t('Generating QR Code...'));
|
||
qrcodeModal.classList.add('show');
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// Generate QR code using the qr.js library (loaded by the Shaarli qrcode plugin)
|
||
function renderQR() {
|
||
if (typeof qr !== 'undefined') {
|
||
const image = qr.image({ size: 8, value: permalink });
|
||
if (image) {
|
||
qrcodeModalBody.innerHTML = '';
|
||
image.style.maxWidth = '100%';
|
||
image.style.borderRadius = '0.5rem';
|
||
image.style.background = 'white';
|
||
image.style.padding = '0.75rem';
|
||
qrcodeModalBody.appendChild(image);
|
||
if (title) {
|
||
const titleDiv = document.createElement('div');
|
||
titleDiv.className = 'qrcode-modal-title';
|
||
titleDiv.textContent = title;
|
||
qrcodeModalBody.appendChild(titleDiv);
|
||
}
|
||
} else {
|
||
setStatusMessage(i18n.t('Failed to generate QR Code'));
|
||
}
|
||
} else {
|
||
// qr.js library not yet loaded — load it dynamically
|
||
const basePath = document.querySelector('input[name="js_base_path"]')?.value || '';
|
||
const script = document.createElement('script');
|
||
script.src = basePath + '/plugins/qrcode/qr-1.1.3.min.js';
|
||
document.body.appendChild(script);
|
||
setTimeout(() => renderQR(), 300);
|
||
}
|
||
}
|
||
renderQR();
|
||
}
|
||
|
||
function closeQrcodeModal() {
|
||
if (qrcodeModal) {
|
||
qrcodeModal.classList.remove('show');
|
||
document.body.style.overflow = '';
|
||
}
|
||
}
|
||
|
||
qrcodeModalClose?.addEventListener('click', closeQrcodeModal);
|
||
qrcodeModal?.addEventListener('click', (e) => {
|
||
if (e.target === qrcodeModal) closeQrcodeModal();
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && qrcodeModal?.classList.contains('show')) closeQrcodeModal();
|
||
});
|
||
|
||
// ===== Transform QR code plugin icons =====
|
||
document.querySelectorAll('.link-plugin .linkqrcode, .link-plugin img.qrcode').forEach(el => {
|
||
// The QR code plugin injects: <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 = i18n.t('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(i18n.t('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(i18n.t('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(i18n.t('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', i18n.t('Delete tag {tag}', { tag: READ_IT_LATER_TAG }));
|
||
removeBtn.title = i18n.t('Delete');
|
||
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 = i18n.t('Read Later');
|
||
card.appendChild(badge);
|
||
} else if (!isActive && badge) {
|
||
badge.remove();
|
||
}
|
||
|
||
const toggleBtn = card.querySelector('.readitlater-toggle-btn');
|
||
if (toggleBtn) {
|
||
const titleText = isActive ? i18n.t('Remove from Read It Later') : i18n.t('Add to 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(i18n.t('Failed to toggle readitlater tag.'));
|
||
} 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 todoCheckbox = form.querySelector('.bookmark-toggle-todo');
|
||
const titleInput = form.querySelector('input[name="lf_title"]');
|
||
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: 'wysiwyg',
|
||
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);
|
||
|
||
// Remove duplicate tags (case-insensitive)
|
||
tags = tags.filter((tag, index, self) =>
|
||
index === self.findIndex((t) => t.toLowerCase() === tag.toLowerCase())
|
||
);
|
||
|
||
// Update return URL based on bookmark type (note vs regular)
|
||
const returnUrlInput = form.querySelector('input[name="returnurl"]');
|
||
if (returnUrlInput) {
|
||
form.addEventListener('submit', () => {
|
||
const isNote = noteCheckbox?.checked || tags.some((tag) => /^note$/i.test(tag));
|
||
if (isNote) {
|
||
returnUrlInput.value = `${shaarli.basePath}/?searchtags=note`;
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
// Reconnaît les conventions Android (`note`, `#note`) et legacy (`shaarli-note`).
|
||
noteCheckbox.checked = tags.some((tag) => /^(note|#note|shaarli-note)$/i.test(tag));
|
||
};
|
||
|
||
const syncTodoCheckbox = () => {
|
||
if (!todoCheckbox) return;
|
||
// Reconnaît les conventions Android (`todo`, `#todo`) et legacy (`shaarli-todo`).
|
||
todoCheckbox.checked = tags.some((tag) => /^(todo|#todo|shaarli-todo)$/i.test(tag));
|
||
};
|
||
|
||
// Helper functions to manage note emoji in title
|
||
const NOTE_EMOJI = '📝';
|
||
const TODO_EMOJI = '✅';
|
||
|
||
const hasNoteEmoji = (title) => {
|
||
return title && title.startsWith(NOTE_EMOJI);
|
||
};
|
||
|
||
const hasTodoEmoji = (title) => {
|
||
return title && title.startsWith(TODO_EMOJI);
|
||
};
|
||
|
||
const addNoteEmoji = (title) => {
|
||
if (!title) return NOTE_EMOJI + ' ';
|
||
if (hasNoteEmoji(title)) return title;
|
||
return NOTE_EMOJI + ' ' + title;
|
||
};
|
||
|
||
const addTodoEmoji = (title) => {
|
||
if (!title) return TODO_EMOJI + ' ';
|
||
if (hasTodoEmoji(title)) return title;
|
||
return TODO_EMOJI + ' ' + title;
|
||
};
|
||
|
||
const removeNoteEmoji = (title) => {
|
||
if (!title) return '';
|
||
if (title.startsWith(NOTE_EMOJI + ' ')) {
|
||
return title.substring(NOTE_EMOJI.length + 1);
|
||
}
|
||
if (title.startsWith(NOTE_EMOJI)) {
|
||
return title.substring(NOTE_EMOJI.length);
|
||
}
|
||
return title;
|
||
};
|
||
|
||
const removeTodoEmoji = (title) => {
|
||
if (!title) return '';
|
||
if (title.startsWith(TODO_EMOJI + ' ')) {
|
||
return title.substring(TODO_EMOJI.length + 1);
|
||
}
|
||
if (title.startsWith(TODO_EMOJI)) {
|
||
return title.substring(TODO_EMOJI.length);
|
||
}
|
||
return title;
|
||
};
|
||
|
||
const updateNoteTitle = (isNote) => {
|
||
if (!titleInput) return;
|
||
const currentTitle = titleInput.value || '';
|
||
if (isNote) {
|
||
if (!hasNoteEmoji(currentTitle)) {
|
||
titleInput.value = addNoteEmoji(currentTitle);
|
||
}
|
||
} else {
|
||
if (hasNoteEmoji(currentTitle)) {
|
||
titleInput.value = removeNoteEmoji(currentTitle);
|
||
}
|
||
}
|
||
};
|
||
|
||
const updateTodoTitle = (isTodo) => {
|
||
if (!titleInput) return;
|
||
const currentTitle = titleInput.value || '';
|
||
if (isTodo) {
|
||
if (!hasTodoEmoji(currentTitle)) {
|
||
titleInput.value = addTodoEmoji(currentTitle);
|
||
}
|
||
} else {
|
||
if (hasTodoEmoji(currentTitle)) {
|
||
titleInput.value = removeTodoEmoji(currentTitle);
|
||
}
|
||
}
|
||
};
|
||
|
||
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();
|
||
syncTodoCheckbox();
|
||
renderTags();
|
||
refreshAutocomplete();
|
||
}
|
||
};
|
||
|
||
const removeTag = (tagToRemove) => {
|
||
tags = tags.filter((tag) => tag.toLowerCase() !== tagToRemove.toLowerCase());
|
||
updateHiddenTags();
|
||
syncReadLaterCheckbox();
|
||
syncNoteCheckbox();
|
||
syncTodoCheckbox();
|
||
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');
|
||
updateNoteTitle(true);
|
||
} else {
|
||
updateNoteTitle(false);
|
||
}
|
||
|
||
updateHiddenTags();
|
||
syncReadLaterCheckbox();
|
||
syncTodoCheckbox();
|
||
renderTags();
|
||
refreshAutocomplete();
|
||
});
|
||
}
|
||
|
||
if (todoCheckbox) {
|
||
todoCheckbox.addEventListener('change', () => {
|
||
tags = tags.filter((tag) => !/^shaarli-todo$/i.test(tag));
|
||
|
||
if (todoCheckbox.checked) {
|
||
tags.push('shaarli-todo');
|
||
updateTodoTitle(true);
|
||
} else {
|
||
updateTodoTitle(false);
|
||
}
|
||
|
||
updateHiddenTags();
|
||
syncReadLaterCheckbox();
|
||
syncNoteCheckbox();
|
||
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);
|
||
|
||
// === Todo migration: shaarli-todo → todo + Android URL ===
|
||
// Lors de la sauvegarde d'un todo, remplacer le tag legacy par la norme Android
|
||
// et générer une URL Android si vide ou legacy.
|
||
form.addEventListener('submit', () => {
|
||
try {
|
||
if (!window.ShaarItRules) return;
|
||
const urlField = form.querySelector('input[name="lf_url"]');
|
||
const url = urlField ? (urlField.value || '').trim() : '';
|
||
|
||
// Vérifier si c'est un todo (tag shaarli-todo ou todo)
|
||
const hasTodoTag = tags.some((t) => /^(todo|shaarli-todo)$/i.test(t));
|
||
if (!hasTodoTag) return;
|
||
|
||
// 1. Remplacer shaarli-todo par todo
|
||
const hadLegacyTag = tags.some((t) => /^shaarli-todo$/i.test(t));
|
||
if (hadLegacyTag) {
|
||
tags = tags.filter((t) => !/^shaarli-todo$/i.test(t));
|
||
if (!tags.some((t) => /^todo$/i.test(t))) {
|
||
tags.push('todo');
|
||
}
|
||
updateHiddenTags();
|
||
}
|
||
|
||
// 2. Générer URL Android si vide ou legacy
|
||
if (!url || /^https?:\/\/shaarli-todo/.test(url) || url === 'http://shaarli-todo') {
|
||
const newUrl = window.ShaarItRules.generateTodoUrl();
|
||
urlField.value = newUrl;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[shaarit] todo migration failed:', e);
|
||
}
|
||
});
|
||
|
||
// === Content-type auto-tagging (harmonisation ShaarIt Android) ===
|
||
// À la soumission, détecte le type de contenu depuis l'URL et injecte
|
||
// les tags automatiques (video, podcast, radio, music, article, news,
|
||
// social, repository+dev, shopping, image, pdf...).
|
||
// Les tags déjà présents ne sont jamais dupliqués ; aucun tag n'est
|
||
// supprimé. Désactivé pour les notes/todos (URLs internes).
|
||
form.addEventListener('submit', () => {
|
||
try {
|
||
if (!window.ShaarItRules) return;
|
||
const urlField = form.querySelector('input[name="lf_url"]');
|
||
const url = urlField ? (urlField.value || '').trim() : '';
|
||
if (!url) return;
|
||
|
||
// Ne pas auto-tagger les notes/todos (URLs internes reconnues par ShaarItRules).
|
||
const fakeLink = { url: url, tags: tags };
|
||
if (window.ShaarItRules.isNote(fakeLink) || window.ShaarItRules.isTodo(fakeLink)) return;
|
||
|
||
const detection = window.ShaarItRules.detectContentType(url);
|
||
if (!detection || !detection.tags || detection.tags.length === 0) return;
|
||
|
||
tags = window.ShaarItRules.mergeAutoTags(tags, detection.tags);
|
||
updateHiddenTags();
|
||
} catch (e) {
|
||
console.warn('[shaarit] content-type auto-tagging failed:', e);
|
||
}
|
||
});
|
||
|
||
updateHiddenTags();
|
||
syncReadLaterCheckbox();
|
||
syncNoteCheckbox();
|
||
syncTodoCheckbox();
|
||
renderTags();
|
||
refreshAutocomplete();
|
||
|
||
// Initial title update if note or todo tag is present
|
||
if (todoCheckbox && todoCheckbox.checked && titleInput) {
|
||
const currentTitle = titleInput.value || '';
|
||
if (!hasTodoEmoji(currentTitle) && !currentTitle.trim()) {
|
||
titleInput.value = TODO_EMOJI + ' ';
|
||
} else if (!hasTodoEmoji(currentTitle)) {
|
||
titleInput.value = addTodoEmoji(currentTitle);
|
||
}
|
||
} else if (noteCheckbox && noteCheckbox.checked && titleInput) {
|
||
const currentTitle = titleInput.value || '';
|
||
// Replace default "Note:" title with just the emoji
|
||
if (currentTitle === 'Note:' || currentTitle === 'Note') {
|
||
titleInput.value = NOTE_EMOJI + ' ';
|
||
} else if (!hasNoteEmoji(currentTitle) && !currentTitle.trim()) {
|
||
titleInput.value = NOTE_EMOJI + ' ';
|
||
} else if (!hasNoteEmoji(currentTitle)) {
|
||
titleInput.value = addNoteEmoji(currentTitle);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
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">' + i18n.t('LIVE') + '</span>');
|
||
lines.push(' </div>');
|
||
lines.push(' <div class="player-title" id="title">' + i18n.t('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="' + i18n.t('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="' + i18n.t('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="' + i18n.t('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 = ' + JSON.stringify(i18n.t('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 || i18n.t('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 = i18n.t('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;
|
||
|
||
// Check if this is a note bookmark (has 'note' tag)
|
||
var isNote = false;
|
||
var noteId = card.dataset.id || '';
|
||
card.querySelectorAll('.link-tag[data-tag]').forEach(function(tagEl) {
|
||
if (tagEl.dataset.tag === 'note') {
|
||
isNote = true;
|
||
}
|
||
});
|
||
|
||
// Add click handler for note bookmarks to redirect to notes page
|
||
if (isNote && titleEl) {
|
||
titleEl.addEventListener('click', function(e) {
|
||
// Only redirect if it's a left-click without modifiers
|
||
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||
e.preventDefault();
|
||
var basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
|
||
window.location.href = basePath + '/?searchtags=note#open-note-' + noteId;
|
||
}
|
||
});
|
||
}
|
||
|
||
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 = i18n.t('Play media');
|
||
playBtn.innerHTML = '<i class="mdi mdi-play-circle-outline"></i>';
|
||
|
||
var openLinkBtn = actionsDiv.querySelector('a[title="' + i18n.t('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 i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
|
||
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 = [
|
||
i18n.t('January'), i18n.t('February'), i18n.t('March'), i18n.t('April'),
|
||
i18n.t('May'), i18n.t('June'), i18n.t('July'), i18n.t('August'),
|
||
i18n.t('September'), i18n.t('October'), i18n.t('November'), i18n.t('December')
|
||
];
|
||
var DAY_NAMES = [
|
||
i18n.t('Mon'), i18n.t('Tue'), i18n.t('Wed'), i18n.t('Thu'),
|
||
i18n.t('Fri'), i18n.t('Sat'), i18n.t('Sun')
|
||
];
|
||
|
||
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));
|
||
|
||
if (!state.startDate || (state.startDate && state.endDate)) {
|
||
// Start new selection
|
||
state.startDate = clickedDate;
|
||
state.endDate = null;
|
||
} else if (state.startDate && !state.endDate) {
|
||
// Complete selection
|
||
if (clickedDate < state.startDate) {
|
||
state.endDate = state.startDate;
|
||
state.startDate = clickedDate;
|
||
} else {
|
||
state.endDate = clickedDate;
|
||
}
|
||
}
|
||
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;
|
||
window.location.href = url;
|
||
});
|
||
}
|
||
|
||
// Initialize
|
||
renderWeekdays();
|
||
updateSummary();
|
||
renderDays();
|
||
})();
|
||
|
||
// ===== Sync Theme from Bookmark in Background =====
|
||
// On every page load (including hard refresh): fetch the single config
|
||
// bookmark and apply {default, mode} if they differ from localStorage.
|
||
// This makes the bookmark the source of truth across devices/sessions.
|
||
(async function syncFromBookmark() {
|
||
try {
|
||
if (!window.ShaaritThemeConfig) return;
|
||
const config = await window.ShaaritThemeConfig.load();
|
||
if (!config) return;
|
||
|
||
const remoteThemeId = config.default;
|
||
const remoteMode = config.mode;
|
||
const localThemeId = localStorage.getItem('shaarit_theme_id') || 'DEFAULT';
|
||
const localMode = localStorage.getItem('theme')
|
||
|| (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||
|
||
let changed = false;
|
||
if (remoteThemeId && remoteThemeId !== localThemeId) {
|
||
localStorage.setItem('shaarit_theme_id', remoteThemeId);
|
||
document.documentElement.setAttribute('data-theme-id', remoteThemeId);
|
||
changed = true;
|
||
}
|
||
if (remoteMode && remoteMode !== localMode) {
|
||
localStorage.setItem('theme', remoteMode);
|
||
document.documentElement.setAttribute('data-theme', remoteMode);
|
||
changed = true;
|
||
}
|
||
|
||
// Enforce dark-only theme constraints after remote apply
|
||
const darkOnlyThemes = ['LINEAR', 'SPOTIFY', 'NOTION', 'DISCORD', 'DRACULA',
|
||
'ONE_DARK_PRO', 'TOKYO_NIGHT', 'NORD', 'NIGHT_OWL', 'ANTHRACITE',
|
||
'CYBERPUNK', 'NAVY_ELEGANCE', 'EARTHY'];
|
||
const effectiveThemeId = remoteThemeId || localThemeId;
|
||
if (darkOnlyThemes.indexOf(effectiveThemeId) !== -1
|
||
&& localStorage.getItem('theme') !== 'dark') {
|
||
localStorage.setItem('theme', 'dark');
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
changed = true;
|
||
}
|
||
|
||
if (changed) {
|
||
window.dispatchEvent(new Event('themeChanged'));
|
||
}
|
||
} catch (err) {
|
||
}
|
||
})();
|
||
|
||
});
|