Compare commits

...

3 Commits

Author SHA1 Message Date
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
11 changed files with 422 additions and 58 deletions

View File

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

View File

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

View File

@ -1,6 +1,9 @@
---
// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?]
// 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);
---
{s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span>
: k === 'bmk' ? <span class="bmk" data-save={d} data-confirm="یہ عبارت محفوظ شدہ سے ہٹائیں؟" title="محفوظ شدہ عبارت (ہٹانے کے لیے دبائیں)">{t}</span> : t))}
: k === 'bmk' ? <span class:list={['bmk', { 'has-note': noteOf(d) }]} data-phrase={d} role="button" tabindex="0"
title={noteOf(d) ? `نوٹ: ${noteOf(d)}` : 'محفوظ شدہ عبارت: نوٹ لکھیں یا ہٹائیں'}>{t}</span> : t))}

View File

@ -110,6 +110,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
<button type="button" data-act="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>
@ -277,7 +278,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 +442,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 +465,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 +487,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,7 +53,10 @@ const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
const 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' : '');
@ -61,7 +64,8 @@ const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '')
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 note = notes.find((n) => n.couplet === c && n.phrase === p.phrase)?.note;
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase, note });
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
}
return out;
@ -142,14 +146,33 @@ 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 = <>
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں"
data-confirm={cn ? 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔' : undefined} />
<button type="button" class="sh" data-c={b.c} aria-label="تصویر بنا کر بھیجیں" title="تصویر بنا کر بھیجیں" />
<button type="button" class="nt" data-c={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
</>;
const lm = lineMarks(b.c, b.lines);
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>
<textarea name="note" rows="4" maxlength="1000" placeholder="آپ کا نوٹ…" aria-label="نوٹ" data-urdu></textarea>
<ul class="note-others" hidden></ul>
<div class="ask-actions">
<button class="btn primary" value="save">محفوظ کریں</button><button class="btn" value="">بند کریں</button>
<button class="btn danger note-clear" value="clear">نوٹ ہٹائیں</button><button class="btn note-unmark" value="unmark">ہائی لائٹ ہٹائیں</button>
</div>
</form>
</dialog>
<script type="application/json" id="notes-data" set:html={JSON.stringify({ poemId, notes, phrases: saved.phrases, signedIn: !!token })} />
<nav class="poem-nav">
{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 +191,101 @@ import Icon from '../components/Icon.astro';
)}
{re && <script is:inline>document.querySelector('.poem mark')?.scrollIntoView({ block: 'center' });</script>}
</Base>
<script>
// notes on couplets and phrases: the glyph beside a couplet's bookmark, a highlighted phrase, or نوٹ in the selection menu
// open one box; saving a note bookmarks the couplet or phrase (the API does both)
const data = document.getElementById('notes-data');
if (data) {
const { poemId, notes, phrases, signedIn } = JSON.parse(data.textContent!) as { poemId: number; notes: { couplet: number; phrase: string | null; note: string }[]; phrases: { couplet: number; phrase: string }[]; signedIn: boolean };
const box = document.getElementById('note-box') as HTMLDialogElement, text = box.querySelector('textarea')!;
const others = box.querySelector('.note-others') as HTMLUListElement;
let at: { couplet: number; phrase: string | null } = { couplet: 0, phrase: null };
const post = (body: object) => fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
.then((r) => (r.status === 401 ? (location.href = '/signin?next=' + encodeURIComponent(location.pathname), null) : r.ok ? r.json() : null));
const 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-c="${couplet}"]`); if (!nt) return;
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں');
const bm = document.querySelector(`[data-c="${couplet}"] > .bm, p[data-c="${couplet}"] > .bm`) as HTMLElement | null;
if (bm) { if (cn) { bm.setAttribute('aria-pressed', 'true'); bm.dataset.confirm = 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔'; } else delete bm.dataset.confirm; }
};
box.addEventListener('close', async () => {
const act = box.returnValue, { couplet, phrase } = at;
if (!act) return;
if (act === 'unmark') { if (await post({ kind: 'phrase', poemId, couplet, phrase })) location.reload(); return; }
const note = act === 'clear' ? '' : text.value.trim();
const d = await post({ note, poemId, couplet, ...(phrase ? { phrase } : {}) });
if (!d) return (window as any).toast('نوٹ محفوظ نہیں ہو سکا', 'error');
if (phrase) { location.reload(); return; } // a phrase's highlight and note glyph are drawn by the page
const i = notes.findIndex((n) => n.couplet === couplet && !n.phrase);
if (i >= 0) notes.splice(i, 1);
if (d.note) notes.push({ couplet, phrase: null, note: d.note });
mark(couplet); (window as any).toast(d.note ? 'نوٹ محفوظ ہو گیا' : 'نوٹ ہٹا دیا');
});
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
const nt = t.closest('.nt') as HTMLElement | null; if (nt) return open(Number(nt.dataset.c), null);
const ph = t.closest('.bmk[data-phrase]') as HTMLElement | null;
if (ph && !getSelection()?.toString().trim()) { const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); }
});
document.addEventListener('keydown', (e) => { const ph = (e.target as HTMLElement).closest?.('.bmk[data-phrase]') as HTMLElement | null;
if (ph && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); } });
// 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) => {
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
await Promise.all(['700 60px "Noto Nastaliq Urdu"', '400 34px "Noto Naskh Arabic"'].map((f) => document.fonts.load(f, lines.join(' '))));
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 >= 30; size -= 2) {
g.font = `700 ${size}px "Noto Nastaliq Urdu"`;
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 "Noto Nastaliq Urdu"'; g.fillText(poet, S / 2, S - 190);
g.fillStyle = '#635543'; g.font = '400 30px "Noto Naskh Arabic"'; g.fillText(`${title} · دیوان`, S / 2, S - 120);
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 (navigator.canShare?.({ files: [file] })) await navigator.share({ files: [file], text: url }).catch(() => {});
else { 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('تصویر ڈاؤن لوڈ ہو گئی'); }
};
document.addEventListener('click', (e) => { const sh = (e.target as HTMLElement).closest('.sh') as HTMLElement | null; if (sh) share(Number(sh.dataset.c)); });
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;

View File

@ -644,3 +644,135 @@ 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.has-note::after { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); }
.bmk:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* the note box */
.note-box { width: min(480px, 92vw); }
.note-for { font-size: var(--fs-md); color: var(--text-muted); max-height: 6.5em; overflow: auto; }
.note-box textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; margin-bottom: 10px; }
.note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); }
.note-others button { all: unset; display: block; cursor: pointer; font-size: var(--fs-sm); color: var(--text-muted); padding: 2px 0; }
.note-others button:hover { color: var(--active); }
.ask-actions { flex-wrap: wrap; }
.note-box .note-clear { margin-inline-start: auto; }
/* 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); }