Merge branch 'feature/ux-motion' into main: notes (separate from bookmarks, notes page), share and download a couplet as an image, Urdu confirm dialog, toasts, busy buttons, page transitions and motion, moderation dashboard, timelines (#74)

This commit is contained in:
Anas Rashid 2026-10-09 17:32:19 +02:00
commit 9f401fe2af
14 changed files with 599 additions and 74 deletions

View File

@ -55,10 +55,39 @@ 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;
assert.ok(marks.categories.includes(book) && marks.poets.includes(266));
// 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: false });
// notes on a couplet and on a phrase are separate from bookmarks: a note never bookmarks; un-bookmarking keeps the note
const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk);
const st = () => call('GET', `/api/library/state?poem=${poem}`, undefined, t).then((r) => r.json());
assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { note: 'پہلا نوٹ' }, 'on a bookmarked couplet');
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { note: 'نیا' });
assert.ok(!(await st()).couplets.includes(1), 'a note does not bookmark');
const ph = lines0[0].split(' ').slice(1, 3).join(' ');
assert.deepEqual((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json(), { note: 'عبارت پر' });
assert.ok(!(await st()).phrases.some((p: any) => p.phrase === ph), 'nor a phrase');
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)).statusCode, 200, "another reader's note is their own");
assert.deepEqual((await st()).notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort());
// bookmarking a noted couplet keeps the note; un-bookmarking keeps it too
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, true);
assert.ok((await st()).couplets.includes(1));
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false);
assert.ok(!(await st()).couplets.includes(1) && (await st()).notes.some((n: any) => n.couplet === 1), 'the note stays');
// clearing a note that has no bookmark removes it; on a bookmark it keeps the bookmark
await note({ poemId: poem, couplet: 1, note: '' });
assert.ok(!(await st()).notes.some((n: any) => n.couplet === 1));
await note({ poemId: poem, couplet: 0, phrase: ph, note: '' });
await note({ poemId: poem, couplet: 2, note: '' });
assert.ok((await st()).couplets.includes(2) && !(await st()).notes.length, 'bookmark kept, notes gone');
// the notes list (the notes page) holds notes only; the saved lists hold bookmarks only
await note({ poemId: poem, couplet: 3, note: 'صرف نوٹ' }).catch(() => {});
const listed = (await call('GET', '/api/library', undefined, t)).json();
assert.ok(listed.notes.every((n: any) => n.note) && !listed.couplets.some((c: any) => c.poem?.couplet === 3));
await note({ poemId: poem, couplet: 3, note: '' });
// toggling again removes
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false);

View File

