divan/web/src/layouts/Base.astro

384 lines
24 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 }
const { title, description = 'اردو کی کلاسیکی شاعری اور نثر', q = '' } = Astro.props;
const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو کی کلاسیکی شاعری اور نثر';
---
<!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" />
<script is:inline>
// font + theme choice applied before paint (Naskh and system theme 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;
} catch (e) {}
</script>
</head>
<body data-user={Astro.locals.user ? '1' : undefined}>
<header class="site-header">
<div class="wrap">
<a class="wordmark" href="/">دیوان</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" 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>
</form>
<div class="toggles">
{Astro.locals.user?.role === 'admin' && <a class="account-link" href="/admin">ایڈمن</a>}
{Astro.locals.user && <a class="account-link" href="/library">نشانات</a>}
{Astro.locals.user && <a class="account-link" href="/words">ذخیرۂ الفاظ</a>}
{Astro.locals.user
? <a class="account-link" href="/account" title={Astro.locals.user.email}>{Astro.locals.user.full_name.split(' ')[0] || 'اکاؤنٹ'}</a>
: <a class="account-link" href={`/signin?next=${encodeURIComponent(Astro.url.pathname)}`}>لاگ ان</a>}
<button type="button" id="font-toggle" aria-label="خط بدلیں">نسخ</button>
<button type="button" id="theme-toggle" aria-label="روشن یا تاریک">◐</button>
</div>
</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="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">
متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
<a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a> · <a href="https://github.com/anas-rashid/divan">کوڈ</a>
</div>
</footer>
<script is:inline>
const root = document.documentElement;
const fontBtn = document.getElementById('font-toggle');
const label = () => (fontBtn.textContent = root.classList.contains('nastaliq') ? 'نستعلیق' : 'نسخ');
label();
fontBtn.onclick = () => {
root.classList.toggle('nastaliq');
try { localStorage.setItem('divan-font', root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh'); } catch (e) {}
label();
};
// 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');
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('؟')}</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; });
const typed = () => target.dispatchEvent(new Event('input', { bubbles: true })); // so suggestions update
const insert = (text) => {
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 === 'bs') {
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();
};
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);
(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;
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 && !confirm(b.dataset.confirm)) 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();
const inText = sel && !sel.isCollapsed && main.contains(sel.anchorNode) && !sel.anchorNode.parentElement?.closest('form, input, textarea');
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 = !(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 '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();
});
document.getElementById('theme-toggle').onclick = () => {
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
root.dataset.theme = dark ? 'light' : 'dark';
try { localStorage.setItem('divan-theme', root.dataset.theme); } catch (e) {}
};
</script>
</body>
</html>