From 209614bb23c29c957f22c86a42ed04d031e485f8 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 14 Feb 2026 13:13:21 -0500 Subject: [PATCH] avec section Notes --- shaarli-pro/css/custom_views.css | 787 +++++++++++++++++++++++++++ shaarli-pro/includes.html | 2 + shaarli-pro/js/custom_views.js | 884 +++++++++++++++++++++++++++++++ shaarli-pro/page.header.html | 33 +- 4 files changed, 1699 insertions(+), 7 deletions(-) create mode 100644 shaarli-pro/css/custom_views.css create mode 100644 shaarli-pro/js/custom_views.js diff --git a/shaarli-pro/css/custom_views.css b/shaarli-pro/css/custom_views.css new file mode 100644 index 0000000..d20a03d --- /dev/null +++ b/shaarli-pro/css/custom_views.css @@ -0,0 +1,787 @@ +/* ========================================= + Special Views for Todos and Notes + ========================================= */ + +/* --- Layout Wrapper (injected by JS) --- */ +.special-view-wrapper { + display: flex; + flex-direction: row; + width: 100%; + min-height: calc(100vh - 60px); + /* Adjust for header */ +} + +/* --- TODO VIEW --- */ +body.view-todo .content-container { + max-width: 100%; + padding: 0; + margin: 0; +} + +body.view-todo #linklist { + display: none; + /* Hide default list when wrapper is active */ +} + +/* Sidebar */ +.todo-sidebar { + width: 280px; + background-color: var(--background-secondary, #f8f9fa); + border-right: 1px solid var(--border-color, #e2e8f0); + padding: 1rem; + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +[data-theme="dark"] .todo-sidebar { + background-color: #1e293b; + border-color: #334155; +} + +.create-task-btn { + width: 100%; + padding: 0.75rem 1rem; + background-color: var(--primary-color, #2563eb); + color: white; + border: none; + border-radius: 2rem; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + cursor: pointer; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, background-color 0.2s; + margin-bottom: 1.5rem; +} + +.create-task-btn:hover { + background-color: var(--primary-dark, #1d4ed8); + transform: translateY(-1px); +} + +.todo-list-item { + padding: 0.75rem 1rem; + margin-bottom: 0.25rem; + border-radius: 0.5rem; + cursor: pointer; + color: var(--text-color, #334155); + display: flex; + align-items: center; + gap: 0.75rem; + transition: background-color 0.2s; +} + +[data-theme="dark"] .todo-list-item { + color: #cbd5e1; +} + +.todo-list-item:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] .todo-list-item:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.todo-list-item.active { + background-color: rgba(37, 99, 235, 0.1); + color: var(--primary-color, #2563eb); + font-weight: 600; +} + +/* Main Task Area */ +.todo-main { + flex: 1; + padding: 2rem; + overflow-y: auto; + background-color: var(--bg-body); +} + +[data-theme="dark"] .todo-main { + background-color: #0f172a; +} + +.todo-main-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.todo-main-header h2 { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +/* Todo Item */ +.todo-item { + padding: 1rem; + border-bottom: 1px solid var(--border-color, #e2e8f0); + display: flex; + align-items: center; + /* keep aligned */ + gap: 1rem; + transition: background-color 0.2s; +} + +[data-theme="dark"] .todo-item { + border-bottom-color: #334155; +} + +.todo-item:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +.todo-checkbox { + width: 20px; + height: 20px; + border: 2px solid var(--text-light, #94a3b8); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.todo-checkbox.checked { + background-color: var(--primary-color, #2563eb); + border-color: var(--primary-color, #2563eb); + color: white; +} + +.todo-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.todo-title { + font-size: 1rem; + color: var(--text-color, #0f172a); + font-weight: 400; +} + +[data-theme="dark"] .todo-title { + color: #f1f5f9; +} + +.todo-item.completed .todo-title { + text-decoration: line-through; + color: var(--text-light, #94a3b8); +} + +.todo-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--text-light, #64748b); +} + +.todo-badge { + padding: 0.125rem 0.5rem; + border-radius: 1rem; + background-color: rgba(0, 0, 0, 0.05); + font-size: 0.7rem; +} + +[data-theme="dark"] .todo-badge { + background-color: rgba(255, 255, 255, 0.1); +} + +.due-date { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.due-date.overdue { + color: var(--danger-color, #ef4444); +} + +/* --- NOTES VIEW --- */ + +/* Wrapper */ +body.view-notes .content-container { + padding: 2rem; + background-color: var(--bg-body); + min-height: 100vh; +} + +[data-theme="dark"] body.view-notes .content-container { + background-color: var(--bg-body); +} + +/* Tool Bar / Input Area */ +.notes-top-bar { + display: flex; + justify-content: center; + align-items: flex-start; + margin-bottom: 2rem; + position: relative; + padding-right: 60px; + /* Space for toggle */ +} + +.note-input-container { + width: 600px; + max-width: 100%; + background-color: var(--background-secondary, #ffffff); + border-radius: 8px; + box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 2px 6px 2px rgba(60, 64, 67, 0.15); + transition: box-shadow 0.2s; + overflow: hidden; +} + +[data-theme="dark"] .note-input-container { + background-color: var(--bg-sidebar); + border: 1px solid var(--border); + box-shadow: none; +} + +.note-input-collapsed { + display: flex; + align-items: center; + padding: 12px 16px; + cursor: text; + color: var(--text-light, #80868b); + font-weight: 500; + font-size: 1rem; +} + +.note-input-placeholder { + flex: 1; +} + +.note-input-actions { + display: flex; + gap: 16px; +} + +.note-input-actions button { + background: none; + border: none; + cursor: pointer; + color: var(--text-light, #80868b); + padding: 4px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s, color 0.2s; +} + +.note-input-actions button:hover { + background-color: rgba(136, 136, 136, 0.1); + color: var(--text-color); +} + +/* Tools (View Toggle) */ +.notes-tools { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex; + gap: 0.5rem; +} + +.icon-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-light, #5f6368); + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; +} + +.icon-btn:hover { + background-color: rgba(136, 136, 136, 0.1); +} + +.icon-btn.active { + color: var(--primary-color, #202124); + /* or specific active color */ + background-color: rgba(136, 136, 136, 0.1); + /* Keep highlight style */ +} + +[data-theme="dark"] .icon-btn { + color: #9aa0a6; +} + +[data-theme="dark"] .icon-btn.active { + color: #e8eaed; +} + + +/* --- LOGIC: Masonry vs List --- */ + +/* Masonry Grid */ +.notes-masonry { + column-count: 4; + column-gap: 16px; + width: 100%; +} + +@media (max-width: 1200px) { + .notes-masonry { + column-count: 3; + } +} + +@media (max-width: 800px) { + .notes-masonry { + column-count: 2; + } +} + +@media (max-width: 500px) { + .notes-masonry { + column-count: 1; + } +} + +/* List View */ +.notes-list-view { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + width: 600px; + max-width: 100%; + margin: 0 auto; +} + +.notes-list-view .note-card { + width: 100%; +} + + +/* --- CARD STYLING --- */ +.note-card { + background-color: var(--background-secondary, #ffffff); + border: 1px solid #e0e0e0; + border-radius: 8px; + margin-bottom: 16px; + /* spacing for masonry */ + break-inside: avoid; + /* Prevent split */ + position: relative; + transition: box-shadow 0.2s, transform 0.2s, background-color 0.2s; + overflow: hidden; + /* for cover image */ +} + +/* Dark Mode Card */ +[data-theme="dark"] .note-card { + background-color: #202124; + border: 1px solid #5f6368; + color: #e8eaed; +} + +.note-card:hover { + box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15); +} + +[data-theme="dark"] .note-card:hover { + background-color: #202124; + /* Keep lightens on hover? usually same but controls appear */ +} + +/* Cover Image */ +.note-cover img { + width: 100%; + height: auto; + display: block; + object-fit: cover; +} + +/* Inner Content */ +.note-inner { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Title */ +.note-title { + font-size: 1rem; + font-weight: 500; + margin: 0; + line-height: 1.5rem; + color: var(--text-color, #202124); +} + +[data-theme="dark"] .note-title { + color: #e8eaed; +} + +/* Body (Truncated) */ +.note-body { + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--text-color, #202124); + word-wrap: break-word; + /* Limit to ~12 lines */ + display: -webkit-box; + -webkit-line-clamp: 12; + line-clamp: 12; + -webkit-box-orient: vertical; + overflow: hidden; + max-height: 300px; + /* Fallback */ +} + +[data-theme="dark"] .note-body { + color: #e8eaed; +} + +/* Tags */ +.note-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.note-tag { + background: rgba(0, 0, 0, 0.06); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.7rem; + color: var(--text-light); +} + +[data-theme="dark"] .note-tag { + background: rgba(255, 255, 255, 0.1); +} + +/* Hover Actions */ +.note-hover-actions { + display: flex; + align-items: center; + gap: 0px; + /* evenly spaced */ + margin-top: 8px; + margin-left: -8px; + /* Alignment fix */ + opacity: 0; + /* Hidden by default */ + transition: opacity 0.2s; + position: relative; + /* For palette popup */ +} + +/* Show actions on hover */ +.note-card:hover .note-hover-actions { + opacity: 1; +} + +/* Always show actions on touch devices? (Can't detect easily here, but opacity 1 is safer for UX if no hover) */ +@media (hover: none) { + .note-hover-actions { + opacity: 1; + } +} + +.note-hover-actions button, +.note-hover-actions a { + background: none; + border: none; + width: 34px; + /* Touch target */ + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-light, #5f6368); + cursor: pointer; + font-size: 1.1rem; + transition: background-color 0.2s, color 0.2s; + text-decoration: none; +} + +[data-theme="dark"] .note-hover-actions button, +[data-theme="dark"] .note-hover-actions a { + color: #9aa0a6; +} + +.note-hover-actions button:hover, +.note-hover-actions a:hover { + background-color: rgba(136, 136, 136, 0.2); + color: var(--text-color); +} + +[data-theme="dark"] .note-hover-actions button:hover, +[data-theme="dark"] .note-hover-actions a:hover { + color: #e8eaed; +} + +.spacer { + flex: 1; +} + +/* Pushes action buttons to left/right if needed, currently all left? Keep aligns left mostly. */ + +.note-body img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin-top: 4px; + /* If we extracted cover, hide first img? */ +} + +/* --- COLORS --- */ +/* Reference: Keep Colors */ +/* Default */ +.note-card.note-color-default { + background-color: var(--background-secondary, #ffffff); +} + +[data-theme="dark"] .note-card.note-color-default { + background-color: #202124; + border-color: #5f6368; +} + +/* Red */ +.note-card.note-color-red { + background-color: #f28b82; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-red { + background-color: #5c2b29; + border-color: #5c2b29; +} + +/* Orange */ +.note-card.note-color-orange { + background-color: #fbbc04; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-orange { + background-color: #614a19; + border-color: #614a19; +} + +/* Yellow */ +.note-card.note-color-yellow { + background-color: #fff475; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-yellow { + background-color: #635d19; + border-color: #635d19; +} + +/* Green */ +.note-card.note-color-green { + background-color: #ccff90; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-green { + background-color: #345920; + border-color: #345920; +} + +/* Teal */ +.note-card.note-color-teal { + background-color: #a7ffeb; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-teal { + background-color: #16504b; + border-color: #16504b; +} + +/* Blue */ +.note-card.note-color-blue { + background-color: #cbf0f8; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-blue { + background-color: #2d555e; + border-color: #2d555e; +} + +/* Dark Blue */ +.note-card.note-color-darkblue { + background-color: #aecbfa; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-darkblue { + background-color: #1e3a5f; + border-color: #1e3a5f; +} + +/* Purple */ +.note-card.note-color-purple { + background-color: #d7aefb; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-purple { + background-color: #42275e; + border-color: #42275e; +} + +/* Pink */ +.note-card.note-color-pink { + background-color: #fdcfe8; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-pink { + background-color: #5b2245; + border-color: #5b2245; +} + +/* Brown */ +.note-card.note-color-brown { + background-color: #e6c9a8; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-brown { + background-color: #442f19; + border-color: #442f19; +} + +/* Grey */ +.note-card.note-color-grey { + background-color: #e8eaed; + border-color: transparent; +} + +[data-theme="dark"] .note-card.note-color-grey { + background-color: #3c3f43; + border-color: #3c3f43; +} + + +/* --- PALETTE POPUP --- */ +.palette-popup { + position: absolute; + bottom: 100%; + left: 0; + width: 320px; + background: white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + border-radius: 4px; + padding: 8px; + display: flex; + flex-wrap: wrap; + gap: 4px; + z-index: 100; + display: none; + /* JS toggles this */ +} + +[data-theme="dark"] .palette-popup { + background: #202124; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); + border: 1px solid #5f6368; +} + +.palette-popup.open { + display: flex; +} + +.palette-btn { + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.1); + cursor: pointer; + position: relative; +} + +.palette-btn:hover { + border-color: #999; +} + +[data-theme="dark"] .palette-btn { + border-color: rgba(255, 255, 255, 0.2); +} + +/* --- MODAL FOR NOTE VIEW --- */ +.note-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(2px); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; +} + +.note-modal-overlay.open { + opacity: 1; + pointer-events: auto; +} + +.note-modal { + background: var(--background-secondary, #fff); + width: 600px; + max-width: 90%; + max-height: 90vh; + border-radius: 8px; + overflow-y: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + padding: 24px; + position: relative; +} + +[data-theme="dark"] .note-modal { + background: var(--bg-card); + border: 1px solid var(--border); +} + +.note-modal .note-title { + font-size: 1.3rem; + line-height: 1.5; + margin-bottom: 16px; +} + +.note-modal .note-body { + font-size: 1rem; + line-height: 1.5; + -webkit-line-clamp: unset; + line-clamp: unset; + max-height: none; + overflow: visible; +} + +/* Modal actions at bottom? */ +.note-modal-actions { + margin-top: 24px; + display: flex; + justify-content: flex-end; +} \ No newline at end of file diff --git a/shaarli-pro/includes.html b/shaarli-pro/includes.html index 20a06df..14e3e9e 100644 --- a/shaarli-pro/includes.html +++ b/shaarli-pro/includes.html @@ -23,6 +23,7 @@ + {if="$pageName=='editlink' || $pageName=='addlink' || $pageName=='editlinkbatch'"} @@ -56,6 +57,7 @@ untaggedonly: {if="$untaggedonly"}true{else}false{/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 new file mode 100644 index 0000000..0d9e8dd --- /dev/null +++ b/shaarli-pro/js/custom_views.js @@ -0,0 +1,884 @@ +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)); +} diff --git a/shaarli-pro/page.header.html b/shaarli-pro/page.header.html index fc6f037..ff7612d 100644 --- a/shaarli-pro/page.header.html +++ b/shaarli-pro/page.header.html @@ -17,33 +17,44 @@