This commit updates the web frontend TypeScript modules: Core modules: - admin.ts: Admin panel functionality and user management - analytics.ts: Analytics dashboard and data visualization - api-explorer.ts: Interactive API documentation explorer - api.ts: Core API client with request/response handling - collections.ts: Book collection management UI - conflicts.ts: Sync conflict resolution interface - custom-section-builder.ts: Dynamic section builder for UI - docs.ts: Documentation viewer and navigation - dom.ts: DOM manipulation utilities and helpers - header.ts: Application header with navigation - library.ts: Library view and book grid management - linking.ts: Device-book linking interface - password_validation.ts: Client-side password strength validation - queue.ts: Device sync queue management UI - search.ts: Full-text search with Lunr integration - storage.ts: Local storage and cache management - theme.ts: Theme management and CSS variable updates - themeDropdown.ts: Theme selector dropdown component - toast.ts: Toast notification system - woodPaneling.ts: Visual theme effects - woodPanelingInit.ts: Visual effects initialization Type definitions: - api.d.ts: Updated TypeScript definitions for API responses These updates enhance the frontend with improved functionality for book management, device synchronization, and user experience.
210 lines
7.2 KiB
TypeScript
210 lines
7.2 KiB
TypeScript
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;
|