Merge pull request 'Moderation: versions of works and the L2 → L1 → admin pipeline' (#54) from feature/moderation-pipeline into main

Reviewed-on: #54
This commit is contained in:
anas 2026-10-08 22:34:50 +00:00
commit 5929201efa
15 changed files with 651 additions and 4 deletions

View File

@ -35,6 +35,8 @@ The import upserts, so re-running it after a divan-data sync applies the changes
**Accounts and admin.** Readers sign up with an email address and password (no email is sent). The first admin is made on the server: sign up on the site, then `npm run make-admin -- you@example.com` in `api/`. Admins manage users at `/admin` (search, password reset on a reader's request, disable, roles, delete) and see every admin action at `/admin/audit`. Moderators (L2 junior, L1 senior) get scoped permissions from admins: a scope (all poets, a poet, a book with everything in it, or one work), content types (poets, books, works, dictionary) and actions (create, edit, delete, arrange); `can()` in `api/src/permissions.ts` is the one check for moderation.
**Moderation.** Moderators open **ترمیم کریں** on a work they may edit, change its Divan text (see `docs/content-model.md`) with an edit summary and submit it. An L1 moderator covering that work approves, returns (with a reason) or rejects it; an admin publishes. L1 drafts go straight to the admin and an admin's own edits publish directly. Every step is recorded (`/mod`, the activity log, each work's history at `/mod/work/<id>`). Publishing numbers the version, writes it to divan-data's `divan/` folder (`DIVAN_DATA_DIR`, default `../divan-data`) and shows it on the site at once; a draft started before a newer version was published cannot be published.
## Daily content sync (server)
`deploy/sync.sh` keeps a server current: it updates a divan-data checkout, fetches new and edited works from Wikisource (incremental, about a minute), rebuilds the export and upserts it into PostgreSQL. The site shows new content immediately. Runs are locked so they never overlap.
@ -57,6 +59,7 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI
| `GET /api/search?q=&poet=1,2&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`); optionally only some poets/writers; plus `authors` (who the results come from, with counts), and on page 1 `poets` (by name) and `books` (books/chapters by title) |
| `GET /api/word?w=` | one word's meanings and pronunciation from the local Wiktionary data (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none) |
| `/api/auth/*` | accounts: sign-up, sign-in (returns a Bearer token), profile, password, delete |
| `/api/mod/*` | moderation: what a moderator may do, queue, drafts, save/submit/approve/return/reject/publish, a work's history, activity log |
| `/api/library/*` | the signed-in reader's library: toggle poets, works, couplets and words; list with full paths; notes |
| `GET /health` | database check |

24
api/src/diff.ts Normal file
View File

@ -0,0 +1,24 @@
// Line diff for reviewing revisions of Divan text: each line kept ('='), removed ('-') or added ('+').
// ponytail: longest-common-subsequence table, O(lines²); fine for a poem or a chapter (hundreds of lines),
// switch to Myers' algorithm if whole books are ever diffed at once.
export type DiffLine = { op: '=' | '-' | '+'; text: string };
export function diffLines(a: string, b: string): DiffLine[] {
const x = a.split('\n'), y = b.split('\n');
const n = x.length, m = y.length;
const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--) lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
const out: DiffLine[] = [];
let i = 0, j = 0;
while (i < n && j < m) {
if (x[i] === y[j]) { out.push({ op: '=', text: x[i] }); i++; j++; }
else if (lcs[i + 1][j] >= lcs[i][j + 1]) out.push({ op: '-', text: x[i++] });
else out.push({ op: '+', text: y[j++] });
}
while (i < n) out.push({ op: '-', text: x[i++] });
while (j < m) out.push({ op: '+', text: y[j++] });
return out;
}
export const changed = (d: DiffLine[]) => d.filter((l) => l.op !== '=').length;

115
api/src/moderation.test.ts Normal file
View File

