Files
bookhoard/templates/dashboard.html
T
john-okeefe 4318f8624b refactor: restructure project from bookmann to shelf
- Rename project from 'bookmann' to 'shelf'
- Move all backend/ contents to root level (flatten structure)
- Update Go module name from 'bookmann' to 'shelf'
- Update all import paths to use new 'shelf' module
- Update Dockerfile to work without backend/ subdirectory
- Update docker-compose.yml to use new structure and rename containers
- Update .gitignore for new file paths
- Update README.md with new project name and structure
- Regenerate database code with new module imports
2026-01-23 09:08:04 -05:00

457 lines
24 KiB
HTML

{{template "base.html" .}}
{{define "title"}}Dashboard - Bookmann{{end}}
{{define "content"}}
<!-- Navigation Header -->
<nav class="border-b" style="border-color: var(--border); background-color: var(--bg-secondary)">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-bold" style="color: var(--text-primary)">📚 Bookmann</h1>
</div>
<div class="flex items-center space-x-4">
<select id="theme-select" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" onchange="changeTheme()">
<option value="tokyo-night">Tokyo Night</option>
<option value="dracula">Dracula</option>
<option value="nord">Nord</option>
<option value="solarized-dark">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="one-dark-pro">One Dark Pro</option>
<option value="material-dark">Material Dark</option>
<option value="catppuccin-mocha">Catppuccin Mocha</option>
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
<option value="catppuccin-frappe">Catppuccin Frappé</option>
<option value="catppuccin-latte">Catppuccin Latte</option>
</select>
<span style="color: var(--text-secondary)">Welcome, {{.User.Username}}!</span>
<button onclick="logout()" class="btn-primary px-4 py-2 rounded text-sm">
Logout
</button>
</div>
</div>
</div>
</nav>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header Section -->
<div class="mb-8">
<div class="flex justify-between items-center">
<div>
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Your Ebook Library</h2>
<p style="color: var(--text-secondary)">Manage your ebook collection and reading progress</p>
</div>
<button onclick="showCreateForm()" class="btn-primary px-6 py-3 rounded-lg font-medium">
+ Add New Ebook
</button>
</div>
</div>
<!-- Search and Filter -->
<div class="mb-6">
<div class="flex flex-col sm:flex-row gap-4">
<input type="text" id="search-input" placeholder="Search ebooks..." class="flex-1 px-4 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onkeyup="filterEbooks()">
<select id="sort-select" class="px-4 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onchange="loadEbooks()">
<option value="created_at DESC">Newest First</option>
<option value="title ASC">Title A-Z</option>
<option value="author ASC">Author A-Z</option>
<option value="created_at ASC">Oldest First</option>
</select>
</div>
</div>
<!-- Create/Edit Form (Hidden by default) -->
<div id="ebook-form" class="card p-6 rounded-lg border mb-6" style="display: none;">
<h3 id="form-title" class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Add New Ebook</h3>
<form id="ebook-form-element" hx-post="/api/ebooks" hx-target="#form-result" hx-swap="innerHTML" hx-on:htmx:after-request="handleFormResponse()" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Title *</label>
<input type="text" name="title" id="form-title-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Author</label>
<input type="text" name="author" id="form-author-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">ISBN</label>
<input type="text" name="isbn" id="form-isbn-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Series</label>
<input type="text" name="series" id="form-series-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Series Number</label>
<input type="number" name="series_number" id="form-series-number-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Publisher</label>
<input type="text" name="publisher" id="form-publisher-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Date Published</label>
<input type="date" name="date_published" id="form-date-published-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">ASIN</label>
<input type="text" name="asin" id="form-asin-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Tags</label>
<input type="text" name="tags" id="form-tags-input" placeholder="fiction, adventure, ..." class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Contributors</label>
<input type="text" name="contributors" id="form-contributors-input" placeholder="Editor Name, Illustrator Name" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Description</label>
<textarea name="description" id="form-description-input" rows="3" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">File Path *</label>
<input type="text" name="file_path" id="form-file-path-input" placeholder="/uploads/book.epub" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">File Size (bytes) *</label>
<input type="number" name="file_size" id="form-file-size-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">MIME Type *</label>
<input type="text" name="mime_type" id="form-mime-type-input" placeholder="application/epub+zip" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Cover Image Path</label>
<input type="text" name="cover_image_path" id="form-cover-image-path-input" placeholder="/uploads/cover.jpg" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button type="button" onclick="hideCreateForm()" class="px-4 py-2 border rounded" style="border-color: var(--border); color: var(--text-secondary)">Cancel</button>
<button type="submit" class="btn-primary px-6 py-2 rounded">Save Ebook</button>
</div>
</form>
<div id="form-result" class="mt-4"></div>
</div>
<!-- Ebooks Grid -->
<div id="ebooks-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Ebooks will be loaded here -->
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center py-8" style="color: var(--text-secondary)">
Loading your ebooks...
</div>
<!-- Empty state -->
<div id="empty-state" class="text-center py-12" style="display: none;">
<div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No ebooks yet</h3>
<p style="color: var(--text-secondary)">Start building your library by adding your first ebook</p>
<button onclick="showCreateForm()" class="btn-primary px-6 py-3 rounded-lg font-medium mt-4">
Add Your First Ebook
</button>
</div>
<!-- Pagination -->
<div id="pagination" class="mt-8 flex justify-center space-x-2" style="display: none;">
<button id="prev-btn" onclick="changePage(-1)" class="px-4 py-2 border rounded disabled:opacity-50" style="border-color: var(--border); color: var(--text-secondary)">Previous</button>
<span id="page-info" style="color: var(--text-secondary)"></span>
<button id="next-btn" onclick="changePage(1)" class="px-4 py-2 border rounded disabled:opacity-50" style="border-color: var(--border); color: var(--text-secondary)">Next</button>
</div>
</div>
<!-- Ebook Detail Modal -->
<div id="ebook-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="display: none; z-index: 1000;">
<div class="card max-w-2xl w-full mx-4 rounded-lg border max-h-screen overflow-y-auto" style="background-color: var(--bg-secondary);">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<h3 id="modal-title" class="text-xl font-semibold" style="color: var(--text-primary)"></h3>
<button onclick="closeModal()" class="text-xl" style="color: var(--text-secondary)">&times;</button>
</div>
<div id="modal-content">
<!-- Content will be loaded here -->
</div>
</div>
</div>
</div>
<script>
let currentPage = 1;
let currentSort = 'created_at DESC';
let allEbooks = [];
function showCreateForm() {
document.getElementById('ebook-form').style.display = 'block';
document.getElementById('form-title').textContent = 'Add New Ebook';
document.getElementById('ebook-form-element').setAttribute('hx-post', '/api/ebooks');
clearForm();
document.getElementById('form-title-input').focus();
}
function showEditForm(ebook) {
document.getElementById('ebook-form').style.display = 'block';
document.getElementById('form-title').textContent = 'Edit Ebook';
document.getElementById('ebook-form-element').setAttribute('hx-put', `/api/ebooks/${ebook.id}`);
populateForm(ebook);
document.getElementById('form-title-input').focus();
}
function hideCreateForm() {
document.getElementById('ebook-form').style.display = 'none';
clearForm();
}
function clearForm() {
document.getElementById('ebook-form-element').reset();
}
function populateForm(ebook) {
document.getElementById('form-title-input').value = ebook.title || '';
document.getElementById('form-author-input').value = ebook.author || '';
document.getElementById('form-isbn-input').value = ebook.isbn || '';
document.getElementById('form-series-input').value = ebook.series || '';
document.getElementById('form-series-number-input').value = ebook.series_number || '';
document.getElementById('form-publisher-input').value = ebook.publisher || '';
document.getElementById('form-date-published-input').value = ebook.date_published || '';
document.getElementById('form-asin-input').value = ebook.asin || '';
document.getElementById('form-tags-input').value = ebook.tags || '';
document.getElementById('form-contributors-input').value = ebook.contributors || '';
document.getElementById('form-description-input').value = ebook.description || '';
document.getElementById('form-file-path-input').value = ebook.file_path || '';
document.getElementById('form-file-size-input').value = ebook.file_size || '';
document.getElementById('form-mime-type-input').value = ebook.mime_type || '';
document.getElementById('form-cover-image-path-input').value = ebook.cover_image_path || '';
}
function handleFormResponse() {
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
hideCreateForm();
loadEbooks();
}
}
function loadEbooks() {
const loading = document.getElementById('loading');
const container = document.getElementById('ebooks-container');
const emptyState = document.getElementById('empty-state');
loading.style.display = 'block';
container.innerHTML = '';
const sort = document.getElementById('sort-select').value;
currentSort = sort;
fetch(`/api/ebooks?limit=12&offset=${(currentPage - 1) * 12}`, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
loading.style.display = 'none';
allEbooks = data;
if (data.length === 0) {
emptyState.style.display = 'block';
document.getElementById('pagination').style.display = 'none';
return;
}
emptyState.style.display = 'none';
renderEbooks(data);
})
.catch(error => {
loading.style.display = 'none';
console.error('Error loading ebooks:', error);
container.innerHTML = '<div class="col-span-full text-center py-8" style="color: var(--text-secondary)">Error loading ebooks. Please try again.</div>';
});
}
function renderEbooks(ebooks) {
const container = document.getElementById('ebooks-container');
container.innerHTML = '';
ebooks.forEach(ebook => {
const card = createEbookCard(ebook);
container.appendChild(card);
});
}
function createEbookCard(ebook) {
const card = document.createElement('div');
card.className = 'card p-4 rounded-lg border hover:shadow-lg transition-shadow';
card.style.cssText = `background-color: var(--bg-secondary); border-color: var(--border);`;
const coverUrl = ebook.cover_image_path ? ebook.cover_image_path : '/static/placeholder-book.svg';
card.innerHTML = `
<div class="flex flex-col h-full">
<div class="flex-1">
<div class="aspect-w-3 aspect-h-4 mb-3 overflow-hidden rounded">
<img src="${coverUrl}" alt="Cover" class="w-full h-48 object-cover rounded" onerror="this.src='/static/placeholder-book.svg'">
</div>
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">${ebook.title}</h3>
${ebook.author ? `<p class="text-sm mb-2" style="color: var(--text-secondary)">by ${ebook.author}</p>` : ''}
${ebook.series ? `<p class="text-sm mb-2" style="color: var(--text-secondary)">${ebook.series}${ebook.series_number ? ' #' + ebook.series_number : ''}</p>` : ''}
<div class="flex flex-wrap gap-1 mb-3">
${ebook.tags ? ebook.tags.split(',').slice(0, 3).map(tag => `<span class="px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">${tag.trim()}</span>`).join('') : ''}
</div>
</div>
<div class="flex justify-between items-center mt-4">
<div class="flex space-x-2">
<button onclick="viewEbook('${ebook.id}')" class="px-3 py-1 text-sm border rounded hover:opacity-80" style="border-color: var(--border); color: var(--text-secondary)">View</button>
<button onclick="showEditForm(${JSON.stringify(ebook).replace(/"/g, '&quot;')})" class="px-3 py-1 text-sm border rounded hover:opacity-80" style="border-color: var(--border); color: var(--text-secondary)">Edit</button>
</div>
<button onclick="deleteEbook('${ebook.id}')" class="px-3 py-1 text-sm text-red-500 hover:text-red-700">
Delete
</button>
</div>
</div>
`;
return card;
}
function viewEbook(id) {
fetch(`/api/ebooks/${id}`, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(ebook => {
showEbookModal(ebook);
})
.catch(error => {
console.error('Error loading ebook:', error);
});
}
function showEbookModal(ebook) {
const modal = document.getElementById('ebook-modal');
const title = document.getElementById('modal-title');
const content = document.getElementById('modal-content');
title.textContent = ebook.title;
content.innerHTML = `
<div class="space-y-4">
<div class="flex space-x-4">
<img src="${ebook.cover_image_path || '/static/placeholder-book.svg'}" alt="Cover" class="w-32 h-40 object-cover rounded" onerror="this.src='/static/placeholder-book.svg'">
<div class="flex-1">
<h4 class="font-semibold mb-2" style="color: var(--text-primary)">${ebook.title}</h4>
${ebook.author ? `<p style="color: var(--text-secondary)">Author: ${ebook.author}</p>` : ''}
${ebook.publisher ? `<p style="color: var(--text-secondary)">Publisher: ${ebook.publisher}</p>` : ''}
${ebook.date_published ? `<p style="color: var(--text-secondary)">Published: ${new Date(ebook.date_published).toLocaleDateString()}</p>` : ''}
${ebook.isbn ? `<p style="color: var(--text-secondary)">ISBN: ${ebook.isbn}</p>` : ''}
${ebook.asin ? `<p style="color: var(--text-secondary)">ASIN: ${ebook.asin}</p>` : ''}
${ebook.series ? `<p style="color: var(--text-secondary)">Series: ${ebook.series}${ebook.series_number ? ' #' + ebook.series_number : ''}</p>` : ''}
</div>
</div>
${ebook.description ? `<div><h5 class="font-semibold mb-2" style="color: var(--text-primary)">Description</h5><p style="color: var(--text-secondary)">${ebook.description}</p></div>` : ''}
${ebook.tags ? `<div><h5 class="font-semibold mb-2" style="color: var(--text-primary)">Tags</h5><div class="flex flex-wrap gap-1">${ebook.tags.split(',').map(tag => `<span class="px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">${tag.trim()}</span>`).join('')}</div></div>` : ''}
${ebook.contributors ? `<div><h5 class="font-semibold mb-2" style="color: var(--text-primary)">Contributors</h5><p style="color: var(--text-secondary)">${ebook.contributors}</p></div>` : ''}
<div class="border-t pt-4" style="border-color: var(--border)">
<h5 class="font-semibold mb-2" style="color: var(--text-primary)">File Information</h5>
<p style="color: var(--text-secondary)">Size: ${formatFileSize(ebook.file_size)} | Type: ${ebook.mime_type}</p>
<p style="color: var(--text-secondary)">Path: ${ebook.file_path}</p>
</div>
</div>
`;
modal.style.display = 'flex';
}
function closeModal() {
document.getElementById('ebook-modal').style.display = 'none';
}
function deleteEbook(id) {
if (confirm('Are you sure you want to delete this ebook?')) {
fetch(`/api/ebooks/${id}`, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
}
})
.then(response => {
if (response.ok) {
loadEbooks();
} else {
alert('Error deleting ebook');
}
})
.catch(error => {
console.error('Error deleting ebook:', error);
alert('Error deleting ebook');
});
}
}
function filterEbooks() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const filteredEbooks = allEbooks.filter(ebook =>
ebook.title.toLowerCase().includes(searchTerm) ||
(ebook.author && ebook.author.toLowerCase().includes(searchTerm)) ||
(ebook.tags && ebook.tags.toLowerCase().includes(searchTerm))
);
renderEbooks(filteredEbooks);
}
function changePage(direction) {
currentPage += direction;
if (currentPage < 1) currentPage = 1;
loadEbooks();
}
function formatFileSize(bytes) {
if (!bytes) return 'Unknown';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/';
}
function changeTheme() {
const theme = document.getElementById('theme-select').value;
applyTheme(theme);
fetch('/api/auth/theme', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({ theme })
}).catch(err => console.log('Theme save failed', err));
}
function loadUserTheme() {
const token = localStorage.getItem('token');
if (token) {
fetch('/api/auth/profile', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(res => res.json()).then(data => {
if (data.theme) applyTheme(data.theme);
}).catch(() => {});
}
}
// Load ebooks on page load
document.addEventListener('DOMContentLoaded', function() {
loadTheme();
loadUserTheme();
document.getElementById('theme-select').value = localStorage.getItem('theme') || 'tokyo-night';
loadEbooks();
});
</script>
{{end}}