Research: content model and moderation (no wiki engine embedded; Divan text format) #49
@ -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
20
api/src/search.test.ts
Normal 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
19
api/src/search.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user