@ -0,0 +1,115 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { adminRoutes } from './admin.ts';
import { permissionRoutes } from './permissions.ts';
import { moderationRoutes } from './moderation.ts';
import { diffLines } from './diff.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('line diff: kept, removed and added lines', () => {
assert.deepEqual(diffLines('ا\nب\nج', 'ا\nد\nج'), [{ op: '=', text: 'ا' }, { op: '-', text: 'ب' }, { op: '+', text: 'د' }, { op: '=', text: 'ج' }]);
});
test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permissions, conflicts, history', async () => {
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
process.env.DIVAN_DATA_DIR = data;
const app = Fastify();
authRoutes(app); adminRoutes(app); permissionRoutes(app); moderationRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: object, token?: string) =>
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `mod-${run}` } });
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const admin = await person('admin', 'admin'), l1 = await person('l1', 'mod-l1'), l2 = await person('l2', 'mod-l2');
const other = await person('l1other', 'mod-l1'), reader = await person('reader', 'reader');
// a Ghalib ghazal; the work is restored at the end
const poem = (await pool.query(`SELECT p.id, p.url, p.title FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal' ORDER BY p.position LIMIT 1`)).rows[0];
const before = (await pool.query('SELECT vorder, position, couplet, text FROM verses WHERE poem_id = $1 ORDER BY vorder', [poem.id])).rows;
const searchBefore = (await pool.query('SELECT search_text FROM poems WHERE id = $1', [poem.id])).rows[0].search_text;
try {
// grants: L2 and L1 on Ghalib's ghazals; the other L1 on Iqbal only
for (const [m, target] of [[l2, '/p266/ghazal'], [l1, '/p266/ghazal'], [other, '/p238']] as const)
await call('POST', `/api/admin/users/${m.id}/grants`, { scope: target === '/p238' ? 'poet' : 'category', target, content: ['works'], actions: ['edit'] }, admin.token);
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, reader.token)).statusCode, 403, 'readers cannot moderate');
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, other.token)).statusCode, 403, 'outside the grant');
assert.deepEqual((await call('GET', `/api/mod/can?poem=${poem.id}`, undefined, l2.token)).json(), { edit: true, review: false, publish: false });
// L2 drafts: the draft starts from the current text; reopening returns the same draft
const { id } = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json();
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id, id);
let rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, l2.token)).json();
assert.equal(rev.changes, 0);
const misra = before.find((v: any) => v.couplet === 1 && v.position === 'Left').text;
const edited = rev.revision.content.replace(misra, misra + ' (ترمیم)');
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'متن بغیر شعر کے {{', summary: 'x' }, l2.token)).statusCode, 200, 'prose is a paragraph');
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: edited, summary: 'ایک مصرع درست کیا' }, l2.token)).statusCode, 200);
rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, l2.token)).json();
assert.deepEqual(rev.diff.filter((d: any) => d.op !== '=').map((d: any) => d.op), ['-', '+'], 'one line changed');
assert.equal(rev.may.approve, false, 'not my own draft');
// L2 submits; the out-of-scope L1 cannot see it; L1 returns it, L2 resubmits, L1 approves
assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
assert.equal((await call('GET', `/api/mod/revisions/${id}`, undefined, other.token)).statusCode, 403);
assert.equal((await call('GET', '/api/mod/queue', undefined, l1.token)).json().review.some((r: any) => r.id === id), true);
assert.equal((await call('POST', `/api/mod/revisions/${id}/return`, {}, l1.token)).statusCode, 400, 'a reason is required');
assert.equal((await call('POST', `/api/mod/revisions/${id}/return`, { comment: 'وزن دیکھیں' }, l1.token)).json().status, 'returned');
assert.equal((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).statusCode, 403, 'not yet approved');
await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token);
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l2.token)).statusCode, 403, 'L2 cannot approve');
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, { comment: 'درست' }, l1.token)).json().status, 'approved');
assert.equal((await call('GET', '/api/mod/queue', undefined, admin.token)).json().publish.some((r: any) => r.id === id), true);
// admin publishes: version 1 in divan-data and on the site
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
const file = JSON.parse(await readFile(join(data, 'divan', poem.url.slice(1) + '.json'), 'utf8'));
assert.equal(file.Edited.by, `l2-${run}@divan.test`);
assert.equal(file.Edited.reviewedBy, `l1-${run}@divan.test`);
assert.equal(file.Edited.publishedBy, `admin-${run}@divan.test`);
assert.ok((await readFile(join(data, 'divan', poem.url.slice(1) + '.dtx'), 'utf8')).includes(misra + ' (ترمیم)'));
assert.equal((await pool.query('SELECT text FROM verses WHERE poem_id = $1 AND couplet = 1 AND position = $2', [poem.id, 'Left'])).rows[0].text, misra + ' (ترمیم)');
// the full event trail, in order
rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, admin.token)).json();
assert.deepEqual(rev.events.map((e: any) => e.action), ['created', 'saved', 'saved', 'submitted', 'returned', 'submitted', 'approved', 'published']);
assert.equal(rev.events.find((e: any) => e.action === 'returned').comment, 'وزن دیکھیں');
// conflict: an L1 draft started from version 1, but an admin publishes version 2 first
const d2 = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l1.token)).json().id;
const d3 = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, admin.token)).json().id;
const cur = (await call('GET', `/api/mod/work/${poem.id}`, undefined, admin.token)).json();
assert.equal(cur.version, 1);
await call('POST', `/api/mod/revisions/${d3}/save`, { content: cur.content.replace('(ترمیم)', '(ترمیم دوم)') }, admin.token);
assert.deepEqual((await call('POST', `/api/mod/revisions/${d3}/submit`, {}, admin.token)).json(), { status: 'published', version: 2 }, "an admin's own edit publishes");
await call('POST', `/api/mod/revisions/${d2}/save`, { content: cur.content.replace('(ترمیم)', '(ترمیم سوم)') }, l1.token);
assert.equal((await call('POST', `/api/mod/revisions/${d2}/submit`, {}, l1.token)).json().status, 'approved', "an L1's own draft goes to the admin");
const conflict = await call('POST', `/api/mod/revisions/${d2}/publish`, {}, admin.token);
assert.equal(conflict.statusCode, 409, 'a newer version was published meanwhile');
assert.equal((await call('POST', `/api/mod/revisions/${d2}/reject`, { comment: 'پرانا متن' }, admin.token)).json().status, 'rejected');
const hist = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l1.token)).json();
assert.deepEqual(hist.history.filter((h: any) => h.version).map((h: any) => h.version), [2, 1]);
assert.ok((await call('GET', '/api/mod/log', undefined, l2.token)).json().entries.length >= 10);
} finally {
// restore the work and remove the test people and their revisions
await pool.query('DELETE FROM verses WHERE poem_id = $1', [poem.id]);
for (const v of before) await pool.query('INSERT INTO verses (poem_id, vorder, position, couplet, text) VALUES ($1, $2, $3, $4, $5)', [poem.id, v.vorder, v.position, v.couplet, v.text]);
await pool.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1', [poem.id, poem.title, searchBefore]);
await pool.query(`DELETE FROM revisions WHERE entity = 'work' AND entity_id = $1 AND author_email LIKE $2`, [poem.id, `%-${run}@divan.test`]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1', [`%-${run}@divan.test`]);
await rm(data, { recursive: true, force: true });
await app.close();
}
});