@ -6,7 +6,11 @@
// 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/:id/note {note}
// POST /api/library/:id/delete
// POST /api/library/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it; never bookmarks it (a
// note and a bookmark are separate); an empty note clears it -> {note}
// notes and bookmarks share a row: bookmarked false is a note only; un-bookmarking keeps the note, clearing the note of a
// row that is not bookmarked removes the row
// POST /api/library/:id/delete (a couplet or phrase with a note keeps the note)
// 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)
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
@ -22,6 +26,25 @@ export const cleanPhrase = (p: unknown) => {
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;
}
// an existing couplet or phrase row: a bookmark goes (the row stays if it holds a note), a note only becomes a bookmark;
// null when there is no row
async function flipBookmark(match: string, args: unknown[]) {
const row = (await pool.query(`SELECT id, bookmarked, note FROM library WHERE user_id = $1 AND ${match}`, args)).rows[0];
if (!row) return null;
if (!row.bookmarked) { await pool.query('UPDATE library SET bookmarked = true WHERE id = $1', [row.id]); return { saved: true, id: Number(row.id) }; }
if (row.note) await pool.query('UPDATE library SET bookmarked = false WHERE id = $1', [row.id]);
else await pool.query('DELETE FROM library WHERE id = $1', [row.id]);
return { saved: false };
}
async function reader(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req);
if (!u) reply.code(401).send({ error: 'لاگ ان کریں' });
@ -66,7 +89,7 @@ export function libraryRoutes(app: FastifyInstance) {
app.get('/api/library', async (req, reply) => {
const u = await reader(req, reply); if (!u) return;
const { rows } = await pool.query(
`SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page,
`SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page, l.bookmarked,
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet,
eb.title AS ebook_title, ep.nickname AS ebook_poet, ep.url AS ebook_poet_url
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
@ -87,8 +110,11 @@ export function libraryRoutes(app: FastifyInstance) {
poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })),
categories: rows.filter((r) => r.kind === 'category').map((r) => ({ ...item(r), path: chapters.get(r.category_id) ?? [] })),
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
// every note on a couplet or phrase, bookmarked or not (the notes page)
notes: rows.filter((r) => (r.kind === 'couplet' || r.kind === 'phrase') && r.note).map((r) => ({
...item(r), phrase: r.phrase, bookmarked: r.bookmarked, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
pages: rows.filter((r) => r.kind === 'page' && r.ebook_title).map((r) => ({
...item(r), page: r.page, ebook: { id: r.ebook_id, title: r.ebook_title, poet: r.ebook_poet, poet_url: r.ebook_poet_url } })),
words: rows.filter((r) => r.kind === 'word').map((r) => ({
@ -107,13 +133,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 { 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, bookmarked 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))`,
[u.id, poet, category, poem]);
return {
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),
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => r.couplet),
phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).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 })),
};
});
@ -123,7 +150,7 @@ export function libraryRoutes(app: FastifyInstance) {
const u = await reader(req, reply); if (!u) return;
const { rows } = await pool.query(
`SELECT poem_id, bool_or(kind = 'poem') AS fav, count(*) FILTER (WHERE kind IN ('couplet', 'phrase'))::int AS bm
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') GROUP BY poem_id`, [u.id]);
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') AND bookmarked GROUP BY poem_id`, [u.id]);
const ids = rows.map((r) => r.poem_id);
const [cats, poets] = await Promise.all([
pool.query(`WITH RECURSIVE up AS (
@ -170,12 +197,11 @@ export function libraryRoutes(app: FastifyInstance) {
const phrase = cleanPhrase(b.phrase);
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)
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 reply.code(404).send({ error: 'نہیں ملا' });
if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(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 });
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)
return { saved: false };
const flip = await flipBookmark(`kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`, args);
if (flip) return flip;
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args);
return { saved: true, id: Number(rows[0].id) };
}
@ -190,22 +216,51 @@ export function libraryRoutes(app: FastifyInstance) {
const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(category_id, 0) = coalesce($4::int, 0)
AND coalesce(poem_id, 0) = coalesce($5::int, 0) AND coalesce(couplet, -1) = coalesce($6::int, -1)`;
const args = [u.id, kind, ...cols];
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${match} RETURNING id`, args)).rowCount) return { saved: false };
const flip = await flipBookmark(match, args);
if (flip) return flip;
const { rows } = await pool.query(
'INSERT INTO library (user_id, kind, poet_id, category_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id', args);
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;
const row = (await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id, bookmarked`, [...args, note])).rows[0];
if (row) { if (!note && !row.bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [row.id]); return { note }; }
if (note) await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note, bookmarked)
VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n}, false)`, [...args, note]);
return { note };
});
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 note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3', [note, Number(req.params.id) || 0, u.id]);
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3 RETURNING bookmarked', [note, Number(req.params.id) || 0, u.id]);
if (!r.rowCount) return reply.code(404).send({ error: 'نہیں ملا' });
if (!note && !r.rows[0].bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [Number(req.params.id)]); // a note only, now empty
return { ok: true };
});
app.post<{ Params: { id: string } }>('/api/library/:id/delete', async (req, reply) => {
const u = await reader(req, reply); if (!u) return;
const r = await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [Number(req.params.id) || 0, u.id]);
// removing a bookmarked couplet or phrase keeps its note (the row stays as a note only)
const id = Number(req.params.id) || 0;
const kept = await pool.query(`UPDATE library SET bookmarked = false WHERE id = $1 AND user_id = $2 AND kind IN ('couplet', 'phrase') AND note IS NOT NULL AND bookmarked`, [id, u.id]);
const r = kept.rowCount ? kept : await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [id, u.id]);
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
});
}

View File

@ -239,7 +239,9 @@ CREATE INDEX IF NOT EXISTS ebooks_search ON ebooks USING gin (search_text gin_tr
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '<sha256>.jpg|png|webp' in the file store
-- library: bookmarked pages of e-books (kind 'page': ebook_id, page)
ALTER TABLE library ADD COLUMN IF NOT EXISTS ebook_id integer;
ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer; -- kinds: see library_kind_check above
ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer;
-- a couplet or phrase can hold a note without being bookmarked (bookmarked false: a note only)
ALTER TABLE library ADD COLUMN IF NOT EXISTS bookmarked boolean NOT NULL DEFAULT true; -- kinds: see library_kind_check above
-- e-book details (owner request): language, reading direction, the writer's name as printed, co-authors (names;
-- coauthor_ids = those who are poets on the site, so the book is listed on their pages too). Extra tags are
-- entity_tags with entity 'ebook'.

View File

@ -1,9 +1,10 @@
---
// tabs of the reader's own space: saved texts, favourite writers, the word book (kept separate), profile
const { current } = Astro.props as { current: 'bookmarks' | 'writers' | 'words' };
const { current } = Astro.props as { current: 'bookmarks' | 'notes' | 'writers' | 'words' };
---
<nav class="admin-nav">
<a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}><span class="bm-icon on" aria-hidden="true"></span> محفوظ شدہ</a>
<a href="/notes" aria-current={current === 'notes' ? 'page' : undefined}>نوٹس</a>
<a href="/writers" aria-current={current === 'writers' ? 'page' : undefined}>پسندیدہ لکھاری</a>
<a href="/words" aria-current={current === 'words' ? 'page' : undefined}>ذخیرۂ الفاظ</a>
<a href="/account">پروفائل</a>

View File

@ -1,6 +1,10 @@
---
// 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 noteOf = (d?: string) => (d ? (JSON.parse(d).note as string | undefined) : undefined);
const noteOnly = (d?: string) => !!d && !!JSON.parse(d).noteOnly; // a phrase with a note but no bookmark
---
{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), 'note-only': noteOnly(d) }]} data-phrase={d} role="button" tabindex="0"
title={noteOf(d) ? `نوٹ: ${noteOf(d)}` : 'محفوظ شدہ عبارت: نوٹ لکھیں یا ہٹائیں'}>{t}</span> : t))}

View File

@ -69,6 +69,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
{Astro.locals.user?.role === 'admin' && <a class="icon-btn" href="/admin" title="ایڈمن" aria-label="ایڈمن"><Icon name="admin" /></a>}
{['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="icon-btn" href="/mod" title="موڈریشن" aria-label="موڈریشن"><Icon name="edit" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/library" title="محفوظ شدہ" aria-label="محفوظ شدہ"><Icon name="bookmark" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/notes" title="میرے نوٹس" aria-label="میرے نوٹس"><Icon name="note" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/words" title="ذخیرۂ الفاظ" aria-label="ذخیرۂ الفاظ"><span class="glyph-icon"><Icon name="words" /><span aria-hidden="true">ع</span></span></a>}
{Astro.locals.user
? <a class="icon-btn" href="/account" title={`اکاؤنٹ: ${Astro.locals.user.full_name || Astro.locals.user.email}`} aria-label="اکاؤنٹ"><Icon name="user" /></a>
@ -110,6 +111,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="saveword" class="plus" 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="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
<button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button>
@ -195,6 +197,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
',':'،', '.':'۔', '?':'؟', ';':'؛',
};
const q = document.getElementById('q'), kb = document.getElementById('kb'), kbBtn = document.getElementById('kb-toggle'), romBtn = document.getElementById('rom-toggle');
const kbHome = [kb.parentElement, kb.nextSibling]; // the keyboard's place in the header
let shift = false;
const key = (ch) => ch ? `<button type="button" data-ch="${ch}">${ch}</button>` : '';
const render = () => {
@ -241,9 +244,17 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return;
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
// in an open dialog (the note box) the header can't be reached: the keyboard moves under the box, and back when it closes
const box = b.dataset.urduKb && target.closest('dialog');
if (box) target.closest('.in-box-field')?.after(kb); else if (b.dataset.urduKb) kbHome[0].insertBefore(kb, kbHome[1]);
(b.dataset.urduKb ? kbBtn : romBtn).click();
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
});
document.addEventListener('close', (ev) => { // a dialog closing takes nothing of the header's with it
if (!ev.target.contains?.(kb)) return;
kb.hidden = true; kbBtn.setAttribute('aria-pressed', 'false'); kbHome[0].insertBefore(kb, kbHome[1]);
ev.target.querySelectorAll('[data-urdu-kb]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
}, true);
document.addEventListener('keydown', (ev) => {
if (!ev.target.matches?.('[data-urdu]')) return;
if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
@ -277,7 +288,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
document.addEventListener('click', async (e) => {
const b = e.target.closest('[data-save]'); if (!b) return;
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);
if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page
else if (d) {
@ -441,7 +452,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
const block = sel.anchorNode.parentElement?.closest('[data-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=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.hidden = false;
// below the selection, centred, kept inside the viewport (absolute: moves with the page)
@ -464,6 +475,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
case 'meaning': showWord(oneWord(selText)); break;
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
.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 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
@ -485,5 +497,46 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
});
</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>
</html>

View File

@ -53,15 +53,21 @@ const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
const categoryId = page.type === 'category' ? page.category.id : undefined;
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data
: { poet: false, category: false, poem: false, couplets: [], phrases: [] };
: { poet: false, category: false, poem: false, couplets: [], phrases: [], notes: [] };
// the reader's notes on this work: on a couplet (phrase null) or on a bookmarked phrase in it
const notes: { couplet: number; phrase: string | null; note: string }[] = saved.notes ?? [];
const noteOf = (c: number) => notes.find((n) => n.couplet === c && !n.phrase)?.note;
// on contents pages: which works are bookmarked or hold bookmarks, and which books/chapters do
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
// the marked part of each line of a block, for its bookmarked phrases
const lineMarks = (c: number, lines: string[]) => {
const out: Mark[][] = lines.map(() => []);
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 noteOnly = notes.filter((n) => n.couplet === c && n.phrase && !saved.phrases.some((p: any) => p.couplet === c && p.phrase === n.phrase))
.map((n) => ({ couplet: c, phrase: n.phrase, noteOnly: true }));
for (const p of [...saved.phrases.filter((x: any) => x.couplet === c), ...noteOnly]) {
const note = notes.find((n) => n.couplet === c && n.phrase === p.phrase)?.note;
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase, note, noteOnly: p.noteOnly });
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
}
return out;
@ -142,14 +148,37 @@ import Icon from '../components/Icon.astro';
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
{blocks.map((b) => {
const 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 = <span class="c-tools">
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />
<button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
<button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" />
<button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" />
</span>;
const lm = lineMarks(b.c, b.lines);
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
})}
</article>
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p>
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے؛ کسی عبارت پر نوٹ بھی لکھ سکتے ہیں۔</p>
<dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
<form method="dialog">
<p id="note-for" class="note-for"></p>
<div class="in-box-field note-field">
<textarea id="note-text" name="note" rows="4" maxlength="1000" dir="auto" placeholder="آپ کا نوٹ، کسی بھی زبان میں…" aria-label="نوٹ" data-urdu></textarea>
<button type="button" class="in-box rom" data-urdu-rom="note-text" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
<button type="button" class="in-box" data-urdu-kb="note-text" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button>
</div>
<ul class="note-others" hidden></ul>
<div class="ask-actions">
<button class="btn primary" value="save">محفوظ کریں</button><button class="btn" value="">بند کریں</button>
<button class="btn danger note-clear" value="clear">نوٹ ہٹائیں</button><button class="btn note-unmark" value="unmark">ہائی لائٹ ہٹائیں</button>
</div>
</form>
</dialog>
<script type="application/json" id="notes-data" set:html={JSON.stringify({ poemId, notes, phrases: saved.phrases, signedIn: !!token })} />
<nav class="poem-nav">
{page.prev && <a class="icon-btn" href={page.prev} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
{page.next && <a class="icon-btn" href={page.next} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
@ -168,3 +197,111 @@ import Icon from '../components/Icon.astro';
)}
{re && <script is:inline>document.querySelector('.poem mark')?.scrollIntoView({ block: 'center' });</script>}
</Base>
<script>
import nq700 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-700-normal.woff2?url';
import nq400 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-400-normal.woff2?url';
let cardFonts: Promise<unknown> | null = null;
// notes on couplets and phrases: the glyph beside a couplet's bookmark, a highlighted phrase, or نوٹ in the selection menu
// open one box; a note never bookmarks (bookmarks are the bookmark button's)
const data = document.getElementById('notes-data');
if (data) {
const { poemId, notes, phrases, signedIn } = JSON.parse(data.textContent!) as { poemId: number; notes: { couplet: number; phrase: string | null; note: string }[]; phrases: { couplet: number; phrase: string }[]; signedIn: boolean };
const box = document.getElementById('note-box') as HTMLDialogElement, text = box.querySelector('textarea')!;
const others = box.querySelector('.note-others') as HTMLUListElement;
let at: { couplet: number; phrase: string | null } = { couplet: 0, phrase: null };
const post = (body: object) => fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
.then((r) => (r.status === 401 ? (location.href = '/signin?next=' + encodeURIComponent(location.pathname), null) : r.ok ? r.json() : null));
const linesOf = (c: number) => [...document.querySelectorAll(`[data-c="${c}"] > p:not(.tag-chips), p[data-c="${c}"]`)].map((p) => p.textContent!.replace(/\s+/g, ' ').trim());
const lineOf = (c: number) => linesOf(c).join(' / ');
const open = (couplet: number, phrase: string | null) => {
if (!signedIn) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return; }
at = { couplet, phrase };
const mine = notes.find((n) => n.couplet === couplet && n.phrase === phrase);
box.querySelector('#note-for')!.textContent = phrase ? `«${phrase}»` : lineOf(couplet);
text.value = mine?.note ?? '';
(box.querySelector('.note-clear') as HTMLElement).hidden = !mine;
(box.querySelector('.note-unmark') as HTMLElement).hidden = !phrases.some((p) => p.couplet === couplet && p.phrase === phrase); // only a highlighted phrase
// a couplet's box also lists the notes on phrases in it; pressing one opens that note
const inIt = phrase ? [] : notes.filter((n) => n.couplet === couplet && n.phrase);
others.hidden = !inIt.length;
others.replaceChildren(...inIt.map((n) => { const li = document.createElement('li'); const b = document.createElement('button'); b.type = 'button';
b.textContent = `«${n.phrase}»: ${n.note}`; b.onclick = () => { box.close(); open(couplet, n.phrase); }; li.append(b); return li; }));
box.showModal(); text.focus();
};
const mark = (couplet: number) => { // the glyph follows the notes on this couplet
const nt = document.querySelector(`.nt[data-for="${couplet}"]`); if (!nt) return;
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'); // the bookmark is separate
};
box.addEventListener('close', async () => {
const act = box.returnValue, { couplet, phrase } = at;
if (!act) return;
if (act === 'unmark') { if (await post({ kind: 'phrase', poemId, couplet, phrase })) location.reload(); return; }
const note = act === 'clear' ? '' : text.value.trim();
const d = await post({ note, poemId, couplet, ...(phrase ? { phrase } : {}) });
if (!d) return (window as any).toast('نوٹ محفوظ نہیں ہو سکا', 'error');
if (phrase) { location.reload(); return; } // a phrase's highlight and note glyph are drawn by the page
const i = notes.findIndex((n) => n.couplet === couplet && !n.phrase);
if (i >= 0) notes.splice(i, 1);
if (d.note) notes.push({ couplet, phrase: null, note: d.note });
mark(couplet); (window as any).toast(d.note ? 'نوٹ محفوظ ہو گیا' : 'نوٹ ہٹا دیا');
});
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
const nt = t.closest('.nt') as HTMLElement | null; if (nt) return open(Number(nt.dataset.for), null);
const ph = t.closest('.bmk[data-phrase]') as HTMLElement | null;
if (ph && !getSelection()?.toString().trim()) { const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); }
});
document.addEventListener('keydown', (e) => { const ph = (e.target as HTMLElement).closest?.('.bmk[data-phrase]') as HTMLElement | null;
if (ph && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); } });
// share a couplet as an image: a square card (frame, logo, the lines, the poet), drawn here and handed to the share sheet
const share = async (c: number, download = false) => {
const [poet, title] = (document.querySelector('[data-cite]') as HTMLElement).dataset.cite!.split('، ');
const prose = !!document.querySelector(`p.para[data-c="${c}"]`);
const lines = linesOf(c), S = 1080, cv = document.createElement('canvas'); cv.width = cv.height = S;
const g = cv.getContext('2d')!; // the card is always on paper, whatever the page's palette
// the card's own Nastaliq faces, loaded here: the page may not have used bold Nastaliq yet, and a fallback font is
// wider (lines would wrap) and not Nastaliq
const NQ = '"Divan Card"';
if (!cardFonts) cardFonts = Promise.all([[nq700, '700'], [nq400, '400']].map(([url, weight]) => new FontFace('Divan Card', `url(${url})`, { weight }).load()
.then((f) => { document.fonts.add(f); })));
await cardFonts;
g.fillStyle = '#fbf6ea'; g.fillRect(0, 0, S, S);
g.strokeStyle = '#b88828'; g.lineWidth = 18; g.strokeRect(9, 9, S - 18, S - 18); g.lineWidth = 3; g.strokeRect(40, 40, S - 80, S - 80);
const logo = new Image(); logo.src = '/logo/turanj.svg'; await logo.decode().catch(() => {});
if (logo.complete) g.drawImage(logo, S / 2 - 70, 80, 140, 140);
g.direction = 'rtl'; g.textAlign = 'center'; g.textBaseline = 'middle';
// the lines: one per misra; prose wraps; the size shrinks until everything fits the card
let size = 72, rows: string[] = [];
const wrap = (t: string, w: number) => { const out: string[] = []; let row = '';
for (const word of t.split(' ')) { const next = row ? row + ' ' + word : word; if (g.measureText(next).width > w && row) { out.push(row); row = word; } else row = next; }
return row ? [...out, row] : out; };
for (; size >= (prose ? 30 : 22); size -= 2) { // verse shrinks until each misra fits on one line
g.font = `700 ${size}px ${NQ}`;
rows = lines.flatMap((l) => wrap(l, S - 200));
// verse keeps each misra on one line (prose wraps); Nastaliq is tall: lines 2.6 sizes apart leave room for the ✤ between misras
if ((prose || rows.length === lines.length) && rows.length * size * 2.6 <= 560) break;
}
const lh = size * 2.6, top = 250 + (560 - rows.length * lh) / 2;
g.fillStyle = '#17130f'; rows.forEach((r, i) => g.fillText(r, S / 2, top + lh * (i + .5)));
// the couplet line (as on the home page) between the verse and the poet: two tapering half-lines and a ✤
const y = S - 260, half = (from: number, to: number) => { g.beginPath(); g.moveTo(from, y); g.quadraticCurveTo((from + to) / 2, y - 6, to, y - 4); g.lineTo(to, y + 4); g.quadraticCurveTo((from + to) / 2, y + 6, from, y); g.fill(); };
g.fillStyle = '#1f4e79'; half(S / 2 - 30, S / 2 - 230); half(S / 2 + 30, S / 2 + 230);
g.fillStyle = '#bf3f2a'; g.font = '700 34px "Noto Naskh Arabic"'; g.fillText('✤', S / 2, y + 7);
g.fillStyle = '#bf3f2a'; g.font = `700 40px ${NQ}`; g.fillText(poet, S / 2, S - 190);
g.fillStyle = '#635543'; g.font = `400 30px ${NQ}`; g.fillText(`${title} · دیوان`, S / 2, S - 115);
const blob: Blob = await new Promise((done) => cv.toBlob((b) => done(b!), 'image/png'));
const file = new File([blob], `divan-${poemId}-${c + 1}.png`, { type: 'image/png' });
const url = `${location.origin}${location.pathname}#c${c}`;
if (download) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); (window as any).toast('تصویر ڈاؤن لوڈ ہو گئی'); return; }
// the share menu: with the picture where the browser can share files, otherwise the couplet and its link; never a download
const text = `${lines.join('\n')}\n— ${poet}`;
if (navigator.canShare?.({ files: [file] })) await navigator.share({ files: [file], text: url }).catch(() => {});
else if (navigator.share) await navigator.share({ title: document.title, text, url }).catch(() => {});
else { try { await navigator.clipboard.writeText(`${text}\n${url}`); (window as any).toast('یہ براؤزر شیئر نہیں کر سکتا: شعر اور لنک کاپی ہو گئے'); } catch { (window as any).toast('شیئر نہیں ہو سکا', 'error'); } }
};
document.addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('.sh, .dl') as HTMLElement | null; if (b) share(Number(b.dataset.for), b.classList.contains('dl')); });
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
}
</script>

