From f122eb8f1037f12fa5dd20a931aad97df3750587 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 17:27:04 +0200 Subject: [PATCH] Couplet tools in one row; share and download apart; Urdu keyboard in the note box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bookmark, note, share and download sit in one row centred under each couplet on the ✤ line (bookmark and note on one side, share and download on the other), clear of the text, the same on phones - Share always opens the share menu (the picture where the browser can share files, else the couplet and its link; with no share menu, both are copied); a new download button saves the picture - The note box has the Urdu keyboard and Roman typing like the search box (the keyboard moves into the box while it is open); notes can be in any language (the box follows the typed direction) Co-Authored-By: Claude Opus 5.5 --- web/src/layouts/Base.astro | 9 +++++++++ web/src/pages/[...path].astro | 31 ++++++++++++++++++++----------- web/src/styles/global.css | 19 +++++++++++++++++++ 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 3cacf0fc..98bed530 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -196,6 +196,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can ',':'،', '.':'۔', '?':'؟', ';':'؛', }; 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 ? `` : ''; const render = () => { @@ -242,9 +243,17 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can 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) target.closest('.in-box-field')?.after(kb); else if (b.dataset.urduKb) kbHome[0].insertBefore(kb, kbHome[1]); (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)) return; + kb.hidden = true; kbBtn.setAttribute('aria-pressed', 'false'); kbHome[0].insertBefore(kb, kbHome[1]); + ev.target.querySelectorAll('[data-urdu-kb]').forEach((x) => x.setAttribute('aria-pressed', 'false')); + }, 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; diff --git a/web/src/pages/[...path].astro b/web/src/pages/[...path].astro index e4a03c32..ab39ffd0 100644 --- a/web/src/pages/[...path].astro +++ b/web/src/pages/[...path].astro @@ -148,12 +148,13 @@ import Icon from '../components/Icon.astro'; {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 = <> + const bm = + +
@@ -224,10 +229,10 @@ import Icon from '../components/Icon.astro'; box.showModal(); text.focus(); }; const mark = (couplet: number) => { // the glyph follows the notes on this couplet - const nt = document.querySelector(`.nt[data-c="${couplet}"]`); if (!nt) return; + 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 ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'); - const bm = document.querySelector(`[data-c="${couplet}"] > .bm, p[data-c="${couplet}"] > .bm`) as HTMLElement | null; + const bm = document.querySelector(`#c${couplet} .bm`) as HTMLElement | null; if (bm) { if (cn) { bm.setAttribute('aria-pressed', 'true'); bm.dataset.confirm = 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔'; } else delete bm.dataset.confirm; } }; box.addEventListener('close', async () => { @@ -245,14 +250,14 @@ import Icon from '../components/Icon.astro'; }); 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.c), null); + 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) => { + 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; @@ -290,10 +295,14 @@ import Icon from '../components/Icon.astro'; 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 { 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('تصویر ڈاؤن لوڈ ہو گئی'); } + 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 sh = (e.target as HTMLElement).closest('.sh') as HTMLElement | null; if (sh) share(Number(sh.dataset.c)); }); + 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); }); } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 2f2226a5..97dd3a44 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -729,6 +729,10 @@ p, li { text-wrap: pretty; } .note-box { width: min(480px, 92vw); } .note-for { font-size: var(--fs-md); color: var(--text-muted); max-height: 6.5em; overflow: auto; } .note-box textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; margin-bottom: 10px; } +.note-field .in-box { top: 20px; } /* at the first line of the box, not its middle */ +.note-field textarea { padding-left: 80px; } /* the buttons sit on the left whatever the note's language (the box follows what is typed) */ +.note-box .kb { padding: 0 0 10px; } +.note-box .kb button { min-width: 2em; font-size: 1rem; } .note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); } .note-others button { all: unset; display: block; cursor: pointer; font-size: var(--fs-sm); color: var(--text-muted); padding: 2px 0; } .note-others button:hover { color: var(--active); } @@ -776,3 +780,18 @@ p, li { text-wrap: pretty; } .timeline blockquote { margin: 4px 0; padding-inline-start: 10px; border-inline-start: 2px solid var(--tone); color: var(--text-muted); font-size: var(--fs-sm); } [data-tone] { --tone: var(--text-muted); } [data-tone='ok'] { --tone: var(--ok); } [data-tone='action'] { --tone: var(--action); } [data-tone='accent'] { --tone: var(--accent); } [data-tone='danger'] { --tone: var(--danger); } + +/* a couplet's bookmark, note and share sit in one row, centred under it on the ✤ line (bookmark and note on one side + of the ✤, share on the other): clear of the text however long the misras are, the same on phones */ +.c-tools { position: absolute; left: 50%; bottom: -4px; transform: translateX(-50%); z-index: 1; display: flex; align-items: center; gap: 4px; } +.c-tools > button { position: static; inset: auto; top: auto; } +.c-tools > .sh { margin-inline-start: 26px; } /* room for the ✤ between */ +.poem .para > .c-tools, .poem .single > .c-tools { bottom: -20px; } +.poem .para, .poem .single { margin-bottom: 26px; } +@media (max-width: 760px) { .c-tools { bottom: 2px; } .c-tools > .sh { margin-inline-start: 4px; } .couplet { padding-bottom: 28px; } } +/* download the couplet's picture: beside share */ +.dl { width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer; opacity: 0; transition: opacity .15s; } +.dl::before { content: ''; display: block; width: 16px; height: 16px; margin: auto; background-color: var(--text-muted); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E") center / contain no-repeat; } +.dl:hover::before { background-color: var(--active); } +.couplet:hover .dl, .poem .para:hover .dl, .poem .single:hover .dl, .dl:focus-visible { opacity: 1; } +@media (hover: none) { .dl { opacity: .55; } }