Version history (#51): compare any two versions of a work, arrangement or tags (0 = the Wikisource text), bring an old version back as a draft through the pipeline, filter the moderation log by person, kind and step; one history table for the three pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-10 00:42:26 +02:00
parent 709b5d4594
commit 5c8559c8e9
10 changed files with 188 additions and 61 deletions

View File

@ -124,6 +124,25 @@ test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permi
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);
// version history (#51): compare any two versions, bring an old one back through the pipeline, filter the log
const cmp = (await call('GET', `/api/mod/compare/work/${poem.id}`, undefined, l2.token)).json();
assert.deepEqual([cmp.a, cmp.b, cmp.changes], [1, 2, 2], 'by default the latest version against the one before');
assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text.includes('(ترمیم دوم)')));
const v0 = (await call('GET', `/api/mod/compare/work/${poem.id}?a=0&b=2`, undefined, l2.token)).json();
assert.ok(v0.diff.some((d: any) => d.op === '-' && d.text === misra), 'version 0 is the Wikisource text');
assert.equal((await call('GET', `/api/mod/compare/work/${poem.id}?a=7`, undefined, l2.token)).statusCode, 404);
assert.equal((await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 0 }, other.token)).statusCode, 403, 'outside the grant');
assert.equal((await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 2 }, l2.token)).statusCode, 400, 'already the current version');
const back = (await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 0 }, l2.token)).json().id;
const br = (await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json();
assert.deepEqual([br.revision.status, br.revision.base_version, br.revision.summary], ['draft', 2, 'ویکی ماخذ کا متن واپس لایا']);
assert.ok(br.diff.some((d: any) => d.op === '+' && d.text === misra), 'the draft brings the old line back');
assert.deepEqual(br.events.map((e: any) => e.action), ['created', 'reverted']);
assert.deepEqual((await call('POST', `/api/mod/revisions/${back}/submit`, {}, l2.token)).json().status, 'submitted', 'then reviewed like any edit');
const mine = (await call('GET', `/api/mod/log?who=l2-${run}&action=reverted`, undefined, l1.token)).json().entries;
assert.deepEqual(mine.map((e: any) => e.revision), [back], 'log filtered by person and step');
assert.equal((await call('GET', `/api/mod/log?who=l2-${run}&kind=order`, undefined, l1.token)).json().entries.length, 0);
} finally {
// restore the work and remove the test people and their revisions
await pool.query('DELETE FROM verses WHERE poem_id = $1', [poem.id]);

View File

@ -23,7 +23,10 @@
// POST /api/mod/tags/:kind/:id/draft start (or reopen) my tagging draft
// 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
// GET /api/mod/log?page=&who=&kind=&action= who did what, newest first; filtered by person, kind of change, step
// GET /api/mod/compare/:entity/:id?a=&b= two published versions of a work, order or tags and their diff (0 = the
// Wikisource text before Divan's first version)
// POST /api/mod/revert/:entity/:id {version}: a draft that brings back that version, through the pipeline
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { pool } from './db.ts';
import { sessionUser } from './auth.ts';
@ -107,8 +110,7 @@ async function actions(u: any, r: any) {
async function publish(r: any, u: any, comment?: string) {
const order = r.entity === 'order', tags = r.entity.startsWith('tags-'), ebook = r.entity === 'ebook';
const cur = order ? await currentOrder(r.entity_id) : tags ? await currentTags(tagKind(r), r.entity_id)
: ebook ? await currentEbook(r.entity_id) : await current(r.entity_id);
const cur = await currentOf(r.entity, 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 });
@ -162,6 +164,20 @@ async function publish(r: any, u: any, comment?: string) {
return version;
}
// what an entity is now (its latest published version), for any kind of revision
const currentOf = (entity: string, id: number): Promise<any> => entity === 'order' ? currentOrder(id)
: entity.startsWith('tags-') ? currentTags(tagKind({ entity }), id) : entity === 'ebook' ? currentEbook(id) : current(id);
// the kinds of change with versions to compare and bring back (an e-book's details are a one-off)
const VERSIONED = ['work', 'order', 'tags-work', 'tags-category'];
// a published version's content; version 0 is what the first Divan draft started from (the Wikisource text)
async function versionText(entity: string, id: number, v: number, cur: { version: number; content: string }) {
if (v === cur.version) return cur.content;
const { rows } = await pool.query(v
? `SELECT content FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published' AND version = $3`
: `SELECT base_content AS content FROM revisions WHERE entity = $1 AND entity_id = $2 AND base_version = 0 AND $3 = 0 ORDER BY id LIMIT 1`, [entity, id, v]);
return rows[0]?.content as string | undefined;
}
const identityOf = (p: any) => identity({ id: Number(p.id), name: publicName(p) });
export function moderationRoutes(app: FastifyInstance) {
@ -358,14 +374,53 @@ export function moderationRoutes(app: FastifyInstance) {
}
});
app.get<{ Querystring: { page?: string } }>('/api/mod/log', async (req, reply) => {
app.get<{ Querystring: { page?: string; who?: string; kind?: string; action?: 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 page = Math.max(1, Number(req.query.page) || 1), { who, kind, action } = req.query;
// filters: a person (part of their address), a kind of change (tags covers both), a step; empty = all
const { rows } = await pool.query(
`SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.entity, r.version, ${TARGET_TITLE} AS title, ${TARGET_URL} AS url
FROM revision_events e JOIN revisions r ON r.id = e.revision_id ${TARGET}
WHERE r.entity IN ${ENTITIES} AND ${CHANGED}
ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`);
AND ($1 = '' OR e.actor_email ILIKE '%' || $1 || '%') AND ($2 = '' OR r.entity = $2 OR r.entity LIKE $2 || '-%') AND ($3 = '' OR e.action = $3)
ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`, [who?.trim() ?? '', kind ?? '', action ?? '']);
return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) };
});
app.get<{ Params: { entity: string; id: string }; Querystring: { a?: string; b?: string } }>('/api/mod/compare/:entity/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const { entity } = req.params, id = Number(req.params.id) || 0;
const cur = VERSIONED.includes(entity) ? await currentOf(entity, id) : null;
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
const pick = (q: string | undefined, d: number) => (q === undefined || q === '' ? d : Number(q));
const a = pick(req.query.a, Math.max(0, cur.version - 1)), b = pick(req.query.b, cur.version);
const [ta, tb] = await Promise.all([a, b].map((v) => Number.isInteger(v) && v >= 0 && v <= cur.version ? versionText(entity, id, v, cur) : undefined));
if (ta === undefined || tb === undefined) return reply.code(404).send({ error: 'یہ ورژن نہیں ملا' });
const target = cur.poem ?? cur.cat ?? cur.target, diff = diffLines(ta, tb);
return { entity, id, title: target.title, url: target.url, version: cur.version, a, b, diff, changes: changed(diff),
may: { revert: await mayChange(u, { entity, entity_id: id }) } };
});
app.post<{ Params: { entity: string; id: string }; Body: { version?: number } }>('/api/mod/revert/:entity/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const { entity } = req.params, id = Number(req.params.id) || 0, v = Number(req.body?.version);
const cur = VERSIONED.includes(entity) ? await currentOf(entity, id) : null;
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
if (!(await mayChange(u, { entity, entity_id: id }))) return reply.code(403).send({ error: 'اس میں تبدیلی کی اجازت نہیں' });
const content = Number.isInteger(v) && v >= 0 && v < cur.version ? await versionText(entity, id, v, cur) : undefined;
if (content === undefined) return reply.code(400).send({ error: 'یہ ورژن واپس نہیں لایا جا سکتا' });
if (content === cur.content) return reply.code(400).send({ error: 'یہ ورژن موجودہ ورژن جیسا ہی ہے' });
// the reverted text goes into my open draft (or a new one) and then through review like any edit
const summary = v ? `ورژن ${v} واپس لایا` : 'ویکی ماخذ کا متن واپس لایا';
const open = (await pool.query(
`SELECT id FROM revisions WHERE entity = $1 AND entity_id = $2 AND author_id = $3 AND status IN ('draft', 'returned') LIMIT 1`, [entity, id, u.id])).rows[0];
const revId = open ? Number(open.id) : Number((await pool.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
VALUES ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`, [entity, id, cur.version, cur.content, u.id, u.email])).rows[0].id);
if (!open) await event(revId, u, 'created');
await pool.query(`UPDATE revisions SET content = $2, summary = $3, base_version = $4, base_content = $5, updated_at = now() WHERE id = $1`,
[revId, content, summary, cur.version, cur.content]);
await event(revId, u, 'reverted', summary);
return { id: revId };
});
}

