feat: update frontend for library system
- Redesign dashboard to show library selection first - Add media browsing within selected library - Implement library management interface - Add user visibility controls for libraries - Support library type icons and metadata - Add create library modal with type selection - Include folder management for each library - Implement user-specific library access controls Replaces single ebook library with flexible multi-library system
This commit is contained in:
+265
-54
@@ -79,93 +79,295 @@ templ AdminLibrary(user User) {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-1 p-8">
|
<main class="flex-1 p-8">
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-6xl">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center space-x-4 mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
||||||
← Back to Dashboard
|
← Back to Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
<button onclick="showCreateLibraryModal()" class="btn-primary px-4 py-2 rounded-lg font-medium">
|
||||||
|
+ Create Library
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Library Management</h1>
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Library Management</h1>
|
||||||
<p style="color: var(--text-secondary)">Configure library scanning settings and manage ebook folders</p>
|
<p style="color: var(--text-secondary)">Manage libraries and configure media scanning</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<!-- Libraries Section -->
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Ebook Folders</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Libraries</h3>
|
||||||
<p style="color: var(--text-secondary)" class="mb-4">Manage folders that Bookmann scans for ebooks</p>
|
<p style="color: var(--text-secondary)" class="mb-4">Manage media libraries and their folders</p>
|
||||||
|
|
||||||
<div id="folders-list" class="space-y-3 mb-6">
|
<div id="libraries-list" class="space-y-3 mb-6">
|
||||||
<!-- Folders will be loaded here -->
|
<!-- Libraries will be loaded here -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form hx-post="/api/auth/ebook-folders" hx-target="#add-folder-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
|
||||||
<input type="text" name="folder_path" placeholder="/path/to/ebooks" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded">Add Folder</button>
|
|
||||||
</form>
|
|
||||||
<div id="add-folder-result" class="mt-2"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- User Library Visibility Section -->
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Scan Settings</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Library Visibility</h3>
|
||||||
|
<p style="color: var(--text-secondary)" class="mb-4">Control which libraries are visible to users</p>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div id="visibility-controls" class="space-y-4">
|
||||||
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Scan Frequency</h4>
|
<!-- Visibility controls will be loaded here -->
|
||||||
<form hx-put="/api/library/scan-settings" hx-target="#scan-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3 max-w-md">
|
|
||||||
<select name="scan_frequency_minutes" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
|
||||||
<option value="15">Every 15 minutes</option>
|
|
||||||
<option value="30">Every 30 minutes</option>
|
|
||||||
<option value="60">Every hour</option>
|
|
||||||
<option value="120">Every 2 hours</option>
|
|
||||||
<option value="240">Every 4 hours</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded">Update</button>
|
|
||||||
</form>
|
|
||||||
<div id="scan-result" class="mt-2"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- User Visibility Management -->
|
||||||
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Auto Scan</h4>
|
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<form hx-put="/api/library/scan-settings" hx-target="#auto-scan-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex items-center space-x-3">
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">User Library Access</h3>
|
||||||
<input type="checkbox" name="auto_scan_enabled" id="auto-scan" class="w-4 h-4" style="background-color: var(--bg-primary); border-color: var(--border)">
|
<p style="color: var(--text-secondary)" class="mb-4">Manage individual user access to specific libraries</p>
|
||||||
<label for="auto-scan" style="color: var(--text-secondary)">Enable automatic library scanning</label>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded">Update</button>
|
<div class="mb-4">
|
||||||
</form>
|
<select id="user-select" onchange="loadUserVisibility()" class="px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||||
<div id="auto-scan-result" class="mt-2"></div>
|
<option value="">Select a user...</option>
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="user-libraries" class="space-y-3">
|
||||||
|
<!-- User library checkboxes will be loaded here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Library Modal -->
|
||||||
|
<div id="create-library-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center z-50">
|
||||||
|
<div class="card p-6 rounded-lg border max-w-md w-full mx-4" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
|
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Create Library</h3>
|
||||||
|
<form hx-post="/api/libraries" hx-target="#create-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' onsubmit="this.reset();">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Name</label>
|
||||||
|
<input type="text" name="name" placeholder="My Ebook Library" 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 class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Description</label>
|
||||||
|
<textarea name="description" placeholder="Optional description" 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 class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Type</label>
|
||||||
|
<select name="type" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||||
|
<option value="ebooks">📚 Ebooks</option>
|
||||||
|
<option value="comics">📖 Comics</option>
|
||||||
|
<option value="manga">🗾 Manga</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button type="button" onclick="hideCreateLibraryModal()" class="btn-secondary px-4 py-2 rounded">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary px-4 py-2 rounded">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="create-result" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function loadFolders() {
|
let libraries = [];
|
||||||
fetch('/api/auth/ebook-folders', {
|
let users = [];
|
||||||
|
let libraryTypes = [];
|
||||||
|
|
||||||
|
function loadLibraries() {
|
||||||
|
fetch('/api/libraries', {
|
||||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(folders => {
|
.then(data => {
|
||||||
const container = document.getElementById('folders-list');
|
libraries = data;
|
||||||
container.innerHTML = folders.map(folder =>
|
renderLibraries();
|
||||||
'<div class="flex items-center justify-between p-3 border rounded" style="background-color: var(--bg-primary); border-color: var(--border)">' +
|
|
||||||
'<span style="color: var(--text-primary)">' + folder.folder_path + '</span>' +
|
|
||||||
'<button onclick="removeFolder(\'' + folder.id + '\')" class="text-red-500 hover:text-red-700">Remove</button>' +
|
|
||||||
'</div>'
|
|
||||||
).join('');
|
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error loading folders:', error));
|
.catch(error => console.error('Error loading libraries:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFolder(folderId) {
|
function renderLibraries() {
|
||||||
if (confirm('Remove this folder from scanning?')) {
|
const container = document.getElementById('libraries-list');
|
||||||
fetch('/api/auth/ebook-folders/' + folderId, {
|
container.innerHTML = libraries.map(library =>
|
||||||
|
'<div class="p-4 border rounded-lg" style="background-color: var(--bg-primary); border-color: var(--border)">' +
|
||||||
|
'<div class="flex justify-between items-start mb-2">' +
|
||||||
|
'<div>' +
|
||||||
|
'<h4 class="font-semibold" style="color: var(--text-primary)">' + library.name + '</h4>' +
|
||||||
|
'<p class="text-sm" style="color: var(--text-secondary)">' + (library.description || '') + '</p>' +
|
||||||
|
'<span class="inline-block px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">' +
|
||||||
|
(library.type_name || library.type) + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="flex space-x-2">' +
|
||||||
|
'<button onclick="showLibraryFolders(\'' + library.id + '\')" class="text-xs px-2 py-1 rounded" style="background-color: var(--bg-secondary); color: var(--text-primary)">Folders</button>' +
|
||||||
|
'<button onclick="editLibrary(\'' + library.id + '\')" class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary)">Edit</button>' +
|
||||||
|
'<button onclick="deleteLibrary(\'' + library.id + '\')" class="text-xs px-2 py-1 rounded text-red-500">Delete</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="library-folders-' + library.id + '" class="hidden mt-3 space-y-2"></div>' +
|
||||||
|
'</div>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUsers() {
|
||||||
|
fetch('/api/auth/users', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
users = data;
|
||||||
|
renderUserSelect();
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error loading users:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUserSelect() {
|
||||||
|
const select = document.getElementById('user-select');
|
||||||
|
select.innerHTML = '<option value="">Select a user...</option>' +
|
||||||
|
users.map(user => '<option value="' + user.id + '">' + user.username + ' (' + user.email + ')</option>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUserVisibility() {
|
||||||
|
const userId = document.getElementById('user-select').value;
|
||||||
|
if (!userId) {
|
||||||
|
document.getElementById('user-libraries').innerHTML = '<p style="color: var(--text-secondary)">Please select a user</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user's visible libraries
|
||||||
|
fetch('/api/libraries/visible', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(visibleLibraries => {
|
||||||
|
const container = document.getElementById('user-libraries');
|
||||||
|
const visibleIds = visibleLibraries.map(lib => lib.id);
|
||||||
|
|
||||||
|
container.innerHTML = libraries.map(library => {
|
||||||
|
const isVisible = visibleIds.includes(library.id);
|
||||||
|
return '<label class="flex items-center space-x-3 p-2 rounded" style="background-color: var(--bg-primary);">' +
|
||||||
|
'<input type="checkbox" ' + (isVisible ? 'checked' : '') +
|
||||||
|
' onchange="setLibraryVisibility(\'' + userId + '\', \'' + library.id + '\', this.checked)" ' +
|
||||||
|
'class="w-4 h-4">' +
|
||||||
|
'<span style="color: var(--text-primary)">' + library.name + ' (' + library.type_name + ')</span>' +
|
||||||
|
'</label>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error loading user libraries:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLibraryVisibility(userId, libraryId, isVisible) {
|
||||||
|
fetch('/api/libraries/visibility', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
library_id: libraryId,
|
||||||
|
is_visible: isVisible
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(() => {
|
||||||
|
// Refresh visibility controls
|
||||||
|
loadUserVisibility();
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error setting library visibility:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateLibraryModal() {
|
||||||
|
document.getElementById('create-library-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCreateLibraryModal() {
|
||||||
|
document.getElementById('create-library-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteLibrary(libraryId) {
|
||||||
|
const library = libraries.find(l => l.id === libraryId);
|
||||||
|
if (!library) return;
|
||||||
|
|
||||||
|
if (confirm('Delete library "' + library.name + '"? This will permanently remove all associated media and cannot be undone.')) {
|
||||||
|
fetch('/api/libraries/' + libraryId, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.ok) loadFolders();
|
if (response.ok) {
|
||||||
else alert('Error removing folder');
|
loadLibraries(); // Refresh
|
||||||
|
} else {
|
||||||
|
alert('Error deleting library');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error deleting library:', error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLibraryFolders(libraryId) {
|
||||||
|
const container = document.getElementById('library-folders-' + libraryId);
|
||||||
|
const library = libraries.find(l => l.id === libraryId);
|
||||||
|
|
||||||
|
if (container.classList.contains('hidden')) {
|
||||||
|
// Load folders for this library
|
||||||
|
fetch('/api/libraries/' + libraryId + '/folders', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(folders => {
|
||||||
|
container.innerHTML = folders.map(folder =>
|
||||||
|
'<div class="flex justify-between items-center p-2 rounded" style="background-color: var(--bg-secondary); border-color: var(--border)">' +
|
||||||
|
'<span class="text-sm" style="color: var(--text-primary)">' + folder.folder_path + '</span>' +
|
||||||
|
'<button onclick="removeLibraryFolder(\'' + libraryId + '\', \'' + folder.folder_path + '\')" ' +
|
||||||
|
'class="text-xs text-red-500">Remove</button>' +
|
||||||
|
'</div>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Add folder input
|
||||||
|
container.innerHTML += '<div class="mt-2 flex space-x-2">' +
|
||||||
|
'<input type="text" id="folder-path-' + libraryId + '" placeholder="Add folder path" ' +
|
||||||
|
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
|
||||||
|
'<button onclick="addLibraryFolder(\'' + libraryId + '\')" ' +
|
||||||
|
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error loading folders:', error));
|
||||||
|
} else {
|
||||||
|
container.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLibraryFolder(libraryId) {
|
||||||
|
const input = document.getElementById('folder-path-' + libraryId);
|
||||||
|
const folderPath = input.value.trim();
|
||||||
|
|
||||||
|
if (!folderPath) return;
|
||||||
|
|
||||||
|
fetch('/api/libraries/' + libraryId + '/folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ folder_path: folderPath })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(() => {
|
||||||
|
input.value = '';
|
||||||
|
showLibraryFolders(libraryId); // Refresh
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error adding folder:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLibraryFolder(libraryId, folderPath) {
|
||||||
|
if (confirm('Remove this folder from the library?')) {
|
||||||
|
fetch('/api/libraries/' + libraryId + '/folders', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ folder_path: folderPath })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(() => {
|
||||||
|
showLibraryFolders(libraryId); // Refresh
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error removing folder:', error));
|
.catch(error => console.error('Error removing folder:', error));
|
||||||
}
|
}
|
||||||
@@ -177,9 +379,18 @@ templ AdminLibrary(user User) {
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadTheme() {
|
||||||
|
// Load saved theme if exists
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
document.body.className = 'theme-' + savedTheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadTheme();
|
loadTheme();
|
||||||
loadFolders();
|
loadLibraries();
|
||||||
|
loadUsers();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+175
-55
@@ -95,52 +95,60 @@ templ Dashboard(user User) {
|
|||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div>
|
||||||
<div>
|
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Your Media Library</h2>
|
||||||
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Your Ebook Library</h2>
|
<p style="color: var(--text-secondary)">Browse and manage your media collections</p>
|
||||||
<p style="color: var(--text-secondary)">Manage your ebook collection and reading progress</p>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Libraries Section -->
|
||||||
|
<div id="libraries-container" class="mb-8">
|
||||||
|
<!-- Libraries will be loaded here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Items Section -->
|
||||||
|
<div id="media-section" class="hidden">
|
||||||
|
<!-- Search and Filter -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<button onclick="backToLibraries()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
|
← Back to Libraries
|
||||||
|
</button>
|
||||||
|
<input type="text" id="search-input" placeholder="Search media..." 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="filterMedia()">
|
||||||
|
<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="loadMediaItems()">
|
||||||
|
<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>
|
||||||
<button onclick="showCreateForm()" class="btn-primary px-6 py-3 rounded-lg font-medium">
|
|
||||||
+ Add New Ebook
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search and Filter -->
|
<!-- Media Grid -->
|
||||||
<div class="mb-6">
|
<div id="media-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div class="flex flex-col sm:flex-row gap-4">
|
<!-- Media items will be loaded here -->
|
||||||
<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>
|
||||||
</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 -->
|
<!-- Loading indicator -->
|
||||||
<div id="loading" class="text-center py-8" style="color: var(--text-secondary)">
|
<div id="loading" class="text-center py-8" style="color: var(--text-secondary)">
|
||||||
Loading your ebooks...
|
Loading your media libraries...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function loadEbooks() {
|
let libraries = [];
|
||||||
|
let currentLibrary = null;
|
||||||
|
let mediaItems = [];
|
||||||
|
|
||||||
|
function loadLibraries() {
|
||||||
const loading = document.getElementById('loading');
|
const loading = document.getElementById('loading');
|
||||||
const container = document.getElementById('ebooks-container');
|
const librariesContainer = document.getElementById('libraries-container');
|
||||||
|
|
||||||
loading.style.display = 'block';
|
loading.style.display = 'block';
|
||||||
container.innerHTML = '';
|
librariesContainer.innerHTML = '';
|
||||||
|
|
||||||
const sort = document.getElementById('sort-select').value;
|
fetch('/api/libraries/visible', {
|
||||||
|
|
||||||
fetch(`/api/ebooks?limit=12&offset=0&sort=${sort}`, {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -149,39 +157,132 @@ templ Dashboard(user User) {
|
|||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
renderEbooks(data);
|
libraries = data;
|
||||||
|
renderLibraries();
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
console.error('Error loading ebooks:', error);
|
console.error('Error loading libraries:', error);
|
||||||
container.innerHTML = '<div class="col-span-full text-center py-8" style="color: var(--text-secondary)">Error loading ebooks. Please try again.</div>';
|
librariesContainer.innerHTML = '<div class="text-center py-8" style="color: var(--text-secondary)">Error loading libraries. Please try again.</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderEbooks(ebooks) {
|
function renderLibraries() {
|
||||||
const container = document.getElementById('ebooks-container');
|
const container = document.getElementById('libraries-container');
|
||||||
|
|
||||||
|
if (libraries.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center py-8" style="color: var(--text-secondary)">No libraries available. Please contact an administrator.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">' +
|
||||||
|
libraries.map(library => {
|
||||||
|
const icon = getLibraryIcon(library.type_name);
|
||||||
|
return `<div class="card p-6 rounded-lg border hover:shadow-lg transition-shadow cursor-pointer" onclick="selectLibrary('${library.id}')" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-4xl mb-3">${icon}</div>
|
||||||
|
<h3 class="font-semibold text-lg mb-2" style="color: var(--text-primary)">${library.name}</h3>
|
||||||
|
<p class="text-sm mb-3" style="color: var(--text-secondary)">${library.description || ''}</p>
|
||||||
|
<span class="inline-block px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary)">${library.type_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLibraryIcon(typeName) {
|
||||||
|
switch (typeName) {
|
||||||
|
case 'ebooks': return '📚';
|
||||||
|
case 'comics': return '📖';
|
||||||
|
case 'manga': return '🗾';
|
||||||
|
default: return '📁';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLibrary(libraryId) {
|
||||||
|
currentLibrary = libraries.find(l => l.id === libraryId);
|
||||||
|
if (!currentLibrary) return;
|
||||||
|
|
||||||
|
const librariesContainer = document.getElementById('libraries-container');
|
||||||
|
const mediaSection = document.getElementById('media-section');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
|
||||||
|
// Hide libraries, show media section
|
||||||
|
librariesContainer.classList.add('hidden');
|
||||||
|
mediaSection.classList.remove('hidden');
|
||||||
|
loading.style.display = 'block';
|
||||||
|
|
||||||
|
loadMediaItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToLibraries() {
|
||||||
|
const librariesContainer = document.getElementById('libraries-container');
|
||||||
|
const mediaSection = document.getElementById('media-section');
|
||||||
|
|
||||||
|
librariesContainer.classList.remove('hidden');
|
||||||
|
mediaSection.classList.add('hidden');
|
||||||
|
currentLibrary = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMediaItems() {
|
||||||
|
if (!currentLibrary) return;
|
||||||
|
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const container = document.getElementById('media-container');
|
||||||
|
|
||||||
|
loading.style.display = 'block';
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
for (const ebook of ebooks) {
|
const sort = document.getElementById('sort-select').value;
|
||||||
|
|
||||||
|
fetch(`/api/media-items?library_id=${currentLibrary.id}&limit=12&offset=0&sort=${sort}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
mediaItems = data;
|
||||||
|
renderMediaItems();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
console.error('Error loading media items:', error);
|
||||||
|
container.innerHTML = '<div class="col-span-full text-center py-8" style="color: var(--text-secondary)">Error loading media items. Please try again.</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMediaItems() {
|
||||||
|
const container = document.getElementById('media-container');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (mediaItems.length === 0) {
|
||||||
|
container.innerHTML = '<div class="col-span-full text-center py-8" style="color: var(--text-secondary)">No media items found in this library.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of mediaItems) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card p-4 rounded-lg border hover:shadow-lg transition-shadow';
|
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);';
|
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';
|
const coverUrl = item.cover_image_path ? item.cover_image_path : '/static/placeholder-book.svg';
|
||||||
|
|
||||||
// Get user's rating for this ebook
|
// Get user's rating for this media item
|
||||||
const rating = await getEbookRating(ebook.id);
|
const rating = await getMediaRating(item.id);
|
||||||
const ratingStars = renderRatingStars(ebook.id, rating);
|
const ratingStars = renderRatingStars(item.id, rating);
|
||||||
|
|
||||||
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>' : '') + '<div class="mb-2" id="rating-' + ebook.id + '">' + ratingStars + '</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></div></div></div>';
|
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)">' + item.title + '</h3>' + (item.author ? '<p class="text-sm mb-2" style="color: var(--text-secondary)">by ' + item.author + '</p>' : '') + '<div class="mb-2" id="rating-' + item.id + '">' + ratingStars + '</div><div class="flex justify-between items-center mt-4"><div class="flex space-x-2"><button onclick="viewMediaItem(\'' + item.id + '\')" class="px-3 py-1 text-sm border rounded hover:opacity-80" style="border-color: var(--border); color: var(--text-secondary)">View</button></div></div></div>';
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEbookRating(ebookId) {
|
async function getMediaRating(mediaId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/ebooks/${ebookId}/rating`, {
|
const response = await fetch(`/api/media-items/${mediaId}/rating`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -209,7 +310,7 @@ templ Dashboard(user User) {
|
|||||||
return Math.round(fivePointRating * 2);
|
return Math.round(fivePointRating * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRatingStars(ebookId, currentRating) {
|
function renderRatingStars(mediaId, currentRating) {
|
||||||
const fivePointRating = convertToFivePoint(currentRating);
|
const fivePointRating = convertToFivePoint(currentRating);
|
||||||
let stars = '';
|
let stars = '';
|
||||||
|
|
||||||
@@ -229,7 +330,7 @@ templ Dashboard(user User) {
|
|||||||
const starClass = filled ? 'text-yellow-400' :
|
const starClass = filled ? 'text-yellow-400' :
|
||||||
halfFilled ? 'text-yellow-200' : 'text-gray-400';
|
halfFilled ? 'text-yellow-200' : 'text-gray-400';
|
||||||
|
|
||||||
stars += `<span onclick="openRatingDialog('${ebookId}', ${currentRating})" class="cursor-pointer text-xl ${starClass} hover:text-yellow-300 transition-colors" title="Click to rate (supports half-stars)">${starHtml}</span>`;
|
stars += `<span onclick="openRatingDialog('${mediaId}', ${currentRating})" class="cursor-pointer text-xl ${starClass} hover:text-yellow-300 transition-colors" title="Click to rate (supports half-stars)">${starHtml}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add rating text
|
// Add rating text
|
||||||
@@ -238,9 +339,9 @@ templ Dashboard(user User) {
|
|||||||
return stars;
|
return stars;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRatingDialog(ebookId, currentRating) {
|
function openRatingDialog(mediaId, currentRating) {
|
||||||
const fivePointRating = convertToFivePoint(currentRating);
|
const fivePointRating = convertToFivePoint(currentRating);
|
||||||
const newRating = prompt(`Rate this ebook (1-5, supports 0.5 increments):`, currentRating > 0 ? fivePointRating.toFixed(1) : '');
|
const newRating = prompt(`Rate this media (1-5, supports 0.5 increments):`, currentRating > 0 ? fivePointRating.toFixed(1) : '');
|
||||||
|
|
||||||
if (newRating === null) return; // User cancelled
|
if (newRating === null) return; // User cancelled
|
||||||
|
|
||||||
@@ -252,12 +353,12 @@ templ Dashboard(user User) {
|
|||||||
|
|
||||||
// Round to nearest 0.5
|
// Round to nearest 0.5
|
||||||
const roundedRating = Math.round(rating * 2) / 2;
|
const roundedRating = Math.round(rating * 2) / 2;
|
||||||
setRating(ebookId, convertToTenPoint(roundedRating));
|
setRating(mediaId, convertToTenPoint(roundedRating));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setRating(ebookId, rating) {
|
async function setRating(mediaId, rating) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/ebooks/${ebookId}/rating`, {
|
const response = await fetch(`/api/media-items/${mediaId}/rating`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||||
@@ -268,9 +369,9 @@ templ Dashboard(user User) {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const ratingContainer = document.getElementById(`rating-${ebookId}`);
|
const ratingContainer = document.getElementById(`rating-${mediaId}`);
|
||||||
if (ratingContainer) {
|
if (ratingContainer) {
|
||||||
ratingContainer.innerHTML = renderRatingStars(ebookId, data.rating);
|
ratingContainer.innerHTML = renderRatingStars(mediaId, data.rating);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@@ -283,16 +384,35 @@ templ Dashboard(user User) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterMedia() {
|
||||||
|
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
||||||
|
const filteredItems = mediaItems.filter(item =>
|
||||||
|
item.title.toLowerCase().includes(searchTerm) ||
|
||||||
|
(item.author && item.author.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Temporarily replace mediaItems and re-render
|
||||||
|
const tempItems = mediaItems;
|
||||||
|
mediaItems = filteredItems;
|
||||||
|
renderMediaItems();
|
||||||
|
mediaItems = tempItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewMediaItem(mediaId) {
|
||||||
|
// TODO: Implement detailed media view
|
||||||
|
alert('Media viewer coming soon!');
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load ebooks on page load
|
// Load libraries on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadTheme();
|
loadTheme();
|
||||||
loadEbooks();
|
loadLibraries();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user