3602 lines
148 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// =====================================================================
// Shared theme-config bookmark helper
// Maintains a single private bookmark titled "themes" tagged "shaarit_config"
// Used by the dark/light switch (script.js) AND the theme picker (tools.html)
// Prevents creating a new bookmark on every theme change (duplicate bug)
// =====================================================================
(function () {
const CONFIG_TAG = 'shaarit_config';
const LEGACY_TAG = 'themes';
const CONFIG_TITLE = 'themes';
const CONFIG_URL = 'https://shaarit.app/config/themes';
let inflight = null; // serialize concurrent saves
function getBasePath() {
return (window.shaarli && window.shaarli.basePath) || '';
}
async function fetchDoc(url) {
const res = await fetch(url, { credentials: 'same-origin' });
const text = await res.text();
return new DOMParser().parseFromString(text, 'text/html');
}
// Returns array of { id, editUrl } for every bookmark matching the config,
// searched across the new tag and the legacy "themes" tag (dedup by id).
async function findCandidates() {
const basePath = getBasePath();
const byId = new Map();
for (const tag of [CONFIG_TAG, LEGACY_TAG]) {
let doc;
try {
doc = await fetchDoc(basePath + '/?searchtags=' + encodeURIComponent(tag));
} catch (e) { continue; }
doc.querySelectorAll('.link-outer[data-id]').forEach(el => {
const id = el.getAttribute('data-id');
if (!id || byId.has(id)) return;
byId.set(id, { id, editUrl: basePath + '/admin/shaare/' + id });
});
}
// Sort by numeric id asc so "primary" is deterministic (oldest kept)
return [...byId.values()].sort((a, b) => Number(a.id) - Number(b.id));
}
// Reads existing config JSON from the edit page's textarea (raw value,
// not affected by the markdown plugin).
async function readExistingConfig(editUrl) {
try {
const doc = await fetchDoc(editUrl);
const ta = doc.querySelector('textarea[name="lf_description"]');
if (!ta) return null;
const raw = (ta.textContent || '').trim();
if (!raw) return null;
return JSON.parse(raw);
} catch (e) {
return null;
}
}
async function getTokenFrom(url) {
const doc = await fetchDoc(url);
const tokenEl = doc.querySelector('input[name="token"]');
const idEl = doc.querySelector('input[name="lf_id"]');
return {
token: tokenEl ? tokenEl.value : '',
lfId: idEl ? idEl.value : '',
};
}
async function deleteBookmark(id, token) {
if (!id || !token) return;
const basePath = getBasePath();
try {
await fetch(basePath + '/admin/shaare/delete?id=' + encodeURIComponent(id)
+ '&token=' + encodeURIComponent(token), { credentials: 'same-origin' });
} catch (e) { /* ignore */ }
}
// Merge partial updates into the persisted config and save to the single
// bookmark. Deletes any duplicates left over from previous buggy saves.
async function save(partial) {
// Serialize so rapid toggles do not race and create duplicates.
const run = async () => {
const basePath = getBasePath();
const candidates = await findCandidates();
const primary = candidates[0] || null;
const duplicates = candidates.slice(1);
// Build merged config
let existing = null;
if (primary) existing = await readExistingConfig(primary.editUrl);
const base = existing && typeof existing === 'object' ? existing : {};
const config = Object.assign(
{ version: 2, themes: [], default: 'DEFAULT', mode: 'dark' },
base,
partial || {}
);
// Get token + lf_id from the primary's edit page (or add page)
const src = primary ? primary.editUrl : basePath + '/admin/add-shaare';
const { token, lfId } = await getTokenFrom(src);
if (!token) return { ok: false, reason: 'no-token' };
const fd = new FormData();
fd.append('lf_title', CONFIG_TITLE);
fd.append('lf_url', CONFIG_URL);
fd.append('lf_tags', CONFIG_TAG);
fd.append('lf_description', JSON.stringify(config));
fd.append('lf_private', 'on');
fd.append('save_edit', 'Save');
fd.append('token', token);
if (lfId) fd.append('lf_id', lfId);
await fetch(basePath + '/admin/shaare', {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
// Clean up any leftover duplicates (reuse the same token, Shaarli
// accepts the session token for both edit and delete).
for (const dup of duplicates) {
await deleteBookmark(dup.id, token);
}
return { ok: true, id: lfId || (primary && primary.id) || null, config };
};
const p = (inflight ? inflight.catch(() => {}) : Promise.resolve()).then(run);
inflight = p.finally(() => { if (inflight === p) inflight = null; });
return inflight;
}
window.ShaaritThemeConfig = { save, findCandidates };
})();
document.addEventListener('DOMContentLoaded', () => {
// ===== Add Note Button Handler (Android convention) =====
const addNoteBtn = document.querySelector('.sidebar-add-note');
if (addNoteBtn) {
addNoteBtn.addEventListener('click', (e) => {
e.preventDefault();
if (!window.ShaarItRules) {
console.warn('[shaarit] ShaarItRules not available, cannot generate note URL');
return;
}
const basePath = addNoteBtn.getAttribute('data-base-path') || '';
const noteUrl = window.ShaarItRules.generateNoteUrl();
const redirectUrl = `${basePath}/admin/shaare?post=${encodeURIComponent(noteUrl)}&tags=note`;
window.location.href = redirectUrl;
});
}
// ===== Add Todo Button Handler (Android convention) =====
const addTodoBtn = document.querySelector('.sidebar-add-todo');
if (addTodoBtn) {
addTodoBtn.addEventListener('click', (e) => {
e.preventDefault();
if (!window.ShaarItRules) {
console.warn('[shaarit] ShaarItRules not available, cannot generate todo URL');
return;
}
const basePath = addTodoBtn.getAttribute('data-base-path') || '';
const todoUrl = window.ShaarItRules.generateTodoUrl();
const redirectUrl = `${basePath}/admin/shaare?post=${encodeURIComponent(todoUrl)}&tags=todo&title=%E2%9C%85%20`;
window.location.href = redirectUrl;
});
}
// ===== 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 checkThemeRestrictions() {
const themeId = localStorage.getItem('shaarit_theme_id') || 'DEFAULT';
const darkOnlyThemes = ['LINEAR', 'SPOTIFY', 'NOTION', 'DISCORD', 'DRACULA', 'ONE_DARK_PRO', 'TOKYO_NIGHT', 'NORD', 'NIGHT_OWL', 'ANTHRACITE', 'CYBERPUNK', 'NAVY_ELEGANCE', 'EARTHY'];
return darkOnlyThemes.indexOf(themeId) !== -1;
}
function updateTheme(theme) {
// Enforce dark mode if the theme only supports dark
if (checkThemeRestrictions()) {
theme = 'dark';
}
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
if (themeCheckbox) {
themeCheckbox.checked = theme === 'dark';
// Disable the checkbox if restricted to dark mode
themeCheckbox.disabled = checkThemeRestrictions();
themeCheckbox.closest('.theme-switch').style.opacity = themeCheckbox.disabled ? '0.5' : '1';
}
if (themeIconLight) {
themeIconLight.className = theme === 'dark' ? 'mdi mdi-weather-night' : 'mdi mdi-weather-sunny';
}
if (themeLabelSpan) {
themeLabelSpan.textContent = theme === 'dark' ? 'Dark Mode' : 'Light Mode';
}
}
async function syncModeToBookmark(mode) {
try {
if (!window.ShaaritThemeConfig) return;
const result = await window.ShaaritThemeConfig.save({ mode });
if (result && result.ok) {
console.log('[shaarit] Mode synced to bookmark:', mode);
}
} catch (e) {
console.error('[shaarit] Failed to sync mode to bookmark', e);
}
}
// Handle themeChanged event fired by tools.html
window.addEventListener('themeChanged', () => {
updateTheme(localStorage.getItem('theme') || 'dark');
});
// Init Theme
let savedTheme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
updateTheme(savedTheme);
if (themeCheckbox) {
themeCheckbox.addEventListener('change', () => {
if (checkThemeRestrictions()) return; // Extra safety
const next = themeCheckbox.checked ? 'dark' : 'light';
updateTheme(next);
// Sync mode to bookmark in background
syncModeToBookmark(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();
}
});
// ===== 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
});
// ===== Search Overlay (Spotlight Style) =====
try {
const searchOverlay = document.getElementById('search-overlay');
const searchToggleBtns = document.querySelectorAll('[data-search-toggle]');
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 searchNotesBtn = document.getElementById('search-notes-btn');
const searchForm = document.getElementById('search-form');
let searchMode = 'search'; // 'search' | 'tags' | 'notes'
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 (excluding hidden tags)
function fetchTags() {
if (cachedTags) return cachedTags;
try {
const tagsSet = new Set();
const addTag = (value) => {
if (typeof value !== 'string') return;
const cleaned = value.trim();
if (!cleaned || cleaned.includes('•')) return;
// Skip hidden tags
if (isTagHidden(cleaned)) return;
tagsSet.add(cleaned);
};
// Prefer explicit tag data attributes to avoid polluted text values (e.g. remove buttons)
document.querySelectorAll('.link-tag[data-tag], .tag-item[data-tag], [data-tag-name]').forEach((el) => {
addTag(el.getAttribute('data-tag') || el.getAttribute('data-tag-name'));
});
// Fallback selectors with reliable text content
document.querySelectorAll('.link-tag-link, .tag-link, .cloud-tag').forEach((el) => {
addTag(el.textContent);
});
// 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,
isNote: (el.querySelector('.link-title')?.getAttribute('title') || '').toLowerCase().includes('note'),
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 || '',
// Filter out hidden tags
tags: filterHiddenTags(
Array.from(el.querySelectorAll('.link-tag[data-tag]')).map(t => (t.getAttribute('data-tag') || '').trim()).filter(Boolean)
),
description: el.querySelector('.link-description')?.textContent || ''
}));
return cachedBookmarks;
}
} catch (e) {
console.error('Failed to fetch bookmarks:', e);
}
return [];
}
function getSearchHint(mode) {
if (mode === 'tags') {
return 'Rechercher dans les tags...';
}
if (mode === 'notes') {
return 'Rechercher dans les notes...';
}
return 'Rechercher dans les bookmarks et notes...';
}
// Render search results (tags, global bookmarks, or notes)
function renderResults(results, query, mode = 'search') {
if (!searchResults) return;
if (results.length === 0) {
if (query && query.length > 0) {
const scopeLabel = mode === 'tags' ? 'tags' : (mode === 'notes' ? 'notes' : 'bookmarks/notes');
searchResults.innerHTML = `
<div class="search-no-results">
No ${scopeLabel} found for "<strong>${escapeHtml(query)}</strong>"
</div>
`;
} else {
searchResults.innerHTML = `
<div class="search-results-empty">
<span class="search-results-hint">${getSearchHint(mode)}</span>
</div>
`;
}
return;
}
let html;
if (mode === 'tags') {
// 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 or notes
html = results.slice(0, 12).map((item, index) => {
const highlightedTitle = highlightMatch(escapeHtml(item.title), query);
const highlightedDescription = item.description ? highlightMatch(escapeHtml(item.description), query) : '';
const typeLabel = item.isNote ? 'note' : 'bookmark';
const typeIcon = item.isNote ? 'mdi-note-text-outline' : 'mdi-bookmark-outline';
const highlightedTags = (item.tags || []).slice(0, 4).map((tag) => {
const tagValue = escapeHtml(tag);
return `<span class="search-result-tag">${highlightMatch(tagValue, query)}</span>`;
}).join('');
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 ${typeIcon} search-result-icon"></i>
<div class="search-result-main">
<span class="search-result-text">${highlightedTitle}</span>
<span class="search-result-kind">${typeLabel}</span>
</div>
</div>
${highlightedDescription ? `<div class="search-result-sub">${highlightedDescription}</div>` : ''}
${highlightedTags ? `<div class="search-result-tags">${highlightedTags}</div>` : ''}
</div>
`;
}).join('');
}
searchResults.innerHTML = html;
// Add click handlers to results
searchResults.querySelectorAll('.search-result-item').forEach(item => {
item.addEventListener('click', () => {
if (mode === 'tags') {
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;
}
}
});
});
}
// Perform live search
async function performSearch(query) {
const normalizedQuery = (query || '').trim();
if (searchMode === 'tags') {
const tags = fetchTags();
if (!normalizedQuery) {
renderResults(tags.slice(0, 15), '', 'tags');
return;
}
const tagResults = tags.filter(tag => fuzzyMatch(tag, normalizedQuery));
renderResults(tagResults, normalizedQuery, 'tags');
return;
}
const bookmarks = await fetchBookmarks();
const searchPool = searchMode === 'notes' ? bookmarks.filter(item => item.isNote) : bookmarks;
if (!normalizedQuery) {
renderResults(searchPool.slice(0, 12), '', searchMode);
return;
}
const results = searchPool.filter((item) => {
const haystack = [
item.title,
item.url,
item.description,
(item.tags || []).join(' ')
].join(' ').toLowerCase();
return haystack.includes(normalizedQuery.toLowerCase());
});
renderResults(results, normalizedQuery, searchMode);
}
// 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(mode = 'search') {
searchOverlay?.classList.add('show');
selectedIndex = -1;
setTimeout(() => {
setSearchMode(mode);
searchModalInput?.focus();
}, 100);
}
function closeSearch() {
searchOverlay?.classList.remove('show');
selectedIndex = -1;
}
function submitSearchByMode() {
const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
const query = (searchModalInput?.value || '').trim();
if (selectedIndex >= 0 && navigateToSelected()) {
return;
}
if (!query) {
if (searchMode === 'tags') {
window.location.href = `${basePath}/tags/cloud`;
return;
}
window.location.href = `${basePath}/`;
return;
}
if (searchMode === 'tags') {
window.location.href = `${basePath}/?searchtags=${encodeURIComponent(query)}`;
return;
}
if (searchMode === 'notes') {
window.location.href = `${basePath}/?searchterm=${encodeURIComponent(query)}&searchtags=note`;
return;
}
window.location.href = `${basePath}/?searchterm=${encodeURIComponent(query)}`;
}
// Toggle search mode (tags vs search)
function setSearchMode(mode) {
if (!['search', 'tags', 'notes'].includes(mode)) {
mode = 'search';
}
searchMode = mode;
searchAllBtn?.classList.toggle('active', mode === 'search');
searchTagsBtn?.classList.toggle('active', mode === 'tags');
searchNotesBtn?.classList.toggle('active', mode === 'notes');
searchAllBtn?.setAttribute('aria-pressed', String(mode === 'search'));
searchTagsBtn?.setAttribute('aria-pressed', String(mode === 'tags'));
searchNotesBtn?.setAttribute('aria-pressed', String(mode === 'notes'));
searchAllBtn?.setAttribute('aria-selected', String(mode === 'search'));
searchTagsBtn?.setAttribute('aria-selected', String(mode === 'tags'));
searchNotesBtn?.setAttribute('aria-selected', String(mode === 'notes'));
if (searchModalInput) {
searchModalInput.name = mode === 'tags' ? 'searchtags' : 'searchterm';
searchModalInput.placeholder = getSearchHint(mode);
// Re-run search with new mode
performSearch(searchModalInput.value);
}
}
searchToggleBtns.forEach((btn) => {
btn.addEventListener('click', () => openSearch('search'));
});
// 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) => {
e.preventDefault();
setSearchMode('search');
});
searchNotesBtn?.addEventListener('click', (e) => {
e.preventDefault();
setSearchMode('notes');
});
searchForm?.addEventListener('submit', (e) => {
e.preventDefault();
submitSearchByMode();
});
// 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':
e.preventDefault();
submitSearchByMode();
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('search');
}
// T to open search directly in tags mode (when not typing)
if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
openSearch('tags');
}
// N to open search directly in notes mode (when not typing)
if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 'n' || e.key === 'N')) {
e.preventDefault();
openSearch('notes');
}
});
} catch (error) {
console.error('Search overlay initialization failed:', error);
}
// ===== 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');
const bulkModal = document.getElementById('bulk-confirm-modal');
const bulkModalTitle = document.getElementById('bulk-modal-title');
const bulkModalSubtitle = document.getElementById('bulk-modal-subtitle');
const bulkModalList = document.getElementById('bulk-modal-list');
const bulkModalConfirm = document.getElementById('bulk-modal-confirm');
const bulkModalCancel = document.getElementById('bulk-modal-cancel');
const bulkModalClose = document.getElementById('bulk-modal-close');
let selectionMode = false;
let selectedIds = new Set();
let pendingBulkAction = null;
const BULK_ACTION_COPY = {
delete: {
title: (count) => `Are you sure to delete ${count} link${count > 1 ? 's' : ''}?`,
subtitle: 'The following links will be irretrievably deleted:',
confirm: (count) => `DELETE ${count} LINK${count > 1 ? 'S' : ''}`,
confirmClass: 'bulk-confirm-delete'
},
public: {
title: (count) => `Are you sure to set ${count} link${count > 1 ? 's' : ''} public?`,
subtitle: 'The following links will be set as public:',
confirm: (count) => `SET ${count} LINK${count > 1 ? 'S' : ''} PUBLIC`,
confirmClass: 'bulk-confirm-public'
},
private: {
title: (count) => `Are you sure to set ${count} link${count > 1 ? 's' : ''} private?`,
subtitle: 'The following links will be set as private:',
confirm: (count) => `SET ${count} LINK${count > 1 ? 'S' : ''} PRIVATE`,
confirmClass: 'bulk-confirm-private'
}
};
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);
}
});
if (!selectionMode && selectedIds.size > 0) {
enterSelectionMode();
}
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);
}
});
function getSelectedIds() {
return Array.from(selectedIds);
}
function populateBulkModalList(ids) {
if (!bulkModalList) return;
bulkModalList.innerHTML = '';
ids.forEach((id) => {
const card = document.querySelector(`.link-outer[data-id="${id}"]`);
const title = card?.querySelector('.link-title')?.textContent?.trim() ||
card?.querySelector('.link-url')?.textContent?.trim() ||
'Untitled link';
const item = document.createElement('div');
item.className = 'bulk-modal-item';
const idSpan = document.createElement('span');
idSpan.className = 'bulk-modal-item-id';
idSpan.textContent = `#${id}`;
const titleSpan = document.createElement('span');
titleSpan.className = 'bulk-modal-item-title';
titleSpan.textContent = title;
item.appendChild(idSpan);
item.appendChild(titleSpan);
bulkModalList.appendChild(item);
});
}
function openBulkModal(actionType) {
if (!bulkModal || selectedIds.size === 0) return;
const meta = BULK_ACTION_COPY[actionType];
if (!meta) return;
const ids = getSelectedIds();
pendingBulkAction = actionType;
if (bulkModalTitle) {
bulkModalTitle.textContent = meta.title(ids.length);
}
if (bulkModalSubtitle) {
bulkModalSubtitle.textContent = meta.subtitle;
}
if (bulkModalConfirm) {
bulkModalConfirm.textContent = meta.confirm(ids.length);
bulkModalConfirm.className = `bulk-modal-btn bulk-modal-confirm ${meta.confirmClass}`;
}
populateBulkModalList(ids);
bulkModal.classList.add('show');
bulkModal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeBulkModal() {
if (!bulkModal) return;
pendingBulkAction = null;
bulkModal.classList.remove('show');
bulkModal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function buildIdsParam(ids) {
return ids.map(id => encodeURIComponent(id)).join('+');
}
function executeBulkAction() {
if (!pendingBulkAction || selectedIds.size === 0) return;
const ids = getSelectedIds();
const token = (window.shaarli?.token || '').trim() ||
document.querySelector('input[name="token"]')?.value || '';
if (!token) {
alert('Missing security token. Please refresh the page and try again.');
return;
}
const basePath = window.shaarli?.basePath || '';
const idsParam = buildIdsParam(ids);
let targetUrl = '';
if (pendingBulkAction === 'delete') {
targetUrl = `${basePath}/admin/shaare/delete?id=${idsParam}&token=${encodeURIComponent(token)}`;
} else {
const visibility = pendingBulkAction === 'public' ? 'public' : 'private';
targetUrl = `${basePath}/admin/shaare/visibility?token=${encodeURIComponent(token)}&newVisibility=${visibility}&id=${idsParam}`;
}
closeBulkModal();
window.location.href = targetUrl;
}
// Bulk actions with confirmation modal
bulkDelete?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('delete');
});
bulkPublic?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('public');
});
bulkPrivate?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('private');
});
bulkModalConfirm?.addEventListener('click', executeBulkAction);
bulkModalCancel?.addEventListener('click', closeBulkModal);
bulkModalClose?.addEventListener('click', closeBulkModal);
bulkModal?.addEventListener('click', (event) => {
if (event.target === bulkModal) {
closeBulkModal();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && bulkModal?.classList.contains('show')) {
closeBulkModal();
}
});
// ===== 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 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">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;
// 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 = '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();
})();
// ===== Sync Theme from Bookmark in Background =====
setTimeout(() => {
// Run sync after a slight delay to avoid blocking initial load
if (!window.shaarli || !window.shaarli.basePath) return;
fetch(window.shaarli.basePath + '/?searchtags=themes')
.then(res => res.text())
.then(text => {
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const linkCard = doc.querySelector('.linklist-item');
if (linkCard) {
const descEl = linkCard.querySelector('.link-description p');
if (descEl) {
try {
const config = JSON.parse(descEl.textContent.trim());
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');
// Sync theme if different
if (remoteThemeId && remoteThemeId !== localThemeId) {
localStorage.setItem('shaarit_theme_id', remoteThemeId);
document.documentElement.setAttribute('data-theme-id', remoteThemeId);
console.log('[shaarit] Theme synced from bookmark:', remoteThemeId);
}
// Sync mode if different
if (remoteMode && remoteMode !== localMode) {
localStorage.setItem('theme', remoteMode);
document.documentElement.setAttribute('data-theme', remoteMode);
console.log('[shaarit] Mode synced from bookmark:', remoteMode);
}
// Enforce light/dark constraints based on synced theme
const darkOnlyThemes = ['LINEAR', 'SPOTIFY', 'NOTION', 'DISCORD', 'DRACULA', 'ONE_DARK_PRO', 'TOKYO_NIGHT', 'NORD', 'NIGHT_OWL', 'ANTHRACITE', 'CYBERPUNK', 'NAVY_ELEGANCE', 'EARTHY'];
const syncedThemeId = remoteThemeId || localThemeId;
if (darkOnlyThemes.indexOf(syncedThemeId) !== -1) {
localStorage.setItem('theme', 'dark');
document.documentElement.setAttribute('data-theme', 'dark');
}
window.dispatchEvent(new Event('themeChanged'));
} catch(e) {}
}
}
})
.catch(err => console.log('[shaarit] Background theme sync failed:', err));
}, 1500);
});