- 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>
308 lines
24 KiB
Plaintext
308 lines
24 KiB
Plaintext
---
|
||
// 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>
|