feat: refactorer la recherche avec modale moderne, pills interactives Notes/Tags/All, résultats avec badges kind et tags inline, chips de filtrage avec bouton close, layout responsive mobile optimisé, et simplification majeure du CSS avec suppression de 2500+ lignes de styles obsolètes (content-container, toolbar, pagination, link cards, compact table, etc.)

This commit is contained in:
Bruno Charest 2026-02-20 09:51:06 -05:00
parent 1ea13ff16b
commit 83d9a7425e
4 changed files with 266 additions and 2664 deletions

File diff suppressed because it is too large Load Diff

View File

@ -91,9 +91,10 @@ document.addEventListener('DOMContentLoaded', () => {
const searchResults = document.getElementById('search-results'); const searchResults = document.getElementById('search-results');
const searchTagsBtn = document.getElementById('search-tags-btn'); const searchTagsBtn = document.getElementById('search-tags-btn');
const searchAllBtn = document.getElementById('search-all-btn'); const searchAllBtn = document.getElementById('search-all-btn');
const searchNotesBtn = document.getElementById('search-notes-btn');
const searchForm = document.getElementById('search-form'); const searchForm = document.getElementById('search-form');
let searchMode = 'search'; // 'search' or 'tags' let searchMode = 'search'; // 'search' | 'tags' | 'notes'
let selectedIndex = -1; let selectedIndex = -1;
let searchTimeout = null; let searchTimeout = null;
let cachedBookmarks = null; let cachedBookmarks = null;
@ -167,9 +168,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (linkElements.length > 0) { if (linkElements.length > 0) {
cachedBookmarks = Array.from(linkElements).map(el => ({ cachedBookmarks = Array.from(linkElements).map(el => ({
id: el.dataset.id, id: el.dataset.id,
isNote: (el.querySelector('.link-title')?.getAttribute('title') || '').toLowerCase().includes('note'),
title: el.querySelector('.link-title a')?.textContent || el.querySelector('.link-title')?.textContent || '', title: el.querySelector('.link-title a')?.textContent || el.querySelector('.link-title')?.textContent || '',
url: el.querySelector('.link-url a')?.href || el.querySelector('.link-title a')?.href || '', url: el.querySelector('.link-url a')?.href || el.querySelector('.link-title a')?.href || '',
tags: Array.from(el.querySelectorAll('.link-tag')).map(t => t.textContent.trim()), tags: Array.from(el.querySelectorAll('.link-tag[data-tag]')).map(t => (t.getAttribute('data-tag') || '').trim()).filter(Boolean),
description: el.querySelector('.link-description')?.textContent || '' description: el.querySelector('.link-description')?.textContent || ''
})); }));
return cachedBookmarks; return cachedBookmarks;
@ -181,21 +183,32 @@ document.addEventListener('DOMContentLoaded', () => {
return []; return [];
} }
// Render search results (tags or bookmarks) function getSearchHint(mode) {
function renderResults(results, query, isTagMode = false) { if (mode === 'tags') {
return 'Type to search in tags...';
}
if (mode === 'notes') {
return 'Type to search in notes...';
}
return 'Type to search in bookmarks and notes...';
}
// Render search results (tags, global bookmarks, or notes)
function renderResults(results, query, mode = 'search') {
if (!searchResults) return; if (!searchResults) return;
if (results.length === 0) { if (results.length === 0) {
if (query && query.length > 0) { if (query && query.length > 0) {
const scopeLabel = mode === 'tags' ? 'tags' : (mode === 'notes' ? 'notes' : 'bookmarks/notes');
searchResults.innerHTML = ` searchResults.innerHTML = `
<div class="search-no-results"> <div class="search-no-results">
No results found for "<strong>${escapeHtml(query)}</strong>" No ${scopeLabel} found for "<strong>${escapeHtml(query)}</strong>"
</div> </div>
`; `;
} else { } else {
searchResults.innerHTML = ` searchResults.innerHTML = `
<div class="search-results-empty"> <div class="search-results-empty">
<span class="search-results-hint">Start typing to see tag suggestions...</span> <span class="search-results-hint">${getSearchHint(mode)}</span>
</div> </div>
`; `;
} }
@ -203,7 +216,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
let html; let html;
if (isTagMode) { if (mode === 'tags') {
// Render tags // Render tags
html = results.slice(0, 15).map((tag, index) => { html = results.slice(0, 15).map((tag, index) => {
const highlightedTag = highlightMatch(escapeHtml(tag), query); const highlightedTag = highlightMatch(escapeHtml(tag), query);
@ -219,19 +232,33 @@ document.addEventListener('DOMContentLoaded', () => {
`; `;
}).join(''); }).join('');
} else { } else {
// Render bookmarks // Render bookmarks or notes
html = results.slice(0, 10).map((item, index) => { html = results.slice(0, 12).map((item, index) => {
const highlightedTitle = highlightMatch(escapeHtml(item.title), query); const highlightedTitle = highlightMatch(escapeHtml(item.title), query);
const highlightedDescription = item.description ? highlightMatch(escapeHtml(item.description), query) : '';
const typeLabel = item.isNote ? 'note' : 'bookmark';
const typeIcon = item.isNote ? 'mdi-note-text-outline' : 'mdi-bookmark-outline';
const highlightedTags = (item.tags || []).slice(0, 4).map((tag) => {
const tagValue = escapeHtml(tag);
return `<span class="search-result-tag">${highlightMatch(tagValue, query)}</span>`;
}).join('');
return ` return `
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}" <div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
data-index="${index}" data-index="${index}"
data-url="${escapeHtml(item.url)}" data-url="${escapeHtml(item.url)}"
data-id="${item.id}"> data-id="${item.id}">
<div class="search-result-item-content"> <div class="search-result-item-content">
<i class="mdi mdi-bookmark-outline search-result-icon"></i> <i class="mdi ${typeIcon} search-result-icon"></i>
<div class="search-result-main">
<span class="search-result-text">${highlightedTitle}</span> <span class="search-result-text">${highlightedTitle}</span>
<span class="search-result-kind">${typeLabel}</span>
</div> </div>
</div> </div>
${highlightedDescription ? `<div class="search-result-sub">${highlightedDescription}</div>` : ''}
${highlightedTags ? `<div class="search-result-tags">${highlightedTags}</div>` : ''}
</div>
`; `;
}).join(''); }).join('');
} }
@ -241,7 +268,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Add click handlers to results // Add click handlers to results
searchResults.querySelectorAll('.search-result-item').forEach(item => { searchResults.querySelectorAll('.search-result-item').forEach(item => {
item.addEventListener('click', () => { item.addEventListener('click', () => {
if (isTagMode) { if (mode === 'tags') {
const tag = item.dataset.tag; const tag = item.dataset.tag;
if (tag) { if (tag) {
// Navigate to tag search // Navigate to tag search
@ -257,26 +284,42 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Perform live search // Perform live search
async function performSearch(query) { async function performSearch(query) {
const tags = fetchTags(); const normalizedQuery = (query || '').trim();
if (!query || query.length === 0) { if (searchMode === 'tags') {
// Show all tags when empty const tags = fetchTags();
renderResults(tags.slice(0, 15), '', true); if (!normalizedQuery) {
renderResults(tags.slice(0, 15), '', 'tags');
return; return;
} }
// Filter tags with fuzzy matching const tagResults = tags.filter(tag => fuzzyMatch(tag, normalizedQuery));
const results = tags.filter(tag => fuzzyMatch(tag, query)); renderResults(tagResults, normalizedQuery, 'tags');
renderResults(results, query, true); return;
}
const bookmarks = await fetchBookmarks();
const searchPool = searchMode === 'notes' ? bookmarks.filter(item => item.isNote) : bookmarks;
if (!normalizedQuery) {
renderResults(searchPool.slice(0, 12), '', searchMode);
return;
}
const results = searchPool.filter((item) => {
const haystack = [
item.title,
item.url,
item.description,
(item.tags || []).join(' ')
].join(' ').toLowerCase();
return haystack.includes(normalizedQuery.toLowerCase());
});
renderResults(results, normalizedQuery, searchMode);
} }
// Update selected result // Update selected result
@ -318,15 +361,12 @@ document.addEventListener('DOMContentLoaded', () => {
return false; return false;
} }
function openSearch() { function openSearch(mode = 'search') {
searchOverlay?.classList.add('show'); searchOverlay?.classList.add('show');
selectedIndex = -1; selectedIndex = -1;
setTimeout(() => { setTimeout(() => {
setSearchMode(mode);
searchModalInput?.focus(); searchModalInput?.focus();
// Trigger initial search if there's existing text
if (searchModalInput?.value) {
performSearch(searchModalInput.value);
}
}, 100); }, 100);
} }
@ -337,20 +377,30 @@ document.addEventListener('DOMContentLoaded', () => {
// Toggle search mode (tags vs search) // Toggle search mode (tags vs search)
function setSearchMode(mode) { function setSearchMode(mode) {
if (!['search', 'tags', 'notes'].includes(mode)) {
mode = 'search';
}
searchMode = mode; searchMode = mode;
searchTagsBtn?.classList.toggle('active', mode === 'tags');
searchAllBtn?.classList.toggle('active', mode === 'search'); searchAllBtn?.classList.toggle('active', mode === 'search');
searchTagsBtn?.classList.toggle('active', mode === 'tags');
searchNotesBtn?.classList.toggle('active', mode === 'notes');
searchAllBtn?.setAttribute('aria-pressed', String(mode === 'search'));
searchTagsBtn?.setAttribute('aria-pressed', String(mode === 'tags'));
searchNotesBtn?.setAttribute('aria-pressed', String(mode === 'notes'));
if (searchModalInput) { if (searchModalInput) {
searchModalInput.name = mode === 'tags' ? 'searchtags' : 'searchterm'; searchModalInput.name = mode === 'tags' ? 'searchtags' : 'searchterm';
searchModalInput.placeholder = getSearchHint(mode);
// Re-run search with new mode // Re-run search with new mode
performSearch(searchModalInput.value); performSearch(searchModalInput.value);
} }
} }
searchToggleBtns.forEach((btn) => { searchToggleBtns.forEach((btn) => {
btn.addEventListener('click', openSearch); btn.addEventListener('click', () => openSearch('search'));
}); });
// Close search on overlay click // Close search on overlay click
@ -367,7 +417,25 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
searchAllBtn?.addEventListener('click', (e) => { searchAllBtn?.addEventListener('click', (e) => {
// Only prevent default if not submitting e.preventDefault();
setSearchMode('search');
});
searchNotesBtn?.addEventListener('click', (e) => {
e.preventDefault();
setSearchMode('notes');
});
searchForm?.addEventListener('submit', (e) => {
if (searchMode === 'notes') {
e.preventDefault();
if (selectedIndex < 0) {
updateSelection(0);
}
navigateToSelected();
return;
}
if (selectedIndex >= 0) { if (selectedIndex >= 0) {
e.preventDefault(); e.preventDefault();
navigateToSelected(); navigateToSelected();
@ -425,6 +493,10 @@ document.addEventListener('DOMContentLoaded', () => {
// If an item is selected, navigate to it // If an item is selected, navigate to it
if (selectedIndex >= 0 && navigateToSelected()) { if (selectedIndex >= 0 && navigateToSelected()) {
e.preventDefault(); e.preventDefault();
} else if (searchMode === 'notes') {
e.preventDefault();
updateSelection(0);
navigateToSelected();
} }
// Otherwise, submit the form normally // Otherwise, submit the form normally
break; break;
@ -435,14 +507,19 @@ document.addEventListener('DOMContentLoaded', () => {
// S to open search (when not typing) // S to open search (when not typing)
if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 's' || e.key === 'S')) { if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault(); e.preventDefault();
openSearch(); openSearch('search');
} }
// T to open search directly in tags mode (when not typing) // T to open search directly in tags mode (when not typing)
if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 't' || e.key === 'T')) { if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault(); e.preventDefault();
openSearch(); openSearch('tags');
setSearchMode('tags'); }
// N to open search directly in notes mode (when not typing)
if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 'n' || e.key === 'N')) {
e.preventDefault();
openSearch('notes');
} }
}); });

View File

@ -12,6 +12,10 @@
{loop="$plugin_start_zone"} {loop="$plugin_start_zone"}
{$value} {$value}
{/loop} {/loop}
{$active_search_tags=[]}
{if="!empty($search_tags)"}
{$active_search_tags=tags_str2array($search_tags, $tags_separator)}
{/if}
<!-- {* ----- toolbar ----- *} --> <!-- {* ----- toolbar ----- *} -->
<div class="content-toolbar"> <div class="content-toolbar">
<div class="toolbar-left">{include="linklist.paging"}</div> <div class="toolbar-left">{include="linklist.paging"}</div>
@ -32,8 +36,7 @@
{if="!empty($search_tags)"} {if="!empty($search_tags)"}
<div class="search-results-header"> <div class="search-results-header">
<span class="search-count">{$result_count} résultat(s) tagué(s)</span> <span class="search-count">{$result_count} résultat(s) tagué(s)</span>
{$exploded_tags=tags_str2array($search_tags, $tags_separator)} {loop="$active_search_tags"}
{loop="$exploded_tags"}
<span class="search-tag-chip">{$value} <a href="{$base_path}/remove-tag/{function=" <span class="search-tag-chip">{$value} <a href="{$base_path}/remove-tag/{function="
urlencode($value)"}" class="search-tag-close" title="Retirer le tag" urlencode($value)"}" class="search-tag-close" title="Retirer le tag"
aria-label="Retirer le tag {$value}"><i class="mdi mdi-close" aria-label="Retirer le tag {$value}"><i class="mdi mdi-close"
@ -94,7 +97,7 @@
{if="$value.description"}<div class="link-description">{$value.description}</div>{/if} {if="$value.description"}<div class="link-description">{$value.description}</div>{/if}
<div class="link-footer"> <div class="link-footer">
<div class="link-tag-list"> <div class="link-tag-list">
{loop="$value.taglist"}<span class="link-tag" data-tag="{$value}"><a {loop="$value.taglist"}<span class="link-tag{if="in_array($value, $active_search_tags)"} is-search-match{/if}" data-tag="{$value}"><a
class="link-tag-link" href="{$base_path}/add-tag/{$value|urlencode}">{$value}</a>{if="$is_logged_in"}<button type="button" class="tag-remove-btn" data-tag="{$value}" aria-label="Supprimer le tag {$value}" title="Supprimer">&times;</button>{/if}</span>{/loop} class="link-tag-link" href="{$base_path}/add-tag/{$value|urlencode}">{$value}</a>{if="$is_logged_in"}<button type="button" class="tag-remove-btn" data-tag="{$value}" aria-label="Supprimer le tag {$value}" title="Supprimer">&times;</button>{/if}</span>{/loop}
</div> </div>
<div class="link-actions"> <div class="link-actions">

View File

@ -267,11 +267,15 @@ Bookmarklet detection logic
<input type="text" name="searchterm" class="search-modal-input" id="search-modal-input" <input type="text" name="searchterm" class="search-modal-input" id="search-modal-input"
placeholder="Type to search..." autocomplete="off" value="{if="isset($search_term)"}{$search_term}{/if}"> placeholder="Type to search..." autocomplete="off" value="{if="isset($search_term)"}{$search_term}{/if}">
<div class="search-modal-actions"> <div class="search-modal-actions">
<button type="button" class="search-pill-btn search-pill-notes" id="search-notes-btn" aria-pressed="false">
<i class="mdi mdi-note-text-outline"></i>
<span>notes</span>
</button>
<button type="button" class="search-pill-btn search-pill-tags" id="search-tags-btn"> <button type="button" class="search-pill-btn search-pill-tags" id="search-tags-btn">
<i class="mdi mdi-tag-outline"></i> <i class="mdi mdi-tag-outline"></i>
<span>tags</span> <span>tags</span>
</button> </button>
<button type="submit" class="search-pill-btn search-pill-search active" id="search-all-btn"> <button type="button" class="search-pill-btn search-pill-search active" id="search-all-btn" aria-pressed="true">
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
<span>search</span> <span>search</span>
</button> </button>
@ -286,7 +290,9 @@ Bookmarklet detection logic
</div> </div>
<div class="search-footer"> <div class="search-footer">
<span class="search-footer-hint"> <span class="search-footer-hint">
<kbd class="search-kbd">S</kbd> global
<kbd class="search-kbd">T</kbd> tags <kbd class="search-kbd">T</kbd> tags
<kbd class="search-kbd">N</kbd> notes
<kbd class="search-kbd"></kbd><kbd class="search-kbd"></kbd> to navigate <kbd class="search-kbd"></kbd><kbd class="search-kbd"></kbd> to navigate
<kbd class="search-kbd">Enter</kbd> to select <kbd class="search-kbd">Enter</kbd> to select
<kbd class="search-kbd">ESC</kbd> to close <kbd class="search-kbd">ESC</kbd> to close