Merge remote-tracking branch 'gitea/main' into research/content-model

This commit is contained in:
Anas Rashid 2026-10-09 00:15:49 +02:00
commit 6d1668b8d3
7 changed files with 236 additions and 36 deletions

View File

@ -54,7 +54,7 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI
|---|---| |---|---|
| `GET /api/poets` | all poets | | `GET /api/poets` | all poets |
| `GET /api/page?url=/p238/...` | the poet, category or poem at a site URL (breadcrumbs, children, verses, prev/next) | | `GET /api/page?url=/p238/...` | the poet, category or poem at a site URL (breadcrumbs, children, verses, prev/next) |
| `GET /api/search?q=&poet=&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`) | | `GET /api/search?q=&poet=1,2&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`); optionally only some poets/writers; plus `authors` (who the results come from, with counts), and on page 1 `poets` (by name) and `books` (books/chapters by title) |
| `GET /api/word?w=` | one word's meanings and pronunciation from the local Wiktionary data (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none) | | `GET /api/word?w=` | one word's meanings and pronunciation from the local Wiktionary data (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none) |
| `/api/auth/*` | accounts: sign-up, sign-in (returns a Bearer token), profile, password, delete | | `/api/auth/*` | accounts: sign-up, sign-in (returns a Bearer token), profile, password, delete |
| `/api/library/*` | the signed-in reader's library: toggle poets, works, couplets and words; list with full paths; notes | | `/api/library/*` | the signed-in reader's library: toggle poets, works, couplets and words; list with full paths; notes |

20
api/src/search.test.ts Normal file
View File

@ -0,0 +1,20 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { nameMatches } from './search.ts';
import { terms } from './urdu.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('poets/writers by name or pen name; books/chapters by title; Urdu variants match', async () => {
const ghalib = await nameMatches(terms('غالب'));
assert.ok(ghalib.poets.some((p) => p.url === '/p266'), 'Ghalib by pen name');
const iqbal = await nameMatches(terms('اقبال'));
assert.equal(iqbal.poets[0].url, '/p238', 'most works first');
const book = await nameMatches(terms('بانگ درا'));
assert.ok(book.books.some((b) => b.url.startsWith('/p238/')), 'Iqbal\'s book by title');
assert.ok(book.books[0].poet && book.books[0].works > 0);
const arabic = await nameMatches(terms('مير تقي مير')); // Arabic yeh
assert.ok(arabic.poets.some((p) => p.url === '/p303'), 'Arabic ي/ی variants');
assert.deepEqual(await nameMatches(terms('ژژژژژ')), { poets: [], books: [] });
});

19
api/src/search.ts Normal file
View File

@ -0,0 +1,19 @@
import { pool } from './db.ts';
import { normalise } from './urdu.ts';
// poets/writers by name or pen name, and books/chapters by title, matched with the same Urdu normalisation
// as the content; ponytail: ~1,400 rows filtered in memory per search, add normalised columns + trigram index if it grows
export async function nameMatches(terms: string[]) {
const has = (text: string) => { const n = normalise(text); return terms.every((t) => n.includes(t)); };
const [poets, cats] = await Promise.all([
pool.query(`SELECT p.id, p.url, p.name, p.nickname, p.birth_year_ah, p.death_year_ah, p.birth_year_ce, p.death_year_ce,
(SELECT count(*)::int FROM poems w WHERE w.poet_id = p.id) AS works FROM poets p`),
pool.query(`SELECT c.id, c.url, c.title, t.nickname AS poet, t.url AS poet_url,
(SELECT count(*)::int FROM poems w WHERE w.category_id = c.id) AS works
FROM categories c JOIN poets t ON t.id = c.poet_id WHERE c.parent_id IS NOT NULL`),
]);
return {
poets: poets.rows.filter((p) => has(`${p.name} ${p.nickname}`)).sort((a, b) => b.works - a.works).slice(0, 24),
books: cats.rows.filter((c) => has(c.title)).sort((a, b) => b.works - a.works).slice(0, 12),
};
}

View File

