Notes on couplets and selected text, and the site's own dialog, toasts and busy buttons

- Notes: a glyph below each couplet's bookmark opens a note box (filled gold when the couplet or a phrase in it has a note; the note shows on hover). Selected text gets نوٹ in the selection menu; a highlighted phrase with a note shows the note glyph and opens its note (with "remove highlight"). API: POST /api/library/note {poemId, couplet, phrase?, note} (bookmarks if needed; empty clears the note), notes in /api/library/state; tests added
- Confirm dialog in Urdu (destructive answer in red, named for what it does) in place of the browser's English confirm(); forms use data-confirm
- Toasts for "done" messages; errors stay by the form and nudge; a submitting button shows a spinner
- Motion: page cross-fade (View Transitions) with the logo held; couplets rise in on load; bookmark and note glyphs pop; the word sidebar slides in; menus and folding sections open smoothly; all off with reduced motion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-09 17:14:59 +02:00
parent 2bdf6c9dde
commit 1485781fb7
9 changed files with 284 additions and 20 deletions

View File

@ -55,10 +55,24 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id; const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id;
assert.ok(marks.categories.includes(book) && marks.poets.includes(266)); assert.ok(marks.categories.includes(book) && marks.poets.includes(266));
// state for a page // state for a page
assert.deepEqual((await call('GET', `/api/library/state?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }] }); assert.deepEqual((await call('GET', `/api/library/state?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }], notes: [] });
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true }); assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true });
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false }); assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false });
// notes on a couplet and on a phrase: saving a note bookmarks it; an empty note keeps the bookmark
const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk);
assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { saved: true, note: 'پہلا نوٹ' });
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { saved: true, note: 'نیا' }, 'not bookmarked yet: bookmarked with the note');
const ph = lines0[0].split(' ').slice(0, 3).join(' ');
assert.equal((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json().saved, true);
assert.equal((await note({ poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں', note: 'x' })).statusCode, 400);
assert.equal((await note({ poemId: poem, couplet: 9999, note: 'x' })).statusCode, 404);
assert.equal((await note({ poemId: poem, couplet: 1, note: 'x' }, other.token)).json().saved, true, "another reader's note is their own");
const st = (await call('GET', `/api/library/state?poem=${poem}`, undefined, t)).json();
assert.deepEqual(st.notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort());
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: '' })).json(), { saved: true, note: null }, 'cleared, still bookmarked');
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false);
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: '' })).json(), { saved: false, note: null }, 'nothing to clear');
// toggling again removes // toggling again removes
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false); assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false);

View File

