feat(typescript): add feature modules for template conversion
- Add search.ts - header search with keyboard navigation - Debounced search with 300ms delay - Arrow key navigation through results - Escape to close, Enter to select - Library type icons and highlighting - Add collections.ts - collection and rule management - Rule CRUD operations (create, update, delete) - Rule testing functionality - Bulk collection operations - Add bookshelf.ts - book display and navigation - Library selection state management - Book viewing interactions - Pagination logic - Add linking.ts - book matching and manual linking - Search and match functionality - Manual link modal - Bulk auto-link and suggestions - Add api-explorer.ts - API testing interface - Request/response display - cURL command generation - History tracking - Add admin.ts - admin dashboard actions - Library scan triggers - System statistics display - Profile management - Add analytics.ts - analytics data loading - Chart.js integration - Daily reading minutes chart - Device usage and popular books display - Add queue.ts - sync queue management - Process pending items - Clear failed/all items - Filter by status, type, device - Add conflicts.ts - conflict resolution - Individual and bulk resolve operations - Winner device selection - Manual override inputs - Add docs.ts - documentation search - Lunr.js search integration - Sidebar toggle for mobile
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
async function triggerLibraryScan(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Library scan started');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error(error.error || 'Failed to start scan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scan error:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to start library scan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerQuickScan(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/quick-scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Quick scan started');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error(error.error || 'Failed to start quick scan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Quick scan error:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to start quick scan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSystemStats(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/stats', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const stats = await response.json();
|
||||||
|
renderSystemStats(stats);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystemStats(stats: Record<string, unknown>): void {
|
||||||
|
const container = document.getElementById('system-stats');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books || 0}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Total Books</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_users || 0}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Users</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_devices || 0}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Devices</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_libraries || 0}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Libraries</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).triggerLibraryScan = triggerLibraryScan;
|
||||||
|
(window as any).triggerQuickScan = triggerQuickScan;
|
||||||
|
(window as any).loadSystemStats = loadSystemStats;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { ReadingStatsResponse, DeviceUsageResponse, PopularBooksResponse } from './types/api';
|
||||||
|
|
||||||
|
async function loadAnalytics(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statsRes, devicesRes, popularRes] = await Promise.all([
|
||||||
|
fetch('/api/analytics/stats', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
}),
|
||||||
|
fetch('/api/analytics/devices', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
}),
|
||||||
|
fetch('/api/analytics/popular', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (statsRes.ok) {
|
||||||
|
const stats: ReadingStatsResponse = await statsRes.json();
|
||||||
|
renderReadingStats(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (devicesRes.ok) {
|
||||||
|
const devices: DeviceUsageResponse = await devicesRes.json();
|
||||||
|
renderDeviceUsage(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popularRes.ok) {
|
||||||
|
const popular: PopularBooksResponse = await popularRes.json();
|
||||||
|
renderPopularBooks(popular);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load analytics:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to load analytics data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReadingStats(stats: ReadingStatsResponse): void {
|
||||||
|
const container = document.getElementById('reading-stats');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books_read}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Books Read</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_pages_read}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Pages Read</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_reading_time_minutes}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Minutes Reading</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">${Math.round(stats.completion_rate * 100)}%</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Completion Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeviceUsage(devices: DeviceUsageResponse): void {
|
||||||
|
const container = document.getElementById('device-usage');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!devices.devices || devices.devices.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color: var(--text-secondary)">No device usage data available</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = devices.devices.map(device => `
|
||||||
|
<div class="p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium" style="color: var(--text-primary)">${device.device_name}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">${device.device_type}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-medium" style="color: var(--text-primary)">${Math.round(device.total_time_minutes)} min</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">${device.sync_count} syncs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPopularBooks(popular: PopularBooksResponse): void {
|
||||||
|
const container = document.getElementById('popular-books');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!popular.books || popular.books.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color: var(--text-secondary)">No reading history available</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = popular.books.map(book => `
|
||||||
|
<div class="p-3 rounded-lg border flex items-center space-x-3" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium" style="color: var(--text-primary)">${book.title}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">${book.author}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-medium" style="color: var(--text-primary)">${book.read_count}x</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">${Math.round(book.avg_completion * 100)}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadAnalytics);
|
||||||
|
|
||||||
|
(window as any).loadAnalytics = loadAnalytics;
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
interface ApiExplorerRequest {
|
||||||
|
method: string;
|
||||||
|
endpoint: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestHistory: ApiExplorerRequest[] = [];
|
||||||
|
|
||||||
|
function sendApiRequest(): void {
|
||||||
|
const method = (document.getElementById('api-method') as HTMLSelectElement)?.value || 'GET';
|
||||||
|
const endpoint = (document.getElementById('api-endpoint') as HTMLInputElement)?.value || '';
|
||||||
|
const bodyText = (document.getElementById('api-body') as HTMLTextAreaElement)?.value || '';
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ApiExplorerRequest = {
|
||||||
|
method,
|
||||||
|
endpoint,
|
||||||
|
headers,
|
||||||
|
body: bodyText || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
addToHistory(request);
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
fetch(endpoint, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: bodyText || undefined
|
||||||
|
})
|
||||||
|
.then(async response => {
|
||||||
|
const endTime = performance.now();
|
||||||
|
const duration = Math.round(endTime - startTime);
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
let responseData: unknown;
|
||||||
|
try {
|
||||||
|
responseData = JSON.parse(responseText);
|
||||||
|
} catch {
|
||||||
|
responseData = responseText;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayResponse(response, responseData, duration);
|
||||||
|
generateCurl(request);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
displayError(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResponse(response: Response, data: unknown, duration: number): void {
|
||||||
|
const container = document.getElementById('api-response');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const statusColor = response.ok ? 'var(--accent)' : 'var(--error)';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="px-2 py-1 rounded text-sm" style="background-color: ${statusColor}; color: var(--bg-primary)">
|
||||||
|
${response.status} ${response.statusText}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm ml-2" style="color: var(--text-secondary)">${duration}ms</span>
|
||||||
|
</div>
|
||||||
|
<pre class="p-4 rounded overflow-auto max-h-96" style="background-color: var(--bg-primary); color: var(--text-primary)">${JSON.stringify(data, null, 2)}</pre>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayError(error: Error): void {
|
||||||
|
const container = document.getElementById('api-response');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="p-4 rounded" style="background-color: var(--bg-primary)">
|
||||||
|
<p style="color: var(--error)">Error: ${error.message}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCurl(request: ApiExplorerRequest): void {
|
||||||
|
const container = document.getElementById('curl-command');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let curl = `curl -X ${request.method} '${request.endpoint}'`;
|
||||||
|
|
||||||
|
Object.entries(request.headers).forEach(([key, value]) => {
|
||||||
|
curl += ` \\\n -H '${key}: ${value}'`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.body) {
|
||||||
|
curl += ` \\\n -d '${request.body}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.textContent = curl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToHistory(request: ApiExplorerRequest): void {
|
||||||
|
requestHistory.unshift(request);
|
||||||
|
if (requestHistory.length > 20) {
|
||||||
|
requestHistory.pop();
|
||||||
|
}
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory(): void {
|
||||||
|
const container = document.getElementById('request-history');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (requestHistory.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-sm p-2" style="color: var(--text-secondary)">No requests yet</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = requestHistory.slice(0, 10).map((req, i) => `
|
||||||
|
<div class="p-2 rounded cursor-pointer hover:bg-opacity-50 transition-colors"
|
||||||
|
style="background-color: var(--bg-secondary)"
|
||||||
|
onclick="window.loadFromHistory(${i})">
|
||||||
|
<span class="text-xs font-mono" style="color: ${req.method === 'GET' ? 'var(--accent)' : req.method === 'POST' ? 'var(--success)' : req.method === 'DELETE' ? 'var(--error)' : 'var(--text-primary)'}">${req.method}</span>
|
||||||
|
<span class="text-xs ml-2" style="color: var(--text-secondary)">${req.endpoint}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromHistory(index: number): void {
|
||||||
|
const request = requestHistory[index];
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
const methodSelect = document.getElementById('api-method') as HTMLSelectElement;
|
||||||
|
const endpointInput = document.getElementById('api-endpoint') as HTMLInputElement;
|
||||||
|
const bodyInput = document.getElementById('api-body') as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
if (methodSelect) methodSelect.value = request.method;
|
||||||
|
if (endpointInput) endpointInput.value = request.endpoint;
|
||||||
|
if (bodyInput) bodyInput.value = request.body || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCurl(): void {
|
||||||
|
const curl = document.getElementById('curl-command')?.textContent;
|
||||||
|
if (curl) {
|
||||||
|
navigator.clipboard.writeText(curl);
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('cURL copied to clipboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson(): void {
|
||||||
|
const bodyInput = document.getElementById('api-body') as HTMLTextAreaElement;
|
||||||
|
if (!bodyInput) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(bodyInput.value);
|
||||||
|
bodyInput.value = JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Invalid JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).sendApiRequest = sendApiRequest;
|
||||||
|
(window as any).loadFromHistory = loadFromHistory;
|
||||||
|
(window as any).copyCurl = copyCurl;
|
||||||
|
(window as any).formatJson = formatJson;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
function selectLibrary(libraryId: string): void {
|
||||||
|
localStorage.setItem('selectedLibrary', libraryId);
|
||||||
|
|
||||||
|
document.querySelectorAll('.library-item').forEach(el => {
|
||||||
|
el.classList.remove('ring-2');
|
||||||
|
el.classList.remove('ring-accent');
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = document.querySelector(`[data-library-id="${libraryId}"]`);
|
||||||
|
if (selected) {
|
||||||
|
selected.classList.add('ring-2');
|
||||||
|
selected.classList.add('ring-accent');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBookshelf(libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBookshelf(libraryId: string): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/libraries/${libraryId}/books`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
renderBooks(data.books || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load bookshelf:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBooks(books: unknown[]): void {
|
||||||
|
const container = document.getElementById('books-grid');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (books.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-center p-8" style="color: var(--text-secondary)">No books in this library</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = books.map((book: any) => `
|
||||||
|
<div class="book-card p-3 rounded-lg border transition-transform hover:scale-105 cursor-pointer"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border)"
|
||||||
|
onclick="window.selectBook('${book.id}')">
|
||||||
|
${book.cover_image_path ?
|
||||||
|
`<img src="/covers/${book.cover_image_path}" alt="${book.title}" class="w-full h-48 object-cover rounded mb-2">` :
|
||||||
|
`<div class="w-full h-48 rounded mb-2 flex items-center justify-center" style="background-color: var(--bg-primary)">
|
||||||
|
<span class="text-4xl">📖</span>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
<h3 class="font-medium text-sm truncate" style="color: var(--text-primary)">${book.title}</h3>
|
||||||
|
<p class="text-xs truncate" style="color: var(--text-secondary)">${book.author || 'Unknown Author'}</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBook(bookId: string): void {
|
||||||
|
localStorage.setItem('selectedBook', bookId);
|
||||||
|
window.location.href = `/books/${bookId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(page: number): void {
|
||||||
|
const libraryId = localStorage.getItem('selectedLibrary');
|
||||||
|
if (!libraryId) return;
|
||||||
|
|
||||||
|
const offset = (page - 1) * 50;
|
||||||
|
loadBookshelfPaginated(libraryId, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBookshelfPaginated(libraryId: string, offset: number): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/libraries/${libraryId}/books?offset=${offset}&limit=50`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
renderBooks(data.books || []);
|
||||||
|
updatePagination(data.total, offset);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load bookshelf:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination(total: number, offset: number): void {
|
||||||
|
const container = document.getElementById('pagination');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const limit = 50;
|
||||||
|
const currentPage = Math.floor(offset / limit) + 1;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="flex justify-center space-x-2">
|
||||||
|
${currentPage > 1 ? `<button onclick="window.changePage(${currentPage - 1})" class="btn-secondary px-3 py-1 rounded">Previous</button>` : ''}
|
||||||
|
<span class="px-3 py-1" style="color: var(--text-secondary)">Page ${currentPage} of ${totalPages}</span>
|
||||||
|
${currentPage < totalPages ? `<button onclick="window.changePage(${currentPage + 1})" class="btn-secondary px-3 py-1 rounded">Next</button>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).selectLibrary = selectLibrary;
|
||||||
|
(window as any).loadBookshelf = loadBookshelf;
|
||||||
|
(window as any).selectBook = selectBook;
|
||||||
|
(window as any).changePage = changePage;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { CollectionData, CollectionRule } from './types/api';
|
||||||
|
|
||||||
|
async function loadCollections(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/collections', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
renderCollections(data.collections || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load collections:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCollections(collections: CollectionData[]): void {
|
||||||
|
const container = document.getElementById('collections-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (collections.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No collections yet</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = collections.map(collection => `
|
||||||
|
<a href="/collections/${collection.id}" class="block p-4 rounded-lg border transition-colors hover:border-opacity-50"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="text-2xl">${collection.icon || '📁'}</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium" style="color: var(--text-primary)">${collection.name}</h3>
|
||||||
|
${collection.description ? `<p class="text-sm" style="color: var(--text-secondary)">${collection.description}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCollectionRules(collectionId: string): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const rules: CollectionRule[] = await response.json();
|
||||||
|
renderRules(rules);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load rules:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRules(rules: CollectionRule[]): void {
|
||||||
|
const container = document.getElementById('rules-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = rules.map(rule => `
|
||||||
|
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? 'Enabled' : 'Disabled'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
|
||||||
|
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(rule)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Rule created');
|
||||||
|
}
|
||||||
|
loadCollectionRules(collectionId);
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error(error.error || 'Failed to create rule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create rule:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to create rule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
if (!confirm('Are you sure you want to delete this rule?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Rule deleted');
|
||||||
|
}
|
||||||
|
loadCollectionRules(collectionId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete rule:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to delete rule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/collections/${collectionId}/rules/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(rule)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const results = await response.json();
|
||||||
|
renderTestResults(results);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to test rule:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to test rule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTestResults(results: unknown[]): void {
|
||||||
|
const container = document.getElementById('test-results');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!results || (Array.isArray(results) && results.length === 0)) {
|
||||||
|
container.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).loadCollections = loadCollections;
|
||||||
|
(window as any).loadCollectionRules = loadCollectionRules;
|
||||||
|
(window as any).createRule = createRule;
|
||||||
|
(window as any).deleteRule = deleteRule;
|
||||||
|
(window as any).testRule = testRule;
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import type { ConflictDetailResponse, ConflictListResponse, BulkResolveResponse } from './types/api';
|
||||||
|
|
||||||
|
async function refreshConflicts(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conflicts', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: ConflictListResponse = await response.json();
|
||||||
|
renderConflicts(data.conflicts);
|
||||||
|
updateConflictStats(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh conflicts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveConflict(conflictId: string, winner: string, manualData?: Record<string, unknown>): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conflicts/${conflictId}/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ winner, manual_data: manualData })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Conflict resolved');
|
||||||
|
}
|
||||||
|
refreshConflicts();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error(error.error || 'Failed to resolve conflict');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve conflict:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to resolve conflict');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkResolve(strategy: 'most_recent' | 'highest_progress', conflictIds: string[]): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conflicts/bulk-resolve', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ conflict_ids: conflictIds, strategy })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: BulkResolveResponse = await response.json();
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success(`Resolved ${data.success} conflicts`);
|
||||||
|
}
|
||||||
|
refreshConflicts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to bulk resolve:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to bulk resolve conflicts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDismiss(conflictIds: string[]): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conflicts/bulk-dismiss', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ conflict_ids: conflictIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Conflicts dismissed');
|
||||||
|
}
|
||||||
|
refreshConflicts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to dismiss conflicts:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to dismiss conflicts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissAllResolved(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conflicts/dismiss-resolved', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Resolved conflicts dismissed');
|
||||||
|
}
|
||||||
|
refreshConflicts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to dismiss resolved:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to dismiss resolved conflicts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
|
||||||
|
const container = document.getElementById('conflicts-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (conflicts.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = conflicts.map(conflict => `
|
||||||
|
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
|
||||||
|
</div>
|
||||||
|
${conflict.resolution_status === 'unresolved' ? `
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConflictStats(data: ConflictListResponse): void {
|
||||||
|
const totalEl = document.getElementById('conflicts-total');
|
||||||
|
const unresolvedEl = document.getElementById('conflicts-unresolved');
|
||||||
|
|
||||||
|
if (totalEl) totalEl.textContent = String(data.total);
|
||||||
|
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResolveModal(conflictId: string): void {
|
||||||
|
const modal = document.getElementById('resolve-modal');
|
||||||
|
const conflictIdInput = document.getElementById('resolve-conflict-id') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (modal && conflictIdInput) {
|
||||||
|
conflictIdInput.value = conflictId;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideResolveModal(): void {
|
||||||
|
const modal = document.getElementById('resolve-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResolveSubmit(event: Event): void {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const form = event.target as HTMLFormElement;
|
||||||
|
const conflictId = (form.querySelector('#resolve-conflict-id') as HTMLInputElement)?.value;
|
||||||
|
const winner = (form.querySelector('input[name="winner"]:checked') as HTMLInputElement)?.value;
|
||||||
|
|
||||||
|
if (!conflictId || !winner) {
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Please select a winner');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveConflict(conflictId, winner);
|
||||||
|
hideResolveModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).refreshConflicts = refreshConflicts;
|
||||||
|
(window as any).resolveConflict = resolveConflict;
|
||||||
|
(window as any).bulkResolve = bulkResolve;
|
||||||
|
(window as any).bulkDismiss = bulkDismiss;
|
||||||
|
(window as any).dismissAllResolved = dismissAllResolved;
|
||||||
|
(window as any).showResolveModal = showResolveModal;
|
||||||
|
(window as any).hideResolveModal = hideResolveModal;
|
||||||
|
(window as any).handleResolveSubmit = handleResolveSubmit;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
function toggleSidebar(): void {
|
||||||
|
const sidebar = document.getElementById('docs-sidebar');
|
||||||
|
const overlay = document.getElementById('docs-overlay');
|
||||||
|
|
||||||
|
if (sidebar && overlay) {
|
||||||
|
sidebar.classList.toggle('translate-x-0');
|
||||||
|
sidebar.classList.toggle('-translate-x-full');
|
||||||
|
overlay.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeDocsSearch(): void {
|
||||||
|
const searchInput = document.getElementById('docs-search') as HTMLInputElement;
|
||||||
|
const searchResults = document.getElementById('docs-search-results');
|
||||||
|
|
||||||
|
if (!searchInput || !searchResults) return;
|
||||||
|
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
searchResults.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
performDocsSearch(query);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function performDocsSearch(query: string): void {
|
||||||
|
const searchResults = document.getElementById('docs-search-results');
|
||||||
|
if (!searchResults) return;
|
||||||
|
|
||||||
|
if (!(window as any).lunr) {
|
||||||
|
console.warn('Lunr.js not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idx = (window as any).lunrIndex;
|
||||||
|
if (!idx) {
|
||||||
|
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = idx.search(query);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
|
||||||
|
} else {
|
||||||
|
searchResults.innerHTML = results.slice(0, 10).map((result: { ref: string }) => {
|
||||||
|
const doc = (window as any).docsData?.[result.ref];
|
||||||
|
if (!doc) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="${result.ref}" class="block p-2 hover:bg-opacity-50 transition-colors" style="background-color: var(--bg-secondary)">
|
||||||
|
<p class="font-medium text-sm" style="color: var(--text-primary)">${doc.title || result.ref}</p>
|
||||||
|
${doc.section ? `<p class="text-xs" style="color: var(--text-secondary)">${doc.section}</p>` : ''}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initializeDocsSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).toggleSidebar = toggleSidebar;
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import type { UnlinkedBookData, PotentialMatchData } from './types/api';
|
||||||
|
|
||||||
|
async function loadUnlinkedBooks(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sync/unlinked-books', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
renderUnlinkedBooks(data.unlinked || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load unlinked books:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
||||||
|
const container = document.getElementById('unlinked-books-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (books.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = books.map(book => `
|
||||||
|
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium" style="color: var(--text-primary)">${book.title_from_device}</h3>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">${book.device_name} (${book.device_type})</p>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">${book.file_path}</p>
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${book.potential_matches && book.potential_matches.length > 0 ? `
|
||||||
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border)">
|
||||||
|
<p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p>
|
||||||
|
${book.potential_matches.slice(0, 3).map(match => `
|
||||||
|
<div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sync/link-book', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ progress_id: progressId, media_item_id: mediaItemId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Book linked successfully');
|
||||||
|
}
|
||||||
|
loadUnlinkedBooks();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error(error.error || 'Failed to link book');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to link book:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to link book');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoLinkBooks(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
if (!confirm('Auto-link all books with high confidence matches?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sync/auto-link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ confidence_threshold: 0.9 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success(`Auto-linked ${data.linked_count || 0} books`);
|
||||||
|
}
|
||||||
|
loadUnlinkedBooks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-link:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to auto-link books');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSuggestions(progressId: string): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sync/suggestions/${progressId}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const suggestions = await response.json();
|
||||||
|
showSuggestionsModal(progressId, suggestions);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get suggestions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void {
|
||||||
|
const modal = document.getElementById('match-modal');
|
||||||
|
const content = document.getElementById('match-modal-content');
|
||||||
|
|
||||||
|
if (!modal || !content) return;
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
${suggestions.map(s => `
|
||||||
|
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
|
||||||
|
style="background-color: var(--bg-primary); border-color: var(--border)"
|
||||||
|
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
|
||||||
|
<p class="font-medium" style="color: var(--text-primary)">${s.title}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMatchModal(): void {
|
||||||
|
const modal = document.getElementById('match-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).loadUnlinkedBooks = loadUnlinkedBooks;
|
||||||
|
(window as any).linkBook = linkBook;
|
||||||
|
(window as any).autoLinkBooks = autoLinkBooks;
|
||||||
|
(window as any).getSuggestions = getSuggestions;
|
||||||
|
(window as any).showSuggestionsModal = showSuggestionsModal;
|
||||||
|
(window as any).hideMatchModal = hideMatchModal;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { QueueItemResponse } from './types/api';
|
||||||
|
|
||||||
|
async function refreshQueue(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/all', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
renderQueueItems(data.items || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh queue:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processPendingItems(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/process', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Processing queue items');
|
||||||
|
}
|
||||||
|
refreshQueue();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process queue:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to process queue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearFailedItems(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
if (!confirm('Are you sure you want to clear all failed items?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/failed', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Failed items cleared');
|
||||||
|
}
|
||||||
|
refreshQueue();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear failed items:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to clear items');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAllItems(): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
if (!confirm('Are you sure you want to clear all queue items?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queue/all', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Queue cleared');
|
||||||
|
}
|
||||||
|
refreshQueue();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear queue:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to clear queue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryQueueItem(itemId: string): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/queue/items/${itemId}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Item queued for retry');
|
||||||
|
}
|
||||||
|
refreshQueue();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to retry item:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to retry item');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQueueItem(itemId: string): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/queue/items/${itemId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if ((window as any).showToast?.success) {
|
||||||
|
(window as any).showToast.success('Item deleted');
|
||||||
|
}
|
||||||
|
refreshQueue();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete item:', error);
|
||||||
|
if ((window as any).showToast?.error) {
|
||||||
|
(window as any).showToast.error('Failed to delete item');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueueItems(items: QueueItemResponse[]): void {
|
||||||
|
const container = document.getElementById('queue-items');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">Queue is empty</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = items.map(item => `
|
||||||
|
<div class="p-3 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium" style="color: var(--text-primary)">${item.media_title || 'Unknown'}</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">${item.status} - ${item.sync_type}</p>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">Attempts: ${item.attempts}/${item.max_attempts}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
${item.status === 'failed' ? `<button onclick="window.retryQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Retry</button>` : ''}
|
||||||
|
<button onclick="window.deleteQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${item.error_message ? `<p class="text-xs mt-2" style="color: var(--accent)">${item.error_message}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).refreshQueue = refreshQueue;
|
||||||
|
(window as any).processPendingItems = processPendingItems;
|
||||||
|
(window as any).clearFailedItems = clearFailedItems;
|
||||||
|
(window as any).clearAllItems = clearAllItems;
|
||||||
|
(window as any).retryQueueItem = retryQueueItem;
|
||||||
|
(window as any).deleteQueueItem = deleteQueueItem;
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import type { MediaItemSummary } from './types/api';
|
||||||
|
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
const SEARCH_MIN_CHARS = 2;
|
||||||
|
|
||||||
|
function initializeSearch(): void {
|
||||||
|
const searchInput = document.getElementById('header-search') as HTMLInputElement | null;
|
||||||
|
if (!searchInput) {
|
||||||
|
console.warn('Search input not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', handleSearchInput);
|
||||||
|
searchInput.addEventListener('keydown', handleSearchKeydown);
|
||||||
|
searchInput.addEventListener('focus', () => {
|
||||||
|
if (searchInput.value.length >= SEARCH_MIN_CHARS) {
|
||||||
|
performSearch(searchInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
const searchInputEl = document.getElementById('header-search');
|
||||||
|
|
||||||
|
if (searchResults && !searchResults.contains(e.target as Node) && e.target !== searchInputEl) {
|
||||||
|
hideSearchResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchInput(e: Event): void {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const query = target.value.trim();
|
||||||
|
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length < SEARCH_MIN_CHARS) {
|
||||||
|
hideSearchResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
performSearch(query);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchKeydown(e: KeyboardEvent): void {
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
if (!searchResults || searchResults.classList.contains('hidden')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = searchResults.querySelectorAll('.search-result-item');
|
||||||
|
const currentIndex = parseInt(searchResults.dataset.selectedIndex || '-1');
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
|
||||||
|
selectSearchResult(items, nextIndex);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prevIndex = Math.max(currentIndex - 1, -1);
|
||||||
|
selectSearchResult(items, prevIndex);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentIndex >= 0 && items[currentIndex]) {
|
||||||
|
const link = items[currentIndex].querySelector('a');
|
||||||
|
if (link) link.click();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideSearchResults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSearchResult(items: NodeListOf<Element>, index: number): void {
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
if (i === index) {
|
||||||
|
item.classList.add('bg-opacity-80');
|
||||||
|
} else {
|
||||||
|
item.classList.remove('bg-opacity-80');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
if (searchResults) {
|
||||||
|
searchResults.dataset.selectedIndex = index.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performSearch(query: string): void {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
console.warn('No authentication token found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSearchLoading();
|
||||||
|
|
||||||
|
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return { error: 'no results found', results: [] };
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
|
||||||
|
hideSearchLoading();
|
||||||
|
|
||||||
|
if (data && 'error' in data && data.error === 'no results found') {
|
||||||
|
showNoResults(query);
|
||||||
|
} else if (Array.isArray(data) && data.length > 0) {
|
||||||
|
showSearchResults(data, query);
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
showNoResults(query);
|
||||||
|
} else {
|
||||||
|
showNoResults(query);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
hideSearchLoading();
|
||||||
|
console.error('Search error:', error);
|
||||||
|
showSearchError();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSearchLoading(): void {
|
||||||
|
createSearchResultsContainer();
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
if (!searchResults) return;
|
||||||
|
|
||||||
|
searchResults.innerHTML = `
|
||||||
|
<div class="p-4 text-center" style="color: var(--text-secondary)">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--accent)"></div>
|
||||||
|
<p class="mt-2 text-sm">Searching...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSearchLoading(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSearchResults(results: MediaItemSummary[], query: string): void {
|
||||||
|
createSearchResultsContainer();
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
if (!searchResults) return;
|
||||||
|
|
||||||
|
searchResults.dataset.selectedIndex = '-1';
|
||||||
|
|
||||||
|
const libraryIconMap: Record<string, string> = {
|
||||||
|
'ebooks': '📚',
|
||||||
|
'comics': '📖',
|
||||||
|
'manga': '🗾'
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="p-3 border-b" style="border-color: var(--border)">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
||||||
|
${results.length} result${results.length !== 1 ? 's' : ''} for "${escapeHtml(query)}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-96 overflow-y-auto">
|
||||||
|
`;
|
||||||
|
|
||||||
|
results.forEach((item, index) => {
|
||||||
|
const icon = libraryIconMap[item.library_type_name] || '📁';
|
||||||
|
const titleHtml = highlightMatch(item.title, query);
|
||||||
|
const authorHtml = item.author ? highlightMatch(item.author, query) : '';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
|
||||||
|
style="border-color: var(--border); background-color: var(--bg-secondary)"
|
||||||
|
data-index="${index}">
|
||||||
|
<a href="/bookshelf"
|
||||||
|
class="block"
|
||||||
|
onclick="window.selectLibraryAndBook('${item.library_id}', '${item.id}')">
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<div class="text-2xl">${icon}</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
|
||||||
|
${titleHtml}
|
||||||
|
</h4>
|
||||||
|
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''}
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
||||||
|
${escapeHtml(item.library_name)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</div>
|
||||||
|
<div class="p-2 border-t text-center" style="border-color: var(--border)">
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">
|
||||||
|
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">↑↓</kbd> to navigate,
|
||||||
|
<kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">Enter</kbd> to select
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
searchResults.innerHTML = html;
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNoResults(query: string): void {
|
||||||
|
createSearchResultsContainer();
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
if (!searchResults) return;
|
||||||
|
|
||||||
|
searchResults.innerHTML = `
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<div class="text-4xl mb-2">🔍</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-primary)">No results found for "${escapeHtml(query)}"</p>
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSearchError(): void {
|
||||||
|
createSearchResultsContainer();
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
if (!searchResults) return;
|
||||||
|
|
||||||
|
searchResults.innerHTML = `
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<div class="text-4xl mb-2">⚠️</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-primary)">Search error</p>
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSearchResults(): void {
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
if (searchResults) {
|
||||||
|
searchResults.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSearchResultsContainer(): void {
|
||||||
|
let searchResults = document.getElementById('search-results');
|
||||||
|
if (!searchResults) {
|
||||||
|
searchResults = document.createElement('div');
|
||||||
|
searchResults.id = 'search-results';
|
||||||
|
searchResults.className = 'hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border';
|
||||||
|
searchResults.style.cssText = 'background-color: var(--bg-secondary); border-color: var(--border)';
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('header-search');
|
||||||
|
if (searchInput) {
|
||||||
|
const searchContainer = searchInput.closest('.relative');
|
||||||
|
if (searchContainer) {
|
||||||
|
searchContainer.appendChild(searchResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightMatch(text: string, query: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||||
|
return escapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLibraryAndBook(libraryId: string, bookId: string): void {
|
||||||
|
localStorage.setItem('selectedLibrary', libraryId);
|
||||||
|
localStorage.setItem('selectedBook', bookId);
|
||||||
|
hideSearchResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeSearch);
|
||||||
|
|
||||||
|
(window as any).selectLibraryAndBook = selectLibraryAndBook;
|
||||||
Reference in New Issue
Block a user