Add UI for manual linking of unmatched books from device sync: - Unlinked books list: Shows books without automatic matches - Book matching panel: Displays potential matches with confidence scores - Manual linking interface: Allows users to confirm book associations - Confidence indicators: Visual representation of match quality - Device attribution: Shows which device reported the unlinked book Features: - SHA-256 hash display for fingerprinting - File path and title from device - Potential matches with cover images - Match method indicators (UUID, SHA-256, ISBN, etc.) - One-click linking for high-confidence matches - Manual search for low-confidence cases This helps users resolve book matching conflicts when automatic identification fails due to format conversions or missing metadata.
353 lines
19 KiB
Templ
353 lines
19 KiB
Templ
package templates
|
|
|
|
templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Unlinked Books - Bookmann</title>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<script src="/static/toast.js"></script>
|
|
<script src="/static/header.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet">
|
|
</head>
|
|
<body class="theme-{ user.Theme }">
|
|
@Header(user, "/unlinked")
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-8">
|
|
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Unlinked Books</h1>
|
|
<p style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
|
|
</div>
|
|
|
|
<div id="unlinked-container" class="space-y-6">
|
|
if len(unlinkedBooks) == 0 {
|
|
<div class="text-center py-16" style="color: var(--text-secondary)">
|
|
<div class="text-6xl mb-4">✓</div>
|
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">All Books Linked!</h3>
|
|
<p>There are no unlinked books that need manual resolution.</p>
|
|
</div>
|
|
}
|
|
|
|
for _, book := range unlinkedBooks {
|
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
|
<div class="flex gap-6">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<div class="text-4xl">
|
|
if book.DeviceType == "koreader" {
|
|
📖
|
|
} else if book.DeviceType == "kobo" {
|
|
📚
|
|
} else {
|
|
📱
|
|
}
|
|
</div>
|
|
<div>
|
|
<h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
|
|
<p class="text-sm" style="color: var(--text-secondary)">
|
|
Device: { book.DeviceName } ({ book.DeviceType })
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2 text-sm mb-4">
|
|
<div>
|
|
<span style="color: var(--text-secondary)">File Path:</span>
|
|
<code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px;">
|
|
{ book.FilePath }
|
|
</code>
|
|
</div>
|
|
if book.SHA256 != "" {
|
|
<div>
|
|
<span style="color: var(--text-secondary)">SHA-256:</span>
|
|
<code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px; font-size: 11px;">
|
|
{ book.SHA256 }
|
|
</code>
|
|
</div>
|
|
}
|
|
<div>
|
|
<span style="color: var(--text-secondary)">Last Synced:</span>
|
|
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
|
|
</div>
|
|
if book.ConfidenceScore > 0 {
|
|
<div>
|
|
<span style="color: var(--text-secondary)">Auto-Link Confidence:</span>
|
|
<span style="color: var(--text-primary)">
|
|
{ book.ConfidenceScore }% confidence
|
|
</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<button onclick="searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')"
|
|
class="btn-secondary px-4 py-2 rounded-lg text-sm">
|
|
🔍 Find Potential Matches
|
|
</button>
|
|
<button onclick="showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')"
|
|
class="btn-primary px-4 py-2 rounded-lg text-sm">
|
|
🔗 Manually Link
|
|
</button>
|
|
</div>
|
|
|
|
<div id="matches-{ book.ProgressID }" class="hidden">
|
|
<div class="border-t pt-4" style="border-color: var(--border);">
|
|
<h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4>
|
|
<div id="matches-list-{ book.ProgressID }" class="space-y-3">
|
|
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manual Link Modal -->
|
|
<div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
|
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
|
|
<button onclick="hideManualLinkModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
|
</div>
|
|
|
|
<input type="hidden" id="link-progress-id">
|
|
<input type="hidden" id="link-book-sha256">
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
|
|
<input type="text" id="link-book-title" readonly
|
|
class="w-full px-4 py-2 border rounded-lg"
|
|
style="background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);">
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
|
|
<div class="flex gap-2">
|
|
<input type="text" id="link-search-input" placeholder="Search by title, author..."
|
|
class="flex-1 px-4 py-2 border rounded-lg"
|
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
|
<button type="button" onclick="searchBooksForLink()"
|
|
class="btn-primary px-4 py-2 rounded-lg">
|
|
Search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
|
|
<p style="color: var(--text-secondary)">Search for books to link</p>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
|
|
<input type="number" id="link-confidence" min="0" max="1" step="0.1" value="1.0"
|
|
class="w-full px-4 py-2 border rounded-lg"
|
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3">
|
|
<button type="button" onclick="hideManualLinkModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
|
Cancel
|
|
</button>
|
|
<button type="button" onclick="confirmManualLink()" class="btn-primary px-4 py-2 rounded-lg">
|
|
Link Book
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let selectedMediaItem = null;
|
|
|
|
function searchMatches(progressId, sha256, title) {
|
|
const container = document.getElementById(`matches-${progressId}`);
|
|
const matchesList = document.getElementById(`matches-list-${progressId}`);
|
|
|
|
container.classList.remove('hidden');
|
|
matchesList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
|
|
|
const url = sha256 ? `/api/books/match?sha256=${sha256}` : `/api/books/match?title=${encodeURIComponent(title)}`;
|
|
|
|
fetch(url, {
|
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.matches && result.matches.length > 0) {
|
|
matchesList.innerHTML = result.matches.map(match => `
|
|
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
|
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
|
onclick="autoLinkBook('${progressId}', '${match.media_item_id}', ${match.confidence})">
|
|
<div class="flex gap-4">
|
|
<img src="${match.cover_image_path || '/static/placeholder-book.svg'}"
|
|
alt="Cover" class="w-16 h-24 object-cover rounded">
|
|
<div>
|
|
<h5 class="font-semibold" style="color: var(--text-primary)">${match.title}</h5>
|
|
<p class="text-sm" style="color: var(--text-secondary)">by ${match.author || 'Unknown'}</p>
|
|
<div class="mt-2 flex items-center gap-2">
|
|
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
|
|
${Math.round(match.confidence * 100)}% confidence
|
|
</span>
|
|
<span class="text-xs" style="color: var(--text-secondary)">${match.match_method}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
matchesList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>';
|
|
}
|
|
})
|
|
.catch(error => {
|
|
matchesList.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search</p>';
|
|
});
|
|
}
|
|
|
|
function autoLinkBook(progressId, mediaItemId, confidence) {
|
|
if (!confirm('Link this book? The confidence score is ' + Math.round(confidence * 100) + '%')) return;
|
|
|
|
const data = {
|
|
device_file: {
|
|
file_path: document.getElementById(`matches-${progressId}`).querySelector('code').textContent,
|
|
sha256: document.getElementById(`matches-${progressId}`).querySelector('[title="SHA-256"]')?.textContent || ''
|
|
},
|
|
media_item_id: mediaItemId,
|
|
confidence_score: confidence
|
|
};
|
|
|
|
fetch(`/api/devices/${deviceId}/sync/link-book`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
showToast('Book linked successfully', 'success');
|
|
location.reload();
|
|
})
|
|
.catch(error => {
|
|
showToast('Failed to link book', 'error');
|
|
});
|
|
}
|
|
|
|
function showManualLinkModal(progressId, bookTitle) {
|
|
document.getElementById('link-progress-id').value = progressId;
|
|
document.getElementById('link-book-title').value = bookTitle;
|
|
document.getElementById('link-search-results').innerHTML = '<p style="color: var(--text-secondary)">Search for books to link</p>';
|
|
selectedMediaItem = null;
|
|
document.getElementById('manual-link-modal').classList.remove('hidden');
|
|
}
|
|
|
|
function hideManualLinkModal() {
|
|
document.getElementById('manual-link-modal').classList.add('hidden');
|
|
document.getElementById('link-search-input').value = '';
|
|
selectedMediaItem = null;
|
|
}
|
|
|
|
function searchBooksForLink() {
|
|
const searchTerm = document.getElementById('link-search-input').value;
|
|
const resultsContainer = document.getElementById('link-search-results');
|
|
|
|
if (searchTerm.length < 2) {
|
|
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters</p>';
|
|
return;
|
|
}
|
|
|
|
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
|
|
|
fetch(`/api/books/match?title=${encodeURIComponent(searchTerm)}`, {
|
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.matches && result.matches.length > 0) {
|
|
resultsContainer.innerHTML = result.matches.map(match => `
|
|
<div class="card p-3 rounded-lg border cursor-pointer ${selectedMediaItem === match.media_item_id ? 'border-2 border-blue-500' : ''}"
|
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
|
onclick="selectBookForLink('${match.media_item_id}', '${match.title}', '${match.cover_image_path || ''}')">
|
|
<div class="flex gap-3">
|
|
<img src="${match.cover_image_path || '/static/placeholder-book.svg'}"
|
|
alt="Cover" class="w-12 h-16 object-cover rounded">
|
|
<div>
|
|
<h5 class="font-semibold text-sm" style="color: var(--text-primary)">${match.title}</h5>
|
|
<p class="text-xs" style="color: var(--text-secondary)">by ${match.author || 'Unknown'}</p>
|
|
<p class="text-xs" style="color: var(--accent)">${Math.round(match.confidence * 100)}% confidence</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No matches found</p>';
|
|
}
|
|
})
|
|
.catch(error => {
|
|
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search</p>';
|
|
});
|
|
}
|
|
|
|
function selectBookForLink(mediaItemId, title, coverPath) {
|
|
selectedMediaItem = mediaItemId;
|
|
const resultsContainer = document.getElementById('link-search-results');
|
|
const cards = resultsContainer.querySelectorAll('.card');
|
|
cards.forEach(card => {
|
|
card.classList.remove('border-2', 'border-blue-500');
|
|
if (card.getAttribute('onclick').includes(mediaItemId)) {
|
|
card.classList.add('border-2', 'border-blue-500');
|
|
}
|
|
});
|
|
}
|
|
|
|
function confirmManualLink() {
|
|
if (!selectedMediaItem) {
|
|
showToast('Please select a book to link', 'error');
|
|
return;
|
|
}
|
|
|
|
const progressId = document.getElementById('link-progress-id').value;
|
|
const confidence = parseFloat(document.getElementById('link-confidence').value);
|
|
|
|
const data = {
|
|
device_file: {
|
|
file_path: document.getElementById('link-book-title').value,
|
|
sha256: document.getElementById('link-book-sha256').value
|
|
},
|
|
media_item_id: selectedMediaItem,
|
|
confidence_score: confidence
|
|
};
|
|
|
|
fetch(`/api/devices/${deviceId}/sync/link-book`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
showToast('Book linked successfully', 'success');
|
|
hideManualLinkModal();
|
|
location.reload();
|
|
})
|
|
.catch(error => {
|
|
showToast('Failed to link book', 'error');
|
|
});
|
|
}
|
|
|
|
function logout() {
|
|
localStorage.removeItem('token');
|
|
window.location.href = '/login';
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
}
|