@ -6,6 +6,8 @@
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items // GET /api/library/marks which works, books/sections and poets hold the reader's saved items
// POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?} // POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
// POST /api/library/:id/note {note} // POST /api/library/:id/note {note}
// POST /api/library/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it: bookmarks it if needed; an empty
// note clears the note and keeps the bookmark -> {saved, note}
// POST /api/library/:id/delete // POST /api/library/:id/delete
// kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a // kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book) // couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book)
@ -22,6 +24,14 @@ export const cleanPhrase = (p: unknown) => {
return s.length >= 2 && s.length <= 300 ? s : null; return s.length >= 2 && s.length <= 300 ? s : null;
}; };
// a phrase must be in its couplet or paragraph (its lines, or across its two misras); null when it is
async function phraseIn(poemId: number, couplet: number, phrase: string) {
const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t;
if (!text) return { status: 404, error: 'نہیں ملا' };
if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return { status: 400, error: 'یہ عبارت اس شعر میں نہیں' };
return null;
}
async function reader(req: FastifyRequest, reply: FastifyReply) { async function reader(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req); const u = await sessionUser(req);
if (!u) reply.code(401).send({ error: 'لاگ ان کریں' }); if (!u) reply.code(401).send({ error: 'لاگ ان کریں' });
@ -107,13 +117,14 @@ export function libraryRoutes(app: FastifyInstance) {
} }
const poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0; const poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0;
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) `SELECT kind, couplet, phrase, note FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2)
OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`, OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`,
[u.id, poet, category, poem]); [u.id, poet, category, poem]);
return { return {
poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'), poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'),
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet), couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet),
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })), phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
notes: rows.filter((r) => r.note && (r.kind === 'couplet' || r.kind === 'phrase')).map((r) => ({ couplet: r.couplet, phrase: r.phrase, note: r.note })),
}; };
}); });
@ -170,9 +181,8 @@ export function libraryRoutes(app: FastifyInstance) {
const phrase = cleanPhrase(b.phrase); const phrase = cleanPhrase(b.phrase);
if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' }); if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' });
// the phrase must be in that couplet or paragraph (its lines, or across its two misras) // the phrase must be in that couplet or paragraph (its lines, or across its two misras)
const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t; const where = await phraseIn(poemId, couplet, phrase);
if (!text) return reply.code(404).send({ error: 'نہیں ملا' }); if (where) return reply.code(where.status).send({ error: where.error });
if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return reply.code(400).send({ error: 'یہ عبارت اس شعر میں نہیں' });
const args = [u.id, poemId, couplet, phrase]; const args = [u.id, poemId, couplet, phrase];
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4 RETURNING id`, args)).rowCount) if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4 RETURNING id`, args)).rowCount)
return { saved: false }; return { saved: false };
@ -196,6 +206,28 @@ export function libraryRoutes(app: FastifyInstance) {
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined }; return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
}); });
app.post<{ Body: { poemId?: number; couplet?: number; phrase?: string; note?: string } }>('/api/library/note', async (req, reply) => {
const u = await reader(req, reply); if (!u) return;
const b = req.body ?? {}, poemId = Number(b.poemId) || 0, couplet = Number.isInteger(b.couplet) ? b.couplet! : null;
const note = String(b.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
if (!poemId || couplet == null) return reply.code(400).send({ error: 'شعر منتخب کریں' });
let kind = 'couplet', match = `kind = 'couplet' AND poem_id = $2 AND couplet = $3`, args: unknown[] = [u.id, poemId, couplet];
if (b.phrase != null) {
const phrase = cleanPhrase(b.phrase);
if (!phrase) return reply.code(400).send({ error: 'عبارت منتخب کریں' });
const where = await phraseIn(poemId, couplet, phrase);
if (where) return reply.code(where.status).send({ error: where.error });
kind = 'phrase'; match = `kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`; args.push(phrase);
} else if (!(await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet])).rowCount) {
return reply.code(404).send({ error: 'نہیں ملا' });
}
const n = args.length + 1;
if ((await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id`, [...args, note])).rowCount) return { saved: true, note };
if (!note) return { saved: false, note: null }; // nothing saved and nothing to write
await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note) VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n})`, [...args, note]);
return { saved: true, note };
});
app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => { app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => {
const u = await reader(req, reply); if (!u) return; const u = await reader(req, reply); if (!u) return;
const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null; const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;

View File

@ -1,6 +1,9 @@
--- ---
// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?] // renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?]
// a bookmarked phrase opens its note when pressed (the poem page's note box), where it can also be removed
const { s } = Astro.props as { s: [string, string, string?][] }; const { s } = Astro.props as { s: [string, string, string?][] };
const noteOf = (d?: string) => (d ? (JSON.parse(d).note as string | undefined) : undefined);
--- ---
{s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span> {s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span>
: k === 'bmk' ? <span class="bmk" data-save={d} data-confirm="یہ عبارت محفوظ شدہ سے ہٹائیں؟" title="محفوظ شدہ عبارت (ہٹانے کے لیے دبائیں)">{t}</span> : t))} : k === 'bmk' ? <span class:list={['bmk', { 'has-note': noteOf(d) }]} data-phrase={d} role="button" tabindex="0"
title={noteOf(d) ? `نوٹ: ${noteOf(d)}` : 'محفوظ شدہ عبارت: نوٹ لکھیں یا ہٹائیں'}>{t}</span> : t))}

View File

@ -110,6 +110,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button> <button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
<button type="button" data-act="saveword" class="plus" 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="bookmark" title="یہ عبارت محفوظ کریں">محفوظ کریں</button>
<button type="button" data-act="note" title="اس عبارت پر نوٹ لکھیں">نوٹ</button>
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button> <button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button> <button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
<button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button> <button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button>
@ -277,7 +278,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
document.addEventListener('click', async (e) => { document.addEventListener('click', async (e) => {
const b = e.target.closest('[data-save]'); if (!b) return; const b = e.target.closest('[data-save]'); if (!b) return;
if (getSelection()?.toString().trim()) return; // selecting text, not pressing if (getSelection()?.toString().trim()) return; // selecting text, not pressing
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return; if (b.dataset.confirm && !(await ask(b.dataset.confirm, { danger: true, ok: 'ہٹائیں' }))) return;
const item = JSON.parse(b.dataset.save), d = await save(item); 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 if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page
else if (d) { else if (d) {
@ -441,7 +442,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
const block = sel.anchorNode.parentElement?.closest('[data-c]'); const block = sel.anchorNode.parentElement?.closest('[data-c]');
source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.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=saveword]').hidden = !w;
menu.querySelector('[data-act=bookmark]').hidden = !(source.poemId && Number.isInteger(source.couplet)); menu.querySelector('[data-act=bookmark]').hidden = menu.querySelector('[data-act=note]').hidden = !(source.poemId && Number.isInteger(source.couplet));
menu.querySelector('[data-act=meaning]').hidden = !w; menu.querySelector('[data-act=meaning]').hidden = !w;
menu.hidden = false; menu.hidden = false;
// below the selection, centred, kept inside the viewport (absolute: moves with the page) // below the selection, centred, kept inside the viewport (absolute: moves with the page)
@ -464,6 +465,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
case 'meaning': showWord(oneWord(selText)); break; case 'meaning': showWord(oneWord(selText)); break;
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText }) case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
.then((d) => d && (d.saved ? location.reload() : flash(b, 'پہلے سے محفوظ تھی، ہٹا دی'))); break; .then((d) => d && (d.saved ? location.reload() : flash(b, 'پہلے سے محفوظ تھی، ہٹا دی'))); break;
case 'note': hideMenu(); document.dispatchEvent(new CustomEvent('divan:note', { detail: { couplet: source.couplet, phrase: selText } })); break; // the poem page opens its note box
case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'ذخیرۂ الفاظ میں ڈال دیا' : 'ذخیرۂ الفاظ سے نکال دیا')); 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 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break; case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
@ -485,5 +487,46 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
}); });
</script> </script>
<!-- shared UI: the confirm dialog (instead of the browser's English confirm), toasts, busy buttons -->
<dialog class="ask" id="ask" aria-labelledby="ask-msg">
<form method="dialog">
<p id="ask-msg"></p>
<div class="ask-actions"><button class="btn primary" value="ok">ہاں</button><button class="btn" value="">نہیں</button></div>
</form>
</dialog>
<div class="toasts" aria-live="polite"></div>
<script is:inline>
(() => {
const dlg = document.getElementById('ask'), ok = dlg.querySelector('[value=ok]');
// ask(message, {danger, ok}) -> Promise<boolean>; the destructive answer is red and named for what it does
window.ask = (message, o = {}) => new Promise((done) => {
dlg.querySelector('#ask-msg').textContent = message;
ok.textContent = o.ok || 'ہاں'; ok.classList.toggle('danger', !!o.danger); ok.classList.toggle('primary', !o.danger);
dlg.returnValue = ''; dlg.showModal(); dlg.querySelector('[value=""]').focus();
dlg.addEventListener('close', () => done(dlg.returnValue === 'ok'), { once: true });
});
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); }); // a click outside the box is "no"
// toast(message, kind): slides in, leaves by itself
const box = document.querySelector('.toasts');
window.toast = (message, kind = 'ok') => {
const t = document.createElement('p'); t.className = 'toast ' + kind; t.textContent = message; box.append(t);
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 350); }, 4200); // removed on a timer: with reduced motion there is no animation to end
};
// a page's "done" line becomes a toast
document.querySelectorAll('.form-done').forEach((p) => { if (p.textContent.trim()) { toast(p.textContent.trim()); p.hidden = true; } });
// forms: data-confirm asks first; a submitting button shows it is busy
document.addEventListener('submit', async (e) => {
const f = e.target; if (f.method === 'dialog') return;
const btn = e.submitter;
if (f.dataset.confirm && !f.dataset.asked) {
e.preventDefault();
if (!(await ask(f.dataset.confirm, { danger: 'danger' in f.dataset, ok: f.dataset.ok }))) return;
f.dataset.asked = '1'; f.requestSubmit(btn); delete f.dataset.asked; return;
}
if (btn && !e.defaultPrevented && !f.hasAttribute('data-js')) btn.setAttribute('aria-busy', 'true');
});
addEventListener('pageshow', () => document.querySelectorAll('[aria-busy=true]').forEach((b) => b.removeAttribute('aria-busy'))); // back button
})();
</script>
</body> </body>
</html> </html>

View File

@ -53,7 +53,10 @@ 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 poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
const categoryId = page.type === 'category' ? page.category.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 const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data
: { poet: false, category: false, poem: false, couplets: [], phrases: [] }; : { 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 // 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 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' : ''); const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
@ -61,7 +64,8 @@ const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '')
const lineMarks = (c: number, lines: string[]) => { const lineMarks = (c: number, lines: string[]) => {
const out: Mark[][] = lines.map(() => []); const out: Mark[][] = lines.map(() => []);
for (const p of saved.phrases.filter((x: any) => x.couplet === c)) { for (const p of saved.phrases.filter((x: any) => x.couplet === c)) {
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase }); 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 });
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save })); phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
} }
return out; return out;
@ -142,14 +146,32 @@ import Icon from '../components/Icon.astro';
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>} {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}> <article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
{blocks.map((b) => { {blocks.map((b) => {
const bm = <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />; 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 = <>
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں"
data-confirm={cn ? 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔' : undefined} />
<button type="button" class="nt" data-c={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
</>;
const lm = lineMarks(b.c, b.lines); 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> 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> : 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>; : <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
})} })}
</article> </article>
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p> <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>
<textarea name="note" rows="4" maxlength="1000" placeholder="آپ کا نوٹ…" aria-label="نوٹ" data-urdu></textarea>
<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"> <nav class="poem-nav">
{page.prev && <a class="icon-btn" href={page.prev} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>} {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>} {page.next && <a class="icon-btn" href={page.next} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
@ -168,3 +190,62 @@ import Icon from '../components/Icon.astro';
)} )}
{re && <script is:inline>document.querySelector('.poem mark')?.scrollIntoView({ block: 'center' });</script>} {re && <script is:inline>document.querySelector('.poem mark')?.scrollIntoView({ block: 'center' });</script>}
</Base> </Base>
<script>
// notes on couplets and phrases: the glyph beside a couplet's bookmark, a highlighted phrase, or نوٹ in the selection menu
// open one box; saving a note bookmarks the couplet or phrase (the API does both)
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 lineOf = (c: number) => [...document.querySelectorAll(`[data-c="${c}"] p, p[data-c="${c}"]`)].map((p) => p.textContent!.trim()).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-c="${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;
if (bm) { if (cn) { bm.setAttribute('aria-pressed', 'true'); bm.dataset.confirm = 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔'; } else delete bm.dataset.confirm; }
};
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.c), 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); } });
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
}
</script>

View File

@ -70,7 +70,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
<section class="panel"> <section class="panel">
<h2>کردار</h2> <h2>کردار</h2>
{self ? <p class="muted">اپنا کردار خود نہیں بدل سکتے۔</p> : ( {self ? <p class="muted">اپنا کردار خود نہیں بدل سکتے۔</p> : (
<form method="post" class="role-pick" onsubmit={`return confirm(${JSON.stringify(isMod ? 'کردار بدلیں؟ موڈریٹر نہ رہنے پر اس کی اجازتیں ختم ہو جائیں گی۔' : 'کردار بدلیں؟')})`}> <form method="post" class="role-pick" data-confirm={isMod ? 'کردار بدلیں؟ موڈریٹر نہ رہنے پر اس کی اجازتیں ختم ہو جائیں گی۔' : 'کردار بدلیں؟'} data-ok="کردار بدلیں">
<input type="hidden" name="act" value="role" /> <input type="hidden" name="act" value="role" />
{Object.entries(ROLE).map(([k, v]) => ( {Object.entries(ROLE).map(([k, v]) => (
<button class="btn" name="role" value={k} aria-pressed={k === user.role ? 'true' : 'false'} disabled={k === user.role}> <button class="btn" name="role" value={k} aria-pressed={k === user.role ? 'true' : 'false'} disabled={k === user.role}>
@ -92,7 +92,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
<p class="grant-scope"><strong>{SCOPE[g.scope]}</strong>{g.label && <>: {g.url ? <a href={g.url}>{g.label}</a> : g.label}</>}{g.poet && g.scope !== 'poet' && <small class="muted"> ({g.poet})</small>}</p> <p class="grant-scope"><strong>{SCOPE[g.scope]}</strong>{g.label && <>: {g.url ? <a href={g.url}>{g.label}</a> : g.label}</>}{g.poet && g.scope !== 'poet' && <small class="muted"> ({g.poet})</small>}</p>
<p class="tag-chips">{g.content.map((c: string) => <span class="badge plain">{CONTENT[c]}</span>)}<span class="sep" aria-hidden="true"></span>{g.actions.map((a: string) => <span class="badge" data-role="mod-l2">{ACTION[a]}</span>)}</p> <p class="tag-chips">{g.content.map((c: string) => <span class="badge plain">{CONTENT[c]}</span>)}<span class="sep" aria-hidden="true"></span>{g.actions.map((a: string) => <span class="badge" data-role="mod-l2">{ACTION[a]}</span>)}</p>
</div> </div>
<form method="post" onsubmit="return confirm('یہ اجازت واپس لیں؟')"> <form method="post" data-confirm="یہ اجازت واپس لیں؟" data-danger data-ok="واپس لیں">
<input type="hidden" name="grant" value={g.id} /> <input type="hidden" name="grant" value={g.id} />
<button class="icon-btn small danger" name="act" value="revoke" title="اجازت واپس لیں" aria-label="اجازت واپس لیں"><Icon name="trash" size={16} /></button> <button class="icon-btn small danger" name="act" value="revoke" title="اجازت واپس لیں" aria-label="اجازت واپس لیں"><Icon name="trash" size={16} /></button>
</form> </form>
@ -129,7 +129,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
<section class="panel"> <section class="panel">
<h2>اکاؤنٹ</h2> <h2>اکاؤنٹ</h2>
<div class="account-actions"> <div class="account-actions">
<form method="post" onsubmit="return confirm('اس صارف کا پاس ورڈ ری سیٹ کریں؟ اس کے سب سیشن ختم ہو جائیں گے۔')"> <form method="post" data-confirm="اس صارف کا پاس ورڈ ری سیٹ کریں؟ اس کے سب سیشن ختم ہو جائیں گے۔" data-ok="ری سیٹ کریں">
<button class="btn" name="act" value="reset"><Icon name="key" size={16} /> پاس ورڈ ری سیٹ</button> <button class="btn" name="act" value="reset"><Icon name="key" size={16} /> پاس ورڈ ری سیٹ</button>
</form> </form>
{!self && ( {!self && (
@ -141,7 +141,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
)} )}
</div> </div>
{!self && ( {!self && (
<form method="post" class="danger-zone" onsubmit="return confirm('یہ اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔ جاری رکھیں؟')"> <form method="post" class="danger-zone" data-confirm="یہ اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔ جاری رکھیں؟" data-danger data-ok="حذف کریں">
<p class="muted small-line">اکاؤنٹ اور اس کا تمام ڈیٹا (محفوظ شدہ، ذخیرۂ الفاظ) مستقل طور پر حذف۔ واپسی ممکن نہیں۔</p> <p class="muted small-line">اکاؤنٹ اور اس کا تمام ڈیٹا (محفوظ شدہ، ذخیرۂ الفاظ) مستقل طور پر حذف۔ واپسی ممکن نہیں۔</p>
<button class="btn danger" name="act" value="delete"><Icon name="trash" size={16} /> اکاؤنٹ حذف کریں</button> <button class="btn danger" name="act" value="delete"><Icon name="trash" size={16} /> اکاؤنٹ حذف کریں</button>
</form> </form>

View File

@ -10,7 +10,8 @@ export const POST: APIRoute = async ({ request, cookies, url }) => {
if (origin && new URL(origin).host !== url.host) return json({ error: 'forbidden' }, 403); if (origin && new URL(origin).host !== url.host) return json({ error: 'forbidden' }, 403);
const token = cookies.get(COOKIE)?.value; const token = cookies.get(COOKIE)?.value;
if (!token) return json({ error: 'لاگ ان کریں' }, 401); if (!token) return json({ error: 'لاگ ان کریں' }, 401);
const r = await asUser(token, '/api/library/toggle', await request.json().catch(() => ({}))); const body = await request.json().catch(() => ({}));
const r = await asUser(token, 'note' in body ? '/api/library/note' : '/api/library/toggle', body); // {note, poemId, couplet, phrase?}: a note
return json(r.data, r.status); return json(r.data, r.status);
}; };

View File

@ -57,11 +57,11 @@ const centuries = Object.fromEntries(Array.from({ length: 16 }, (_, c) => [c, ce
const norm = (s: string) => s.normalize('NFC').replace(/[ً-ْٰٔ]/g, '').replace(/[يى]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ'); const norm = (s: string) => s.normalize('NFC').replace(/[ً-ْٰٔ]/g, '').replace(/[يى]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ');
// arrows, ✕ and ہٹائیں are drawn as icons (the same Lucide paths as Icon.astro); the words stay as tooltip and label // arrows, ✕ and ہٹائیں are drawn as icons (the same Lucide paths as Icon.astro); the words stay as tooltip and label
const ICON: Record<string, string> = { '↑': '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>', '↓': '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>', '✕': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 'ہٹائیں': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>' }; const ICON: Record<string, string> = { '↑': '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>', '↓': '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>', '✕': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 'ہٹائیں': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>' };
const btn = (label: string, title: string, fn: () => void) => { const btn = (label: string, title: string, fn: () => unknown) => {
const b = document.createElement('button'); b.type = 'button'; b.className = 'tool'; b.title = title; const b = document.createElement('button'); b.type = 'button'; b.className = 'tool'; b.title = title;
if (ICON[label]) { b.innerHTML = `<svg class="icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[label]}</svg>`; b.setAttribute('aria-label', title); } if (ICON[label]) { b.innerHTML = `<svg class="icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[label]}</svg>`; b.setAttribute('aria-label', title); }
else b.textContent = label; else b.textContent = label;
b.onclick = () => { fn(); draw(); }; return b; b.onclick = async () => { await fn(); draw(); }; return b; // fn may ask first
}; };
// a box with the Urdu keyboard and Roman typing buttons inside it, as in search (Base.astro handles them) // a box with the Urdu keyboard and Roman typing buttons inside it, as in search (Base.astro handles them)
const KB = '<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>'; const KB = '<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>';
@ -96,7 +96,7 @@ const centuries = Object.fromEntries(Array.from({ length: 16 }, (_, c) => [c, ce
kind.textContent = s.century === null ? `منتخب شعرا (${s.poets.length})` : `صدی: ${data.centuries[s.century]}`; kind.textContent = s.century === null ? `منتخب شعرا (${s.poets.length})` : `صدی: ${data.centuries[s.century]}`;
head.append(keyed(title, `site-title-${i}`, 'site-title'), sort, kind, head.append(keyed(title, `site-title-${i}`, 'site-title'), sort, kind,
btn('↑', 'حصہ اوپر', () => move(state, i, -1)), btn('↓', 'حصہ نیچے', () => move(state, i, 1)), btn('↑', 'حصہ اوپر', () => move(state, i, -1)), btn('↓', 'حصہ نیچے', () => move(state, i, 1)),
btn('ہٹائیں', 'یہ حصہ ہٹائیں', () => { if (confirm('یہ حصہ ہٹا دیں؟')) state.splice(i, 1); })); btn('ہٹائیں', 'یہ حصہ ہٹائیں', async () => { if (await (window as any).ask('یہ حصہ ہٹا دیں؟', { danger: true, ok: 'ہٹائیں' })) state.splice(i, 1); }));
box.append(head); box.append(head);
if (s.century === null) box.append(poetList(s, i)); if (s.century === null) box.append(poetList(s, i));
return box; return box;

View File

@ -644,3 +644,93 @@ h1 + .muted { text-align: center; margin-top: 0; }
.account-actions { display: flex; flex-wrap: wrap; gap: 8px; } .account-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.danger-zone { margin-top: 14px; padding-top: 12px; border-top: 1px dashed color-mix(in srgb, var(--danger) 40%, transparent); } .danger-zone { margin-top: 14px; padding-top: 12px; border-top: 1px dashed color-mix(in srgb, var(--danger) 40%, transparent); }
.danger-zone p { margin: 0 0 6px; } .danger-zone p { margin: 0 0 6px; }
/* ── motion: purposeful and short; all of it off when the reader's device asks for reduced motion ── */
/* page to page: a cross-fade, the logo stays put */
@view-transition { navigation: auto; }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; }
.wordmark .logo { view-transition-name: logo; }
/* the confirm dialog */
.ask { border: 1.5px solid var(--border); border-radius: var(--r-lg); padding: 18px 22px 14px; max-width: min(420px, 92vw); background: var(--surface-raised); color: var(--text);
box-shadow: var(--shadow-lg); font-family: var(--font-ui); }
.ask::backdrop { background: rgb(0 0 0 / .35); backdrop-filter: blur(2px); }
.ask p { margin: 0 0 14px; line-height: 1.9; }
.ask-actions { display: flex; gap: 8px; justify-content: flex-start; }
.ask[open] { animation: pop-in .18s ease-out; }
@keyframes pop-in { from { opacity: 0; transform: scale(.96) translateY(6px); } }
/* toasts: bottom centre, above everything */
.toasts { position: fixed; inset-inline: 0; bottom: 20px; z-index: 50; display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; }
.toast { margin: 0; padding: 8px 18px; border-radius: var(--r-pill); background: var(--text); color: var(--surface-page); box-shadow: var(--shadow-lg);
font: var(--fs-sm)/1.8 var(--font-ui); animation: toast-in .25s ease-out; }
.toast::before { content: '✓ '; color: var(--ok); font-weight: 700; }
.toast.error::before { content: '! '; color: var(--danger); }
.toast.out { animation: toast-out .3s ease-in forwards; }
@keyframes toast-in { from { opacity: 0; transform: translateY(14px); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(14px); } }
/* errors stay by the form and nudge once */
.form-error:not(:empty) { animation: nudge .4s ease-in-out; }
@keyframes nudge { 25% { transform: translateX(-5px); } 50% { transform: translateX(5px); } 75% { transform: translateX(-3px); } }
/* a submitted button is busy until the page answers */
[aria-busy='true'] { position: relative; color: transparent !important; pointer-events: none; }
[aria-busy='true']::after { content: ''; position: absolute; inset: 0; margin: auto; width: 1em; height: 1em; border-radius: 50%;
border: 2px solid var(--text-muted); border-top-color: transparent; animation: spin .7s linear infinite; }
.btn.primary[aria-busy='true']::after { border-color: var(--action-ink); border-top-color: transparent; }
@keyframes spin { to { transform: rotate(360deg); } }
/* reading: the couplets rise in, one after another, on first load */
.poem > .couplet, .poem > .para { animation: rise .45s ease-out both; }
.poem > .couplet:nth-of-type(2), .poem > .para:nth-of-type(2) { animation-delay: 45ms; }
.poem > .couplet:nth-of-type(3), .poem > .para:nth-of-type(3) { animation-delay: 90ms; }
.poem > .couplet:nth-of-type(4), .poem > .para:nth-of-type(4) { animation-delay: 135ms; }
.poem > .couplet:nth-of-type(5), .poem > .para:nth-of-type(5) { animation-delay: 180ms; }
.poem > .couplet:nth-of-type(6), .poem > .para:nth-of-type(6) { animation-delay: 225ms; }
.poem > .couplet:nth-of-type(7), .poem > .para:nth-of-type(7) { animation-delay: 270ms; }
.poem > .couplet:nth-of-type(8), .poem > .para:nth-of-type(8) { animation-delay: 315ms; }
.poem > .couplet:nth-of-type(9), .poem > .para:nth-of-type(9) { animation-delay: 360ms; }
.poem > .couplet:nth-of-type(10), .poem > .para:nth-of-type(10) { animation-delay: 405ms; }
.poem > .couplet:nth-of-type(11), .poem > .para:nth-of-type(11) { animation-delay: 450ms; }
.poem > .couplet:nth-of-type(12), .poem > .para:nth-of-type(12) { animation-delay: 495ms; }
.poem > .couplet:nth-of-type(13), .poem > .para:nth-of-type(13) { animation-delay: 540ms; }
.poem > .couplet:nth-of-type(14), .poem > .para:nth-of-type(14) { animation-delay: 585ms; }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
/* saving: the bookmark gives a small pop when it fills */
.save[aria-pressed='true']::before, .bm[aria-pressed='true']::before, .wordbook[aria-pressed='true'] { animation: pop .35s ease-out; }
@keyframes pop { 40% { transform: scale(1.35); } }
/* the word sidebar slides in (on phones it rises from the bottom) */
.dict { transition: transform .25s ease-out, opacity .25s; }
@starting-style { .dict { transform: translateX(-100%); opacity: 0; } }
@media (max-width: 700px) { @starting-style { .dict { transform: translateY(100%); } } }
/* menus and folding sections open smoothly */
.display-menu[open] .display-panel { animation: pop-in .16s ease-out; transform-origin: top left; }
:root { interpolate-size: allow-keywords; }
details::details-content { transition: block-size .25s, content-visibility .25s allow-discrete; overflow: clip; }
/* headings break into even lines */
h1, h2, h3, .ask p { text-wrap: balance; }
p, li { text-wrap: pretty; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after, ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; transition: none !important; }
}
.ask .btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
/* notes: a glyph below each couplet's bookmark (outline: write a note; filled gold: this couplet or a phrase in it has one) */
:root { --nt-outline: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M21%2015a2%202%200%200%201-2%202H7l-4%204V5a2%202%200%200%201%202-2h14a2%202%200%200%201%202%202z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M7 8h10M7 12h6' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); --nt-filled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M21%2015a2%202%200%200%201-2%202H7l-4%204V5a2%202%200%200%201%202-2h14a2%202%200%200%201%202%202z' fill='black' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M7 8h10M7 12h6' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.nt { position: absolute; inset-inline-end: -26px; top: calc(.5em + 26px); width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer;
opacity: 0; transition: opacity .15s; }
.nt::before { content: ''; display: block; width: 17px; height: 17px; margin: auto; background-color: var(--text-muted);
-webkit-mask: var(--nt-outline) center / contain no-repeat; mask: var(--nt-outline) center / contain no-repeat; }
.nt[aria-pressed='true'] { opacity: 1; }
.nt[aria-pressed='true']::before { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); animation: pop .35s ease-out; }
.couplet:hover .nt, .poem .para:hover .nt, .poem .single:hover .nt, .nt:focus-visible { opacity: 1; }
@media (hover: none) { .nt { opacity: .55; } .nt[aria-pressed='true'] { opacity: 1; } }
@media (max-width: 700px) { .nt { inset-inline-end: -4px; top: 26px; } }
/* a highlighted phrase with a note: the note glyph instead of the bookmark */
.bmk.has-note::after { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); }
.bmk:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* the note box */
.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-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); }
.ask-actions { flex-wrap: wrap; }
.note-box .note-clear { margin-inline-start: auto; }