Files
bookhoard/web/src/linking.ts
T
john-okeefe ea5ad7a41b feat(web): update frontend TypeScript modules and API types
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.
2026-02-27 17:06:48 -05:00

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;