@ -1,7 +1,8 @@
// Divan API (Fastify + PostgreSQL) // Divan API (Fastify + PostgreSQL)
// GET /api/poets all poets // GET /api/poets all poets
// GET /api/page?url=/p238/... poet, category or poem at that URL // GET /api/page?url=/p238/... poet, category or poem at that URL
// GET /api/search?q=&poet=&page= // GET /api/search?q=&poet=1,2&page= content results (paged, optionally only these poets/writers), the poets/writers
// the results come from (with counts), poets by name and books/chapters by title
// GET /api/word?w= Wiktionary meanings and pronunciation (sidebar) // GET /api/word?w= Wiktionary meanings and pronunciation (sidebar)
// /api/auth/* accounts (see auth.ts) // /api/auth/* accounts (see auth.ts)
// /api/admin/* admin panel (see admin.ts) // /api/admin/* admin panel (see admin.ts)
@ -10,6 +11,7 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import { pool } from './db.ts'; import { pool } from './db.ts';
import { likePatterns, normalise, terms } from './urdu.ts'; import { likePatterns, normalise, terms } from './urdu.ts';
import { nameMatches } from './search.ts';
import { lookup, PUNCT } from './dictionary.ts'; import { lookup, PUNCT } from './dictionary.ts';
import { authRoutes } from './auth.ts'; import { authRoutes } from './auth.ts';
import { adminRoutes } from './admin.ts'; import { adminRoutes } from './admin.ts';
@ -91,13 +93,15 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/search', async (req) => { app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/search', async (req) => {
const patterns = likePatterns(req.query.q ?? ''); const patterns = likePatterns(req.query.q ?? '');
if (!patterns.length) return { total: 0, page: 1, results: [] }; if (!patterns.length) return { total: 0, page: 1, pageSize: PAGE_SIZE, results: [], poets: [], books: [] };
const page = Math.max(1, Number(req.query.page) || 1); const page = Math.max(1, Number(req.query.page) || 1);
const params: unknown[] = [...patterns]; const params: unknown[] = [...patterns];
const where = patterns.map((_, i) => `p.search_text ILIKE $${i + 1}`); const where = patterns.map((_, i) => `p.search_text ILIKE $${i + 1}`);
if (req.query.poet) { const textWhere = where.join(' AND ');
params.push(Number(req.query.poet)); const poetIds = [...new Set(String(req.query.poet ?? '').split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0))].slice(0, 50);
where.push(`p.poet_id = $${params.length}`); if (poetIds.length) {
params.push(poetIds);
where.push(`p.poet_id = ANY($${params.length})`);
} }
const sql = `FROM poems p JOIN poets t ON t.id = p.poet_id WHERE ${where.join(' AND ')}`; const sql = `FROM poems p JOIN poets t ON t.id = p.poet_id WHERE ${where.join(' AND ')}`;
// several words: poems with them together as a phrase come first // several words: poems with them together as a phrase come first
@ -130,7 +134,15 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
: vs.filter((v) => v.couplet === best.couplet && (v.position === 'Right' || v.position === 'Left')).map((v) => v.text); : vs.filter((v) => v.couplet === best.couplet && (v.position === 'Right' || v.position === 'Left')).map((v) => v.text);
return { ...r, snippet: lines, prose: best?.position === 'Paragraph' }; return { ...r, snippet: lines, prose: best?.position === 'Paragraph' };
}); });
return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results }; // names and titles on the first page: poets/writers whose name has every word, books/chapters likewise
const names = page === 1 && !poetIds.length ? await nameMatches(ts) : { poets: [], books: [] };
// which poets/writers the matching content comes from (whatever the poet filter), for narrowing down
const [authors, selected] = await Promise.all([
pool.query(`SELECT t.id, t.url, t.nickname, count(*)::int AS n FROM poems p JOIN poets t ON t.id = p.poet_id
WHERE ${textWhere} GROUP BY t.id ORDER BY n DESC, t.nickname LIMIT 40`, patterns),
poetIds.length ? pool.query('SELECT id, url, nickname FROM poets WHERE id = ANY($1) ORDER BY nickname', [poetIds]) : { rows: [] },
]);
return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results, ...names, authors: authors.rows, selected: selected.rows };
}); });
// one word in Arabic script (Urdu, Persian, Arabic), as selected by a reader // one word in Arabic script (Urdu, Persian, Arabic), as selected by a reader

View File

