Files
bookhoard/web/src/search.ts
T
john-okeefe 2075077bb7 refactor: remove unnecessary DOMContentLoaded wrappers
Since main.js has 'defer' attribute, the DOM is guaranteed to be
ready when modules execute. These wrappers are unnecessary.

dashboard.ts:
- Removed DOMContentLoaded wrapper, code runs directly
- Event delegation setup runs immediately

custom-section-builder.ts:
- Removed DOMContentLoaded wrapper
- initCustomSectionBuilder() called directly

toast.ts:
- Removed DOMContentLoaded wrapper
- initializeToastSystem() called directly at top level
- Removed dead Alpine.data registration (unused)

search.ts:
- Removed DOMContentLoaded wrapper
- initializeSearch exported for use in header

theme.ts:
- Removed DOMContentLoaded wrapper
- Functions now exported for use in header Alpine component
2026-03-13 12:51:16 -04:00

312 lines
9.9 KiB
TypeScript

import { Alpine } from "./alpine";
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 2;
function initializeSearch(): void {
const searchInput = document.getElementById(
"header-search",
) as HTMLInputElement | null;
if (!searchInput) {
console.warn("Search input not found");
return;
}
searchInput.addEventListener("input", handleSearchInput);
searchInput.addEventListener("keydown", handleSearchKeydown);
searchInput.addEventListener("focus", () => {
if (searchInput.value.length >= SEARCH_MIN_CHARS) {
performSearch(searchInput.value);
}
});
document.addEventListener("click", (e: MouseEvent) => {
const searchResults = document.getElementById("search-results");
const searchInputEl = document.getElementById("header-search");
if (
searchResults &&
!searchResults.contains(e.target as Node) &&
e.target !== searchInputEl
) {
hideSearchResults();
}
});
}
function handleSearchInput(e: Event): void {
const target = e.target as HTMLInputElement;
const query = target.value.trim();
if (searchInputTimeout) {
clearTimeout(searchInputTimeout);
}
if (query.length < SEARCH_MIN_CHARS) {
hideSearchResults();
return;
}
searchInputTimeout = setTimeout(() => {
performSearch(query);
}, SEARCH_DEBOUNCE_MS);
}
function handleSearchKeydown(e: KeyboardEvent): void {
const searchResults = document.getElementById("search-results");
if (!searchResults || searchResults.classList.contains("hidden")) {
return;
}
const items = searchResults.querySelectorAll(".search-result-item");
const currentIndex = parseInt(searchResults.dataset.selectedIndex || "-1");
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
selectSearchResult(items, nextIndex);
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prevIndex = Math.max(currentIndex - 1, -1);
selectSearchResult(items, prevIndex);
} else if (e.key === "Enter") {
e.preventDefault();
if (currentIndex >= 0 && items[currentIndex]) {
const link = items[currentIndex].querySelector("a");
if (link) link.click();
}
} else if (e.key === "Escape") {
hideSearchResults();
}
}
function selectSearchResult(items: NodeListOf<Element>, index: number): void {
items.forEach((item, i) => {
if (i === index) {
item.classList.add("bg-opacity-80");
} else {
item.classList.remove("bg-opacity-80");
}
});
const searchResults = document.getElementById("search-results");
if (searchResults) {
searchResults.dataset.selectedIndex = index.toString();
}
}
function performSearch(query: string): void {
const token = localStorage.getItem("token");
if (!token) {
console.warn("No authentication token found");
return;
}
showSearchLoading();
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 404) {
return { error: "no results found", results: [] };
}
return response.json();
})
.then(
(
data:
| { error?: string; results?: MediaItemSummary[] }
| MediaItemSummary[],
) => {
hideSearchLoading();
if (data && "error" in data && data.error === "no results found") {
showNoResults(query);
} else if (Array.isArray(data) && data.length > 0) {
showSearchResults(data, query);
} else if (Array.isArray(data)) {
showNoResults(query);
} else {
showNoResults(query);
}
},
)
.catch((error) => {
hideSearchLoading();
console.error("Search error:", error);
showSearchError();
});
}
function showSearchLoading(): void {
createSearchResultsContainer();
const searchResults = document.getElementById("search-results");
if (!searchResults) return;
searchResults.innerHTML = `
<div class="p-4 text-center" style="color: var(--text-secondary)">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--accent)"></div>
<p class="mt-2 text-sm">Searching...</p>
</div>
`;
searchResults.classList.remove("hidden");
}
function hideSearchLoading(): void {}
function showSearchResults(results: MediaItemSummary[], query: string): void {
createSearchResultsContainer();
const searchResults = document.getElementById("search-results");
if (!searchResults) return;
searchResults.dataset.selectedIndex = "-1";
const libraryIconMap: Record<string, string> = {
ebooks: "📚",
comics: "📖",
manga: "🗾",
};
let html = `
<div class="p-3 border-b" style="border-color: var(--border)">
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
${results.length} result${results.length !== 1 ? "s" : ""} for "${searchEscapeHtml(query)}"
</p>
</div>
<div class="max-h-96 overflow-y-auto">
`;
results.forEach((item, index) => {
const icon = libraryIconMap[item.library_type_name] || "📁";
const titleHtml = highlightMatch(item.title, query);
const authorHtml = item.author ? highlightMatch(item.author, query) : "";
html += `
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
style="border-color: var(--border); background-color: var(--bg-secondary)"
data-index="${index}">
<a href="/bookshelf"
class="block"
onclick="window.selectLibraryAndBook('${item.library_id}', '${item.id}')">
<div class="flex items-start space-x-3">
<div class="text-2xl">${icon}</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
${titleHtml}
</h4>
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ""}
<p class="text-xs mt-1" style="color: var(--text-secondary)">
${searchEscapeHtml(item.library_name)}
</p>
</div>
</div>
</a>
</div>
`;
});
html += `
</div>
<div class="p-2 border-t text-center" style="border-color: var(--border)">
<p class="text-xs" style="color: var(--text-secondary)">
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">↑↓</kbd> to navigate,
<kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">Enter</kbd> to select
</p>
</div>
`;
searchResults.innerHTML = html;
searchResults.classList.remove("hidden");
}
function showNoResults(query: string): void {
createSearchResultsContainer();
const searchResults = document.getElementById("search-results");
if (!searchResults) return;
searchResults.innerHTML = `
<div class="p-4 text-center">
<div class="text-4xl mb-2">🔍</div>
<p class="text-sm" style="color: var(--text-primary)">No results found for "${searchEscapeHtml(query)}"</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
</div>
`;
searchResults.classList.remove("hidden");
}
function showSearchError(): void {
createSearchResultsContainer();
const searchResults = document.getElementById("search-results");
if (!searchResults) return;
searchResults.innerHTML = `
<div class="p-4 text-center">
<div class="text-4xl mb-2">⚠️</div>
<p class="text-sm" style="color: var(--text-primary)">Search error</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p>
</div>
`;
searchResults.classList.remove("hidden");
}
function hideSearchResults(): void {
const searchResults = document.getElementById("search-results");
if (searchResults) {
searchResults.classList.add("hidden");
}
}
function createSearchResultsContainer(): void {
let searchResults = document.getElementById("search-results");
if (!searchResults) {
searchResults = document.createElement("div");
searchResults.id = "search-results";
searchResults.className =
"hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border";
searchResults.style.cssText =
"background-color: var(--bg-secondary); border-color: var(--border)";
const searchInput = document.getElementById("header-search");
if (searchInput) {
const searchContainer = searchInput.closest(".relative");
if (searchContainer) {
searchContainer.appendChild(searchResults);
}
}
}
}
function highlightMatch(text: string, query: string): string {
if (!text) return "";
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
return searchEscapeHtml(text).replace(
regex,
'<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>',
);
}
function searchEscapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function selectLibraryAndBook(libraryId: string, bookId: string): void {
localStorage.setItem("selectedLibrary", libraryId);
localStorage.setItem("selectedBook", bookId);
hideSearchResults();
}
export { selectLibraryAndBook, initializeSearch };
Alpine.data("search", () => ({
selectLibraryAndBook,
initializeSearch,
}));