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)); }