diff --git a/web/src/admin.ts b/web/src/admin.ts index 0300fec..2165c06 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -1,82 +1,84 @@ async function triggerLibraryScan(): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/libraries/scan', { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/libraries/quick-scan', { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/admin/stats', { - headers: { 'Authorization': `Bearer ${token}` } - }); + 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); + 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; + const container = document.getElementById("system-stats"); + if (!container) return; - container.innerHTML = ` + container.innerHTML = `

${stats.total_books || 0}

@@ -99,78 +101,88 @@ function renderSystemStats(stats: Record): void { } async function scanAllLibraries(): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const libsResp = await fetch('/api/libraries', { - headers: { 'Authorization': `Bearer ${token}` } - }); + try { + const libsResp = await fetch("/api/libraries", { + headers: { Authorization: `Bearer ${token}` }, + }); - if (!libsResp.ok) { - throw new Error('Failed to get libraries'); - } - - const libsData = await libsResp.json(); - - if (!libsData.data || libsData.data.length === 0) { - if ((window as any).showToast?.error) { - (window as any).showToast.error('No libraries found. Please create a library first.'); - } - return; - } - - const libraries = libsData.data; - - const jobs: string[] = []; - const libraryNames: Record = {}; - - for (const lib of libraries) { - const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ force: true }) - }); - - if (scanResp.ok) { - const result = await scanResp.json(); - jobs.push(result.job_id); - libraryNames[result.job_id] = lib.name; - } else { - console.error(`Failed to scan library: ${lib.name}`); - } - } - - if (jobs.length === 0) { - if ((window as any).showToast?.error) { - (window as any).showToast.error('Failed to start scan for any library'); - } - return; - } - - showScanProgress(jobs, libraryNames); - - } catch (error) { - console.error('Scan error:', error); - if ((window as any).showToast?.error) { - (window as any).showToast.error('Failed to start scan: ' + (error as Error).message); - } + if (!libsResp.ok) { + throw new Error("Failed to get libraries"); } + + const libsData = await libsResp.json(); + + if (!libsData.data || libsData.data.length === 0) { + if ((window as any).showToast?.error) { + (window as any).showToast.error( + "No libraries found. Please create a library first.", + ); + } + return; + } + + const libraries = libsData.data; + + const jobs: string[] = []; + const libraryNames: Record = {}; + + for (const lib of libraries) { + const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ force: true }), + }); + + if (scanResp.ok) { + const result = await scanResp.json(); + jobs.push(result.job_id); + libraryNames[result.job_id] = lib.name; + } else { + console.error(`Failed to scan library: ${lib.name}`); + } + } + + if (jobs.length === 0) { + if ((window as any).showToast?.error) { + (window as any).showToast.error("Failed to start scan for any library"); + } + return; + } + + showScanProgress(jobs, libraryNames); + } catch (error) { + console.error("Scan error:", error); + if ((window as any).showToast?.error) { + (window as any).showToast.error( + "Failed to start scan: " + (error as Error).message, + ); + } + } } -function showScanProgress(jobIds: string[], libraryNames: Record): void { - const container = document.getElementById('scan-progress-container') as HTMLElement; - const list = document.getElementById('library-progress-list') as HTMLElement; +function showScanProgress( + jobIds: string[], + libraryNames: Record, +): void { + const container = document.getElementById( + "scan-progress-container", + ) as HTMLElement; + const list = document.getElementById("library-progress-list") as HTMLElement; - if (!container || !list) return; + if (!container || !list) return; - container.classList.remove('hidden'); - container.classList.remove('opacity-0', '-translate-y-2.5'); + container.classList.remove("hidden"); + container.classList.remove("opacity-0", "-translate-y-2.5"); - list.innerHTML = jobIds.map(jobId => ` + list.innerHTML = jobIds + .map( + (jobId) => `
@@ -188,133 +200,158 @@ function showScanProgress(jobIds: string[], libraryNames: Record
- `).join(''); + `, + ) + .join(""); - pollScanProgress(jobIds, libraryNames); + pollScanProgress(jobIds, libraryNames); } -function pollScanProgress(jobIds: string[], _libraryNames: Record): void { - const token = localStorage.getItem('token'); - const startTime = Date.now(); +function pollScanProgress( + jobIds: string[], + _libraryNames: Record, +): void { + const token = localStorage.getItem("token"); + const startTime = Date.now(); - const interval = setInterval(async () => { - let allComplete = true; - let totalProgress = 0; - let totalFiles = 0; - let totalNewItems = 0; - let totalErrors = 0; + const interval = setInterval(async () => { + let allComplete = true; + let totalProgress = 0; + let totalFiles = 0; + let totalNewItems = 0; + let totalErrors = 0; - for (const jobId of jobIds) { - try { - const resp = await fetch(`/api/scanner/status/${jobId}`, { - headers: { 'Authorization': `Bearer ${token}` } - }); + for (const jobId of jobIds) { + try { + const resp = await fetch(`/api/scanner/status/${jobId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); - if (resp.ok) { - const status = await resp.json(); + if (resp.ok) { + const status = await resp.json(); - updateLibraryProgress(jobId, status); + updateLibraryProgress(jobId, status); - totalProgress += status.progress || 0; - totalFiles += status.files_scanned || 0; - totalNewItems += status.new_items || 0; - totalErrors += status.errors || 0; + totalProgress += status.progress || 0; + totalFiles += status.files_scanned || 0; + totalNewItems += status.new_items || 0; + totalErrors += status.errors || 0; - if (status.status !== 'completed' && status.status !== 'failed') { - allComplete = false; - } - } - } catch (error) { - console.error(`Failed to poll job ${jobId}:`, error); - } + if (status.status !== "completed" && status.status !== "failed") { + allComplete = false; + } } + } catch (error) { + console.error(`Failed to poll job ${jobId}:`, error); + } + } - const overallProgress = Math.round(totalProgress / jobIds.length); - const progressBar = document.getElementById('scan-progress-bar') as HTMLElement; - const progressText = document.getElementById('scan-progress-text') as HTMLElement; - const statusText = document.getElementById('scan-status') as HTMLElement; + const overallProgress = Math.round(totalProgress / jobIds.length); + const progressBar = document.getElementById( + "scan-progress-bar", + ) as HTMLElement; + const progressText = document.getElementById( + "scan-progress-text", + ) as HTMLElement; + const statusText = document.getElementById("scan-status") as HTMLElement; - if (progressBar) progressBar.style.width = overallProgress + '%'; - if (progressText) progressText.textContent = overallProgress + '%'; + if (progressBar) progressBar.style.width = overallProgress + "%"; + if (progressText) progressText.textContent = overallProgress + "%"; - const elapsed = Math.round((Date.now() - startTime) / 1000); - if (!allComplete && statusText) { - statusText.textContent = `Scanning... ${elapsed}s elapsed • ${totalFiles} files processed`; - } + const elapsed = Math.round((Date.now() - startTime) / 1000); + if (!allComplete && statusText) { + statusText.textContent = `Scanning... ${elapsed}s elapsed • ${totalFiles} files processed`; + } - if (allComplete) { - clearInterval(interval); - showScanResults(jobIds.length, totalFiles, totalNewItems, totalErrors, elapsed); - } - }, 2000); + if (allComplete) { + clearInterval(interval); + showScanResults( + jobIds.length, + totalFiles, + totalNewItems, + totalErrors, + elapsed, + ); + } + }, 2000); } function updateLibraryProgress(jobId: string, status: any): void { - const bar = document.getElementById(`bar-${jobId}`) as HTMLElement; - const statusText = document.getElementById(`status-${jobId}`) as HTMLElement; + const bar = document.getElementById(`bar-${jobId}`) as HTMLElement; + const statusText = document.getElementById(`status-${jobId}`) as HTMLElement; - if (bar) { - bar.style.width = (status.progress || 0) + '%'; - } + if (bar) { + bar.style.width = (status.progress || 0) + "%"; + } - if (statusText) { - const statusMessages: Record = { - 'pending': 'Pending...', - 'running': `Scanning... ${status.progress || 0}%`, - 'completed': `✓ Complete (${status.new_items || 0} items)`, - 'failed': `✗ Failed` - }; - statusText.textContent = statusMessages[status.status] || status.status; - } + if (statusText) { + const statusMessages: Record = { + pending: "Pending...", + running: `Scanning... ${status.progress || 0}%`, + completed: `✓ Complete (${status.new_items || 0} items)`, + failed: `✗ Failed`, + }; + statusText.textContent = statusMessages[status.status] || status.status; + } } -function showScanResults(libCount: number, files: number, items: number, errors: number, elapsed: number): void { - const resultsDiv = document.getElementById('scan-results') as HTMLElement; - const contentDiv = document.getElementById('scan-results-content') as HTMLElement; +function showScanResults( + libCount: number, + files: number, + items: number, + errors: number, + elapsed: number, +): void { + const resultsDiv = document.getElementById("scan-results") as HTMLElement; + const contentDiv = document.getElementById( + "scan-results-content", + ) as HTMLElement; - if (!resultsDiv || !contentDiv) return; + if (!resultsDiv || !contentDiv) return; - contentDiv.innerHTML = ` -

• ${libCount} librar${libCount === 1 ? 'y' : 'ies'} scanned

+ contentDiv.innerHTML = ` +

• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned

• ${files} files processed

• ${items} new items added

- ${errors > 0 ? `

• ${errors} errors

` : ''} + ${errors > 0 ? `

• ${errors} errors

` : ""}

Completed in ${elapsed} seconds

`; - resultsDiv.classList.remove('hidden'); + resultsDiv.classList.remove("hidden"); - const statusText = document.getElementById('scan-status') as HTMLElement; - if (statusText) statusText.textContent = 'Scan complete!'; + const statusText = document.getElementById("scan-status") as HTMLElement; + if (statusText) statusText.textContent = "Scan complete!"; } function hideScanProgress(): void { - const container = document.getElementById('scan-progress-container') as HTMLElement; - if (container) container.classList.add('hidden'); + const container = document.getElementById( + "scan-progress-container", + ) as HTMLElement; + if (container) container.classList.add("hidden"); } async function loadWatchStatus(): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/scanner/watch/status', { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (response.ok) { - const data = await response.json(); - const countEl = document.getElementById('watch-count'); - if (countEl) { - countEl.textContent = data.total_watching?.toString() || '0'; - } - } - } catch (error) { - console.error('Failed to load watch status:', error); + try { + const response = await fetch("/api/scanner/watch/status", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) { + const data = await response.json(); + const countEl = document.getElementById("watch-count"); + if (countEl) { + countEl.textContent = data.total_watching?.toString() || "0"; + } } + } catch (error) { + console.error("Failed to load watch status:", error); + } } -document.addEventListener('DOMContentLoaded', function() { - loadWatchStatus(); +document.addEventListener("DOMContentLoaded", function () { + loadWatchStatus(); }); (window as any).triggerLibraryScan = triggerLibraryScan; diff --git a/web/src/analytics.ts b/web/src/analytics.ts index 3cfb579..4932061 100644 --- a/web/src/analytics.ts +++ b/web/src/analytics.ts @@ -1,47 +1,47 @@ async function loadAnalytics(): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + 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}` } - }) - ]); + 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'); - } + 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; + const container = document.getElementById("reading-stats"); + if (!container) return; - container.innerHTML = ` + container.innerHTML = `

${stats.total_books_read}

@@ -64,15 +64,18 @@ function renderReadingStats(stats: ReadingStatsResponse): void { } function renderDeviceUsage(devices: DeviceUsageResponse): void { - const container = document.getElementById('device-usage'); - if (!container) return; + const container = document.getElementById("device-usage"); + if (!container) return; - if (!devices.devices || devices.devices.length === 0) { - container.innerHTML = '

No device usage data available

'; - return; - } + if (!devices.devices || devices.devices.length === 0) { + container.innerHTML = + '

No device usage data available

'; + return; + } - container.innerHTML = devices.devices.map(device => ` + container.innerHTML = devices.devices + .map( + (device) => `
@@ -85,19 +88,24 @@ function renderDeviceUsage(devices: DeviceUsageResponse): void {
- `).join(''); + `, + ) + .join(""); } function renderPopularBooks(popular: PopularBooksResponse): void { - const container = document.getElementById('popular-books'); - if (!container) return; + const container = document.getElementById("popular-books"); + if (!container) return; - if (!popular.books || popular.books.length === 0) { - container.innerHTML = '

No reading history available

'; - return; - } + if (!popular.books || popular.books.length === 0) { + container.innerHTML = + '

No reading history available

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

${book.title}

@@ -108,9 +116,11 @@ function renderPopularBooks(popular: PopularBooksResponse): void {

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

- `).join(''); + `, + ) + .join(""); } -document.addEventListener('DOMContentLoaded', loadAnalytics); +document.addEventListener("DOMContentLoaded", loadAnalytics); (window as any).loadAnalytics = loadAnalytics; diff --git a/web/src/api-explorer.ts b/web/src/api-explorer.ts index 2e0af52..44b6830 100644 --- a/web/src/api-explorer.ts +++ b/web/src/api-explorer.ts @@ -1,70 +1,78 @@ interface ApiExplorerRequest { - method: string; - endpoint: string; - headers: Record; - body?: string; + 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 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 token = localStorage.getItem("token"); - const headers: Record = { - 'Content-Type': 'application/json' - }; + const headers: Record = { + "Content-Type": "application/json", + }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } - const request: ApiExplorerRequest = { - method, - endpoint, - headers, - body: bodyText || undefined - }; + const request: ApiExplorerRequest = { + method, + endpoint, + headers, + body: bodyText || undefined, + }; - addToHistory(request); + addToHistory(request); - const startTime = performance.now(); + const startTime = performance.now(); - fetch(endpoint, { - method, - headers, - body: bodyText || undefined + 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); }) - .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); + .catch((error) => { + displayError(error); }); } -function displayResponse(response: Response, data: unknown, duration: number): void { - const container = document.getElementById('api-response'); - if (!container) return; +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)'; + const statusColor = response.ok ? "var(--accent)" : "var(--error)"; - container.innerHTML = ` + container.innerHTML = `
${response.status} ${response.statusText} @@ -76,10 +84,10 @@ function displayResponse(response: Response, data: unknown, duration: number): v } function displayError(error: Error): void { - const container = document.getElementById('api-response'); - if (!container) return; + const container = document.getElementById("api-response"); + if (!container) return; - container.innerHTML = ` + container.innerHTML = `

Error: ${error.message}

@@ -87,84 +95,94 @@ function displayError(error: Error): void { } function generateCurl(request: ApiExplorerRequest): void { - const container = document.getElementById('curl-command'); - if (!container) return; + const container = document.getElementById("curl-command"); + if (!container) return; - let curl = `curl -X ${request.method} '${request.endpoint}'`; + let curl = `curl -X ${request.method} '${request.endpoint}'`; - Object.entries(request.headers).forEach(([key, value]) => { - curl += ` \\\n -H '${key}: ${value}'`; - }); + Object.entries(request.headers).forEach(([key, value]) => { + curl += ` \\\n -H '${key}: ${value}'`; + }); - if (request.body) { - curl += ` \\\n -d '${request.body}'`; - } + if (request.body) { + curl += ` \\\n -d '${request.body}'`; + } - container.textContent = curl; + container.textContent = curl; } function addToHistory(request: ApiExplorerRequest): void { - requestHistory.unshift(request); - if (requestHistory.length > 20) { - requestHistory.pop(); - } - renderHistory(); + requestHistory.unshift(request); + if (requestHistory.length > 20) { + requestHistory.pop(); + } + renderHistory(); } function renderHistory(): void { - const container = document.getElementById('request-history'); - if (!container) return; + const container = document.getElementById("request-history"); + if (!container) return; - if (requestHistory.length === 0) { - container.innerHTML = '

No requests yet

'; - return; - } + if (requestHistory.length === 0) { + container.innerHTML = + '

No requests yet

'; + return; + } - container.innerHTML = requestHistory.slice(0, 10).map((req, i) => ` + container.innerHTML = requestHistory + .slice(0, 10) + .map( + (req, i) => `
- ${req.method} + ${req.method} ${req.endpoint}
- `).join(''); + `, + ) + .join(""); } function loadFromHistory(index: number): void { - const request = requestHistory[index]; - if (!request) return; + 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; + 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 || ''; + 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'); - } + 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; + 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'); - } + 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; diff --git a/web/src/api.ts b/web/src/api.ts index 9817108..dfec089 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,91 +1,99 @@ function getAuthHeader(): string { - const token = localStorage.getItem('token'); - return token ? `Bearer ${token}` : ''; + const token = localStorage.getItem("token"); + return token ? `Bearer ${token}` : ""; } async function apiGet(url: string): Promise { - return fetch(`/api${url}`, { - headers: { - 'Authorization': getAuthHeader(), - 'Content-Type': 'application/json' - } - }); + return fetch(`/api${url}`, { + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + }, + }); } async function apiPost(url: string, data?: unknown): Promise { - return fetch(`/api${url}`, { - method: 'POST', - headers: { - 'Authorization': getAuthHeader(), - 'Content-Type': 'application/json' - }, - body: data ? JSON.stringify(data) : undefined - }); + return fetch(`/api${url}`, { + method: "POST", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + }, + body: data ? JSON.stringify(data) : undefined, + }); } async function apiPut(url: string, data?: unknown): Promise { - return fetch(`/api${url}`, { - method: 'PUT', - headers: { - 'Authorization': getAuthHeader(), - 'Content-Type': 'application/json' - }, - body: data ? JSON.stringify(data) : undefined - }); + return fetch(`/api${url}`, { + method: "PUT", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + }, + body: data ? JSON.stringify(data) : undefined, + }); } -async function apiDelete(url: string, data?: T): Promise { - return fetch(`/api${url}`, { - method: 'DELETE', - headers: { - 'Authorization': getAuthHeader(), - 'Content-Type': 'application/json' - }, - body: data ? JSON.stringify(data) : undefined - }); +async function apiDelete( + url: string, + data?: T, +): Promise { + return fetch(`/api${url}`, { + method: "DELETE", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + }, + body: data ? JSON.stringify(data) : undefined, + }); } async function apiPatch(url: string, data?: unknown): Promise { - return fetch(`/api${url}`, { - method: 'PATCH', - headers: { - 'Authorization': getAuthHeader(), - 'Content-Type': 'application/json' - }, - body: data ? JSON.stringify(data) : undefined - }); + return fetch(`/api${url}`, { + method: "PATCH", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + }, + body: data ? JSON.stringify(data) : undefined, + }); } async function handleResponse(response: Response): Promise { - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(errorData.error || `HTTP ${response.status}`); - } - return response.json(); + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: "Unknown error" })); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + return response.json(); } async function handleVoidResponse(response: Response): Promise { - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(errorData.error || `HTTP ${response.status}`); - } + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: "Unknown error" })); + throw new Error(errorData.error || `HTTP ${response.status}`); + } } function handleError(error: unknown, context: string): void { - console.error(`${context}:`, error); - const message = error instanceof Error ? error.message : 'An unexpected error occurred'; - if ((window as any).showToast?.error) { - (window as any).showToast.error(message); - } + console.error(`${context}:`, error); + const message = + error instanceof Error ? error.message : "An unexpected error occurred"; + if ((window as any).showToast?.error) { + (window as any).showToast.error(message); + } } (window as any).api = { - get: apiGet, - post: apiPost, - put: apiPut, - delete: apiDelete, - patch: apiPatch, - handleResponse, - handleVoidResponse, - handleError + get: apiGet, + post: apiPost, + put: apiPut, + delete: apiDelete, + patch: apiPatch, + handleResponse, + handleVoidResponse, + handleError, }; diff --git a/web/src/collections.ts b/web/src/collections.ts index 94918d4..de213c2 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -1,181 +1,204 @@ async function loadCollections(): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/collections', { - headers: { 'Authorization': `Bearer ${token}` } - }); + 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); + 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; + const container = document.getElementById("collections-list"); + if (!container) return; - if (collections.length === 0) { - container.innerHTML = '

