From 19af91c594112d8aa30093020484dc99fd96cd78 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 00:31:47 +0200 Subject: [PATCH] Moderation: versions of works and the L2 -> L1 -> admin pipeline (#31, #51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - revisions (a work's Divan text, summary, status, author/reviewer/publisher, the text it started from) and revision_events (who did what, with comments): the review thread and the moderation log. - API (api/src/moderation.ts): what a moderator may do on a work, queue, drafts (one open draft per person and work), save, submit, approve, return/reject (reason required), publish; history; log. Rules come from the IAM grants: L2 drafts, an L1 covering the work reviews, an admin publishes; L1 drafts go to the admin, admin drafts publish directly. Publishing numbers the version, writes it to divan-data (owned.ts) and updates the site; a draft based on an older version is refused (409). - Line diff (api/src/diff.ts) against the text a draft started from. - Site: ترمیم کریں / تاریخچہ on works, /mod (queue, activity log), /mod/rev/ (editor with the Urdu keyboard and Roman typing, diff, preview, actions, timeline), /mod/work/ (versions). Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + api/src/diff.ts | 24 ++++ api/src/moderation.test.ts | 115 +++++++++++++++ api/src/moderation.ts | 229 ++++++++++++++++++++++++++++++ api/src/owned.ts | 7 +- api/src/server.ts | 3 + db/schema.sql | 35 +++++ web/src/components/ModNav.astro | 7 + web/src/layouts/Base.astro | 1 + web/src/lib/mod.ts | 9 ++ web/src/pages/[...path].astro | 7 +- web/src/pages/mod/index.astro | 56 ++++++++ web/src/pages/mod/rev/[id].astro | 90 ++++++++++++ web/src/pages/mod/work/[id].astro | 42 ++++++ web/src/styles/global.css | 27 ++++ 15 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 api/src/diff.ts create mode 100644 api/src/moderation.test.ts create mode 100644 api/src/moderation.ts create mode 100644 web/src/components/ModNav.astro create mode 100644 web/src/lib/mod.ts create mode 100644 web/src/pages/mod/index.astro create mode 100644 web/src/pages/mod/rev/[id].astro create mode 100644 web/src/pages/mod/work/[id].astro diff --git a/README.md b/README.md index 3446011c..7c76a965 100644 --- a/README.md +++ b/README.md @@ -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/`). 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 | diff --git a/api/src/diff.ts b/api/src/diff.ts new file mode 100644 index 00000000..d6a02fcd --- /dev/null +++ b/api/src/diff.ts @@ -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; diff --git a/api/src/moderation.test.ts b/api/src/moderation.test.ts new file mode 100644 index 00000000..1bf2787b --- /dev/null +++ b/api/src/moderation.test.ts @@ -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(); + } +}); diff --git a/api/src/moderation.ts b/api/src/moderation.ts new file mode 100644 index 00000000..88a0a1a1 --- /dev/null +++ b/api/src/moderation.ts @@ -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>, 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) })) }; + }); +} diff --git a/api/src/owned.ts b/api/src/owned.ts index 4b93aab6..0ca295a8 100644 --- a/api/src/owned.ts +++ b/api/src/owned.ts @@ -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']; diff --git a/api/src/server.ts b/api/src/server.ts index 65b20667..de0eb0cb 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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' }); diff --git a/db/schema.sql b/db/schema.sql index 06b0dad4..d67dc630 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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); diff --git a/web/src/components/ModNav.astro b/web/src/components/ModNav.astro new file mode 100644 index 00000000..12ab7dfe --- /dev/null +++ b/web/src/components/ModNav.astro @@ -0,0 +1,7 @@ +--- +const { current } = Astro.props as { current?: 'queue' | 'log' }; +--- + diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index a47f25fb..24e7b1f2 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -42,6 +42,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
{Astro.locals.user?.role === 'admin' && } + {['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && } {Astro.locals.user && } {Astro.locals.user && } {Astro.locals.user diff --git a/web/src/lib/mod.ts b/web/src/lib/mod.ts new file mode 100644 index 00000000..9bc70a4c --- /dev/null +++ b/web/src/lib/mod.ts @@ -0,0 +1,9 @@ +// Urdu labels for moderation (api/src/moderation.ts) +export const STATUS: Record = { + draft: 'مسودہ', submitted: 'جائزے کا منتظر', approved: 'منظور شدہ، اشاعت کا منتظر', published: 'شائع شدہ', returned: 'واپس بھیجا گیا', rejected: 'مسترد', +}; +export const EVENT: Record = { + created: 'مسودہ بنایا', saved: 'محفوظ کیا', submitted: 'جائزے کے لیے بھیجا', approved: 'منظور کیا', returned: 'واپس بھیجا', + rejected: 'مسترد کیا', published: 'شائع کیا', +}; +export const when = (d: string) => new Date(d).toISOString().slice(0, 16).replace('T', ' '); diff --git a/web/src/pages/[...path].astro b/web/src/pages/[...path].astro index 2859fdc5..fc7ffd92 100644 --- a/web/src/pages/[...path].astro +++ b/web/src/pages/[...path].astro @@ -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}» نمایاں · تلاش کے نتائج · نشان ہٹائیں

)} -

+

+ {modCan.edit && <>

+ تاریخچہ}

{page.poem.radif != null &&

{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}

}
{blocks.map((b) => { diff --git a/web/src/pages/mod/index.astro b/web/src/pages/mod/index.astro new file mode 100644 index 00000000..ce01cfc0 --- /dev/null +++ b/web/src/pages/mod/index.astro @@ -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][] : []; +--- + +

موڈریشن

+ + {groups.map(([title, items, empty]) => ( +
+

{title} {ud(items.length)}

+ {items.length === 0 ?

{empty}

: ( +
    + {items.map((r) => ( +
  • + {r.title} + {STATUS[r.status]} +

    {r.summary || 'بغیر خلاصہ'} · {r.author_email} · {when(r.updated_at)}

    +
  • + ))} +
+ )} +
+ ))} + {log && ( + + + + {log.entries.map((e: any) => ( + + + + + + + + ))} + +
وقت (UTC)کس نےکیاکلامتبصرہ
{when(e.at)}{e.actor_email}{EVENT[e.action] ?? e.action}{e.version && e.action === 'published' ? ` (ورژن ${ud(e.version)})` : ''}{e.title}{e.comment}
+ )} + diff --git a/web/src/pages/mod/rev/[id].astro b/web/src/pages/mod/rev/[id].astro new file mode 100644 index 00000000..1dc25ae1 --- /dev/null +++ b/web/src/pages/mod/rev/[id].astro @@ -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]); +--- + +

{rev.work_title}

+ +

+ {STATUS[rev.status]} + · {rev.author_email} + · {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'} + · کلام · تاریخچہ +

+ {error && } + {done &&

{done}

} + + {may.save ? ( +
+ + +

شعر کے دو مصرعے الگ سطروں پر، اشعار کے درمیان خالی سطر۔ == باب == عنوان، [[لفظ]] لغت کا ربط، <ref>…</ref> حاشیہ۔

+
+ + + + +
+
+ ) : rev.summary &&

خلاصہ: {rev.summary}

} + +
+

تبدیلیاں {ud(changes)} سطریں

+ {changes === 0 ?

ابھی کوئی تبدیلی نہیں۔

: ( +
+ {diff.map((d: any) => d.op === '=' ? null :
{d.op === '+' ? '+' : '−'}{d.text || ' '}
)} +
+ )} +
+ +
+

پیش نظارہ

+
+ {doc.blocks.map((b: any) => b.type === 'heading' ?

{b.text}

+ : b.type === 'para' ?

{b.line.text}

+ : b.type === 'couplet' ?
{b.lines.map((l: any) =>

{l.text}

)}
+ : b.lines.map((l: any) =>

{l.text}

))} +
+
+ + {acts.length > 0 && ( + + )} + +
+

سرگرمی

+
    + {events.map((e: any) => ( +
  1. {when(e.at)} · {e.actor_email} · {EVENT[e.action] ?? e.action}{e.comment && <>: {e.comment}}
  2. + ))} +
+
+ diff --git a/web/src/pages/mod/work/[id].astro b/web/src/pages/mod/work/[id].astro new file mode 100644 index 00000000..33f519dc --- /dev/null +++ b/web/src/pages/mod/work/[id].astro @@ -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; +--- + +

{work.title}

+ +

کلام · {version ? `موجودہ ورژن ${ud(version)}` : 'ویکی ماخذ کا متن (ابھی دیوان کا کوئی ورژن نہیں)'}

+ {may.edit &&
} + + + + {history.map((h: any) => ( + + + + + + + + + + ))} + +
ورژنحالتخلاصہلکھنے والےجائزہاشاعتتاریخ
{h.version ? ud(h.version) : '—'}{STATUS[h.status]}{h.summary}{h.author_email}{h.reviewer_email ?? ''}{h.publisher_email ?? ''}{when(h.published_at ?? h.created_at)}
+ {history.length === 0 &&

ابھی کوئی ترمیم نہیں۔

} + diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 803bf51a..5d85c1e9 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -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; } -- 2.47.3