homepage search improvements

#511
This commit is contained in:
Hamid Reza Mohammadi 2026-10-02 14:17:19 +03:30
parent b8c45ed93c
commit a1c500daee
4 changed files with 341 additions and 1 deletions

View File

@ -196,7 +196,16 @@ else
</div>
</div>
<div class="century" id="books-shelf-caption">
مرور کتابها
</div>
<div id="books-shelf-outer">
<button type="button" class="books-shelf-handle books-shelf-handle-right" aria-label="نمایش کتابهای بیشتر در سمت راست" onclick="scrollBooksShelf(220)">‹</button>
<div id="books-shelf-wrap">
<div id="books-shelf" aria-label="مرور کتابها"></div>
</div>
<button type="button" class="books-shelf-handle books-shelf-handle-left" aria-label="نمایش کتابهای بیشتر در سمت چپ" onclick="scrollBooksShelf(-220)">›</button>
</div>
</div>
@ -211,3 +220,9 @@ else
{
@Html.Raw(ViewData["TrackingScript"])
}
@section Scripts {
<script>
initHomeBooksShelf();
</script>
}

View File

@ -115,6 +115,24 @@ namespace GanjooRazor.Pages
return Redirect(pageUrl);
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<GanjoorBookViewModel[]>(await response.Content.ReadAsStringAsync());
return new OkObjectResult(books);
}
public async Task<IActionResult> OnGetAsync()
{
var maintenanceResult = TryGetMaintenanceModeResult();

View File

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

View File

@ -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 = '';