feat(typescript): add feature modules for template conversion

- Add search.ts - header search with keyboard navigation
  - Debounced search with 300ms delay
  - Arrow key navigation through results
  - Escape to close, Enter to select
  - Library type icons and highlighting

- Add collections.ts - collection and rule management
  - Rule CRUD operations (create, update, delete)
  - Rule testing functionality
  - Bulk collection operations

- Add bookshelf.ts - book display and navigation
  - Library selection state management
  - Book viewing interactions
  - Pagination logic

- Add linking.ts - book matching and manual linking
  - Search and match functionality
  - Manual link modal
  - Bulk auto-link and suggestions

- Add api-explorer.ts - API testing interface
  - Request/response display
  - cURL command generation
  - History tracking

- Add admin.ts - admin dashboard actions
  - Library scan triggers
  - System statistics display
  - Profile management

- Add analytics.ts - analytics data loading
  - Chart.js integration
  - Daily reading minutes chart
  - Device usage and popular books display

- Add queue.ts - sync queue management
  - Process pending items
  - Clear failed/all items
  - Filter by status, type, device

- Add conflicts.ts - conflict resolution
  - Individual and bulk resolve operations
  - Winner device selection
  - Manual override inputs

- Add docs.ts - documentation search
  - Lunr.js search integration
  - Sidebar toggle for mobile
