divan/web/src/layouts/Base.astro
Anas Rashid f20cc25080 Tags folded at the end of a page; a "how to use" page instead of the hint
- A page's tags (poem, book, poet) move from the top into a closed «ٹیگ» section with their count at the end of the content, above previous/next; couplets keep their own tags
- The line under each poem ("select a word…") is gone; /help (استعمال کا طریقہ, linked in the footer) explains the features: search and Roman typing, meanings and the selection menu, a couplet's tools (and ⋯ on phones), notes, saved items and the word book, reading mode, fonts and colours, e-books, tags and RSS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:13:45 +02:00

629 lines
44 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
// fonts are self-hosted (no Google Fonts request; works with Brave Shields and offline)
import '@fontsource/noto-naskh-arabic/400.css';
import '@fontsource/noto-naskh-arabic/700.css';
import '@fontsource/noto-nastaliq-urdu/400.css';
import '@fontsource/noto-nastaliq-urdu/700.css';
import '../styles/global.css';
interface Props { title?: string; description?: string; q?: string; author?: string; jsonld?: object }
// author and jsonld describe a work for search engines and the browser's own reader view
const { title, description = 'اردو کا کلاسیکی ادب', q = '', author, jsonld } = Astro.props;
const fullTitle = title ? `${title} · ${brand.name}` : `${brand.name} · ${brand.tagline}`;
import Icon from '../components/Icon.astro';
import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can turn its rays
import { brand } from '../lib/brand';
---
<!doctype html>
<html lang="ur-PK" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="alternate" type="application/rss+xml" title={`${brand.name}: نئی تبدیلیاں`} href="/rss.xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#fbf6ea" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#14120f" media="(prefers-color-scheme: dark)" />
<!-- link previews (WhatsApp, Facebook, X, Slack): the logo and headline -->
<meta property="og:site_name" content={brand.name} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="ur_PK" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={new URL(Astro.url.pathname, Astro.url.origin).href} />
<meta property="og:image" content={new URL('/og-image.png', Astro.url.origin).href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={`${brand.name}: ${brand.tagline}`} />
<meta name="twitter:card" content="summary_large_image" />
{author && <meta name="author" content={author} />}
{jsonld && <script type="application/ld+json" set:html={JSON.stringify(jsonld).replace(/</g, '\\u003c')} />}
<script is:inline>
// font, palette and light/dark applied before paint (Naskh, sepia and the system's light/dark by default)
try {
// ?font=nastaliq|naskh in the URL sets the choice (shareable links)
const f = new URLSearchParams(location.search).get('font');
if (f === 'nastaliq' || f === 'naskh') localStorage.setItem('divan-font', f);
if (localStorage.getItem('divan-font') === 'nastaliq') document.documentElement.classList.add('nastaliq');
const t = localStorage.getItem('divan-theme');
if (t) document.documentElement.dataset.theme = t;
const pal = localStorage.getItem('divan-palette'); // sepia when unset
if (pal) document.documentElement.dataset.palette = pal;
if (document.cookie.includes('divan-digits=latn')) document.documentElement.dataset.digits = 'latn'; // Western numerals
if (localStorage.getItem('divan-reading')) document.documentElement.dataset.reading = ''; // reading mode, kept across pages
} catch (e) {}
</script>
</head>
<body data-user={Astro.locals.user ? '1' : undefined}>
<header class="site-header">
<div class="wrap">
<a class="wordmark" href="/" title="سرورق"><Fragment set:html={logo.replace('<svg ', '<svg class="logo" ')} /></a>
<form class="search-form" action="/search" method="get" role="search">
<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 rom" aria-label="رومن حروف سے اردو" aria-pressed="false" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
<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>
</button>
<button type="submit" class="in-box go" aria-label="تلاش کریں" title="تلاش کریں">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>
</button>
</form>
<button type="button" class="icon-btn reading-exit" data-reading-toggle title="مطالعے کے انداز سے باہر" aria-label="مطالعے کے انداز سے باہر"><Icon name="close" /></button>
<nav class="toggles" aria-label="سائٹ">
{Astro.locals.user?.role === 'admin' && <a class="icon-btn" href="/admin" title="ایڈمن" aria-label="ایڈمن"><Icon name="admin" /></a>}
{['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="icon-btn" href="/mod" title="موڈریشن" aria-label="موڈریشن"><Icon name="edit" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/library" title="محفوظ شدہ" aria-label="محفوظ شدہ"><Icon name="bookmark" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/notes" title="میرے نوٹس" aria-label="میرے نوٹس"><Icon name="note" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/words" title="ذخیرۂ الفاظ" aria-label="ذخیرۂ الفاظ"><span class="glyph-icon"><Icon name="words" /><span aria-hidden="true">ع</span></span></a>}
{Astro.locals.user
? <a class="icon-btn" href="/account" title={`اکاؤنٹ: ${Astro.locals.user.full_name || Astro.locals.user.email}`} aria-label="اکاؤنٹ"><Icon name="user" /></a>
: <a class="icon-btn" href={`/signin?next=${encodeURIComponent(Astro.url.pathname)}`} title="لاگ ان" aria-label="لاگ ان"><Icon name="login" /></a>}
<details class="display-menu">
<summary class="icon-btn" title="خط اور رنگ" aria-label="خط اور رنگ"><Icon name="settings" /></summary>
<div class="display-panel">
<p>خط</p>
<div class="seg"><button type="button" data-font="naskh">نسخ</button><button type="button" data-font="nastaliq" class="f-nastaliq">نستعلیق</button></div>
<p>رنگ</p>
<div class="swatches">
{[['', 'سپیا', '#fbf6ea', '#bf3f2a'], ['teal', 'فیروزی', '#e2f0ee', '#0b4f6c'], ['lapis', 'نیلگوں', '#e6ebf8', '#1d3b8b'],
['green', 'سبز', '#e5eede', '#1f5d3a'], ['rose', 'گلابی', '#f5e6ea', '#6b2c5e']].map(([v, name, a, b]) => (
<button type="button" data-palette={v} title={name} aria-label={name} style={`--a: ${a}; --b: ${b}`}><span></span>{name}</button>
))}
</div>
<p>ہندسے</p>
<div class="seg"><button type="button" data-digits="">۱۲۳ مشرقی</button><button type="button" data-digits="latn">123 مغربی</button></div>
<p>روشنی</p>
<div class="seg">
<button type="button" data-theme-pick="light"><Icon name="sun" size={16} /> روشن</button>
<button type="button" data-theme-pick="dark"><Icon name="moon" size={16} /> تاریک</button>
<button type="button" data-theme-pick=""><Icon name="theme" size={16} /> خودکار</button>
</div>
</div>
</details>
{Astro.locals.user && (
<form method="post" action="/account" class="inline-form">
<input type="hidden" name="act" value="signout" />
<button class="icon-btn danger" title="لاگ آؤٹ" aria-label="لاگ آؤٹ"><Icon name="logout" /></button>
</form>
)}
</nav>
</div>
<div id="kb" class="wrap kb" hidden></div>
</header>
<main class="wrap"><slot /></main>
<div id="selmenu" class="selmenu" role="toolbar" aria-label="منتخب عبارت" hidden>
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
<button type="button" data-act="saveword" class="plus" title="ذخیرۂ الفاظ میں ڈالیں">ذخیرۂ الفاظ</button>
<button type="button" data-act="bookmark" title="یہ عبارت محفوظ کریں">محفوظ کریں</button>
<button type="button" data-act="note" title="اس عبارت پر نوٹ لکھیں">نوٹ</button>
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
<button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button>
<button type="button" data-act="google" title="گوگل میں تلاش">گوگل</button>
</div>
<aside id="dict" class="dict" hidden aria-live="polite">
<button type="button" class="dict-close" aria-label="بند کریں">✕</button>
<div class="dict-body"></div>
</aside>
<footer class="site-footer">
<div class="wrap">
<div class="footer-brand">
<a class="footer-logo" href="/" title="سرورق"><img src="/logo/turanj.svg" width="64" height="64" alt={brand.name} /></a>
<div>
<p class="footer-tagline">{brand.tagline}</p>
</div>
<p class="made-in">
<svg class="flag" viewBox="0 0 30 20" width="27" height="18" role="img" aria-label="پاکستان کا پرچم">
<rect width="30" height="20" fill="#fff" /><rect x="7.5" width="22.5" height="20" fill="#01411c" />
<circle cx="19.2" cy="10" r="6" fill="#fff" /><circle cx="20.6" cy="8.7" r="5.4" fill="#01411c" />
<polygon fill="#fff" points="23.65,5.99 23.12,7.39 24.25,8.39 22.75,8.32 22.15,9.69 21.75,8.25 20.26,8.11 21.51,7.28 21.19,5.82 22.36,6.75" />
</svg>
{brand.madeBy}
</p>
</div>
<nav class="footer-links" aria-label="سائٹ کے بارے میں">
<div><h2>{brand.name}</h2><ul>
<li><a href="/about"><Icon name="info" size={18} /><span>{brand.name} کے بارے میں<small>مقصد اور طریقۂ کار</small></span></a></li>
<li><a href="/help"><Icon name="help" size={18} /><span>استعمال کا طریقہ<small>سائٹ کی سہولتیں</small></span></a></li>
<li><a href="/tags"><Icon name="tag" size={18} /><span>ٹیگ<small>موضوعات سے کلام</small></span></a></li>
<li><a href="/rss.xml"><Icon name="rss" size={18} /><span>نئی تبدیلیاں<small>RSS: نئے ورژن اور ای بکس</small></span></a></li>
<li><a href="/privacy"><Icon name="lock" size={18} /><span>رازداری<small>ہم کیا محفوظ رکھتے ہیں</small></span></a></li>
<li><a href="/terms"><Icon name="scale" size={18} /><span>استعمال کی شرائط<small>لائسنس اور ذمہ داریاں</small></span></a></li>
</ul></div>
<div><h2>ماخذ</h2><ul>
<li><a href="https://ur.wikisource.org" rel="noopener"><Icon name="dict" size={18} /><span>ویکی ماخذ<small>متن · عوامی ملکیت</small></span><Icon name="external" size={13} class="ext" /></a></li>
<li><a href="https://ur.wikipedia.org" rel="noopener"><Icon name="globe" size={18} /><span>ویکیپیڈیا<small>شعرا کے تعارف</small></span><Icon name="external" size={13} class="ext" /></a></li>
</ul></div>
<div><h2>کھلا منبع</h2><ul>
<li><a href="https://github.com/anas-rashid/divan-data" rel="noopener"><Icon name="database" size={18} /><span>ڈیٹا<small>تمام متن، آزادانہ</small></span><Icon name="external" size={13} class="ext" /></a></li>
<li><a href="https://github.com/anas-rashid/divan" rel="noopener"><Icon name="code" size={18} /><span>کوڈ<small>سائٹ کا سورس</small></span><Icon name="external" size={13} class="ext" /></a></li>
</ul></div>
</nav>
<p class="footer-bar">
<span>متن: عوامی ملکیت · کوڈ: <span lang="en" dir="ltr">GNU GPL v3</span></span>
<a href={brand.contact.url} rel="noopener">{brand.contact.text}</a>
<a href={brand.basedOn.url} rel="noopener" lang="en" dir="ltr">{brand.basedOn.text}</a>
</p>
</div>
</footer>
<script is:inline>
const root = document.documentElement;
// display settings: reading font, colour palette, light/dark/automatic; each choice is remembered
const panel = document.querySelector('.display-menu');
const store = (k, v) => { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) {} };
const marks = () => {
const on = { font: root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh', palette: root.dataset.palette ?? '', theme: root.dataset.theme ?? '' };
panel.querySelectorAll('[data-font]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.font === on.font)));
panel.querySelectorAll('[data-palette]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.palette === on.palette)));
panel.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === on.theme)));
panel.querySelectorAll('[data-digits]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.digits === (root.dataset.digits ?? ''))));
};
panel.addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.font) { root.classList.toggle('nastaliq', b.dataset.font === 'nastaliq'); store('divan-font', b.dataset.font); }
if (b.dataset.palette != null) { if (b.dataset.palette) root.dataset.palette = b.dataset.palette; else delete root.dataset.palette; store('divan-palette', b.dataset.palette); }
if (b.dataset.digits != null) {
// numerals: a cookie, so the server sends pages with them; switching back to Eastern reloads the page
document.cookie = `divan-digits=${b.dataset.digits}; path=/; max-age=${b.dataset.digits ? 31536000 : 0}; samesite=lax`;
if (!b.dataset.digits) { delete root.dataset.digits; location.reload(); return; }
root.dataset.digits = 'latn'; westernDigits(document.body);
}
if (b.dataset.themePick != null) { if (b.dataset.themePick) root.dataset.theme = b.dataset.themePick; else delete root.dataset.theme; store('divan-theme', b.dataset.themePick); }
marks();
});
document.addEventListener('click', (e) => { if (panel.open && !panel.contains(e.target)) panel.open = false; }); // close when clicking elsewhere
// Western numerals also in what scripts add later (word sidebar, page numbers, suggestions)
function westernDigits(node) {
const walk = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
for (let t = walk.nextNode(); t; t = walk.nextNode()) if (/[۰-۹]/.test(t.data)) t.data = t.data.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x6f0));
}
new MutationObserver((list) => {
if (root.dataset.digits !== 'latn') return;
for (const m of list) for (const n of m.addedNodes) n.nodeType === 3 ? westernDigits(n.parentNode) : n.nodeType === 1 && westernDigits(n);
}).observe(document.body, { childList: true, subtree: true });
marks();
// Urdu on-screen keyboard, Gboard/Windows Urdu layout
const BASE = [[...'طصھدٹپتبجح'], [...'مورنلہاکی'], [...'قفےسشغع']];
const SHIFT = [['ظ','ض','ذ','ڈ','ث','ّ','ۃ','ـ','چ','خ'], ['ژ','ز','ڑ','ں','ۂ','ء','آ','گ',''], ['','','ۓ','','ؤ','ئ','']];
// Roman -> Urdu (phonetic) typing, its own toggle
const PHONETIC = {
q:'ق', w:'و', e:'ع', r:'ر', t:'ت', y:'ے', u:'ء', i:'ی', o:'ہ', p:'پ',
a:'ا', s:'س', d:'د', f:'ف', g:'گ', h:'ح', j:'ج', k:'ک', l:'ل',
z:'ز', x:'ش', c:'چ', v:'ط', b:'ب', n:'ن', m:'م',
A:'آ', T:'ٹ', D:'ڈ', R:'ڑ', N:'ں', Z:'ذ', S:'ص', G:'غ', H:'ھ', K:'خ', C:'ث', V:'ظ', X:'ژ', J:'ض', I:'ئ', O:'ۂ', Y:'ؤ',
',':'،', '.':'۔', '?':'؟', ';':'؛',
};
const q = document.getElementById('q'), kb = document.getElementById('kb'), kbBtn = document.getElementById('kb-toggle'), romBtn = document.getElementById('rom-toggle');
const kbHome = [kb.parentElement, kb.nextSibling]; // the keyboard's place in the header
let shift = false;
const key = (ch) => ch ? `<button type="button" data-ch="${ch}">${ch}</button>` : '';
const render = () => {
const L = shift ? SHIFT : BASE;
kb.innerHTML = `<div>${L[0].map((c) => key(c)).join('')}</div><div>${L[1].map((c) => key(c)).join('')}</div>` +
`<div><button type="button" data-act="shift" class="fn${shift ? ' on' : ''}" aria-label="شفٹ">⇧</button>${L[2].map((c) => key(c)).join('')}` +
'<button type="button" data-act="bs" class="fn" aria-label="مٹائیں">⌫</button></div>' +
`<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}` +
'<button type="button" data-act="close" class="fn" aria-label="کی بورڈ بند کریں" title="بند کریں">✕</button></div>';
};
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; });
// a page may redraw its boxes (e.g. the home sections editor): find the box again by its id
const live = () => (target.isConnected ? target : (target = (target.id && document.getElementById(target.id)) || q));
const typed = () => target.dispatchEvent(new Event('input', { bubbles: true })); // so suggestions update
const insert = (text) => {
live();
// rich editors (the content editor) take typed text through the browser's own editing command
if (target.isContentEditable) { target.focus(); document.execCommand('insertText', false, text); return; }
const s = target.selectionStart ?? target.value.length, e = target.selectionEnd ?? target.value.length;
target.setRangeText(text, s, e, 'end'); target.focus(); typed();
};
kb.onmousedown = (ev) => ev.preventDefault(); // keep focus/caret in the search box
kb.onclick = (ev) => {
const b = ev.target.closest('button'); if (!b) return;
if (b.dataset.act === 'shift') { shift = !shift; render(); return; }
if (b.dataset.act === 'close') { closeKb(); return; }
if (b.dataset.act === 'bs') {
live();
if (target.isContentEditable) { target.focus(); document.execCommand('delete'); return; }
const s = target.selectionStart, e = target.selectionEnd;
target.setRangeText('', s === e ? Math.max(0, s - 1) : s, e, 'end'); target.focus(); typed();
return;
}
insert(b.dataset.ch);
if (shift) { shift = false; render(); } // one-shot shift, as on Gboard
};
kbBtn.onclick = () => {
kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); target.focus();
if (kb.hidden) home(); else place();
};
// back to its place in the header (not floating)
const home = () => { kb.classList.remove('kb-float'); document.body.style.paddingBottom = ''; if (kb.parentElement !== kbHome[0]) kbHome[0].insertBefore(kb, kbHome[1]); };
const closeKb = () => {
kb.hidden = true; kbBtn.setAttribute('aria-pressed', 'false'); home();
document.querySelectorAll('[data-urdu-kb][aria-pressed=true]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
};
// For the content editor the keyboard is docked at the bottom of the window (like a phone's) and the page follows
// the writing cursor: the line being written is kept in view just above the keyboard, never under it. The page
// gets room at the bottom while it is open, so the last lines can come up above it.
const caret = () => {
const sel = getSelection();
if (sel?.rangeCount && target.contains(sel.anchorNode)) {
const r = sel.getRangeAt(0), rect = r.getClientRects()[0] ?? r.getBoundingClientRect();
if (rect && (rect.width || rect.height)) return rect;
const el = sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement;
if (el) return el.getBoundingClientRect(); // an empty line has no caret box of its own
}
return null;
};
let placing = 0;
function place() {
if (kb.hidden || !kb.classList.contains('kb-float')) return;
cancelAnimationFrame(placing);
placing = requestAnimationFrame(() => {
document.body.style.paddingBottom = `${kb.offsetHeight + 24}px`;
const c = caret(); if (!c) return;
const room = kb.getBoundingClientRect().top - 16, top = 90; // keep the line between the header area and the keyboard
if (c.bottom > room) scrollBy({ top: c.bottom - room + 40, behavior: 'smooth' });
else if (c.top < top) scrollBy({ top: c.top - top - 40, behavior: 'smooth' });
});
}
document.addEventListener('selectionchange', place);
addEventListener('resize', place);
romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); target.focus(); };
// 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)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
// in an open dialog (the note box) the header can't be reached: the keyboard moves under the box, and back when it closes
const box = b.dataset.urduKb && target.closest('dialog');
if (box) { kb.classList.remove('kb-float'); target.closest('.in-box-field')?.after(kb); }
else if (b.dataset.urduKb && target.isContentEditable) { kb.classList.add('kb-float'); document.body.append(kb); } // the editor: floating
else if (b.dataset.urduKb) home();
(b.dataset.urduKb ? kbBtn : romBtn).click();
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
});
document.addEventListener('close', (ev) => { // a dialog closing takes nothing of the header's with it
if (ev.target.contains?.(kb)) closeKb();
}, true);
document.addEventListener('keydown', (ev) => {
if (!ev.target.matches?.('[data-urdu]')) return;
if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
const ch = PHONETIC[ev.key];
if (ch) { ev.preventDefault(); insert(ch); }
});
// word sidebar: select one word in the text -> Wiktionary meanings and pronunciation (data shown as text only)
const dict = document.getElementById('dict'), dictBody = dict.querySelector('.dict-body'), main = document.querySelector('main');
const LANG = { ur: 'اردو', fa: 'فارسی', ar: 'عربی' };
const POS = { Noun: 'اسم', 'Proper noun': 'اسم معرفہ', Verb: 'فعل', Adjective: 'صفت', Adverb: 'متعلق فعل', Pronoun: 'ضمیر',
Postposition: 'حرف جار', Preposition: 'حرف جار', Conjunction: 'حرف عطف', Interjection: 'حرف ندا', Particle: 'حرف',
Translation: 'ترجمہ (اردو ویکی لغت)', Phrase: 'فقرہ', Proverb: 'کہاوت', Name: 'اسم معرفہ' };
const el = (tag, cls, text, attrs) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
for (const k in attrs ?? {}) e.setAttribute(k, attrs[k]);
return e;
};
let current = '';
// reader's library: bookmarks (poets, books/chapters, poems, couplets, phrases) and the word book (+);
// signed-out readers go to sign-in
let source = {}; // the couplet the selected word came from
async function save(item) {
if (!document.body.dataset.user) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return null; }
const r = await fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(item) });
if (r.status === 401) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return null; }
return r.ok ? r.json() : null;
}
document.addEventListener('click', async (e) => {
const b = e.target.closest('[data-save]'); if (!b) return;
if (getSelection()?.toString().trim()) return; // selecting text, not pressing
if (b.dataset.confirm && !(await ask(b.dataset.confirm, { danger: true, ok: 'ہٹائیں' }))) return;
const item = JSON.parse(b.dataset.save), d = await save(item);
if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page
else if (d) {
b.setAttribute('aria-pressed', String(d.saved));
if (b.classList.contains('save')) b.textContent = d.saved ? 'محفوظ شدہ' : 'محفوظ کریں';
}
});
const wordSave = (w) => { // sidebar button, with the word's saved state
const b = el('button', 'wordbook', null, { type: 'button' });
const set = (on) => { b.setAttribute('aria-pressed', String(on)); b.textContent = on ? 'ذخیرۂ الفاظ میں ہے' : 'ذخیرۂ الفاظ میں ڈالیں'; };
set(false);
b.onclick = async () => { const d = await save({ kind: 'word', word: w, ...source }); if (d) set(d.saved); };
if (document.body.dataset.user) fetch('/api/library?word=' + encodeURIComponent(w)).then((r) => r.ok && r.json()).then((d) => d && set(d.word));
return b;
};
async function showWord(w) {
if (w === current && !dict.hidden) return;
current = w; dict.hidden = false; document.body.classList.add('dict-open');
dictBody.replaceChildren(el('h2', null, w), el('p', 'muted', 'تلاش جاری ہے…'));
let d = null;
// v: bump when the response format changes (responses are browser-cached for a day)
try { const r = await fetch('/api/word?v=6&w=' + encodeURIComponent(w)); if (r.ok) d = await r.json(); } catch (e) {}
if (w !== current) return; // a newer selection won
const buttons = (items) => { // clickable words
const box = el('div', 'similar');
for (const s of items) {
const b = el('button', null, s.title, { type: 'button', title: s.langs.map((c) => LANG[c]).join('، ') });
b.onclick = () => showWord(s.title);
box.append(b);
}
return box;
};
const words = (title, items, into) => { if (items.length) into.push(el('h3', null, title), buttons(items)); };
const list = (items, cls, attrs) => { const ol = el('ol', cls, null, attrs); items.forEach((x) => ol.append(el('li', null, x))); return ol; };
const out = [el('h2', null, w), wordSave(w)];
if (!d || !d.found) {
out.push(el('p', 'muted', 'ویکی لغت میں یہ لفظ نہیں ملا۔'));
words('ملتے جلتے الفاظ', [...(d?.similar ?? []), ...(d?.variants ?? [])], out);
dictBody.replaceChildren(...out);
return;
}
// one section per language, in order اردو، فارسی، عربی; each says where its meanings come from.
// A Persian or Arabic word is explained first by its Urdu translations, then English, then its own language.
for (const l of d.langs) {
const sec = el('section', 'dict-lang');
sec.append(el('h3', null, LANG[l.code]));
// collapsible extra (more meanings, examples, long etymologies, word lists)
const more = (label, ...children) => {
const d = el('details', 'more');
d.append(el('summary', null, label), ...children);
return d;
};
const tagged = (tags) => (tags?.length ? ` (${tags.join(', ')})` : '');
// a pronunciation line: vowelled form, transliteration, every IPA (with dialect labels), every recording
const pron = (r) => {
const pr = el('p', 'pron');
if (r.form) pr.append(el('span', 'form', r.form), ' ');
if (r.tr) pr.append(el('bdi', 'en', r.tr));
r.ipa.forEach((i) => pr.append(' ', el('bdi', 'ipa', i.ipa + tagged(i.tags), { title: i.tags.join(', ') })));
r.audio.forEach((a) => {
if (!a.url.startsWith('https://upload.wikimedia.org/')) return;
const b = el('button', 'play', '🔊', { type: 'button', 'aria-label': 'تلفظ سنیں', title: a.tags.join(', ') });
b.onclick = () => new Audio(a.url).play();
pr.append(' ', b);
});
return pr;
};
// one meaning: gloss with labels; usage examples (text, romanisation, translation) folded
const sense = (x) => {
const li = el('li', null, x.gloss + tagged(x.tags));
if (x.examples.length) {
const ex = x.examples.map((e) => {
const p = el('p', 'example');
p.append(el('span', 'ex-text', e.text, { dir: 'auto' }));
if (e.roman) p.append(el('br'), el('i', null, e.roman));
if (e.english) p.append(el('br'), e.english);
return p;
});
li.append(more(`مثالیں (${x.examples.length})`, ...ex));
}
return li;
};
const senses = (defs) => {
const ol = el('ol', 'en', null, { dir: 'ltr', lang: 'en' });
defs.slice(0, 4).forEach((x) => ol.append(sense(x)));
if (defs.length <= 4) return [ol];
const rest = el('ol', 'en', null, { dir: 'ltr', lang: 'en', start: '5' });
defs.slice(4).forEach((x) => rest.append(sense(x)));
return [ol, more(`مزید معانی (${defs.length - 4})`, rest)];
};
const urdu = () => {
if (!l.urdu?.length) return;
sec.append(el('p', 'pos', 'اردو میں · انگریزی ویکی لغت کے واسطے سے'));
for (const e of l.urdu) {
const p = el('p', 'via');
p.append(e.urdu.join('، '), ' ', el('bdi', 'en muted', `(${e.gloss})`));
sec.append(p);
}
};
// readings: the same spelling with different short vowels (ملک: مُلک، مَلِک، مِلک), each with its meanings
const english = () => {
if (l.readings.length > 1) sec.append(el('p', 'pos', `${l.readings.length} قراءتیں (حرکات کے فرق سے)`));
for (const r of l.readings) {
const box = el('div', l.readings.length > 1 ? 'reading' : null);
box.append(pron(r));
for (const s of r.senses) box.append(el('p', 'pos', `انگریزی میں · ${POS[s.pos.charAt(0).toUpperCase() + s.pos.slice(1)] ?? s.pos}`), ...senses(s.defs));
sec.append(box);
}
if (l.translation.length) sec.append(el('p', 'pos', POS.Translation), list(l.translation, 'en', { dir: 'ltr', lang: 'en' }));
};
if (l.code !== 'ur') { urdu(); english(); }
if (l.meanings.length) {
const label = el('p', 'pos');
label.append(`معانی: ${LANG[l.code]} ویکی لغت` + (l.origin ? ` · اصل: ${l.origin}` : '') + ' · ');
if (l.source) label.append(el('a', null, 'ماخذ', { href: l.source, target: '_blank', rel: 'noopener' }));
const own = list(l.meanings.slice(0, 6), null, { lang: l.code });
sec.append(label, own);
if (l.meanings.length > 6) sec.append(more(`مزید معانی (${l.meanings.length - 6})`, list(l.meanings.slice(6), null, { lang: l.code, start: '7' })));
}
if (l.code === 'ur') { english(); urdu(); }
for (const [label, items] of [['مترادفات', l.synonyms], ['مشتقات', l.derived], ['متعلقہ الفاظ', l.related]]) {
if (!items.length) continue;
const box = buttons(items.map((t) => ({ title: t, langs: [l.code] })));
sec.append(...(items.length > 8 ? [more(`${label} (${items.length})`, box)] : [el('p', 'pos', label), box]));
}
for (const ety of l.etymologies) {
const text = el('p', 'en ety', ety, { dir: 'ltr', lang: 'en' });
sec.append(...(ety.length > 220 ? [more('اشتقاق', text)] : [el('p', 'pos', 'اشتقاق'), text]));
}
if (l.en) {
const p = el('p', 'pos');
p.append(el('a', null, 'انگریزی ویکی لغت', { href: l.en, target: '_blank', rel: 'noopener' }));
sec.append(p);
}
out.push(sec);
}
words('ملتے جلتے الفاظ · دیگر حروفِ علت', d.variants ?? [], out); // same consonants, other long vowels
const src = el('p', 'src muted');
src.append('ویکی لغت (Wiktionary) · ');
src.append('CC BY-SA');
dictBody.replaceChildren(...out, src);
}
const TRIM = /["'«»()\[\]،۔؟؛!:.,\-–—_]+|^\s+|\s+$/g; // punctuation and dashes anywhere, outer spaces
// selection in the reading text: Ganjoor-style menu by the selection; a single word also opens the sidebar
const menu = document.getElementById('selmenu');
const oneWord = (t) => { const w = t.replace(TRIM, '').replace(/\u0614/g, ''); return /^[\p{Script=Arabic}\p{M}\u200C]{1,40}$/u.test(w) ? w : null; };
let selText = '', selTimer;
const hideMenu = () => { menu.hidden = true; };
document.addEventListener('selectionchange', () => {
clearTimeout(selTimer);
selTimer = setTimeout(() => {
const sel = getSelection();
// only the reading text (poems and prose, intros, e-book text, search snippets, library couplets), never the
// site's menus, buttons, tabs or forms
const at = sel && !sel.isCollapsed && sel.anchorNode?.parentElement;
const inText = at && main.contains(at) && at.closest('article, .intro, .ebook-text, .snippet, .lib-couplet')
&& !at.closest('form, input, textarea, [contenteditable], button, nav, .admin-nav');
selText = inText ? sel.toString().replace(/\u0614/g, '').trim() : '';
if (!selText) return hideMenu();
const w = oneWord(selText);
const block = sel.anchorNode.parentElement?.closest('[data-c]');
source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.c) } : {};
menu.querySelector('[data-act=saveword]').hidden = !w;
menu.querySelector('[data-act=bookmark]').hidden = menu.querySelector('[data-act=note]').hidden = !(source.poemId && Number.isInteger(source.couplet));
menu.querySelector('[data-act=meaning]').hidden = !w;
menu.hidden = false;
// below the selection, centred, kept inside the viewport (absolute: moves with the page)
const r = sel.getRangeAt(0).getBoundingClientRect(), m = menu.getBoundingClientRect();
menu.style.top = `${r.bottom + scrollY + 8}px`;
menu.style.left = `${Math.max(8, Math.min(innerWidth - m.width - 8, r.left + r.width / 2 - m.width / 2)) + scrollX}px`;
if (w) showWord(w);
}, 350);
});
menu.onmousedown = (e) => e.preventDefault(); // keep the selection
const cite = () => { // "text\n— poet، title\nlink" for copying
const art = document.querySelector('[data-cite]');
return `${selText}\n— ${art?.dataset.cite ?? document.title}\n${location.origin}${location.pathname}`;
};
const flash = (b, msg) => { const t = b.textContent; b.textContent = msg; setTimeout(() => (b.textContent = t), 1200); };
menu.onclick = async (e) => {
const b = e.target.closest('button'); if (!b) return;
const q = /\s/.test(selText) ? `"${selText}"` : selText, enc = encodeURIComponent(selText);
switch (b.dataset.act) {
case 'meaning': showWord(oneWord(selText)); break;
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
.then((d) => d && (d.saved ? location.reload() : flash(b, 'پہلے سے محفوظ تھی، ہٹا دی'))); break;
case 'note': hideMenu(); document.dispatchEvent(new CustomEvent('divan:note', { detail: { couplet: source.couplet, phrase: selText } })); break; // the poem page opens its note box
case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'ذخیرۂ الفاظ میں ڈال دیا' : 'ذخیرۂ الفاظ سے نکال دیا')); break;
case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
case 'copy':
try { await navigator.clipboard.writeText(cite()); flash(b, 'کاپی ہو گیا'); } catch (err) { flash(b, 'ناکام'); }
break;
case 'share': { // this page with the selection highlighted (?hl=), as search results link
const url = `${location.origin}${location.pathname}?hl=${encodeURIComponent(q)}`;
if (navigator.share) navigator.share({ title: document.title, text: selText, url }).catch(() => {});
else try { await navigator.clipboard.writeText(url); flash(b, 'لنک کاپی ہو گیا'); } catch (err) { flash(b, 'ناکام'); }
}
}
};
const closeDict = () => { dict.hidden = true; current = ''; document.body.classList.remove('dict-open'); };
dict.querySelector('.dict-close').onclick = closeDict;
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!menu.hidden) hideMenu(); else if (!dict.hidden) closeDict();
});
</script>
<!-- shared UI: the confirm dialog (instead of the browser's English confirm), toasts, busy buttons -->
<dialog class="ask" id="ask" aria-labelledby="ask-msg">
<form method="dialog">
<p id="ask-msg"></p>
<div class="ask-actions"><button class="btn primary" value="ok">ہاں</button><button class="btn" value="">نہیں</button></div>
</form>
</dialog>
<div class="toasts" aria-live="polite"></div>
<script is:inline>
// the sticky header: its height for anchors and toolbars (--header-h), and a shadow once the page is scrolled
(() => {
const root = document.documentElement, header = document.querySelector('.site-header');
new ResizeObserver(() => root.style.setProperty('--header-h', header.offsetHeight + 'px')).observe(header);
// phones: the header steps aside while scrolling down and returns on the way up (not while it is in use)
const phone = matchMedia('(max-width: 640px)');
let last = scrollY;
const mark = () => {
scrollY > 8 ? (root.dataset.scrolled = '') : delete root.dataset.scrolled;
const busy = header.contains(document.activeElement) || header.querySelector('details[open]') || !document.getElementById('kb').hidden;
if (!phone.matches || busy || scrollY < header.offsetHeight) delete root.dataset.headerHidden;
else if (scrollY > last + 6) root.dataset.headerHidden = '';
else if (scrollY < last - 6) delete root.dataset.headerHidden;
if (Math.abs(scrollY - last) > 6) last = scrollY;
};
addEventListener('scroll', mark, { passive: true }); mark();
})();
</script>
<script is:inline>
// reading mode: only the text, the breadcrumbs and the logo; remembered across pages until switched off
document.addEventListener('click', (e) => {
if (!e.target.closest('[data-reading-toggle]')) return;
const root = document.documentElement, on = !('reading' in root.dataset);
const flip = () => { on ? (root.dataset.reading = '') : delete root.dataset.reading; };
document.startViewTransition ? document.startViewTransition(flip) : flip();
try { on ? localStorage.setItem('divan-reading', '1') : localStorage.removeItem('divan-reading'); } catch (err) {}
});
</script>
<script is:inline>
(() => {
const dlg = document.getElementById('ask'), ok = dlg.querySelector('[value=ok]');
// ask(message, {danger, ok}) -> Promise<boolean>; the destructive answer is red and named for what it does
window.ask = (message, o = {}) => new Promise((done) => {
dlg.querySelector('#ask-msg').textContent = message;
ok.textContent = o.ok || 'ہاں'; ok.classList.toggle('danger', !!o.danger); ok.classList.toggle('primary', !o.danger);
dlg.returnValue = ''; dlg.showModal(); dlg.querySelector('[value=""]').focus();
dlg.addEventListener('close', () => done(dlg.returnValue === 'ok'), { once: true });
});
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); }); // a click outside the box is "no"
// toast(message, kind): slides in, leaves by itself
const box = document.querySelector('.toasts');
window.toast = (message, kind = 'ok') => {
const t = document.createElement('p'); t.className = 'toast ' + kind; t.textContent = message; box.append(t);
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 350); }, 4200); // removed on a timer: with reduced motion there is no animation to end
};
// a page's "done" line becomes a toast
document.querySelectorAll('.form-done').forEach((p) => { if (p.textContent.trim()) { toast(p.textContent.trim()); p.hidden = true; } });
// forms: data-confirm asks first; a submitting button shows it is busy
document.addEventListener('submit', async (e) => {
const f = e.target; if (f.method === 'dialog') return;
const btn = e.submitter;
if (f.dataset.confirm && !f.dataset.asked) {
e.preventDefault();
if (!(await ask(f.dataset.confirm, { danger: 'danger' in f.dataset, ok: f.dataset.ok }))) return;
f.dataset.asked = '1'; f.requestSubmit(btn); delete f.dataset.asked; return;
}
if (btn && !e.defaultPrevented && !f.hasAttribute('data-js')) btn.setAttribute('aria-busy', 'true');
});
addEventListener('pageshow', () => document.querySelectorAll('[aria-busy=true]').forEach((b) => b.removeAttribute('aria-busy'))); // back button
})();
</script>
</body>
</html>