- 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
183 lines
7.3 KiB
TypeScript
183 lines
7.3 KiB
TypeScript
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;
|