From 172103225467afbe083edeeed487dba5c722135a Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 14 Feb 2026 16:55:34 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20am=C3=A9liorer=20l'accessibilit=C3=A9?= =?UTF-8?q?=20et=20les=20performances=20avec=20support=20pour=20prefers-re?= =?UTF-8?q?duced-motion,=20prefers-contrast,=20focus-visible,=20ajout=20de?= =?UTF-8?q?=20preconnect=20pour=20les=20domaines=20externes,=20versioning?= =?UTF-8?q?=20des=20assets=20CSS/JS,=20et=20correction=20des=20chemins=20d?= =?UTF-8?q?e=20ressources=20avec=20ltrim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shaarli-pro/css/custom_views.css | 4 +- shaarli-pro/css/style.css | 55 ++ shaarli-pro/img/favicon.svg | 5 + shaarli-pro/includes.html | 25 +- shaarli-pro/js/custom_views.js | 1332 +++++++++++++++--------------- shaarli-pro/js/script.js | 14 +- shaarli-pro/linklist.html | 246 +++--- shaarli-pro/linklist.paging.html | 2 +- shaarli-pro/page.footer.html | 2 +- shaarli-pro/page.header.html | 201 ++--- 10 files changed, 1011 insertions(+), 875 deletions(-) create mode 100644 shaarli-pro/img/favicon.svg diff --git a/shaarli-pro/css/custom_views.css b/shaarli-pro/css/custom_views.css index d20a03d..6f75207 100644 --- a/shaarli-pro/css/custom_views.css +++ b/shaarli-pro/css/custom_views.css @@ -26,8 +26,8 @@ body.view-todo #linklist { /* Sidebar */ .todo-sidebar { width: 280px; - background-color: var(--background-secondary, #f8f9fa); - border-right: 1px solid var(--border-color, #e2e8f0); + background-color: var(--bg-sidebar); + border-right: 1px solid var(--border); padding: 1rem; display: flex; flex-direction: column; diff --git a/shaarli-pro/css/style.css b/shaarli-pro/css/style.css index cf289d9..3ac2989 100644 --- a/shaarli-pro/css/style.css +++ b/shaarli-pro/css/style.css @@ -3743,4 +3743,59 @@ select:focus { /* Utility */ .text-right { text-align: right; +} + +/* ===== Accessibility Enhancements ===== */ + +/* Reduced Motion - Respect user preferences */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* High Contrast Mode */ +@media (prefers-contrast: high) { + :root { + --border: #000000; + --text-main: #000000; + --text-secondary: #000000; + --primary: #0000ff; + --primary-hover: #0000cc; + --bg-body: #ffffff; + --bg-sidebar: #ffffff; + --bg-card: #ffffff; + } + + [data-theme="dark"] { + --border: #ffffff; + --text-main: #ffffff; + --text-secondary: #ffffff; + --primary: #00ffff; + --primary-hover: #66ffff; + --bg-body: #000000; + --bg-sidebar: #000000; + --bg-card: #000000; + } + + .sidebar-link:focus, + .header-nav-link:focus, + button:focus-visible, + a:focus-visible { + outline: 3px solid currentColor; + outline-offset: 4px; + } +} + +/* Focus Visible - Enhanced keyboard navigation */ +@supports not selector(:focus-visible) { + *:focus { + outline: 2px solid var(--primary); + outline-offset: 2px; + } } \ No newline at end of file diff --git a/shaarli-pro/img/favicon.svg b/shaarli-pro/img/favicon.svg new file mode 100644 index 0000000..6ff16e3 --- /dev/null +++ b/shaarli-pro/img/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shaarli-pro/includes.html b/shaarli-pro/includes.html index 14e3e9e..e8ecbc1 100644 --- a/shaarli-pro/includes.html +++ b/shaarli-pro/includes.html @@ -2,6 +2,7 @@ + @@ -22,42 +23,46 @@ - - + + {if="$pageName=='editlink' || $pageName=='addlink' || $pageName=='editlinkbatch'"} - - + + {/if} + + + + - + {loop="$plugins_includes.css_files"} - + {/loop} {if="is_file('data/user.css')"} - + {/if} - - + + {if="file_exists('tpl/shaarli-pro/extra.html')"} {include="extra"} diff --git a/shaarli-pro/js/custom_views.js b/shaarli-pro/js/custom_views.js index 0d9e8dd..8d2d2a3 100644 --- a/shaarli-pro/js/custom_views.js +++ b/shaarli-pro/js/custom_views.js @@ -1,233 +1,236 @@ -document.addEventListener('DOMContentLoaded', function () { - // Check URL parameters for custom views - const urlParams = new URLSearchParams(window.location.search); - const searchTags = urlParams.get('searchtags'); +document.addEventListener("DOMContentLoaded", function () { + // Check URL parameters for custom views + const urlParams = new URLSearchParams(window.location.search); + const searchTags = urlParams.get("searchtags"); - const linkList = document.getElementById('links-list'); - const container = document.querySelector('.content-container'); + const linkList = document.getElementById("links-list"); + const container = document.querySelector(".content-container"); - // Always init Pinned Items logic (sorting and listeners) - // This function is defined at the end of the file - if (typeof initPinnedItems === 'function') { - initPinnedItems(); - } + // Always init Pinned Items logic (sorting and listeners) + // This function is defined at the end of the file + if (typeof initPinnedItems === "function") { + initPinnedItems(); + } - if (!linkList || !container) return; + if (!linkList || !container) return; - if (searchTags === 'todo') { - initTodoView(linkList, container); - } else if (searchTags === 'note') { - initNoteView(linkList, container); - } + if (searchTags === "todo") { + initTodoView(linkList, container); + } else if (searchTags === "note") { + initNoteView(linkList, container); + } }); /** * Initialize the Google Tasks-like view */ function initTodoView(linkList, container) { - document.body.classList.add('view-todo'); + document.body.classList.add("view-todo"); - // Extract task data from existing DOM - const rawLinks = Array.from(linkList.querySelectorAll('.link-outer')); - const tasks = rawLinks.map(link => parseTaskFromLink(link)).filter(t => t !== null); + // Extract task data from existing DOM + const rawLinks = Array.from(linkList.querySelectorAll(".link-outer")); + const tasks = rawLinks.map((link) => parseTaskFromLink(link)).filter((t) => t !== null); - // Create new Layout - const wrapper = document.createElement('div'); - wrapper.className = 'special-view-wrapper'; + // Create new Layout + const wrapper = document.createElement("div"); + wrapper.className = "special-view-wrapper"; - // 1. Sidebar - const sidebar = document.createElement('div'); - sidebar.className = 'todo-sidebar'; + // 1. Sidebar + const sidebar = document.createElement("div"); + sidebar.className = "todo-sidebar"; - // Extract unique groups for the sidebar - const groups = new Set(); - tasks.forEach(t => { - if (t.group) groups.add(t.group); - }); + // Extract unique groups for the sidebar + const groups = new Set(); + tasks.forEach((t) => { + if (t.group) groups.add(t.group); + }); - const groupsList = Array.from(groups).map(g => - `
${g}
` - ).join(''); + const groupsList = Array.from(groups) + .map((g) => `
${g}
`) + .join(""); - sidebar.innerHTML = ` - + sidebar.innerHTML = ` +
- Mes tâches - ${tasks.length} + Mes tâches + ${tasks.length}
- ${groups.size > 0 ? `${groupsList}` : ''} + ${groups.size > 0 ? `${groupsList}` : ""} `; - // 2. Main Content - const main = document.createElement('div'); - main.className = 'todo-main'; + // 2. Main Content + const main = document.createElement("div"); + main.className = "todo-main"; - const mainHeader = document.createElement('div'); - mainHeader.className = 'todo-main-header'; - mainHeader.innerHTML = ` + const mainHeader = document.createElement("div"); + mainHeader.className = "todo-main-header"; + mainHeader.innerHTML = `

