Files
bookhoard/templates/dashboard.templ
T
john-okeefe 6d3db17cf5 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
2026-01-28 11:03:17 -05:00

420 lines
19 KiB
Templ

package templates
templ Dashboard(user User) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookmann</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;
--bg-secondary: #16161e;
--text-primary: #a9b1d6;
--text-secondary: #565f89;
--accent: #7aa2f7;
--border: #414868;
}
.theme-tokyo-night {
--bg-primary: #1a1b26;
--bg-secondary: #16161e;
--text-primary: #a9b1d6;
--text-secondary: #565f89;
--accent: #7aa2f7;
--border: #414868;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.card {
background-color: var(--bg-secondary);
border-color: var(--border);
}
.btn-primary {
background-color: var(--accent);
color: var(--bg-primary);
}
.btn-primary:hover {
opacity: 0.8;
}
.rating-star {
display: inline-block;
transition: all 0.2s ease;
cursor: pointer;
position: relative;
}
.rating-star:hover {
transform: scale(1.1);
}
.half-star {
position: relative;
display: inline-block;
}
.half-star::before {
content: '★';
position: absolute;
left: 0;
top: 0;
width: 50%;
overflow: hidden;
color: #facc15; /* yellow-400 */
}
.rating-text {
font-size: 0.875rem;
margin-left: 0.5rem;
opacity: 0.8;
}
</style>
</head>
<body class="theme-tokyo-night">
<!-- 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">
<a href="/" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Dashboard</a>
if user.Role == "admin" {
<a href="/admin" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Admin</a>
}
<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>
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Your Media Library</h2>
<p style="color: var(--text-secondary)">Browse and manage your media collections</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>
<!-- Media Grid -->
<div id="media-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Media items will be loaded here -->
</div>
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center py-8" style="color: var(--text-secondary)">
Loading your media libraries...
</div>
</div>
<script>
let libraries = [];
let currentLibrary = null;
let mediaItems = [];
function loadLibraries() {
const loading = document.getElementById('loading');
const librariesContainer = document.getElementById('libraries-container');
loading.style.display = 'block';
librariesContainer.innerHTML = '';
fetch('/api/libraries/visible', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
loading.style.display = 'none';
libraries = data;
renderLibraries();
})
.catch(error => {
loading.style.display = 'none';
console.error('Error loading libraries:', error);
librariesContainer.innerHTML = '<div class="text-center py-8" style="color: var(--text-secondary)">Error loading libraries. Please try again.</div>';
});
}
function renderLibraries() {
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 = '';
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');
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 = item.cover_image_path ? item.cover_image_path : '/static/placeholder-book.svg';
// Get user's rating for this media item
const rating = await getMediaRating(item.id);
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)">' + 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);
}
}
async function getMediaRating(mediaId) {
try {
const response = await fetch(`/api/media-items/${mediaId}/rating`, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
return data.rating || 0;
}
return 0;
} catch (error) {
console.error('Error fetching rating:', error);
return 0;
}
}
// Convert 10-point rating to 5-point with half-star precision
function convertToFivePoint(rating) {
return rating / 2;
}
// Convert 5-point with half-stars back to 10-point
function convertToTenPoint(fivePointRating) {
return Math.round(fivePointRating * 2);
}
function renderRatingStars(mediaId, currentRating) {
const fivePointRating = convertToFivePoint(currentRating);
let stars = '';
for (let i = 1; i <= 5; i++) {
const filled = i <= Math.floor(fivePointRating);
const halfFilled = !filled && (i - 0.5) <= fivePointRating;
let starHtml = '';
if (filled) {
starHtml = '★';
} else if (halfFilled) {
starHtml = '⭐'; // Using different symbol for half-star
} else {
starHtml = '☆';
}
const starClass = filled ? 'text-yellow-400' :
halfFilled ? 'text-yellow-200' : 'text-gray-400';
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
stars += `<span class="text-sm ml-2" style="color: var(--text-secondary)">${currentRating > 0 ? fivePointRating.toFixed(1) + '/5' : 'Not rated'}</span>`;
return stars;
}
function openRatingDialog(mediaId, currentRating) {
const fivePointRating = convertToFivePoint(currentRating);
const newRating = prompt(`Rate this media (1-5, supports 0.5 increments):`, currentRating > 0 ? fivePointRating.toFixed(1) : '');
if (newRating === null) return; // User cancelled
const rating = parseFloat(newRating);
if (isNaN(rating) || rating < 0.5 || rating > 5) {
alert('Please enter a valid rating between 0.5 and 5');
return;
}
// Round to nearest 0.5
const roundedRating = Math.round(rating * 2) / 2;
setRating(mediaId, convertToTenPoint(roundedRating));
}
async function setRating(mediaId, rating) {
try {
const response = await fetch(`/api/media-items/${mediaId}/rating`, {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
},
body: JSON.stringify({ rating: rating })
});
if (response.ok) {
const data = await response.json();
const ratingContainer = document.getElementById(`rating-${mediaId}`);
if (ratingContainer) {
ratingContainer.innerHTML = renderRatingStars(mediaId, data.rating);
}
} else {
const errorText = await response.text();
console.error('Error setting rating:', errorText);
alert('Failed to set rating: ' + errorText);
}
} catch (error) {
console.error('Error setting rating:', error);
alert('Failed to set rating due to network error');
}
}
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() {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/';
}
// Load libraries on page load
document.addEventListener('DOMContentLoaded', function() {
loadTheme();
loadLibraries();
});
</script>
</body>
</html>
}