@ -34,7 +34,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
<div class="wrap"> <div class="wrap">
<a class="wordmark" href="/">دیوان</a> <a class="wordmark" href="/">دیوان</a>
<form class="search-form" action="/search" method="get" role="search"> <form class="search-form" action="/search" method="get" role="search">
<input type="search" name="q" id="q" value={q} placeholder="شعر، لفظ یا عبارت تلاش کریں" aria-label="تلاش" autocomplete="off" /> <input type="search" name="q" id="q" data-urdu value={q} placeholder="شعر، لفظ یا عبارت تلاش کریں" aria-label="تلاش" autocomplete="off" />
<button type="button" id="rom-toggle" class="in-box" aria-label="رومن حروف سے اردو" aria-pressed="false" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button> <button type="button" id="rom-toggle" class="in-box" aria-label="رومن حروف سے اردو" aria-pressed="false" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
<button type="button" id="kb-toggle" class="in-box" aria-label="اردو کی بورڈ" aria-pressed="false" title="اردو کی بورڈ"> <button type="button" id="kb-toggle" class="in-box" aria-label="اردو کی بورڈ" aria-pressed="false" title="اردو کی بورڈ">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg> <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg>
@ -105,27 +105,40 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
`<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}</div>`; `<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}</div>`;
}; };
render(); render();
// the Urdu keyboard and Roman typing work on the search box and on any input marked data-urdu
// (e.g. the author filter): whichever was focused last
let target = q;
document.addEventListener('focusin', (e) => { if (e.target.matches?.('[data-urdu]')) target = e.target; });
const typed = () => target.dispatchEvent(new Event('input', { bubbles: true })); // so suggestions update
const insert = (text) => { const insert = (text) => {
const s = q.selectionStart ?? q.value.length, e = q.selectionEnd ?? q.value.length; const s = target.selectionStart ?? target.value.length, e = target.selectionEnd ?? target.value.length;
q.setRangeText(text, s, e, 'end'); q.focus(); target.setRangeText(text, s, e, 'end'); target.focus(); typed();
}; };
kb.onmousedown = (ev) => ev.preventDefault(); // keep focus/caret in the search box kb.onmousedown = (ev) => ev.preventDefault(); // keep focus/caret in the search box
kb.onclick = (ev) => { kb.onclick = (ev) => {
const b = ev.target.closest('button'); if (!b) return; const b = ev.target.closest('button'); if (!b) return;
if (b.dataset.act === 'shift') { shift = !shift; render(); return; } if (b.dataset.act === 'shift') { shift = !shift; render(); return; }
if (b.dataset.act === 'bs') { if (b.dataset.act === 'bs') {
const s = q.selectionStart, e = q.selectionEnd; const s = target.selectionStart, e = target.selectionEnd;
q.setRangeText('', s === e ? Math.max(0, s - 1) : s, e, 'end'); q.focus(); target.setRangeText('', s === e ? Math.max(0, s - 1) : s, e, 'end'); target.focus(); typed();
return; return;
} }
insert(b.dataset.ch); insert(b.dataset.ch);
if (shift) { shift = false; render(); } // one-shot shift, as on Gboard if (shift) { shift = false; render(); } // one-shot shift, as on Gboard
}; };
kbBtn.onclick = () => { kbBtn.onclick = () => {
kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); q.focus(); kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); target.focus();
}; };
romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); q.focus(); }; romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); target.focus(); };
q.addEventListener('keydown', (ev) => { // other inputs can open the same keyboard / Roman typing with their own buttons
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return;
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
(b.dataset.urduKb ? kbBtn : romBtn).click();
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
});
document.addEventListener('keydown', (ev) => {
if (!ev.target.matches?.('[data-urdu]')) return;
if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return; if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
const ch = PHONETIC[ev.key]; const ch = PHONETIC[ev.key];
if (ch) { ev.preventDefault(); insert(ch); } if (ch) { ev.preventDefault(); insert(ch); }

View File

@ -1,41 +1,144 @@
--- ---
// Search: poets/writers by name and books/chapters by title (first page), then the content results, paged
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import Seg from '../components/Seg.astro'; import Seg from '../components/Seg.astro';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { ud, highlighter, parts, excerpt } from '../lib/urdu'; import { ud, years, highlighter, parts, excerpt } from '../lib/urdu';
const q = Astro.url.searchParams.get('q')?.trim() ?? ''; const q = Astro.url.searchParams.get('q')?.trim() ?? '';
// one or more poets/writers to search within (?poet=1&poet=2, or ?poet=1,2)
const poetIds = [...new Set(Astro.url.searchParams.getAll('poet').flatMap((v) => v.split(',')).map(Number).filter((n) => n > 0))];
const pageNo = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1); const pageNo = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1);
const res = q ? await api(`/api/search?q=${encodeURIComponent(q)}&page=${pageNo}`) : null; const res = q ? await api(`/api/search?q=${encodeURIComponent(q)}&page=${pageNo}${poetIds.length ? `&poet=${poetIds.join(',')}` : ''}`) : null;
// every poet/writer for the name box (382; filtered in the browser)
const allPoets = res ? ((await api('/api/poets')) ?? []).map((p: any) => ({ id: p.id, name: p.nickname })) : [];
const pages = res ? Math.ceil(res.total / res.pageSize) : 0; const pages = res ? Math.ceil(res.total / res.pageSize) : 0;
const re = highlighter(q); const re = highlighter(q);
const hl = (t: string) => parts(t, re).map((s, i): [string, string] => [s, i % 2 ? 'mark' : '']); const hl = (t: string) => parts(t, re).map((s, i): [string, string] => [s, i % 2 ? 'mark' : '']);
const open = (url: string) => `${url}?hl=${encodeURIComponent(q)}`; const open = (url: string) => `${url}?hl=${encodeURIComponent(q)}`;
const link = (n: number) => `/search?q=${encodeURIComponent(q)}&page=${n}`; const link = (n: number, poets: number[] = poetIds) => `/search?q=${encodeURIComponent(q)}${poets.length ? `&poet=${poets.join(',')}` : ''}${n > 1 ? `&page=${n}` : ''}`;
// author chips: the selected ones first, then the authors the results come from
const chips = res ? [...res.selected.map((p: any) => ({ ...p, n: res.authors.find((a: any) => a.id === p.id)?.n ?? 0 })),
...res.authors.filter((a: any) => !poetIds.includes(a.id))] : [];
// numbered pages: first, last, and two either side of the current one; gaps shown as …
const numbers = (() => {
const keep = new Set([1, pages, pageNo - 2, pageNo - 1, pageNo, pageNo + 1, pageNo + 2].filter((n) => n >= 1 && n <= pages));
const sorted = [...keep].sort((a, b) => a - b), out: (number | null)[] = [];
sorted.forEach((n, i) => { if (i && n - sorted[i - 1] > 1) out.push(null); out.push(n); });
return out;
})();
const from = res ? (pageNo - 1) * res.pageSize + 1 : 0, to = res ? Math.min(res.total, pageNo * res.pageSize) : 0;
--- ---
<Base title={q ? `تلاش: ${q}` : 'تلاش'} q={q}> <Base title={q ? `تلاش: ${q}` : 'تلاش'} q={q}>
{!q && <p class="muted">اوپر کے خانے میں شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔</p>} {!q && <p class="muted">اوپر کے خانے میں شاعر کا نام، شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔</p>}
{res && ( {res && (
<> <>
<h1>تلاش: {q}</h1> <h1>تلاش: {q}</h1>
<p class="muted">{res.total ? `${ud(res.total)} نتائج` : 'کوئی نتیجہ نہیں ملا۔ کم الفاظ یا کلیدی لفظ آزمائیں۔'}</p>
{res.results.map((r: any) => ( {res.poets.length > 0 && (
<div class="result"> <section class="search-group">
<a href={open(r.url)}><Seg s={hl(r.title)} /></a> · <a class="muted" href={r.poet_url}>{r.poet}</a> <h2>شعرا و ادبا <small class="muted">{ud(res.poets.length)}</small></h2>
{r.snippet.length > 0 && ( <div class="poets">
<a class="snippet" href={open(r.url)}> {res.poets.map((p: any) => (
{r.prose ? <p><Seg s={hl(excerpt(r.snippet[0], re))} /></p> : r.snippet.map((l: string) => <p><Seg s={hl(l)} /></p>)} <div class="poet-result">
</a> <a class="poet-card" href={p.url}><Seg s={hl(p.nickname)} /><small>{years(p)}</small></a>
)} <a class="muted small" href={link(1, [p.id])}>{ud(p.works)} کلام میں «{q}»</a>
</div> </div>
))} ))}
{pages > 1 && ( </div>
<nav class="pager"> </section>
{pageNo > 1 && <a href={link(pageNo - 1)}>‹ پچھلا</a>}
<span class="muted">صفحہ {ud(pageNo)} از {ud(pages)}</span>
{pageNo < pages && <a href={link(pageNo + 1)}>اگلا ›</a>}
</nav>
)} )}
{res.books.length > 0 && (
<section class="search-group">
<h2>کتابیں اور ابواب <small class="muted">{ud(res.books.length)}</small></h2>
<ul class="book-results">
{res.books.map((b: any) => (
<li><a href={b.url}><Seg s={hl(b.title)} /></a> · <a class="muted" href={b.poet_url}>{b.poet}</a> <small class="muted">({ud(b.works)})</small></li>
))}
</ul>
</section>
)}
<section class="search-group">
<h2>کلام <small class="muted">{ud(res.total)}</small></h2>
{chips.length > 0 && (
<form method="get" action="/search" class="author-filter" id="author-filter">
<input type="hidden" name="q" value={q} />
<p class="muted">شاعر یا ادیب کے لحاظ سے محدود کریں:</p>
<div class="author-chips">
{chips.map((a: any, i: number) => (
<label class={`chip${i >= 12 && !poetIds.includes(a.id) ? ' extra' : ''}`}>
<input type="checkbox" name="poet" value={a.id} checked={poetIds.includes(a.id)} />
{a.nickname} <small>{ud(a.n)}</small>
</label>
))}
{chips.length > 12 && <button type="button" class="chip more" onclick="this.parentElement.classList.add('all'); this.remove()">مزید {ud(chips.length - 12)}</button>}
</div>
<div class="author-find">
<input id="author-name" data-urdu placeholder="کسی اور شاعر یا ادیب کا نام" autocomplete="off" aria-label="شاعر کا نام" />
<button type="button" class="in-box-btn" data-urdu-rom="author-name" title="رومن حروف سے اردو">اب</button>
<button type="button" class="in-box-btn" data-urdu-kb="author-name" title="اردو کی بورڈ" aria-label="اردو کی بورڈ">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg>
</button>
<div class="author-suggest" id="author-suggest" hidden></div>
</div>
<p class="filter-actions">
<button type="submit">نتائج محدود کریں</button>
{poetIds.length > 0 && <a href={link(1, [])}>سب شعرا میں تلاش</a>}
</p>
<script type="application/json" id="all-poets" set:html={JSON.stringify(allPoets)} />
</form>
)}
<p class="muted center">
{res.total ? (pages > 1 ? `نتائج ${ud(from)}–${ud(to)} از ${ud(res.total)}` : `${ud(res.total)} نتائج`)
: 'کلام میں کوئی نتیجہ نہیں ملا۔ کم الفاظ یا کلیدی لفظ آزمائیں۔'}
</p>
{res.results.map((r: any) => (
<div class="result">
<a href={open(r.url)}><Seg s={hl(r.title)} /></a> · <a class="muted" href={r.poet_url}>{r.poet}</a>
{r.snippet.length > 0 && (
<a class="snippet" href={open(r.url)}>
{r.prose ? <p><Seg s={hl(excerpt(r.snippet[0], re))} /></p> : r.snippet.map((l: string) => <p><Seg s={hl(l)} /></p>)}
</a>
)}
</div>
))}
{pages > 1 && (
<nav class="pager" aria-label="صفحات">
{pageNo > 1 ? <a href={link(pageNo - 1)} rel="prev">‹ پچھلا</a> : <span />}
<span class="pages">
{numbers.map((n) => n === null ? <span class="gap">…</span>
: n === pageNo ? <span class="current" aria-current="page">{ud(n)}</span>
: <a href={link(n)}>{ud(n)}</a>)}
</span>
{pageNo < pages ? <a href={link(pageNo + 1)} rel="next">اگلا ›</a> : <span />}
</nav>
)}
</section>
</> </>
)} )}
<script is:inline>
// author filter: ticking a chip applies it; the name box suggests any poet/writer (Urdu-normalised)
(() => {
const form = document.getElementById('author-filter'); if (!form) return;
form.addEventListener('change', (e) => { if (e.target.name === 'poet') form.submit(); });
const poets = JSON.parse(document.getElementById('all-poets').textContent);
const norm = (s) => s.normalize('NFC').replace(/[\u064B-\u0652\u0654\u0670\u0614]/g, '').replace(/[يىئ]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ').replace(/[أإ]/g, 'ا');
const box = document.getElementById('author-name'), list = document.getElementById('author-suggest');
const add = (p) => {
const i = document.createElement('input'); i.type = 'hidden'; i.name = 'poet'; i.value = p.id;
form.append(i); form.submit();
};
box.addEventListener('input', () => {
const t = norm(box.value.trim());
const hits = t ? poets.filter((p) => norm(p.name).includes(t)).slice(0, 12) : [];
list.replaceChildren(...hits.map((p) => {
const b = document.createElement('button'); b.type = 'button'; b.textContent = p.name; b.onclick = () => add(p); return b;
}));
list.hidden = !hits.length;
});
box.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); list.querySelector('button')?.click(); } });
})();
</script>
</Base> </Base>

