divan/web/src/pages/[...path].astro
Anas Rashid 2efde6ee22 Notes apart from bookmarks, and a notes page
- A note never bookmarks: notes and bookmarks share a row, bookmarked false is a note only (db: library.bookmarked). Un-bookmarking keeps a note; clearing the note of a row that is not bookmarked removes it; ✕ on the saved page keeps a note
- A phrase with a note but no bookmark is underlined only, with the note glyph
- /notes: every note with where it is and its couplet (the noted phrase marked), editable in place with the Urdu keyboard, deleted after asking; from a note icon in the header and a نوٹس tab
- API tests for all of it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:32:16 +02:00

308 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.

---
// Poet (/p238), category (/p238/shaeri/c641) and poem (/p238/shaeri/c641/sh6556) pages
import Base from '../layouts/Base.astro';
import Seg from '../components/Seg.astro';
import Tags from '../components/Tags.astro';
import { api } from '../lib/api';
import { asUser, COOKIE } from '../lib/auth';
import { phraseParts, phraseRe } from '../lib/phrases';
import { ud, years, highlighter, parts } from '../lib/urdu';
const url = '/' + (Astro.params.path ?? '');
const page = await api(`/api/page?url=${encodeURIComponent(url)}`);
if (!page) return new Response(null, { status: 404, statusText: 'Not found' });
// group verses into couplets / single lines / paragraphs for rendering
type Verse = { position: string; couplet: number; text: string };
const blocks: { kind: 'couplet' | 'single' | 'para'; lines: string[]; c: number; mark?: string }[] = [];
if (page.type === 'poem') {
const { matla, maqta } = page.poem;
for (const v of page.verses as Verse[]) {
if (v.position === 'Right') blocks.push({ kind: 'couplet', lines: [v.text], c: v.couplet,
mark: v.couplet === matla ? 'مطلع' : v.couplet === maqta ? 'مقطع' : undefined });
else if (v.position === 'Left' && blocks.at(-1)?.kind === 'couplet' && blocks.at(-1)!.lines.length === 1) blocks.at(-1)!.lines.push(v.text);
else if (v.position === 'Paragraph') blocks.push({ kind: 'para', lines: [v.text], c: v.couplet });
else blocks.push({ kind: 'single', lines: [v.text], c: v.couplet });
}
}
// pen name (marked ؔ in the text) set apart; split() with a capture group puts matches at odd indexes
// ?hl= (from search results) marks the searched words on the page; segments: [text, 'mark' | 'pen' | '']
const hlq = Astro.url.searchParams.get('hl') ?? '';
const re = highlighter(hlq);
// bookmarked phrases first ('bmk', carrying what to un-bookmark), then search hits, then the pen name
type Mark = { part: string; save: string };
const show = (line: string, marks: Mark[] = []): [string, string, string?][] => {
const plain = (text: string) => parts(text, re).flatMap((s, i): [string, string][] =>
i % 2 ? [[s, 'mark']] : s.split(/(\S*\u0614\S*)/).map((t, j) => [t, j % 2 ? 'pen' : '']));
if (!marks.length) return plain(line);
const bm = new RegExp(`(${marks.map((m) => phraseRe(m.part).source).join('|')})`, 'u');
return line.split(bm).flatMap((s, i): [string, string, string?][] =>
i % 2 ? [[s, 'bmk', marks.find((m) => phraseRe(m.part).test(s))?.save]] : plain(s));
};
// contents of a divan grouped by radif letter ("ردیف الف … ی"); ے is filed under ی as in printed divans
const LETTER = (l: string) => (l === 'ا' ? 'الف' : l === 'ے' ? 'ی' : l);
const groups: { letter: string | null; poems: any[] }[] = [];
for (const p of page.poems ?? []) {
const l = p.radif_letter ? LETTER(p.radif_letter) : null;
if (groups.at(-1)?.letter !== l || !groups.length) groups.push({ letter: l, poems: [] });
groups.at(-1)!.poems.push(p);
}
const crumbs = page.breadcrumbs ?? [];
// what the signed-in reader has bookmarked on this page (poet, book/chapter, poem, couplets, phrases)
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
const categoryId = page.type === 'category' ? page.category.id : undefined;
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data
: { poet: false, category: false, poem: false, couplets: [], phrases: [], notes: [] };
// the reader's notes on this work: on a couplet (phrase null) or on a bookmarked phrase in it
const notes: { couplet: number; phrase: string | null; note: string }[] = saved.notes ?? [];
const noteOf = (c: number) => notes.find((n) => n.couplet === c && !n.phrase)?.note;
// on contents pages: which works are bookmarked or hold bookmarks, and which books/chapters do
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
// the marked part of each line of a block, for its bookmarked phrases
const lineMarks = (c: number, lines: string[]) => {
const out: Mark[][] = lines.map(() => []);
const noteOnly = notes.filter((n) => n.couplet === c && n.phrase && !saved.phrases.some((p: any) => p.couplet === c && p.phrase === n.phrase))
.map((n) => ({ couplet: c, phrase: n.phrase, noteOnly: true }));
for (const p of [...saved.phrases.filter((x: any) => x.couplet === c), ...noteOnly]) {
const note = notes.find((n) => n.couplet === c && n.phrase === p.phrase)?.note;
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase, note, noteOnly: p.noteOnly });
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
}
return out;
};
const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'); // bookmark buttons
// moderators who may edit this work get edit and history links
const modCan = token && poemId && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false };
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41)
const catCan = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
? (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data : {};
const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : catCan.tags;
// tags (#52): the page's own, and each couplet's (tags count couplets from 1, verses from 0)
const pageTags = (page.tags ?? []).filter((t: any) => !t.couplet);
const coupletTags = (c: number) => (page.tags ?? []).filter((t: any) => t.couplet === c + 1);
// every poet has an ای بکس section beside their books and genres (/p17/ebooks)
const ebookCount = page.type === 'poet' ? ((await api<any[]>(`/api/ebooks?poet=${poetId}`)) ?? []).length : 0;
const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`;
const bookmark = (c: number) => JSON.stringify({ kind: 'couplet', poemId, couplet: c });
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
import Icon from '../components/Icon.astro';
---
<Base title={page.type === 'poem' ? `${page.poem.title} · ${page.poet.nickname}` : title} description={page.type === 'poet' ? page.poet.description?.slice(0, 160) : undefined}>
{page.type !== 'poet' && (
<nav class="crumbs">
{crumbs.map((c: any, i: number) => (
i < crumbs.length - 1 || page.type === 'poem' ? <span><a href={c.url}>{c.title}</a></span> : <span>{c.title}</span>
))}
</nav>
)}
{page.type === 'poet' && (
<>
<h1>{page.poet.name}</h1>
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
<p class="muted">{years(page.poet)}</p>
<Tags tags={pageTags} />
{page.poet.description && <div class="intro">{page.poet.description}</div>}
</>
)}
{page.type === 'category' && (
<>
<h1>{page.category.title}</h1>
<Tags tags={pageTags} />
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
</>
)}
{page.type !== 'poem' && (page.children.length > 0 || page.type === 'poet') && (
<div class="cards">
{page.children.map((c: any) => (
<a class={`card${marks.categories.includes(c.id) ? ' mark-bm' : ''}`} href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a>
))}
{page.type === 'poet' && <a class="card" href={`${page.poet.url}/ebooks`}>ای بکس{ebookCount > 0 && <small>{ud(ebookCount)}</small>}</a>}
</div>
)}
{page.type !== 'poem' && groups.map((g) => (
<>
{g.letter && <h2 class="radif-head">ردیف {g.letter}</h2>}
<ol class="toc">{g.poems.map((p: any) => <li><a class={glyph(p.id)} href={p.url}>{p.title}</a></li>)}</ol>
</>
))}
{page.type === 'poem' && (
<>
<h1><Seg s={show(page.poem.title)} /></h1>
{re && (
<p class="hl-note muted">
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
</p>
)}
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button>
{modCan.edit && <> <form method="post" action={`/mod/work/${poemId}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> ترمیم کریں</button></form>
<a class="icon-btn small" href={`/mod/work/${poemId}`} title="تاریخچہ" aria-label="تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
<Tags tags={pageTags} />
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
{blocks.map((b) => {
const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c);
// the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note)
const bm = <span class="c-tools">
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />
<button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
<button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" />
<button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" />
</span>;
const lm = lineMarks(b.c, b.lines);
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
})}
</article>
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے؛ کسی عبارت پر نوٹ بھی لکھ سکتے ہیں۔</p>
<dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
<form method="dialog">
<p id="note-for" class="note-for"></p>
<div class="in-box-field note-field">
<textarea id="note-text" name="note" rows="4" maxlength="1000" dir="auto" placeholder="آپ کا نوٹ، کسی بھی زبان میں…" aria-label="نوٹ" data-urdu></textarea>
<button type="button" class="in-box rom" data-urdu-rom="note-text" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
<button type="button" class="in-box" data-urdu-kb="note-text" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button>
</div>
<ul class="note-others" hidden></ul>
<div class="ask-actions">
<button class="btn primary" value="save">محفوظ کریں</button><button class="btn" value="">بند کریں</button>
<button class="btn danger note-clear" value="clear">نوٹ ہٹائیں</button><button class="btn note-unmark" value="unmark">ہائی لائٹ ہٹائیں</button>
</div>
</form>
</dialog>
<script type="application/json" id="notes-data" set:html={JSON.stringify({ poemId, notes, phrases: saved.phrases, signedIn: !!token })} />
<nav class="poem-nav">
{page.prev && <a class="icon-btn" href={page.prev} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
{page.next && <a class="icon-btn" href={page.next} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
</nav>
{page.divan && (
<p class="source divan-edit">
دیوان کا ورژن {ud(page.divan.version)} · ترمیم: {page.divan.by}{page.divan.reviewedBy && <> · جائزہ: {page.divan.reviewedBy}</>} · اشاعت: {page.divan.publishedBy}
· <bdi dir="ltr">{new Date(page.divan.at).toISOString().slice(0, 10)}</bdi>
{page.divan.commit_url && <> · <a href={page.divan.commit_url}>تبدیلی دیکھیں</a></>}
</p>
)}
{page.poem.source_url && (
<p class="source">ماخذ: <a href={page.poem.source_url}>ویکی ماخذ</a> · {page.poet && <a href={page.poet.url}>{page.poet.nickname}</a>}</p>
)}
</>
)}
{re && <script is:inline>document.querySelector('.poem mark')?.scrollIntoView({ block: 'center' });</script>}
</Base>
<script>
import nq700 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-700-normal.woff2?url';
import nq400 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-400-normal.woff2?url';
let cardFonts: Promise<unknown> | null = null;
// notes on couplets and phrases: the glyph beside a couplet's bookmark, a highlighted phrase, or نوٹ in the selection menu
// open one box; a note never bookmarks (bookmarks are the bookmark button's)
const data = document.getElementById('notes-data');
if (data) {
const { poemId, notes, phrases, signedIn } = JSON.parse(data.textContent!) as { poemId: number; notes: { couplet: number; phrase: string | null; note: string }[]; phrases: { couplet: number; phrase: string }[]; signedIn: boolean };
const box = document.getElementById('note-box') as HTMLDialogElement, text = box.querySelector('textarea')!;
const others = box.querySelector('.note-others') as HTMLUListElement;
let at: { couplet: number; phrase: string | null } = { couplet: 0, phrase: null };
const post = (body: object) => fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
.then((r) => (r.status === 401 ? (location.href = '/signin?next=' + encodeURIComponent(location.pathname), null) : r.ok ? r.json() : null));
const linesOf = (c: number) => [...document.querySelectorAll(`[data-c="${c}"] > p:not(.tag-chips), p[data-c="${c}"]`)].map((p) => p.textContent!.replace(/\s+/g, ' ').trim());
const lineOf = (c: number) => linesOf(c).join(' / ');
const open = (couplet: number, phrase: string | null) => {
if (!signedIn) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return; }
at = { couplet, phrase };
const mine = notes.find((n) => n.couplet === couplet && n.phrase === phrase);
box.querySelector('#note-for')!.textContent = phrase ? `«${phrase}»` : lineOf(couplet);
text.value = mine?.note ?? '';
(box.querySelector('.note-clear') as HTMLElement).hidden = !mine;
(box.querySelector('.note-unmark') as HTMLElement).hidden = !phrases.some((p) => p.couplet === couplet && p.phrase === phrase); // only a highlighted phrase
// a couplet's box also lists the notes on phrases in it; pressing one opens that note
const inIt = phrase ? [] : notes.filter((n) => n.couplet === couplet && n.phrase);
others.hidden = !inIt.length;
others.replaceChildren(...inIt.map((n) => { const li = document.createElement('li'); const b = document.createElement('button'); b.type = 'button';
b.textContent = `«${n.phrase}»: ${n.note}`; b.onclick = () => { box.close(); open(couplet, n.phrase); }; li.append(b); return li; }));
box.showModal(); text.focus();
};
const mark = (couplet: number) => { // the glyph follows the notes on this couplet
const nt = document.querySelector(`.nt[data-for="${couplet}"]`); if (!nt) return;
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'); // the bookmark is separate
};
box.addEventListener('close', async () => {
const act = box.returnValue, { couplet, phrase } = at;
if (!act) return;
if (act === 'unmark') { if (await post({ kind: 'phrase', poemId, couplet, phrase })) location.reload(); return; }
const note = act === 'clear' ? '' : text.value.trim();
const d = await post({ note, poemId, couplet, ...(phrase ? { phrase } : {}) });
if (!d) return (window as any).toast('نوٹ محفوظ نہیں ہو سکا', 'error');
if (phrase) { location.reload(); return; } // a phrase's highlight and note glyph are drawn by the page
const i = notes.findIndex((n) => n.couplet === couplet && !n.phrase);
if (i >= 0) notes.splice(i, 1);
if (d.note) notes.push({ couplet, phrase: null, note: d.note });
mark(couplet); (window as any).toast(d.note ? 'نوٹ محفوظ ہو گیا' : 'نوٹ ہٹا دیا');
});
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
const nt = t.closest('.nt') as HTMLElement | null; if (nt) return open(Number(nt.dataset.for), null);
const ph = t.closest('.bmk[data-phrase]') as HTMLElement | null;
if (ph && !getSelection()?.toString().trim()) { const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); }
});
document.addEventListener('keydown', (e) => { const ph = (e.target as HTMLElement).closest?.('.bmk[data-phrase]') as HTMLElement | null;
if (ph && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); } });
// share a couplet as an image: a square card (frame, logo, the lines, the poet), drawn here and handed to the share sheet
const share = async (c: number, download = false) => {
const [poet, title] = (document.querySelector('[data-cite]') as HTMLElement).dataset.cite!.split('، ');
const prose = !!document.querySelector(`p.para[data-c="${c}"]`);
const lines = linesOf(c), S = 1080, cv = document.createElement('canvas'); cv.width = cv.height = S;
const g = cv.getContext('2d')!; // the card is always on paper, whatever the page's palette
// the card's own Nastaliq faces, loaded here: the page may not have used bold Nastaliq yet, and a fallback font is
// wider (lines would wrap) and not Nastaliq
const NQ = '"Divan Card"';
if (!cardFonts) cardFonts = Promise.all([[nq700, '700'], [nq400, '400']].map(([url, weight]) => new FontFace('Divan Card', `url(${url})`, { weight }).load()
.then((f) => { document.fonts.add(f); })));
await cardFonts;
g.fillStyle = '#fbf6ea'; g.fillRect(0, 0, S, S);
g.strokeStyle = '#b88828'; g.lineWidth = 18; g.strokeRect(9, 9, S - 18, S - 18); g.lineWidth = 3; g.strokeRect(40, 40, S - 80, S - 80);
const logo = new Image(); logo.src = '/logo/turanj.svg'; await logo.decode().catch(() => {});
if (logo.complete) g.drawImage(logo, S / 2 - 70, 80, 140, 140);
g.direction = 'rtl'; g.textAlign = 'center'; g.textBaseline = 'middle';
// the lines: one per misra; prose wraps; the size shrinks until everything fits the card
let size = 72, rows: string[] = [];
const wrap = (t: string, w: number) => { const out: string[] = []; let row = '';
for (const word of t.split(' ')) { const next = row ? row + ' ' + word : word; if (g.measureText(next).width > w && row) { out.push(row); row = word; } else row = next; }
return row ? [...out, row] : out; };
for (; size >= (prose ? 30 : 22); size -= 2) { // verse shrinks until each misra fits on one line
g.font = `700 ${size}px ${NQ}`;
rows = lines.flatMap((l) => wrap(l, S - 200));
// verse keeps each misra on one line (prose wraps); Nastaliq is tall: lines 2.6 sizes apart leave room for the ✤ between misras
if ((prose || rows.length === lines.length) && rows.length * size * 2.6 <= 560) break;
}
const lh = size * 2.6, top = 250 + (560 - rows.length * lh) / 2;
g.fillStyle = '#17130f'; rows.forEach((r, i) => g.fillText(r, S / 2, top + lh * (i + .5)));
// the couplet line (as on the home page) between the verse and the poet: two tapering half-lines and a ✤
const y = S - 260, half = (from: number, to: number) => { g.beginPath(); g.moveTo(from, y); g.quadraticCurveTo((from + to) / 2, y - 6, to, y - 4); g.lineTo(to, y + 4); g.quadraticCurveTo((from + to) / 2, y + 6, from, y); g.fill(); };
g.fillStyle = '#1f4e79'; half(S / 2 - 30, S / 2 - 230); half(S / 2 + 30, S / 2 + 230);
g.fillStyle = '#bf3f2a'; g.font = '700 34px "Noto Naskh Arabic"'; g.fillText('✤', S / 2, y + 7);
g.fillStyle = '#bf3f2a'; g.font = `700 40px ${NQ}`; g.fillText(poet, S / 2, S - 190);
g.fillStyle = '#635543'; g.font = `400 30px ${NQ}`; g.fillText(`${title} · دیوان`, S / 2, S - 115);
const blob: Blob = await new Promise((done) => cv.toBlob((b) => done(b!), 'image/png'));
const file = new File([blob], `divan-${poemId}-${c + 1}.png`, { type: 'image/png' });
const url = `${location.origin}${location.pathname}#c${c}`;
if (download) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); (window as any).toast('تصویر ڈاؤن لوڈ ہو گئی'); return; }
// the share menu: with the picture where the browser can share files, otherwise the couplet and its link; never a download
const text = `${lines.join('\n')}\n— ${poet}`;
if (navigator.canShare?.({ files: [file] })) await navigator.share({ files: [file], text: url }).catch(() => {});
else if (navigator.share) await navigator.share({ title: document.title, text, url }).catch(() => {});
else { try { await navigator.clipboard.writeText(`${text}\n${url}`); (window as any).toast('یہ براؤزر شیئر نہیں کر سکتا: شعر اور لنک کاپی ہو گئے'); } catch { (window as any).toast('شیئر نہیں ہو سکا', 'error'); } }
};
document.addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('.sh, .dl') as HTMLElement | null; if (b) share(Number(b.dataset.for), b.classList.contains('dl')); });
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
}
</script>