v4.3 ui: 3D wooden bookshelf redesign, fix player cover aspect ratio, and smooth subtitle scroll
This commit is contained in:
@@ -179,6 +179,9 @@
|
||||
<button class="btn btn-header-archive" onclick="openProjectArchive()" title="Load or manage saved projects">
|
||||
<i class="bi bi-archive me-1"></i> Archive
|
||||
</button>
|
||||
<a class="btn btn-header-archive" href="/home" target="_blank" rel="noopener" title="Open the public Audiobook Library in a new tab">
|
||||
<i class="bi bi-book-half me-1"></i> Library
|
||||
</a>
|
||||
<button class="btn btn-header-help" id="headerHelpBtn" onclick="handleHeaderHelp()" title="Show quick start guide">
|
||||
<i class="bi bi-question-circle me-1"></i>
|
||||
<span id="headerHelpLabel">Quick Start</span>
|
||||
@@ -518,8 +521,9 @@
|
||||
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
VACUUM ডেটাবেসের ফাঁকা স্পেস reclaim করে এটি ছোট করে। প্রজেক্ট ডিলিট করার পর বা মাসে একবার চালানো ভালো। এটি কিছু সময় নিতে পারে।
|
||||
VACUUM reclaims free space in the database and shrinks it. It's good to run after deleting projects or once a month. It may take some time.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
margin: 0;
|
||||
background: #f5e9d6;
|
||||
min-height: 100vh;
|
||||
color: #3e2723;
|
||||
background: radial-gradient(circle at 50% 0%, #f7ecd9 0%, #efe1c9 55%, #e8d7ba 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
@@ -111,11 +112,56 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Category navigation — integrated cleanly onto the wood base */
|
||||
.category-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 28px;
|
||||
justify-content: flex-start; /* Left aligned as requested */
|
||||
align-items: center;
|
||||
padding: 8px 32px 16px; /* Reduced vertical padding for better balance */
|
||||
margin: 0;
|
||||
background: transparent; /* Removed dark background */
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.65); /* Crisp, slightly faded white */
|
||||
padding: 4px 0; /* Reduced vertical padding */
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, text-shadow 0.2s;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.5); /* Stronger shadow for readability on wood */
|
||||
}
|
||||
|
||||
.category-pill:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.category-pill.active {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 2px 5px rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
.category-pill .cat-count {
|
||||
display: none; /* Hide counts to match the clean iBooks look */
|
||||
}
|
||||
|
||||
/* Bookcase container */
|
||||
.bookcase-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
padding: 24px 24px 40px;
|
||||
}
|
||||
|
||||
.library-intro {
|
||||
@@ -137,29 +183,44 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Bookcase shelf */
|
||||
/* Realistic Wooden Bookcase (iBooks style) */
|
||||
.bookcase {
|
||||
background: linear-gradient(180deg, #c8a87b 0%, #a67c52 100%);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 12px 40px rgba(74,44,42,0.3), inset 0 2px 4px rgba(255,255,255,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 24px 24px 0; /* Bottom padding 0 to fit category nav */
|
||||
box-shadow: inset 0 0 30px rgba(0,0,0,0.6), 0 15px 40px rgba(0,0,0,0.3);
|
||||
position: relative;
|
||||
background-color: #b57b47;
|
||||
background-image:
|
||||
repeating-linear-gradient(90deg, transparent 0, transparent 2px, rgba(0,0,0,0.04) 2px, rgba(0,0,0,0.04) 4px),
|
||||
linear-gradient(90deg, #8a5024 0%, #c48c58 8%, #c48c58 92%, #8a5024 100%);
|
||||
border: 10px solid #6b3e1b;
|
||||
/* border-bottom-width removed so the frame wraps completely around */
|
||||
}
|
||||
|
||||
|
||||
.shelf {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 24px;
|
||||
padding: 20px 16px 36px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 30px 24px;
|
||||
padding: 20px 16px 0;
|
||||
position: relative;
|
||||
border-bottom: 8px solid #6b4226;
|
||||
box-shadow: 0 6px 0 #5a3520, 0 8px 12px rgba(0,0,0,0.2);
|
||||
margin-bottom: 24px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 40px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.shelf:last-child {
|
||||
margin-bottom: 0;
|
||||
|
||||
/* 3D Shelf Board */
|
||||
.shelf::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
right: -10px;
|
||||
bottom: -18px; /* Height of the board */
|
||||
height: 18px;
|
||||
background: linear-gradient(to bottom, #d6a67a 0%, #9c6030 30%, #6b3e1b 100%);
|
||||
border-top: 1px solid #f2cda8;
|
||||
border-bottom: 2px solid #3d200a;
|
||||
box-shadow: 0 12px 15px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 2px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Book card */
|
||||
@@ -168,6 +229,8 @@
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: bottom center;
|
||||
position: relative;
|
||||
z-index: 2; /* Ensure books sit on top of the shelf board */
|
||||
margin-bottom: -2px; /* Pull down slightly to rest exactly on the edge */
|
||||
}
|
||||
|
||||
.book-card:hover {
|
||||
@@ -177,11 +240,11 @@
|
||||
.book-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 4px 8px 8px 4px;
|
||||
border-radius: 2px 6px 6px 2px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
-2px 2px 0 rgba(0,0,0,0.1),
|
||||
-4px 4px 0 rgba(0,0,0,0.08),
|
||||
-2px 0px 0px rgba(255,255,255,0.4) inset,
|
||||
-4px 2px 10px rgba(0,0,0,0.5),
|
||||
4px 6px 16px rgba(0,0,0,0.3);
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #2c3e50, #4a6278);
|
||||
@@ -190,16 +253,18 @@
|
||||
justify-content: flex-end;
|
||||
padding: 16px;
|
||||
color: white;
|
||||
border-left: 4px solid rgba(0,0,0,0.15); /* book spine effect */
|
||||
}
|
||||
|
||||
.book-cover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: 4px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(90deg, rgba(0,0,0,0.3), transparent);
|
||||
width: 3px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.2), transparent);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.book-cover img {
|
||||
@@ -259,35 +324,6 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.book-meta {
|
||||
margin-top: 12px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.book-meta-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #4a2c2a;
|
||||
margin-bottom: 2px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-meta-stats {
|
||||
font-size: 0.72rem;
|
||||
color: #6b4226;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.book-meta-stats i { font-size: 0.7rem; }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -728,10 +764,10 @@
|
||||
}
|
||||
|
||||
.player-cover {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
aspect-ratio: 2 / 3;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 12px;
|
||||
border-radius: 4px 8px 8px 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
background: linear-gradient(135deg, #2c3e50, #4a6278);
|
||||
@@ -741,6 +777,7 @@
|
||||
padding: 14px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
border-left: 4px solid rgba(0,0,0,0.15);
|
||||
}
|
||||
.player-cover img {
|
||||
position: absolute;
|
||||
@@ -789,10 +826,10 @@
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: #3e2b1d;
|
||||
max-height: 160px;
|
||||
height: 180px; /* Fixed height so player size stays constant */
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
min-height: 70px;
|
||||
position: relative; /* Crucial for accurate offset calculations */
|
||||
}
|
||||
.player-subtitle .pw {
|
||||
cursor: pointer;
|
||||
@@ -931,7 +968,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.player-cover { width: 150px; height: 150px; }
|
||||
.player-cover { height: 150px; }
|
||||
.player-controls { gap: 16px; }
|
||||
}
|
||||
|
||||
@@ -966,12 +1003,12 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="library-intro" style="text-align:center; padding-top: 40px;">
|
||||
<h2 style="font-family:'Playfair Display',serif; font-size:2.2rem; font-weight:700; color:#4a2c2a; margin-bottom:8px;">Discover Stories That Speak</h2>
|
||||
<p style="color:#6b4226; font-size:1.05rem; opacity:0.85;">Browse our collection of interactive audiobooks</p>
|
||||
</div>
|
||||
|
||||
<main class="bookcase-container">
|
||||
<div class="library-intro">
|
||||
<h2>Discover Stories That Speak</h2>
|
||||
<p>Browse our collection of interactive audiobooks</p>
|
||||
</div>
|
||||
|
||||
<div id="bookcaseContainer">
|
||||
<div class="loading-state">
|
||||
<div class="spinner-border" role="status" style="color: #6b4226;"></div>
|
||||
@@ -1084,13 +1121,17 @@
|
||||
let allBooks = [];
|
||||
let currentBook = null;
|
||||
let currentBookUrl = '';
|
||||
let activeCategory = 'all'; // 'all' | '<category name>' | '__others__'
|
||||
let currentSearch = '';
|
||||
|
||||
const OTHERS_KEY = '__others__';
|
||||
|
||||
async function loadBooks() {
|
||||
try {
|
||||
const resp = await fetch('/api/public/books');
|
||||
const data = await resp.json();
|
||||
allBooks = data.books || [];
|
||||
renderBookcase(allBooks);
|
||||
applyFilters();
|
||||
} catch (e) {
|
||||
document.getElementById('bookcaseContainer').innerHTML = `
|
||||
<div class="empty-state">
|
||||
@@ -1106,13 +1147,29 @@
|
||||
const container = document.getElementById('bookcaseContainer');
|
||||
|
||||
if (!books || books.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-book"></i>
|
||||
<h3>No books yet</h3>
|
||||
<p>The library is being curated. Check back soon!</p>
|
||||
</div>
|
||||
`;
|
||||
// পুরো লাইব্রেরি খালি নাকি শুধু ফিল্টারে কিছু মেলেনি — আলাদা বার্তা
|
||||
const isFiltered = (activeCategory !== 'all') || currentSearch;
|
||||
if (isFiltered) {
|
||||
// ফিল্টারে কিছু মেলেনি — কিন্তু category nav রাখি যাতে অন্য category বাছা যায়
|
||||
container.innerHTML = `
|
||||
<div class="bookcase">
|
||||
<div class="empty-state" style="color:#f0e0c4;">
|
||||
<i class="bi bi-search"></i>
|
||||
<h3>No matching books</h3>
|
||||
<p>Try a different category or search term.</p>
|
||||
</div>
|
||||
${buildCategoryNavHtml()}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-book"></i>
|
||||
<h3>No books yet</h3>
|
||||
<p>The library is being curated. Check back soon!</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1132,23 +1189,32 @@
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// category nav — কাঠের বাক্সের ভেতরে নিচের shelf-label বার
|
||||
html += buildCategoryNavHtml();
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderBookCard(book) {
|
||||
const thumbnailHtml = book.thumbnail_data
|
||||
? `<img src="data:image/${book.thumbnail_format};base64,${book.thumbnail_data}" alt="${escapeHtml(book.name)}">
|
||||
<div class="book-cover-overlay"></div>`
|
||||
: '';
|
||||
|
||||
const coverClass = book.thumbnail_data ? '' : 'book-cover-default';
|
||||
const hasThumb = !!book.thumbnail_data;
|
||||
const author = book.author || 'Unknown Author';
|
||||
|
||||
// থাম্বনেইল থাকলে: শুধু ছবি (টাইটেল/অথর থাম্বনেইলেই আছে, ডুপ্লিকেট দেখাব না)
|
||||
// না থাকলে: ডিফল্ট কভার + টাইটেল/অথর ওভারলে
|
||||
if (hasThumb) {
|
||||
return `
|
||||
<div class="book-card" onclick="openBook(${book.id})" title="${escapeHtml(book.name)}">
|
||||
<div class="book-cover">
|
||||
<img src="data:image/${book.thumbnail_format};base64,${book.thumbnail_data}" alt="${escapeHtml(book.name)}">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="book-card" onclick="openBook(${book.id})" title="${escapeHtml(book.name)}">
|
||||
<div class="book-cover ${coverClass}">
|
||||
${thumbnailHtml}
|
||||
<div class="book-cover book-cover-default">
|
||||
<div class="book-cover-content">
|
||||
<div class="book-title">${escapeHtml(book.name)}</div>
|
||||
<div class="book-author">by ${escapeHtml(author)}</div>
|
||||
@@ -1357,19 +1423,99 @@
|
||||
if (e.key === 'Escape') closeBookModal();
|
||||
});
|
||||
|
||||
function filterBooks(query) {
|
||||
query = query.toLowerCase().trim();
|
||||
if (!query) {
|
||||
renderBookcase(allBooks);
|
||||
return;
|
||||
function buildCategoryNavHtml() {
|
||||
// category গুলো সংগ্রহ (case-insensitive dedupe, original casing রাখা)
|
||||
const catMap = new Map(); // lowercaseKey -> { label, count }
|
||||
let othersCount = 0;
|
||||
for (const b of allBooks) {
|
||||
const cat = (b.category || '').trim();
|
||||
if (!cat) {
|
||||
othersCount++;
|
||||
continue;
|
||||
}
|
||||
const key = cat.toLowerCase();
|
||||
if (catMap.has(key)) {
|
||||
catMap.get(key).count++;
|
||||
} else {
|
||||
catMap.set(key, { label: cat, count: 1 });
|
||||
}
|
||||
}
|
||||
const filtered = allBooks.filter(b =>
|
||||
b.name.toLowerCase().includes(query) ||
|
||||
(b.author && b.author.toLowerCase().includes(query)) ||
|
||||
(b.description && b.description.toLowerCase().includes(query))
|
||||
);
|
||||
|
||||
const sortedCats = Array.from(catMap.values())
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
let html = `<nav class="category-nav" id="categoryNav">`;
|
||||
|
||||
html += `
|
||||
<button class="category-pill ${activeCategory === 'all' ? 'active' : ''}"
|
||||
onclick="selectCategory('all')">
|
||||
<i class="bi bi-grid"></i> All
|
||||
<span class="cat-count">${allBooks.length}</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
for (const c of sortedCats) {
|
||||
const isActive = activeCategory.toLowerCase() === c.label.toLowerCase();
|
||||
html += `
|
||||
<button class="category-pill ${isActive ? 'active' : ''}"
|
||||
onclick="selectCategory('${escapeAttr(c.label)}')">
|
||||
${escapeHtml(c.label)}
|
||||
<span class="cat-count">${c.count}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (othersCount > 0) {
|
||||
html += `
|
||||
<button class="category-pill ${activeCategory === OTHERS_KEY ? 'active' : ''}"
|
||||
onclick="selectCategory('${OTHERS_KEY}')">
|
||||
<i class="bi bi-three-dots"></i> Others
|
||||
<span class="cat-count">${othersCount}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</nav>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function selectCategory(cat) {
|
||||
activeCategory = cat;
|
||||
applyFilters(); // applyFilters → renderBookcase → nav নতুন করে বসবে
|
||||
}
|
||||
|
||||
function filterBooks(query) {
|
||||
currentSearch = (query || '').toLowerCase().trim();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
let filtered = allBooks;
|
||||
|
||||
// category ফিল্টার
|
||||
if (activeCategory === OTHERS_KEY) {
|
||||
filtered = filtered.filter(b => !(b.category || '').trim());
|
||||
} else if (activeCategory !== 'all') {
|
||||
filtered = filtered.filter(b =>
|
||||
(b.category || '').trim().toLowerCase() === activeCategory.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
// সার্চ ফিল্টার
|
||||
if (currentSearch) {
|
||||
filtered = filtered.filter(b =>
|
||||
b.name.toLowerCase().includes(currentSearch) ||
|
||||
(b.author && b.author.toLowerCase().includes(currentSearch)) ||
|
||||
(b.description && b.description.toLowerCase().includes(currentSearch))
|
||||
);
|
||||
}
|
||||
|
||||
renderBookcase(filtered);
|
||||
}
|
||||
|
||||
function escapeAttr(text) {
|
||||
return (text || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
@@ -1748,9 +1894,14 @@
|
||||
sp.classList.add('current-word');
|
||||
// keep highlighted word in view inside subtitle box
|
||||
const box = document.getElementById('plSubtitle');
|
||||
const spTop = sp.offsetTop, spBottom = spTop + sp.offsetHeight;
|
||||
if (spTop < box.scrollTop || spBottom > box.scrollTop + box.clientHeight) {
|
||||
box.scrollTo({ top: spTop - box.clientHeight / 2, behavior: 'smooth' });
|
||||
const spTop = sp.offsetTop;
|
||||
const spBottom = spTop + sp.offsetHeight;
|
||||
const boxScrollTop = box.scrollTop;
|
||||
const boxHeight = box.clientHeight;
|
||||
|
||||
// Scroll if the word is near the edges (30px buffer)
|
||||
if (spTop < boxScrollTop + 30 || spBottom > boxScrollTop + boxHeight - 30) {
|
||||
box.scrollTo({ top: spTop - (boxHeight / 2) + (sp.offsetHeight / 2), behavior: 'smooth' });
|
||||
}
|
||||
playerState.lastWordSpan = sp;
|
||||
}
|
||||
@@ -1829,4 +1980,4 @@
|
||||
loadBooks();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
Reference in New Issue
Block a user