Compare commits

...

8 Commits

Author SHA1 Message Date
Anas Rashid
9f401fe2af 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) 2026-10-09 17:32:19 +02:00
Anas Rashid
2efde6ee22 Notes apart from bookmarks, and a notes page
- A note never bookmarks: notes and bookmarks share a row, bookmarked false is a note only (db: library.bookmarked). Un-bookmarking keeps a note; clearing the note of a row that is not bookmarked removes it; ✕ on the saved page keeps a note
- A phrase with a note but no bookmark is underlined only, with the note glyph
- /notes: every note with where it is and its couplet (the noted phrase marked), editable in place with the Urdu keyboard, deleted after asking; from a note icon in the header and a نوٹس tab
- API tests for all of it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:32:16 +02:00
Anas Rashid
b0c7cbccf1 Couplet tools at the couplet's left edge (on the bottom line, clear of the text) instead of centred
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:27:52 +02:00
Anas Rashid
f122eb8f10 Couplet tools in one row; share and download apart; Urdu keyboard in the note box
- Bookmark, note, share and download sit in one row centred under each couplet on the ✤ line (bookmark and note on one side, share and download on the other), clear of the text, the same on phones
- Share always opens the share menu (the picture where the browser can share files, else the couplet and its link; with no share menu, both are copied); a new download button saves the picture
- The note box has the Urdu keyboard and Roman typing like the search box (the keyboard moves into the box while it is open); notes can be in any language (the box follows the typed direction)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:27:04 +02:00
Anas Rashid
bab7ffa37f Share card: always Nastaliq (the card loads its own Nastaliq faces, so a page that has not used bold Nastaliq no longer falls back to a wider font) and one line per misra (verse shrinks to fit; only prose wraps); the title line in Nastaliq too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:22:07 +02:00
Anas Rashid
86be5db6ea Moderation dashboard (count tiles that jump to each queue; queue items as cards with icon, status and age) and timelines for the moderation activity and the admin record (a coloured dot per kind of action, relative times, filter chips on the record)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:20:02 +02:00
Anas Rashid
8d0a6aa36b Share a couplet as an image: a share glyph under each couplet's note glyph draws a square card (frame, logo, the couplet in Nastaliq, the couplet line, the poet) and hands it to the share sheet, or downloads it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:17:38 +02:00
Anas Rashid
1485781fb7 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>
2026-10-09 17:14:59 +02:00
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; }