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"); // 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 (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"); // 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"; // 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); }); const groupsList = Array.from(groups) .map((g) => `
${g}
`) .join(""); sidebar.innerHTML = `
Mes tâches ${tasks.length}
${groups.size > 0 ? `${groupsList}` : ""} `; // 2. Main Content const main = document.createElement("div"); main.className = "todo-main"; 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"; // 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)); }); main.appendChild(mainHeader); main.appendChild(itemsContainer); wrapper.appendChild(sidebar); wrapper.appendChild(main); // 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"; 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 { 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 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. // 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. 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(); // 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 : "#", }; } function renderTaskItem(task) { 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 ? '' : ""}
${task.title}
${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; }); return el; } function isOverdue(dateStr) { 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; } } /** * Initialize the Google Keep-like view */ /** * Initialize the Google Keep-like view */ function initNoteView(linkList, container) { document.body.classList.add("view-notes"); // 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"; // 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 = `
Créer une note...
`; topBar.appendChild(inputContainer); // View Toggle and other tools const tools = document.createElement("div"); tools.className = "notes-tools"; tools.innerHTML = ` `; topBar.appendChild(tools); wrapper.appendChild(topBar); // 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)); // Initial Render (Grid) renderNotes(contentArea, notes, "grid"); wrapper.appendChild(contentArea); // 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 = `
`; document.body.appendChild(modalOverlay); // 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"); }); 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"); }); } function parseNoteFromLink(linkEl) { 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 : ""; // 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 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 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"); 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"; // 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); }); // 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); } // Inner Content const inner = document.createElement("div"); inner.className = "note-inner"; // 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); } // 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 = `
`; // 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); }); } function openNoteModal(note) { const modal = document.querySelector(".note-modal-overlay"); const content = modal.querySelector(".note-modal-content"); // Build full content let html = `

${note.title}

${note.descHtml}
`; // Add images if not in desc? (desc usually has it) 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. 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}`); } // 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"); // 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); } }); // 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-")); // 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 // 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 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; // 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(); } } } }); // 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]); } } // 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. // 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(); const card = btn.closest(".link-outer, .note-card"); const id = card ? card.dataset.id : null; // 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; } 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; 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 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 { 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(); } } } } } 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); } }); 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); } 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)); }