- Replace navigation bar with Header() component - Remove duplicate logout function (now in header.js) - Maintain consistent header across pages - Update generated template files This provides consistent navigation and theme switching functionality across all pages using the reusable header component.
397 lines
18 KiB
Templ
397 lines
18 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>
|
|
<script src="/static/toast.js"></script>
|
|
<script src="/static/header.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">
|
|
Header(user, "/dashboard")
|
|
|
|
<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!');
|
|
}
|
|
|
|
// Load libraries on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadTheme();
|
|
loadLibraries();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
} |