Merge pull request 'Phones: couplet tools on tap, e-book tabs on one line, a header that steps aside' (#94) from anas/mobile-polish into main

Reviewed-on: #94
This commit is contained in:
anas 2026-10-09 22:03:22 +00:00
commit 6fddb3b2e8
3 changed files with 112 additions and 3 deletions

View File

@ -566,7 +566,17 @@ import { brand } from '../lib/brand';
(() => {
const root = document.documentElement, header = document.querySelector('.site-header');
new ResizeObserver(() => root.style.setProperty('--header-h', header.offsetHeight + 'px')).observe(header);
const mark = () => (scrollY > 8 ? (root.dataset.scrolled = '') : delete root.dataset.scrolled);
// 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>

View File

@ -153,10 +153,13 @@ import Icon from '../components/Icon.astro';
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">
{/* wide screens: four icons on hover; phones: one ⋯ that opens a menu (the CSS shows one or the other) */}
<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="تصویر ڈاؤن لوڈ کریں" />
<button type="button" class="more" data-for={b.c} data-bm={saved.couplets.includes(b.c) ? '' : undefined} data-nt={any ? '' : undefined}
aria-haspopup="menu" aria-label="محفوظ، نوٹ، بھیجیں" title={cn ? `نوٹ: ${cn}` : 'محفوظ، نوٹ، بھیجیں'}><i class="g-bm" /><i class="g-nt" /><i class="g-dots" /></button>
</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>
@ -166,6 +169,12 @@ import Icon from '../components/Icon.astro';
</div>
</article>
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے؛ کسی عبارت پر نوٹ بھی لکھ سکتے ہیں۔</p>
<div id="c-menu" class="c-menu" popover role="menu" aria-label="شعر">
<button type="button" role="menuitem" data-act="bm"><i class="g-bm" /><span>محفوظ کریں</span></button>
<button type="button" role="menuitem" data-act="nt"><i class="g-nt" /><span>نوٹ</span></button>
<button type="button" role="menuitem" data-act="sh"><i class="g-sh" /><span>بھیجیں</span></button>
<button type="button" role="menuitem" data-act="dl"><i class="g-dl" /><span>تصویر ڈاؤن لوڈ</span></button>
</div>
<dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
<form method="dialog">
<p id="note-for" class="note-for"></p>
@ -233,9 +242,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-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
const nt = document.querySelector(`.nt[data-for="${couplet}"]`), more = document.querySelector(`.more[data-for="${couplet}"]`) as HTMLElement | null;
nt?.setAttribute('aria-pressed', String(any)); nt?.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں');
if (more) { any ? (more.dataset.nt = '') : delete more.dataset.nt; more.title = cn ? `نوٹ: ${cn}` : 'محفوظ، نوٹ، بھیجیں'; } // the bookmark is separate
};
box.addEventListener('close', async () => {
const act = box.returnValue, { couplet, phrase } = at;
@ -305,6 +315,45 @@ import Icon from '../components/Icon.astro';
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')); });
// phones: one ⋯ per couplet opens the shared menu beside it: bookmark, note, share, download
const menu = document.getElementById('c-menu') as HTMLElement & { showPopover(): void; hidePopover(): void };
let forC = 0, from: HTMLElement | null = null;
document.addEventListener('click', (e) => {
const more = (e.target as HTMLElement).closest('.more') as HTMLElement | null; if (!more) return;
forC = Number(more.dataset.for); from = more;
const on = 'bm' in more.dataset;
menu.querySelector('[data-act=bm] span')!.textContent = on ? 'محفوظ شدہ سے ہٹائیں' : 'محفوظ کریں';
menu.querySelector('[data-act=bm]')!.toggleAttribute('data-on', on);
menu.showPopover();
const r = more.getBoundingClientRect(), m = menu.getBoundingClientRect(); // below the button, kept on screen
menu.style.left = `${Math.max(8, Math.min(innerWidth - m.width - 8, r.left))}px`;
menu.style.top = `${r.bottom + 6 + m.height > innerHeight ? r.top - m.height - 6 : r.bottom + 6}px`;
(menu.querySelector('[role=menuitem]') as HTMLElement).focus();
});
menu.addEventListener('click', async (e) => {
const act = (e.target as HTMLElement).closest('[data-act]')?.getAttribute('data-act'); if (!act) return;
menu.hidePopover();
if (act === 'nt') return open(forC, null);
if (act === 'sh' || act === 'dl') return share(forC, act === 'dl');
const d = await post({ kind: 'couplet', poemId, couplet: forC }); if (!d || !from) return;
d.saved ? (from.dataset.bm = '') : delete from.dataset.bm;
from.parentElement?.querySelector('.bm')?.setAttribute('aria-pressed', String(d.saved)); // the wide-screen icon too
(window as any).toast(d.saved ? 'شعر محفوظ ہو گیا' : 'محفوظ شدہ سے ہٹا دیا');
});
menu.addEventListener('keydown', (e) => { // arrows move through the menu
const items = [...menu.querySelectorAll<HTMLElement>('[role=menuitem]')], i = items.indexOf(document.activeElement as HTMLElement);
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus(); }
if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
});
// touch screens: tapping a couplet shows its tools (and hides the last one's); links, buttons and selections are left alone
if (matchMedia('(hover: none)').matches) document.querySelector('.poem')?.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
if (t.closest('a, button, .bmk') || getSelection()?.toString().trim()) return;
const block = t.closest('.couplet, .para, .single'); if (!block) return;
const was = block.classList.contains('is-active');
document.querySelectorAll('.poem .is-active').forEach((x) => x.classList.remove('is-active'));
if (!was) block.classList.add('is-active');
});
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
}
</script>

