This commit updates the web frontend TypeScript modules: Core modules: - admin.ts: Admin panel functionality and user management - analytics.ts: Analytics dashboard and data visualization - api-explorer.ts: Interactive API documentation explorer - api.ts: Core API client with request/response handling - collections.ts: Book collection management UI - conflicts.ts: Sync conflict resolution interface - custom-section-builder.ts: Dynamic section builder for UI - docs.ts: Documentation viewer and navigation - dom.ts: DOM manipulation utilities and helpers - header.ts: Application header with navigation - library.ts: Library view and book grid management - linking.ts: Device-book linking interface - password_validation.ts: Client-side password strength validation - queue.ts: Device sync queue management UI - search.ts: Full-text search with Lunr integration - storage.ts: Local storage and cache management - theme.ts: Theme management and CSS variable updates - themeDropdown.ts: Theme selector dropdown component - toast.ts: Toast notification system - woodPaneling.ts: Visual theme effects - woodPanelingInit.ts: Visual effects initialization Type definitions: - api.d.ts: Updated TypeScript definitions for API responses These updates enhance the frontend with improved functionality for book management, device synchronization, and user experience.
307 lines
9.9 KiB
TypeScript
307 lines
9.9 KiB
TypeScript
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();
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", initializeSearch);
|
|
|
|
(window as any).selectLibraryAndBook = selectLibraryAndBook;
|