View File

@ -0,0 +1,36 @@
---
// A work's, arrangement's or tags' versions (#51): compare any two published versions, see what each one changed
// (فرق), and from there bring an old one back. Version 0 is the Wikisource text before Divan's first version.
import { STATUS, when } from '../lib/mod';
import { ud } from '../lib/urdu';
interface Props { entity: string; id: number; history: any[]; version: number; empty: string }
const { entity, id, history, version, empty } = Astro.props;
const base = `/mod/compare/${entity}/${id}`;
const versions = Array.from({ length: version + 1 }, (_, v) => v);
const label = (v: number) => (v ? `ورژن ${ud(v)}` : 'ویکی ماخذ');
---
{version > 0 && (
<form method="get" action={base} class="version-tools">
<label>ورژن <select name="a">{versions.map((v) => <option value={v} selected={v === version - 1}>{label(v)}</option>)}</select></label>
<label>اور <select name="b">{versions.map((v) => <option value={v} selected={v === version}>{label(v)}</option>)}</select></label>
<button>موازنہ کریں</button>
</form>
)}
<table class="admin-table">
<thead><tr><th>ورژن</th><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>
<td>{h.version && <a href={`${base}?a=${h.version - 1}&b=${h.version}`}>فرق</a>}</td>
</tr>
))}
</tbody>
</table>
{history.length === 0 && <p class="muted center">{empty}</p>}