229
api/src/moderation.ts Normal file
View File

@ -0,0 +1,229 @@
// Content moderation (#31, #51): versions of works and the L2 -> L1 -> admin pipeline.
// A revision holds a work's Divan text, a summary and a status; every step taken on it is an event (who, what,
// when, comment), which is both the review thread and the moderation log.
// L2 moderator: drafts and submits -> submitted
// L1 moderator (grant covering the work): approves, returns (with a comment) or rejects; an L1's own draft
// goes straight to the admin step -> approved
// admin: publishes (an admin's own draft publishes directly), returns or rejects
// Publishing numbers the version, writes it to divan-data as Divan-owned content (owned.ts) and updates the site.
// If another version was published after the draft started, publishing is refused until the draft is redone.
// GET /api/mod/can?poem= what the reader may do on a work
// GET /api/mod/queue my drafts, drafts to review, drafts to publish
// GET /api/mod/work/:id a work's current text and its history
// POST /api/mod/work/:id/draft start (or reopen) my draft
// GET /api/mod/revisions/:id a revision, its diff against the version it started from, its events
// POST /api/mod/revisions/:id/save {content, summary}
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment}
// GET /api/mod/log?page= who did what, newest first
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { pool } from './db.ts';
import { sessionUser } from './auth.ts';
import { can } from './permissions.ts';
import { fromPoem, writeOwned } from './owned.ts';
import { parse, toVerses } from './divantext.ts';
import { normalise } from './urdu.ts';
import { diffLines, changed } from './diff.ts';
const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname;
const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
const mayEdit = (u: any, poemId: number) => can(u, 'edit', 'works', { poemId });
const mayReview = async (u: any, poemId: number) => ['mod-l1', 'admin'].includes(u?.role) && (await mayEdit(u, poemId));
const OPEN = ['draft', 'returned'];
async function moderator(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req);
if (!u) return void reply.code(401).send({ error: 'لاگ ان کریں' });
if (!isModerator(u)) return void reply.code(403).send({ error: 'صرف موڈریٹرز کے لیے' });
return u;
}
const event = (revId: number, u: any, action: string, comment?: string | null) =>
pool.query('INSERT INTO revision_events (revision_id, actor_id, actor_email, action, comment) VALUES ($1, $2, $3, $4, $5)',
[revId, u?.id ?? null, u?.email ?? 'server', action, comment || null]);
// a work's current published text: its latest Divan version, or the Wikisource text as Divan text
async function current(poemId: number) {
const poem = (await pool.query(
'SELECT p.id, p.url, p.title, p.source_url, t.nickname AS poet FROM poems p JOIN poets t ON t.id = p.poet_id WHERE p.id = $1', [poemId])).rows[0];
if (!poem) return null;
const last = (await pool.query(
`SELECT version, content FROM revisions WHERE entity = 'work' AND entity_id = $1 AND status = 'published' ORDER BY version DESC LIMIT 1`, [poemId])).rows[0];
if (last) return { poem, version: last.version as number, content: last.content as string };
const verses = (await pool.query('SELECT position AS "Position", couplet AS "CoupletIndex", text AS "Text" FROM verses WHERE poem_id = $1 ORDER BY vorder', [poemId])).rows;
return { poem, version: 0, content: fromPoem({ Title: poem.title, Verses: verses, SourceUrl: poem.source_url ?? undefined }, { شاعر: poem.poet }) };
}
async function revision(id: number) {
return (await pool.query(
`SELECT r.*, p.title AS work_title, p.url AS work_url FROM revisions r JOIN poems p ON p.id = r.entity_id
WHERE r.id = $1 AND r.entity = 'work'`, [id])).rows[0];
}
// what this person may do with this revision now
async function actions(u: any, r: any) {
const mine = Number(r.author_id) === Number(u.id), review = await mayReview(u, r.entity_id), admin = u.role === 'admin';
return {
save: mine && OPEN.includes(r.status),
submit: mine && OPEN.includes(r.status),
approve: !mine && review && r.status === 'submitted',
return: (review || admin) && ['submitted', 'approved'].includes(r.status) && !(mine && !admin),
reject: (review || admin) && ['submitted', 'approved'].includes(r.status) && !(mine && !admin),
publish: admin && r.status === 'approved',
};
}
async function publish(r: any, u: any, comment?: string) {
const cur = await current(r.entity_id);
if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
if (cur.version !== r.base_version)
throw Object.assign(new Error('اس دوران اس کلام کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
const doc = parse(r.content), verses = toVerses(doc);
const title = doc.meta['عنوان'] || cur.poem.title, version = cur.version + 1, at = new Date().toISOString();
// divan-data first (the published record), then the site's database
await writeOwned(dataDir(), cur.poem.url, r.content, { by: r.author_email, at, version, reviewedBy: r.reviewer_email, publishedBy: u.email });
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`UPDATE revisions SET status = 'published', version = $2, publisher_email = $3, published_at = $4, updated_at = now() WHERE id = $1`,
[r.id, version, u.email, at]);
await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1',
[r.entity_id, title, normalise([title, ...verses.map((v) => v.Text)].join(' '))]);
await client.query('DELETE FROM verses WHERE poem_id = $1', [r.entity_id]);
for (const v of verses)
await client.query('INSERT INTO verses (poem_id, vorder, position, couplet, text) VALUES ($1, $2, $3, $4, $5)',
[r.entity_id, v.VOrder, v.Position, v.CoupletIndex, v.Text]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
// ponytail: radif/matla/maqta and the contents order are recomputed by the next daily export + import
await event(r.id, u, 'published', comment);
return version;
}
export function moderationRoutes(app: FastifyInstance) {
app.get<{ Querystring: { poem?: string } }>('/api/mod/can', async (req) => {
const u = await sessionUser(req), poemId = Number(req.query.poem) || 0;
if (!isModerator(u)) return { edit: false, review: false, publish: false };
return { edit: await mayEdit(u, poemId), review: await mayReview(u, poemId), publish: u.role === 'admin' };
});
app.get('/api/mod/queue', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const { rows } = await pool.query(
`SELECT r.id, r.entity_id, r.status, r.summary, r.author_id, r.author_email, r.reviewer_email, r.updated_at, p.title, p.url
FROM revisions r JOIN poems p ON p.id = r.entity_id
WHERE r.entity = 'work' AND (r.status IN ('submitted', 'approved') OR (r.author_id = $1 AND r.status IN ('draft', 'returned')))
ORDER BY r.updated_at DESC LIMIT 300`, [u.id]);
const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) });
const review = [];
for (const r of rows) if (r.status === 'submitted' && Number(r.author_id) !== Number(u.id) && (await mayReview(u, r.entity_id))) review.push(strip(r));
return {
mine: rows.filter((r) => Number(r.author_id) === Number(u.id)).map(strip),
review,
publish: u.role === 'admin' ? rows.filter((r) => r.status === 'approved').map(strip) : [],
};
});
app.get<{ Params: { id: string } }>('/api/mod/work/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const cur = await current(Number(req.params.id) || 0);
if (!cur) return reply.code(404).send({ error: 'کلام نہیں ملا' });
const { rows } = await pool.query(
`SELECT id, version, base_version, status, summary, author_email, reviewer_email, publisher_email, created_at, published_at
FROM revisions WHERE entity = 'work' AND entity_id = $1 ORDER BY coalesce(published_at, created_at) DESC`, [cur.poem.id]);
return { work: cur.poem, version: cur.version, content: cur.content, history: rows.map((r) => ({ ...r, id: Number(r.id) })),
may: { edit: await mayEdit(u, cur.poem.id) } };
});
app.post<{ Params: { id: string } }>('/api/mod/work/:id/draft', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const poemId = Number(req.params.id) || 0;
if (!(await mayEdit(u, poemId))) return reply.code(403).send({ error: 'اس کلام میں ترمیم کی اجازت نہیں' });
const open = (await pool.query(
`SELECT id FROM revisions WHERE entity = 'work' AND entity_id = $1 AND author_id = $2 AND status IN ('draft', 'returned') LIMIT 1`, [poemId, u.id])).rows[0];
if (open) return { id: Number(open.id) };
const cur = await current(poemId);
if (!cur) return reply.code(404).send({ error: 'کلام نہیں ملا' });
const { rows } = await pool.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
VALUES ('work', $1, $2, $3, $3, 'draft', $4, $5) RETURNING id`,
[poemId, cur.version, cur.content, u.id, u.email]);
await event(rows[0].id, u, 'created');
return { id: Number(rows[0].id) };
});
app.get<{ Params: { id: string } }>('/api/mod/revisions/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const r = await revision(Number(req.params.id) || 0);
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
const may = await actions(u, r);
if (Number(r.author_id) !== Number(u.id) && !(await mayReview(u, r.entity_id)) && u.role !== 'admin')
return reply.code(403).send({ error: 'یہ مسودہ دیکھنے کی اجازت نہیں' });
const diff = diffLines(r.base_content, r.content); // against the text the draft started from
const events = (await pool.query('SELECT actor_email, action, comment, at FROM revision_events WHERE revision_id = $1 ORDER BY at, id', [r.id])).rows;
const { author_id, base_content, ...rest } = r;
return { revision: { ...rest, id: Number(r.id) }, diff, changes: changed(diff), events, may };
});
app.post<{ Params: { id: string }; Body: { content?: string; summary?: string } }>('/api/mod/revisions/:id/save', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const r = await revision(Number(req.params.id) || 0);
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
if (!(await actions(u, r)).save) return reply.code(403).send({ error: 'یہ مسودہ اب محفوظ نہیں کیا جا سکتا' });
const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n');
if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
if (content.length > 500_000) return reply.code(400).send({ error: 'متن بہت لمبا ہے' });
const summary = String(req.body?.summary ?? '').trim().slice(0, 500) || null;
await pool.query('UPDATE revisions SET content = $2, summary = $3, updated_at = now() WHERE id = $1', [r.id, content, summary]);
await event(r.id, u, 'saved');
return { ok: true };
});
app.post<{ Params: { id: string; action: string }; Body: { comment?: string } }>('/api/mod/revisions/:id/:action', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const r = await revision(Number(req.params.id) || 0);
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
const action = req.params.action as keyof Awaited<ReturnType<typeof actions>>, may = await actions(u, r);
if (!['submit', 'approve', 'return', 'reject', 'publish'].includes(action)) return reply.code(404).send({ error: 'نامعلوم عمل' });
if (!may[action]) return reply.code(403).send({ error: 'یہ عمل آپ کے لیے دستیاب نہیں' });
const comment = String(req.body?.comment ?? '').trim().slice(0, 2000);
if ((action === 'return' || action === 'reject') && !comment) return reply.code(400).send({ error: 'وجہ لکھیں' });
const set = (status: string, extra = '') => pool.query(`UPDATE revisions SET status = $2, updated_at = now()${extra} WHERE id = $1`, [r.id, status]);
try {
if (action === 'submit') {
// L2 -> L1 review; an L1's own draft goes to the admin; an admin's own draft publishes
if (u.role === 'admin') { await set('approved'); await event(r.id, u, 'submitted', comment); return { status: 'published', version: await publish({ ...r, status: 'approved' }, u) }; }
await set(u.role === 'mod-l1' ? 'approved' : 'submitted');
await event(r.id, u, 'submitted', comment);
return { status: u.role === 'mod-l1' ? 'approved' : 'submitted' };
}
if (action === 'approve') {
await pool.query(`UPDATE revisions SET status = 'approved', reviewer_email = $2, updated_at = now() WHERE id = $1`, [r.id, u.email]);
await event(r.id, u, 'approved', comment);
return { status: 'approved' };
}
if (action === 'return' || action === 'reject') {
await set(action === 'return' ? 'returned' : 'rejected');
await event(r.id, u, action === 'return' ? 'returned' : 'rejected', comment);
return { status: action === 'return' ? 'returned' : 'rejected' };
}
return { status: 'published', version: await publish(r, u, comment) };
} catch (e: any) {
return reply.code(e.code ?? 500).send({ error: e.message });
}
});
app.get<{ Querystring: { page?: string } }>('/api/mod/log', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const page = Math.max(1, Number(req.query.page) || 1);
const { rows } = await pool.query(
`SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.version, p.title, p.url
FROM revision_events e JOIN revisions r ON r.id = e.revision_id JOIN poems p ON p.id = r.entity_id
ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`);
return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) };
});
}

View File

@ -30,15 +30,16 @@ export function fromPoem(poem: { Title: string; Verses: Verse[]; SourceUrl?: str
}
// write a Divan-owned work (the .dtx and the generated .json); returns the paths written
export async function writeOwned(dataDir: string, url: string, dtx: string, edited: { by: string; at?: string }) {
export async function writeOwned(dataDir: string, url: string, dtx: string,
edited: { by: string; at?: string; version?: number; reviewedBy?: string | null; publishedBy?: string }) {
const doc = parse(dtx);
const verses = toVerses(doc);
if (!verses.length) throw new Error('the text has no verses or paragraphs');
const base = join(dataDir, 'divan', url.replace(/^\/+|\/+$/g, ''));
if (!/^[\w/-]+$/.test(url) || url.includes('..')) throw new Error(`not a work url: ${url}`);
const base = join(dataDir, 'divan', url.replace(/^\/+|\/+$/g, ''));
await mkdir(dirname(base), { recursive: true });
const json = { FullUrl: '/' + url.replace(/^\/+/, ''), Title: doc.meta['عنوان'] ?? '', Verses: verses.map(({ VOrder, ...v }) => ({ VOrder, ...v, SectionIndex1: 0 })),
Edited: { by: edited.by, at: edited.at ?? new Date().toISOString() } };
Edited: { ...edited, at: edited.at ?? new Date().toISOString() } };
await writeFile(base + '.dtx', dtx.endsWith('\n') ? dtx : dtx + '\n');
await writeFile(base + '.json', JSON.stringify(json, null, 1) + '\n');
return [base + '.dtx', base + '.json'];

View File

@ -7,6 +7,7 @@
// /api/auth/* accounts (see auth.ts)
// /api/admin/* admin panel (see admin.ts)
// /api/library/* a reader's saved poets, works, couplets and words (see library.ts)
// /api/mod/* content moderation: drafts, review, publishing, history (see moderation.ts)
// GET /health
import Fastify from 'fastify';
import { pool } from './db.ts';
@ -17,6 +18,7 @@ import { authRoutes } from './auth.ts';
import { adminRoutes } from './admin.ts';
import { permissionRoutes } from './permissions.ts';
import { libraryRoutes } from './library.ts';
import { moderationRoutes } from './moderation.ts';
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
const PAGE_SIZE = 20;
@ -156,6 +158,7 @@ authRoutes(app);
adminRoutes(app);
permissionRoutes(app);
libraryRoutes(app);
moderationRoutes(app);
const port = Number(process.env.PORT ?? 4100);
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });

View File

@ -149,3 +149,38 @@ DROP INDEX IF EXISTS library_places;
CREATE UNIQUE INDEX IF NOT EXISTS library_bookmarks ON library (user_id, kind, coalesce(poet_id, 0), coalesce(category_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind IN ('poet', 'category', 'poem', 'couplet');
CREATE UNIQUE INDEX IF NOT EXISTS library_phrases ON library (user_id, poem_id, couplet, phrase) WHERE kind = 'phrase';
CREATE UNIQUE INDEX IF NOT EXISTS library_words ON library (user_id, word) WHERE kind = 'word';
-- content moderation (api/src/moderation.ts): versions of content, and every step taken on them (#31, #51)
CREATE TABLE IF NOT EXISTS revisions (
id bigserial PRIMARY KEY,
entity text NOT NULL, -- 'work' (later: poet, intro, book, chapter, dictionary, tag)
entity_id integer NOT NULL, -- poems.id for works
version integer, -- the published version number (NULL until published)
base_version integer NOT NULL DEFAULT 0, -- the published version the draft started from (0 = Wikisource text)
base_content text NOT NULL DEFAULT '', -- the text the draft started from (for an exact diff)
content text NOT NULL, -- Divan text
summary text, -- the edit summary
status text NOT NULL CHECK (status IN ('draft', 'submitted', 'approved', 'published', 'returned', 'rejected')),
author_id bigint REFERENCES users(id) ON DELETE SET NULL,
author_email text NOT NULL, -- kept when accounts are deleted
reviewer_email text, -- the L1 moderator who approved
publisher_email text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
ALTER TABLE revisions ADD COLUMN IF NOT EXISTS base_content text NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS revisions_entity ON revisions(entity, entity_id);
CREATE INDEX IF NOT EXISTS revisions_status ON revisions(status);
CREATE UNIQUE INDEX IF NOT EXISTS revisions_version ON revisions(entity, entity_id, version) WHERE version IS NOT NULL;
CREATE TABLE IF NOT EXISTS revision_events ( -- who did what: created, saved, submitted, approved, returned,
id bigserial PRIMARY KEY, -- rejected, published, commented
revision_id bigint NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
actor_id bigint REFERENCES users(id) ON DELETE SET NULL,
actor_email text NOT NULL,
action text NOT NULL,
comment text,
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS revision_events_revision ON revision_events(revision_id);
CREATE INDEX IF NOT EXISTS revision_events_at ON revision_events(at DESC);

View File

@ -0,0 +1,7 @@
---
const { current } = Astro.props as { current?: 'queue' | 'log' };
---
<nav class="admin-nav">
<a href="/mod" aria-current={current === 'queue' ? 'page' : undefined}>موڈریشن</a>
<a href="/mod?log=1" aria-current={current === 'log' ? 'page' : undefined}>سرگرمی</a>
</nav>

View File

@ -42,6 +42,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
</form>
<div class="toggles">
{Astro.locals.user?.role === 'admin' && <a class="account-link" href="/admin">ایڈمن</a>}
{['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="account-link" href="/mod">موڈریشن</a>}
{Astro.locals.user && <a class="account-link" href="/library">نشانات</a>}
{Astro.locals.user && <a class="account-link" href="/words">ذخیرۂ الفاظ</a>}
{Astro.locals.user

9
web/src/lib/mod.ts Normal file
View File

@ -0,0 +1,9 @@
// Urdu labels for moderation (api/src/moderation.ts)
export const STATUS: Record<string, string> = {
draft: 'مسودہ', submitted: 'جائزے کا منتظر', approved: 'منظور شدہ، اشاعت کا منتظر', published: 'شائع شدہ', returned: 'واپس بھیجا گیا', rejected: 'مسترد',
};
export const EVENT: Record<string, string> = {
created: 'مسودہ بنایا', saved: 'محفوظ کیا', submitted: 'جائزے کے لیے بھیجا', approved: 'منظور کیا', returned: 'واپس بھیجا',
rejected: 'مسترد کیا', published: 'شائع کیا',
};
export const when = (d: string) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');

View File

@ -66,6 +66,9 @@ const lineMarks = (c: number, lines: string[]) => {
return out;
};
const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'); // bookmark buttons
// moderators who may edit this work get edit and history links
const modCan = token && poemId && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false };
const bookmark = (c: number) => JSON.stringify({ kind: 'couplet', poemId, couplet: c });
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
---
@ -115,7 +118,9 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
</p>
)}
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button></p>
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button>
{modCan.edit && <> <form method="post" action={`/mod/work/${poemId}`} class="inline-form"><button class="mod-edit-btn">ترمیم کریں</button></form>
<a class="mod-history" href={`/mod/work/${poemId}`}>تاریخچہ</a></>}</p>
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
{blocks.map((b) => {

View File

@ -0,0 +1,56 @@
---
// Moderation: my drafts, drafts to review (L1, within grants), drafts to publish (admin); or the activity log
import Base from '../../layouts/Base.astro';
import ModNav from '../../components/ModNav.astro';
import { asUser, COOKIE } from '../../lib/auth';
import { STATUS, EVENT, when } from '../../lib/mod';
import { ud } from '../../lib/urdu';
const me = Astro.locals.user;
if (!me) return Astro.redirect('/signin?next=/mod');
if (!['mod-l2', 'mod-l1', 'admin'].includes(me.role)) return new Response('صرف موڈریٹرز کے لیے', { status: 403 });
const token = Astro.cookies.get(COOKIE)!.value, showLog = Astro.url.searchParams.has('log');
const queue = showLog ? null : (await asUser(token, '/api/mod/queue')).data;
const log = showLog ? (await asUser(token, '/api/mod/log')).data : null;
const groups = queue ? [
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
...(me.role !== 'mod-l2' ? [['جائزے کے منتظر', queue.review, 'جائزے کے لیے کچھ نہیں۔']] : []),
...(me.role === 'admin' ? [['اشاعت کے منتظر', queue.publish, 'اشاعت کے لیے کچھ نہیں۔']] : []),
] as [string, any[], string][] : [];
---
<Base title="موڈریشن">
<h1>موڈریشن</h1>
<ModNav current={showLog ? 'log' : 'queue'} />
{groups.map(([title, items, empty]) => (
<section class="lib-section">
<h2>{title} <small class="muted">{ud(items.length)}</small></h2>
{items.length === 0 ? <p class="muted center">{empty}</p> : (
<ul class="lib-list">
{items.map((r) => (
<li>
<a class="lib-title" href={`/mod/rev/${r.id}`}>{r.title}</a>
<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>
))}
</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}`}>{e.title}</a></td>
<td>{e.comment}</td>
</tr>
))}
</tbody>
</table>
)}
</Base>