View File

@ -862,3 +862,53 @@ p, li { text-wrap: pretty; }
.site-footer .wrap { grid-template-columns: 1fr; }
.footer-bar { justify-content: center; text-align: center; }
}
/* ── phones ── */
/* e-book format tabs: one line each; the English in brackets only where there is room */
.ebook-tabs a { white-space: nowrap; }
@media (max-width: 560px) {
.ebook-tabs { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
.ebook-tabs a span[lang] { display: none; }
}
/* touch screens have no hover: a couplet's tools show when it is tapped (the couplet is lightly marked); a set bookmark
or note stays visible; couplets without either do not keep room for the row */
@media (hover: none) {
.poem .couplet.is-active, .poem .para.is-active, .poem .single.is-active { background: color-mix(in srgb, var(--accent) 14%, transparent); border-radius: var(--r-sm); }
}
/* phones: the header slides away while scrolling down and comes back on the way up (a transform: nothing below jumps) */
@media (max-width: 640px) {
.site-header { transition: transform .25s ease, box-shadow .2s, background .2s; }
:root[data-header-hidden] .site-header { transform: translateY(-100%); box-shadow: none; }
}
/* ── a couplet's tools: one small button (⋯, with a filled bookmark and/or note glyph when set) that opens one shared
menu: bookmark, note, share, download. The button shows on hover (on touch screens when the couplet is tapped); a set
bookmark or note keeps it visible ── */
:root { --dots: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='5' cy='12' r='2.2'/%3E%3Ccircle cx='12' cy='12' r='2.2'/%3E%3Ccircle cx='19' cy='12' r='2.2'/%3E%3C/svg%3E"); --sh-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='M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='m16 6-4-4-4 4'/%3E%3Cpath d='M12 2v13'/%3E%3C/svg%3E"); --dl-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"); }
.c-tools > .more { align-items: center; gap: 3px; height: 24px; padding: 0 6px; border: 0; border-radius: var(--r-pill); background: none;
color: var(--text-muted); cursor: pointer; opacity: 0; transition: opacity .15s, background .15s; }
.more i, .c-menu i { flex: none; width: 15px; height: 15px; background-color: currentColor; -webkit-mask: var(--m) center / contain no-repeat; mask: var(--m) center / contain no-repeat; }
.more i { display: none; }
.more .g-dots { display: block; --m: var(--dots); }
.more[data-bm] .g-bm { display: block; --m: var(--bm-filled); background-color: var(--active); }
.more[data-nt] .g-nt { display: block; --m: var(--nt-filled); background-color: var(--accent); }
.more[data-bm], .more[data-nt] { opacity: 1; }
.more[data-bm] .g-dots, .more[data-nt] .g-dots { opacity: .45; }
:is(.couplet, .para, .single):hover .more, .is-active .more, .more:focus-visible { opacity: 1; }
.more:hover { background: var(--accent-soft); color: var(--active); }
/* the ⋯ and its menu are for phones; wider screens keep the four icons */
.more { display: none; }
@media (max-width: 640px), (hover: none) and (pointer: coarse) {
.c-tools > .bm, .c-tools > .nt, .c-tools > .sh, .c-tools > .dl { display: none; }
.c-tools > .more { display: inline-flex; }
.c-tools { bottom: 2px; } .poem .couplet { padding-bottom: 26px; } /* room for the button: it never sits on the text */
}
.c-menu { position: fixed; inset: auto; margin: 0; min-width: 180px; padding: 6px; border: 1px solid var(--border); border-radius: var(--r-md);
background: var(--surface-raised); color: var(--text); box-shadow: var(--shadow-lg); }
.c-menu:popover-open { display: flex; flex-direction: column; animation: pop-in .14s ease-out; }
.c-menu button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 5px 10px; border: 0; border-radius: var(--r-sm); background: none;
color: var(--text); font: var(--fs-sm)/1.9 var(--font-ui); text-align: start; cursor: pointer; }
.c-menu button:hover, .c-menu button:focus-visible { background: var(--accent-soft); color: var(--active); outline: none; }
.c-menu i { width: 17px; height: 17px; background-color: var(--text-muted); }
.c-menu .g-bm { --m: var(--bm-outline); } .c-menu [data-on] .g-bm { --m: var(--bm-filled); background-color: var(--active); }
.c-menu .g-nt { --m: var(--nt-outline); } .c-menu .g-sh { --m: var(--sh-mask); } .c-menu .g-dl { --m: var(--dl-mask); }