View File

@ -4,6 +4,6 @@ export const STATUS: Record<string, string> = {
};
export const EVENT: Record<string, string> = {
created: 'مسودہ بنایا', saved: 'محفوظ کیا', submitted: 'جائزے کے لیے بھیجا', approved: 'منظور کیا', returned: 'واپس بھیجا',
rejected: 'مسترد کیا', published: 'شائع کیا',
rejected: 'مسترد کیا', published: 'شائع کیا', reverted: 'پرانا ورژن واپس لایا',
};
export const when = (d: string) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');

View File

@ -0,0 +1,43 @@
---
// Two published versions side by side as a diff (#51); "یہ ورژن واپس لائیں" makes a draft of the older one, which then
// goes through review like any edit
import Base from '../../../../layouts/Base.astro';
import ModNav from '../../../../components/ModNav.astro';
import { asUser, COOKIE } from '../../../../lib/auth';
import { ud } from '../../../../lib/urdu';
const me = Astro.locals.user;
if (!me) return Astro.redirect(`/signin?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`);
const token = Astro.cookies.get(COOKIE)!.value, { entity, id } = Astro.params;
let error = '';
if (Astro.request.method === 'POST') {
const r = await asUser(token, `/api/mod/revert/${entity}/${id}`, { version: Number((await Astro.request.formData()).get('version')) });
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
error = r.data.error ?? 'کچھ غلط ہو گیا';
}
const res = await asUser(token, `/api/mod/compare/${entity}/${id}${Astro.url.search}`);
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
const { title, url, version, a, b, diff, changes, may } = res.data;
const label = (v: number) => (v ? `ورژن ${ud(v)}` : 'ویکی ماخذ کا متن');
const HISTORY: Record<string, string> = { work: `/mod/work/${id}`, order: `/mod/order/${id}`, 'tags-work': `/mod/tags/work/${id}`, 'tags-category': `/mod/tags/category/${id}` };
const older = Math.min(a, b);
---
<Base title={`موازنہ: ${title}`}>
<h1>{title}</h1>
<ModNav />
<p class="center"><a href={url}>صفحہ</a> · <a href={HISTORY[entity]}>تاریخچہ</a> · موجودہ: {label(version)}</p>
<p class="center"><span class="del-key">−</span> {label(a)} <span class="add-key">+</span> {label(b)} · {ud(changes)} سطریں بدلیں</p>
{error && <p class="form-error" role="alert">{error}</p>}
{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>
)}
{may.revert && older < version && (
<form method="post" class="center">
<input type="hidden" name="version" value={older} />
<button class="save-like">{label(older)} واپس لائیں</button>
<p class="muted">ایک مسودہ بنے گا جو عام ترمیم کی طرح جائزے اور اشاعت سے گزرے گا۔</p>
</form>
)}
</Base>

View File