View File

@ -0,0 +1,90 @@
---
// One revision: edit (author, while open), preview, diff against the text it started from, actions, timeline
import Base from '../../../layouts/Base.astro';
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 { parse } from '../../../../../api/src/divantext.ts';
const me = Astro.locals.user;
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
const token = Astro.cookies.get(COOKIE)!.value, id = Number(Astro.params.id) || 0;
let error = '', done = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData(), act = String(f.get('act'));
let r: any = { ok: true, data: {} };
if (act === 'save' || act === 'submit') r = await asUser(token, `/api/mod/revisions/${id}/save`, { content: f.get('content'), summary: f.get('summary') });
if (r.ok && act !== 'save') r = await asUser(token, `/api/mod/revisions/${id}/${act}`, { comment: f.get('comment') ?? '' });
if (!r.ok) error = r.data.error ?? 'کچھ غلط ہو گیا';
else done = act === 'save' ? 'محفوظ ہو گیا' : `${STATUS[r.data.status] ?? r.data.status}${r.data.version ? ` · ورژن ${ud(r.data.version)}` : ''}`;
}
const res = await asUser(token, `/api/mod/revisions/${id}`);
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
const { revision: rev, diff, changes, events, may } = res.data;
const doc = parse(rev.content);
const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]);
---
<Base title={`مسودہ: ${rev.work_title}`}>
<h1>{rev.work_title}</h1>
<ModNav />
<p class="center">
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
· <bdi dir="ltr">{rev.author_email}</bdi>
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
· <a href={rev.work_url}>کلام</a> · <a href={`/mod/work/${rev.entity_id}`}>تاریخچہ</a>
</p>
{error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done}</p>}
{may.save ? (
<form method="post" class="mod-edit">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: دوسرے شعر کا مصرع درست کیا" /></label>
<label class="mod-text">متن (دیوان متن)
<textarea name="content" id="mod-content" data-urdu rows="22" dir="rtl" spellcheck="false">{rev.content}</textarea>
</label>
<p class="muted note">شعر کے دو مصرعے الگ سطروں پر، اشعار کے درمیان خالی سطر۔ <code>== باب ==</code> عنوان، <code>[[لفظ]]</code> لغت کا ربط، <code>&lt;ref&gt;…&lt;/ref&gt;</code> حاشیہ۔</p>
<div class="filter-actions">
<button type="button" class="in-box-btn" data-urdu-rom="mod-content" title="رومن حروف سے اردو">اب</button>
<button type="button" class="in-box-btn" data-urdu-kb="mod-content" title="اردو کی بورڈ">کی بورڈ</button>
<button name="act" value="save">محفوظ کریں</button>
<button name="act" value="submit">{me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
</div>
</form>
) : rev.summary && <p class="center">خلاصہ: {rev.summary}</p>}
<section class="lib-section">
<h2>تبدیلیاں <small class="muted">{ud(changes)} سطریں</small></h2>
{changes === 0 ? <p class="muted center">ابھی کوئی تبدیلی نہیں۔</p> : (
<div class="diff" dir="rtl">
{diff.map((d: any) => d.op === '=' ? null : <div class={d.op === '+' ? 'add' : 'del'}><span>{d.op === '+' ? '+' : '−'}</span>{d.text || ' '}</div>)}
</div>
)}
</section>
<section class="lib-section">
<h2>پیش نظارہ</h2>
<article class="poem">
{doc.blocks.map((b: any) => b.type === 'heading' ? <h3 class="mod-h">{b.text}</h3>
: b.type === 'para' ? <p class="para">{b.line.text}</p>
: b.type === 'couplet' ? <div class="couplet" data-mark={b.label}>{b.lines.map((l: any) => <p>{l.text}</p>)}</div>
: b.lines.map((l: any) => <p class="single">{l.text}</p>))}
</article>
</section>
{acts.length > 0 && (
<form method="post" class="account-form mod-actions">
<label>تبصرہ (واپس بھیجنے یا مسترد کرنے کے لیے ضروری)<textarea name="comment" rows="2" data-urdu maxlength="2000"></textarea></label>
<div class="filter-actions">{acts.map(([a, label]) => <button name="act" value={a} class={`act-${a}`}>{label}</button>)}</div>
</form>
)}
<section class="lib-section">
<h2>سرگرمی</h2>
<ol class="timeline">
{events.map((e: any) => (
<li><bdi dir="ltr">{when(e.at)}</bdi> · <bdi dir="ltr">{e.actor_email}</bdi> · <strong>{EVENT[e.action] ?? e.action}</strong>{e.comment && <>: {e.comment}</>}</li>
))}
</ol>
</section>
</Base>

View File

@ -0,0 +1,42 @@
---
// A work's versions: published versions and drafts, with who, when, summary and status
import Base from '../../../layouts/Base.astro';
import ModNav from '../../../components/ModNav.astro';
import { asUser, COOKIE } from '../../../lib/auth';
import { STATUS, when } from '../../../lib/mod';
import { ud } from '../../../lib/urdu';
const me = Astro.locals.user;
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
const token = Astro.cookies.get(COOKIE)!.value, id = Number(Astro.params.id) || 0;
if (Astro.request.method === 'POST') {
const r = await asUser(token, `/api/mod/work/${id}/draft`, {});
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
}
const res = await asUser(token, `/api/mod/work/${id}`);
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
const { work, version, history, may } = res.data;
---
<Base title={`تاریخچہ: ${work.title}`}>
<h1>{work.title}</h1>
<ModNav />
<p class="center"><a href={work.url}>کلام</a> · {version ? `موجودہ ورژن ${ud(version)}` : 'ویکی ماخذ کا متن (ابھی دیوان کا کوئی ورژن نہیں)'}</p>
{may.edit && <form method="post" class="center"><button class="save-like">ترمیم کریں</button></form>}
<table class="admin-table">
<thead><tr><th>ورژن</th><th>حالت</th><th>خلاصہ</th><th>لکھنے والے</th><th>جائزہ</th><th>اشاعت</th><th>تاریخ</th></tr></thead>
<tbody>
{history.map((h: any) => (
<tr>
<td><a href={`/mod/rev/${h.id}`}>{h.version ? ud(h.version) : '—'}</a></td>
<td><span class={`status s-${h.status}`}>{STATUS[h.status]}</span></td>
<td>{h.summary}</td>
<td><bdi dir="ltr">{h.author_email}</bdi></td>
<td><bdi dir="ltr">{h.reviewer_email ?? ''}</bdi></td>
<td><bdi dir="ltr">{h.publisher_email ?? ''}</bdi></td>
<td><bdi dir="ltr">{when(h.published_at ?? h.created_at)}</bdi></td>
</tr>
))}
</tbody>
</table>
{history.length === 0 && <p class="muted center">ابھی کوئی ترمیم نہیں۔</p>}
</Base>

View File

@ -295,3 +295,30 @@ h1 + .muted { text-align: center; margin-top: 0; }
.author-suggest button:hover { border-color: var(--brand); color: var(--brand); }
.filter-actions { display: flex; gap: 12px; justify-content: center; align-items: center; margin-top: 10px !important; }
.filter-actions button { font: inherit; font-size: .85rem; padding: 1px 14px; border: 1.5px solid var(--gold); border-radius: 10px; background: var(--inner); color: var(--ink); cursor: pointer; }
/* moderation */
.status { display: inline-block; font-size: .78rem; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border); margin-inline-start: 6px; }
.s-submitted { border-color: var(--gold); color: var(--gold); }
.s-approved { border-color: var(--lapis); color: var(--lapis); }
.s-published { border-color: #3d8b4f; color: #3d8b4f; }
.s-returned, .s-rejected { border-color: var(--brand); color: var(--brand); }
.small-line { font-size: .8rem; margin: 2px 0 0; }
.mod-edit { max-width: 900px; margin: 12px auto; display: flex; flex-direction: column; gap: 8px; }
.mod-edit label { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
.mod-edit input, .mod-edit textarea { font: inherit; padding: 6px 12px; border: 1.5px solid var(--border); border-radius: 10px; background: var(--paper); color: var(--ink); }
.mod-edit textarea { font-size: 1.05rem; line-height: 2; min-height: 26em; resize: vertical; }
.mod-edit textarea:focus, .mod-edit input:focus { outline: none; border-color: var(--gold); }
.mod-actions { max-width: 700px; }
.mod-actions .act-publish { border-color: #3d8b4f; color: #3d8b4f; }
.mod-actions .act-reject, .mod-actions .act-return { border-color: var(--brand); color: var(--brand); }
.diff { max-width: 900px; margin: 0 auto; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.diff div { padding: 2px 12px; white-space: pre-wrap; line-height: 1.9; }
.diff div span { display: inline-block; width: 1.2em; font-weight: 700; }
.diff .add { background: rgb(61 139 79 / .14); }
.diff .del { background: rgb(191 63 42 / .12); text-decoration: line-through; text-decoration-color: rgb(191 63 42 / .5); }
.timeline { max-width: 760px; margin: 0 auto; padding-inline-start: 20px; font-size: .88rem; }
.timeline li { margin: 4px 0; }
.mod-h { text-align: center; color: var(--brand); }
.inline-form { display: inline; }
.mod-edit-btn, .save-like { font: inherit; font-size: .85rem; padding: 1px 14px; border: 1.5px solid var(--gold); border-radius: 999px; background: var(--inner); color: var(--ink); cursor: pointer; }
.mod-history { font-size: .85rem; margin-inline-start: 6px; }