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

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