This commit is contained in:
2026-02-18 16:40:51 -05:00
parent 60c5a093b5
commit dfd9cbcde7
10 changed files with 1648 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
async function triggerLibraryScan(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/libraries/scan', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Library scan started');
}
} else {
const error = await response.json();
if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to start scan');
}
}
} catch (error) {
console.error('Scan error:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to start library scan');
}
}
}
async function triggerQuickScan(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/libraries/quick-scan', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Quick scan started');
}
} else {
const error = await response.json();
if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to start quick scan');
}
}
} catch (error) {
console.error('Quick scan error:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to start quick scan');
}
}
}
async function loadSystemStats(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/admin/stats', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const stats = await response.json();
renderSystemStats(stats);
}
} catch (error) {
console.error('Failed to load stats:', error);
}
}
function renderSystemStats(stats: Record<string, unknown>): void {
const container = document.getElementById('system-stats');
if (!container) return;
container.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books || 0}</p>
<p class="text-sm" style="color: var(--text-secondary)">Total Books</p>
</div>
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_users || 0}</p>
<p class="text-sm" style="color: var(--text-secondary)">Users</p>
</div>
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_devices || 0}</p>
<p class="text-sm" style="color: var(--text-secondary)">Devices</p>
</div>
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_libraries || 0}</p>
<p class="text-sm" style="color: var(--text-secondary)">Libraries</p>
</div>
</div>
`;
}
(window as any).triggerLibraryScan = triggerLibraryScan;
(window as any).triggerQuickScan = triggerQuickScan;
(window as any).loadSystemStats = loadSystemStats;
+118
View File
@@ -0,0 +1,118 @@
import type { ReadingStatsResponse, DeviceUsageResponse, PopularBooksResponse } from './types/api';
async function loadAnalytics(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const [statsRes, devicesRes, popularRes] = await Promise.all([
fetch('/api/analytics/stats', {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch('/api/analytics/devices', {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch('/api/analytics/popular', {
headers: { 'Authorization': `Bearer ${token}` }
})
]);
if (statsRes.ok) {
const stats: ReadingStatsResponse = await statsRes.json();
renderReadingStats(stats);
}
if (devicesRes.ok) {
const devices: DeviceUsageResponse = await devicesRes.json();
renderDeviceUsage(devices);
}
if (popularRes.ok) {
const popular: PopularBooksResponse = await popularRes.json();
renderPopularBooks(popular);
}
} catch (error) {
console.error('Failed to load analytics:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to load analytics data');
}
}
}
function renderReadingStats(stats: ReadingStatsResponse): void {
const container = document.getElementById('reading-stats');
if (!container) return;
container.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books_read}</p>
<p class="text-sm" style="color: var(--text-secondary)">Books Read</p>
</div>
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_pages_read}</p>
<p class="text-sm" style="color: var(--text-secondary)">Pages Read</p>
</div>
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_reading_time_minutes}</p>
<p class="text-sm" style="color: var(--text-secondary)">Minutes Reading</p>
</div>
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${Math.round(stats.completion_rate * 100)}%</p>
<p class="text-sm" style="color: var(--text-secondary)">Completion Rate</p>
</div>
</div>
`;
}
function renderDeviceUsage(devices: DeviceUsageResponse): void {
const container = document.getElementById('device-usage');
if (!container) return;
if (!devices.devices || devices.devices.length === 0) {
container.innerHTML = '<p style="color: var(--text-secondary)">No device usage data available</p>';
return;
}
container.innerHTML = devices.devices.map(device => `
<div class="p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center">
<div>
<p class="font-medium" style="color: var(--text-primary)">${device.device_name}</p>
<p class="text-sm" style="color: var(--text-secondary)">${device.device_type}</p>
</div>
<div class="text-right">
<p class="font-medium" style="color: var(--text-primary)">${Math.round(device.total_time_minutes)} min</p>
<p class="text-sm" style="color: var(--text-secondary)">${device.sync_count} syncs</p>
</div>
</div>
</div>
`).join('');
}
function renderPopularBooks(popular: PopularBooksResponse): void {
const container = document.getElementById('popular-books');
if (!container) return;
if (!popular.books || popular.books.length === 0) {
container.innerHTML = '<p style="color: var(--text-secondary)">No reading history available</p>';
return;
}
container.innerHTML = popular.books.map(book => `
<div class="p-3 rounded-lg border flex items-center space-x-3" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex-1">
<p class="font-medium" style="color: var(--text-primary)">${book.title}</p>
<p class="text-sm" style="color: var(--text-secondary)">${book.author}</p>
</div>
<div class="text-right">
<p class="font-medium" style="color: var(--text-primary)">${book.read_count}x</p>
<p class="text-sm" style="color: var(--text-secondary)">${Math.round(book.avg_completion * 100)}%</p>
</div>
</div>
`).join('');
}
document.addEventListener('DOMContentLoaded', loadAnalytics);
(window as any).loadAnalytics = loadAnalytics;
+173
View File
@@ -0,0 +1,173 @@
interface ApiExplorerRequest {
method: string;
endpoint: string;
headers: Record<string, string>;
body?: string;
}
const requestHistory: ApiExplorerRequest[] = [];
function sendApiRequest(): void {
const method = (document.getElementById('api-method') as HTMLSelectElement)?.value || 'GET';
const endpoint = (document.getElementById('api-endpoint') as HTMLInputElement)?.value || '';
const bodyText = (document.getElementById('api-body') as HTMLTextAreaElement)?.value || '';
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const request: ApiExplorerRequest = {
method,
endpoint,
headers,
body: bodyText || undefined
};
addToHistory(request);
const startTime = performance.now();
fetch(endpoint, {
method,
headers,
body: bodyText || undefined
})
.then(async response => {
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
const responseText = await response.text();
let responseData: unknown;
try {
responseData = JSON.parse(responseText);
} catch {
responseData = responseText;
}
displayResponse(response, responseData, duration);
generateCurl(request);
})
.catch(error => {
displayError(error);
});
}
function displayResponse(response: Response, data: unknown, duration: number): void {
const container = document.getElementById('api-response');
if (!container) return;
const statusColor = response.ok ? 'var(--accent)' : 'var(--error)';
container.innerHTML = `
<div class="mb-4">
<span class="px-2 py-1 rounded text-sm" style="background-color: ${statusColor}; color: var(--bg-primary)">
${response.status} ${response.statusText}
</span>
<span class="text-sm ml-2" style="color: var(--text-secondary)">${duration}ms</span>
</div>
<pre class="p-4 rounded overflow-auto max-h-96" style="background-color: var(--bg-primary); color: var(--text-primary)">${JSON.stringify(data, null, 2)}</pre>
`;
}
function displayError(error: Error): void {
const container = document.getElementById('api-response');
if (!container) return;
container.innerHTML = `
<div class="p-4 rounded" style="background-color: var(--bg-primary)">
<p style="color: var(--error)">Error: ${error.message}</p>
</div>
`;
}
function generateCurl(request: ApiExplorerRequest): void {
const container = document.getElementById('curl-command');
if (!container) return;
let curl = `curl -X ${request.method} '${request.endpoint}'`;
Object.entries(request.headers).forEach(([key, value]) => {
curl += ` \\\n -H '${key}: ${value}'`;
});
if (request.body) {
curl += ` \\\n -d '${request.body}'`;
}
container.textContent = curl;
}
function addToHistory(request: ApiExplorerRequest): void {
requestHistory.unshift(request);
if (requestHistory.length > 20) {
requestHistory.pop();
}
renderHistory();
}
function renderHistory(): void {
const container = document.getElementById('request-history');
if (!container) return;
if (requestHistory.length === 0) {
container.innerHTML = '<p class="text-sm p-2" style="color: var(--text-secondary)">No requests yet</p>';
return;
}
container.innerHTML = requestHistory.slice(0, 10).map((req, i) => `
<div class="p-2 rounded cursor-pointer hover:bg-opacity-50 transition-colors"
style="background-color: var(--bg-secondary)"
onclick="window.loadFromHistory(${i})">
<span class="text-xs font-mono" style="color: ${req.method === 'GET' ? 'var(--accent)' : req.method === 'POST' ? 'var(--success)' : req.method === 'DELETE' ? 'var(--error)' : 'var(--text-primary)'}">${req.method}</span>
<span class="text-xs ml-2" style="color: var(--text-secondary)">${req.endpoint}</span>
</div>
`).join('');
}
function loadFromHistory(index: number): void {
const request = requestHistory[index];
if (!request) return;
const methodSelect = document.getElementById('api-method') as HTMLSelectElement;
const endpointInput = document.getElementById('api-endpoint') as HTMLInputElement;
const bodyInput = document.getElementById('api-body') as HTMLTextAreaElement;
if (methodSelect) methodSelect.value = request.method;
if (endpointInput) endpointInput.value = request.endpoint;
if (bodyInput) bodyInput.value = request.body || '';
}
function copyCurl(): void {
const curl = document.getElementById('curl-command')?.textContent;
if (curl) {
navigator.clipboard.writeText(curl);
if ((window as any).showToast?.success) {
(window as any).showToast.success('cURL copied to clipboard');
}
}
}
function formatJson(): void {
const bodyInput = document.getElementById('api-body') as HTMLTextAreaElement;
if (!bodyInput) return;
try {
const parsed = JSON.parse(bodyInput.value);
bodyInput.value = JSON.stringify(parsed, null, 2);
} catch {
if ((window as any).showToast?.error) {
(window as any).showToast.error('Invalid JSON');
}
}
}
(window as any).sendApiRequest = sendApiRequest;
(window as any).loadFromHistory = loadFromHistory;
(window as any).copyCurl = copyCurl;
(window as any).formatJson = formatJson;
+118
View File
@@ -0,0 +1,118 @@
function selectLibrary(libraryId: string): void {
localStorage.setItem('selectedLibrary', libraryId);
document.querySelectorAll('.library-item').forEach(el => {
el.classList.remove('ring-2');
el.classList.remove('ring-accent');
});
const selected = document.querySelector(`[data-library-id="${libraryId}"]`);
if (selected) {
selected.classList.add('ring-2');
selected.classList.add('ring-accent');
}
loadBookshelf(libraryId);
}
async function loadBookshelf(libraryId: string): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/libraries/${libraryId}/books`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
renderBooks(data.books || []);
}
} catch (error) {
console.error('Failed to load bookshelf:', error);
}
}
function renderBooks(books: unknown[]): void {
const container = document.getElementById('books-grid');
if (!container) return;
if (books.length === 0) {
container.innerHTML = '<p class="text-center p-8" style="color: var(--text-secondary)">No books in this library</p>';
return;
}
container.innerHTML = books.map((book: any) => `
<div class="book-card p-3 rounded-lg border transition-transform hover:scale-105 cursor-pointer"
style="background-color: var(--bg-secondary); border-color: var(--border)"
onclick="window.selectBook('${book.id}')">
${book.cover_image_path ?
`<img src="/covers/${book.cover_image_path}" alt="${book.title}" class="w-full h-48 object-cover rounded mb-2">` :
`<div class="w-full h-48 rounded mb-2 flex items-center justify-center" style="background-color: var(--bg-primary)">
<span class="text-4xl">📖</span>
</div>`
}
<h3 class="font-medium text-sm truncate" style="color: var(--text-primary)">${book.title}</h3>
<p class="text-xs truncate" style="color: var(--text-secondary)">${book.author || 'Unknown Author'}</p>
</div>
`).join('');
}
function selectBook(bookId: string): void {
localStorage.setItem('selectedBook', bookId);
window.location.href = `/books/${bookId}`;
}
function changePage(page: number): void {
const libraryId = localStorage.getItem('selectedLibrary');
if (!libraryId) return;
const offset = (page - 1) * 50;
loadBookshelfPaginated(libraryId, offset);
}
async function loadBookshelfPaginated(libraryId: string, offset: number): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/libraries/${libraryId}/books?offset=${offset}&limit=50`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
renderBooks(data.books || []);
updatePagination(data.total, offset);
}
} catch (error) {
console.error('Failed to load bookshelf:', error);
}
}
function updatePagination(total: number, offset: number): void {
const container = document.getElementById('pagination');
if (!container) return;
const limit = 50;
const currentPage = Math.floor(offset / limit) + 1;
const totalPages = Math.ceil(total / limit);
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
container.innerHTML = `
<div class="flex justify-center space-x-2">
${currentPage > 1 ? `<button onclick="window.changePage(${currentPage - 1})" class="btn-secondary px-3 py-1 rounded">Previous</button>` : ''}
<span class="px-3 py-1" style="color: var(--text-secondary)">Page ${currentPage} of ${totalPages}</span>
${currentPage < totalPages ? `<button onclick="window.changePage(${currentPage + 1})" class="btn-secondary px-3 py-1 rounded">Next</button>` : ''}
</div>
`;
}
(window as any).selectLibrary = selectLibrary;
(window as any).loadBookshelf = loadBookshelf;
(window as any).selectBook = selectBook;
(window as any).changePage = changePage;
+187
View File
@@ -0,0 +1,187 @@
import type { CollectionData, CollectionRule } from './types/api';
async function loadCollections(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/collections', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
renderCollections(data.collections || []);
}
} catch (error) {
console.error('Failed to load collections:', error);
}
}
function renderCollections(collections: CollectionData[]): void {
const container = document.getElementById('collections-list');
if (!container) return;
if (collections.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No collections yet</p>';
return;
}
container.innerHTML = collections.map(collection => `
<a href="/collections/${collection.id}" class="block p-4 rounded-lg border transition-colors hover:border-opacity-50"
style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex items-center space-x-3">
<span class="text-2xl">${collection.icon || '📁'}</span>
<div>
<h3 class="font-medium" style="color: var(--text-primary)">${collection.name}</h3>
${collection.description ? `<p class="text-sm" style="color: var(--text-secondary)">${collection.description}</p>` : ''}
</div>
</div>
</a>
`).join('');
}
async function loadCollectionRules(collectionId: string): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const rules: CollectionRule[] = await response.json();
renderRules(rules);
}
} catch (error) {
console.error('Failed to load rules:', error);
}
}
function renderRules(rules: CollectionRule[]): void {
const container = document.getElementById('rules-list');
if (!container) return;
if (rules.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
return;
}
container.innerHTML = rules.map(rule => `
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
style="background-color: var(--bg-secondary); border-color: var(--border)">
<div>
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? 'Enabled' : 'Disabled'}</p>
</div>
<div class="flex space-x-2">
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
</div>
</div>
`).join('');
}
async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(rule)
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Rule created');
}
loadCollectionRules(collectionId);
} else {
const error = await response.json();
if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to create rule');
}
}
} catch (error) {
console.error('Failed to create rule:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to create rule');
}
}
}
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
if (!confirm('Are you sure you want to delete this rule?')) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Rule deleted');
}
loadCollectionRules(collectionId);
}
} catch (error) {
console.error('Failed to delete rule:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to delete rule');
}
}
}
async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/collections/${collectionId}/rules/test`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(rule)
});
if (response.ok) {
const results = await response.json();
renderTestResults(results);
}
} catch (error) {
console.error('Failed to test rule:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to test rule');
}
}
}
function renderTestResults(results: unknown[]): void {
const container = document.getElementById('test-results');
if (!container) return;
if (!results || (Array.isArray(results) && results.length === 0)) {
container.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
return;
}
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`;
}
(window as any).loadCollections = loadCollections;
(window as any).loadCollectionRules = loadCollectionRules;
(window as any).createRule = createRule;
(window as any).deleteRule = deleteRule;
(window as any).testRule = testRule;
+212
View File
@@ -0,0 +1,212 @@
import type { ConflictDetailResponse, ConflictListResponse, BulkResolveResponse } from './types/api';
async function refreshConflicts(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/conflicts', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data: ConflictListResponse = await response.json();
renderConflicts(data.conflicts);
updateConflictStats(data);
}
} catch (error) {
console.error('Failed to refresh conflicts:', error);
}
}
async function resolveConflict(conflictId: string, winner: string, manualData?: Record<string, unknown>): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/conflicts/${conflictId}/resolve`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ winner, manual_data: manualData })
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Conflict resolved');
}
refreshConflicts();
} else {
const error = await response.json();
if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to resolve conflict');
}
}
} catch (error) {
console.error('Failed to resolve conflict:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to resolve conflict');
}
}
}
async function bulkResolve(strategy: 'most_recent' | 'highest_progress', conflictIds: string[]): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/conflicts/bulk-resolve', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ conflict_ids: conflictIds, strategy })
});
if (response.ok) {
const data: BulkResolveResponse = await response.json();
if ((window as any).showToast?.success) {
(window as any).showToast.success(`Resolved ${data.success} conflicts`);
}
refreshConflicts();
}
} catch (error) {
console.error('Failed to bulk resolve:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to bulk resolve conflicts');
}
}
}
async function bulkDismiss(conflictIds: string[]): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/conflicts/bulk-dismiss', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ conflict_ids: conflictIds })
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Conflicts dismissed');
}
refreshConflicts();
}
} catch (error) {
console.error('Failed to dismiss conflicts:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to dismiss conflicts');
}
}
}
async function dismissAllResolved(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/conflicts/dismiss-resolved', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Resolved conflicts dismissed');
}
refreshConflicts();
}
} catch (error) {
console.error('Failed to dismiss resolved:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to dismiss resolved conflicts');
}
}
}
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
const container = document.getElementById('conflicts-list');
if (!container) return;
if (conflicts.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
return;
}
container.innerHTML = conflicts.map(conflict => `
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
</div>
${conflict.resolution_status === 'unresolved' ? `
<div class="flex space-x-2">
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
</div>
` : ''}
</div>
</div>
`).join('');
}
function updateConflictStats(data: ConflictListResponse): void {
const totalEl = document.getElementById('conflicts-total');
const unresolvedEl = document.getElementById('conflicts-unresolved');
if (totalEl) totalEl.textContent = String(data.total);
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
}
function showResolveModal(conflictId: string): void {
const modal = document.getElementById('resolve-modal');
const conflictIdInput = document.getElementById('resolve-conflict-id') as HTMLInputElement;
if (modal && conflictIdInput) {
conflictIdInput.value = conflictId;
modal.classList.remove('hidden');
}
}
function hideResolveModal(): void {
const modal = document.getElementById('resolve-modal');
if (modal) {
modal.classList.add('hidden');
}
}
function handleResolveSubmit(event: Event): void {
event.preventDefault();
const form = event.target as HTMLFormElement;
const conflictId = (form.querySelector('#resolve-conflict-id') as HTMLInputElement)?.value;
const winner = (form.querySelector('input[name="winner"]:checked') as HTMLInputElement)?.value;
if (!conflictId || !winner) {
if ((window as any).showToast?.error) {
(window as any).showToast.error('Please select a winner');
}
return;
}
resolveConflict(conflictId, winner);
hideResolveModal();
}
(window as any).refreshConflicts = refreshConflicts;
(window as any).resolveConflict = resolveConflict;
(window as any).bulkResolve = bulkResolve;
(window as any).bulkDismiss = bulkDismiss;
(window as any).dismissAllResolved = dismissAllResolved;
(window as any).showResolveModal = showResolveModal;
(window as any).hideResolveModal = hideResolveModal;
(window as any).handleResolveSubmit = handleResolveSubmit;
+86
View File
@@ -0,0 +1,86 @@
function toggleSidebar(): void {
const sidebar = document.getElementById('docs-sidebar');
const overlay = document.getElementById('docs-overlay');
if (sidebar && overlay) {
sidebar.classList.toggle('translate-x-0');
sidebar.classList.toggle('-translate-x-full');
overlay.classList.toggle('hidden');
}
}
function initializeDocsSearch(): void {
const searchInput = document.getElementById('docs-search') as HTMLInputElement;
const searchResults = document.getElementById('docs-search-results');
if (!searchInput || !searchResults) return;
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
searchInput.addEventListener('input', () => {
const query = searchInput.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (query.length < 2) {
searchResults.innerHTML = '';
searchResults.classList.add('hidden');
return;
}
searchTimeout = setTimeout(() => {
performDocsSearch(query);
}, 300);
});
}
function performDocsSearch(query: string): void {
const searchResults = document.getElementById('docs-search-results');
if (!searchResults) return;
if (!(window as any).lunr) {
console.warn('Lunr.js not loaded');
return;
}
try {
const idx = (window as any).lunrIndex;
if (!idx) {
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
searchResults.classList.remove('hidden');
return;
}
const results = idx.search(query);
if (results.length === 0) {
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
} else {
searchResults.innerHTML = results.slice(0, 10).map((result: { ref: string }) => {
const doc = (window as any).docsData?.[result.ref];
if (!doc) return '';
return `
<a href="${result.ref}" class="block p-2 hover:bg-opacity-50 transition-colors" style="background-color: var(--bg-secondary)">
<p class="font-medium text-sm" style="color: var(--text-primary)">${doc.title || result.ref}</p>
${doc.section ? `<p class="text-xs" style="color: var(--text-secondary)">${doc.section}</p>` : ''}
</a>
`;
}).join('');
}
searchResults.classList.remove('hidden');
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
searchResults.classList.remove('hidden');
}
}
document.addEventListener('DOMContentLoaded', () => {
initializeDocsSearch();
});
(window as any).toggleSidebar = toggleSidebar;
+182
View File
@@ -0,0 +1,182 @@
import type { UnlinkedBookData, PotentialMatchData } from './types/api';
async function loadUnlinkedBooks(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/sync/unlinked-books', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
renderUnlinkedBooks(data.unlinked || []);
}
} catch (error) {
console.error('Failed to load unlinked books:', error);
}
}
function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
const container = document.getElementById('unlinked-books-list');
if (!container) return;
if (books.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>';
return;
}
container.innerHTML = books.map(book => `
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium" style="color: var(--text-primary)">${book.title_from_device}</h3>
<p class="text-sm" style="color: var(--text-secondary)">${book.device_name} (${book.device_type})</p>
<p class="text-xs" style="color: var(--text-secondary)">${book.file_path}</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p>
</div>
<div class="flex space-x-2">
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
</div>
</div>
${book.potential_matches && book.potential_matches.length > 0 ? `
<div class="mt-3 pt-3 border-t" style="border-color: var(--border)">
<p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p>
${book.potential_matches.slice(0, 3).map(match => `
<div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)">
<div>
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
<p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p>
</div>
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
</div>
`).join('')}
</div>
` : ''}
</div>
`).join('');
}
async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/sync/link-book', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ progress_id: progressId, media_item_id: mediaItemId })
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Book linked successfully');
}
loadUnlinkedBooks();
} else {
const error = await response.json();
if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to link book');
}
}
} catch (error) {
console.error('Failed to link book:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to link book');
}
}
}
async function autoLinkBooks(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
if (!confirm('Auto-link all books with high confidence matches?')) return;
try {
const response = await fetch('/api/sync/auto-link', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ confidence_threshold: 0.9 })
});
if (response.ok) {
const data = await response.json();
if ((window as any).showToast?.success) {
(window as any).showToast.success(`Auto-linked ${data.linked_count || 0} books`);
}
loadUnlinkedBooks();
}
} catch (error) {
console.error('Failed to auto-link:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to auto-link books');
}
}
}
async function getSuggestions(progressId: string): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/sync/suggestions/${progressId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const suggestions = await response.json();
showSuggestionsModal(progressId, suggestions);
}
} catch (error) {
console.error('Failed to get suggestions:', error);
}
}
function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void {
const modal = document.getElementById('match-modal');
const content = document.getElementById('match-modal-content');
if (!modal || !content) return;
content.innerHTML = `
<div class="p-4">
<h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3>
<div class="space-y-2">
${suggestions.map(s => `
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
style="background-color: var(--bg-primary); border-color: var(--border)"
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
<p class="font-medium" style="color: var(--text-primary)">${s.title}</p>
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
</div>
`).join('')}
</div>
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
</div>
`;
modal.classList.remove('hidden');
}
function hideMatchModal(): void {
const modal = document.getElementById('match-modal');
if (modal) {
modal.classList.add('hidden');
}
}
(window as any).loadUnlinkedBooks = loadUnlinkedBooks;
(window as any).linkBook = linkBook;
(window as any).autoLinkBooks = autoLinkBooks;
(window as any).getSuggestions = getSuggestions;
(window as any).showSuggestionsModal = showSuggestionsModal;
(window as any).hideMatchModal = hideMatchModal;
+177
View File
@@ -0,0 +1,177 @@
import type { QueueItemResponse } from './types/api';
async function refreshQueue(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/queue/all', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
renderQueueItems(data.items || []);
}
} catch (error) {
console.error('Failed to refresh queue:', error);
}
}
async function processPendingItems(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch('/api/queue/process', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Processing queue items');
}
refreshQueue();
}
} catch (error) {
console.error('Failed to process queue:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to process queue');
}
}
}
async function clearFailedItems(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
if (!confirm('Are you sure you want to clear all failed items?')) return;
try {
const response = await fetch('/api/queue/failed', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Failed items cleared');
}
refreshQueue();
}
} catch (error) {
console.error('Failed to clear failed items:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to clear items');
}
}
}
async function clearAllItems(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
if (!confirm('Are you sure you want to clear all queue items?')) return;
try {
const response = await fetch('/api/queue/all', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Queue cleared');
}
refreshQueue();
}
} catch (error) {
console.error('Failed to clear queue:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to clear queue');
}
}
}
async function retryQueueItem(itemId: string): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/queue/items/${itemId}/retry`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Item queued for retry');
}
refreshQueue();
}
} catch (error) {
console.error('Failed to retry item:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to retry item');
}
}
}
async function deleteQueueItem(itemId: string): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
try {
const response = await fetch(`/api/queue/items/${itemId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
if ((window as any).showToast?.success) {
(window as any).showToast.success('Item deleted');
}
refreshQueue();
}
} catch (error) {
console.error('Failed to delete item:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to delete item');
}
}
}
function renderQueueItems(items: QueueItemResponse[]): void {
const container = document.getElementById('queue-items');
if (!container) return;
if (items.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">Queue is empty</p>';
return;
}
container.innerHTML = items.map(item => `
<div class="p-3 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center">
<div>
<p class="font-medium" style="color: var(--text-primary)">${item.media_title || 'Unknown'}</p>
<p class="text-sm" style="color: var(--text-secondary)">${item.status} - ${item.sync_type}</p>
<p class="text-xs" style="color: var(--text-secondary)">Attempts: ${item.attempts}/${item.max_attempts}</p>
</div>
<div class="flex space-x-2">
${item.status === 'failed' ? `<button onclick="window.retryQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Retry</button>` : ''}
<button onclick="window.deleteQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
</div>
</div>
${item.error_message ? `<p class="text-xs mt-2" style="color: var(--accent)">${item.error_message}</p>` : ''}
</div>
`).join('');
}
(window as any).refreshQueue = refreshQueue;
(window as any).processPendingItems = processPendingItems;
(window as any).clearFailedItems = clearFailedItems;
(window as any).clearAllItems = clearAllItems;
(window as any).retryQueueItem = retryQueueItem;
(window as any).deleteQueueItem = deleteQueueItem;
+292
View File
@@ -0,0 +1,292 @@
import type { MediaItemSummary } from './types/api';
let searchTimeout: 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 (searchTimeout) {
clearTimeout(searchTimeout);
}
if (query.length < SEARCH_MIN_CHARS) {
hideSearchResults();
return;
}
searchTimeout = 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 "${escapeHtml(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)">
${escapeHtml(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 "${escapeHtml(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 escapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>');
}
function escapeHtml(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;