View File

@ -256,3 +256,36 @@ h1 + .muted { text-align: center; margin-top: 0; }
.bmk::after { content: '🔖'; font-size: .55em; vertical-align: super; margin-inline-start: 1px; user-select: none; } .bmk::after { content: '🔖'; font-size: .55em; vertical-align: super; margin-inline-start: 1px; user-select: none; }
.mark-fav::after, .mark-bm::after { font-size: .65em; margin-inline-start: 6px; vertical-align: middle; user-select: none; } .mark-fav::after, .mark-bm::after { font-size: .65em; margin-inline-start: 6px; vertical-align: middle; user-select: none; }
.mark-fav::after, .mark-bm::after { content: '🔖'; } .mark-fav::after, .mark-bm::after { content: '🔖'; }
/* search: poets/writers, books/chapters, content (paged) */
.search-group { margin: 18px 0 26px; }
.search-group h2 { font-size: 1.1rem; text-align: center; color: var(--brand); }
.search-group h2 small { font-size: .8rem; font-weight: 400; }
.poet-result { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.poet-result .small { font-size: .75rem; }
.book-results { list-style: none; padding: 0; max-width: 700px; margin: 0 auto; }
.book-results li { padding: 5px 4px; border-bottom: 1px dashed var(--gold-light); text-align: center; }
.pager .pages { display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; }
.pager .pages a, .pager .pages .current { min-width: 2em; text-align: center; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px; text-decoration: none; }
.pager .pages .current { border-color: var(--brand); color: var(--brand); font-weight: 700; }
.pager .gap { padding: 0 2px; color: var(--muted); }
/* author filter on search */
.author-filter { max-width: 760px; margin: 0 auto 14px; padding: 10px 14px; border: 1px dashed var(--gold-light); border-radius: 12px; text-align: center; }
.author-filter p { margin: 2px 0 8px; }
.author-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.author-chips .chip { display: inline-flex; align-items: center; gap: 4px; padding: 1px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--inner); cursor: pointer; font: inherit; font-size: .88rem; color: var(--ink); }
.author-chips .chip small { color: var(--muted); }
.author-chips .chip input { accent-color: var(--brand); margin: 0; }
.author-chips .chip:has(input:checked) { border-color: var(--brand); color: var(--brand); font-weight: 700; }
.author-chips .chip.extra { display: none; }
.author-chips.all .chip.extra { display: inline-flex; }
.author-find { position: relative; display: flex; gap: 4px; max-width: 420px; margin: 10px auto 0; }
.author-find input { flex: 1; font: inherit; padding: 3px 12px; border: 1.5px solid var(--border); border-radius: 10px; background: var(--paper); color: var(--ink); }
.in-box-btn { display: inline-flex; align-items: center; font: inherit; font-size: .85rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--muted); cursor: pointer; }
.in-box-btn[aria-pressed='true'] { color: var(--brand); border-color: var(--brand); }
.author-suggest { position: absolute; top: 100%; inset-inline: 0; z-index: 10; display: flex; flex-wrap: wrap; gap: 4px; padding: 6px; margin-top: 4px; background: var(--paper); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 4px 14px rgb(0 0 0 / .15); }
.author-suggest button { font: inherit; font-size: .88rem; padding: 0 10px; border: 1px solid var(--gold-light); border-radius: 999px; background: var(--inner); color: var(--ink); cursor: pointer; }
.author-suggest button:hover { border-color: var(--brand); color: var(--brand); }
.filter-actions { display: flex; gap: 12px; justify-content: center; align-items: center; margin-top: 10px !important; }
.filter-actions button { font: inherit; font-size: .85rem; padding: 1px 14px; border: 1.5px solid var(--gold); border-radius: 10px; background: var(--inner); color: var(--ink); cursor: pointer; }