Add advanced TF-IDF search with autocomplete, query operators, facets, pagination, and accent normalization
This commit is contained in:
+770
-39
@@ -25,6 +25,21 @@
|
||||
let activeSidebarTab = "vaults";
|
||||
let filterDebounce = null;
|
||||
|
||||
// Advanced search state
|
||||
let advancedSearchOffset = 0;
|
||||
let advancedSearchTotal = 0;
|
||||
let advancedSearchSort = "relevance";
|
||||
let advancedSearchLastQuery = "";
|
||||
let suggestAbortController = null;
|
||||
let dropdownActiveIndex = -1;
|
||||
let dropdownItems = [];
|
||||
|
||||
// Advanced search constants
|
||||
const SEARCH_HISTORY_KEY = "obsigate_search_history";
|
||||
const MAX_HISTORY_ENTRIES = 50;
|
||||
const SUGGEST_DEBOUNCE_MS = 150;
|
||||
const ADVANCED_SEARCH_LIMIT = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File extension → Lucide icon mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -75,6 +90,414 @@
|
||||
return EXT_ICONS[ext] || "file";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search History Service (localStorage, LIFO, max 50, dedup)
|
||||
// ---------------------------------------------------------------------------
|
||||
const SearchHistory = {
|
||||
_load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
},
|
||||
_save(entries) {
|
||||
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(entries)); } catch {}
|
||||
},
|
||||
getAll() { return this._load(); },
|
||||
add(query) {
|
||||
if (!query || !query.trim()) return;
|
||||
const q = query.trim();
|
||||
let entries = this._load().filter(e => e !== q);
|
||||
entries.unshift(q);
|
||||
if (entries.length > MAX_HISTORY_ENTRIES) entries = entries.slice(0, MAX_HISTORY_ENTRIES);
|
||||
this._save(entries);
|
||||
},
|
||||
remove(query) {
|
||||
const entries = this._load().filter(e => e !== query);
|
||||
this._save(entries);
|
||||
},
|
||||
clear() { this._save([]); },
|
||||
filter(prefix) {
|
||||
if (!prefix) return this.getAll().slice(0, 8);
|
||||
const lp = prefix.toLowerCase();
|
||||
return this._load().filter(e => e.toLowerCase().includes(lp)).slice(0, 8);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query Parser — extracts operators (tag:, #, vault:, title:, path:)
|
||||
// ---------------------------------------------------------------------------
|
||||
const QueryParser = {
|
||||
parse(raw) {
|
||||
const result = { tags: [], vault: null, title: null, path: null, freeText: "" };
|
||||
if (!raw) return result;
|
||||
const tokens = this._tokenize(raw);
|
||||
const freeTokens = [];
|
||||
for (const tok of tokens) {
|
||||
const lower = tok.toLowerCase();
|
||||
if (lower.startsWith("tag:")) {
|
||||
const v = tok.slice(4).replace(/"/g, "").trim().replace(/^#/, "");
|
||||
if (v) result.tags.push(v);
|
||||
} else if (lower.startsWith("#") && tok.length > 1) {
|
||||
result.tags.push(tok.slice(1));
|
||||
} else if (lower.startsWith("vault:")) {
|
||||
result.vault = tok.slice(6).replace(/"/g, "").trim();
|
||||
} else if (lower.startsWith("title:")) {
|
||||
result.title = tok.slice(6).replace(/"/g, "").trim();
|
||||
} else if (lower.startsWith("path:")) {
|
||||
result.path = tok.slice(5).replace(/"/g, "").trim();
|
||||
} else {
|
||||
freeTokens.push(tok);
|
||||
}
|
||||
}
|
||||
result.freeText = freeTokens.join(" ");
|
||||
return result;
|
||||
},
|
||||
_tokenize(raw) {
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
const n = raw.length;
|
||||
while (i < n) {
|
||||
while (i < n && raw[i] === " ") i++;
|
||||
if (i >= n) break;
|
||||
if (raw[i] !== '"') {
|
||||
let j = i;
|
||||
while (j < n && raw[j] !== " ") {
|
||||
if (raw[j] === '"') { j++; while (j < n && raw[j] !== '"') j++; if (j < n) j++; }
|
||||
else j++;
|
||||
}
|
||||
tokens.push(raw.slice(i, j).replace(/"/g, ""));
|
||||
i = j;
|
||||
} else {
|
||||
i++;
|
||||
let j = i;
|
||||
while (j < n && raw[j] !== '"') j++;
|
||||
tokens.push(raw.slice(i, j));
|
||||
i = j + 1;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
},
|
||||
/** Detect the current operator context at cursor for autocomplete */
|
||||
getContext(raw, cursorPos) {
|
||||
const before = raw.slice(0, cursorPos);
|
||||
// Check if we're typing a tag: or # value
|
||||
const tagMatch = before.match(/(?:tag:|#)([\w-]*)$/i);
|
||||
if (tagMatch) return { type: "tag", prefix: tagMatch[1] };
|
||||
// Check if typing title:
|
||||
const titleMatch = before.match(/title:([\w-]*)$/i);
|
||||
if (titleMatch) return { type: "title", prefix: titleMatch[1] };
|
||||
// Default: free text
|
||||
const words = before.trim().split(/\s+/);
|
||||
const lastWord = words[words.length - 1] || "";
|
||||
return { type: "text", prefix: lastWord };
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Autocomplete Dropdown Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
const AutocompleteDropdown = {
|
||||
_dropdown: null,
|
||||
_historySection: null,
|
||||
_titlesSection: null,
|
||||
_tagsSection: null,
|
||||
_historyList: null,
|
||||
_titlesList: null,
|
||||
_tagsList: null,
|
||||
_emptyEl: null,
|
||||
_suggestTimer: null,
|
||||
|
||||
init() {
|
||||
this._dropdown = document.getElementById("search-dropdown");
|
||||
this._historySection = document.getElementById("search-dropdown-history");
|
||||
this._titlesSection = document.getElementById("search-dropdown-titles");
|
||||
this._tagsSection = document.getElementById("search-dropdown-tags");
|
||||
this._historyList = document.getElementById("search-dropdown-history-list");
|
||||
this._titlesList = document.getElementById("search-dropdown-titles-list");
|
||||
this._tagsList = document.getElementById("search-dropdown-tags-list");
|
||||
this._emptyEl = document.getElementById("search-dropdown-empty");
|
||||
|
||||
// Clear history button
|
||||
const clearBtn = document.getElementById("search-dropdown-clear-history");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
SearchHistory.clear();
|
||||
this.hide();
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (this._dropdown && !this._dropdown.contains(e.target) &&
|
||||
e.target.id !== "search-input") {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
show() {
|
||||
if (this._dropdown) this._dropdown.hidden = false;
|
||||
},
|
||||
|
||||
hide() {
|
||||
if (this._dropdown) this._dropdown.hidden = true;
|
||||
dropdownActiveIndex = -1;
|
||||
dropdownItems = [];
|
||||
},
|
||||
|
||||
isVisible() {
|
||||
return this._dropdown && !this._dropdown.hidden;
|
||||
},
|
||||
|
||||
/** Populate and show the dropdown with history, title suggestions, and tag suggestions */
|
||||
async populate(inputValue, cursorPos) {
|
||||
// Cancel previous suggestion request
|
||||
if (suggestAbortController) { suggestAbortController.abort(); suggestAbortController = null; }
|
||||
|
||||
const ctx = QueryParser.getContext(inputValue, cursorPos);
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
|
||||
// History — always show filtered history
|
||||
const historyItems = SearchHistory.filter(inputValue);
|
||||
this._renderHistory(historyItems, inputValue);
|
||||
|
||||
// Title and tag suggestions from API (debounced)
|
||||
clearTimeout(this._suggestTimer);
|
||||
if (ctx.prefix && ctx.prefix.length >= 2) {
|
||||
this._suggestTimer = setTimeout(() => this._fetchSuggestions(ctx, vault, inputValue), SUGGEST_DEBOUNCE_MS);
|
||||
} else {
|
||||
this._renderTitles([], "");
|
||||
this._renderTags([], "");
|
||||
}
|
||||
|
||||
// Show/hide sections
|
||||
const hasContent = historyItems.length > 0;
|
||||
this._historySection.hidden = historyItems.length === 0;
|
||||
this._emptyEl.hidden = hasContent;
|
||||
|
||||
if (hasContent || (ctx.prefix && ctx.prefix.length >= 2)) {
|
||||
this.show();
|
||||
} else if (!hasContent) {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
this._collectItems();
|
||||
},
|
||||
|
||||
async _fetchSuggestions(ctx, vault, inputValue) {
|
||||
suggestAbortController = new AbortController();
|
||||
try {
|
||||
const [titlesRes, tagsRes] = await Promise.all([
|
||||
ctx.type !== "tag" ? api(`/api/suggest?q=${encodeURIComponent(ctx.prefix)}&vault=${encodeURIComponent(vault)}&limit=8`, { signal: suggestAbortController.signal }) : Promise.resolve({ suggestions: [] }),
|
||||
(ctx.type === "tag" || ctx.type === "text") ? api(`/api/tags/suggest?q=${encodeURIComponent(ctx.prefix)}&vault=${encodeURIComponent(vault)}&limit=6`, { signal: suggestAbortController.signal }) : Promise.resolve({ suggestions: [] }),
|
||||
]);
|
||||
|
||||
this._renderTitles(titlesRes.suggestions || [], ctx.prefix);
|
||||
this._renderTags(tagsRes.suggestions || [], ctx.prefix);
|
||||
|
||||
// Update visibility
|
||||
const hasTitles = (titlesRes.suggestions || []).length > 0;
|
||||
const hasTags = (tagsRes.suggestions || []).length > 0;
|
||||
this._titlesSection.hidden = !hasTitles;
|
||||
this._tagsSection.hidden = !hasTags;
|
||||
|
||||
const historyVisible = !this._historySection.hidden;
|
||||
const hasAny = historyVisible || hasTitles || hasTags;
|
||||
this._emptyEl.hidden = hasAny;
|
||||
if (hasAny) this.show(); else if (!historyVisible) this.hide();
|
||||
|
||||
this._collectItems();
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") console.error("Suggestion fetch error:", err);
|
||||
}
|
||||
},
|
||||
|
||||
_renderHistory(items, query) {
|
||||
this._historyList.innerHTML = "";
|
||||
items.forEach((entry) => {
|
||||
const li = el("li", { class: "search-dropdown__item search-dropdown__item--history", role: "option" });
|
||||
const iconEl = el("span", { class: "search-dropdown__icon" });
|
||||
iconEl.innerHTML = '<i data-lucide="clock" style="width:14px;height:14px"></i>';
|
||||
const textEl = el("span", { class: "search-dropdown__text" });
|
||||
textEl.textContent = entry;
|
||||
li.appendChild(iconEl);
|
||||
li.appendChild(textEl);
|
||||
li.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
input.value = entry;
|
||||
this.hide();
|
||||
_triggerAdvancedSearch(entry);
|
||||
});
|
||||
this._historyList.appendChild(li);
|
||||
});
|
||||
},
|
||||
|
||||
_renderTitles(items, prefix) {
|
||||
this._titlesList.innerHTML = "";
|
||||
items.forEach((item) => {
|
||||
const li = el("li", { class: "search-dropdown__item search-dropdown__item--title", role: "option" });
|
||||
const iconEl = el("span", { class: "search-dropdown__icon" });
|
||||
iconEl.innerHTML = '<i data-lucide="file-text" style="width:14px;height:14px"></i>';
|
||||
const textEl = el("span", { class: "search-dropdown__text" });
|
||||
if (prefix) {
|
||||
this._highlightText(textEl, item.title, prefix);
|
||||
} else {
|
||||
textEl.textContent = item.title;
|
||||
}
|
||||
const metaEl = el("span", { class: "search-dropdown__meta" });
|
||||
metaEl.textContent = item.vault;
|
||||
li.appendChild(iconEl);
|
||||
li.appendChild(textEl);
|
||||
li.appendChild(metaEl);
|
||||
li.addEventListener("click", () => {
|
||||
this.hide();
|
||||
openFile(item.vault, item.path);
|
||||
});
|
||||
this._titlesList.appendChild(li);
|
||||
});
|
||||
},
|
||||
|
||||
_renderTags(items, prefix) {
|
||||
this._tagsList.innerHTML = "";
|
||||
items.forEach((item) => {
|
||||
const li = el("li", { class: "search-dropdown__item search-dropdown__item--tag", role: "option" });
|
||||
const iconEl = el("span", { class: "search-dropdown__icon" });
|
||||
iconEl.innerHTML = '<i data-lucide="hash" style="width:14px;height:14px"></i>';
|
||||
const textEl = el("span", { class: "search-dropdown__text" });
|
||||
if (prefix) {
|
||||
this._highlightText(textEl, item.tag, prefix);
|
||||
} else {
|
||||
textEl.textContent = item.tag;
|
||||
}
|
||||
const badge = el("span", { class: "search-dropdown__badge" });
|
||||
badge.textContent = item.count;
|
||||
li.appendChild(iconEl);
|
||||
li.appendChild(textEl);
|
||||
li.appendChild(badge);
|
||||
li.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
// Append tag: operator if not already typing one
|
||||
const current = input.value;
|
||||
const ctx = QueryParser.getContext(current, input.selectionStart);
|
||||
if (ctx.type === "tag") {
|
||||
// Replace the partial tag prefix
|
||||
const before = current.slice(0, input.selectionStart - ctx.prefix.length);
|
||||
input.value = before + item.tag + " ";
|
||||
} else {
|
||||
input.value = (current ? current + " " : "") + "tag:" + item.tag + " ";
|
||||
}
|
||||
this.hide();
|
||||
input.focus();
|
||||
_triggerAdvancedSearch(input.value);
|
||||
});
|
||||
this._tagsList.appendChild(li);
|
||||
});
|
||||
},
|
||||
|
||||
_highlightText(container, text, query) {
|
||||
const lower = text.toLowerCase();
|
||||
const needle = query.toLowerCase();
|
||||
const pos = lower.indexOf(needle);
|
||||
if (pos === -1) { container.textContent = text; return; }
|
||||
container.appendChild(document.createTextNode(text.slice(0, pos)));
|
||||
const markEl = el("mark", {}, [document.createTextNode(text.slice(pos, pos + query.length))]);
|
||||
container.appendChild(markEl);
|
||||
container.appendChild(document.createTextNode(text.slice(pos + query.length)));
|
||||
},
|
||||
|
||||
_collectItems() {
|
||||
dropdownItems = Array.from(this._dropdown.querySelectorAll(".search-dropdown__item"));
|
||||
dropdownActiveIndex = -1;
|
||||
dropdownItems.forEach(item => item.classList.remove("active"));
|
||||
},
|
||||
|
||||
navigateDown() {
|
||||
if (!this.isVisible() || dropdownItems.length === 0) return;
|
||||
if (dropdownActiveIndex >= 0) dropdownItems[dropdownActiveIndex].classList.remove("active");
|
||||
dropdownActiveIndex = (dropdownActiveIndex + 1) % dropdownItems.length;
|
||||
dropdownItems[dropdownActiveIndex].classList.add("active");
|
||||
dropdownItems[dropdownActiveIndex].scrollIntoView({ block: "nearest" });
|
||||
},
|
||||
|
||||
navigateUp() {
|
||||
if (!this.isVisible() || dropdownItems.length === 0) return;
|
||||
if (dropdownActiveIndex >= 0) dropdownItems[dropdownActiveIndex].classList.remove("active");
|
||||
dropdownActiveIndex = dropdownActiveIndex <= 0 ? dropdownItems.length - 1 : dropdownActiveIndex - 1;
|
||||
dropdownItems[dropdownActiveIndex].classList.add("active");
|
||||
dropdownItems[dropdownActiveIndex].scrollIntoView({ block: "nearest" });
|
||||
},
|
||||
|
||||
selectActive() {
|
||||
if (dropdownActiveIndex >= 0 && dropdownActiveIndex < dropdownItems.length) {
|
||||
dropdownItems[dropdownActiveIndex].click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search Chips Controller — renders active filter chips from parsed query
|
||||
// ---------------------------------------------------------------------------
|
||||
const SearchChips = {
|
||||
_container: null,
|
||||
init() { this._container = document.getElementById("search-chips"); },
|
||||
update(parsed) {
|
||||
if (!this._container) return;
|
||||
this._container.innerHTML = "";
|
||||
let hasChips = false;
|
||||
parsed.tags.forEach(tag => { this._addChip("tag", `tag:${tag}`, tag); hasChips = true; });
|
||||
if (parsed.vault) { this._addChip("vault", `vault:${parsed.vault}`, parsed.vault); hasChips = true; }
|
||||
if (parsed.title) { this._addChip("title", `title:${parsed.title}`, parsed.title); hasChips = true; }
|
||||
if (parsed.path) { this._addChip("path", `path:${parsed.path}`, parsed.path); hasChips = true; }
|
||||
this._container.hidden = !hasChips;
|
||||
},
|
||||
clear() {
|
||||
if (!this._container) return;
|
||||
this._container.innerHTML = "";
|
||||
this._container.hidden = true;
|
||||
},
|
||||
_addChip(type, fullOperator, displayText) {
|
||||
const chip = el("span", { class: `search-chip search-chip--${type}` });
|
||||
const label = el("span", { class: "search-chip__label" });
|
||||
label.textContent = fullOperator;
|
||||
const removeBtn = el("button", { class: "search-chip__remove", title: "Retirer ce filtre", type: "button" });
|
||||
removeBtn.innerHTML = '<i data-lucide="x" style="width:10px;height:10px"></i>';
|
||||
removeBtn.addEventListener("click", () => {
|
||||
// Remove this operator from the input
|
||||
const input = document.getElementById("search-input");
|
||||
const raw = input.value;
|
||||
// Remove the operator text from the query
|
||||
const escaped = fullOperator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
input.value = raw.replace(new RegExp("\\s*" + escaped + "\\s*", "i"), " ").trim();
|
||||
_triggerAdvancedSearch(input.value);
|
||||
});
|
||||
chip.appendChild(label);
|
||||
chip.appendChild(removeBtn);
|
||||
this._container.appendChild(chip);
|
||||
safeCreateIcons();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: trigger advanced search from input value
|
||||
// ---------------------------------------------------------------------------
|
||||
function _triggerAdvancedSearch(rawQuery) {
|
||||
const q = (rawQuery || "").trim();
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
advancedSearchOffset = 0;
|
||||
if (q.length > 0 || tagFilter) {
|
||||
SearchHistory.add(q);
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
} else {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe CDN helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1076,7 +1499,7 @@
|
||||
} else {
|
||||
const input = document.getElementById("search-input");
|
||||
if (input.value.trim()) {
|
||||
performSearch(input.value.trim(), document.getElementById("vault-filter").value, null);
|
||||
performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null);
|
||||
} else {
|
||||
showWelcome();
|
||||
}
|
||||
@@ -1087,7 +1510,7 @@
|
||||
const input = document.getElementById("search-input");
|
||||
const query = input.value.trim();
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
performSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null);
|
||||
performAdvancedSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null);
|
||||
}
|
||||
|
||||
function buildSearchResultsHeader(data, query, tagFilter) {
|
||||
@@ -1535,77 +1958,348 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// Search (enhanced with autocomplete, keyboard nav, global shortcuts)
|
||||
// ---------------------------------------------------------------------------
|
||||
function initSearch() {
|
||||
const input = document.getElementById("search-input");
|
||||
const caseBtn = document.getElementById("search-case-btn");
|
||||
const clearBtn = document.getElementById("search-clear-btn");
|
||||
|
||||
|
||||
// Initialize sub-controllers
|
||||
AutocompleteDropdown.init();
|
||||
SearchChips.init();
|
||||
|
||||
// Initially hide clear button
|
||||
clearBtn.style.display = "none";
|
||||
|
||||
|
||||
// --- Input handler: debounced search + autocomplete dropdown ---
|
||||
input.addEventListener("input", () => {
|
||||
const hasText = input.value.length > 0;
|
||||
clearBtn.style.display = hasText ? "flex" : "none";
|
||||
|
||||
|
||||
// Show autocomplete dropdown while typing
|
||||
AutocompleteDropdown.populate(input.value, input.selectionStart);
|
||||
|
||||
// Debounced search execution
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const q = input.value.trim();
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
advancedSearchOffset = 0;
|
||||
if (q.length > 0 || tagFilter) {
|
||||
performSearch(q, vault, tagFilter);
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
} else {
|
||||
SearchChips.clear();
|
||||
showWelcome();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
|
||||
// --- Focus handler: show history dropdown ---
|
||||
input.addEventListener("focus", () => {
|
||||
if (input.value.length === 0) {
|
||||
const historyItems = SearchHistory.filter("");
|
||||
if (historyItems.length > 0) {
|
||||
AutocompleteDropdown.populate("", 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Keyboard navigation in dropdown ---
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (AutocompleteDropdown.isVisible()) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
AutocompleteDropdown.navigateDown();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
AutocompleteDropdown.navigateUp();
|
||||
} else if (e.key === "Enter") {
|
||||
if (AutocompleteDropdown.selectActive()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// No active item — execute search normally
|
||||
AutocompleteDropdown.hide();
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(searchTimeout);
|
||||
advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Escape") {
|
||||
AutocompleteDropdown.hide();
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
const q = input.value.trim();
|
||||
if (q) {
|
||||
SearchHistory.add(q);
|
||||
clearTimeout(searchTimeout);
|
||||
advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null;
|
||||
performAdvancedSearch(q, vault, tagFilter);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
caseBtn.addEventListener("click", () => {
|
||||
searchCaseSensitive = !searchCaseSensitive;
|
||||
caseBtn.classList.toggle("active");
|
||||
});
|
||||
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
clearBtn.style.display = "none";
|
||||
searchCaseSensitive = false;
|
||||
caseBtn.classList.remove("active");
|
||||
SearchChips.clear();
|
||||
AutocompleteDropdown.hide();
|
||||
showWelcome();
|
||||
});
|
||||
|
||||
// --- Global keyboard shortcuts ---
|
||||
document.addEventListener("keydown", (e) => {
|
||||
// Ctrl+K or Cmd+K: focus search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
// "/" key: focus search (when not in an input/textarea)
|
||||
if (e.key === "/" && !_isInputFocused()) {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
// Escape: blur search input and close dropdown
|
||||
if (e.key === "Escape" && document.activeElement === input) {
|
||||
AutocompleteDropdown.hide();
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if user is focused on an input/textarea/contenteditable */
|
||||
function _isInputFocused() {
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
return document.activeElement?.isContentEditable === true;
|
||||
}
|
||||
|
||||
// --- Backward-compatible search (existing /api/search endpoint) ---
|
||||
async function performSearch(query, vaultFilter, tagFilter) {
|
||||
// Cancel any in-flight search request
|
||||
if (searchAbortController) {
|
||||
searchAbortController.abort();
|
||||
}
|
||||
if (searchAbortController) searchAbortController.abort();
|
||||
searchAbortController = new AbortController();
|
||||
|
||||
showLoading();
|
||||
|
||||
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
|
||||
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
||||
|
||||
try {
|
||||
const data = await api(url, { signal: searchAbortController.signal });
|
||||
renderSearchResults(data, query, tagFilter);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") return; // superseded by newer request
|
||||
if (err.name === "AbortError") return;
|
||||
showWelcome();
|
||||
} finally {
|
||||
searchAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Advanced search with TF-IDF, facets, pagination ---
|
||||
async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) {
|
||||
if (searchAbortController) searchAbortController.abort();
|
||||
searchAbortController = new AbortController();
|
||||
showLoading();
|
||||
|
||||
const ofs = offset !== undefined ? offset : advancedSearchOffset;
|
||||
const sortBy = sort || advancedSearchSort;
|
||||
advancedSearchLastQuery = query;
|
||||
|
||||
// Update chips from parsed query
|
||||
const parsed = QueryParser.parse(query);
|
||||
SearchChips.update(parsed);
|
||||
|
||||
let url = `/api/search/advanced?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}&limit=${ADVANCED_SEARCH_LIMIT}&offset=${ofs}&sort=${sortBy}`;
|
||||
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
||||
|
||||
try {
|
||||
const data = await api(url, { signal: searchAbortController.signal });
|
||||
advancedSearchTotal = data.total;
|
||||
advancedSearchOffset = ofs;
|
||||
renderAdvancedSearchResults(data, query, tagFilter);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") return;
|
||||
showWelcome();
|
||||
} finally {
|
||||
searchAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Legacy search results renderer (kept for backward compat) ---
|
||||
function renderSearchResults(data, query, tagFilter) {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
|
||||
const header = buildSearchResultsHeader(data, query, tagFilter);
|
||||
area.appendChild(header);
|
||||
if (data.results.length === 0) {
|
||||
area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [
|
||||
document.createTextNode("Aucun résultat trouvé."),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
const container = el("div", { class: "search-results" });
|
||||
data.results.forEach((r) => {
|
||||
const titleDiv = el("div", { class: "search-result-title" });
|
||||
if (query && query.trim()) {
|
||||
highlightSearchText(titleDiv, r.title, query, searchCaseSensitive);
|
||||
} else {
|
||||
titleDiv.textContent = r.title;
|
||||
}
|
||||
const snippetDiv = el("div", { class: "search-result-snippet" });
|
||||
if (query && query.trim() && r.snippet) {
|
||||
highlightSearchText(snippetDiv, r.snippet, query, searchCaseSensitive);
|
||||
} else {
|
||||
snippetDiv.textContent = r.snippet || "";
|
||||
}
|
||||
const item = el("div", { class: "search-result-item" }, [
|
||||
titleDiv,
|
||||
el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]),
|
||||
snippetDiv,
|
||||
]);
|
||||
if (r.tags && r.tags.length > 0) {
|
||||
const tagsDiv = el("div", { class: "search-result-tags" });
|
||||
r.tags.forEach((tag) => {
|
||||
if (!TagFilterService.isTagFiltered(tag)) {
|
||||
const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
|
||||
tagEl.addEventListener("click", (e) => { e.stopPropagation(); addTagFilter(tag); });
|
||||
tagsDiv.appendChild(tagEl);
|
||||
}
|
||||
});
|
||||
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
||||
}
|
||||
item.addEventListener("click", () => openFile(r.vault, r.path));
|
||||
container.appendChild(item);
|
||||
});
|
||||
area.appendChild(container);
|
||||
}
|
||||
|
||||
// --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) ---
|
||||
function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
|
||||
// Header with result count and sort controls
|
||||
const header = el("div", { class: "search-results-header" });
|
||||
const summaryText = el("span", { class: "search-results-summary-text" });
|
||||
const parsed = QueryParser.parse(query);
|
||||
const freeText = parsed.freeText;
|
||||
|
||||
if (freeText && tagFilter) {
|
||||
summaryText.textContent = `${data.total} résultat(s) pour "${freeText}" avec filtres`;
|
||||
} else if (freeText) {
|
||||
summaryText.textContent = `${data.total} résultat(s) pour "${freeText}"`;
|
||||
} else if (parsed.tags.length > 0 || tagFilter) {
|
||||
summaryText.textContent = `${data.total} fichier(s) avec filtres`;
|
||||
} else {
|
||||
summaryText.textContent = `${data.total} résultat(s)`;
|
||||
}
|
||||
header.appendChild(summaryText);
|
||||
|
||||
// Sort controls
|
||||
const sortDiv = el("div", { class: "search-sort" });
|
||||
const btnRelevance = el("button", { class: "search-sort__btn" + (advancedSearchSort === "relevance" ? " active" : ""), type: "button" });
|
||||
btnRelevance.textContent = "Pertinence";
|
||||
btnRelevance.addEventListener("click", () => {
|
||||
advancedSearchSort = "relevance";
|
||||
advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
performAdvancedSearch(query, vault, tagFilter, 0, "relevance");
|
||||
});
|
||||
const btnDate = el("button", { class: "search-sort__btn" + (advancedSearchSort === "modified" ? " active" : ""), type: "button" });
|
||||
btnDate.textContent = "Date";
|
||||
btnDate.addEventListener("click", () => {
|
||||
advancedSearchSort = "modified";
|
||||
advancedSearchOffset = 0;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
performAdvancedSearch(query, vault, tagFilter, 0, "modified");
|
||||
});
|
||||
sortDiv.appendChild(btnRelevance);
|
||||
sortDiv.appendChild(btnDate);
|
||||
header.appendChild(sortDiv);
|
||||
area.appendChild(header);
|
||||
|
||||
// Active sidebar tag chips
|
||||
if (selectedTags.length > 0) {
|
||||
const activeTags = el("div", { class: "search-results-active-tags" });
|
||||
selectedTags.forEach((tag) => {
|
||||
const removeBtn = el("button", {
|
||||
class: "search-results-active-tag-remove",
|
||||
title: `Retirer ${tag} du filtre`,
|
||||
}, [document.createTextNode("×")]);
|
||||
removeBtn.addEventListener("click", (e) => { e.stopPropagation(); removeTagFilter(tag); });
|
||||
const chip = el("span", { class: "search-results-active-tag" }, [
|
||||
document.createTextNode(`#${tag}`), removeBtn,
|
||||
]);
|
||||
activeTags.appendChild(chip);
|
||||
});
|
||||
area.appendChild(activeTags);
|
||||
}
|
||||
|
||||
// Facets panel
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) {
|
||||
const facetsDiv = el("div", { class: "search-facets" });
|
||||
|
||||
// Vault facets
|
||||
const vaultFacets = data.facets.vaults || {};
|
||||
if (Object.keys(vaultFacets).length > 1) {
|
||||
const group = el("div", { class: "search-facets__group" });
|
||||
const label = el("span", { class: "search-facets__label" });
|
||||
label.textContent = "Vaults";
|
||||
group.appendChild(label);
|
||||
for (const [vaultName, count] of Object.entries(vaultFacets)) {
|
||||
const item = el("span", { class: "search-facets__item" });
|
||||
item.innerHTML = `${vaultName} <span class="facet-count">${count}</span>`;
|
||||
item.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
// Add vault: operator
|
||||
const current = input.value.replace(/vault:\S+\s*/gi, "").trim();
|
||||
input.value = current + " vault:" + vaultName;
|
||||
_triggerAdvancedSearch(input.value);
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
}
|
||||
|
||||
// Tag facets
|
||||
const tagFacets = data.facets.tags || {};
|
||||
if (Object.keys(tagFacets).length > 0) {
|
||||
const group = el("div", { class: "search-facets__group" });
|
||||
const label = el("span", { class: "search-facets__label" });
|
||||
label.textContent = "Tags";
|
||||
group.appendChild(label);
|
||||
const entries = Object.entries(tagFacets).slice(0, 12);
|
||||
for (const [tagName, count] of entries) {
|
||||
const item = el("span", { class: "search-facets__item" });
|
||||
item.innerHTML = `#${tagName} <span class="facet-count">${count}</span>`;
|
||||
item.addEventListener("click", () => {
|
||||
addTagFilter(tagName);
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
}
|
||||
|
||||
area.appendChild(facetsDiv);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (data.results.length === 0) {
|
||||
area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [
|
||||
document.createTextNode("Aucun résultat trouvé."),
|
||||
@@ -1613,52 +2307,89 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Results list
|
||||
const container = el("div", { class: "search-results" });
|
||||
data.results.forEach((r) => {
|
||||
// Create title with highlighting
|
||||
const titleDiv = el("div", { class: "search-result-title" });
|
||||
if (query && query.trim()) {
|
||||
highlightSearchText(titleDiv, r.title, query, searchCaseSensitive);
|
||||
if (freeText) {
|
||||
highlightSearchText(titleDiv, r.title, freeText, searchCaseSensitive);
|
||||
} else {
|
||||
titleDiv.textContent = r.title;
|
||||
}
|
||||
|
||||
// Create snippet with highlighting
|
||||
const snippetDiv = el("div", { class: "search-result-snippet" });
|
||||
if (query && query.trim() && r.snippet) {
|
||||
highlightSearchText(snippetDiv, r.snippet, query, searchCaseSensitive);
|
||||
|
||||
// Snippet — use HTML from backend (already has <mark> tags)
|
||||
const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" });
|
||||
if (r.snippet && r.snippet.includes("<mark>")) {
|
||||
snippetDiv.innerHTML = r.snippet;
|
||||
} else if (freeText && r.snippet) {
|
||||
highlightSearchText(snippetDiv, r.snippet, freeText, searchCaseSensitive);
|
||||
} else {
|
||||
snippetDiv.textContent = r.snippet || "";
|
||||
}
|
||||
|
||||
const item = el("div", { class: "search-result-item" }, [
|
||||
titleDiv,
|
||||
el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]),
|
||||
snippetDiv,
|
||||
|
||||
// Score badge
|
||||
const scoreEl = el("span", { class: "search-result-score", style: "font-size:0.7rem;color:var(--text-muted);margin-left:8px" });
|
||||
scoreEl.textContent = `score: ${r.score}`;
|
||||
|
||||
const vaultPath = el("div", { class: "search-result-vault" }, [
|
||||
document.createTextNode(r.vault + " / " + r.path),
|
||||
scoreEl,
|
||||
]);
|
||||
|
||||
const item = el("div", { class: "search-result-item" }, [titleDiv, vaultPath, snippetDiv]);
|
||||
|
||||
if (r.tags && r.tags.length > 0) {
|
||||
const tagsDiv = el("div", { class: "search-result-tags" });
|
||||
r.tags.forEach((tag) => {
|
||||
if (!TagFilterService.isTagFiltered(tag)) {
|
||||
const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
|
||||
tagEl.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
addTagFilter(tag);
|
||||
});
|
||||
tagEl.addEventListener("click", (e) => { e.stopPropagation(); addTagFilter(tag); });
|
||||
tagsDiv.appendChild(tagEl);
|
||||
}
|
||||
});
|
||||
if (tagsDiv.children.length > 0) {
|
||||
item.appendChild(tagsDiv);
|
||||
}
|
||||
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
||||
}
|
||||
|
||||
item.addEventListener("click", () => openFile(r.vault, r.path));
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
area.appendChild(container);
|
||||
|
||||
// Pagination
|
||||
if (data.total > ADVANCED_SEARCH_LIMIT) {
|
||||
const paginationDiv = el("div", { class: "search-pagination" });
|
||||
const prevBtn = el("button", { class: "search-pagination__btn", type: "button" });
|
||||
prevBtn.textContent = "← Précédent";
|
||||
prevBtn.disabled = advancedSearchOffset === 0;
|
||||
prevBtn.addEventListener("click", () => {
|
||||
advancedSearchOffset = Math.max(0, advancedSearchOffset - ADVANCED_SEARCH_LIMIT);
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
performAdvancedSearch(query, vault, tagFilter, advancedSearchOffset);
|
||||
document.getElementById("content-area").scrollTop = 0;
|
||||
});
|
||||
|
||||
const info = el("span", { class: "search-pagination__info" });
|
||||
const from = advancedSearchOffset + 1;
|
||||
const to = Math.min(advancedSearchOffset + ADVANCED_SEARCH_LIMIT, data.total);
|
||||
info.textContent = `${from}–${to} sur ${data.total}`;
|
||||
|
||||
const nextBtn = el("button", { class: "search-pagination__btn", type: "button" });
|
||||
nextBtn.textContent = "Suivant →";
|
||||
nextBtn.disabled = advancedSearchOffset + ADVANCED_SEARCH_LIMIT >= data.total;
|
||||
nextBtn.addEventListener("click", () => {
|
||||
advancedSearchOffset += ADVANCED_SEARCH_LIMIT;
|
||||
const vault = document.getElementById("vault-filter").value;
|
||||
performAdvancedSearch(query, vault, tagFilter, advancedSearchOffset);
|
||||
document.getElementById("content-area").scrollTop = 0;
|
||||
});
|
||||
|
||||
paginationDiv.appendChild(prevBtn);
|
||||
paginationDiv.appendChild(info);
|
||||
paginationDiv.appendChild(nextBtn);
|
||||
area.appendChild(paginationDiv);
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -103,6 +103,31 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Advanced search autocomplete dropdown -->
|
||||
<div class="search-dropdown" id="search-dropdown" role="listbox" aria-label="Suggestions de recherche" hidden>
|
||||
<div class="search-dropdown__section search-dropdown__section--history" id="search-dropdown-history">
|
||||
<div class="search-dropdown__section-header">
|
||||
<span>Historique</span>
|
||||
<button class="search-dropdown__clear-btn" id="search-dropdown-clear-history" type="button" title="Effacer l'historique">
|
||||
<i data-lucide="trash-2" style="width:12px;height:12px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="search-dropdown__list" id="search-dropdown-history-list"></ul>
|
||||
</div>
|
||||
<div class="search-dropdown__section search-dropdown__section--titles" id="search-dropdown-titles">
|
||||
<div class="search-dropdown__section-header"><span>Fichiers</span></div>
|
||||
<ul class="search-dropdown__list" id="search-dropdown-titles-list"></ul>
|
||||
</div>
|
||||
<div class="search-dropdown__section search-dropdown__section--tags" id="search-dropdown-tags">
|
||||
<div class="search-dropdown__section-header"><span>Tags</span></div>
|
||||
<ul class="search-dropdown__list" id="search-dropdown-tags-list"></ul>
|
||||
</div>
|
||||
<div class="search-dropdown__empty" id="search-dropdown-empty" hidden>
|
||||
Aucune suggestion
|
||||
</div>
|
||||
</div>
|
||||
<!-- Active search filter chips -->
|
||||
<div class="search-chips" id="search-chips" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2162,3 +2162,331 @@ body.resizing-v {
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Autocomplete Dropdown
|
||||
--------------------------------------------------------------------------- */
|
||||
.search-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||
z-index: 200;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.search-dropdown[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.search-dropdown__section {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search-dropdown__section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.search-dropdown__section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.search-dropdown__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.search-dropdown__clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-dropdown__clear-btn:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-bg, rgba(255,0,0,0.08));
|
||||
}
|
||||
.search-dropdown__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.search-dropdown__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.search-dropdown__item:hover,
|
||||
.search-dropdown__item.active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.search-dropdown__item--history {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.search-dropdown__item--history .search-dropdown__icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-dropdown__item--title .search-dropdown__meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
.search-dropdown__item--tag .search-dropdown__badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-primary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.search-dropdown__icon {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-dropdown__text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-dropdown__text mark {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
.search-dropdown__empty {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-dropdown__empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Filter Chips
|
||||
--------------------------------------------------------------------------- */
|
||||
.search-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 6px 0 0;
|
||||
}
|
||||
.search-chips[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.search-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
cursor: default;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-chip__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.search-chip__remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity 120ms;
|
||||
}
|
||||
.search-chip__remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.search-chip--tag {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
.search-chip--vault {
|
||||
background: var(--success, #22c55e);
|
||||
color: #fff;
|
||||
}
|
||||
.search-chip--title {
|
||||
background: var(--warning, #f59e0b);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.search-chip--path {
|
||||
background: var(--text-muted);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Snippet Highlights
|
||||
--------------------------------------------------------------------------- */
|
||||
.search-result__snippet mark {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Facets Panel
|
||||
--------------------------------------------------------------------------- */
|
||||
.search-facets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.search-facets[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.search-facets__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.search-facets__label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.search-facets__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, background 120ms;
|
||||
}
|
||||
.search-facets__item:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.search-facets__item .facet-count {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Pagination
|
||||
--------------------------------------------------------------------------- */
|
||||
.search-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.search-pagination__btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, background 120ms;
|
||||
}
|
||||
.search-pagination__btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.search-pagination__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.search-pagination__info {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Sort Controls
|
||||
--------------------------------------------------------------------------- */
|
||||
.search-sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.search-sort__btn {
|
||||
padding: 3px 10px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms;
|
||||
}
|
||||
.search-sort__btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Responsive
|
||||
--------------------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
.search-dropdown {
|
||||
max-height: 280px;
|
||||
}
|
||||
.search-facets {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.search-chip {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user