diff --git a/GanjooRazor/Pages/Index.cshtml b/GanjooRazor/Pages/Index.cshtml
index 687743dc..c7d4ed39 100644
--- a/GanjooRazor/Pages/Index.cshtml
+++ b/GanjooRazor/Pages/Index.cshtml
@@ -196,7 +196,16 @@ else
-
+
+ مرور کتابها
+
+
@@ -211,3 +220,9 @@ else
{
@Html.Raw(ViewData["TrackingScript"])
}
+
+@section Scripts {
+
+}
diff --git a/GanjooRazor/Pages/Index.cshtml.cs b/GanjooRazor/Pages/Index.cshtml.cs
index 60cf14f2..bcbb377d 100644
--- a/GanjooRazor/Pages/Index.cshtml.cs
+++ b/GanjooRazor/Pages/Index.cshtml.cs
@@ -115,6 +115,24 @@ namespace GanjooRazor.Pages
return Redirect(pageUrl);
}
+ ///
+ /// JSON endpoint for the home page's "مرور کتابها" horizontal book shelf (see bk.js's
+ /// initHomeBooksShelf()). Fetched client-side via "?Handler=BookCatalog" rather than hitting
+ /// the api/ganjoor/book-catalog API route directly from browser JS, same as every other
+ /// client-side data fetch on this site - the browser only ever talks to this page's own
+ /// handlers, which proxy to the API server-side.
+ ///
+ public async Task OnGetBookCatalogAsync()
+ {
+ var response = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/book-catalog");
+ if (!response.IsSuccessStatusCode)
+ {
+ return new BadRequestObjectResult(await ReadErrorMessageAsync(response));
+ }
+ var books = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync());
+ return new OkObjectResult(books);
+ }
+
public async Task OnGetAsync()
{
var maintenanceResult = TryGetMaintenanceModeResult();
diff --git a/GanjooRazor/wwwroot/css/p8.css b/GanjooRazor/wwwroot/css/p8.css
index 0b0204a4..7a87f030 100644
--- a/GanjooRazor/wwwroot/css/p8.css
+++ b/GanjooRazor/wwwroot/css/p8.css
@@ -342,6 +342,180 @@ select {
color: var(--ink-black);
}
+/* Home page "مرور کتابها" book shelf - see initHomeBooksShelf()/buildBookSpine() in bk.js. */
+#books-shelf-outer {
+ position: relative;
+}
+
+#books-shelf-wrap {
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ padding: 10px 34px 26px;
+}
+
+#books-shelf-wrap::-webkit-scrollbar {
+ display: none;
+}
+
+.books-shelf-handle {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 2;
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ border: 1px solid var(--accent-border);
+ background-color: var(--card-paper);
+ color: var(--ink-black);
+ font-size: 1.2rem;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
+}
+
+.books-shelf-handle:hover {
+ background-color: var(--brand);
+ color: #fff;
+}
+
+.books-shelf-handle-right {
+ right: 0;
+}
+
+.books-shelf-handle-left {
+ left: 0;
+}
+
+#books-shelf {
+ display: flex;
+ align-items: flex-end;
+ gap: 14px;
+ width: max-content;
+ min-width: 100%;
+ padding: 0 10px;
+}
+
+.book-spine {
+ position: relative;
+ flex: 0 0 auto;
+ width: 56px;
+ height: 230px;
+ border-radius: 4px 4px 3px 3px;
+ cursor: pointer;
+ box-shadow: 2px 3px 8px rgba(0, 0, 0, 0.28);
+ transition: transform 0.25s ease, box-shadow 0.25s ease, width 0.3s ease, height 0.3s ease;
+ outline-offset: 3px;
+}
+
+.book-spine:hover,
+.book-spine:focus-visible {
+ transform: translateY(-12px);
+ box-shadow: 4px 12px 20px rgba(0, 0, 0, 0.4);
+}
+
+.book-spine.expanded {
+ width: 170px;
+ height: 262px;
+}
+
+.book-spine.expanded:hover {
+ transform: translateY(-6px);
+}
+
+.spine-compact {
+ position: absolute;
+ inset: 0;
+}
+
+.book-spine.expanded .spine-compact {
+ display: none;
+}
+
+.spine-title,
+.spine-poet {
+ position: absolute;
+ left: 50%;
+ transform-origin: center center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+ color: #fdf8ee;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
+ pointer-events: none;
+}
+
+.spine-title {
+ top: 95px;
+ width: 150px;
+ transform: translate(-50%, -50%) rotate(-90deg);
+ font-family: 'IranNastaliq', serif;
+ font-size: 1.2rem;
+}
+
+.spine-poet {
+ top: 200px;
+ width: 50px;
+ transform: translate(-50%, -50%) rotate(-90deg);
+ font-size: 0.68rem;
+ opacity: 0.85;
+}
+
+.spine-cover {
+ display: none;
+ position: absolute;
+ inset: 0;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 18px 12px;
+ text-align: center;
+ color: #fdf8ee;
+}
+
+.book-spine.expanded .spine-cover {
+ display: flex;
+}
+
+.spine-cover-title {
+ font-family: 'IranNastaliq', serif;
+ font-size: 1.7rem;
+ line-height: 1.5;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
+}
+
+.spine-cover-poet {
+ font-size: 0.85rem;
+ opacity: 0.9;
+}
+
+.spine-cover-close {
+ position: absolute;
+ top: 6px;
+ inset-inline-end: 6px;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: none;
+ background: rgba(0, 0, 0, 0.25);
+ color: #fdf8ee;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.spine-cover-close:hover {
+ background: rgba(0, 0, 0, 0.4);
+}
+
#guides {
max-width: 860px;
display: block;
diff --git a/GanjooRazor/wwwroot/js/bk.js b/GanjooRazor/wwwroot/js/bk.js
index af3210be..ee1d77a7 100644
--- a/GanjooRazor/wwwroot/js/bk.js
+++ b/GanjooRazor/wwwroot/js/bk.js
@@ -1620,6 +1620,139 @@ function clearHomeSearch() {
input.focus();
}
+// Home page (Index.cshtml) only: "مرور کتابها" - a horizontally scrollable shelf of book "spines",
+// Grokipedia-homepage-style. Fetched once from this page's own "?Handler=BookCatalog" (which proxies
+// RMuseum's api/ganjoor/book-catalog server-side) rather than calling that API directly from browser
+// JS - same convention every other client-side data fetch on this site follows. There's no cover-art
+// image for any book in the database, so each spine's color is a deterministic hash of its id instead
+// of a real illustration - stable across visits/reloads (not reshuffled on every page load), while
+// still giving the shelf the varied-color look a real bookshelf has.
+var expandedBookSpine = null;
+
+function hashBookColorHue(key) {
+ var str = String(key);
+ var hash = 0;
+ for (var i = 0; i < str.length; i++) {
+ hash = (hash * 31 + str.charCodeAt(i)) | 0;
+ }
+ return Math.abs(hash) % 360;
+}
+
+function collapseExpandedBookSpine() {
+ if (expandedBookSpine) {
+ expandedBookSpine.classList.remove('expanded');
+ expandedBookSpine = null;
+ }
+}
+
+function buildBookSpine(book) {
+ var spine = document.createElement('div');
+ spine.className = 'book-spine';
+ spine.tabIndex = 0;
+ spine.setAttribute('role', 'button');
+ spine.setAttribute('aria-label', book.name + (book.poetName ? ' - ' + book.poetName : ''));
+
+ var hue = hashBookColorHue(book.id);
+ spine.style.background =
+ 'linear-gradient(90deg, rgba(0,0,0,.22), transparent 12%, transparent 88%, rgba(255,255,255,.12)), ' +
+ 'hsl(' + hue + ', 42%, 30%)';
+
+ // The compact (spine) face: book name on top, poet name below (smaller) - both laid out as a
+ // normal horizontal line of text first, so Persian letter-joining/shaping happens correctly,
+ // then rotated 90deg as a whole block. True CSS vertical writing-mode (the "right" way to do
+ // this for Latin text, which is how Grokipedia's own spines work) breaks cursive Arabic/Persian
+ // script - it uprights each letter separately instead of rotating the shaped line - so rotating
+ // the finished line is the only way to get this to read correctly.
+ var compact = document.createElement('div');
+ compact.className = 'spine-compact';
+ var title = document.createElement('span');
+ title.className = 'spine-title';
+ title.textContent = book.name;
+ var poet = document.createElement('span');
+ poet.className = 'spine-poet';
+ poet.textContent = book.poetName || '';
+ compact.appendChild(title);
+ compact.appendChild(poet);
+
+ // The expanded (cover) face: shown once this spine is clicked - see openOrNavigateBookSpine().
+ var cover = document.createElement('div');
+ cover.className = 'spine-cover';
+ var closeBtn = document.createElement('button');
+ closeBtn.type = 'button';
+ closeBtn.className = 'spine-cover-close';
+ closeBtn.setAttribute('aria-label', 'بستن');
+ closeBtn.textContent = '×';
+ closeBtn.addEventListener('click', function (e) {
+ e.stopPropagation();
+ collapseExpandedBookSpine();
+ });
+ var coverTitle = document.createElement('div');
+ coverTitle.className = 'spine-cover-title';
+ coverTitle.textContent = book.name;
+ var coverPoet = document.createElement('div');
+ coverPoet.className = 'spine-cover-poet';
+ coverPoet.textContent = book.poetName || '';
+ cover.appendChild(closeBtn);
+ cover.appendChild(coverTitle);
+ cover.appendChild(coverPoet);
+
+ spine.appendChild(compact);
+ spine.appendChild(cover);
+
+ function openOrNavigateBookSpine() {
+ if (spine.classList.contains('expanded')) {
+ if (book.fullUrl) window.location.href = book.fullUrl;
+ return;
+ }
+ // Only one spine open at a time - collapsing the previous one first is what makes a growing
+ // spine read as "picking a different book" rather than stacking expanded covers.
+ collapseExpandedBookSpine();
+ spine.classList.add('expanded');
+ expandedBookSpine = spine;
+ // The spine's own width grows a lot (56px -> 170px) - without this, it can end up partly
+ // scrolled out of view, which looks like nothing happened.
+ spine.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
+ }
+
+ spine.addEventListener('click', openOrNavigateBookSpine);
+ spine.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ openOrNavigateBookSpine();
+ }
+ });
+
+ return spine;
+}
+
+// Called by the "<"/">" handles next to the shelf (see Index.cshtml). amount is signed in the
+// ordinary "positive = move the view right, negative = move it left" sense - evergreen browsers
+// (Chrome/Firefox/Safari) all follow the spec's "negative" RTL scrollLeft model, where scrollLeft
+// is 0 at the right/start edge and goes negative toward the left/end edge, so a plain scrollBy
+// without any RTL sign-flip already moves the view the way its own delta's sign says.
+function scrollBooksShelf(amount) {
+ var wrap = document.getElementById('books-shelf-wrap');
+ if (!wrap) return;
+ wrap.scrollBy({ left: amount, behavior: 'smooth' });
+}
+
+function initHomeBooksShelf() {
+ var shelf = document.getElementById('books-shelf');
+ if (!shelf) return;
+ $.ajax({
+ type: 'GET',
+ url: '?Handler=BookCatalog',
+ success: function (books) {
+ if (!books || books.length === 0) return;
+ var frag = document.createDocumentFragment();
+ for (var i = 0; i < books.length; i++) {
+ frag.appendChild(buildBookSpine(books[i]));
+ }
+ shelf.appendChild(frag);
+ }
+ });
+}
+
function onInlineSearch(value, resultBlockId, itemsClass) {
const foundPoetsNode = document.getElementById(resultBlockId);
foundPoetsNode.innerHTML = '';