feat(web): update frontend TypeScript modules and API types
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.
This commit is contained in:
+174
-158
@@ -3,177 +3,188 @@ 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;
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
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();
|
||||
const target = e.target as HTMLInputElement;
|
||||
const query = target.value.trim();
|
||||
|
||||
if (searchInputTimeout) {
|
||||
clearTimeout(searchInputTimeout);
|
||||
}
|
||||
if (searchInputTimeout) {
|
||||
clearTimeout(searchInputTimeout);
|
||||
}
|
||||
|
||||
if (query.length < SEARCH_MIN_CHARS) {
|
||||
hideSearchResults();
|
||||
return;
|
||||
}
|
||||
if (query.length < SEARCH_MIN_CHARS) {
|
||||
hideSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
searchInputTimeout = setTimeout(() => {
|
||||
performSearch(query);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
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 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');
|
||||
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();
|
||||
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();
|
||||
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;
|
||||
}
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
console.warn("No authentication token found");
|
||||
return;
|
||||
}
|
||||
|
||||
showSearchLoading();
|
||||
showSearchLoading();
|
||||
|
||||
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
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(response => {
|
||||
if (response.status === 404) {
|
||||
return { error: 'no results found', results: [] };
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
|
||||
.then(
|
||||
(
|
||||
data:
|
||||
| { error?: string; results?: MediaItemSummary[] }
|
||||
| MediaItemSummary[],
|
||||
) => {
|
||||
hideSearchLoading();
|
||||
|
||||
if (data && 'error' in data && data.error === 'no results found') {
|
||||
showNoResults(query);
|
||||
if (data && "error" in data && data.error === "no results found") {
|
||||
showNoResults(query);
|
||||
} else if (Array.isArray(data) && data.length > 0) {
|
||||
showSearchResults(data, query);
|
||||
showSearchResults(data, query);
|
||||
} else if (Array.isArray(data)) {
|
||||
showNoResults(query);
|
||||
showNoResults(query);
|
||||
} else {
|
||||
showNoResults(query);
|
||||
showNoResults(query);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
hideSearchLoading();
|
||||
console.error('Search error:', error);
|
||||
showSearchError();
|
||||
},
|
||||
)
|
||||
.catch((error) => {
|
||||
hideSearchLoading();
|
||||
console.error("Search error:", error);
|
||||
showSearchError();
|
||||
});
|
||||
}
|
||||
|
||||
function showSearchLoading(): void {
|
||||
createSearchResultsContainer();
|
||||
const searchResults = document.getElementById('search-results');
|
||||
if (!searchResults) return;
|
||||
createSearchResultsContainer();
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.innerHTML = `
|
||||
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');
|
||||
searchResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideSearchLoading(): void {
|
||||
}
|
||||
function hideSearchLoading(): void {}
|
||||
|
||||
function showSearchResults(results: MediaItemSummary[], query: string): void {
|
||||
createSearchResultsContainer();
|
||||
const searchResults = document.getElementById('search-results');
|
||||
if (!searchResults) return;
|
||||
createSearchResultsContainer();
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.dataset.selectedIndex = '-1';
|
||||
searchResults.dataset.selectedIndex = "-1";
|
||||
|
||||
const libraryIconMap: Record<string, string> = {
|
||||
'ebooks': '📚',
|
||||
'comics': '📖',
|
||||
'manga': '🗾'
|
||||
};
|
||||
const libraryIconMap: Record<string, string> = {
|
||||
ebooks: "📚",
|
||||
comics: "📖",
|
||||
manga: "🗾",
|
||||
};
|
||||
|
||||
let html = `
|
||||
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)}"
|
||||
${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) : '';
|
||||
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 += `
|
||||
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}">
|
||||
@@ -186,7 +197,7 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
|
||||
<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>` : ''}
|
||||
${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>
|
||||
@@ -195,9 +206,9 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
html += `
|
||||
html += `
|
||||
</div>
|
||||
<div class="p-2 border-t text-center" style="border-color: var(--border)">
|
||||
<p class="text-xs" style="color: var(--text-secondary)">
|
||||
@@ -207,84 +218,89 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
|
||||
</div>
|
||||
`;
|
||||
|
||||
searchResults.innerHTML = html;
|
||||
searchResults.classList.remove('hidden');
|
||||
searchResults.innerHTML = html;
|
||||
searchResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showNoResults(query: string): void {
|
||||
createSearchResultsContainer();
|
||||
const searchResults = document.getElementById('search-results');
|
||||
if (!searchResults) return;
|
||||
createSearchResultsContainer();
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.innerHTML = `
|
||||
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');
|
||||
searchResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showSearchError(): void {
|
||||
createSearchResultsContainer();
|
||||
const searchResults = document.getElementById('search-results');
|
||||
if (!searchResults) return;
|
||||
createSearchResultsContainer();
|
||||
const searchResults = document.getElementById("search-results");
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.innerHTML = `
|
||||
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');
|
||||
searchResults.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideSearchResults(): void {
|
||||
const searchResults = document.getElementById('search-results');
|
||||
if (searchResults) {
|
||||
searchResults.classList.add('hidden');
|
||||
}
|
||||
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)';
|
||||
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);
|
||||
}
|
||||
}
|
||||
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>');
|
||||
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;
|
||||
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();
|
||||
localStorage.setItem("selectedLibrary", libraryId);
|
||||
localStorage.setItem("selectedBook", bookId);
|
||||
hideSearchResults();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializeSearch);
|
||||
document.addEventListener("DOMContentLoaded", initializeSearch);
|
||||
|
||||
(window as any).selectLibraryAndBook = selectLibraryAndBook;
|
||||
|
||||
Reference in New Issue
Block a user