@ -13,7 +13,9 @@ 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;
// the log filtered by person, kind of change and step (#51)
const f = Object.fromEntries(['who', 'kind', 'action'].map((k) => [k, Astro.url.searchParams.get(k) ?? '']));
const log = showLog ? (await asUser(token, `/api/mod/log?${new URLSearchParams(f)}`)).data : null;
// what kind of change a row is (a work's text needs no label)
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ' };
// each kind of change has its icon; each event its tone on the timeline
@ -52,6 +54,21 @@ const groups = queue ? [
)}
</section>
))}
{log && (
<form method="get" class="version-tools" aria-label="ریکارڈ چھانیں">
<input type="hidden" name="log" />
<input type="search" name="who" value={f.who} placeholder="کس نے (ای میل)" aria-label="کس نے" dir="auto" />
<select name="kind" aria-label="کس چیز میں">
{[['', 'سب تبدیلیاں'], ['work', 'متن'], ['order', 'ترتیب'], ['tags', 'ٹیگ'], ['ebook', 'ای بکس']].map(([v, t]) => <option value={v} selected={f.kind === v}>{t}</option>)}
</select>
<select name="action" aria-label="کیا کیا">
<option value="">ہر قدم</option>
{Object.entries(EVENT).map(([v, t]) => <option value={v} selected={f.action === v}>{t}</option>)}
</select>
<button>دکھائیں</button>
</form>
)}
{log && log.entries.length === 0 && <p class="muted center">اس چھانٹی میں کچھ نہیں۔</p>}
{log && (
<ol class="timeline">
{log.entries.map((e: any) => (

View File

@ -1,9 +1,9 @@
---
// A book/section's arrangements (#41): its current order, published versions and drafts; "ترتیب دیں" starts a draft
import Base from '../../../layouts/Base.astro';
import History from '../../../components/History.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;
@ -26,21 +26,5 @@ const { section, version, history, count, may } = res.data;
{version ? `موجودہ ترتیب: ورژن ${ud(version)}` : 'ویکی ماخذ کی ترتیب (ابھی دیوان کی کوئی ترتیب نہیں)'}</p>
{error && <p class="form-error" role="alert">{error}</p>}
{may.arrange && count > 1 && <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>}
<History entity="order" id={section.id} history={history} version={version} empty="ابھی ترتیب میں کوئی تبدیلی نہیں۔" />
</Base>

View File

@ -1,10 +1,10 @@
---
// A page's tags (#52): its current tags, published versions and drafts; "ٹیگ لگائیں" starts a draft
import Base from '../../../../layouts/Base.astro';
import History from '../../../../components/History.astro';
import ModNav from '../../../../components/ModNav.astro';
import Tags from '../../../../components/Tags.astro';
import { asUser, COOKIE } from '../../../../lib/auth';
import { STATUS, when } from '../../../../lib/mod';
import { ud } from '../../../../lib/urdu';
const me = Astro.locals.user;
@ -28,20 +28,5 @@ const current = content.split('\n').filter(Boolean).map((l: string) => { const [
<Tags tags={current} />
{error && <p class="form-error" role="alert">{error}</p>}
{may.tags && <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 entity={`tags-${kind}`} id={target.id} history={history} version={version} empty="ابھی ٹیگ میں کوئی تبدیلی نہیں۔" />
</Base>

View File

@ -1,9 +1,9 @@
---
// A work's versions: published versions and drafts, with who, when, summary and status
import Base from '../../../layouts/Base.astro';
import History from '../../../components/History.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;
@ -22,21 +22,5 @@ const { work, version, history, may } = res.data;
<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>}
<History entity="work" id={work.id} history={history} version={version} empty="ابھی کوئی ترمیم نہیں۔" />
</Base>

View File

@ -412,6 +412,10 @@ h1 + .muted { text-align: center; margin-top: 0; }
.timeline li { margin: 4px 0; }
.mod-h { text-align: center; color: var(--brand); }
.inline-form { display: inline; }
.version-tools { display: flex; flex-wrap: wrap; gap: 8px 12px; justify-content: center; align-items: center; margin: 10px 0; }
.add-key, .del-key { font-weight: 700; padding: 0 6px; border-radius: 4px; }
.add-key { background: color-mix(in srgb, var(--ok) 14%, transparent); }
.del-key { background: color-mix(in srgb, var(--danger) 12%, transparent); }
/* a page's action row (save, edit, history, tags): spaced as one row of buttons, wrapping on phones */
.center:has(> .save) { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 8px; }
.mod-history { font-size: .85rem; margin-inline-start: 6px; }