No collections yet

'; - return; - } + if (collections.length === 0) { + container.innerHTML = + '

No collections yet

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

${collection.name}

- ${collection.description ? `

${collection.description}

` : ''} + ${collection.description ? `

${collection.description}

` : ""}
- `).join(''); + `, + ) + .join(""); } async function loadCollectionRules(collectionId: string): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch(`/api/collections/${collectionId}/rules`, { - headers: { 'Authorization': `Bearer ${token}` } - }); + 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); + 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; + const container = document.getElementById("rules-list"); + if (!container) return; - if (rules.length === 0) { - container.innerHTML = '

No rules defined

'; - return; - } + if (rules.length === 0) { + container.innerHTML = + '

No rules defined

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

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

-

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

+

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

- `).join(''); + `, + ) + .join(""); } -async function createRule(collectionId: string, rule: Partial): Promise { - const token = localStorage.getItem('token'); - if (!token) return; +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) - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - if (!confirm('Are you sure you want to delete this rule?')) 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}` } - }); + 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'); - } + 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; +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) - }); + 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'); - } + 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; + const container = document.getElementById("test-results"); + if (!container) return; - if (!results || (Array.isArray(results) && results.length === 0)) { - container.innerHTML = '

No matching books found

'; - 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

`; + container.innerHTML = `

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

`; } (window as any).loadCollections = loadCollections; diff --git a/web/src/conflicts.ts b/web/src/conflicts.ts index 8bbf7b0..e532860 100644 --- a/web/src/conflicts.ts +++ b/web/src/conflicts.ts @@ -1,203 +1,227 @@ async function refreshConflicts(): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/conflicts', { - headers: { 'Authorization': `Bearer ${token}` } - }); + 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); + 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; +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 }) - }); + 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'); - } + 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; +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 }) - }); + 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'); - } + 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; + 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 }) - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/conflicts/dismiss-resolved', { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } - }); + 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'); - } + 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; + const container = document.getElementById("conflicts-list"); + if (!container) return; - if (conflicts.length === 0) { - container.innerHTML = '

No conflicts found

'; - return; - } + if (conflicts.length === 0) { + container.innerHTML = + '

No conflicts found

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

${conflict.media_item_title}

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

- ${conflict.resolution_status === 'unresolved' ? ` + ${ + conflict.resolution_status === "unresolved" + ? `
- ` : ''} + ` + : "" + }
- `).join(''); + `, + ) + .join(""); } function updateConflictStats(data: ConflictListResponse): void { - const totalEl = document.getElementById('conflicts-total'); - const unresolvedEl = document.getElementById('conflicts-unresolved'); + 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); + 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; + 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'); - } + 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'); - } + const modal = document.getElementById("resolve-modal"); + if (modal) { + modal.classList.add("hidden"); + } } function handleResolveSubmit(event: Event): void { - event.preventDefault(); + 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; + 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; + if (!conflictId || !winner) { + if ((window as any).showToast?.error) { + (window as any).showToast.error("Please select a winner"); } + return; + } - resolveConflict(conflictId, winner); - hideResolveModal(); + resolveConflict(conflictId, winner); + hideResolveModal(); } (window as any).refreshConflicts = refreshConflicts; diff --git a/web/src/custom-section-builder.ts b/web/src/custom-section-builder.ts index 8cc6da5..37e7705 100644 --- a/web/src/custom-section-builder.ts +++ b/web/src/custom-section-builder.ts @@ -1,175 +1,195 @@ interface FilterField { - id: string; - label: string; - operators: Operator[]; - valueType: 'text' | 'number' | 'date' | 'select' | 'multiselect'; - options?: string[]; + id: string; + label: string; + operators: Operator[]; + valueType: "text" | "number" | "date" | "select" | "multiselect"; + options?: string[]; } interface Operator { - id: string; - label: string; - requiresValue: boolean; + id: string; + label: string; + requiresValue: boolean; } interface FilterRule { - id: string; - field: string; - operator: string; - value: string | string[]; - priority: number; + id: string; + field: string; + operator: string; + value: string | string[]; + priority: number; } const FILTER_FIELDS: FilterField[] = [ - { - id: 'title', - label: 'Title', - operators: [ - { id: 'contains', label: 'Contains', requiresValue: true }, - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'starts_with', label: 'Starts With', requiresValue: true }, - { id: 'ends_with', label: 'Ends With', requiresValue: true }, - { id: 'regex', label: 'Matches Regex', requiresValue: true }, - ], - valueType: 'text', - }, - { - id: 'author', - label: 'Author', - operators: [ - { id: 'contains', label: 'Contains', requiresValue: true }, - { id: 'equals', label: 'Equals', requiresValue: true }, - ], - valueType: 'text', - }, - { - id: 'genre', - label: 'Genre', - operators: [ - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'not_equals', label: 'Not Equals', requiresValue: true }, - { id: 'in', label: 'In', requiresValue: true }, - { id: 'not_in', label: 'Not In', requiresValue: true }, - ], - valueType: 'select', - options: ['Fiction', 'Non-Fiction', 'Sci-Fi', 'Fantasy', 'Mystery', 'Romance', 'Thriller', 'Biography', 'History', 'Self-Help'], - }, - { - id: 'series', - label: 'Series', - operators: [ - { id: 'is_set', label: 'Is Set', requiresValue: false }, - { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'contains', label: 'Contains', requiresValue: true }, - ], - valueType: 'text', - }, - { - id: 'progress', - label: 'Reading Progress', - operators: [ - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'not_equals', label: 'Not Equals', requiresValue: true }, - { id: 'greater_than', label: 'Greater Than', requiresValue: true }, - { id: 'less_than', label: 'Less Than', requiresValue: true }, - { id: 'between', label: 'Between', requiresValue: true }, - { id: 'is_set', label: 'Is Set', requiresValue: false }, - { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, - ], - valueType: 'number', - }, - { - id: 'rating', - label: 'Rating', - operators: [ - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'not_equals', label: 'Not Equals', requiresValue: true }, - { id: 'greater_than', label: 'Greater Than', requiresValue: true }, - { id: 'less_than', label: 'Less Than', requiresValue: true }, - { id: 'is_set', label: 'Is Set', requiresValue: false }, - { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, - ], - valueType: 'number', - }, - { - id: 'date_added', - label: 'Date Added', - operators: [ - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'not_equals', label: 'Not Equals', requiresValue: true }, - { id: 'before', label: 'Before', requiresValue: true }, - { id: 'after', label: 'After', requiresValue: true }, - { id: 'between', label: 'Between', requiresValue: true }, - { id: 'last_x_days', label: 'Last X Days', requiresValue: true }, - ], - valueType: 'date', - }, - { - id: 'last_read', - label: 'Last Read Date', - operators: [ - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'before', label: 'Before', requiresValue: true }, - { id: 'after', label: 'After', requiresValue: true }, - { id: 'between', label: 'Between', requiresValue: true }, - { id: 'last_x_days', label: 'Last X Days', requiresValue: true }, - { id: 'is_set', label: 'Is Set', requiresValue: false }, - { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, - ], - valueType: 'date', - }, - { - id: 'publisher', - label: 'Publisher', - operators: [ - { id: 'contains', label: 'Contains', requiresValue: true }, - { id: 'equals', label: 'Equals', requiresValue: true }, - ], - valueType: 'text', - }, - { - id: 'language', - label: 'Language', - operators: [ - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'not_equals', label: 'Not Equals', requiresValue: true }, - { id: 'in', label: 'In', requiresValue: true }, - ], - valueType: 'select', - options: ['English', 'Spanish', 'French', 'German', 'Japanese', 'Chinese', 'Russian', 'Other'], - }, - { - id: 'format', - label: 'Format', - operators: [ - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'in', label: 'In', requiresValue: true }, - ], - valueType: 'select', - options: ['Ebook', 'Audiobook', 'Comic', 'Manga', 'Magazine'], - }, - { - id: 'tags', - label: 'Tags', - operators: [ - { id: 'contains', label: 'Contains', requiresValue: true }, - { id: 'not_contains', label: 'Does Not Contain', requiresValue: true }, - { id: 'equals', label: 'Equals', requiresValue: true }, - ], - valueType: 'text', - }, - { - id: 'narrators', - label: 'Narrators (Audiobooks)', - operators: [ - { id: 'contains', label: 'Contains', requiresValue: true }, - { id: 'equals', label: 'Equals', requiresValue: true }, - { id: 'is_set', label: 'Is Set', requiresValue: false }, - { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, - ], - valueType: 'text', - }, + { + id: "title", + label: "Title", + operators: [ + { id: "contains", label: "Contains", requiresValue: true }, + { id: "equals", label: "Equals", requiresValue: true }, + { id: "starts_with", label: "Starts With", requiresValue: true }, + { id: "ends_with", label: "Ends With", requiresValue: true }, + { id: "regex", label: "Matches Regex", requiresValue: true }, + ], + valueType: "text", + }, + { + id: "author", + label: "Author", + operators: [ + { id: "contains", label: "Contains", requiresValue: true }, + { id: "equals", label: "Equals", requiresValue: true }, + ], + valueType: "text", + }, + { + id: "genre", + label: "Genre", + operators: [ + { id: "equals", label: "Equals", requiresValue: true }, + { id: "not_equals", label: "Not Equals", requiresValue: true }, + { id: "in", label: "In", requiresValue: true }, + { id: "not_in", label: "Not In", requiresValue: true }, + ], + valueType: "select", + options: [ + "Fiction", + "Non-Fiction", + "Sci-Fi", + "Fantasy", + "Mystery", + "Romance", + "Thriller", + "Biography", + "History", + "Self-Help", + ], + }, + { + id: "series", + label: "Series", + operators: [ + { id: "is_set", label: "Is Set", requiresValue: false }, + { id: "is_not_set", label: "Is Not Set", requiresValue: false }, + { id: "equals", label: "Equals", requiresValue: true }, + { id: "contains", label: "Contains", requiresValue: true }, + ], + valueType: "text", + }, + { + id: "progress", + label: "Reading Progress", + operators: [ + { id: "equals", label: "Equals", requiresValue: true }, + { id: "not_equals", label: "Not Equals", requiresValue: true }, + { id: "greater_than", label: "Greater Than", requiresValue: true }, + { id: "less_than", label: "Less Than", requiresValue: true }, + { id: "between", label: "Between", requiresValue: true }, + { id: "is_set", label: "Is Set", requiresValue: false }, + { id: "is_not_set", label: "Is Not Set", requiresValue: false }, + ], + valueType: "number", + }, + { + id: "rating", + label: "Rating", + operators: [ + { id: "equals", label: "Equals", requiresValue: true }, + { id: "not_equals", label: "Not Equals", requiresValue: true }, + { id: "greater_than", label: "Greater Than", requiresValue: true }, + { id: "less_than", label: "Less Than", requiresValue: true }, + { id: "is_set", label: "Is Set", requiresValue: false }, + { id: "is_not_set", label: "Is Not Set", requiresValue: false }, + ], + valueType: "number", + }, + { + id: "date_added", + label: "Date Added", + operators: [ + { id: "equals", label: "Equals", requiresValue: true }, + { id: "not_equals", label: "Not Equals", requiresValue: true }, + { id: "before", label: "Before", requiresValue: true }, + { id: "after", label: "After", requiresValue: true }, + { id: "between", label: "Between", requiresValue: true }, + { id: "last_x_days", label: "Last X Days", requiresValue: true }, + ], + valueType: "date", + }, + { + id: "last_read", + label: "Last Read Date", + operators: [ + { id: "equals", label: "Equals", requiresValue: true }, + { id: "before", label: "Before", requiresValue: true }, + { id: "after", label: "After", requiresValue: true }, + { id: "between", label: "Between", requiresValue: true }, + { id: "last_x_days", label: "Last X Days", requiresValue: true }, + { id: "is_set", label: "Is Set", requiresValue: false }, + { id: "is_not_set", label: "Is Not Set", requiresValue: false }, + ], + valueType: "date", + }, + { + id: "publisher", + label: "Publisher", + operators: [ + { id: "contains", label: "Contains", requiresValue: true }, + { id: "equals", label: "Equals", requiresValue: true }, + ], + valueType: "text", + }, + { + id: "language", + label: "Language", + operators: [ + { id: "equals", label: "Equals", requiresValue: true }, + { id: "not_equals", label: "Not Equals", requiresValue: true }, + { id: "in", label: "In", requiresValue: true }, + ], + valueType: "select", + options: [ + "English", + "Spanish", + "French", + "German", + "Japanese", + "Chinese", + "Russian", + "Other", + ], + }, + { + id: "format", + label: "Format", + operators: [ + { id: "equals", label: "Equals", requiresValue: true }, + { id: "in", label: "In", requiresValue: true }, + ], + valueType: "select", + options: ["Ebook", "Audiobook", "Comic", "Manga", "Magazine"], + }, + { + id: "tags", + label: "Tags", + operators: [ + { id: "contains", label: "Contains", requiresValue: true }, + { id: "not_contains", label: "Does Not Contain", requiresValue: true }, + { id: "equals", label: "Equals", requiresValue: true }, + ], + valueType: "text", + }, + { + id: "narrators", + label: "Narrators (Audiobooks)", + operators: [ + { id: "contains", label: "Contains", requiresValue: true }, + { id: "equals", label: "Equals", requiresValue: true }, + { id: "is_set", label: "Is Set", requiresValue: false }, + { id: "is_not_set", label: "Is Not Set", requiresValue: false }, + ], + valueType: "text", + }, ]; let ruleCounter = 0; @@ -177,64 +197,64 @@ let selectedBooks: Map = new Map(); let customSectionTimeout: number | null = null; function initCustomSectionBuilder(): void { - const addRuleBtn = document.getElementById('add-rule-btn'); - const previewBtn = document.getElementById('preview-btn'); - const searchBtn = document.getElementById('search-books-btn'); - const bookSearchInput = document.getElementById('book-search'); - const cancelBtn = document.getElementById('cancel-btn'); - const form = document.getElementById('custom-section-form'); + const addRuleBtn = document.getElementById("add-rule-btn"); + const previewBtn = document.getElementById("preview-btn"); + const searchBtn = document.getElementById("search-books-btn"); + const bookSearchInput = document.getElementById("book-search"); + const cancelBtn = document.getElementById("cancel-btn"); + const form = document.getElementById("custom-section-form"); - if (addRuleBtn) { - addRuleBtn.addEventListener('click', addFilterRule); - } + if (addRuleBtn) { + addRuleBtn.addEventListener("click", addFilterRule); + } - if (previewBtn) { - previewBtn.addEventListener('click', loadPreview); - } + if (previewBtn) { + previewBtn.addEventListener("click", loadPreview); + } - if (searchBtn) { - searchBtn.addEventListener('click', searchBooks); - } + if (searchBtn) { + searchBtn.addEventListener("click", searchBooks); + } - if (bookSearchInput) { - bookSearchInput.addEventListener('input', onBookSearchInput); - bookSearchInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - searchBooks(); - } - }); - } + if (bookSearchInput) { + bookSearchInput.addEventListener("input", onBookSearchInput); + bookSearchInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + searchBooks(); + } + }); + } - if (cancelBtn) { - cancelBtn.addEventListener('click', () => { - window.location.href = '/dashboard'; - }); - } + if (cancelBtn) { + cancelBtn.addEventListener("click", () => { + window.location.href = "/dashboard"; + }); + } - if (form) { - form.addEventListener('submit', saveCustomSection); - } + if (form) { + form.addEventListener("submit", saveCustomSection); + } } function addFilterRule(): void { - const container = document.getElementById('rules-container'); - if (!container) return; + const container = document.getElementById("rules-container"); + if (!container) return; - ruleCounter++; - const ruleId = `rule-${ruleCounter}`; + ruleCounter++; + const ruleId = `rule-${ruleCounter}`; - const ruleElement = document.createElement('div'); - ruleElement.className = 'rule-item p-3 rounded border'; - ruleElement.dataset.ruleId = ruleId; - ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`; + const ruleElement = document.createElement("div"); + ruleElement.className = "rule-item p-3 rounded border"; + ruleElement.dataset.ruleId = ruleId; + ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`; - ruleElement.innerHTML = ` + ruleElement.innerHTML = `
`; - container.appendChild(ruleElement); + container.appendChild(ruleElement); - const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement; - const removeBtn = ruleElement.querySelector('.remove-rule-btn') as HTMLButtonElement; + const fieldSelect = ruleElement.querySelector( + ".field-select", + ) as HTMLSelectElement; + const removeBtn = ruleElement.querySelector( + ".remove-rule-btn", + ) as HTMLButtonElement; - fieldSelect.addEventListener('change', () => onFieldChange(ruleElement)); - removeBtn.addEventListener('click', () => removeFilterRule(ruleId)); + fieldSelect.addEventListener("change", () => onFieldChange(ruleElement)); + removeBtn.addEventListener("click", () => removeFilterRule(ruleId)); } function onFieldChange(ruleElement: HTMLElement): void { - const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement; - const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement; - const valueInput = ruleElement.querySelector('.value-input') as HTMLInputElement; + const fieldSelect = ruleElement.querySelector( + ".field-select", + ) as HTMLSelectElement; + const operatorSelect = ruleElement.querySelector( + ".operator-select", + ) as HTMLSelectElement; + const valueInput = ruleElement.querySelector( + ".value-input", + ) as HTMLInputElement; - const fieldId = fieldSelect.value; - const field = FILTER_FIELDS.find(f => f.id === fieldId); + const fieldId = fieldSelect.value; + const field = FILTER_FIELDS.find((f) => f.id === fieldId); - operatorSelect.innerHTML = field - ? field.operators.map(op => ``).join('') - : ''; + operatorSelect.innerHTML = field + ? field.operators + .map((op) => ``) + .join("") + : ''; - operatorSelect.disabled = !field; + operatorSelect.disabled = !field; - if (field && field.operators.some(op => op.id === operatorSelect.value && op.requiresValue)) { - valueInput.classList.remove('hidden'); + if ( + field && + field.operators.some( + (op) => op.id === operatorSelect.value && op.requiresValue, + ) + ) { + valueInput.classList.remove("hidden"); - if (field.valueType === 'select' && field.options) { - valueInput.type = 'select'; - } else if (field.valueType === 'number') { - valueInput.type = 'number'; - valueInput.step = '0.01'; - } else if (field.valueType === 'date') { - valueInput.type = 'date'; - } else { - valueInput.type = 'text'; - } + if (field.valueType === "select" && field.options) { + valueInput.type = "select"; + } else if (field.valueType === "number") { + valueInput.type = "number"; + valueInput.step = "0.01"; + } else if (field.valueType === "date") { + valueInput.type = "date"; } else { - valueInput.classList.add('hidden'); + valueInput.type = "text"; } + } else { + valueInput.classList.add("hidden"); + } - operatorSelect.addEventListener('change', () => { - const selectedOp = field?.operators.find(op => op.id === operatorSelect.value); - if (selectedOp?.requiresValue) { - valueInput.classList.remove('hidden'); - } else { - valueInput.classList.add('hidden'); - } - }); + operatorSelect.addEventListener("change", () => { + const selectedOp = field?.operators.find( + (op) => op.id === operatorSelect.value, + ); + if (selectedOp?.requiresValue) { + valueInput.classList.remove("hidden"); + } else { + valueInput.classList.add("hidden"); + } + }); } function removeFilterRule(ruleId: string): void { - const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`); - if (ruleElement) { - ruleElement.remove(); - } + const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`); + if (ruleElement) { + ruleElement.remove(); + } } function onBookSearchInput(): void { - if (customSectionTimeout) { - clearTimeout(customSectionTimeout); - } - customSectionTimeout = window.setTimeout(() => { - searchBooks(); - }, 300); + if (customSectionTimeout) { + clearTimeout(customSectionTimeout); + } + customSectionTimeout = window.setTimeout(() => { + searchBooks(); + }, 300); } async function searchBooks(): Promise { - const searchInput = document.getElementById('book-search') as HTMLInputElement; - const librarySelect = document.getElementById('section-library') as HTMLSelectElement; - const resultsContainer = document.getElementById('search-results') as HTMLElement; + const searchInput = document.getElementById( + "book-search", + ) as HTMLInputElement; + const librarySelect = document.getElementById( + "section-library", + ) as HTMLSelectElement; + const resultsContainer = document.getElementById( + "search-results", + ) as HTMLElement; - const query = searchInput?.value.trim(); - const libraryId = librarySelect?.value; + const query = searchInput?.value.trim(); + const libraryId = librarySelect?.value; - if (!query || !libraryId) { - if (resultsContainer) resultsContainer.classList.add('hidden'); - return; + if (!query || !libraryId) { + if (resultsContainer) resultsContainer.classList.add("hidden"); + return; + } + + try { + const response = await fetch( + `/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to search books"); } - try { - const response = await fetch(`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error('Failed to search books'); - } - - const data = await response.json(); - displaySearchResults(data.books || []); - } catch (error) { - console.error('Search books error:', error); - (window as any).showToast?.error('Failed to search books'); - } + const data = await response.json(); + displaySearchResults(data.books || []); + } catch (error) { + console.error("Search books error:", error); + (window as any).showToast?.error("Failed to search books"); + } } function displaySearchResults(books: BookInfo[]): void { - const resultsContainer = document.getElementById('search-results') as HTMLElement; - if (!resultsContainer) return; + const resultsContainer = document.getElementById( + "search-results", + ) as HTMLElement; + if (!resultsContainer) return; - if (books.length === 0) { - resultsContainer.innerHTML = '

