feat: add bookshelf dashboard with visual shelf layout
- Create bookshelf.templ with beautiful visual bookshelf interface - Implement wooden shelf appearance with CSS gradients - Add responsive grid layout (2/3/6 columns based on screen size) - Books display with 3D spine effect and hover animations - Auto-select first library and load books on page load - Empty state and loading state handling Visual Features: - Wooden shelves with gradient shadows (12px bottom border) - Books hover with lift (translateY) and rotation effects - Book covers with aspect ratio 2/3 and inset spine highlight - Error handling falls back to placeholder-book.svg - 6 books per shelf for optimal display JavaScript Features: - Fetch visible libraries from API - Populate library selector dropdown - Load and display media items on shelves - Handle empty states gracefully - Book detail placeholder (to be implemented)
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
package templates
|
||||
|
||||
templ BookShelf(user User) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Library - 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;
|
||||
}
|
||||
.theme-dracula {
|
||||
--bg-primary: #282a36;
|
||||
--bg-secondary: #21222c;
|
||||
--text-primary: #f8f8f2;
|
||||
--text-secondary: #6272a4;
|
||||
--accent: #bd93f9;
|
||||
--border: #44475a;
|
||||
}
|
||||
.theme-nord {
|
||||
--bg-primary: #2e3440;
|
||||
--bg-secondary: #3b4252;
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary: #5e81ac;
|
||||
--accent: #88c0d0;
|
||||
--border: #4c566a;
|
||||
}
|
||||
.theme-solarized-dark {
|
||||
--bg-primary: #002b36;
|
||||
--bg-secondary: #073642;
|
||||
--text-primary: #93a1a1;
|
||||
--text-secondary: #586e75;
|
||||
--accent: #2aa198;
|
||||
--border: #586e75;
|
||||
}
|
||||
.theme-monokai {
|
||||
--bg-primary: #272822;
|
||||
--bg-secondary: #3e3d32;
|
||||
--text-primary: #f8f8f2;
|
||||
--text-secondary: #75715e;
|
||||
--accent: #a6e22e;
|
||||
--border: #49483e;
|
||||
}
|
||||
.theme-one-dark-pro {
|
||||
--bg-primary: #282c34;
|
||||
--bg-secondary: #21252b;
|
||||
--text-primary: #abb2bf;
|
||||
--text-secondary: #5c6370;
|
||||
--accent: #61dafb;
|
||||
--border: #3e4451;
|
||||
}
|
||||
.theme-material-dark {
|
||||
--bg-primary: #263238;
|
||||
--bg-secondary: #37474f;
|
||||
--text-primary: #eeffff;
|
||||
--text-secondary: #546e7a;
|
||||
--accent: #80cbc4;
|
||||
--border: #455a64;
|
||||
}
|
||||
.theme-catppuccin-mocha {
|
||||
--bg-primary: #1e1e2e;
|
||||
--bg-secondary: #181825;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #bac2de;
|
||||
--accent: #f38ba8;
|
||||
--border: #313244;
|
||||
}
|
||||
.theme-catppuccin-macchiato {
|
||||
--bg-primary: #24273a;
|
||||
--bg-secondary: #1e2030;
|
||||
--text-primary: #cad3f5;
|
||||
--text-secondary: #b8c0e0;
|
||||
--accent: #f0c6c6;
|
||||
--border: #363a4f;
|
||||
}
|
||||
.theme-catppuccin-frappe {
|
||||
--bg-primary: #303446;
|
||||
--bg-secondary: #292c3c;
|
||||
--text-primary: #c6d0f5;
|
||||
--text-secondary: #b5bfe2;
|
||||
--accent: #f2d5cf;
|
||||
--border: #414559;
|
||||
}
|
||||
.theme-catppuccin-latte {
|
||||
--bg-primary: #eff1f5;
|
||||
--bg-secondary: #e6e9ef;
|
||||
--text-primary: #4c4f69;
|
||||
--text-secondary: #5c5f77;
|
||||
--accent: #d20f39;
|
||||
--border: #bcc0cc;
|
||||
}
|
||||
/* Wood themes */
|
||||
.theme-wood-light {
|
||||
--bg-primary: rgba(222, 184, 135, 0.9);
|
||||
--bg-secondary: rgba(210, 166, 121, 0.95);
|
||||
--text-primary: #2c1810;
|
||||
--text-secondary: #5a3a2a;
|
||||
--accent: #8b5a2b;
|
||||
--border: #6b4423;
|
||||
}
|
||||
.theme-wood-dark {
|
||||
--bg-primary: rgba(139, 115, 85, 0.9);
|
||||
--bg-secondary: rgba(107, 83, 68, 0.95);
|
||||
--text-primary: #f5e6d3;
|
||||
--text-secondary: #d4c4b0;
|
||||
--accent: #deb887;
|
||||
--border: #5a4636;
|
||||
}
|
||||
.theme-wood-mahogany {
|
||||
--bg-primary: rgba(160, 82, 45, 0.9);
|
||||
--bg-secondary: rgba(139, 69, 19, 0.95);
|
||||
--text-primary: #ffe4c4;
|
||||
--text-secondary: #daa520;
|
||||
--accent: #f4a460;
|
||||
--border: #8b4513;
|
||||
}
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
.card {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
.shelf {
|
||||
position: relative;
|
||||
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.1) 100%);
|
||||
padding: 2rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.shelf::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(107, 68, 35, 0.3) 0%,
|
||||
rgba(107, 68, 35, 0.5) 50%,
|
||||
rgba(107, 68, 35, 0.3) 100%);
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.book-item {
|
||||
position: relative;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.book-item:hover {
|
||||
transform: translateY(-8px) rotate(-2deg);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
}
|
||||
.book-cover {
|
||||
aspect-ratio: 2/3;
|
||||
overflow: hidden;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow:
|
||||
2px 2px 4px rgba(0, 0, 0, 0.2),
|
||||
-1px -1px 2px rgba(255, 255, 255, 0.1) inset;
|
||||
position: relative;
|
||||
}
|
||||
.book-cover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(to right,
|
||||
rgba(0, 0, 0, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%);
|
||||
}
|
||||
.library-selector {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="theme-tokyo-night">
|
||||
Header(user, "/bookshelf")
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Library Selector -->
|
||||
<div class="library-selector">
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div class="flex-1 w-full">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Select Library</label>
|
||||
<select id="library-select" onchange="selectLibrary()" class="w-full px-4 py-3 border rounded-lg text-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
|
||||
<option value="">Loading libraries...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="loadBookshelf()" class="btn-primary px-6 py-3 rounded-lg font-medium">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bookshelf Container -->
|
||||
<div id="bookshelf-container">
|
||||
<!-- Loading state -->
|
||||
<div id="loading" class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading your library...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="empty-state" class="hidden 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)">No Books Yet</h3>
|
||||
<p>Select a library to view your collection</p>
|
||||
</div>
|
||||
|
||||
<!-- Books Grid -->
|
||||
<div id="books-grid" class="hidden">
|
||||
<!-- Books will be loaded here on shelves -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/theme.js"></script>
|
||||
<script>
|
||||
let libraries = [];
|
||||
let currentLibrary = null;
|
||||
let mediaItems = [];
|
||||
|
||||
function loadLibraries() {
|
||||
const loading = document.getElementById('loading');
|
||||
const librarySelect = document.getElementById('library-select');
|
||||
|
||||
loading.style.display = 'block';
|
||||
|
||||
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;
|
||||
populateLibrarySelect();
|
||||
|
||||
// Auto-select first library if available
|
||||
if (libraries.length > 0 && !currentLibrary) {
|
||||
currentLibrary = libraries[0];
|
||||
librarySelect.value = currentLibrary.id;
|
||||
loadBookshelf();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
loading.style.display = 'none';
|
||||
console.error('Error loading libraries:', error);
|
||||
showToast('Error loading libraries', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function populateLibrarySelect() {
|
||||
const librarySelect = document.getElementById('library-select');
|
||||
|
||||
if (libraries.length === 0) {
|
||||
librarySelect.innerHTML = '<option value="">No libraries available</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
librarySelect.innerHTML = '<option value="">Select a library...</option>' +
|
||||
libraries.map(library => {
|
||||
const icon = getLibraryIcon(library.type_name);
|
||||
return `<option value="${library.id}">${icon} ${library.name}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getLibraryIcon(typeName) {
|
||||
switch (typeName) {
|
||||
case 'ebooks': return '📚';
|
||||
case 'comics': return '📖';
|
||||
case 'manga': return '🗾';
|
||||
default: return '📁';
|
||||
}
|
||||
}
|
||||
|
||||
function selectLibrary() {
|
||||
const librarySelect = document.getElementById('library-select');
|
||||
const libraryId = librarySelect.value;
|
||||
|
||||
currentLibrary = libraries.find(l => l.id === libraryId);
|
||||
loadBookshelf();
|
||||
}
|
||||
|
||||
function loadBookshelf() {
|
||||
if (!currentLibrary) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
const loading = document.getElementById('loading');
|
||||
const booksGrid = document.getElementById('books-grid');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
loading.style.display = 'block';
|
||||
booksGrid.classList.add('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
fetch(`/api/media-items?library_id=${currentLibrary.id}&limit=100&offset=0`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
mediaItems = data;
|
||||
|
||||
if (mediaItems.length === 0) {
|
||||
showEmptyState();
|
||||
} else {
|
||||
renderBookshelf();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
loading.style.display = 'none';
|
||||
console.error('Error loading bookshelf:', error);
|
||||
showToast('Error loading books', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showEmptyState() {
|
||||
const booksGrid = document.getElementById('books-grid');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
loading.style.display = 'none';
|
||||
booksGrid.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function renderBookshelf() {
|
||||
const booksGrid = document.getElementById('books-grid');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
loading.style.display = 'none';
|
||||
emptyState.classList.add('hidden');
|
||||
booksGrid.classList.remove('hidden');
|
||||
|
||||
// Group books into shelves (6 books per shelf for better display)
|
||||
const booksPerShelf = 6;
|
||||
const shelves = [];
|
||||
|
||||
for (let i = 0; i < mediaItems.length; i += booksPerShelf) {
|
||||
shelves.push(mediaItems.slice(i, i + booksPerShelf));
|
||||
}
|
||||
|
||||
let html = '';
|
||||
shelves.forEach((shelfBooks, index) => {
|
||||
html += `<div class="shelf">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
${shelfBooks.map(book => renderBookCard(book)).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
booksGrid.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderBookCard(book) {
|
||||
const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';
|
||||
const authorHtml = book.author ? `<p class="text-xs" style="color: var(--text-secondary)">${book.author}</p>` : '';
|
||||
|
||||
return `<div class="book-item" onclick="viewBook('${book.id}')">
|
||||
<div class="book-cover">
|
||||
<img src="${coverUrl}"
|
||||
alt="${book.title}"
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
|
||||
${authorHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function viewBook(bookId) {
|
||||
// TODO: Navigate to book detail view
|
||||
showToast('Book viewer coming soon!', 'info');
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTheme();
|
||||
loadLibraries();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user