search barre

This commit is contained in:
Bruno Charest 2026-01-13 20:53:39 -05:00
parent a9f3c009fc
commit 2da5ffcacc
3 changed files with 593 additions and 81 deletions

View File

@ -623,20 +623,21 @@ input:checked+.theme-slider:before {
border-color: var(--primary); border-color: var(--primary);
} }
/* ===== Search Overlay ===== */ /* ===== Search Overlay (Spotlight Style) ===== */
.search-overlay { .search-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: var(--overlay-bg); background: rgba(0, 0, 0, 0.55);
z-index: 500; z-index: 500;
display: none; display: none;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
padding-top: 15vh; padding-top: 12vh;
animation: fadeIn 0.2s ease; animation: fadeIn 0.2s ease;
backdrop-filter: blur(4px);
} }
.search-overlay.show { .search-overlay.show {
@ -655,94 +656,332 @@ input:checked+.theme-slider:before {
.search-modal { .search-modal {
width: 90%; width: 90%;
max-width: 600px; max-width: 580px;
background: var(--bg-card); background: #ffffff;
border-radius: 1rem; border-radius: 12px;
box-shadow: var(--shadow-xl); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.05);
overflow: hidden; overflow: hidden;
animation: slideUp 0.2s ease; animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-theme="dark"] .search-modal {
background: var(--bg-card);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
} }
@keyframes slideUp { @keyframes slideUp {
from { from {
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(20px) scale(0.98);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0) scale(1);
} }
} }
/* Search Header (Input Zone) */
.search-modal-header { .search-modal-header {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
gap: 0.75rem; gap: 0.75rem;
} }
.search-modal-icon {
color: var(--text-muted);
font-size: 1.25rem;
}
.search-modal-input { .search-modal-input {
flex: 1; flex: 1;
border: none; border: none;
background: transparent; background: transparent;
font-size: 1.125rem; font-size: 1rem;
color: var(--text-main); font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #111827;
outline: none; outline: none;
caret-color: var(--primary);
}
[data-theme="dark"] .search-modal-input {
color: var(--text-main);
} }
.search-modal-input::placeholder { .search-modal-input::placeholder {
color: #9ca3af;
}
[data-theme="dark"] .search-modal-input::placeholder {
color: var(--text-muted); color: var(--text-muted);
} }
/* Pill Buttons */
.search-modal-actions { .search-modal-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
.search-type-btn { .search-pill-btn {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--border);
background: var(--bg-body);
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 9999px;
border: none;
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.search-type-btn:hover, .search-pill-btn i {
.search-type-btn.active { font-size: 0.9rem;
background: var(--primary);
border-color: var(--primary);
color: white;
} }
.search-hint { /* Tags pill - Gray style */
padding: 0.75rem 1.25rem; .search-pill-tags {
background: #E5E7EB;
color: #374151;
}
.search-pill-tags:hover {
background: #D1D5DB;
}
.search-pill-tags.active {
background: #3B82F6;
color: #ffffff;
}
[data-theme="dark"] .search-pill-tags {
background: #374151;
color: #D1D5DB;
}
[data-theme="dark"] .search-pill-tags:hover {
background: #4B5563;
}
[data-theme="dark"] .search-pill-tags.active {
background: #3B82F6;
color: #ffffff;
}
/* Search pill - Blue style */
.search-pill-search {
background: #E5E7EB;
color: #374151;
}
.search-pill-search:hover {
background: #D1D5DB;
}
.search-pill-search.active {
background: #3B82F6;
color: #ffffff;
}
[data-theme="dark"] .search-pill-search {
background: #374151;
color: #D1D5DB;
}
[data-theme="dark"] .search-pill-search:hover {
background: #4B5563;
}
[data-theme="dark"] .search-pill-search.active {
background: #3B82F6;
color: #ffffff;
}
/* Separator */
.search-separator {
height: 1px;
background: #E5E7EB;
margin: 0;
}
[data-theme="dark"] .search-separator {
background: var(--border);
}
/* Results List */
.search-results {
max-height: 320px;
overflow-y: auto;
padding: 0.5rem 0;
}
.search-results-empty {
padding: 1.5rem 1.25rem;
text-align: center;
}
.search-results-hint {
color: #9ca3af;
font-size: 0.875rem;
}
[data-theme="dark"] .search-results-hint {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8rem; }
/* Result Item */
.search-result-item {
display: block;
padding: 0.625rem 1.25rem;
color: #374151;
font-size: 0.9375rem;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
cursor: pointer;
transition: background 0.1s ease;
text-decoration: none;
line-height: 1.5;
}
.search-result-item:hover,
.search-result-item.selected {
background: #F3F4F6;
color: #111827;
}
[data-theme="dark"] .search-result-item {
color: var(--text-secondary);
}
[data-theme="dark"] .search-result-item:hover,
[data-theme="dark"] .search-result-item.selected {
background: var(--bg-card-hover);
color: var(--text-main);
}
/* Highlight (Fuzzy Matching) */
.search-result-item mark,
.search-highlight {
background-color: #FFFF00;
color: #000000;
padding: 0 2px;
border-radius: 2px;
}
[data-theme="dark"] .search-result-item mark,
[data-theme="dark"] .search-highlight {
background-color: #FDE047;
color: #000000;
}
/* No Results */
.search-no-results {
padding: 1.5rem 1.25rem;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
}
[data-theme="dark"] .search-no-results {
color: var(--text-muted);
}
/* Footer */
.search-footer {
padding: 0.625rem 1.25rem;
background: #F9FAFB;
border-top: 1px solid #E5E7EB;
}
[data-theme="dark"] .search-footer {
background: var(--bg-body); background: var(--bg-body);
border-top-color: var(--border);
}
.search-footer-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: #9ca3af;
font-size: 0.75rem;
}
[data-theme="dark"] .search-footer-hint {
color: var(--text-muted);
} }
.search-kbd { .search-kbd {
display: inline-block; display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;
background: #ffffff;
border: 1px solid #D1D5DB;
border-radius: 4px;
font-family: 'Inter', system-ui, sans-serif;
font-size: 0.6875rem;
font-weight: 500;
color: #6B7280;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .search-kbd {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border-color: var(--border);
border-radius: 0.25rem; color: var(--text-muted);
font-family: monospace; }
font-size: 0.75rem;
margin-left: 0.25rem; /* Loading state */
.search-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #9ca3af;
}
.search-loading::after {
content: '';
width: 20px;
height: 20px;
border: 2px solid #E5E7EB;
border-top-color: #3B82F6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Result item with icon */
.search-result-item-content {
display: flex;
align-items: center;
gap: 0.625rem;
}
.search-result-icon {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
font-size: 0.875rem;
}
[data-theme="dark"] .search-result-icon {
color: var(--text-muted);
}
.search-result-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* ===== Content Container ===== */ /* ===== Content Container ===== */

View File

@ -44,18 +44,250 @@ document.addEventListener('DOMContentLoaded', () => {
mobileMenuBtn?.addEventListener('click', toggleSidebar); mobileMenuBtn?.addEventListener('click', toggleSidebar);
sidebarOverlay?.addEventListener('click', toggleSidebar); sidebarOverlay?.addEventListener('click', toggleSidebar);
// ===== Search Overlay ===== // ===== Search Overlay (Spotlight Style) =====
const searchOverlay = document.getElementById('search-overlay'); const searchOverlay = document.getElementById('search-overlay');
const searchToggleBtn = document.getElementById('search-toggle-btn'); const searchToggleBtn = document.getElementById('search-toggle-btn');
const searchModalInput = document.getElementById('search-modal-input'); const searchModalInput = document.getElementById('search-modal-input');
const searchResults = document.getElementById('search-results');
const searchTagsBtn = document.getElementById('search-tags-btn');
const searchAllBtn = document.getElementById('search-all-btn');
const searchForm = document.getElementById('search-form');
let searchMode = 'search'; // 'search' or 'tags'
let selectedIndex = -1;
let searchTimeout = null;
let cachedBookmarks = null;
let cachedTags = null;
// Highlight matching text with <mark> tags
function highlightMatch(text, query) {
if (!query || query.length === 0) return text;
// Escape special regex characters in query
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
// Fuzzy search - matches substring anywhere in text
function fuzzyMatch(text, query) {
if (!query || query.length === 0) return true;
return text.toLowerCase().includes(query.toLowerCase());
}
// Fetch all unique tags from the page
function fetchTags() {
if (cachedTags) return cachedTags;
try {
const tagElements = document.querySelectorAll('.link-tag, .tag-link, [class*="tag"]');
const tagsSet = new Set();
tagElements.forEach(el => {
const tagText = el.textContent.trim();
if (tagText && tagText.length > 0 && !tagText.includes('•')) {
tagsSet.add(tagText);
}
});
// Convert to array and sort alphabetically
cachedTags = Array.from(tagsSet).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
return cachedTags;
} catch (e) {
console.error('Failed to fetch tags:', e);
return [];
}
}
// Fetch bookmarks for live search
async function fetchBookmarks() {
if (cachedBookmarks) return cachedBookmarks;
try {
// Try to get bookmarks from page (if already loaded)
const linkElements = document.querySelectorAll('.link-outer');
if (linkElements.length > 0) {
cachedBookmarks = Array.from(linkElements).map(el => ({
id: el.dataset.id,
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 || '',
tags: Array.from(el.querySelectorAll('.link-tag')).map(t => t.textContent.trim()),
description: el.querySelector('.link-description')?.textContent || ''
}));
return cachedBookmarks;
}
} catch (e) {
console.error('Failed to fetch bookmarks:', e);
}
return [];
}
// Render search results (tags or bookmarks)
function renderResults(results, query, isTagMode = false) {
if (!searchResults) return;
if (results.length === 0) {
if (query && query.length > 0) {
searchResults.innerHTML = `
<div class="search-no-results">
No results found for "<strong>${escapeHtml(query)}</strong>"
</div>
`;
} else {
searchResults.innerHTML = `
<div class="search-results-empty">
<span class="search-results-hint">Start typing to see tag suggestions...</span>
</div>
`;
}
return;
}
let html;
if (isTagMode) {
// Render tags
html = results.slice(0, 15).map((tag, index) => {
const highlightedTag = highlightMatch(escapeHtml(tag), query);
return `
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
data-index="${index}"
data-tag="${escapeHtml(tag)}">
<div class="search-result-item-content">
<i class="mdi mdi-tag-outline search-result-icon"></i>
<span class="search-result-text">${highlightedTag}</span>
</div>
</div>
`;
}).join('');
} else {
// Render bookmarks
html = results.slice(0, 10).map((item, index) => {
const highlightedTitle = highlightMatch(escapeHtml(item.title), query);
return `
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
data-index="${index}"
data-url="${escapeHtml(item.url)}"
data-id="${item.id}">
<div class="search-result-item-content">
<i class="mdi mdi-bookmark-outline search-result-icon"></i>
<span class="search-result-text">${highlightedTitle}</span>
</div>
</div>
`;
}).join('');
}
searchResults.innerHTML = html;
// Add click handlers to results
searchResults.querySelectorAll('.search-result-item').forEach(item => {
item.addEventListener('click', () => {
if (isTagMode) {
const tag = item.dataset.tag;
if (tag) {
// Navigate to tag search
window.location.href = shaarli.basePath + '/?searchtags=' + encodeURIComponent(tag);
}
} else {
const url = item.dataset.url;
if (url) {
window.location.href = url;
}
}
});
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Perform live search
async function performSearch(query) {
const tags = fetchTags();
if (!query || query.length === 0) {
// Show all tags when empty
renderResults(tags.slice(0, 15), '', true);
return;
}
// Filter tags with fuzzy matching
const results = tags.filter(tag => fuzzyMatch(tag, query));
renderResults(results, query, true);
}
// Update selected result
function updateSelection(newIndex) {
const items = searchResults?.querySelectorAll('.search-result-item');
if (!items || items.length === 0) return;
// Clamp index
if (newIndex < 0) newIndex = items.length - 1;
if (newIndex >= items.length) newIndex = 0;
selectedIndex = newIndex;
items.forEach((item, index) => {
item.classList.toggle('selected', index === selectedIndex);
});
// Scroll into view
items[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}
// Navigate to selected result
function navigateToSelected() {
const selected = searchResults?.querySelector('.search-result-item.selected');
if (selected) {
// Check if it's a tag
const tag = selected.dataset.tag;
if (tag) {
window.location.href = shaarli.basePath + '/?searchtags=' + encodeURIComponent(tag);
return true;
}
// Otherwise check for URL
const url = selected.dataset.url;
if (url) {
window.location.href = url;
return true;
}
}
return false;
}
function openSearch() { function openSearch() {
searchOverlay?.classList.add('show'); searchOverlay?.classList.add('show');
setTimeout(() => searchModalInput?.focus(), 100); selectedIndex = -1;
setTimeout(() => {
searchModalInput?.focus();
// Trigger initial search if there's existing text
if (searchModalInput?.value) {
performSearch(searchModalInput.value);
}
}, 100);
} }
function closeSearch() { function closeSearch() {
searchOverlay?.classList.remove('show'); searchOverlay?.classList.remove('show');
selectedIndex = -1;
}
// Toggle search mode (tags vs search)
function setSearchMode(mode) {
searchMode = mode;
searchTagsBtn?.classList.toggle('active', mode === 'tags');
searchAllBtn?.classList.toggle('active', mode === 'search');
if (searchModalInput) {
searchModalInput.name = mode === 'tags' ? 'searchtags' : 'searchterm';
// Re-run search with new mode
performSearch(searchModalInput.value);
}
} }
searchToggleBtn?.addEventListener('click', openSearch); searchToggleBtn?.addEventListener('click', openSearch);
@ -67,24 +299,74 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
// Keyboard shortcut: S to open search, ESC to close // Search mode toggle buttons
searchTagsBtn?.addEventListener('click', (e) => {
e.preventDefault();
setSearchMode('tags');
});
searchAllBtn?.addEventListener('click', (e) => {
// Only prevent default if not submitting
if (selectedIndex >= 0) {
e.preventDefault();
navigateToSelected();
}
});
// Live search on input
searchModalInput?.addEventListener('input', (e) => {
const query = e.target.value;
// Debounce search
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
selectedIndex = -1;
performSearch(query);
}, 150);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// Don't trigger if typing in an input const isSearchOpen = searchOverlay?.classList.contains('show');
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { const isTyping = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
// Handle ESC - always close search/filter
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (isSearchOpen) {
closeSearch(); closeSearch();
e.preventDefault();
}
closeFilterPanel();
return;
}
// If search is open, handle navigation
if (isSearchOpen && e.target === searchModalInput) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
updateSelection(selectedIndex + 1);
break;
case 'ArrowUp':
e.preventDefault();
updateSelection(selectedIndex - 1);
break;
case 'Enter':
// If an item is selected, navigate to it
if (selectedIndex >= 0 && navigateToSelected()) {
e.preventDefault();
}
// Otherwise, submit the form normally
break;
} }
return; return;
} }
if (e.key === 's' || e.key === 'S') { // S to open search (when not typing)
if (!isTyping && (e.key === 's' || e.key === 'S')) {
e.preventDefault(); e.preventDefault();
openSearch(); openSearch();
} }
if (e.key === 'Escape') {
closeSearch();
closeFilterPanel();
}
}); });
// ===== Filter Panel ===== // ===== Filter Panel =====
@ -360,26 +642,6 @@ document.addEventListener('DOMContentLoaded', () => {
form.submit(); form.submit();
} }
// ===== Search Type Toggle =====
const searchTagsBtn = document.getElementById('search-tags-btn');
const searchAllBtn = document.getElementById('search-all-btn');
const searchForm = document.getElementById('search-form');
searchTagsBtn?.addEventListener('click', (e) => {
e.preventDefault();
searchModalInput.name = 'searchtags';
searchAllBtn?.classList.remove('active');
searchTagsBtn?.classList.add('active');
searchForm?.submit();
});
searchAllBtn?.addEventListener('click', (e) => {
e.preventDefault();
searchModalInput.name = 'searchterm';
searchTagsBtn?.classList.remove('active');
searchAllBtn?.classList.add('active');
searchForm?.submit();
});
// ===== Thumbnail Update ===== // ===== Thumbnail Update =====
const thumbnailsPage = document.querySelector('.page-thumbnails'); const thumbnailsPage = document.querySelector('.page-thumbnails');

View File

@ -207,27 +207,38 @@
</div> </div>
</header> </header>
<!-- Search Overlay --> <!-- Search Overlay (Spotlight Style) -->
<div class="search-overlay" id="search-overlay"> <div class="search-overlay" id="search-overlay">
<div class="search-modal"> <div class="search-modal">
<form method="GET" action="{$base_path}/" id="search-form"> <form method="GET" action="{$base_path}/" id="search-form">
<div class="search-modal-header"> <div class="search-modal-header">
<i class="mdi mdi-magnify search-modal-icon"></i>
<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="Search..." autocomplete="off" value="{if=" placeholder="Type to search..." autocomplete="off" value="{if="
isset($search_term)"}{$search_term}{/if}"> isset($search_term)"}{$search_term}{/if}">
<div class="search-modal-actions"> <div class="search-modal-actions">
<button type="submit" name="searchtags" class="search-type-btn" id="search-tags-btn"> <button type="button" class="search-pill-btn search-pill-tags" id="search-tags-btn">
<i class="mdi mdi-tag"></i> tags <i class="mdi mdi-tag-outline"></i>
<span>tags</span>
</button> </button>
<button type="submit" class="search-type-btn active" id="search-all-btn"> <button type="submit" class="search-pill-btn search-pill-search active" id="search-all-btn">
<i class="mdi mdi-magnify"></i> search <i class="mdi mdi-magnify"></i>
<span>search</span>
</button> </button>
</div> </div>
</div> </div>
</form> </form>
<div class="search-hint"> <div class="search-separator"></div>
Press <kbd class="search-kbd">ESC</kbd> to close or <kbd class="search-kbd">Enter</kbd> to search <div class="search-results" id="search-results">
<div class="search-results-empty">
<span class="search-results-hint">Start typing to see suggestions...</span>
</div>
</div>
<div class="search-footer">
<span class="search-footer-hint">
<kbd class="search-kbd"></kbd><kbd class="search-kbd"></kbd> to navigate
<kbd class="search-kbd">Enter</kbd> to select
<kbd class="search-kbd">ESC</kbd> to close
</span>
</div> </div>
</div> </div>
</div> </div>