Editor: live code view beside the editor; unchanged drafts are not kept #60

Merged
anas merged 1 commits from feature/editor-code-view into main 2026-10-08 23:21:10 +00:00
5 changed files with 42 additions and 5 deletions

View File

@ -50,6 +50,13 @@ test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permi
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 });
// opening the editor is not a change: an unchanged draft is not listed, and saving it unchanged drops it
const blank = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id;
assert.equal((await call('GET', '/api/mod/queue', undefined, l2.token)).json().mine.some((r: any) => r.id === blank), false, 'unchanged draft not listed');
const text = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l2.token)).json().content;
assert.deepEqual((await call('POST', `/api/mod/revisions/${blank}/save`, { content: text.replace('}}\n', '}}\n\n') }, l2.token)).json(), { discarded: true }, 'layout-only difference is no change');
assert.equal((await call('GET', `/api/mod/revisions/${blank}`, undefined, l2.token)).statusCode, 404, 'dropped');
// 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);
@ -59,6 +66,7 @@ test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permi
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);
assert.equal((await call('GET', '/api/mod/queue', undefined, l2.token)).json().mine.some((r: any) => r.id === id), true, 'changed draft listed');
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');

View File

@ -22,7 +22,7 @@ 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 { parse, toVerses, toText } from './divantext.ts';
import { normalise } from './urdu.ts';
import { diffLines, changed } from './diff.ts';
import { commit, identity } from './git.ts';
@ -33,6 +33,8 @@ 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'];
// a draft whose text is still the text it started from is not shown anywhere (opening the editor is not a change)
const CHANGED = `(r.status <> 'draft' OR r.content <> r.base_content)`;
async function moderator(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req);
@ -131,7 +133,7 @@ export function moderationRoutes(app: FastifyInstance) {
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')))
WHERE r.entity = 'work' AND (r.status IN ('submitted', 'approved') OR (r.author_id = $1 AND r.status IN ('draft', 'returned') AND ${CHANGED}))
ORDER BY r.updated_at DESC LIMIT 300`, [u.id]);
const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) });
const review = [];
@ -149,7 +151,7 @@ export function moderationRoutes(app: FastifyInstance) {
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]);
FROM revisions r WHERE entity = 'work' AND entity_id = $1 AND ${CHANGED} 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) } };
});
@ -192,6 +194,14 @@ export function moderationRoutes(app: FastifyInstance) {
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: 'متن بہت لمبا ہے' });
// no change from the text it started from (compared as Divan text, so layout-only differences don't count):
// a plain draft is dropped rather than kept
const same = toText(parse(content)) === toText(parse(r.base_content));
if (same && r.status === 'draft') {
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
return { discarded: true };
}
if (same) 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');
@ -238,6 +248,7 @@ export function moderationRoutes(app: FastifyInstance) {
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
WHERE ${CHANGED}
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

@ -21,6 +21,7 @@ const groups = queue ? [
<Base title="موڈریشن">
<h1>موڈریشن</h1>
<ModNav current={showLog ? 'log' : 'queue'} />
{Astro.url.searchParams.has('unchanged') && <p class="form-done" role="status">متن میں کوئی تبدیلی نہیں تھی، اس لیے مسودہ نہیں رکھا گیا۔</p>}
{groups.map(([title, items, empty]) => (
<section class="lib-section">
<h2>{title} <small class="muted">{ud(items.length)}</small></h2>

View File

@ -15,6 +15,7 @@ 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 && r.data.discarded) return Astro.redirect('/mod?unchanged=1'); // nothing changed: no draft kept
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)}` : ''}`;
@ -64,8 +65,13 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
<button type="button" class="tool" data-cmd="redo" title="تبدیلی دوبارہ">دوبارہ</button>
<button type="button" class="tool" data-urdu-rom="dv-editor-host" title="رومن حروف سے اردو">اب</button>
<button type="button" class="tool" data-urdu-kb="dv-editor-host" title="اردو کی بورڈ">کی بورڈ</button>
<span class="sep"></span>
<button type="button" class="tool" data-cmd="code" aria-pressed="false" title="ساتھ ساتھ دیوان متن (کوڈ) دیکھیں">کوڈ</button>
</div>
<div class="editor-split">
<div id="dv-editor-host"></div>
<pre id="live-code" class="live-code" dir="rtl" hidden aria-label="دیوان متن"></pre>
</div>
<div id="dv-editor-host"></div>
<p class="muted note">Enter: اسی شعر یا بند میں نیا مصرع · خالی مصرع پر Enter: نیا شعر · <a href="/mod/guide" target="_blank">لکھنے کا مکمل طریقہ</a></p>
</div>
<label class="mod-text" id="source-pane" hidden>متن (دیوان متن)
@ -130,6 +136,11 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
fillMeta(meta);
document.getElementById('meta-add')!.onclick = () => row();
const sync = () => { source.value = fromEditor(editor.getJSON(), readMeta()); };
// the code beside the editor (read-only; the سورس tab edits it), updated as you type
const code = document.getElementById('live-code')!;
const showCode = () => { if (!code.hidden) code.textContent = fromEditor(editor.getJSON(), readMeta()); };
editor.on('update', showCode);
rows.addEventListener('input', showCode);
let tab = 'editor';
document.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach((b) => (b.onclick = () => {
if (b.dataset.tab === tab) return;
@ -146,7 +157,8 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
document.querySelector('.toolbar')!.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-cmd]'); if (!b) return;
const c = b.dataset.cmd!;
if (c === 'undo') editor.chain().focus().undo().run();
if (c === 'code') { code.hidden = !code.hidden; b.setAttribute('aria-pressed', String(!code.hidden)); code.parentElement!.classList.toggle('with-code', !code.hidden); showCode(); }
else if (c === 'undo') editor.chain().focus().undo().run();
else if (c === 'redo') editor.chain().focus().redo().run();
else if (c === 'h2' || c === 'h3') actions.heading(editor, c === 'h2' ? 2 : 3);
else if (c === 'label') actions.label(editor, b.dataset.label!);

View File

@ -365,6 +365,11 @@ h1 + .muted { text-align: center; margin-top: 0; }
.tool { font: inherit; font-size: .85rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); cursor: pointer; }
.tool:hover, .tool[aria-pressed='true'] { border-color: var(--brand); color: var(--brand); }
.dv-editor { min-height: 20em; outline: none; margin: 8px 0; }
/* کوڈ: Divan text beside the editor (stacked on phones) */
.editor-split.with-code { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: start; }
@media (max-width: 800px) { .editor-split.with-code { grid-template-columns: minmax(0, 1fr); } }
.live-code { position: sticky; top: 56px; max-height: 80vh; overflow: auto; margin: 8px 0; padding: 10px; white-space: pre-wrap; word-break: break-word;
font-family: 'Noto Naskh Arabic', ui-monospace, monospace; font-size: .9rem; line-height: 1.9; background: var(--inner); border: 1px solid var(--border); border-radius: 10px; unicode-bidi: plaintext; }
/* Tiptap's own CSS turns ligatures off, which breaks Nastaliq (and Naskh's لا); keep the font's shaping */
.ProseMirror.dv-editor { font-variant-ligatures: normal; font-feature-settings: normal; }
.dv-editor:focus-within { border-color: var(--gold); }