View File

@ -5,7 +5,7 @@ import Icon from '../../components/Icon.astro';
import AdminNav from '../../components/AdminNav.astro';
import { asUser, COOKIE } from '../../lib/auth';
import { ud } from '../../lib/urdu';
import { ROLE, CONTENT, ACTION as DO } from '../../lib/roles';
import { ROLE, CONTENT, ACTION as DO, ago } from '../../lib/roles';
const me = Astro.locals.user;
if (!me) return Astro.redirect('/signin?next=/admin/audit');
@ -18,25 +18,28 @@ const ACTION: Record<string, string> = {
grant: 'اجازت دی', revoke: 'اجازت واپس لی',
};
const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' '));
const TONE: Record<string, string> = { grant: 'ok', enable: 'ok', role: 'action', promote: 'action', 'password-reset': 'accent', disable: 'danger', delete: 'danger', revoke: 'danger' };
const detail = (e: any) => e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '')
: e.detail?.content ? [e.detail.target, e.detail.content.map((c: string) => CONTENT[c]).join('، '), e.detail.actions.map((a: string) => DO[a]).join('، ')].filter(Boolean).join(' · ') : '';
const kinds = [...new Set(log.entries.map((e: any) => e.action as string))];
---
<Base title="ایڈمن: ریکارڈ">
<h1>ایڈمن</h1>
<AdminNav current="audit" />
<table class="admin-table">
<thead><tr><th>وقت (UTC)</th><th>ایڈمن</th><th>عمل</th><th>صارف</th><th>تفصیل</th></tr></thead>
<tbody>
{log.entries.map((e: any) => (
<tr>
<td>{when(e.at)}</td>
<td><bdi dir="ltr">{e.actor_email}</bdi></td>
<td>{ACTION[e.action] ?? e.action}</td>
<td><bdi dir="ltr">{e.target_email ?? '—'}</bdi></td>
<td>{e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '')
: e.detail?.content ? `${e.detail.target ?? ''} · ${e.detail.content.map((c: string) => CONTENT[c]).join('، ')} · ${e.detail.actions.map((a: string) => DO[a]).join('، ')}` : ''}</td>
</tr>
))}
</tbody>
</table>
{kinds.length > 1 && (
<div class="chips role-filter audit-filter" role="group" aria-label="عمل سے چھانٹیں">
{kinds.map((k) => <label class="chip"><input type="checkbox" value={k} checked /> {ACTION[k] ?? k}</label>)}
</div>
)}
<ol class="timeline">
{log.entries.map((e: any) => (
<li data-tone={TONE[e.action] ?? 'muted'} data-action={e.action}>
<header><bdi dir="ltr" class="who">{e.actor_email}</bdi><time class="muted" datetime={e.at} title={when(e.at)}>{ago(e.at)}</time></header>
<p><strong>{ACTION[e.action] ?? e.action}</strong>{e.target_email && <>: <bdi dir="ltr" class="target">{e.target_email}</bdi></>}</p>
{detail(e) && <p class="muted small-line">{detail(e)}</p>}
</li>
))}
</ol>
{pages > 1 && (
<nav class="pager">
{page > 1 && <a class="icon-btn" href={`/admin/audit?page=${page - 1}`} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
@ -44,4 +47,11 @@ const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T
{page < pages && <a class="icon-btn" href={`/admin/audit?page=${page + 1}`} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
</nav>
)}
<script>
// the action chips show and hide entries on this page
document.querySelector('.audit-filter')?.addEventListener('change', (e) => {
const on = new Set([...document.querySelectorAll<HTMLInputElement>('.audit-filter input:checked')].map((i) => i.value));
document.querySelectorAll<HTMLElement>('.timeline li').forEach((li) => (li.hidden = !on.has(li.dataset.action!)));
});
</script>
</Base>

View File

@ -70,7 +70,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
<section class="panel">
<h2>کردار</h2>
{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" />
{Object.entries(ROLE).map(([k, v]) => (
<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="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>
<form method="post" onsubmit="return confirm('یہ اجازت واپس لیں؟')">
<form method="post" data-confirm="یہ اجازت واپس لیں؟" data-danger data-ok="واپس لیں">
<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>
</form>
@ -129,7 +129,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
<section class="panel">
<h2>اکاؤنٹ</h2>
<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>
</form>
{!self && (
@ -141,7 +141,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
)}
</div>
{!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>
<button class="btn danger" name="act" value="delete"><Icon name="trash" size={16} /> اکاؤنٹ حذف کریں</button>
</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);
const token = cookies.get(COOKIE)?.value;
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);
};

View File

@ -5,6 +5,8 @@ import ModNav from '../../components/ModNav.astro';
import { asUser, COOKIE } from '../../lib/auth';
import { STATUS, EVENT, when } from '../../lib/mod';
import { ud } from '../../lib/urdu';
import { ago } from '../../lib/roles';
import Icon from '../../components/Icon.astro';
const me = Astro.locals.user;
if (!me) return Astro.redirect('/signin?next=/mod');
@ -14,6 +16,9 @@ const queue = showLog ? null : (await asUser(token, '/api/mod/queue')).data;
const log = showLog ? (await asUser(token, '/api/mod/log')).data : null;
// what kind of change a row is (a work's text needs no label)
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ' };
// each kind of change has its icon; each event its tone on the timeline
const ICON: Record<string, any> = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload' };
const TONE: Record<string, string> = { published: 'ok', approved: 'action', submitted: 'accent', returned: 'danger', rejected: 'danger' };
const groups = queue ? [
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
...(me.role !== 'mod-l2' ? [['جائزے کے منتظر', queue.review, 'جائزے کے لیے کچھ نہیں۔']] : []),
@ -24,36 +29,39 @@ const groups = queue ? [
<h1>موڈریشن</h1>
<ModNav current={showLog ? 'log' : 'queue'} />
{Astro.url.searchParams.has('unchanged') && <p class="form-done" role="status">متن میں کوئی تبدیلی نہیں تھی، اس لیے مسودہ نہیں رکھا گیا۔</p>}
{groups.map(([title, items, empty]) => (
<section class="lib-section">
{queue && (
<nav class="stat-tiles" aria-label="خلاصہ">
{groups.map(([title, items], i) => <a class="stat" href={`#q${i}`}><strong>{ud(items.length)}</strong><span>{title}</span></a>)}
</nav>
)}
{groups.map(([title, items, empty], i) => (
<section class="queue" id={`q${i}`}>
<h2>{title} <small class="muted">{ud(items.length)}</small></h2>
{items.length === 0 ? <p class="muted center">{empty}</p> : (
<ul class="lib-list">
{items.length === 0 ? <p class="empty"><span aria-hidden="true">✤</span>{empty}</p> : (
<ul class="queue-list">
{items.map((r) => (
<li>
<a class="lib-title" href={`/mod/rev/${r.id}`}>{KIND[r.entity] ?? ''}{r.title}</a>
<li><a class="queue-card" href={`/mod/rev/${r.id}`}>
<span class="kind" aria-hidden="true"><Icon name={ICON[r.entity] ?? 'edit'} size={18} /></span>
<span class="what"><strong>{KIND[r.entity] ?? ''}{r.title}</strong>
<small class="muted">{r.summary || 'بغیر خلاصہ'} · <bdi dir="ltr">{r.author_email}</bdi></small></span>
<span class={`status s-${r.status}`}>{STATUS[r.status]}</span>
<p class="muted small-line">{r.summary || 'بغیر خلاصہ'} · <bdi dir="ltr">{r.author_email}</bdi> · <bdi dir="ltr">{when(r.updated_at)}</bdi></p>
</li>
<span class="seen muted" title={when(r.updated_at)}>{ago(r.updated_at)}</span>
</a></li>
))}
</ul>
)}
</section>
))}
{log && (
<table class="admin-table">
<thead><tr><th>وقت (UTC)</th><th>کس نے</th><th>کیا</th><th>کلام</th><th>تبصرہ</th></tr></thead>
<tbody>
{log.entries.map((e: any) => (
<tr>
<td><bdi dir="ltr">{when(e.at)}</bdi></td>
<td><bdi dir="ltr">{e.actor_email}</bdi></td>
<td>{EVENT[e.action] ?? e.action}{e.version && e.action === 'published' ? ` (ورژن ${ud(e.version)})` : ''}</td>
<td><a href={`/mod/rev/${e.revision}`}>{KIND[e.entity] ?? ''}{e.title}</a></td>
<td>{e.comment}</td>
</tr>
))}
</tbody>
</table>
<ol class="timeline">
{log.entries.map((e: any) => (
<li data-tone={TONE[e.action] ?? 'muted'}>
<header><bdi dir="ltr" class="who">{e.actor_email}</bdi><time class="muted" datetime={e.at} title={when(e.at)}>{ago(e.at)}</time></header>
<p><strong>{EVENT[e.action] ?? e.action}{e.version && e.action === 'published' ? ` (ورژن ${ud(e.version)})` : ''}</strong>:
<a href={`/mod/rev/${e.revision}`}>{KIND[e.entity] ?? ''}{e.title}</a></p>
{e.comment && <blockquote>{e.comment}</blockquote>}
</li>
))}
</ol>
)}
</Base>

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, 'ہ');
// 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 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;
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;
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)
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]}`;
head.append(keyed(title, `site-title-${i}`, 'site-title'), sort, kind,
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);
if (s.century === null) box.append(poetList(s, i));
return box;

64
web/src/pages/notes.astro Normal file
View File

@ -0,0 +1,64 @@
---
// نوٹس: the reader's notes on couplets and on phrases in them, newest first, each with where it is and the couplet
// (the phrase marked). A note is separate from a bookmark: deleting a note never removes a bookmark.
import Base from '../layouts/Base.astro';
import Icon from '../components/Icon.astro';
import LibraryNav from '../components/LibraryNav.astro';
import { asUser, COOKIE } from '../lib/auth';
import { ud } from '../lib/urdu';
import { ago } from '../lib/roles';
import { phraseParts, phraseRe } from '../lib/phrases';
const me = Astro.locals.user;
if (!me) return Astro.redirect('/signin?next=/notes');
const token = Astro.cookies.get(COOKIE)!.value;
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData(), id = Number(f.get('id')) || 0;
const r = await asUser(token, `/api/library/${id}/note`, { note: f.get('act') === 'delete' ? '' : f.get('note') });
return Astro.redirect(r.ok ? `/notes?done=${f.get('act')}${f.get('act') === 'save' ? `#n${id}` : ''}` : '/notes', 303);
}
const notes: any[] = (await asUser(token, '/api/library')).data.notes ?? [];
const done = Astro.url.searchParams.get('done');
// the noted phrase inside its couplet: [before, phrase, after] per line
const marked = (lines: string[], phrase: string | null) => !phrase ? lines.map((l) => [l, '', '']) : phraseParts(lines, phrase).map((part, i) => {
if (!part) return [lines[i], '', ''];
const m = lines[i].match(phraseRe(part))!;
return [lines[i].slice(0, m.index), m[0], lines[i].slice(m.index! + m[0].length)];
});
---
<Base title="نوٹس">
<h1>{me.full_name || 'نوٹس'}</h1>
<LibraryNav current="notes" />
{done && <p class="form-done" role="status">{done === 'delete' ? 'نوٹ ہٹا دیا' : 'نوٹ محفوظ ہو گیا'}</p>}
<p class="muted center">{ud(notes.length)} نوٹس</p>
{notes.length === 0 && <p class="empty"><span aria-hidden="true">✤</span>ابھی کوئی نوٹ نہیں۔ کلام پڑھتے ہوئے کسی شعر کے ساتھ والا نوٹ کا نشان دبائیں، یا کوئی عبارت منتخب کر کے "نوٹ" چنیں۔</p>}
<ul class="note-list">
{notes.map((n) => (
<li id={`n${n.id}`} class="note-card">
{n.poem ? <>
<header>
<nav class="crumbs">{[...n.poem.path, { title: n.poem.title, url: n.poem.url }].map((x: any) => <span><a href={x.url}>{x.title}</a></span>)}</nav>
{n.bookmarked && <span class="badge plain" title="یہ شعر محفوظ شدہ میں بھی ہے">محفوظ شدہ</span>}
</header>
<a class="lib-couplet" href={`${n.poem.url}#c${n.poem.couplet}`}>{marked(n.lines, n.phrase).map(([a, m, z]) => <p>{a}{m && <mark>{m}</mark>}{z}</p>)}</a>
</> : <span class="muted">یہ شعر اب دستیاب نہیں</span>}
<form method="post" class="note-edit">
<input type="hidden" name="id" value={n.id} />
<div class="in-box-field note-field">
<textarea id={`note-${n.id}`} name="note" rows="2" maxlength="1000" dir="auto" aria-label="نوٹ" data-urdu required>{n.note}</textarea>
<button type="button" class="in-box rom" data-urdu-rom={`note-${n.id}`} aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ">اب</button>
<button type="button" class="in-box" data-urdu-kb={`note-${n.id}`} aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button>
</div>
<div class="note-actions">
<small class="muted" title={new Date(n.saved_at).toISOString().slice(0, 16).replace('T', ' ')}>{ago(n.saved_at)}</small>
<button class="btn" name="act" value="save"><Icon name="check" size={16} /> محفوظ کریں</button>
</div>
</form>
<form method="post" class="note-delete" data-confirm="یہ نوٹ ہٹائیں؟ شعر محفوظ شدہ میں ہو تو وہیں رہے گا۔" data-danger data-ok="ہٹائیں">
<input type="hidden" name="id" value={n.id} />
<button class="icon-btn small danger" name="act" value="delete" title="نوٹ ہٹائیں" aria-label="نوٹ ہٹائیں"><Icon name="trash" size={16} /></button>
</form>
</li>
))}
</ul>
</Base>

View File

@ -644,3 +644,164 @@ h1 + .muted { text-align: center; margin-top: 0; }
.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 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.note-only { background: none; text-decoration-color: var(--accent); } /* a note without a bookmark: underline only */
.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-field .in-box { top: 20px; } /* at the first line of the box, not its middle */
.note-field textarea { padding-left: 80px; } /* the buttons sit on the left whatever the note's language (the box follows what is typed) */
.note-box .kb { padding: 0 0 10px; }
.note-box .kb button { min-width: 2em; font-size: 1rem; }
.note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); }
.note-others button { all: unset; display: block; cursor: pointer; font-size: var(--fs-sm); color: var(--text-muted); padding: 2px 0; }
.note-others button:hover { color: var(--active); }
.ask-actions { flex-wrap: wrap; }
.note-box .note-clear { margin-inline-start: auto; }
/* share a couplet as an image: under the note glyph, shown on hover like the bookmark */
.sh { position: absolute; inset-inline-end: -26px; top: calc(.5em + 52px); width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer; opacity: 0; transition: opacity .15s; }
.sh::before { content: ''; display: block; width: 16px; height: 16px; margin: auto; background-color: var(--text-muted); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='m16 6-4-4-4 4'/%3E%3Cpath d='M12 2v13'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='m16 6-4-4-4 4'/%3E%3Cpath d='M12 2v13'/%3E%3C/svg%3E") center / contain no-repeat; }
.sh:hover::before { background-color: var(--active); }
.couplet:hover .sh, .poem .para:hover .sh, .poem .single:hover .sh, .sh:focus-visible { opacity: 1; }
@media (hover: none) { .sh { opacity: .55; } }
@media (max-width: 700px) { .sh { inset-inline-end: -4px; top: 52px; } }
/* moderation dashboard: count tiles that jump to their queue; queue items as cards */
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; max-width: 760px; margin: 0 auto 18px; }
.stat { display: flex; flex-direction: column; align-items: center; padding: 12px 10px 8px; border: 1.5px solid var(--border); border-radius: var(--r-lg);
background: var(--surface-raised); color: var(--text); transition: transform .2s, border-color .2s; }
.stat:hover { transform: translateY(-2px); border-color: var(--active); color: var(--text); }
.stat strong { font: 700 2.2rem/1.4 var(--font-ui); color: var(--action); }
.stat span { font-size: var(--fs-sm); color: var(--text-muted); }
.queue { max-width: 760px; margin: 0 auto 22px; scroll-margin-top: 16px; }
.queue h2 { text-align: start; font-size: var(--fs-lg); }
.queue h2::after { display: none; }
.queue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.queue-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--r-md);
background: var(--surface-raised); color: var(--text); transition: transform .2s, border-color .2s; }
.queue-card:hover { transform: translateY(-1px); border-color: var(--active); color: var(--text); }
.queue-card .kind { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); }
.queue-card .what { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.6; }
.queue-card .what small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-xs); }
@media (max-width: 560px) { .queue-card { flex-wrap: wrap; } .queue-card .status { margin-inline-start: 46px; } }
/* an empty queue or list: a quiet line with the ✤ */
.empty { text-align: center; color: var(--text-muted); padding: 14px; border: 1px dashed var(--line); border-radius: var(--r-md); }
.empty span { display: block; color: var(--brand); opacity: .6; font-size: 1.3rem; }
/* timelines (moderation activity, admin record): a dot per entry, coloured by what happened */
.timeline { list-style: none; max-width: 760px; margin: 8px auto; padding: 0; padding-inline-start: 22px; border-inline-start: 2px solid var(--line); }
.timeline li { position: relative; margin: 0 0 12px; padding: 8px 14px; border-radius: var(--r-md); background: var(--surface-raised); border: 1px solid var(--line); }
.timeline li::before { content: ''; position: absolute; inset-inline-start: -30px; top: 16px; width: 12px; height: 12px; border-radius: 50%;
background: var(--tone); box-shadow: 0 0 0 3px var(--surface-page); }
.timeline li p { margin: 0; line-height: 1.8; }
.timeline .who { font: var(--fs-sm) var(--font-latin); color: var(--text-muted); }
.timeline header { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.timeline time { font-size: var(--fs-xs); white-space: nowrap; }
.timeline .target { font-family: var(--font-latin); }
.timeline blockquote { margin: 4px 0; padding-inline-start: 10px; border-inline-start: 2px solid var(--tone); color: var(--text-muted); font-size: var(--fs-sm); }
[data-tone] { --tone: var(--text-muted); }
[data-tone='ok'] { --tone: var(--ok); } [data-tone='action'] { --tone: var(--action); } [data-tone='accent'] { --tone: var(--accent); } [data-tone='danger'] { --tone: var(--danger); }
/* a couplet's bookmark, note, share and download sit in one row at its left edge, on the bottom line (the ✤ line, where
there is never text): clear of the misras however long they are, the same on phones */
.c-tools { position: absolute; left: 0; bottom: -4px; z-index: 1; display: flex; align-items: center; gap: 4px; }
.c-tools > button { position: static; inset: auto; top: auto; }
.poem .para > .c-tools, .poem .single > .c-tools { bottom: -20px; }
.poem .para, .poem .single { margin-bottom: 26px; }
@media (max-width: 760px) { .c-tools { bottom: 2px; } .couplet { padding-bottom: 28px; } }
/* download the couplet's picture: beside share */
.dl { width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer; opacity: 0; transition: opacity .15s; }
.dl::before { content: ''; display: block; width: 16px; height: 16px; margin: auto; background-color: var(--text-muted); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E") center / contain no-repeat; }
.dl:hover::before { background-color: var(--active); }
.couplet:hover .dl, .poem .para:hover .dl, .poem .single:hover .dl, .dl:focus-visible { opacity: 1; }
@media (hover: none) { .dl { opacity: .55; } }
/* the notes page: one card per note, the couplet with the noted phrase marked, the note editable in place */
.note-list { list-style: none; padding: 0; max-width: 760px; margin: 12px auto; display: flex; flex-direction: column; gap: 12px; }
.note-card { position: relative; padding: 10px 16px 12px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface-raised); }
.note-card header { display: flex; align-items: center; gap: 8px; padding-inline-end: 40px; }
.note-card .crumbs { text-align: start; margin: 0; font-size: var(--fs-xs); flex: 1; }
.note-card .lib-couplet { margin: 6px 0 8px; }
.note-card mark { background: var(--accent-soft); box-shadow: inset 0 -2px var(--accent); }
.note-edit textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; background: var(--surface-page); }
.note-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; }
.note-delete { position: absolute; top: 10px; left: 12px; margin: 0; }