Mes tâches

`; - const itemsContainer = document.createElement('div'); - itemsContainer.className = 'todo-items-container'; + const itemsContainer = document.createElement("div"); + itemsContainer.className = "todo-items-container"; - // Sort Tasks: Pinned items first - tasks.sort((a, b) => { - const aPinned = a.tags && a.tags.includes('shaarli-pin'); - const bPinned = b.tags && b.tags.includes('shaarli-pin'); - return bPinned - aPinned; - }); + // Sort Tasks: Pinned items first + tasks.sort((a, b) => { + const aPinned = a.tags && a.tags.includes("shaarli-pin"); + const bPinned = b.tags && b.tags.includes("shaarli-pin"); + return bPinned - aPinned; + }); - // Render Tasks - tasks.forEach(task => { - itemsContainer.appendChild(renderTaskItem(task)); - }); + // Render Tasks + tasks.forEach((task) => { + itemsContainer.appendChild(renderTaskItem(task)); + }); - main.appendChild(mainHeader); - main.appendChild(itemsContainer); + main.appendChild(mainHeader); + main.appendChild(itemsContainer); - wrapper.appendChild(sidebar); - wrapper.appendChild(main); + wrapper.appendChild(sidebar); + wrapper.appendChild(main); - // Inject and Hide original - linkList.style.display = 'none'; + // Inject and Hide original + linkList.style.display = "none"; - // Remove pagination/toolbar if present to clean up view - const toolbar = document.querySelector('.content-toolbar'); - if (toolbar) toolbar.style.display = 'none'; + // Remove pagination/toolbar if present to clean up view + const toolbar = document.querySelector(".content-toolbar"); + if (toolbar) toolbar.style.display = "none"; - if (linkList.parentNode) { - linkList.parentNode.insertBefore(wrapper, linkList); + if (linkList.parentNode) { + linkList.parentNode.insertBefore(wrapper, linkList); + } else { + container.appendChild(wrapper); + } + + // Global filter function + window.filterTasksByGroup = function (group) { + const title = document.getElementById("todo-list-title"); + const items = document.querySelectorAll(".todo-item"); + + // Update Sidebar Active State + document.querySelectorAll(".todo-list-item").forEach((el) => el.classList.remove("active")); + if (event && event.currentTarget) event.currentTarget.classList.add("active"); + + if (group === "all") { + title.textContent = "Mes tâches"; + items.forEach((item) => (item.style.display = "flex")); } else { - container.appendChild(wrapper); + title.textContent = group; + items.forEach((item) => { + if (item.dataset.group === group) item.style.display = "flex"; + else item.style.display = "none"; + }); } - - // Global filter function - window.filterTasksByGroup = function (group) { - const title = document.getElementById('todo-list-title'); - const items = document.querySelectorAll('.todo-item'); - - // Update Sidebar Active State - document.querySelectorAll('.todo-list-item').forEach(el => el.classList.remove('active')); - if (event && event.currentTarget) event.currentTarget.classList.add('active'); - - if (group === 'all') { - title.textContent = 'Mes tâches'; - items.forEach(item => item.style.display = 'flex'); - } else { - title.textContent = group; - items.forEach(item => { - if (item.dataset.group === group) item.style.display = 'flex'; - else item.style.display = 'none'; - }); - } - }; + }; } - function parseTaskFromLink(linkEl) { - const id = linkEl.dataset.id; - const titleEl = linkEl.querySelector('.link-title'); // "Title" normally - // For Todos, the Bookmark Title is the Task Title? - // Or is the title inside the description? - // Mental model says: "Todo: reste un bookmark privé... LinkEntity.title" is used. + const id = linkEl.dataset.id; + const titleEl = linkEl.querySelector(".link-title"); // "Title" normally + // For Todos, the Bookmark Title is the Task Title? + // Or is the title inside the description? + // Mental model says: "Todo: reste un bookmark privé... LinkEntity.title" is used. - const title = titleEl ? titleEl.textContent.trim() : 'Task'; - const descEl = linkEl.querySelector('.link-description'); - const rawDesc = descEl ? descEl.innerHTML : ''; - const textDesc = descEl ? descEl.textContent : ''; + const title = titleEl ? titleEl.textContent.trim() : "Task"; + const descEl = linkEl.querySelector(".link-description"); + const rawDesc = descEl ? descEl.innerHTML : ""; + const textDesc = descEl ? descEl.textContent : ""; - // Check if it's really a todo (should be if we are in ?searchtags=todo, but double check) - // We assume yes. + // Check if it's really a todo (should be if we are in ?searchtags=todo, but double check) + // We assume yes. - // Parse Metadata from Description text - // Format: 📅 **Échéance :** - // Format: 🏷️ **Groupe :** - // Format: - [ ] Subtask or Main task status? + // Parse Metadata from Description text + // Format: 📅 **Échéance :** + // Format: 🏷️ **Groupe :** + // Format: - [ ] Subtask or Main task status? - // Status - // If [x] is found in the first few lines, maybe completed? - // User says: "Puis une checkbox markdown: - [ ] Titre" - // Wait, if the Description contains the checkbox and title, then Bookmark Title is ignored? - // Let's assume Bookmark Title is the master Display Title. - // And "Checkbox status" can be parsed from description. + // Status + // If [x] is found in the first few lines, maybe completed? + // User says: "Puis une checkbox markdown: - [ ] Titre" + // Wait, if the Description contains the checkbox and title, then Bookmark Title is ignored? + // Let's assume Bookmark Title is the master Display Title. + // And "Checkbox status" can be parsed from description. - let isCompleted = false; - if (textDesc.includes('[x]')) isCompleted = true; + let isCompleted = false; + if (textDesc.includes("[x]")) isCompleted = true; - // Due Date - let dueDate = null; - const dateMatch = textDesc.match(/Échéance\s*:\s*\*+([^*]+)\*+/); // varies by markdown regex - // Text might be "📅 **Échéance :** 2023-10-10" - // Regex: Échéance\s*:\s*(.*?)(\n|$) - const dueMatch = textDesc.match(/Échéance\s*[:]\s*(.*?)(\n|$)/); - if (dueMatch) dueDate = dueMatch[1].trim(); + // Due Date + let dueDate = null; + const dateMatch = textDesc.match(/Échéance\s*:\s*\*+([^*]+)\*+/); // varies by markdown regex + // Text might be "📅 **Échéance :** 2023-10-10" + // Regex: Échéance\s*:\s*(.*?)(\n|$) + const dueMatch = textDesc.match(/Échéance\s*[:]\s*(.*?)(\n|$)/); + if (dueMatch) dueDate = dueMatch[1].trim(); - // Group - let group = null; - const groupMatch = textDesc.match(/Groupe\s*[:]\s*(.*?)(\n|$)/); - if (groupMatch) group = groupMatch[1].trim(); + // Group + let group = null; + const groupMatch = textDesc.match(/Groupe\s*[:]\s*(.*?)(\n|$)/); + if (groupMatch) group = groupMatch[1].trim(); - return { - id, - title, - isCompleted, - dueDate, - group, - originalUrl: linkEl.querySelector('.link-url') ? linkEl.querySelector('.link-url').textContent : '', - editUrl: linkEl.querySelector('a[href*="admin/shaare/"]') ? linkEl.querySelector('a[href*="admin/shaare/"]').href : '#' - }; + return { + id, + title, + isCompleted, + dueDate, + group, + originalUrl: linkEl.querySelector(".link-url") ? linkEl.querySelector(".link-url").textContent : "", + editUrl: linkEl.querySelector('a[href*="admin/shaare/"]') ? linkEl.querySelector('a[href*="admin/shaare/"]').href : "#", + }; } function renderTaskItem(task) { - const el = document.createElement('div'); - el.className = `todo-item ${task.isCompleted ? 'completed' : ''}`; - el.dataset.group = task.group || ''; + const el = document.createElement("div"); + el.className = `todo-item ${task.isCompleted ? "completed" : ""}`; + el.dataset.group = task.group || ""; - /* + /* We cannot easily toggle state (AJAX) without a backend API that supports partial updates efficiently. But we can provide a link to Edit. Or simulate it. */ - el.innerHTML = ` -
- ${task.isCompleted ? '' : ''} + el.innerHTML = ` +
+ ${task.isCompleted ? '' : ""}
${task.title}
- ${task.group ? `${task.group}` : ''} - ${task.dueDate ? ` ${formatDate(task.dueDate)}` : ''} + ${task.group ? `${task.group}` : ""} + ${task.dueDate ? ` ${formatDate(task.dueDate)}` : ""}
`; - // Simple click handler to open edit (since we can't sync state easily) - el.querySelector('.todo-checkbox').addEventListener('click', (e) => { - e.stopPropagation(); - // Open edit page or toggle visually - // window.location.href = task.editUrl; - }); + // Simple click handler to open edit (since we can't sync state easily) + el.querySelector(".todo-checkbox").addEventListener("click", (e) => { + e.stopPropagation(); + // Open edit page or toggle visually + // window.location.href = task.editUrl; + }); - return el; + return el; } function isOverdue(dateStr) { - try { - return new Date(dateStr) < new Date(); - } catch (e) { return false; } + try { + return new Date(dateStr) < new Date(); + } catch (e) { + return false; + } } function formatDate(dateStr) { - try { - const d = new Date(dateStr); - return d.toLocaleDateString(); - } catch (e) { return dateStr; } + try { + const d = new Date(dateStr); + return d.toLocaleDateString(); + } catch (e) { + return dateStr; + } } /** @@ -238,24 +241,24 @@ function formatDate(dateStr) { * Initialize the Google Keep-like view */ function initNoteView(linkList, container) { - document.body.classList.add('view-notes'); + document.body.classList.add("view-notes"); - // Hide standard toolbar - const toolbar = document.querySelector('.content-toolbar'); - if (toolbar) toolbar.style.display = 'none'; + // Hide standard toolbar + const toolbar = document.querySelector(".content-toolbar"); + if (toolbar) toolbar.style.display = "none"; - // 1. Create Layout Wrapper - const wrapper = document.createElement('div'); - wrapper.className = 'notes-wrapper'; + // 1. Create Layout Wrapper + const wrapper = document.createElement("div"); + wrapper.className = "notes-wrapper"; - // 2. Create Search/Input Area (Top) - const topBar = document.createElement('div'); - topBar.className = 'notes-top-bar'; + // 2. Create Search/Input Area (Top) + const topBar = document.createElement("div"); + topBar.className = "notes-top-bar"; - // Custom Input "Take a note..." - const inputContainer = document.createElement('div'); - inputContainer.className = 'note-input-container'; - inputContainer.innerHTML = ` + // Custom Input "Take a note..." + const inputContainer = document.createElement("div"); + inputContainer.className = "note-input-container"; + inputContainer.innerHTML = `
Créer une note...
@@ -265,43 +268,43 @@ function initNoteView(linkList, container) {
`; - topBar.appendChild(inputContainer); + topBar.appendChild(inputContainer); - // View Toggle and other tools - const tools = document.createElement('div'); - tools.className = 'notes-tools'; - tools.innerHTML = ` + // View Toggle and other tools + const tools = document.createElement("div"); + tools.className = "notes-tools"; + tools.innerHTML = ` `; - topBar.appendChild(tools); + topBar.appendChild(tools); - wrapper.appendChild(topBar); + wrapper.appendChild(topBar); - // 3. Content Area - const contentArea = document.createElement('div'); - contentArea.className = 'notes-content-area'; + // 3. Content Area + const contentArea = document.createElement("div"); + contentArea.className = "notes-content-area"; - const links = Array.from(linkList.querySelectorAll('.link-outer')); - const notes = links.map(link => parseNoteFromLink(link)); + const links = Array.from(linkList.querySelectorAll(".link-outer")); + const notes = links.map((link) => parseNoteFromLink(link)); - // Initial Render (Grid) - renderNotes(contentArea, notes, 'grid'); + // Initial Render (Grid) + renderNotes(contentArea, notes, "grid"); - wrapper.appendChild(contentArea); + wrapper.appendChild(contentArea); - // Replace original list - linkList.style.display = 'none'; - if (linkList.parentNode) { - linkList.parentNode.insertBefore(wrapper, linkList); - } else { - container.appendChild(wrapper); - } + // Replace original list + linkList.style.display = "none"; + if (linkList.parentNode) { + linkList.parentNode.insertBefore(wrapper, linkList); + } else { + container.appendChild(wrapper); + } - // Modal Container - const modalOverlay = document.createElement('div'); - modalOverlay.className = 'note-modal-overlay'; - modalOverlay.innerHTML = ` + // Modal Container + const modalOverlay = document.createElement("div"); + modalOverlay.className = "note-modal-overlay"; + modalOverlay.innerHTML = `
@@ -309,159 +312,160 @@ function initNoteView(linkList, container) {
`; - document.body.appendChild(modalOverlay); + document.body.appendChild(modalOverlay); - // Event Listeners for Toggles - const btnGrid = wrapper.querySelector('#btn-view-grid'); - const btnList = wrapper.querySelector('#btn-view-list'); + // Event Listeners for Toggles + const btnGrid = wrapper.querySelector("#btn-view-grid"); + const btnList = wrapper.querySelector("#btn-view-list"); - btnGrid.addEventListener('click', () => { - btnGrid.classList.add('active'); - btnList.classList.remove('active'); - renderNotes(contentArea, notes, 'grid'); - }); + btnGrid.addEventListener("click", () => { + btnGrid.classList.add("active"); + btnList.classList.remove("active"); + renderNotes(contentArea, notes, "grid"); + }); - btnList.addEventListener('click', () => { - btnList.classList.add('active'); - btnGrid.classList.remove('active'); - renderNotes(contentArea, notes, 'list'); - }); + btnList.addEventListener("click", () => { + btnList.classList.add("active"); + btnGrid.classList.remove("active"); + renderNotes(contentArea, notes, "list"); + }); - // Close Modal - modalOverlay.querySelector('#note-modal-close').addEventListener('click', () => { - modalOverlay.classList.remove('open'); - }); - modalOverlay.addEventListener('click', (e) => { - if (e.target === modalOverlay) modalOverlay.classList.remove('open'); - }); + // Close Modal + modalOverlay.querySelector("#note-modal-close").addEventListener("click", () => { + modalOverlay.classList.remove("open"); + }); + modalOverlay.addEventListener("click", (e) => { + if (e.target === modalOverlay) modalOverlay.classList.remove("open"); + }); } function parseNoteFromLink(linkEl) { - const id = linkEl.dataset.id; - const titleEl = linkEl.querySelector('.link-title'); - const title = titleEl ? titleEl.textContent.trim() : ''; + const id = linkEl.dataset.id; + const titleEl = linkEl.querySelector(".link-title"); + const title = titleEl ? titleEl.textContent.trim() : ""; - const descEl = linkEl.querySelector('.link-description'); - const descHtml = descEl ? descEl.innerHTML : ''; - const descText = descEl ? descEl.textContent : ''; + const descEl = linkEl.querySelector(".link-description"); + const descHtml = descEl ? descEl.innerHTML : ""; + const descText = descEl ? descEl.textContent : ""; - // Extract Image from Description (First image as cover) - let coverImage = null; - if (descEl) { - const img = descEl.querySelector('img'); - if (img) { - coverImage = img.src; - // Optionally remove img from body text if it's purely a cover - // But usually we keep it or hide it via CSS if we construct a custom card - } + // Extract Image from Description (First image as cover) + let coverImage = null; + if (descEl) { + const img = descEl.querySelector("img"); + if (img) { + coverImage = img.src; + // Optionally remove img from body text if it's purely a cover + // But usually we keep it or hide it via CSS if we construct a custom card } + } - const urlEl = linkEl.querySelector('.link-url'); - const url = urlEl ? urlEl.textContent.trim() : ''; + const urlEl = linkEl.querySelector(".link-url"); + const url = urlEl ? urlEl.textContent.trim() : ""; - const tags = []; - let color = 'default'; - linkEl.querySelectorAll('.link-tag-list a').forEach(tag => { - const t = tag.textContent.trim(); - // Check for color tag - if (t.startsWith('note-')) { - const potentialColor = t.substring(5); - color = potentialColor; - } else { - tags.push(t); - } - }); + const tags = []; + let color = "default"; + linkEl.querySelectorAll(".link-tag-list a").forEach((tag) => { + const t = tag.textContent.trim(); + // Check for color tag + if (t.startsWith("note-")) { + const potentialColor = t.substring(5); + color = potentialColor; + } else { + tags.push(t); + } + }); - const isPinnedByTag = tags.includes('shaarli-pin'); + const isPinnedByTag = tags.includes("shaarli-pin"); - const actionsEl = linkEl.querySelector('.link-actions'); - const editUrl = actionsEl && actionsEl.querySelector('a[href*="admin/shaare"]') ? actionsEl.querySelector('a[href*="admin/shaare"]').href : '#'; - const deleteUrl = actionsEl && actionsEl.querySelector('a[href*="delete"]') ? actionsEl.querySelector('a[href*="delete"]').href : '#'; - const pinUrl = actionsEl && actionsEl.querySelector('a[href*="pin"]') ? actionsEl.querySelector('a[href*="pin"]').href : '#'; + const actionsEl = linkEl.querySelector(".link-actions"); + const editUrl = actionsEl && actionsEl.querySelector('a[href*="admin/shaare"]') ? actionsEl.querySelector('a[href*="admin/shaare"]').href : "#"; + const deleteUrl = actionsEl && actionsEl.querySelector('a[href*="delete"]') ? actionsEl.querySelector('a[href*="delete"]').href : "#"; + const pinUrl = actionsEl && actionsEl.querySelector('a[href*="pin"]') ? actionsEl.querySelector('a[href*="pin"]').href : "#"; - // User requested "availability of the tag 'shaarli-pin' as the main source" - const isPinned = tags.includes('shaarli-pin'); + // User requested "availability of the tag 'shaarli-pin' as the main source" + const isPinned = tags.includes("shaarli-pin"); - return { id, title, descHtml, descText, coverImage, url, tags, color, editUrl, deleteUrl, pinUrl, isPinned }; + return { id, title, descHtml, descText, coverImage, url, tags, color, editUrl, deleteUrl, pinUrl, isPinned }; } function renderNotes(container, notes, viewMode) { - container.innerHTML = ''; - container.className = viewMode === 'grid' ? 'notes-masonry' : 'notes-list-view'; + container.innerHTML = ""; + container.className = viewMode === "grid" ? "notes-masonry" : "notes-list-view"; - // Sort: Pinned items first - notes.sort((a, b) => { - const aPinned = a.tags.includes('shaarli-pin'); - const bPinned = b.tags.includes('shaarli-pin'); - return bPinned - aPinned; + // Sort: Pinned items first + notes.sort((a, b) => { + const aPinned = a.tags.includes("shaarli-pin"); + const bPinned = b.tags.includes("shaarli-pin"); + return bPinned - aPinned; + }); + + notes.forEach((note) => { + const card = document.createElement("div"); + card.className = `note-card note-color-${note.color}`; + card.dataset.id = note.id; + if (viewMode === "list") card.classList.add("list-mode"); + + // Main Click to Open Modal + card.addEventListener("click", (e) => { + // Prevent if clicking buttons + if (e.target.closest("button") || e.target.closest("a") || e.target.closest(".note-hover-actions")) return; + openNoteModal(note); }); - notes.forEach(note => { - const card = document.createElement('div'); - card.className = `note-card note-color-${note.color}`; - card.dataset.id = note.id; - if (viewMode === 'list') card.classList.add('list-mode'); + // Cover Image + if (note.coverImage && viewMode === "grid") { + // Show cover mainly in grid + const imgContainer = document.createElement("div"); + imgContainer.className = "note-cover"; + imgContainer.innerHTML = `Cover`; + card.appendChild(imgContainer); + } - // Main Click to Open Modal - card.addEventListener('click', (e) => { - // Prevent if clicking buttons - if (e.target.closest('button') || e.target.closest('a') || e.target.closest('.note-hover-actions')) return; - openNoteModal(note); - }); + // Inner Content + const inner = document.createElement("div"); + inner.className = "note-inner"; - // Cover Image - if (note.coverImage && viewMode === 'grid') { // Show cover mainly in grid - const imgContainer = document.createElement('div'); - imgContainer.className = 'note-cover'; - imgContainer.innerHTML = `Cover`; - card.appendChild(imgContainer); + // Title + if (note.title) { + const h3 = document.createElement("h3"); + h3.className = "note-title"; + h3.textContent = note.title; + inner.appendChild(h3); + } + + // Body (truncated in grid, maybe?) + if (note.descHtml) { + const body = document.createElement("div"); + body.className = "note-body"; + // Start simple: use innerHTML but maybe strip big images if we used cover? + // For now, let's just dump it and style images to fit or hide if first child + body.innerHTML = note.descHtml; + inner.appendChild(body); + } + + // Tags (Labels) + if (note.tags.length > 0) { + const tagContainer = document.createElement("div"); + tagContainer.className = "note-tags"; + note.tags.forEach((t) => { + if (t !== "note") { + const span = document.createElement("span"); + span.className = "note-tag"; + span.textContent = t; + tagContainer.appendChild(span); } + }); + inner.appendChild(tagContainer); + } - // Inner Content - const inner = document.createElement('div'); - inner.className = 'note-inner'; + // Hover Actions (Keep style: at bottom, visible on hover) + const actions = document.createElement("div"); + actions.className = "note-hover-actions"; - // Title - if (note.title) { - const h3 = document.createElement('h3'); - h3.className = 'note-title'; - h3.textContent = note.title; - inner.appendChild(h3); - } + // Palette Button Logic + const paletteBtnId = `palette-${note.id}`; - // Body (truncated in grid, maybe?) - if (note.descHtml) { - const body = document.createElement('div'); - body.className = 'note-body'; - // Start simple: use innerHTML but maybe strip big images if we used cover? - // For now, let's just dump it and style images to fit or hide if first child - body.innerHTML = note.descHtml; - inner.appendChild(body); - } - - // Tags (Labels) - if (note.tags.length > 0) { - const tagContainer = document.createElement('div'); - tagContainer.className = 'note-tags'; - note.tags.forEach(t => { - if (t !== 'note') { - const span = document.createElement('span'); - span.className = 'note-tag'; - span.textContent = t; - tagContainer.appendChild(span); - } - }); - inner.appendChild(tagContainer); - } - - // Hover Actions (Keep style: at bottom, visible on hover) - const actions = document.createElement('div'); - actions.className = 'note-hover-actions'; - - // Palette Button Logic - const paletteBtnId = `palette-${note.id}`; - - actions.innerHTML = ` + actions.innerHTML = `
@@ -474,411 +478,423 @@ function renderNotes(container, notes, viewMode) {
- + `; - // Palette Toggle - const paletteBtn = actions.querySelector(`#${paletteBtnId}`); - const palettePopup = actions.querySelector(`#popup-${paletteBtnId}`); + // Palette Toggle + const paletteBtn = actions.querySelector(`#${paletteBtnId}`); + const palettePopup = actions.querySelector(`#popup-${paletteBtnId}`); - paletteBtn.addEventListener('click', (e) => { - e.stopPropagation(); - // Close others? - document.querySelectorAll('.palette-popup.open').forEach(p => { - if (p !== palettePopup) p.classList.remove('open'); - }); - palettePopup.classList.toggle('open'); - }); - - // Close palette when clicking outside - // (Handled globally or card based? simple: card mouseleave?) - card.addEventListener('mouseleave', () => { - palettePopup.classList.remove('open'); - }); - - - inner.appendChild(actions); - card.appendChild(inner); - - container.appendChild(card); + paletteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + // Close others? + document.querySelectorAll(".palette-popup.open").forEach((p) => { + if (p !== palettePopup) p.classList.remove("open"); + }); + palettePopup.classList.toggle("open"); }); + + // Close palette when clicking outside + // (Handled globally or card based? simple: card mouseleave?) + card.addEventListener("mouseleave", () => { + palettePopup.classList.remove("open"); + }); + + inner.appendChild(actions); + card.appendChild(inner); + + container.appendChild(card); + }); } function openNoteModal(note) { - const modal = document.querySelector('.note-modal-overlay'); - const content = modal.querySelector('.note-modal-content'); + const modal = document.querySelector(".note-modal-overlay"); + const content = modal.querySelector(".note-modal-content"); - // Build full content - let html = ` + // Build full content + let html = `

${note.title}

${note.descHtml}
`; - // Add images if not in desc? (desc usually has it) + // Add images if not in desc? (desc usually has it) - content.innerHTML = html; - modal.classList.add('open'); + content.innerHTML = html; + modal.classList.add("open"); } function generatePaletteButtons(note) { - const colors = [ - 'default', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'darkblue', 'purple', 'pink', 'brown', 'grey' - ]; - // Map to hex for the button background - const colorMap = { - 'default': '#ffffff', 'red': '#f28b82', 'orange': '#fbbc04', 'yellow': '#fff475', - 'green': '#ccff90', 'teal': '#a7ffeb', 'blue': '#cbf0f8', 'darkblue': '#aecbfa', - 'purple': '#d7aefb', 'pink': '#fdcfe8', 'brown': '#e6c9a8', 'grey': '#e8eaed' - }; - // Dark mode mapping could be handled via CSS classes on buttons but inline styles are easier for the picker circles - // We will just use class names and let CSS handle the preview color if possible, or set style. + const colors = ["default", "red", "orange", "yellow", "green", "teal", "blue", "darkblue", "purple", "pink", "brown", "grey"]; + // Map to hex for the button background + const colorMap = { + default: "#ffffff", + red: "#f28b82", + orange: "#fbbc04", + yellow: "#fff475", + green: "#ccff90", + teal: "#a7ffeb", + blue: "#cbf0f8", + darkblue: "#aecbfa", + purple: "#d7aefb", + pink: "#fdcfe8", + brown: "#e6c9a8", + grey: "#e8eaed", + }; + // Dark mode mapping could be handled via CSS classes on buttons but inline styles are easier for the picker circles + // We will just use class names and let CSS handle the preview color if possible, or set style. - return colors.map(c => { - // We use style for the button background roughly. - // Actually, let's use the class on the button itself to pick up the color from CSS variables if defined, - // OR just hardcode the light mode preview for simplicity as the picker is usually on white/dark background. - return ``; - }).join(''); + return colors + .map((c) => { + // We use style for the button background roughly. + // Actually, let's use the class on the button itself to pick up the color from CSS variables if defined, + // OR just hardcode the light mode preview for simplicity as the picker is usually on white/dark background. + return ``; + }) + .join(""); } window.setNoteColor = function (noteId, color, editUrl) { - // 1. Visual Update (Immediate feedback) - const card = document.querySelector(`.note-card[data-id="${noteId}"]`); - if (card) { - // Remove all color classes - card.classList.forEach(cls => { - if (cls.startsWith('note-color-')) card.classList.remove(cls); - }); - card.classList.add(`note-color-${color}`); - } + // 1. Visual Update (Immediate feedback) + const card = document.querySelector(`.note-card[data-id="${noteId}"]`); + if (card) { + // Remove all color classes + card.classList.forEach((cls) => { + if (cls.startsWith("note-color-")) card.classList.remove(cls); + }); + card.classList.add(`note-color-${color}`); + } - // 2. Persistence via AJAX Form Submission - fetch(editUrl) - .then(response => response.text()) - .then(html => { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const form = doc.querySelector('form[name="linkform"]'); + // 2. Persistence via AJAX Form Submission + fetch(editUrl) + .then((response) => response.text()) + .then((html) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const form = doc.querySelector('form[name="linkform"]'); - if (!form) throw new Error('Could not find edit form'); + if (!form) throw new Error("Could not find edit form"); - // Extract all necessary fields - const formData = new URLSearchParams(); - const inputs = form.querySelectorAll('input, textarea'); + // Extract all necessary fields + const formData = new URLSearchParams(); + const inputs = form.querySelectorAll("input, textarea"); - inputs.forEach(input => { - if (input.type === 'checkbox') { - if (input.checked) formData.append(input.name, input.value || 'on'); - } else if (input.name) { - formData.append(input.name, input.value); - } - }); + inputs.forEach((input) => { + if (input.type === "checkbox") { + if (input.checked) formData.append(input.name, input.value || "on"); + } else if (input.name) { + formData.append(input.name, input.value); + } + }); - // Update Tags - let currentTags = formData.get('lf_tags') || ''; - let tagsArray = currentTags.split(/[\s,]+/).filter(t => t.trim() !== ''); + // Update Tags + let currentTags = formData.get("lf_tags") || ""; + let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== ""); - // Remove existing color tags - tagsArray = tagsArray.filter(t => !t.startsWith('note-')); + // Remove existing color tags + tagsArray = tagsArray.filter((t) => !t.startsWith("note-")); - // Add new color tag (unless default) - if (color !== 'default') { - tagsArray.push(`note-${color}`); - } + // Add new color tag (unless default) + if (color !== "default") { + tagsArray.push(`note-${color}`); + } - formData.set('lf_tags', tagsArray.join(' ')); - formData.append('save_edit', '1'); // Trigger save action + formData.set("lf_tags", tagsArray.join(" ")); + formData.append("save_edit", "1"); // Trigger save action - // POST back to Shaarli - return fetch(form.action, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: formData.toString() - }); - }) - .then(response => { - if (response.ok) { - console.log(`Color ${color} saved for note ${noteId}`); - } else { - throw new Error('Failed to save color'); - } - }) - .catch(err => { - console.error('Error saving note color:', err); - alert("Erreur lors de la sauvegarde de la couleur. Veuillez rafraîchir la page."); - }); + // POST back to Shaarli + return fetch(form.action, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formData.toString(), + }); + }) + .then((response) => { + if (response.ok) { + console.log(`Color ${color} saved for note ${noteId}`); + } else { + throw new Error("Failed to save color"); + } + }) + .catch((err) => { + console.error("Error saving note color:", err); + alert("Erreur lors de la sauvegarde de la couleur. Veuillez rafraîchir la page."); + }); }; /* ========================================================== PINNED ITEMS LOGIC (Tag: shaarli-pin) ========================================================== */ function initPinnedItems() { - const container = document.querySelector('.links-list, .notes-masonry, .notes-list-view'); - if (!container) return; // Exit if no container found (e.g. empty page or other view) + const container = document.querySelector(".links-list, .notes-masonry, .notes-list-view"); + if (!container) return; // Exit if no container found (e.g. empty page or other view) - const items = Array.from(container.children); - const pinnedItems = []; + const items = Array.from(container.children); + const pinnedItems = []; - items.forEach(item => { - // Support both Standard Link items and Note items - if (item.classList.contains('link-outer') || item.classList.contains('note-card')) { - let isPinned = false; + items.forEach((item) => { + // Support both Standard Link items and Note items + if (item.classList.contains("link-outer") || item.classList.contains("note-card")) { + let isPinned = false; - // 1. Check for Tag 'shaarli-pin' - // In note-card, we might need to check dataset or re-parse tags if not visible? - // But usually renderNotes puts tags in DOM or we rely on data attribute if we saved it? - // Let's rely on finding the tag text in the DOM for consistency with Standard View. - // For Note View, tags are in .note-tags > .note-tag - // For Standard View, tags are in .link-tag-list > a + // 1. Check for Tag 'shaarli-pin' + // In note-card, we might need to check dataset or re-parse tags if not visible? + // But usually renderNotes puts tags in DOM or we rely on data attribute if we saved it? + // Let's rely on finding the tag text in the DOM for consistency with Standard View. + // For Note View, tags are in .note-tags > .note-tag + // For Standard View, tags are in .link-tag-list > a - const itemHtml = item.innerHTML; // Simple search in content (quick & dirty but effective for tag presence) - // Better: Select text content of tags specifically to avoid false positives in description - const tagElements = item.querySelectorAll('.link-tag-list a, .note-tag'); - for (let t of tagElements) { - if (t.textContent.trim() === 'shaarli-pin') { - isPinned = true; - break; - } - } - - // 2. Enforce Visual State based on Tag Presence - const pinBtnIcon = item.querySelector('.mdi-pin-outline, .mdi-pin'); - const titleArea = item.querySelector('.link-title, .note-title'); - const titleIcon = titleArea ? titleArea.querySelector('i.mdi-pin') : null; - - if (isPinned) { - // It IS Pinned: Ensure UI reflects this - pinnedItems.push(item); - item.classList.add('is-pinned-tag'); - - // Button -> Filled Pin - if (pinBtnIcon) { - pinBtnIcon.classList.remove('mdi-pin-outline'); - pinBtnIcon.classList.add('mdi-pin'); - if (pinBtnIcon.parentElement) pinBtnIcon.parentElement.classList.add('active'); - } - - // Title -> Add Icon if missing - if (titleArea && !titleIcon) { - const newIcon = document.createElement('i'); - newIcon.className = 'mdi mdi-pin'; - newIcon.style.color = 'var(--primary)'; - newIcon.style.marginRight = '8px'; - titleArea.prepend(newIcon); - } - } else { - // It is NOT Pinned: Ensure UI reflects this (Clean up native sticky or mismatches) - item.classList.remove('is-pinned-tag'); - - // Button -> Outline Pin - if (pinBtnIcon) { - pinBtnIcon.classList.remove('mdi-pin'); - pinBtnIcon.classList.add('mdi-pin-outline'); - if (pinBtnIcon.parentElement) pinBtnIcon.parentElement.classList.remove('active'); - } - - // Title -> Remove Icon if present - if (titleIcon) { - titleIcon.remove(); - } - } + const itemHtml = item.innerHTML; // Simple search in content (quick & dirty but effective for tag presence) + // Better: Select text content of tags specifically to avoid false positives in description + const tagElements = item.querySelectorAll(".link-tag-list a, .note-tag"); + for (let t of tagElements) { + if (t.textContent.trim() === "shaarli-pin") { + isPinned = true; + break; } - }); + } - // 3. Move Pinned Items to Top (Only for standard list, renderNotes already sorts itself) - if (container.classList.contains('links-list')) { - for (let i = pinnedItems.length - 1; i >= 0; i--) { - container.prepend(pinnedItems[i]); + // 2. Enforce Visual State based on Tag Presence + const pinBtnIcon = item.querySelector(".mdi-pin-outline, .mdi-pin"); + const titleArea = item.querySelector(".link-title, .note-title"); + const titleIcon = titleArea ? titleArea.querySelector("i.mdi-pin") : null; + + if (isPinned) { + // It IS Pinned: Ensure UI reflects this + pinnedItems.push(item); + item.classList.add("is-pinned-tag"); + + // Button -> Filled Pin + if (pinBtnIcon) { + pinBtnIcon.classList.remove("mdi-pin-outline"); + pinBtnIcon.classList.add("mdi-pin"); + if (pinBtnIcon.parentElement) pinBtnIcon.parentElement.classList.add("active"); } + + // Title -> Add Icon if missing + if (titleArea && !titleIcon) { + const newIcon = document.createElement("i"); + newIcon.className = "mdi mdi-pin"; + newIcon.style.color = "var(--primary)"; + newIcon.style.marginRight = "8px"; + titleArea.prepend(newIcon); + } + } else { + // It is NOT Pinned: Ensure UI reflects this (Clean up native sticky or mismatches) + item.classList.remove("is-pinned-tag"); + + // Button -> Outline Pin + if (pinBtnIcon) { + pinBtnIcon.classList.remove("mdi-pin"); + pinBtnIcon.classList.add("mdi-pin-outline"); + if (pinBtnIcon.parentElement) pinBtnIcon.parentElement.classList.remove("active"); + } + + // Title -> Remove Icon if present + if (titleIcon) { + titleIcon.remove(); + } + } } + }); - // 4. Click Listener for all Pin Buttons (Event Delegation) - // Avoid adding multiple listeners if init called multiple times? - // We'll rely on one global listener on document, but here we add it inside this function which is called once on load. - // To be safe, let's remove old one if we could, but anonymous function makes it hard. - // Better: Allow this to run, but ensure we don't duplicate. - // Since initPinnedItems is called on DOMContentLoaded, it runs once. + // 3. Move Pinned Items to Top (Only for standard list, renderNotes already sorts itself) + if (container.classList.contains("links-list")) { + for (let i = pinnedItems.length - 1; i >= 0; i--) { + container.prepend(pinnedItems[i]); + } + } - // Note: We already have the listener attached in previous version. - // We will just keep the listener logic here in the replacement. - document.addEventListener('click', function (e) { - const btn = e.target.closest('a[href*="do=pin"], .note-hover-actions a[href*="pin"], .link-actions a[href*="pin"]'); - if (btn) { - e.preventDefault(); - e.stopPropagation(); + // 4. Click Listener for all Pin Buttons (Event Delegation) + // Avoid adding multiple listeners if init called multiple times? + // We'll rely on one global listener on document, but here we add it inside this function which is called once on load. + // To be safe, let's remove old one if we could, but anonymous function makes it hard. + // Better: Allow this to run, but ensure we don't duplicate. + // Since initPinnedItems is called on DOMContentLoaded, it runs once. - const card = btn.closest('.link-outer, .note-card'); - const id = card ? card.dataset.id : null; + // Note: We already have the listener attached in previous version. + // We will just keep the listener logic here in the replacement. + document.addEventListener( + "click", + function (e) { + const btn = e.target.closest('a[href*="do=pin"], .note-hover-actions a[href*="pin"], .link-actions a[href*="pin"]'); + if (btn) { + e.preventDefault(); + e.stopPropagation(); - // Re-derive edit URL if needed - let editUrl = btn.href.replace('do=pin', 'do=editlink').replace('pin', 'editlink'); - if (card) { - const editBtn = card.querySelector('a[href*="edit_link"], a[href*="admin/shaare"]'); - if (editBtn) editUrl = editBtn.href; - } + const card = btn.closest(".link-outer, .note-card"); + const id = card ? card.dataset.id : null; - if (id && editUrl) { - togglePinTag(id, editUrl, btn); - } + // Re-derive edit URL if needed + let editUrl = btn.href.replace("do=pin", "do=editlink").replace("pin", "editlink"); + if (card) { + const editBtn = card.querySelector('a[href*="edit_link"], a[href*="admin/shaare"]'); + if (editBtn) editUrl = editBtn.href; } - }, { once: false }); // Listener is permanent + + if (id && editUrl) { + togglePinTag(id, editUrl, btn); + } + } + }, + { once: false }, + ); // Listener is permanent } function togglePinTag(id, editUrl, btn) { - const icon = btn.querySelector('i'); - let isPinning = false; + const icon = btn.querySelector("i"); + let isPinning = false; - if (icon) { - if (icon.classList.contains('mdi-pin-outline')) { - icon.classList.remove('mdi-pin-outline'); - icon.classList.add('mdi-pin'); - btn.classList.add('active'); - isPinning = true; - } else { - icon.classList.remove('mdi-pin'); - icon.classList.add('mdi-pin-outline'); - btn.classList.remove('active'); - isPinning = false; + if (icon) { + if (icon.classList.contains("mdi-pin-outline")) { + icon.classList.remove("mdi-pin-outline"); + icon.classList.add("mdi-pin"); + btn.classList.add("active"); + isPinning = true; + } else { + icon.classList.remove("mdi-pin"); + icon.classList.add("mdi-pin-outline"); + btn.classList.remove("active"); + isPinning = false; + } + } + + // Update Title Icon (The one "devant le titre") + const card = btn.closest(".link-outer, .note-card"); + if (card) { + // Update Title Icon + const titleArea = card.querySelector(".link-title, .note-title"); + if (titleArea) { + let titleIcon = titleArea.querySelector("i.mdi-pin"); + if (isPinning) { + if (!titleIcon) { + const newIcon = document.createElement("i"); + newIcon.className = "mdi mdi-pin"; + newIcon.style.color = "var(--primary)"; + newIcon.style.marginRight = "8px"; + titleArea.prepend(newIcon); } + } else { + if (titleIcon) titleIcon.remove(); + } } - // Update Title Icon (The one "devant le titre") - const card = btn.closest('.link-outer, .note-card'); - if (card) { - // Update Title Icon - const titleArea = card.querySelector('.link-title, .note-title'); - if (titleArea) { - let titleIcon = titleArea.querySelector('i.mdi-pin'); - if (isPinning) { - if (!titleIcon) { - const newIcon = document.createElement('i'); - newIcon.className = 'mdi mdi-pin'; - newIcon.style.color = 'var(--primary)'; - newIcon.style.marginRight = '8px'; - titleArea.prepend(newIcon); - } + // Update Tag List Visualization + // We need to handle both Note Cards (.note-tags) and Standard Links (.link-tag-list) + let tagContainer = card.querySelector(".note-tags"); + if (!tagContainer) tagContainer = card.querySelector(".link-tag-list"); + + if (tagContainer) { + // Check if tag exists already + let existingTagElement = null; + + // Search in children + // Notes: .note-tag + // Links: .label-tag > a OR just a + const allCandidates = tagContainer.querySelectorAll("*"); + for (let el of allCandidates) { + if (el.textContent.trim() === "shaarli-pin") { + // We found the text. + // If note, it's the span.note-tag + if (el.classList.contains("note-tag")) { + existingTagElement = el; + break; + } + // If link, we want the anchor or its wrapper + if (el.tagName === "A") { + // Check if wrapped in .label-tag + if (el.parentElement.classList.contains("label-tag")) { + existingTagElement = el.parentElement; } else { - if (titleIcon) titleIcon.remove(); + existingTagElement = el; } + break; + } } + } - // Update Tag List Visualization - // We need to handle both Note Cards (.note-tags) and Standard Links (.link-tag-list) - let tagContainer = card.querySelector('.note-tags'); - if (!tagContainer) tagContainer = card.querySelector('.link-tag-list'); + if (isPinning) { + if (!existingTagElement) { + if (card.classList.contains("note-card")) { + // Add Note Tag + const span = document.createElement("span"); + span.className = "note-tag"; + span.textContent = "shaarli-pin"; + tagContainer.appendChild(span); + } else { + // Add Link Tag (Standard View) + // Structure: shaarli-pin + const wrapper = document.createElement("span"); + wrapper.className = "link-tag"; - if (tagContainer) { - // Check if tag exists already - let existingTagElement = null; + const link = document.createElement("a"); + link.href = "?searchtags=shaarli-pin"; + link.textContent = "shaarli-pin"; - // Search in children - // Notes: .note-tag - // Links: .label-tag > a OR just a - const allCandidates = tagContainer.querySelectorAll('*'); - for (let el of allCandidates) { - if (el.textContent.trim() === 'shaarli-pin') { - // We found the text. - // If note, it's the span.note-tag - if (el.classList.contains('note-tag')) { - existingTagElement = el; - break; - } - // If link, we want the anchor or its wrapper - if (el.tagName === 'A') { - // Check if wrapped in .label-tag - if (el.parentElement.classList.contains('label-tag')) { - existingTagElement = el.parentElement; - } else { - existingTagElement = el; - } - break; - } - } - } - - if (isPinning) { - if (!existingTagElement) { - if (card.classList.contains('note-card')) { - // Add Note Tag - const span = document.createElement('span'); - span.className = 'note-tag'; - span.textContent = 'shaarli-pin'; - tagContainer.appendChild(span); - } else { - // Add Link Tag (Standard View) - // Structure: shaarli-pin - const wrapper = document.createElement('span'); - wrapper.className = 'link-tag'; - - const link = document.createElement('a'); - link.href = '?searchtags=shaarli-pin'; - link.textContent = 'shaarli-pin'; - - wrapper.appendChild(link); - - // Append space first for separation if there are other tags - if (tagContainer.children.length > 0) { - tagContainer.appendChild(document.createTextNode(' ')); - } - tagContainer.appendChild(wrapper); - } - } - } else { - if (existingTagElement) { - // Remove the element - const prev = existingTagElement.previousSibling; - existingTagElement.remove(); - // Clean up trailing space/text if it was the last one or between tags - if (prev && prev.nodeType === Node.TEXT_NODE && !prev.textContent.trim()) { - prev.remove(); - } - } + wrapper.appendChild(link); + + // Append space first for separation if there are other tags + if (tagContainer.children.length > 0) { + tagContainer.appendChild(document.createTextNode(" ")); } + tagContainer.appendChild(wrapper); + } } + } else { + if (existingTagElement) { + // Remove the element + const prev = existingTagElement.previousSibling; + existingTagElement.remove(); + // Clean up trailing space/text if it was the last one or between tags + if (prev && prev.nodeType === Node.TEXT_NODE && !prev.textContent.trim()) { + prev.remove(); + } + } + } } + } - fetch(editUrl) - .then(response => response.text()) - .then(html => { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const form = doc.querySelector('form[name="linkform"]'); - if (!form) throw new Error('Could not find edit form'); + fetch(editUrl) + .then((response) => response.text()) + .then((html) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const form = doc.querySelector('form[name="linkform"]'); + if (!form) throw new Error("Could not find edit form"); - const formData = new URLSearchParams(); - const inputs = form.querySelectorAll('input, textarea'); - inputs.forEach(input => { - if (input.type === 'checkbox') { - if (input.checked) formData.append(input.name, input.value || 'on'); - } else if (input.name) { - formData.append(input.name, input.value); - } - }); + const formData = new URLSearchParams(); + const inputs = form.querySelectorAll("input, textarea"); + inputs.forEach((input) => { + if (input.type === "checkbox") { + if (input.checked) formData.append(input.name, input.value || "on"); + } else if (input.name) { + formData.append(input.name, input.value); + } + }); - let currentTags = formData.get('lf_tags') || ''; - let tagsArray = currentTags.split(/[\s,]+/).filter(t => t.trim() !== ''); - const pinTag = 'shaarli-pin'; + let currentTags = formData.get("lf_tags") || ""; + let tagsArray = currentTags.split(/[\s,]+/).filter((t) => t.trim() !== ""); + const pinTag = "shaarli-pin"; - if (isPinning) { - if (!tagsArray.includes(pinTag)) tagsArray.push(pinTag); - } else { - tagsArray = tagsArray.filter(t => t !== pinTag); - } + if (isPinning) { + if (!tagsArray.includes(pinTag)) tagsArray.push(pinTag); + } else { + tagsArray = tagsArray.filter((t) => t !== pinTag); + } - formData.set('lf_tags', tagsArray.join(' ')); - formData.append('save_edit', '1'); + formData.set("lf_tags", tagsArray.join(" ")); + formData.append("save_edit", "1"); - return fetch(form.action, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: formData.toString() - }); - }) - .then(res => { - if (res.ok) console.log("Pin toggled successfully"); - }) - .catch(err => console.error(err)); + return fetch(form.action, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }); + }) + .then((res) => { + if (res.ok) console.log("Pin toggled successfully"); + }) + .catch((err) => console.error(err)); } diff --git a/shaarli-pro/js/script.js b/shaarli-pro/js/script.js index 4b10ba6..4d4e85f 100644 --- a/shaarli-pro/js/script.js +++ b/shaarli-pro/js/script.js @@ -59,14 +59,24 @@ document.addEventListener('DOMContentLoaded', () => { 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 tags function highlightMatch(text, query) { - if (!query || query.length === 0) return text; + 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 text.replace(regex, '$1'); + return escapedText.replace(regex, '$1'); } // Fuzzy search - matches substring anywhere in text diff --git a/shaarli-pro/linklist.html b/shaarli-pro/linklist.html index b7ee8a3..5258ec4 100644 --- a/shaarli-pro/linklist.html +++ b/shaarli-pro/linklist.html @@ -1,114 +1,148 @@ - - -{$pageName="linklist"} -{include="includes"} - - -{include="page.header"} - -{include="page.footer"} - + + - \ No newline at end of file + + + + {include="page.footer"} + + + \ No newline at end of file diff --git a/shaarli-pro/linklist.paging.html b/shaarli-pro/linklist.paging.html index a9081b5..801a783 100644 --- a/shaarli-pro/linklist.paging.html +++ b/shaarli-pro/linklist.paging.html @@ -13,7 +13,7 @@ {/if} {$from=($page_current - 1) * $links_per_page + 1} {$to=min($total, ($page_current - 1) * $links_per_page + $links_per_page)} -
+
{if="$page_max > 1"}