diff --git a/web/src/admin.ts b/web/src/admin.ts new file mode 100644 index 0000000..5f0e77c --- /dev/null +++ b/web/src/admin.ts @@ -0,0 +1,103 @@ +async function triggerLibraryScan(): Promise { + 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 { + 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 { + 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): void { + const container = document.getElementById('system-stats'); + if (!container) return; + + container.innerHTML = ` +
+
+

${stats.total_books || 0}

+

Total Books

+
+
+

${stats.total_users || 0}

+

Users

+
+
+

${stats.total_devices || 0}

+

Devices

+
+
+

${stats.total_libraries || 0}

+

Libraries

+
+
+ `; +} + +(window as any).triggerLibraryScan = triggerLibraryScan; +(window as any).triggerQuickScan = triggerQuickScan; +(window as any).loadSystemStats = loadSystemStats; diff --git a/web/src/analytics.ts b/web/src/analytics.ts new file mode 100644 index 0000000..b15108a --- /dev/null +++ b/web/src/analytics.ts @@ -0,0 +1,118 @@ +import type { ReadingStatsResponse, DeviceUsageResponse, PopularBooksResponse } from './types/api'; + +async function loadAnalytics(): Promise { + 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 = ` +
+
+

${stats.total_books_read}

+

Books Read

+
+
+

${stats.total_pages_read}

+

Pages Read

+
+
+

${stats.total_reading_time_minutes}

+

Minutes Reading

+
+
+

${Math.round(stats.completion_rate * 100)}%

+

Completion Rate

+
+
+ `; +} + +function renderDeviceUsage(devices: DeviceUsageResponse): void { + const container = document.getElementById('device-usage'); + if (!container) return; + + if (!devices.devices || devices.devices.length === 0) { + container.innerHTML = '

No device usage data available

'; + return; + } + + container.innerHTML = devices.devices.map(device => ` +
+
+
+

${device.device_name}

+

${device.device_type}

+
+
+

${Math.round(device.total_time_minutes)} min

+

${device.sync_count} syncs

+
+
+
+ `).join(''); +} + +function renderPopularBooks(popular: PopularBooksResponse): void { + const container = document.getElementById('popular-books'); + if (!container) return; + + if (!popular.books || popular.books.length === 0) { + container.innerHTML = '

No reading history available

'; + return; + } + + container.innerHTML = popular.books.map(book => ` +
+
+

${book.title}

+

${book.author}

+
+
+

${book.read_count}x

+

${Math.round(book.avg_completion * 100)}%

+
+
+ `).join(''); +} + +document.addEventListener('DOMContentLoaded', loadAnalytics); + +(window as any).loadAnalytics = loadAnalytics; diff --git a/web/src/api-explorer.ts b/web/src/api-explorer.ts new file mode 100644 index 0000000..2e0af52 --- /dev/null +++ b/web/src/api-explorer.ts @@ -0,0 +1,173 @@ +interface ApiExplorerRequest { + method: string; + endpoint: string; + headers: Record; + 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 = { + '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 = ` +
+ + ${response.status} ${response.statusText} + + ${duration}ms +
+
${JSON.stringify(data, null, 2)}
+ `; +} + +function displayError(error: Error): void { + const container = document.getElementById('api-response'); + if (!container) return; + + container.innerHTML = ` +
+

Error: ${error.message}

+
+ `; +} + +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 = '

No requests yet

'; + return; + } + + container.innerHTML = requestHistory.slice(0, 10).map((req, i) => ` +
+ ${req.method} + ${req.endpoint} +
+ `).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; diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts new file mode 100644 index 0000000..de5f416 --- /dev/null +++ b/web/src/bookshelf.ts @@ -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 { + 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 = '

No books in this library

'; + return; + } + + container.innerHTML = books.map((book: any) => ` +
+ ${book.cover_image_path ? + `${book.title}` : + `
+ 📖 +
` + } +

${book.title}

+

${book.author || 'Unknown Author'}

+
+ `).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 { + 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 = ` +
+ ${currentPage > 1 ? `` : ''} + Page ${currentPage} of ${totalPages} + ${currentPage < totalPages ? `` : ''} +
+ `; +} + +(window as any).selectLibrary = selectLibrary; +(window as any).loadBookshelf = loadBookshelf; +(window as any).selectBook = selectBook; +(window as any).changePage = changePage; diff --git a/web/src/collections.ts b/web/src/collections.ts new file mode 100644 index 0000000..84d0c2f --- /dev/null +++ b/web/src/collections.ts @@ -0,0 +1,187 @@ +import type { CollectionData, CollectionRule } from './types/api'; + +async function loadCollections(): Promise { + 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 = '

No collections yet

'; + return; + } + + container.innerHTML = collections.map(collection => ` + +
+ ${collection.icon || '📁'} +
+

${collection.name}

+ ${collection.description ? `

${collection.description}

` : ''} +
+
+
+ `).join(''); +} + +async function loadCollectionRules(collectionId: string): Promise { + 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 = '

No rules defined

'; + return; + } + + container.innerHTML = rules.map(rule => ` +
+
+

${rule.field} ${rule.operator} "${rule.value}"

+

Priority: ${rule.priority} | ${rule.enabled ? 'Enabled' : 'Disabled'}

+
+
+ + +
+
+ `).join(''); +} + +async function createRule(collectionId: string, rule: Partial): Promise { + 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 { + 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): Promise { + 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 = '

No matching books found

'; + return; + } + + container.innerHTML = `

${Array.isArray(results) ? results.length : 0} matching books

`; +} + +(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; diff --git a/web/src/conflicts.ts b/web/src/conflicts.ts new file mode 100644 index 0000000..f4c0e12 --- /dev/null +++ b/web/src/conflicts.ts @@ -0,0 +1,212 @@ +import type { ConflictDetailResponse, ConflictListResponse, BulkResolveResponse } from './types/api'; + +async function refreshConflicts(): Promise { + 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): Promise { + 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 { + 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 { + 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 { + 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 = '

No conflicts found

'; + return; + } + + container.innerHTML = conflicts.map(conflict => ` +
+
+
+

${conflict.media_item_title}

+

${conflict.conflict_type} - ${conflict.resolution_status}

+
+ ${conflict.resolution_status === 'unresolved' ? ` +
+ +
+ ` : ''} +
+
+ `).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; diff --git a/web/src/docs.ts b/web/src/docs.ts new file mode 100644 index 0000000..68494fa --- /dev/null +++ b/web/src/docs.ts @@ -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 | 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 = '

Search index not loaded

'; + searchResults.classList.remove('hidden'); + return; + } + + const results = idx.search(query); + + if (results.length === 0) { + searchResults.innerHTML = '

No results found

'; + } else { + searchResults.innerHTML = results.slice(0, 10).map((result: { ref: string }) => { + const doc = (window as any).docsData?.[result.ref]; + if (!doc) return ''; + + return ` + +

${doc.title || result.ref}

+ ${doc.section ? `

${doc.section}

` : ''} +
+ `; + }).join(''); + } + + searchResults.classList.remove('hidden'); + } catch (error) { + console.error('Search error:', error); + searchResults.innerHTML = '

Search error

'; + searchResults.classList.remove('hidden'); + } +} + +document.addEventListener('DOMContentLoaded', () => { + initializeDocsSearch(); +}); + +(window as any).toggleSidebar = toggleSidebar; diff --git a/web/src/linking.ts b/web/src/linking.ts new file mode 100644 index 0000000..0bae11a --- /dev/null +++ b/web/src/linking.ts @@ -0,0 +1,182 @@ +import type { UnlinkedBookData, PotentialMatchData } from './types/api'; + +async function loadUnlinkedBooks(): Promise { + 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 = '

No unlinked books

'; + return; + } + + container.innerHTML = books.map(book => ` +
+
+
+

${book.title_from_device}

+

${book.device_name} (${book.device_type})

+

${book.file_path}

+

Confidence: ${Math.round(book.confidence_score * 100)}%

+
+
+ +
+
+ ${book.potential_matches && book.potential_matches.length > 0 ? ` +
+

Potential Matches:

+ ${book.potential_matches.slice(0, 3).map(match => ` +
+
+

${match.title}

+

${match.author} (${Math.round(match.confidence * 100)}%)

+
+ +
+ `).join('')} +
+ ` : ''} +
+ `).join(''); +} + +async function linkBook(progressId: string, mediaItemId: string): Promise { + 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 { + 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 { + 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 = ` +
+

Select a match

+
+ ${suggestions.map(s => ` +
+

${s.title}

+

${s.author}

+

${Math.round(s.confidence * 100)}% match

+
+ `).join('')} +
+ +
+ `; + + 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; diff --git a/web/src/queue.ts b/web/src/queue.ts new file mode 100644 index 0000000..047d4f9 --- /dev/null +++ b/web/src/queue.ts @@ -0,0 +1,177 @@ +import type { QueueItemResponse } from './types/api'; + +async function refreshQueue(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = '

Queue is empty

'; + return; + } + + container.innerHTML = items.map(item => ` +
+
+
+

${item.media_title || 'Unknown'}

+

${item.status} - ${item.sync_type}

+

Attempts: ${item.attempts}/${item.max_attempts}

+
+
+ ${item.status === 'failed' ? `` : ''} + +
+
+ ${item.error_message ? `

${item.error_message}

` : ''} +
+ `).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; diff --git a/web/src/search.ts b/web/src/search.ts new file mode 100644 index 0000000..7b0581c --- /dev/null +++ b/web/src/search.ts @@ -0,0 +1,292 @@ +import type { MediaItemSummary } from './types/api'; + +let searchTimeout: ReturnType | 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, 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 = ` +
+
+

Searching...

+
+ `; + 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 = { + 'ebooks': '📚', + 'comics': '📖', + 'manga': '🗾' + }; + + let html = ` +
+

+ ${results.length} result${results.length !== 1 ? 's' : ''} for "${escapeHtml(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 += ` +
+
+

+ Press ↑↓ to navigate, + Enter to select +

+
+ `; + + searchResults.innerHTML = html; + searchResults.classList.remove('hidden'); +} + +function showNoResults(query: string): void { + createSearchResultsContainer(); + const searchResults = document.getElementById('search-results'); + if (!searchResults) return; + + searchResults.innerHTML = ` +
+
🔍
+

No results found for "${escapeHtml(query)}"

+

Try different keywords

+
+ `; + searchResults.classList.remove('hidden'); +} + +function showSearchError(): void { + createSearchResultsContainer(); + const searchResults = document.getElementById('search-results'); + if (!searchResults) return; + + searchResults.innerHTML = ` +
+
⚠️
+

Search error

+

Please try again

+
+ `; + 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, '$1'); +} + +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;