No books found

'; - } else { - resultsContainer.innerHTML = books.map(book => ` + if (books.length === 0) { + resultsContainer.innerHTML = + '

No books found

'; + } else { + resultsContainer.innerHTML = books + .map( + (book) => `
- ${builderEscapeHtml(book.title)}
@@ -371,197 +424,228 @@ function displaySearchResults(books: BookInfo[]): void {
- `).join(''); - } + `, + ) + .join(""); + } - resultsContainer.classList.remove('hidden'); + resultsContainer.classList.remove("hidden"); } -(window as any).addBookToSelection = function(bookId: string, title: string, author: string): void { - if (selectedBooks.has(bookId)) { - (window as any).showToast?.warning('Book already selected'); - return; - } +(window as any).addBookToSelection = function ( + bookId: string, + title: string, + author: string, +): void { + if (selectedBooks.has(bookId)) { + (window as any).showToast?.warning("Book already selected"); + return; + } - selectedBooks.set(bookId, { - media_item_id: bookId, - title: title, - author: author, - cover_image_path: '', - }); + selectedBooks.set(bookId, { + media_item_id: bookId, + title: title, + author: author, + cover_image_path: "", + }); - updateSelectedBooksDisplay(); + updateSelectedBooksDisplay(); }; -(window as any).removeBookFromSelection = function(bookId: string): void { - selectedBooks.delete(bookId); - updateSelectedBooksDisplay(); +(window as any).removeBookFromSelection = function (bookId: string): void { + selectedBooks.delete(bookId); + updateSelectedBooksDisplay(); }; function updateSelectedBooksDisplay(): void { - const container = document.getElementById('selected-books') as HTMLElement; - if (!container) return; + const container = document.getElementById("selected-books") as HTMLElement; + if (!container) return; - if (selectedBooks.size === 0) { - container.innerHTML = '

No books selected

'; - return; - } + if (selectedBooks.size === 0) { + container.innerHTML = + '

No books selected

'; + return; + } - container.innerHTML = Array.from(selectedBooks.values()).map(book => ` + container.innerHTML = Array.from(selectedBooks.values()) + .map( + (book) => `
${builderEscapeHtml(book.title)}
- `).join(''); + `, + ) + .join(""); } async function loadPreview(): Promise { - const previewContainer = document.getElementById('preview-container') as HTMLElement; - const librarySelect = document.getElementById('section-library') as HTMLSelectElement; - const libraryId = librarySelect?.value; + const previewContainer = document.getElementById( + "preview-container", + ) as HTMLElement; + const librarySelect = document.getElementById( + "section-library", + ) as HTMLSelectElement; + const libraryId = librarySelect?.value; - if (!libraryId) { - (window as any).showToast?.error('Please select a library first'); - return; - } + if (!libraryId) { + (window as any).showToast?.error("Please select a library first"); + return; + } - const rules = gatherFilterRules(); - const manualBookIds = Array.from(selectedBooks.keys()); + const rules = gatherFilterRules(); + const manualBookIds = Array.from(selectedBooks.keys()); - previewContainer.innerHTML = '
'; + previewContainer.innerHTML = + '
'; - try { - const response = await (window as any).api.post('/collections/preview', { - library_id: libraryId, - rules: rules, - manual_book_ids: manualBookIds, - limit: 20, - }); + try { + const response = await (window as any).api.post("/collections/preview", { + library_id: libraryId, + rules: rules, + manual_book_ids: manualBookIds, + limit: 20, + }); - if (response.ok) { - const data = await response.json(); - displayPreview(data.items || []); - } else { - throw new Error('Failed to load preview'); - } - } catch (error) { - console.error('Preview error:', error); - previewContainer.innerHTML = '

Failed to load preview

'; + if (response.ok) { + const data = await response.json(); + displayPreview(data.items || []); + } else { + throw new Error("Failed to load preview"); } + } catch (error) { + console.error("Preview error:", error); + previewContainer.innerHTML = + '

Failed to load preview

'; + } } function gatherFilterRules(): FilterRule[] { - const container = document.getElementById('rules-container') as HTMLElement; - if (!container) return []; + const container = document.getElementById("rules-container") as HTMLElement; + if (!container) return []; - const ruleElements = container.querySelectorAll('.rule-item'); - const rules: FilterRule[] = []; + const ruleElements = container.querySelectorAll(".rule-item"); + const rules: FilterRule[] = []; - ruleElements.forEach((element, index) => { - const fieldSelect = element.querySelector('.field-select') as HTMLSelectElement; - const operatorSelect = element.querySelector('.operator-select') as HTMLSelectElement; - const valueInput = element.querySelector('.value-input') as HTMLInputElement; + ruleElements.forEach((element, index) => { + const fieldSelect = element.querySelector( + ".field-select", + ) as HTMLSelectElement; + const operatorSelect = element.querySelector( + ".operator-select", + ) as HTMLSelectElement; + const valueInput = element.querySelector( + ".value-input", + ) as HTMLInputElement; - if (fieldSelect.value && operatorSelect.value) { - rules.push({ - id: `rule-${index}`, - field: fieldSelect.value, - operator: operatorSelect.value, - value: valueInput.value, - priority: index, - }); - } - }); + if (fieldSelect.value && operatorSelect.value) { + rules.push({ + id: `rule-${index}`, + field: fieldSelect.value, + operator: operatorSelect.value, + value: valueInput.value, + priority: index, + }); + } + }); - return rules; + return rules; } function displayPreview(items: BookInfo[]): void { - const previewContainer = document.getElementById('preview-container') as HTMLElement; - if (!previewContainer) return; + const previewContainer = document.getElementById( + "preview-container", + ) as HTMLElement; + if (!previewContainer) return; - if (items.length === 0) { - previewContainer.innerHTML = '

No items match your criteria

'; - return; - } + if (items.length === 0) { + previewContainer.innerHTML = + '

No items match your criteria

'; + return; + } - previewContainer.innerHTML = ` + previewContainer.innerHTML = `
- ${items.map(item => ` + ${items + .map( + (item) => `
- ${builderEscapeHtml(item.title)}

${builderEscapeHtml(item.title)}

- ${item.author ? `

${builderEscapeHtml(item.author)}

` : ''} + ${item.author ? `

${builderEscapeHtml(item.author)}

` : ""}
- `).join('')} + `, + ) + .join("")}

- ${items.length} item${items.length !== 1 ? 's' : ''} will be shown + ${items.length} item${items.length !== 1 ? "s" : ""} will be shown

`; } async function saveCustomSection(event: Event): Promise { - event.preventDefault(); + event.preventDefault(); - const formData = new FormData(event.target as HTMLFormElement); - const libraryId = formData.get('library_id') as string; - const name = formData.get('name') as string; - const icon = formData.get('icon') as string; - const description = formData.get('description') as string; - const matchType = (document.getElementById('match-type') as HTMLSelectElement).value; + const formData = new FormData(event.target as HTMLFormElement); + const libraryId = formData.get("library_id") as string; + const name = formData.get("name") as string; + const icon = formData.get("icon") as string; + const description = formData.get("description") as string; + const matchType = (document.getElementById("match-type") as HTMLSelectElement) + .value; - if (!libraryId || !name) { - (window as any).showToast?.error('Please fill in required fields'); - return; - } + if (!libraryId || !name) { + (window as any).showToast?.error("Please fill in required fields"); + return; + } - const rules = gatherFilterRules(); - const manualBookIds = Array.from(selectedBooks.keys()); + const rules = gatherFilterRules(); + const manualBookIds = Array.from(selectedBooks.keys()); - if (rules.length === 0 && manualBookIds.length === 0) { - (window as any).showToast?.error('Please add filter rules or select books'); - return; - } + if (rules.length === 0 && manualBookIds.length === 0) { + (window as any).showToast?.error("Please add filter rules or select books"); + return; + } - try { - const response = await (window as any).api.post('/collections', { - library_id: libraryId, - name: name, - icon: icon, - description: description, - show_on_dashboard: true, - auto_assign_rules: JSON.stringify(rules), - manual_book_ids: manualBookIds, - match_type: matchType, - }); + try { + const response = await (window as any).api.post("/collections", { + library_id: libraryId, + name: name, + icon: icon, + description: description, + show_on_dashboard: true, + auto_assign_rules: JSON.stringify(rules), + manual_book_ids: manualBookIds, + match_type: matchType, + }); - if (response.ok) { - (window as any).showToast?.success('Custom section created successfully'); - setTimeout(() => { - window.location.href = '/dashboard'; - }, 1000); - } else { - throw new Error('Failed to save custom section'); - } - } catch (error) { - console.error('Save custom section error:', error); - (window as any).showToast?.error('Failed to save custom section'); + if (response.ok) { + (window as any).showToast?.success("Custom section created successfully"); + setTimeout(() => { + window.location.href = "/dashboard"; + }, 1000); + } else { + throw new Error("Failed to save custom section"); } + } catch (error) { + console.error("Save custom section error:", error); + (window as any).showToast?.error("Failed to save custom section"); + } } function builderEscapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } -document.addEventListener('DOMContentLoaded', initCustomSectionBuilder); +document.addEventListener("DOMContentLoaded", initCustomSectionBuilder); diff --git a/web/src/docs.ts b/web/src/docs.ts index 87af596..26395aa 100644 --- a/web/src/docs.ts +++ b/web/src/docs.ts @@ -1,86 +1,94 @@ function toggleSidebar(): void { - const sidebar = document.getElementById('docs-sidebar'); - const overlay = document.getElementById('docs-overlay'); + 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'); - } + 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'); + const searchInput = document.getElementById( + "docs-search", + ) as HTMLInputElement; + const searchResults = document.getElementById("docs-search-results"); - if (!searchInput || !searchResults) return; + if (!searchInput || !searchResults) return; - let docsSearchTimeout: ReturnType | null = null; + let docsSearchTimeout: ReturnType | null = null; - searchInput.addEventListener('input', () => { - const query = searchInput.value.trim(); + searchInput.addEventListener("input", () => { + const query = searchInput.value.trim(); - if (docsSearchTimeout) { - clearTimeout(docsSearchTimeout); - } + if (docsSearchTimeout) { + clearTimeout(docsSearchTimeout); + } - if (query.length < 2) { - searchResults.innerHTML = ''; - searchResults.classList.add('hidden'); - return; - } + if (query.length < 2) { + searchResults.innerHTML = ""; + searchResults.classList.add("hidden"); + return; + } - docsSearchTimeout = setTimeout(() => { - performDocsSearch(query); - }, 300); - }); + docsSearchTimeout = setTimeout(() => { + performDocsSearch(query); + }, 300); + }); } function performDocsSearch(query: string): void { - const searchResults = document.getElementById('docs-search-results'); - if (!searchResults) return; + const searchResults = document.getElementById("docs-search-results"); + if (!searchResults) return; - if (!(window as any).lunr) { - console.warn('Lunr.js not loaded'); - 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; } - 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); - 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 ""; - 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 ` + return `

${doc.title || result.ref}

- ${doc.section ? `

${doc.section}

` : ''} + ${doc.section ? `

${doc.section}

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

Search error

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

Search error

'; + searchResults.classList.remove("hidden"); + } } -document.addEventListener('DOMContentLoaded', () => { - initializeDocsSearch(); +document.addEventListener("DOMContentLoaded", () => { + initializeDocsSearch(); }); (window as any).toggleSidebar = toggleSidebar; diff --git a/web/src/dom.ts b/web/src/dom.ts index 96b3170..f61c099 100644 --- a/web/src/dom.ts +++ b/web/src/dom.ts @@ -1,137 +1,137 @@ function escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } function querySelector(selector: string): T | null { - return document.querySelector(selector); + return document.querySelector(selector); } function querySelectorAll(selector: string): NodeListOf { - return document.querySelectorAll(selector); + return document.querySelectorAll(selector); } function getElementById(id: string): T | null { - return document.getElementById(id) as T | null; + return document.getElementById(id) as T | null; } function createElement( - tagName: K, - attributes?: Record, - children?: (string | Node)[] + tagName: K, + attributes?: Record, + children?: (string | Node)[], ): HTMLElementTagNameMap[K] { - const element = document.createElement(tagName); + const element = document.createElement(tagName); - if (attributes) { - Object.entries(attributes).forEach(([key, value]) => { - if (key === 'className') { - element.className = value; - } else if (key === 'dataset') { - Object.entries(JSON.parse(value)).forEach(([dataKey, dataValue]) => { - element.dataset[dataKey] = String(dataValue); - }); - } else { - element.setAttribute(key, value); - } + if (attributes) { + Object.entries(attributes).forEach(([key, value]) => { + if (key === "className") { + element.className = value; + } else if (key === "dataset") { + Object.entries(JSON.parse(value)).forEach(([dataKey, dataValue]) => { + element.dataset[dataKey] = String(dataValue); }); - } + } else { + element.setAttribute(key, value); + } + }); + } - if (children) { - children.forEach(child => { - if (typeof child === 'string') { - element.appendChild(document.createTextNode(child)); - } else { - element.appendChild(child); - } - }); - } + if (children) { + children.forEach((child) => { + if (typeof child === "string") { + element.appendChild(document.createTextNode(child)); + } else { + element.appendChild(child); + } + }); + } - return element; + return element; } function showElement(element: HTMLElement | null): void { - if (element) { - element.classList.remove('hidden'); - } + if (element) { + element.classList.remove("hidden"); + } } function hideElement(element: HTMLElement | null): void { - if (element) { - element.classList.add('hidden'); - } + if (element) { + element.classList.add("hidden"); + } } function toggleElement(element: HTMLElement | null): void { - if (element) { - element.classList.toggle('hidden'); - } + if (element) { + element.classList.toggle("hidden"); + } } function setTextContent(element: HTMLElement | null, text: string): void { - if (element) { - element.textContent = text; - } + if (element) { + element.textContent = text; + } } function setInnerHTML(element: HTMLElement | null, html: string): void { - if (element) { - element.innerHTML = html; - } + if (element) { + element.innerHTML = html; + } } function addClass(element: HTMLElement | null, className: string): void { - if (element) { - element.classList.add(className); - } + if (element) { + element.classList.add(className); + } } function removeClass(element: HTMLElement | null, className: string): void { - if (element) { - element.classList.remove(className); - } + if (element) { + element.classList.remove(className); + } } function toggleClass(element: HTMLElement | null, className: string): void { - if (element) { - element.classList.toggle(className); - } + if (element) { + element.classList.toggle(className); + } } function hasClass(element: HTMLElement | null, className: string): boolean { - return element ? element.classList.contains(className) : false; + return element ? element.classList.contains(className) : false; } (window as any).dom = { - escapeHtml, - querySelector, - querySelectorAll, - getElementById, - createElement, - showElement, - hideElement, - toggleElement, - setTextContent, - setInnerHTML, - addClass, - removeClass, - toggleClass, - hasClass + escapeHtml, + querySelector, + querySelectorAll, + getElementById, + createElement, + showElement, + hideElement, + toggleElement, + setTextContent, + setInnerHTML, + addClass, + removeClass, + toggleClass, + hasClass, }; export { - escapeHtml, - querySelector, - querySelectorAll, - getElementById, - createElement, - showElement, - hideElement, - toggleElement, - setTextContent, - setInnerHTML, - addClass, - removeClass, - toggleClass, - hasClass + escapeHtml, + querySelector, + querySelectorAll, + getElementById, + createElement, + showElement, + hideElement, + toggleElement, + setTextContent, + setInnerHTML, + addClass, + removeClass, + toggleClass, + hasClass, }; diff --git a/web/src/header.ts b/web/src/header.ts index fe4893d..11176cd 100644 --- a/web/src/header.ts +++ b/web/src/header.ts @@ -1,82 +1,88 @@ // Header functionality const toggleThemeDropdown = (): void => { - const dropdown = document.getElementById('theme-dropdown'); - if (dropdown) { - dropdown.classList.toggle('hidden'); - - // Close user menu if open - const userMenu = document.getElementById('user-menu'); - if (userMenu && !dropdown.classList.contains('hidden')) { - userMenu.classList.add('hidden'); - } + const dropdown = document.getElementById("theme-dropdown"); + if (dropdown) { + dropdown.classList.toggle("hidden"); + + // Close user menu if open + const userMenu = document.getElementById("user-menu"); + if (userMenu && !dropdown.classList.contains("hidden")) { + userMenu.classList.add("hidden"); } + } }; const toggleUserMenu = (): void => { - const menu = document.getElementById('user-menu'); - if (menu) { - menu.classList.toggle('hidden'); - - // Close theme dropdown if open - const themeDropdown = document.getElementById('theme-dropdown'); - if (themeDropdown && !menu.classList.contains('hidden')) { - themeDropdown.classList.add('hidden'); - } + const menu = document.getElementById("user-menu"); + if (menu) { + menu.classList.toggle("hidden"); + + // Close theme dropdown if open + const themeDropdown = document.getElementById("theme-dropdown"); + if (themeDropdown && !menu.classList.contains("hidden")) { + themeDropdown.classList.add("hidden"); } + } }; const changeThemeTo = (theme: string): void => { - // Apply the theme using the consolidated function from theme.ts - if ((window as any).applyTheme) { - (window as any).applyTheme(theme); - } + // Apply the theme using the consolidated function from theme.ts + if ((window as any).applyTheme) { + (window as any).applyTheme(theme); + } - // Save to server if logged in - const token = localStorage.getItem('token'); - if (token) { - fetch('/api/auth/theme', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ theme }) - }).catch(err => console.log('Theme save failed', err)); - } + // Save to server if logged in + const token = localStorage.getItem("token"); + if (token) { + fetch("/api/auth/theme", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ theme }), + }).catch((err) => console.log("Theme save failed", err)); + } - // Close dropdown - const dropdown = document.getElementById('theme-dropdown'); - if (dropdown) { - dropdown.classList.add('hidden'); - } + // Close dropdown + const dropdown = document.getElementById("theme-dropdown"); + if (dropdown) { + dropdown.classList.add("hidden"); + } }; const logout = (): void => { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - window.location.href = '/'; + localStorage.removeItem("token"); + localStorage.removeItem("user"); + window.location.href = "/"; }; // Close dropdowns when clicking outside -document.addEventListener('click', (e) => { - const target = e.target as HTMLElement; - const themeDropdown = document.getElementById('theme-dropdown'); - const userMenu = document.getElementById('user-menu'); - const themeButton = target?.closest('button[onclick="toggleThemeDropdown()"]'); - const userButton = target?.closest('button[onclick="toggleUserMenu()"]'); - - if (!themeButton && themeDropdown && !themeDropdown.classList.contains('hidden')) { - if (!themeDropdown.contains(target)) { - themeDropdown.classList.add('hidden'); - } +document.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const themeDropdown = document.getElementById("theme-dropdown"); + const userMenu = document.getElementById("user-menu"); + const themeButton = target?.closest( + 'button[onclick="toggleThemeDropdown()"]', + ); + const userButton = target?.closest('button[onclick="toggleUserMenu()"]'); + + if ( + !themeButton && + themeDropdown && + !themeDropdown.classList.contains("hidden") + ) { + if (!themeDropdown.contains(target)) { + themeDropdown.classList.add("hidden"); } - - if (!userButton && userMenu && !userMenu.classList.contains('hidden')) { - if (!userMenu.contains(target)) { - userMenu.classList.add('hidden'); - } + } + + if (!userButton && userMenu && !userMenu.classList.contains("hidden")) { + if (!userMenu.contains(target)) { + userMenu.classList.add("hidden"); } + } }); // Make functions available globally diff --git a/web/src/library.ts b/web/src/library.ts index abc23bd..6fec3a2 100644 --- a/web/src/library.ts +++ b/web/src/library.ts @@ -2,43 +2,43 @@ // Procedural/imperative style (no OOP) interface Library { - id: string; - name: string; - description: string | null; - library_type_id: string; - type_name: string; - created_at: string; - updated_at: string; + id: string; + name: string; + description: string | null; + library_type_id: string; + type_name: string; + created_at: string; + updated_at: string; } interface LibraryFolder { - id: string; - library_id: string; - folder_path: string; - created_at: string; + id: string; + library_id: string; + folder_path: string; + created_at: string; } interface DeleteFolderRequest { - folder_path: string; + folder_path: string; } interface User { - id: string; - username: string; - email: string; - role: string; + id: string; + username: string; + email: string; + role: string; } interface LibrariesResponse { - data: Library[]; + data: Library[]; } interface VisibleLibrariesResponse { - data: Array<{ - id: string; - name: string; - type_name: string; - }>; + data: Array<{ + id: string; + name: string; + type_name: string; + }>; } // State (already SSR'd, used for updates) @@ -47,305 +47,368 @@ let users: User[] = []; // Reload libraries from API (called after create/delete/update) async function reloadLibraries(): Promise { - try { - const response = await (window as any).api.get('/libraries'); - const result = await (window as any).api.handleResponse(response) as LibrariesResponse; - libraries = result.data; - renderLibraries(); - } catch (error) { - (window as any).api.handleError(error, 'Failed to load libraries'); - } + try { + const response = await (window as any).api.get("/libraries"); + const result = (await (window as any).api.handleResponse( + response, + )) as LibrariesResponse; + libraries = result.data; + renderLibraries(); + } catch (error) { + (window as any).api.handleError(error, "Failed to load libraries"); + } } // Render libraries list (replaces SSR content after updates) function renderLibraries(): void { - const container = document.getElementById('libraries-list'); - if (!container) return; + const container = document.getElementById("libraries-list"); + if (!container) return; - if (libraries.length === 0) { - container.innerHTML = '

No libraries yet. Create your first library to get started.

'; - return; - } + if (libraries.length === 0) { + container.innerHTML = + '

No libraries yet. Create your first library to get started.

'; + return; + } - container.innerHTML = libraries.map(library => + container.innerHTML = libraries + .map( + (library) => '
' + - '
' + - '
' + - `

${escapeHtmlLocal(library.name)}

` + - (library.description ? `

${escapeHtmlLocal(library.description)}

` : '') + - `${escapeHtmlLocal(library.type_name)}` + - '
' + - '
' + - `` + - `` + - `` + - '
' + - '
' + - `` + - '
' - ).join(''); + '
' + + "
" + + `

${escapeHtmlLocal(library.name)}

` + + (library.description + ? `

${escapeHtmlLocal(library.description)}

` + : "") + + `${escapeHtmlLocal(library.type_name)}` + + "
" + + '
' + + `` + + `` + + `` + + "
" + + "
" + + `` + + "
", + ) + .join(""); } // Load user's visible libraries for visibility management async function loadUserVisibility(): Promise { - const select = document.getElementById('user-select') as HTMLSelectElement; - const userId = select?.value; - if (!userId) { - const container = document.getElementById('user-libraries'); - if (container) { - container.innerHTML = '

Please select a user

'; - } - return; + const select = document.getElementById("user-select") as HTMLSelectElement; + const userId = select?.value; + if (!userId) { + const container = document.getElementById("user-libraries"); + if (container) { + container.innerHTML = + '

Please select a user

'; } + return; + } - try { - const response = await (window as any).api.get('/libraries/visible'); - const visibleLibraries = await (window as any).api.handleResponse(response) as Library[]; - const container = document.getElementById('user-libraries'); - if (!container) return; + try { + const response = await (window as any).api.get("/libraries/visible"); + const visibleLibraries = (await (window as any).api.handleResponse( + response, + )) as Library[]; + const container = document.getElementById("user-libraries"); + if (!container) return; - const visibleIds = new Set(visibleLibraries.map((lib: Library) => lib.id)); + const visibleIds = new Set(visibleLibraries.map((lib: Library) => lib.id)); - container.innerHTML = libraries.map(library => { - const isVisible = visibleIds.has(library.id); - return ''; - }).join(''); - } catch (error) { - (window as any).api.handleError(error, 'Failed to load user libraries'); - } + container.innerHTML = libraries + .map((library) => { + const isVisible = visibleIds.has(library.id); + return ( + '" + ); + }) + .join(""); + } catch (error) { + (window as any).api.handleError(error, "Failed to load user libraries"); + } } // Set library visibility for a user -async function setLibraryVisibility(userId: string, libraryId: string, isVisible: boolean): Promise { - // userId is used in the HTML onchange handler but the API gets user from JWT context - console.debug('Setting visibility for user:', userId, 'library:', libraryId, 'visible:', isVisible); +async function setLibraryVisibility( + userId: string, + libraryId: string, + isVisible: boolean, +): Promise { + // userId is used in the HTML onchange handler but the API gets user from JWT context + console.debug( + "Setting visibility for user:", + userId, + "library:", + libraryId, + "visible:", + isVisible, + ); - try { - const response = await (window as any).api.post('/libraries/visibility', { - library_id: libraryId, - is_visible: isVisible - }); - await (window as any).api.handleVoidResponse(response); + try { + const response = await (window as any).api.post("/libraries/visibility", { + library_id: libraryId, + is_visible: isVisible, + }); + await (window as any).api.handleVoidResponse(response); - if ((window as any).showToast?.success) { - (window as any).showToast.success('Library visibility updated'); - } - - // Refresh visibility controls - void loadUserVisibility(); - } catch (error) { - (window as any).api.handleError(error, 'Failed to update library visibility'); + if ((window as any).showToast?.success) { + (window as any).showToast.success("Library visibility updated"); } + + // Refresh visibility controls + void loadUserVisibility(); + } catch (error) { + (window as any).api.handleError( + error, + "Failed to update library visibility", + ); + } } // Create library form handler async function handleCreateLibrarySubmit(event: Event): Promise { - event.preventDefault(); + event.preventDefault(); - const form = event.target as HTMLFormElement; - const formData = new FormData(form); + const form = event.target as HTMLFormElement; + const formData = new FormData(form); - const libraryId = (document.getElementById('library-id') as HTMLInputElement)?.value; - const isEdit = !!libraryId; + const libraryId = (document.getElementById("library-id") as HTMLInputElement) + ?.value; + const isEdit = !!libraryId; - const libraryData = { - name: formData.get('name') as string, - description: formData.get('description') as string, - type: formData.get('type') as string - }; + const libraryData = { + name: formData.get("name") as string, + description: formData.get("description") as string, + type: formData.get("type") as string, + }; - try { - const url = isEdit ? `/libraries/${libraryId}` : '/libraries'; - const method = isEdit ? 'put' : 'post'; + try { + const url = isEdit ? `/libraries/${libraryId}` : "/libraries"; + const method = isEdit ? "put" : "post"; - const response = await (window as any).api[method](url, libraryData); + const response = await (window as any).api[method](url, libraryData); - if (isEdit) { - await (window as any).api.handleVoidResponse(response); - } else { - await (window as any).api.handleResponse(response) as { data: Library }; - } - - if ((window as any).showToast?.success) { - (window as any).showToast.success(isEdit ? 'Library updated successfully' : 'Library created successfully'); - } - - hideCreateLibraryModal(); - form.reset(); - - const libraryIdInput = document.getElementById('library-id') as HTMLInputElement; - if (libraryIdInput) { - libraryIdInput.value = ''; - } - - void reloadLibraries(); - } catch (error) { - (window as any).api.handleError(error, isEdit ? 'Failed to update library' : 'Failed to create library'); + if (isEdit) { + await (window as any).api.handleVoidResponse(response); + } else { + (await (window as any).api.handleResponse(response)) as { data: Library }; } + + if ((window as any).showToast?.success) { + (window as any).showToast.success( + isEdit + ? "Library updated successfully" + : "Library created successfully", + ); + } + + hideCreateLibraryModal(); + form.reset(); + + const libraryIdInput = document.getElementById( + "library-id", + ) as HTMLInputElement; + if (libraryIdInput) { + libraryIdInput.value = ""; + } + + void reloadLibraries(); + } catch (error) { + (window as any).api.handleError( + error, + isEdit ? "Failed to update library" : "Failed to create library", + ); + } } // Delete library async function deleteLibrary(libraryId: string): Promise { - const library = libraries.find(l => l.id === libraryId); - if (!library) return; + const library = libraries.find((l) => l.id === libraryId); + if (!library) return; - showDeleteModal(library); + showDeleteModal(library); } // Show library folders async function showLibraryFolders(libraryId: string): Promise { - const container = document.getElementById(`library-folders-${libraryId}`); - if (!container) return; + const container = document.getElementById(`library-folders-${libraryId}`); + if (!container) return; - try { - const response = await (window as any).api.get(`/libraries/${libraryId}/folders`); - const folders = await (window as any).api.handleResponse(response) as LibraryFolder[]; + try { + const response = await (window as any).api.get( + `/libraries/${libraryId}/folders`, + ); + const folders = (await (window as any).api.handleResponse( + response, + )) as LibraryFolder[]; - container.innerHTML = folders.map((folder: LibraryFolder) => - '
' + - `${escapeHtmlLocal(folder.folder_path)}` + - `' + - '
' - ).join(''); + container.innerHTML = folders + .map( + (folder: LibraryFolder) => + '
' + + `${escapeHtmlLocal(folder.folder_path)}` + + `' + + "
", + ) + .join(""); - container.innerHTML += '
' + - `' + - `' + - `' + - '
'; + container.innerHTML += + '
' + + `' + + `' + + `' + + "
"; - container.classList.remove('hidden'); - } catch (error) { - (window as any).api.handleError(error, 'Failed to load folders'); - } + container.classList.remove("hidden"); + } catch (error) { + (window as any).api.handleError(error, "Failed to load folders"); + } } // Add library folder async function addLibraryFolder(libraryId: string): Promise { - const input = document.getElementById(`folder-path-${libraryId}`) as HTMLInputElement; - const folderPath = input?.value.trim(); + const input = document.getElementById( + `folder-path-${libraryId}`, + ) as HTMLInputElement; + const folderPath = input?.value.trim(); - if (!folderPath) { - return; + if (!folderPath) { + return; + } + + try { + const response = await (window as any).api.post( + `/libraries/${libraryId}/folders`, + { + folder_path: folderPath, + }, + ); + await (window as any).api.handleVoidResponse(response); + + if ((window as any).showToast?.success) { + (window as any).showToast.success("Folder added successfully"); } - try { - const response = await (window as any).api.post(`/libraries/${libraryId}/folders`, { - folder_path: folderPath - }); - await (window as any).api.handleVoidResponse(response); - - if ((window as any).showToast?.success) { - (window as any).showToast.success('Folder added successfully'); - } - - if (input) { - input.value = ''; - } - void showLibraryFolders(libraryId); // Refresh - } catch (error) { - (window as any).api.handleError(error, 'Failed to add folder'); + if (input) { + input.value = ""; } + void showLibraryFolders(libraryId); // Refresh + } catch (error) { + (window as any).api.handleError(error, "Failed to add folder"); + } } // Remove library folder -async function removeLibraryFolder(libraryId: string, folderPath: string): Promise { - if (!confirm(`Remove folder "${folderPath}" from the library?`)) { - return; +async function removeLibraryFolder( + libraryId: string, + folderPath: string, +): Promise { + if (!confirm(`Remove folder "${folderPath}" from the library?`)) { + return; + } + + try { + const response = await (window as any).api.delete( + `/libraries/${libraryId}/folders`, + { folder_path: folderPath }, + ); + await (window as any).api.handleVoidResponse(response); + + if ((window as any).showToast?.success) { + (window as any).showToast.success("Folder removed successfully"); } - try { - const response = await (window as any).api.delete( - `/libraries/${libraryId}/folders`, - { folder_path: folderPath } - ); - await (window as any).api.handleVoidResponse(response); - - if ((window as any).showToast?.success) { - (window as any).showToast.success('Folder removed successfully'); - } - - void showLibraryFolders(libraryId); // Refresh - } catch (error) { - (window as any).api.handleError(error, 'Failed to remove folder'); - } + void showLibraryFolders(libraryId); // Refresh + } catch (error) { + (window as any).api.handleError(error, "Failed to remove folder"); + } } // Edit library (placeholder - opens modal or navigates to edit page) function editLibrary(libraryId: string): void { - const library = libraries.find(l => l.id === libraryId); - if (!library) { - if ((window as any).showToast?.error) { - (window as any).showToast.error('Library not found'); - } - return; + const library = libraries.find((l) => l.id === libraryId); + if (!library) { + if ((window as any).showToast?.error) { + (window as any).showToast.error("Library not found"); } + return; + } - const form = document.getElementById('create-library-form') as HTMLFormElement; - if (form) { - const nameInput = form.querySelector('[name="name"]') as HTMLInputElement; - const descInput = form.querySelector('[name="description"]') as HTMLTextAreaElement; - const typeInput = form.querySelector('[name="type"]') as HTMLSelectElement; + const form = document.getElementById( + "create-library-form", + ) as HTMLFormElement; + if (form) { + const nameInput = form.querySelector('[name="name"]') as HTMLInputElement; + const descInput = form.querySelector( + '[name="description"]', + ) as HTMLTextAreaElement; + const typeInput = form.querySelector('[name="type"]') as HTMLSelectElement; - if (nameInput) nameInput.value = library.name; - if (descInput) descInput.value = library.description || ''; - if (typeInput) typeInput.value = library.type_name; - } + if (nameInput) nameInput.value = library.name; + if (descInput) descInput.value = library.description || ""; + if (typeInput) typeInput.value = library.type_name; + } - const modalTitle = document.querySelector('#create-library-modal h2'); - if (modalTitle) { - modalTitle.textContent = 'Edit Library'; - } + const modalTitle = document.querySelector("#create-library-modal h2"); + if (modalTitle) { + modalTitle.textContent = "Edit Library"; + } - const libraryIdInput = document.getElementById('library-id') as HTMLInputElement; - if (libraryIdInput) { - libraryIdInput.value = libraryId; - } + const libraryIdInput = document.getElementById( + "library-id", + ) as HTMLInputElement; + if (libraryIdInput) { + libraryIdInput.value = libraryId; + } - showCreateLibraryModal(); + showCreateLibraryModal(); } // Modal controls function showCreateLibraryModal(): void { - const modal = document.getElementById('create-library-modal') as HTMLElement; - if (modal) { - modal.classList.remove('hidden'); + const modal = document.getElementById("create-library-modal") as HTMLElement; + if (modal) { + modal.classList.remove("hidden"); - const modalTitle = document.querySelector('#create-library-modal h2'); - if (modalTitle) { - modalTitle.textContent = 'Create Library'; - } + const modalTitle = document.querySelector("#create-library-modal h2"); + if (modalTitle) { + modalTitle.textContent = "Create Library"; } + } } function hideCreateLibraryModal(): void { - const modal = document.getElementById('create-library-modal') as HTMLElement; - if (modal) { - modal.classList.add('hidden'); - } + const modal = document.getElementById("create-library-modal") as HTMLElement; + if (modal) { + modal.classList.add("hidden"); + } } // Delete modal state let libraryToDelete: Library | null = null; function showDeleteModal(library: Library): void { - libraryToDelete = library; + libraryToDelete = library; - const modal = document.getElementById('delete-library-modal') as HTMLElement; - const content = document.getElementById('delete-modal-content') as HTMLElement; + const modal = document.getElementById("delete-library-modal") as HTMLElement; + const content = document.getElementById( + "delete-modal-content", + ) as HTMLElement; - if (modal && content) { - const message = `Are you sure you want to delete "${escapeHtmlLocal(library.name)}"? + if (modal && content) { + const message = `Are you sure you want to delete "${escapeHtmlLocal(library.name)}"? This will remove: • Library metadata from the database @@ -356,189 +419,203 @@ This will remove: This action cannot be undone.`; - content.innerHTML = message.replace(/\n/g, '
'); - modal.classList.remove('hidden'); - } + content.innerHTML = message.replace(/\n/g, "
"); + modal.classList.remove("hidden"); + } } function hideDeleteModal(): void { - const modal = document.getElementById('delete-library-modal') as HTMLElement; - if (modal) { - modal.classList.add('hidden'); - } - libraryToDelete = null; + const modal = document.getElementById("delete-library-modal") as HTMLElement; + if (modal) { + modal.classList.add("hidden"); + } + libraryToDelete = null; } async function confirmDeleteLibrary(): Promise { - if (!libraryToDelete) return; + if (!libraryToDelete) return; - const libraryId = libraryToDelete.id; - hideDeleteModal(); + const libraryId = libraryToDelete.id; + hideDeleteModal(); - try { - const response = await (window as any).api.delete(`/libraries/${libraryId}`); - await (window as any).api.handleVoidResponse(response); + try { + const response = await (window as any).api.delete( + `/libraries/${libraryId}`, + ); + await (window as any).api.handleVoidResponse(response); - if ((window as any).showToast?.success) { - (window as any).showToast.success('Library deleted successfully'); - } - - await reloadLibraries(); - - const libraryIdInput = document.getElementById('library-id') as HTMLInputElement | null; - if (libraryIdInput) { - libraryIdInput.value = ''; - } - } catch (error) { - (window as any).api.handleError(error, 'Failed to delete library'); + if ((window as any).showToast?.success) { + (window as any).showToast.success("Library deleted successfully"); } + + await reloadLibraries(); + + const libraryIdInput = document.getElementById( + "library-id", + ) as HTMLInputElement | null; + if (libraryIdInput) { + libraryIdInput.value = ""; + } + } catch (error) { + (window as any).api.handleError(error, "Failed to delete library"); + } } // Local escape HTML helper function escapeHtmlLocal(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } // Event delegation for handling dynamic button clicks function handleLibraryListClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest('button') as HTMLElement; - if (!button) return; + const target = event.target as HTMLElement; + const button = target.closest("button") as HTMLElement; + if (!button) return; - const action = button.dataset.action; - const libraryId = button.dataset.libraryId; + const action = button.dataset.action; + const libraryId = button.dataset.libraryId; - switch (action) { - case 'show-folders': - if (libraryId) showLibraryFolders(libraryId); - break; - case 'delete': - if (libraryId) deleteLibrary(libraryId); - break; - case 'edit': - if (libraryId) editLibrary(libraryId); - break; - case 'add-folder': - if (libraryId) addLibraryFolder(libraryId); - break; - case 'remove-folder': - if (libraryId && button.dataset.folderPath) { - removeLibraryFolder(libraryId, button.dataset.folderPath); - } - break; - case 'browse-folder': - if (button.dataset.inputId) showFolderBrowser(button.dataset.inputId); - break; - } + switch (action) { + case "show-folders": + if (libraryId) showLibraryFolders(libraryId); + break; + case "delete": + if (libraryId) deleteLibrary(libraryId); + break; + case "edit": + if (libraryId) editLibrary(libraryId); + break; + case "add-folder": + if (libraryId) addLibraryFolder(libraryId); + break; + case "remove-folder": + if (libraryId && button.dataset.folderPath) { + removeLibraryFolder(libraryId, button.dataset.folderPath); + } + break; + case "browse-folder": + if (button.dataset.inputId) showFolderBrowser(button.dataset.inputId); + break; + } } function handleFolderBrowserClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest('button') as HTMLElement; - const div = target.closest('div[data-action]') as HTMLElement; + const target = event.target as HTMLElement; + const button = target.closest("button") as HTMLElement; + const div = target.closest("div[data-action]") as HTMLElement; - if (button) { - const action = button.dataset.action; - const path = button.dataset.path; + if (button) { + const action = button.dataset.action; + const path = button.dataset.path; - switch (action) { - case 'browse-parent': - if (path) navigateFolderBrowser(path); - break; - case 'browse-cancel': - hideFolderBrowser(); - break; - case 'browse-select': - if (path) selectBrowseFolder(path); - break; - } - } - - if (div && div.dataset.action === 'browse-navigate') { - const path = div.dataset.path; + switch (action) { + case "browse-parent": if (path) navigateFolderBrowser(path); + break; + case "browse-cancel": + hideFolderBrowser(); + break; + case "browse-select": + if (path) selectBrowseFolder(path); + break; } + } + + if (div && div.dataset.action === "browse-navigate") { + const path = div.dataset.path; + if (path) navigateFolderBrowser(path); + } } function handleGlobalClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest('button') as HTMLElement; - if (!button) return; + const target = event.target as HTMLElement; + const button = target.closest("button") as HTMLElement; + if (!button) return; - const action = button.dataset.action; + const action = button.dataset.action; - switch (action) { - case 'show-create-modal': - showCreateLibraryModal(); - break; - case 'hide-create-modal': - hideCreateLibraryModal(); - break; - case 'hide-delete-modal': - hideDeleteModal(); - break; - case 'confirm-delete': - void confirmDeleteLibrary(); - break; - } + switch (action) { + case "show-create-modal": + showCreateLibraryModal(); + break; + case "hide-create-modal": + hideCreateLibraryModal(); + break; + case "hide-delete-modal": + hideDeleteModal(); + break; + case "confirm-delete": + void confirmDeleteLibrary(); + break; + } } // Folder browser state -let currentBrowsePath = ''; -let currentBrowseInputId = ''; +let currentBrowsePath = ""; +let currentBrowseInputId = ""; // Show folder browser modal function showFolderBrowser(inputId: string): void { - currentBrowseInputId = inputId; - currentBrowsePath = '/'; + currentBrowseInputId = inputId; + currentBrowsePath = "/"; - const modal = document.getElementById('folder-browser-modal') as HTMLElement; - if (modal) { - modal.classList.remove('hidden'); - void loadBrowseDirectories(currentBrowsePath); - } + const modal = document.getElementById("folder-browser-modal") as HTMLElement; + if (modal) { + modal.classList.remove("hidden"); + void loadBrowseDirectories(currentBrowsePath); + } } // Load directories for browsing async function loadBrowseDirectories(path: string): Promise { - try { - const response = await (window as any).api.get(`/libraries/browse?path=${encodeURIComponent(path)}`); - const data = await (window as any).api.handleResponse(response) as { - current_path: string; - parent_path: string; - directories: string[]; - }; + try { + const response = await (window as any).api.get( + `/libraries/browse?path=${encodeURIComponent(path)}`, + ); + const data = (await (window as any).api.handleResponse(response)) as { + current_path: string; + parent_path: string; + directories: string[]; + }; - currentBrowsePath = data.current_path; - renderBrowseDirectories(data); - } catch (error) { - (window as any).api.handleError(error, 'Failed to load directories'); - } + currentBrowsePath = data.current_path; + renderBrowseDirectories(data); + } catch (error) { + (window as any).api.handleError(error, "Failed to load directories"); + } } // Render browse directories (uses event delegation via data-action attributes) -function renderBrowseDirectories(data: { current_path: string; parent_path: string; directories: string[] }): void { - const container = document.getElementById('folder-browser-content'); - if (!container) return; +function renderBrowseDirectories(data: { + current_path: string; + parent_path: string; + directories: string[]; +}): void { + const container = document.getElementById("folder-browser-content"); + if (!container) return; - let html = ` + let html = `
- ${data.parent_path ? - `` - : ''} + ${ + data.parent_path + ? `` + : "" + } ${escapeHtmlLocal(data.current_path)}
`; - if (data.directories.length === 0) { - html += '

No subdirectories

'; - } else { - data.directories.forEach(dir => { - const fullPath = data.current_path === '/' ? `/${dir}` : `${data.current_path}/${dir}`; - html += ` + if (data.directories.length === 0) { + html += + '

No subdirectories

'; + } else { + data.directories.forEach((dir) => { + const fullPath = + data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`; + html += `
`; - }); - } + }); + } - html += ` + html += `
@@ -557,54 +634,56 @@ function renderBrowseDirectories(data: { current_path: string; parent_path: stri
`; - container.innerHTML = html; + container.innerHTML = html; } // Navigate to subdirectory function navigateFolderBrowser(path: string): void { - void loadBrowseDirectories(path); + void loadBrowseDirectories(path); } // Select folder and close browser function selectBrowseFolder(path: string): void { - const input = document.getElementById(currentBrowseInputId) as HTMLInputElement; - if (input) { - input.value = path; - } - hideFolderBrowser(); + const input = document.getElementById( + currentBrowseInputId, + ) as HTMLInputElement; + if (input) { + input.value = path; + } + hideFolderBrowser(); } // Hide folder browser modal function hideFolderBrowser(): void { - const modal = document.getElementById('folder-browser-modal') as HTMLElement; - if (modal) { - modal.classList.add('hidden'); - } + const modal = document.getElementById("folder-browser-modal") as HTMLElement; + if (modal) { + modal.classList.add("hidden"); + } } // Initialize page function initializeLibraryAdmin(): void { - // Setup event listeners - const librariesList = document.getElementById('libraries-list'); - if (librariesList) { - librariesList.addEventListener('click', handleLibraryListClick); - } + // Setup event listeners + const librariesList = document.getElementById("libraries-list"); + if (librariesList) { + librariesList.addEventListener("click", handleLibraryListClick); + } - const folderBrowserModal = document.getElementById('folder-browser-modal'); - if (folderBrowserModal) { - folderBrowserModal.addEventListener('click', handleFolderBrowserClick); - } + const folderBrowserModal = document.getElementById("folder-browser-modal"); + if (folderBrowserModal) { + folderBrowserModal.addEventListener("click", handleFolderBrowserClick); + } - document.addEventListener('click', handleGlobalClick); + document.addEventListener("click", handleGlobalClick); - // Setup form submission - const createLibraryForm = document.getElementById('create-library-form'); - if (createLibraryForm) { - createLibraryForm.addEventListener('submit', handleCreateLibrarySubmit); - } + // Setup form submission + const createLibraryForm = document.getElementById("create-library-form"); + if (createLibraryForm) { + createLibraryForm.addEventListener("submit", handleCreateLibrarySubmit); + } - // Load libraries from API on page load - void reloadLibraries(); + // Load libraries from API on page load + void reloadLibraries(); } // Export functions for global access @@ -625,8 +704,8 @@ function initializeLibraryAdmin(): void { (window as any).confirmDeleteLibrary = confirmDeleteLibrary; // Initialize on DOM ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeLibraryAdmin); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeLibraryAdmin); } else { - initializeLibraryAdmin(); + initializeLibraryAdmin(); } diff --git a/web/src/linking.ts b/web/src/linking.ts index 15ec132..e08c78d 100644 --- a/web/src/linking.ts +++ b/web/src/linking.ts @@ -1,31 +1,34 @@ async function loadUnlinkedBooks(): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/sync/unlinked-books', { - headers: { 'Authorization': `Bearer ${token}` } - }); + 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); + 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; + const container = document.getElementById("unlinked-books-list"); + if (!container) return; - if (books.length === 0) { - container.innerHTML = '

No unlinked books

'; - return; - } + if (books.length === 0) { + container.innerHTML = + '

No unlinked books

'; + return; + } - container.innerHTML = books.map(book => ` + container.innerHTML = books + .map( + (book) => `
@@ -38,10 +41,15 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
- ${book.potential_matches && book.potential_matches.length > 0 ? ` + ${ + book.potential_matches && book.potential_matches.length > 0 + ? `

Potential Matches:

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

${match.title}

@@ -49,106 +57,125 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
- `).join('')} + `, + ) + .join("")}
- ` : ''} + ` + : "" + }
- `).join(''); + `, + ) + .join(""); } -async function linkBook(progressId: string, mediaItemId: string): Promise { - const token = localStorage.getItem('token'); - if (!token) return; +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 }) - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - if (!confirm('Auto-link all books with high confidence matches?')) 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 }) - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch(`/api/sync/suggestions/${progressId}`, { - headers: { 'Authorization': `Bearer ${token}` } - }); + 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); + 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'); +function showSuggestionsModal( + progressId: string, + suggestions: PotentialMatchData[], +): void { + const modal = document.getElementById("match-modal"); + const content = document.getElementById("match-modal-content"); - if (!modal || !content) return; + if (!modal || !content) return; - content.innerHTML = ` + content.innerHTML = `

