v4.3 ui: 3D wooden bookshelf redesign, fix player cover aspect ratio, and smooth subtitle scroll

This commit is contained in:
Ashim Kumar
2026-07-03 18:43:07 +06:00
parent cf93085e22
commit 14d18fbad4
14 changed files with 1174 additions and 193 deletions

View File

@@ -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">

View File

@@ -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, '&quot;');
}
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>