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:
parent
2bdf6c9dde
commit
1485781fb7
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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))}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user