Select a match

- ${suggestions.map(s => ` + ${suggestions + .map( + (s) => `
@@ -156,20 +183,22 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat

${s.author}

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

- `).join('')} + `, + ) + .join("")}
`; - modal.classList.remove('hidden'); + modal.classList.remove("hidden"); } function hideMatchModal(): void { - const modal = document.getElementById('match-modal'); - if (modal) { - modal.classList.add('hidden'); - } + const modal = document.getElementById("match-modal"); + if (modal) { + modal.classList.add("hidden"); + } } (window as any).loadUnlinkedBooks = loadUnlinkedBooks; diff --git a/web/src/password_validation.ts b/web/src/password_validation.ts index 36d0c6e..ff020cc 100644 --- a/web/src/password_validation.ts +++ b/web/src/password_validation.ts @@ -3,174 +3,185 @@ // Validation check functions function hasMinimumLength(password: string): boolean { - return password.length >= 8; + return password.length >= 8; } function hasUppercase(password: string): boolean { - return /[A-Z]/.test(password); + return /[A-Z]/.test(password); } function hasLowercase(password: string): boolean { - return /[a-z]/.test(password); + return /[a-z]/.test(password); } function hasNumber(password: string): boolean { - return /[0-9]/.test(password); + return /[0-9]/.test(password); } function hasSpecialChar(password: string): boolean { - return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); + return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); } function passwordsMatch(password: string, confirm: string): boolean { - if (!password && !confirm) { - return false; - } - return password === confirm; + if (!password && !confirm) { + return false; + } + return password === confirm; } function hasUsername(username: string): boolean { - return username.trim().length > 0; + return username.trim().length > 0; } function hasEmail(email: string): boolean { - return email.trim().length > 0; + return email.trim().length > 0; } // UI update functions function updateRequirementStatus(elementId: string, passed: boolean): void { - const element = document.getElementById(elementId); - if (!element) { - return; - } + const element = document.getElementById(elementId); + if (!element) { + return; + } - const icon = element.querySelector('.requirement-icon'); - if (!icon) { - return; - } + const icon = element.querySelector(".requirement-icon"); + if (!icon) { + return; + } - if (passed) { - icon.textContent = '✓'; - icon.className = 'requirement-icon text-green-500'; - element.style.color = 'var(--text-primary)'; - } else { - icon.textContent = '○'; - icon.className = 'requirement-icon'; - element.style.color = 'var(--text-secondary)'; - } + if (passed) { + icon.textContent = "✓"; + icon.className = "requirement-icon text-green-500"; + element.style.color = "var(--text-primary)"; + } else { + icon.textContent = "○"; + icon.className = "requirement-icon"; + element.style.color = "var(--text-secondary)"; + } } function updateSubmitButton(allPassed: boolean): void { - const button = document.getElementById('register-btn') as HTMLButtonElement; - if (!button) { - return; - } + const button = document.getElementById("register-btn") as HTMLButtonElement; + if (!button) { + return; + } - if (allPassed) { - button.disabled = false; - button.classList.remove('opacity-50', 'cursor-not-allowed'); - } else { - button.disabled = true; - button.classList.add('opacity-50', 'cursor-not-allowed'); - } + if (allPassed) { + button.disabled = false; + button.classList.remove("opacity-50", "cursor-not-allowed"); + } else { + button.disabled = true; + button.classList.add("opacity-50", "cursor-not-allowed"); + } } // Main validation orchestrator function validateAll(): void { - const passwordField = document.getElementById('password') as HTMLInputElement; - const confirmField = document.getElementById('confirm-password') as HTMLInputElement; - const usernameField = document.getElementById('username') as HTMLInputElement; - const emailField = document.getElementById('email') as HTMLInputElement; + const passwordField = document.getElementById("password") as HTMLInputElement; + const confirmField = document.getElementById( + "confirm-password", + ) as HTMLInputElement; + const usernameField = document.getElementById("username") as HTMLInputElement; + const emailField = document.getElementById("email") as HTMLInputElement; - if (!passwordField || !confirmField || !usernameField || !emailField) { - return; - } + if (!passwordField || !confirmField || !usernameField || !emailField) { + return; + } - const password = passwordField.value; - const confirm = confirmField.value; - const username = usernameField.value; - const email = emailField.value; + const password = passwordField.value; + const confirm = confirmField.value; + const username = usernameField.value; + const email = emailField.value; - // Check password requirements - const hasLen = hasMinimumLength(password); - const hasUpper = hasUppercase(password); - const hasLower = hasLowercase(password); - const hasNum = hasNumber(password); - const hasSpecial = hasSpecialChar(password); - const doMatch = passwordsMatch(password, confirm); - const hasUser = hasUsername(username); - const hasEmailAddr = hasEmail(email); + // Check password requirements + const hasLen = hasMinimumLength(password); + const hasUpper = hasUppercase(password); + const hasLower = hasLowercase(password); + const hasNum = hasNumber(password); + const hasSpecial = hasSpecialChar(password); + const doMatch = passwordsMatch(password, confirm); + const hasUser = hasUsername(username); + const hasEmailAddr = hasEmail(email); - // Update requirement indicators - updateRequirementStatus('req-length', hasLen); - updateRequirementStatus('req-upper', hasUpper); - updateRequirementStatus('req-lower', hasLower); - updateRequirementStatus('req-number', hasNum); - updateRequirementStatus('req-special', hasSpecial); - updateRequirementStatus('req-match', doMatch); + // Update requirement indicators + updateRequirementStatus("req-length", hasLen); + updateRequirementStatus("req-upper", hasUpper); + updateRequirementStatus("req-lower", hasLower); + updateRequirementStatus("req-number", hasNum); + updateRequirementStatus("req-special", hasSpecial); + updateRequirementStatus("req-match", doMatch); - // Enable/disable submit button - const allPassed = hasLen && hasUpper && hasLower && hasNum && - hasSpecial && doMatch && hasUser && hasEmailAddr; - updateSubmitButton(allPassed); + // Enable/disable submit button + const allPassed = + hasLen && + hasUpper && + hasLower && + hasNum && + hasSpecial && + doMatch && + hasUser && + hasEmailAddr; + updateSubmitButton(allPassed); } // Debounce function to avoid excessive validation calls let debounceTimer: number | null = null; function debouncedValidation(): void { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - } - debounceTimer = window.setTimeout(() => { - validateAll(); - debounceTimer = null; - }, 100); + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = window.setTimeout(() => { + validateAll(); + debounceTimer = null; + }, 100); } // Event handlers (PASSIVE - no preventDefault, doesn't block password managers) function onPasswordInput(): void { - debouncedValidation(); + debouncedValidation(); } function onConfirmInput(): void { - debouncedValidation(); + debouncedValidation(); } function onUsernameInput(): void { - debouncedValidation(); + debouncedValidation(); } function onEmailInput(): void { - debouncedValidation(); + debouncedValidation(); } // Initialization function initPasswordValidation(): void { - const passwordField = document.getElementById('password') as HTMLInputElement; - const confirmField = document.getElementById('confirm-password') as HTMLInputElement; - const usernameField = document.getElementById('username') as HTMLInputElement; - const emailField = document.getElementById('email') as HTMLInputElement; + const passwordField = document.getElementById("password") as HTMLInputElement; + const confirmField = document.getElementById( + "confirm-password", + ) as HTMLInputElement; + const usernameField = document.getElementById("username") as HTMLInputElement; + const emailField = document.getElementById("email") as HTMLInputElement; - if (!passwordField || !confirmField || !usernameField || !emailField) { - return; - } + if (!passwordField || !confirmField || !usernameField || !emailField) { + return; + } - // Add passive event listeners - don't prevent default, don't block password managers - passwordField.addEventListener('input', onPasswordInput, { passive: true }); - passwordField.addEventListener('paste', onPasswordInput, { passive: true }); + // Add passive event listeners - don't prevent default, don't block password managers + passwordField.addEventListener("input", onPasswordInput, { passive: true }); + passwordField.addEventListener("paste", onPasswordInput, { passive: true }); - confirmField.addEventListener('input', onConfirmInput, { passive: true }); - confirmField.addEventListener('paste', onConfirmInput, { passive: true }); + confirmField.addEventListener("input", onConfirmInput, { passive: true }); + confirmField.addEventListener("paste", onConfirmInput, { passive: true }); - usernameField.addEventListener('input', onUsernameInput, { passive: true }); - usernameField.addEventListener('paste', onUsernameInput, { passive: true }); + usernameField.addEventListener("input", onUsernameInput, { passive: true }); + usernameField.addEventListener("paste", onUsernameInput, { passive: true }); - emailField.addEventListener('input', onEmailInput, { passive: true }); - emailField.addEventListener('paste', onEmailInput, { passive: true }); + emailField.addEventListener("input", onEmailInput, { passive: true }); + emailField.addEventListener("paste", onEmailInput, { passive: true }); - // Initial validation - validateAll(); + // Initial validation + validateAll(); } // Export for use in template diff --git a/web/src/queue.ts b/web/src/queue.ts index 645091a..8b02948 100644 --- a/web/src/queue.ts +++ b/web/src/queue.ts @@ -1,170 +1,175 @@ async function refreshQueue(): Promise { - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/queue/all', { - headers: { 'Authorization': `Bearer ${token}` } - }); + 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); + 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; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch('/api/queue/process', { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - if (!confirm('Are you sure you want to clear all failed items?')) 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}` } - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - if (!confirm('Are you sure you want to clear all queue items?')) 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}` } - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch(`/api/queue/items/${itemId}/retry`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } - }); + 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'); - } + 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; + const token = localStorage.getItem("token"); + if (!token) return; - try { - const response = await fetch(`/api/queue/items/${itemId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` } - }); + 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'); - } + 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; + const container = document.getElementById("queue-items"); + if (!container) return; - if (items.length === 0) { - container.innerHTML = '

Queue is empty

'; - return; - } + if (items.length === 0) { + container.innerHTML = + '

Queue is empty

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

${item.media_title || 'Unknown'}

+

${item.media_title || "Unknown"}

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

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

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

${item.error_message}

` : ''} + ${item.error_message ? `

${item.error_message}

` : ""}
- `).join(''); + `, + ) + .join(""); } (window as any).refreshQueue = refreshQueue; diff --git a/web/src/search.ts b/web/src/search.ts index af2817c..3244fc4 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -3,177 +3,188 @@ const SEARCH_DEBOUNCE_MS = 300; const SEARCH_MIN_CHARS = 2; function initializeSearch(): void { - const searchInput = document.getElementById('header-search') as HTMLInputElement | null; - if (!searchInput) { - console.warn('Search input not found'); - return; + const searchInput = document.getElementById( + "header-search", + ) as HTMLInputElement | null; + if (!searchInput) { + console.warn("Search input not found"); + return; + } + + searchInput.addEventListener("input", handleSearchInput); + searchInput.addEventListener("keydown", handleSearchKeydown); + searchInput.addEventListener("focus", () => { + if (searchInput.value.length >= SEARCH_MIN_CHARS) { + performSearch(searchInput.value); } + }); - searchInput.addEventListener('input', handleSearchInput); - searchInput.addEventListener('keydown', handleSearchKeydown); - searchInput.addEventListener('focus', () => { - if (searchInput.value.length >= SEARCH_MIN_CHARS) { - performSearch(searchInput.value); - } - }); + document.addEventListener("click", (e: MouseEvent) => { + const searchResults = document.getElementById("search-results"); + const searchInputEl = document.getElementById("header-search"); - document.addEventListener('click', (e: MouseEvent) => { - const searchResults = document.getElementById('search-results'); - const searchInputEl = document.getElementById('header-search'); - - if (searchResults && !searchResults.contains(e.target as Node) && e.target !== searchInputEl) { - hideSearchResults(); - } - }); + if ( + searchResults && + !searchResults.contains(e.target as Node) && + e.target !== searchInputEl + ) { + hideSearchResults(); + } + }); } function handleSearchInput(e: Event): void { - const target = e.target as HTMLInputElement; - const query = target.value.trim(); + const target = e.target as HTMLInputElement; + const query = target.value.trim(); - if (searchInputTimeout) { - clearTimeout(searchInputTimeout); - } + if (searchInputTimeout) { + clearTimeout(searchInputTimeout); + } - if (query.length < SEARCH_MIN_CHARS) { - hideSearchResults(); - return; - } + if (query.length < SEARCH_MIN_CHARS) { + hideSearchResults(); + return; + } - searchInputTimeout = setTimeout(() => { - performSearch(query); - }, SEARCH_DEBOUNCE_MS); + searchInputTimeout = setTimeout(() => { + performSearch(query); + }, SEARCH_DEBOUNCE_MS); } function handleSearchKeydown(e: KeyboardEvent): void { - const searchResults = document.getElementById('search-results'); - if (!searchResults || searchResults.classList.contains('hidden')) { - return; - } + const searchResults = document.getElementById("search-results"); + if (!searchResults || searchResults.classList.contains("hidden")) { + return; + } - const items = searchResults.querySelectorAll('.search-result-item'); - const currentIndex = parseInt(searchResults.dataset.selectedIndex || '-1'); + const items = searchResults.querySelectorAll(".search-result-item"); + const currentIndex = parseInt(searchResults.dataset.selectedIndex || "-1"); - if (e.key === 'ArrowDown') { - e.preventDefault(); - const nextIndex = Math.min(currentIndex + 1, items.length - 1); - selectSearchResult(items, nextIndex); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - const prevIndex = Math.max(currentIndex - 1, -1); - selectSearchResult(items, prevIndex); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (currentIndex >= 0 && items[currentIndex]) { - const link = items[currentIndex].querySelector('a'); - if (link) link.click(); - } - } else if (e.key === 'Escape') { - hideSearchResults(); + if (e.key === "ArrowDown") { + e.preventDefault(); + const nextIndex = Math.min(currentIndex + 1, items.length - 1); + selectSearchResult(items, nextIndex); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prevIndex = Math.max(currentIndex - 1, -1); + selectSearchResult(items, prevIndex); + } else if (e.key === "Enter") { + e.preventDefault(); + if (currentIndex >= 0 && items[currentIndex]) { + const link = items[currentIndex].querySelector("a"); + if (link) link.click(); } + } else if (e.key === "Escape") { + hideSearchResults(); + } } function selectSearchResult(items: NodeListOf, index: number): void { - items.forEach((item, i) => { - if (i === index) { - item.classList.add('bg-opacity-80'); - } else { - item.classList.remove('bg-opacity-80'); - } - }); - - const searchResults = document.getElementById('search-results'); - if (searchResults) { - searchResults.dataset.selectedIndex = index.toString(); + items.forEach((item, i) => { + if (i === index) { + item.classList.add("bg-opacity-80"); + } else { + item.classList.remove("bg-opacity-80"); } + }); + + const searchResults = document.getElementById("search-results"); + if (searchResults) { + searchResults.dataset.selectedIndex = index.toString(); + } } function performSearch(query: string): void { - const token = localStorage.getItem('token'); - if (!token) { - console.warn('No authentication token found'); - return; - } + const token = localStorage.getItem("token"); + if (!token) { + console.warn("No authentication token found"); + return; + } - showSearchLoading(); + showSearchLoading(); - fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } + fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .then((response) => { + if (response.status === 404) { + return { error: "no results found", results: [] }; + } + return response.json(); }) - .then(response => { - if (response.status === 404) { - return { error: 'no results found', results: [] }; - } - return response.json(); - }) - .then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => { + .then( + ( + data: + | { error?: string; results?: MediaItemSummary[] } + | MediaItemSummary[], + ) => { hideSearchLoading(); - if (data && 'error' in data && data.error === 'no results found') { - showNoResults(query); + if (data && "error" in data && data.error === "no results found") { + showNoResults(query); } else if (Array.isArray(data) && data.length > 0) { - showSearchResults(data, query); + showSearchResults(data, query); } else if (Array.isArray(data)) { - showNoResults(query); + showNoResults(query); } else { - showNoResults(query); + showNoResults(query); } - }) - .catch(error => { - hideSearchLoading(); - console.error('Search error:', error); - showSearchError(); + }, + ) + .catch((error) => { + hideSearchLoading(); + console.error("Search error:", error); + showSearchError(); }); } function showSearchLoading(): void { - createSearchResultsContainer(); - const searchResults = document.getElementById('search-results'); - if (!searchResults) return; + createSearchResultsContainer(); + const searchResults = document.getElementById("search-results"); + if (!searchResults) return; - searchResults.innerHTML = ` + searchResults.innerHTML = `

Searching...

`; - searchResults.classList.remove('hidden'); + searchResults.classList.remove("hidden"); } -function hideSearchLoading(): void { -} +function hideSearchLoading(): void {} function showSearchResults(results: MediaItemSummary[], query: string): void { - createSearchResultsContainer(); - const searchResults = document.getElementById('search-results'); - if (!searchResults) return; + createSearchResultsContainer(); + const searchResults = document.getElementById("search-results"); + if (!searchResults) return; - searchResults.dataset.selectedIndex = '-1'; + searchResults.dataset.selectedIndex = "-1"; - const libraryIconMap: Record = { - 'ebooks': '📚', - 'comics': '📖', - 'manga': '🗾' - }; + const libraryIconMap: Record = { + ebooks: "📚", + comics: "📖", + manga: "🗾", + }; - let html = ` + let html = `

- ${results.length} result${results.length !== 1 ? 's' : ''} for "${searchEscapeHtml(query)}" + ${results.length} result${results.length !== 1 ? "s" : ""} for "${searchEscapeHtml(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) : ''; + 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 += `
@@ -186,7 +197,7 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {

${titleHtml}

- ${authorHtml ? `

${authorHtml}

` : ''} + ${authorHtml ? `

${authorHtml}

` : ""}

${searchEscapeHtml(item.library_name)}

@@ -195,9 +206,9 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
`; - }); + }); - html += ` + html += `

@@ -207,84 +218,89 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {

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

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

Try different keywords

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

Search error

Please try again

`; - searchResults.classList.remove('hidden'); + searchResults.classList.remove("hidden"); } function hideSearchResults(): void { - const searchResults = document.getElementById('search-results'); - if (searchResults) { - searchResults.classList.add('hidden'); - } + const searchResults = document.getElementById("search-results"); + if (searchResults) { + searchResults.classList.add("hidden"); + } } function createSearchResultsContainer(): void { - let searchResults = document.getElementById('search-results'); - if (!searchResults) { - searchResults = document.createElement('div'); - searchResults.id = 'search-results'; - searchResults.className = 'hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border'; - searchResults.style.cssText = 'background-color: var(--bg-secondary); border-color: var(--border)'; + let searchResults = document.getElementById("search-results"); + if (!searchResults) { + searchResults = document.createElement("div"); + searchResults.id = "search-results"; + searchResults.className = + "hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border"; + searchResults.style.cssText = + "background-color: var(--bg-secondary); border-color: var(--border)"; - const searchInput = document.getElementById('header-search'); - if (searchInput) { - const searchContainer = searchInput.closest('.relative'); - if (searchContainer) { - searchContainer.appendChild(searchResults); - } - } + const searchInput = document.getElementById("header-search"); + if (searchInput) { + const searchContainer = searchInput.closest(".relative"); + if (searchContainer) { + searchContainer.appendChild(searchResults); + } } + } } function highlightMatch(text: string, query: string): string { - if (!text) return ''; - const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(`(${escapedQuery})`, 'gi'); - return searchEscapeHtml(text).replace(regex, '$1'); + if (!text) return ""; + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(${escapedQuery})`, "gi"); + return searchEscapeHtml(text).replace( + regex, + '$1', + ); } function searchEscapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } function selectLibraryAndBook(libraryId: string, bookId: string): void { - localStorage.setItem('selectedLibrary', libraryId); - localStorage.setItem('selectedBook', bookId); - hideSearchResults(); + localStorage.setItem("selectedLibrary", libraryId); + localStorage.setItem("selectedBook", bookId); + hideSearchResults(); } -document.addEventListener('DOMContentLoaded', initializeSearch); +document.addEventListener("DOMContentLoaded", initializeSearch); (window as any).selectLibraryAndBook = selectLibraryAndBook; diff --git a/web/src/storage.ts b/web/src/storage.ts index 8c6c264..1a54385 100644 --- a/web/src/storage.ts +++ b/web/src/storage.ts @@ -1,83 +1,83 @@ function getToken(): string | null { - return localStorage.getItem('token'); + return localStorage.getItem("token"); } function setToken(token: string): void { - localStorage.setItem('token', token); + localStorage.setItem("token", token); } function removeToken(): void { - localStorage.removeItem('token'); + localStorage.removeItem("token"); } function getRefreshToken(): string | null { - return localStorage.getItem('refresh_token'); + return localStorage.getItem("refresh_token"); } function setRefreshToken(token: string): void { - localStorage.setItem('refresh_token', token); + localStorage.setItem("refresh_token", token); } function removeRefreshToken(): void { - localStorage.removeItem('refresh_token'); + localStorage.removeItem("refresh_token"); } function getTheme(): string { - return localStorage.getItem('theme') || 'tokyo-night'; + return localStorage.getItem("theme") || "tokyo-night"; } function setTheme(theme: string): void { - localStorage.setItem('theme', theme); + localStorage.setItem("theme", theme); } function getSelectedLibrary(): string | null { - return localStorage.getItem('selectedLibrary'); + return localStorage.getItem("selectedLibrary"); } function setSelectedLibrary(libraryId: string): void { - localStorage.setItem('selectedLibrary', libraryId); + localStorage.setItem("selectedLibrary", libraryId); } function getSelectedBook(): string | null { - return localStorage.getItem('selectedBook'); + return localStorage.getItem("selectedBook"); } function setSelectedBook(bookId: string): void { - localStorage.setItem('selectedBook', bookId); + localStorage.setItem("selectedBook", bookId); } function clearAll(): void { - localStorage.clear(); + localStorage.clear(); } (window as any).storage = { - getToken, - setToken, - removeToken, - getRefreshToken, - setRefreshToken, - removeRefreshToken, - getTheme, - setTheme, - getSelectedLibrary, - setSelectedLibrary, - getSelectedBook, - setSelectedBook, - clearAll + getToken, + setToken, + removeToken, + getRefreshToken, + setRefreshToken, + removeRefreshToken, + getTheme, + setTheme, + getSelectedLibrary, + setSelectedLibrary, + getSelectedBook, + setSelectedBook, + clearAll, }; export { - getToken, - setToken, - removeToken, - getRefreshToken, - setRefreshToken, - removeRefreshToken, - getTheme, - setTheme, - getSelectedLibrary, - setSelectedLibrary, - getSelectedBook, - setSelectedBook, - clearAll + getToken, + setToken, + removeToken, + getRefreshToken, + setRefreshToken, + removeRefreshToken, + getTheme, + setTheme, + getSelectedLibrary, + setSelectedLibrary, + getSelectedBook, + setSelectedBook, + clearAll, }; diff --git a/web/src/theme.ts b/web/src/theme.ts index 547f678..c40801f 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -1,133 +1,140 @@ // Theme management functionality type ThemeType = - | 'tokyo-night' - | 'dracula' - | 'nord' - | 'solarized-dark' - | 'monokai' - | 'one-dark-pro' - | 'material-dark' - | 'catppuccin-mocha' - | 'catppuccin-macchiato' - | 'catppuccin-frappe' - | 'catppuccin-latte'; + | "tokyo-night" + | "dracula" + | "nord" + | "solarized-dark" + | "monokai" + | "one-dark-pro" + | "material-dark" + | "catppuccin-mocha" + | "catppuccin-macchiato" + | "catppuccin-frappe" + | "catppuccin-latte"; -const DEFAULT_THEME: ThemeType = 'tokyo-night'; -const THEME_STORAGE_KEY = 'theme'; -const TOKEN_STORAGE_KEY = 'token'; +const DEFAULT_THEME: ThemeType = "tokyo-night"; +const THEME_STORAGE_KEY = "theme"; +const TOKEN_STORAGE_KEY = "token"; // Apply theme to document body const applyTheme = (theme: string): void => { - // Apply regular theme only - document.body.className = `theme-${theme}`; - document.body.style.background = ''; - document.body.style.backgroundSize = ''; - document.body.style.backgroundAttachment = ''; + // Apply regular theme only + document.body.className = `theme-${theme}`; + document.body.style.background = ""; + document.body.style.backgroundSize = ""; + document.body.style.backgroundAttachment = ""; - localStorage.setItem(THEME_STORAGE_KEY, theme); + localStorage.setItem(THEME_STORAGE_KEY, theme); }; // Load theme from localStorage or use default const loadTheme = (): void => { - const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null; - const theme = storedTheme || DEFAULT_THEME; - applyTheme(theme); + const storedTheme = localStorage.getItem( + THEME_STORAGE_KEY, + ) as ThemeType | null; + const theme = storedTheme || DEFAULT_THEME; + applyTheme(theme); }; // Handle theme change from user selection const changeTheme = async (): Promise => { - const themeSelect = document.getElementById('theme-select') as HTMLSelectElement; - if (!themeSelect) return; - - const theme = themeSelect.value as ThemeType; - applyTheme(theme); - - // Save to server if logged in - const token = localStorage.getItem(TOKEN_STORAGE_KEY); - if (!token) return; - - try { - const response = await fetch('/api/auth/theme', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ theme }) - }); - - if (!response.ok) { - console.log('Theme save failed'); - } - } catch (error) { - console.log('Theme save failed', error); + const themeSelect = document.getElementById( + "theme-select", + ) as HTMLSelectElement; + if (!themeSelect) return; + + const theme = themeSelect.value as ThemeType; + applyTheme(theme); + + // Save to server if logged in + const token = localStorage.getItem(TOKEN_STORAGE_KEY); + if (!token) return; + + try { + const response = await fetch("/api/auth/theme", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ theme }), + }); + + if (!response.ok) { + console.log("Theme save failed"); } + } catch (error) { + console.log("Theme save failed", error); + } }; // Load user's theme from server if logged in const loadUserTheme = async (): Promise => { - const token = localStorage.getItem(TOKEN_STORAGE_KEY); - if (!token) return; + const token = localStorage.getItem(TOKEN_STORAGE_KEY); + if (!token) return; - try { - const response = await fetch('/api/auth/profile', { - headers: { 'Authorization': `Bearer ${token}` } - }); + try { + const response = await fetch("/api/auth/profile", { + headers: { Authorization: `Bearer ${token}` }, + }); - if (response.ok) { - const data = await response.json(); - if (data.theme) { - applyTheme(data.theme as string); - } - } - } catch { - // Silently fail - user will get default theme + if (response.ok) { + const data = await response.json(); + if (data.theme) { + applyTheme(data.theme as string); + } } + } catch { + // Silently fail - user will get default theme + } }; // Initialize theme system const initializeTheme = (): void => { - loadTheme(); - loadUserTheme(); - - // Set theme select value to current theme - const themeSelect = document.getElementById('theme-select') as HTMLSelectElement; - if (themeSelect) { - const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME; - themeSelect.value = currentTheme; - } - - // Setup smooth scroll for anchor links - setupSmoothScroll(); + loadTheme(); + loadUserTheme(); + + // Set theme select value to current theme + const themeSelect = document.getElementById( + "theme-select", + ) as HTMLSelectElement; + if (themeSelect) { + const currentTheme = + localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME; + themeSelect.value = currentTheme; + } + + // Setup smooth scroll for anchor links + setupSmoothScroll(); }; // Setup smooth scrolling for anchor links const setupSmoothScroll = (): void => { - document.querySelectorAll('a[href^="#"]').forEach(anchor => { - anchor.addEventListener('click', (e) => { - e.preventDefault(); - const href = anchor.getAttribute('href'); - if (!href) return; - - const target = document.querySelector(href); - if (target) { - target.scrollIntoView({ - behavior: 'smooth', - block: 'start' - }); - } + document.querySelectorAll('a[href^="#"]').forEach((anchor) => { + anchor.addEventListener("click", (e) => { + e.preventDefault(); + const href = anchor.getAttribute("href"); + if (!href) return; + + const target = document.querySelector(href); + if (target) { + target.scrollIntoView({ + behavior: "smooth", + block: "start", }); + } }); + }); }; // Auto-initialize when DOM is ready -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeTheme); - } else { - initializeTheme(); - } +if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeTheme); + } else { + initializeTheme(); + } } // Make changeTheme available globally for HTML onchange attribute diff --git a/web/src/themeDropdown.ts b/web/src/themeDropdown.ts index e8a3188..e95a058 100644 --- a/web/src/themeDropdown.ts +++ b/web/src/themeDropdown.ts @@ -2,25 +2,25 @@ // Update visual indicators for theme buttons const updateThemeIndicators = (): void => { - const currentTheme = localStorage.getItem('theme') || 'tokyo-night'; + const currentTheme = localStorage.getItem("theme") || "tokyo-night"; - // Update theme buttons (all buttons with changeThemeTo onclick) - document.querySelectorAll('[onclick^="changeThemeTo"]').forEach(btn => { - const onclick = btn.getAttribute('onclick') || ''; - const match = onclick.match(/changeThemeTo\('(.+?)'\)/); - if (match) { - const theme = match[1]; - if (theme === currentTheme) { - // Active state - use CSS class instead of inline style - btn.classList.add('bg-theme-active'); - btn.classList.remove('bg-theme-inactive'); - } else { - // Inactive state - btn.classList.remove('bg-theme-active'); - btn.classList.add('bg-theme-inactive'); - } - } - }); + // Update theme buttons (all buttons with changeThemeTo onclick) + document.querySelectorAll('[onclick^="changeThemeTo"]').forEach((btn) => { + const onclick = btn.getAttribute("onclick") || ""; + const match = onclick.match(/changeThemeTo\('(.+?)'\)/); + if (match) { + const theme = match[1]; + if (theme === currentTheme) { + // Active state - use CSS class instead of inline style + btn.classList.add("bg-theme-active"); + btn.classList.remove("bg-theme-inactive"); + } else { + // Inactive state + btn.classList.remove("bg-theme-active"); + btn.classList.add("bg-theme-inactive"); + } + } + }); }; // Make function available globally @@ -29,27 +29,27 @@ const updateThemeIndicators = (): void => { // Update on dropdown toggle const originalToggleThemeDropdown = (window as any).toggleThemeDropdown; if (originalToggleThemeDropdown) { - (window as any).toggleThemeDropdown = () => { - originalToggleThemeDropdown(); - updateThemeIndicators(); - (window as any).updateWoodPanelingIndicators?.(); - }; + (window as any).toggleThemeDropdown = () => { + originalToggleThemeDropdown(); + updateThemeIndicators(); + (window as any).updateWoodPanelingIndicators?.(); + }; } // Update after theme changes const originalChangeThemeTo = (window as any).changeThemeTo; if (originalChangeThemeTo) { - (window as any).changeThemeTo = (...args: unknown[]) => { - originalChangeThemeTo(...args); - updateThemeIndicators(); - }; + (window as any).changeThemeTo = (...args: unknown[]) => { + originalChangeThemeTo(...args); + updateThemeIndicators(); + }; } // Auto-initialize when DOM is ready -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', updateThemeIndicators); - } else { - updateThemeIndicators(); - } +if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", updateThemeIndicators); + } else { + updateThemeIndicators(); + } } diff --git a/web/src/toast.ts b/web/src/toast.ts index ed35315..2e50fae 100644 --- a/web/src/toast.ts +++ b/web/src/toast.ts @@ -1,225 +1,235 @@ // Toast notification system for backend errors // Displays toast notifications at the top of the page -type ToastType = 'error' | 'success' | 'info'; +type ToastType = "error" | "success" | "info"; const TOAST_DEFAULT_DURATION = 5000; // Create toast container const createToastContainer = (): HTMLElement => { - let container = document.getElementById('toast-container'); - if (!container) { - container = document.createElement('div'); - container.id = 'toast-container'; - container.className = 'fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none'; - document.body.appendChild(container); - } - return container; + let container = document.getElementById("toast-container"); + if (!container) { + container = document.createElement("div"); + container.id = "toast-container"; + container.className = + "fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none"; + document.body.appendChild(container); + } + return container; }; // Escape HTML to prevent XSS const toastEscapeHtml = (text: string): string => { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; }; // Get toast configuration by type const getToastConfig = (type: ToastType) => { - const configs = { - error: { - bgClass: 'bg-red-500/90', - icon: '❌' - }, - success: { - bgClass: 'bg-green-500/90', - icon: '✅' - }, - info: { - bgClass: 'bg-blue-500/90', - icon: 'ℹ️' - } - }; - return configs[type]; + const configs = { + error: { + bgClass: "bg-red-500/90", + icon: "❌", + }, + success: { + bgClass: "bg-green-500/90", + icon: "✅", + }, + info: { + bgClass: "bg-blue-500/90", + icon: "ℹ️", + }, + }; + return configs[type]; }; // Create a toast element const createToastElement = (message: string, type: ToastType): HTMLElement => { - const toast = document.createElement('div'); - const config = getToastConfig(type); - - toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`; - - toast.innerHTML = ` + const toast = document.createElement("div"); + const config = getToastConfig(type); + + toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`; + + toast.innerHTML = ` ${config.icon} ${toastEscapeHtml(message)} `; - - // Add close button handler - const closeBtn = toast.querySelector('.toast-close') as HTMLElement; - if (closeBtn) { - closeBtn.onclick = () => removeToast(toast); - } - - return toast; + + // Add close button handler + const closeBtn = toast.querySelector(".toast-close") as HTMLElement; + if (closeBtn) { + closeBtn.onclick = () => removeToast(toast); + } + + return toast; }; // Trigger toast animation const animateToastIn = (toast: HTMLElement): void => { - setTimeout(() => { - toast.style.opacity = '1'; - toast.style.transform = 'translateY(0)'; - }, 10); + setTimeout(() => { + toast.style.opacity = "1"; + toast.style.transform = "translateY(0)"; + }, 10); }; // Remove toast with animation const removeToast = (toast: HTMLElement): void => { - toast.style.opacity = '0'; - toast.style.transform = 'translateY(-20px)'; - setTimeout(() => { - if (toast.parentElement) { - toast.parentElement.removeChild(toast); - } - }, 300); + toast.style.opacity = "0"; + toast.style.transform = "translateY(-20px)"; + setTimeout(() => { + if (toast.parentElement) { + toast.parentElement.removeChild(toast); + } + }, 300); }; // Show toast notification -const showToast = (message: string, type: ToastType, duration: number = TOAST_DEFAULT_DURATION): void => { - const container = createToastContainer(); - const toast = createToastElement(message, type); - container.appendChild(toast); - animateToastIn(toast); - - // Auto-remove after duration - setTimeout(() => { - removeToast(toast); - }, duration); +const showToast = ( + message: string, + type: ToastType, + duration: number = TOAST_DEFAULT_DURATION, +): void => { + const container = createToastContainer(); + const toast = createToastElement(message, type); + container.appendChild(toast); + animateToastIn(toast); + + // Auto-remove after duration + setTimeout(() => { + removeToast(toast); + }, duration); }; // Parse error from XHR response const parseXHRError = (xhr: XMLHttpRequest): string => { - let errorMessage = 'An error occurred'; - try { - const response = JSON.parse(xhr.responseText); - errorMessage = response.error || response.message || errorMessage; - } catch (e) { - errorMessage = xhr.responseText || errorMessage; - } - return errorMessage; + let errorMessage = "An error occurred"; + try { + const response = JSON.parse(xhr.responseText); + errorMessage = response.error || response.message || errorMessage; + } catch (e) { + errorMessage = xhr.responseText || errorMessage; + } + return errorMessage; }; // Parse error from fetch response const parseFetchError = async (response: Response): Promise => { - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - const data = await response.json(); - return data.error || data.message || `Error ${response.status}`; - } - return `Error ${response.status}: ${response.statusText}`; + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + const data = await response.json(); + return data.error || data.message || `Error ${response.status}`; + } + return `Error ${response.status}: ${response.statusText}`; }; // Setup HTMX error listeners const setupHTMXListeners = (): void => { - // Listen for HTMX afterSwap event to detect errors in swapped content - document.body.addEventListener('htmx:afterSwap', (evt: Event) => { - interface HTMXEventDetail { - xhr: XMLHttpRequest; - succeeded: boolean; - target: Element; - } - - const customEvent = evt as CustomEvent; - - // Check if request failed - if (customEvent.detail.succeeded === false && customEvent.detail.xhr) { - const xhr = customEvent.detail.xhr; - - // Show toast for HTTP errors - if (xhr.status >= 400 && xhr.status < 600) { - const errorMessage = parseXHRError(xhr); - showToast(errorMessage, 'error'); - } - } - }); - - // Also listen for response errors (network issues, invalid responses) - document.body.addEventListener('htmx:responseError', (evt: Event) => { - const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>; - const xhr = customEvent.detail.xhr; + // Listen for HTMX afterSwap event to detect errors in swapped content + document.body.addEventListener("htmx:afterSwap", (evt: Event) => { + interface HTMXEventDetail { + xhr: XMLHttpRequest; + succeeded: boolean; + target: Element; + } + + const customEvent = evt as CustomEvent; + + // Check if request failed + if (customEvent.detail.succeeded === false && customEvent.detail.xhr) { + const xhr = customEvent.detail.xhr; + + // Show toast for HTTP errors + if (xhr.status >= 400 && xhr.status < 600) { const errorMessage = parseXHRError(xhr); - showToast(errorMessage, 'error'); - }); + showToast(errorMessage, "error"); + } + } + }); + + // Also listen for response errors (network issues, invalid responses) + document.body.addEventListener("htmx:responseError", (evt: Event) => { + const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>; + const xhr = customEvent.detail.xhr; + const errorMessage = parseXHRError(xhr); + showToast(errorMessage, "error"); + }); }; // Setup fetch interceptor const setupFetchInterceptor = (): void => { - const originalFetch = window.fetch; - window.fetch = async (...args: Parameters): Promise => { - try { - const response = await originalFetch(...args); + const originalFetch = window.fetch; + window.fetch = async ( + ...args: Parameters + ): Promise => { + try { + const response = await originalFetch(...args); - // Special handling for 401 Unauthorized - if (response.status === 401) { - // Clear invalid tokens from localStorage - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); + // Special handling for 401 Unauthorized + if (response.status === 401) { + // Clear invalid tokens from localStorage + localStorage.removeItem("token"); + localStorage.removeItem("refreshToken"); + localStorage.removeItem("user"); - // Check if this was a page navigation (not API call) - const url = args[0] as string; + // Check if this was a page navigation (not API call) + const url = args[0] as string; - // Don't show toast for page navigations - will be handled by redirect - if (!url.startsWith('/api/')) { - // Direct navigation to protected page will be caught by middleware - // Just throw to prevent further processing - throw new Error('Session expired'); - } - - // API call - show toast error - const errorMessage = await parseFetchError(response); - showToast(errorMessage, 'error'); - - return response; - } - - // Handle other errors - if (!response.ok) { - const errorMessage = await parseFetchError(response); - showToast(errorMessage, 'error'); - } - - return response; - } catch (error) { - // Don't show toast for redirect errors - if ((error as Error).message !== 'Session expired') { - showToast('Network error: Unable to connect to server', 'error'); - } - throw error; + // Don't show toast for page navigations - will be handled by redirect + if (!url.startsWith("/api/")) { + // Direct navigation to protected page will be caught by middleware + // Just throw to prevent further processing + throw new Error("Session expired"); } - }; + + // API call - show toast error + const errorMessage = await parseFetchError(response); + showToast(errorMessage, "error"); + + return response; + } + + // Handle other errors + if (!response.ok) { + const errorMessage = await parseFetchError(response); + showToast(errorMessage, "error"); + } + + return response; + } catch (error) { + // Don't show toast for redirect errors + if ((error as Error).message !== "Session expired") { + showToast("Network error: Unable to connect to server", "error"); + } + throw error; + } + }; }; // Initialize toast system const initializeToastSystem = (): void => { - setupHTMXListeners(); - setupFetchInterceptor(); + setupHTMXListeners(); + setupFetchInterceptor(); }; // Auto-initialize when DOM is ready -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeToastSystem); - } else { - initializeToastSystem(); - } +if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeToastSystem); + } else { + initializeToastSystem(); + } } // Export toast API for manual use (window as any).showToast = { - error: (message: string, duration?: number) => showToast(message, 'error', duration), - success: (message: string, duration?: number) => showToast(message, 'success', duration), - info: (message: string, duration?: number) => showToast(message, 'info', duration) + error: (message: string, duration?: number) => + showToast(message, "error", duration), + success: (message: string, duration?: number) => + showToast(message, "success", duration), + info: (message: string, duration?: number) => + showToast(message, "info", duration), }; diff --git a/web/src/types/api.d.ts b/web/src/types/api.d.ts index 6628d09..ae352d6 100644 --- a/web/src/types/api.d.ts +++ b/web/src/types/api.d.ts @@ -11,75 +11,75 @@ // Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it // Used in: search.ts interface MediaItemSummary { - id: string; - library_id: string; - title: string; - author?: string; - isbn?: string; - description?: string; - file_path: string; - file_size?: number; - mime_type?: string; - cover_image_path?: string; - series?: string; - series_number?: number; - tags?: string[]; - asin?: string; - date_published?: string; - publisher?: string; - contributors?: string[]; - language?: string; - edition?: string; - page_count?: number; - genre?: string; - copyright_year?: number; - goodreads_id?: string; - openlibrary_id?: string; - google_books_id?: string; - added_by_admin_id?: string; - created_at: string; - updated_at: string; - format_group: string; - format_mimetype?: string; - is_reflowable?: boolean; - has_fixed_layout?: boolean; - total_characters?: number; - chapter_count?: number; - entitlement_id?: string; - revision_number?: number; - kobo_content_id?: string; - kobo_metadata?: string; - tags_search?: string[]; - contributors_search?: string[]; - file_sha256?: string; - opf_identifier?: string; - opf_uuid?: string; - hash_confidence?: string; - library_name: string; - library_type_name: string; + id: string; + library_id: string; + title: string; + author?: string; + isbn?: string; + description?: string; + file_path: string; + file_size?: number; + mime_type?: string; + cover_image_path?: string; + series?: string; + series_number?: number; + tags?: string[]; + asin?: string; + date_published?: string; + publisher?: string; + contributors?: string[]; + language?: string; + edition?: string; + page_count?: number; + genre?: string; + copyright_year?: number; + goodreads_id?: string; + openlibrary_id?: string; + google_books_id?: string; + added_by_admin_id?: string; + created_at: string; + updated_at: string; + format_group: string; + format_mimetype?: string; + is_reflowable?: boolean; + has_fixed_layout?: boolean; + total_characters?: number; + chapter_count?: number; + entitlement_id?: string; + revision_number?: number; + kobo_content_id?: string; + kobo_metadata?: string; + tags_search?: string[]; + contributors_search?: string[]; + file_sha256?: string; + opf_identifier?: string; + opf_uuid?: string; + hash_confidence?: string; + library_name: string; + library_type_name: string; } // Matches handlers.CollectionData / CollectionResponse JSON response // Source: internal/handlers/collections.go:123-131 CollectionResponse // Used in: collections.ts interface CollectionData { - id: string; - name: string; - description: string; - color: string; - icon: string; - auto_assign_rules?: unknown; - created_at: string; + id: string; + name: string; + description: string; + color: string; + icon: string; + auto_assign_rules?: unknown; + created_at: string; } // Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71) // JSON tags: media_item_id, title, author, cover_image_path // Used in: collections.templ (server-rendered), collections.ts interface BookInfo { - media_item_id: string; - title: string; - author: string; - cover_image_path: string; + media_item_id: string; + title: string; + author: string; + cover_image_path: string; } // Dashboard type definitions @@ -87,237 +87,252 @@ interface BookInfo { // Source: handlers.SectionData in collections.go (lines 73-81) // Used in: dashboard API responses, TypeScript dashboard components interface SectionData { - id: string; - is_system: boolean; - title: string; - description: string; - icon: string; - items: BookInfo[]; - view_all_url: string; - priority: number; + id: string; + is_system: boolean; + title: string; + description: string; + icon: string; + items: BookInfo[]; + view_all_url: string; + priority: number; } // Matches database.UserDashboardPreferences and dashboard preferences API // Source: internal/database/models.go:381-390 // Used in: dashboard preferences API interface DashboardPreferences { - library_id: string; - hidden_collections: string[]; - collection_order: string[]; - items_per_section: number; + library_id: string; + hidden_collections: string[]; + collection_order: string[]; + items_per_section: number; } // Matches handlers.UnlinkedBookData JSON response // Used in: unlinked_books.ts, unlinked_books.templ interface UnlinkedBookData { - progress_id: string; - device_id: string; - device_name: string; - device_type: 'koreader' | 'kobo' | 'web'; - title_from_device: string; - file_path: string; - sha256: string; - last_sync_time: string; - confidence_score: number; - potential_matches: PotentialMatchData[]; + progress_id: string; + device_id: string; + device_name: string; + device_type: "koreader" | "kobo" | "web"; + title_from_device: string; + file_path: string; + sha256: string; + last_sync_time: string; + confidence_score: number; + potential_matches: PotentialMatchData[]; } interface PotentialMatchData { - media_item_id: string; - title: string; - author: string; - confidence: number; - cover_image_path?: string; + media_item_id: string; + title: string; + author: string; + confidence: number; + cover_image_path?: string; } // Matches collection rule objects // Used in: collection_rules.ts interface CollectionRule { - id: string; - field: 'genre' | 'series' | 'author' | 'language' | 'publisher' | 'copyright_year' | 'tags'; - operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'greater_than' | 'less_than'; - value: string; - enabled: boolean; - priority: number; + id: string; + field: + | "genre" + | "series" + | "author" + | "language" + | "publisher" + | "copyright_year" + | "tags"; + operator: + | "equals" + | "not_equals" + | "contains" + | "not_contains" + | "starts_with" + | "ends_with" + | "greater_than" + | "less_than"; + value: string; + enabled: boolean; + priority: number; } // Matches API test rule responses // Used in: collection_rules.ts (test results) interface TestRuleMatch { - title: string; - author: string; - cover_image_path?: string; + title: string; + author: string; + cover_image_path?: string; } // Matches handlers.SearchResponse (internal/handlers/search.go) interface SearchResponse { - results: SearchBookResponse[]; - total: number; + results: SearchBookResponse[]; + total: number; } interface SearchBookResponse { - id: string; - title: string; - authors: SearchAuthor[]; + id: string; + title: string; + authors: SearchAuthor[]; } interface SearchAuthor { - first_name: string; - last_name: string; + first_name: string; + last_name: string; } // Matches AuthResponse (internal/handlers/auth.go:59-65) interface AuthResponse { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - user: UserProfile; + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; + user: UserProfile; } interface UserProfile { - id: string; - email: string; - username: string; - first_name?: string; - last_name?: string; - role: string; - theme?: string; + id: string; + email: string; + username: string; + first_name?: string; + last_name?: string; + role: string; + theme?: string; } // Matches handlers.ReadingStatsResponse (internal/handlers/analytics.go:26-35) // Used in: analytics.ts interface ReadingStatsResponse { - total_books_read: number; - total_pages_read: number; - total_reading_time_minutes: number; - average_session_time_minutes: number; - longest_session_minutes: number; - most_active_day_of_week: string; - completion_rate: number; - daily_reading_minutes: DailyReading[]; + total_books_read: number; + total_pages_read: number; + total_reading_time_minutes: number; + average_session_time_minutes: number; + longest_session_minutes: number; + most_active_day_of_week: string; + completion_rate: number; + daily_reading_minutes: DailyReading[]; } interface DailyReading { - date: string; - minutes: number; - pages: number; + date: string; + minutes: number; + pages: number; } // Matches handlers.DeviceUsageResponse (internal/handlers/analytics.go:43-45) // Note: Response is wrapped: { devices: DeviceUsage[] } // Used in: analytics.ts interface DeviceUsageResponse { - devices: DeviceUsage[]; + devices: DeviceUsage[]; } interface DeviceUsage { - device_id: string; - device_name: string; - device_type: string; - sync_count: number; - last_sync: string; - total_time_seconds: number; - total_time_minutes: number; + device_id: string; + device_name: string; + device_type: string; + sync_count: number; + last_sync: string; + total_time_seconds: number; + total_time_minutes: number; } // Matches handlers.PopularBooksResponse (internal/handlers/analytics.go:57-59) // Note: Response is wrapped: { books: PopularBook[] } // Used in: analytics.ts interface PopularBooksResponse { - books: PopularBook[]; + books: PopularBook[]; } interface PopularBook { - media_item_id: string; - title: string; - author: string; - read_count: number; - avg_completion: number; - last_read: string; + media_item_id: string; + title: string; + author: string; + read_count: number; + avg_completion: number; + last_read: string; } // Matches handlers.QueueItemResponse (internal/handlers/queue.go:35-51) // Used in: queue.ts interface QueueItemResponse { - id: string; - device_id: string; - device_name: string; - device_type: string; - media_item_id?: string; - media_title?: string; - user_email: string; - sync_type: string; - priority: number; - attempts: number; - max_attempts: number; - status: string; - error_message?: string; - created_at: string; - processed_at?: string; + id: string; + device_id: string; + device_name: string; + device_type: string; + media_item_id?: string; + media_title?: string; + user_email: string; + sync_type: string; + priority: number; + attempts: number; + max_attempts: number; + status: string; + error_message?: string; + created_at: string; + processed_at?: string; } // Matches handlers.QueueStatsResponse (internal/handlers/queue.go:27-33) // Used in: queue.ts interface QueueStatsResponse { - pending_count: number; - processing_count: number; - failed_count: number; - completed_count: number; - total_count: number; + pending_count: number; + processing_count: number; + failed_count: number; + completed_count: number; + total_count: number; } // Matches handlers.ConflictDetailResponse (internal/handlers/conflicts.go:42-53) // Used in: conflicts.ts interface ConflictDetailResponse { - id: string; - media_item_id: string; - media_item_title: string; - conflict_type: string; - conflict_data: Record; - resolution_status: string; - resolution_data?: Record; - resolved_by?: string; - resolved_at?: string; - created_at: string; + id: string; + media_item_id: string; + media_item_title: string; + conflict_type: string; + conflict_data: Record; + resolution_status: string; + resolution_data?: Record; + resolved_by?: string; + resolved_at?: string; + created_at: string; } // Matches handlers.ConflictSourceData (internal/handlers/conflicts.go:36-40) interface ConflictSourceData { - source: string; - timestamp: string; - data: Record; + source: string; + timestamp: string; + data: Record; } // Matches handlers.ConflictListResponse (internal/handlers/conflicts.go:55-59) // Used in: conflicts.ts interface ConflictListResponse { - conflicts: ConflictDetailResponse[]; - total: number; - unresolved: number; + conflicts: ConflictDetailResponse[]; + total: number; + unresolved: number; } // Matches handlers.ConflictResolveResponse (internal/handlers/conflicts.go:61-65) // Used in: conflicts.ts interface ConflictResolveResponse { - conflict_resolved: boolean; - applied_to: Record; - devices_synced: string[]; + conflict_resolved: boolean; + applied_to: Record; + devices_synced: string[]; } // Matches handlers.BulkResolveResponse (internal/handlers/conflicts.go:424-429) // Used in: conflicts.ts interface BulkResolveResponse { - results: ConflictResult[]; - total: number; - success: number; - failed: number; + results: ConflictResult[]; + total: number; + success: number; + failed: number; } // Matches handlers.ConflictResult (internal/handlers/conflicts.go:431-436) interface ConflictResult { - conflict_id: string; - status: string; - error?: string; - winner?: string; + conflict_id: string; + status: string; + error?: string; + winner?: string; } diff --git a/web/src/woodPaneling.ts b/web/src/woodPaneling.ts index 8484a7d..f1a04bb 100644 --- a/web/src/woodPaneling.ts +++ b/web/src/woodPaneling.ts @@ -1,70 +1,76 @@ // Wood paneling management functionality -type WoodPanelingType = 'none' | 'wood-light' | 'wood-dark' | 'wood-mahogany'; +type WoodPanelingType = "none" | "wood-light" | "wood-dark" | "wood-mahogany"; -const WOOD_STORAGE_KEY = 'wood-paneling'; +const WOOD_STORAGE_KEY = "wood-paneling"; // Apply wood paneling to collections container const applyWoodPaneling = (paneling: WoodPanelingType): void => { - const container = document.getElementById('collections-container'); - if (!container) return; + const container = document.getElementById("collections-container"); + if (!container) return; - // Remove all wood background classes - container.classList.remove('bg-wood-light', 'bg-wood-dark', 'bg-wood-mahogany'); - container.removeAttribute('data-wood'); + // Remove all wood background classes + container.classList.remove( + "bg-wood-light", + "bg-wood-dark", + "bg-wood-mahogany", + ); + container.removeAttribute("data-wood"); - if (paneling !== 'none') { - // Add selected wood background class - container.classList.add(`bg-${paneling}`); - container.setAttribute('data-wood', paneling); - } + if (paneling !== "none") { + // Add selected wood background class + container.classList.add(`bg-${paneling}`); + container.setAttribute("data-wood", paneling); + } - // Save to localStorage - localStorage.setItem(WOOD_STORAGE_KEY, paneling); + // Save to localStorage + localStorage.setItem(WOOD_STORAGE_KEY, paneling); }; // Load wood paneling from localStorage on page load const loadWoodPaneling = (): void => { - const stored = localStorage.getItem(WOOD_STORAGE_KEY) as WoodPanelingType | null; - if (stored) { - applyWoodPaneling(stored); - } else { - // Default to none - applyWoodPaneling('none'); - } + const stored = localStorage.getItem( + WOOD_STORAGE_KEY, + ) as WoodPanelingType | null; + if (stored) { + applyWoodPaneling(stored); + } else { + // Default to none + applyWoodPaneling("none"); + } }; // Change wood paneling (called from theme dropdown) const changeWoodPaneling = (paneling: WoodPanelingType): void => { - applyWoodPaneling(paneling); + applyWoodPaneling(paneling); - // Update active indicators - updateWoodPanelingIndicators(); + // Update active indicators + updateWoodPanelingIndicators(); - // Close dropdown - const dropdown = document.getElementById('theme-dropdown'); - if (dropdown) { - dropdown.classList.add('hidden'); - } + // Close dropdown + const dropdown = document.getElementById("theme-dropdown"); + if (dropdown) { + dropdown.classList.add("hidden"); + } }; // Update visual indicators for wood paneling buttons const updateWoodPanelingIndicators = (): void => { - const currentWood = localStorage.getItem(WOOD_STORAGE_KEY) || 'none'; + const currentWood = localStorage.getItem(WOOD_STORAGE_KEY) || "none"; - // Update wood paneling buttons - document.querySelectorAll('.wood-paneling-btn').forEach(btn => { - const wood = btn.getAttribute('data-wood'); - if (wood === currentWood) { - // Active state - use CSS class instead of inline style - btn.classList.add('bg-wood-active'); - btn.classList.remove('bg-wood-inactive'); - } else { - // Inactive state - btn.classList.remove('bg-wood-active'); - btn.classList.add('bg-wood-inactive'); - } - }); + // Update wood paneling buttons + document.querySelectorAll(".wood-paneling-btn").forEach((btn) => { + const wood = btn.getAttribute("data-wood"); + if (wood === currentWood) { + // Active state - use CSS class instead of inline style + btn.classList.add("bg-wood-active"); + btn.classList.remove("bg-wood-inactive"); + } else { + // Inactive state + btn.classList.remove("bg-wood-active"); + btn.classList.add("bg-wood-inactive"); + } + }); }; // Make functions available globally @@ -73,14 +79,14 @@ const updateWoodPanelingIndicators = (): void => { (window as any).updateWoodPanelingIndicators = updateWoodPanelingIndicators; // Auto-initialize when DOM is ready -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - loadWoodPaneling(); - updateWoodPanelingIndicators(); - }); - } else { - loadWoodPaneling(); - updateWoodPanelingIndicators(); - } +if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + loadWoodPaneling(); + updateWoodPanelingIndicators(); + }); + } else { + loadWoodPaneling(); + updateWoodPanelingIndicators(); + } } diff --git a/web/src/woodPanelingInit.ts b/web/src/woodPanelingInit.ts index 202122f..feb9f29 100644 --- a/web/src/woodPanelingInit.ts +++ b/web/src/woodPanelingInit.ts @@ -1,25 +1,25 @@ // Early initialization script to prevent flash of wrong background // Loads before woodPaneling.js to apply paneling immediately -const WOOD_INIT_STORAGE_KEY = 'wood-paneling'; +const WOOD_INIT_STORAGE_KEY = "wood-paneling"; // Apply wood paneling immediately (before DOM ready if possible) -(function() { - const woodPaneling = localStorage.getItem(WOOD_INIT_STORAGE_KEY) || 'none'; - if (woodPaneling !== 'none') { - const applyPaneling = () => { - const container = document.getElementById('collections-container'); - if (container) { - container.classList.add(`bg-${woodPaneling}`); - container.setAttribute('data-wood', woodPaneling); - } - }; +(function () { + const woodPaneling = localStorage.getItem(WOOD_INIT_STORAGE_KEY) || "none"; + if (woodPaneling !== "none") { + const applyPaneling = () => { + const container = document.getElementById("collections-container"); + if (container) { + container.classList.add(`bg-${woodPaneling}`); + container.setAttribute("data-wood", woodPaneling); + } + }; - // Apply immediately if DOM is ready, otherwise wait - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', applyPaneling); - } else { - applyPaneling(); - } + // Apply immediately if DOM is ready, otherwise wait + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", applyPaneling); + } else { + applyPaneling(); } + } })();