From a1c500daee31a7734753c248bcef19658dfd0c6f Mon Sep 17 00:00:00 2001 From: Hamid Reza Mohammadi Date: Fri, 2 Oct 2026 14:17:19 +0330 Subject: [PATCH] homepage search improvements #511 --- GanjooRazor/Pages/Index.cshtml | 17 ++- GanjooRazor/Pages/Index.cshtml.cs | 18 ++++ GanjooRazor/wwwroot/css/p8.css | 174 ++++++++++++++++++++++++++++++ GanjooRazor/wwwroot/js/bk.js | 133 +++++++++++++++++++++++ 4 files changed, 341 insertions(+), 1 deletion(-) 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 = '';