Compare commits
27 Commits
feature/se
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 12567631a5 | |||
|
|
c8f5459ad7 | ||
|
|
07e85d7a54 | ||
|
|
cc797fd262 | ||
| e16b4b412e | |||
|
|
804cf0dc09 | ||
| be7140841a | |||
|
|
6874a4e4a2 | ||
| a165f63ece | |||
| e9eb3c6f88 | |||
|
|
24fcbccccd | ||
|
|
61321b5130 | ||
| 77c07c1552 | |||
| fdb42e78d8 | |||
| 0694a2ed4f | |||
| 586f49f836 | |||
| 4fc4e644d6 | |||
|
|
f037968ef2 | ||
|
|
de2e3eb751 | ||
|
|
de1d7eb0a0 | ||
|
|
b2461e228a | ||
|
|
86b26ba737 | ||
| 650b684ae8 | |||
|
|
5c8559c8e9 | ||
| 709b5d4594 | |||
| 7d398cc19d | |||
|
|
aa57dcd78c |
4
NOTICE
4
NOTICE
@ -1,5 +1,5 @@
|
||||
Divan (دیوان)
|
||||
Copyright (C) 2026 Anas Rashid and Divan contributors
|
||||
Copyright (C) 2026 Muhammad Anas Rashid and Divan contributors
|
||||
|
||||
This program is free software under the GNU General Public License version 3 (see LICENSE), with the
|
||||
additional terms below, which the GPL allows in its section 7. They apply to the code written for Divan by its
|
||||
@ -10,7 +10,7 @@ Additional terms under section 7 of the GNU GPL version 3
|
||||
|
||||
1. Attribution (section 7(b)). If you convey this work or a work based on it, keep this attribution in the
|
||||
Appropriate Legal Notices it displays (for a website: its about page or footer):
|
||||
"Based on Divan (https://git.anasrashid.net/anas/divan) by Anas Rashid, under the GNU GPL v3."
|
||||
"Based on Divan (https://git.anasrashid.net/anas/divan) by Muhammad Anas Rashid, under the GNU GPL v3."
|
||||
|
||||
2. Origin (section 7(c)). A modified version must be marked as different from the original, and must not
|
||||
be presented as Divan, or as made, approved or endorsed by Divan or its authors.
|
||||
|
||||
@ -79,4 +79,4 @@ v2 is reaching parity with Ganjoor's features in phases: reading, accounts, comm
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 (see `LICENSE`), with additional terms under its section 7 (see `NOTICE`): keep the attribution "Based on Divan by Anas Rashid", mark modified versions as different, and do not use Divan's name and logos for your own public site ([TRADEMARKS.md](TRADEMARKS.md); set your own in `web/src/lib/brand.ts`). Texts are public domain; the divan-data compilation is CC BY-SA 4.0 (Urdu Wikisource and Wikipedia contributors, and Divan).
|
||||
GPL-3.0 (see `LICENSE`), with additional terms under its section 7 (see `NOTICE`): keep the attribution "Based on Divan by Muhammad Anas Rashid", mark modified versions as different, and do not use Divan's name and logos for your own public site ([TRADEMARKS.md](TRADEMARKS.md); set your own in `web/src/lib/brand.ts`). Texts are public domain; the divan-data compilation is CC BY-SA 4.0 (Urdu Wikisource and Wikipedia contributors, and Divan).
|
||||
|
||||
@ -31,7 +31,7 @@ they identify this project and its site, so that readers know what is Divan and
|
||||
2. Replace the logo files listed above with your own, and the logo in the header (`web/src/layouts/Base.astro`)
|
||||
and home page (`web/src/pages/index.astro`).
|
||||
3. Rewrite the about page (`web/src/pages/about.astro`) for your project, keeping the attribution
|
||||
"Based on Divan by Anas Rashid" from [NOTICE](NOTICE).
|
||||
"Based on Divan by Muhammad Anas Rashid" from [NOTICE](NOTICE).
|
||||
4. Update `web/public/site.webmanifest`.
|
||||
|
||||
Questions or permission requests: open an issue on the Divan repository.
|
||||
|
||||
@ -89,7 +89,7 @@ export async function sessionUser(req: FastifyRequest) {
|
||||
}
|
||||
|
||||
const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
|
||||
const fromSite = (req: FastifyRequest) => {
|
||||
export const fromSite = (req: FastifyRequest) => {
|
||||
const key = process.env.DIVAN_SITE_KEY;
|
||||
if (!key) return LOOPBACK.includes(req.ip);
|
||||
const given = Buffer.from(String(req.headers['x-site-key'] ?? '')), want = Buffer.from(key);
|
||||
|
||||
91
api/src/details.test.ts
Normal file
91
api/src/details.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
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 { execFileSync } from 'node:child_process';
|
||||
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 { checkDetails, parseDetails } from './details.ts';
|
||||
import { pool } from './db.ts';
|
||||
|
||||
after(() => pool.end());
|
||||
|
||||
test('details text: fields, the intro to the end, checks', () => {
|
||||
const d = parseDetails('نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nپہلی سطر\n\nدوسری: سطر');
|
||||
assert.equal(d['تعارف'], 'پہلی سطر\n\nدوسری: سطر', 'a colon in the intro is text');
|
||||
assert.equal(checkDetails('poet', 'نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nx'), null, 'Urdu digits are years');
|
||||
assert.match(checkDetails('poet', 'نام: \nتخلص: اقبال')!, /ضروری/);
|
||||
assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: 1900\nوفات: 1800')!, /پہلے/);
|
||||
assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: سن')!, /عیسوی/);
|
||||
assert.match(checkDetails('book', 'عنوان:')!, /ضروری/);
|
||||
});
|
||||
|
||||
test('poet and book editors: permissions, publishing to divan-data and the site, history and revert', async () => {
|
||||
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
|
||||
process.env.DIVAN_DATA_DIR = data;
|
||||
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
|
||||
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
|
||||
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': `det-${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'), l2 = await person('l2', 'mod-l2');
|
||||
const poet = (await pool.query(`SELECT * FROM poets WHERE url = '/p266'`)).rows[0];
|
||||
const root = (await pool.query(`SELECT id, title FROM categories WHERE poet_id = $1 AND parent_id IS NULL`, [poet.id])).rows[0];
|
||||
const book = (await pool.query(`SELECT id, url, title FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
|
||||
try {
|
||||
assert.equal((await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).statusCode, 403, 'no grant yet');
|
||||
await call('POST', `/api/admin/users/${l2.id}/grants`, { scope: 'poet', target: '/p266', content: ['poets', 'books'], actions: ['edit'] }, admin.token);
|
||||
const can = (await call('GET', `/api/mod/can?category=${book.id}`, undefined, l2.token)).json();
|
||||
assert.deepEqual([can.poet, can.book], [true, true]);
|
||||
|
||||
// the poet: L2 drafts a new intro and years; the admin publishes
|
||||
const cur = (await call('GET', `/api/mod/details/poet/${poet.id}`, undefined, l2.token)).json();
|
||||
assert.equal(cur.fields['تخلص'], poet.nickname);
|
||||
const id = (await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).json().id;
|
||||
const text = `نام: ${poet.name}\nتخلص: ${poet.nickname}\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف\nدوسرا پیراگراف`;
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'نام:\nتخلص: x' }, l2.token)).statusCode, 400);
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: text, summary: 'تعارف' }, l2.token)).statusCode, 200);
|
||||
await pool.query(`UPDATE revisions SET status = 'approved' WHERE id = $1`, [id]); // the L1 step is tested in moderation.test.ts
|
||||
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
|
||||
const now = (await pool.query('SELECT description, birth_year_ce, death_year_ce, birth_year_ah FROM poets WHERE id = $1', [poet.id])).rows[0];
|
||||
assert.deepEqual([now.description, now.birth_year_ce, now.death_year_ce, now.birth_year_ah], ['نیا تعارف\nدوسرا پیراگراف', 1797, 1869, 1212]);
|
||||
assert.match(await readFile(join(data, 'divan', 'p266.poet'), 'utf8'), /^نام: .+\nتخلص: .+\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف/);
|
||||
assert.match(git('log', '-1', '--format=%s'), new RegExp(`^شاعر: ${poet.nickname}: تعارف \\(ورژن 1\\)`));
|
||||
|
||||
// the book's title, by the admin directly
|
||||
const b = (await call('POST', `/api/mod/details/book/${book.id}/draft`, undefined, admin.token)).json().id;
|
||||
await call('POST', `/api/mod/revisions/${b}/save`, { content: 'عنوان: غزلیات (نیا)' }, admin.token);
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${b}/submit`, {}, admin.token)).json().version, 1);
|
||||
assert.equal((await pool.query('SELECT title FROM categories WHERE id = $1', [book.id])).rows[0].title, 'غزلیات (نیا)');
|
||||
assert.equal((await readFile(join(data, 'divan', 'p266', 'ghazal.book'), 'utf8')), 'عنوان: غزلیات (نیا)\n');
|
||||
|
||||
// history: compare with the text before Divan's version, and bring it back as a draft
|
||||
const cmp = (await call('GET', `/api/mod/compare/poet/${poet.id}?a=0&b=1`, undefined, l2.token)).json();
|
||||
assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text === 'نیا تعارف'));
|
||||
const back = (await call('POST', `/api/mod/revert/book/${book.id}`, { version: 0 }, l2.token)).json().id;
|
||||
assert.equal((await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json().revision.content, `عنوان: ${book.title}`);
|
||||
const log = (await call('GET', `/api/mod/log?who=l2-${run}`, undefined, admin.token)).json().entries;
|
||||
assert.ok(log.some((e: any) => e.entity === 'poet' && e.title === poet.nickname && e.url === '/p266'), 'the log names the poet');
|
||||
} finally {
|
||||
await pool.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6, birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`,
|
||||
[poet.id, poet.name, poet.nickname, poet.description, poet.birth_year_ce, poet.death_year_ce, poet.birth_year_ah, poet.death_year_ah]);
|
||||
await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [root.id, root.title]);
|
||||
await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [book.id, book.title]);
|
||||
await pool.query(`DELETE FROM revisions WHERE entity IN ('poet', 'book') AND author_email LIKE $1`, [`%-${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();
|
||||
}
|
||||
});
|
||||
84
api/src/details.ts
Normal file
84
api/src/details.ts
Normal file
@ -0,0 +1,84 @@
|
||||
// Poet and book details (#32): a poet's name, pen name, years and intro, and a book or section's title, edited in
|
||||
// Divan. Each is a revision (entity 'poet', entity_id = poets.id; 'book', entity_id = categories.id) through the same
|
||||
// pipeline as a work. The text is one "label: value" per line; a poet's intro follows "تعارف:" to the end:
|
||||
// نام: محمد اقبال
|
||||
// تخلص: اقبال
|
||||
// پیدائش: 1877
|
||||
// وفات: 1938
|
||||
// تعارف:
|
||||
// ڈاکٹر سر علامہ محمد اقبال …
|
||||
// Published details live in divan-data as divan/<url>.poet and divan/<url>.book, which the export
|
||||
// (export_divan.py) prefers to Wikisource's and Wikipedia's, so the daily sync keeps them.
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { pool } from './db.ts';
|
||||
|
||||
export type DetailKind = 'poet' | 'book';
|
||||
export const isDetail = (entity: string): entity is DetailKind => entity === 'poet' || entity === 'book';
|
||||
const POET = ['نام', 'تخلص', 'پیدائش', 'وفات'];
|
||||
const hijri = (ce: number | null) => (ce ? Math.round(((ce - 622) * 33) / 32) : null); // as export_divan.py
|
||||
|
||||
export function parseDetails(text: string) {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n'), out: Record<string, string> = {};
|
||||
const intro = lines.findIndex((l) => /^تعارف\s*:/.test(l));
|
||||
for (const l of intro < 0 ? lines : lines.slice(0, intro)) {
|
||||
const m = l.match(/^([^:]+):\s*(.*)$/);
|
||||
if (m) out[m[1].trim()] = m[2].trim();
|
||||
}
|
||||
if (intro >= 0) out['تعارف'] = [lines[intro].replace(/^تعارف\s*:\s*/, ''), ...lines.slice(intro + 1)].join('\n').trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
export const detailsText = (kind: DetailKind, d: Record<string, string>) => kind === 'book' ? `عنوان: ${d['عنوان'] ?? ''}`
|
||||
: `${POET.map((l) => `${l}: ${d[l] ?? ''}`).join('\n')}\nتعارف:\n${d['تعارف'] ?? ''}`;
|
||||
|
||||
// what the details are now (the site's values, which follow the latest published version) and that version
|
||||
export async function currentDetails(kind: DetailKind, id: number) {
|
||||
const row = kind === 'poet'
|
||||
? (await pool.query('SELECT id, url, nickname AS title, name, nickname, description, birth_year_ce, death_year_ce FROM poets WHERE id = $1', [id])).rows[0]
|
||||
: (await pool.query('SELECT id, url, title FROM categories WHERE id = $1', [id])).rows[0];
|
||||
if (!row) return null;
|
||||
const last = (await pool.query(
|
||||
`SELECT max(version) AS v FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published'`, [kind, id])).rows[0];
|
||||
const content = detailsText(kind, kind === 'book' ? { عنوان: row.title } : {
|
||||
نام: row.name, تخلص: row.nickname, پیدائش: row.birth_year_ce ?? '', وفات: row.death_year_ce ?? '', تعارف: row.description ?? '' });
|
||||
return { details: { kind, id: row.id, url: row.url, title: row.title }, version: Number(last?.v ?? 0), content };
|
||||
}
|
||||
|
||||
const year = (s: string | undefined) => (s ? Number(s.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x6f0))) : null);
|
||||
|
||||
// what is wrong with the text, or null
|
||||
export function checkDetails(kind: DetailKind, text: string) {
|
||||
const d = parseDetails(text);
|
||||
if (kind === 'book') {
|
||||
if (!d['عنوان']) return 'عنوان ضروری ہے';
|
||||
return d['عنوان'].length > 200 ? 'عنوان بہت لمبا ہے' : null;
|
||||
}
|
||||
if (!d['نام'] || !d['تخلص']) return 'نام اور تخلص ضروری ہیں';
|
||||
if (d['نام'].length > 100 || d['تخلص'].length > 100) return 'نام بہت لمبا ہے';
|
||||
const [born, died] = [year(d['پیدائش']), year(d['وفات'])];
|
||||
for (const y of [born, died]) if (y !== null && !(Number.isInteger(y) && y >= 500 && y <= 2100)) return 'سال عیسوی میں لکھیں، جیسے ۱۸۷۷';
|
||||
if (born && died && died < born) return 'وفات کا سال پیدائش سے پہلے ہے';
|
||||
return (d['تعارف'] ?? '').length > 5000 ? 'تعارف بہت لمبا ہے' : null;
|
||||
}
|
||||
|
||||
export async function applyDetails(client: PoolClient, kind: DetailKind, id: number, text: string) {
|
||||
const d = parseDetails(text);
|
||||
if (kind === 'book') return void (await client.query('UPDATE categories SET title = $2 WHERE id = $1', [id, d['عنوان']]));
|
||||
const [born, died] = [year(d['پیدائش']), year(d['وفات'])];
|
||||
await client.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6,
|
||||
birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`,
|
||||
[id, d['نام'], d['تخلص'], d['تعارف'] || null, born, died, hijri(born), hijri(died)]);
|
||||
// the poet's top level carries their name (as the export makes it)
|
||||
await client.query('UPDATE categories SET title = $2 WHERE poet_id = $1 AND parent_id IS NULL', [id, d['نام']]);
|
||||
}
|
||||
|
||||
export async function writeDetails(dataDir: string, kind: DetailKind, url: string, text: string) {
|
||||
const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.' + kind);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const d = parseDetails(text);
|
||||
for (const k of ['پیدائش', 'وفات']) if (d[k]) d[k] = String(year(d[k])); // plain digits for the export
|
||||
await writeFile(path, detailsText(kind, d).trim() + '\n');
|
||||
return path;
|
||||
}
|
||||
@ -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]);
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
// If another version was published after the draft started, publishing is refused until the draft is redone.
|
||||
// Arranging a book or section (order.ts, entity 'order') uses the same revisions, steps and publishing; it needs
|
||||
// the separate 'arrange' permission. Tagging (tags.ts, entity 'tags-category' / 'tags-work') likewise, with the 'tags'
|
||||
// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission.
|
||||
// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission. A poet's details
|
||||
// (name, pen name, years, intro) and a book or section's title (details.ts, entities 'poet' and 'book') likewise, with
|
||||
// the edit permission on poets or books (#32).
|
||||
// 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
|
||||
@ -20,10 +22,15 @@
|
||||
// GET /api/mod/order/:id a book/section's current order and its history
|
||||
// POST /api/mod/order/:id/draft start (or reopen) my arrangement draft
|
||||
// GET /api/mod/tags/:kind/:id a book/section's (kind category) or work's tags and their history
|
||||
// GET /api/mod/details/:kind/:id a poet's details (kind poet) or a book/section's title (kind book), history
|
||||
// POST /api/mod/details/:kind/:id/draft start (or reopen) my draft of them
|
||||
// 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';
|
||||
@ -37,6 +44,7 @@ import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './
|
||||
import { currentTags, checkTags, applyTags, writeTags, parseTags, tagsText, type Tag, type TagTarget } from './tags.ts';
|
||||
import { currentEbook, checkEbook, applyEbook, writeEbook, mayUpload } from './ebooks.ts';
|
||||
import { publicName } from './auth.ts';
|
||||
import { isDetail, currentDetails, checkDetails, applyDetails, writeDetails, parseDetails, detailsText, type DetailKind } from './details.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);
|
||||
@ -47,11 +55,13 @@ const mayTag = (u: any, kind: TagTarget, id: number) => can(u, 'edit', 'tags', k
|
||||
// may change what this revision changes (a work's text, a book/section's order, tags); reviewing needs L1 or admin
|
||||
const mayEbook = async (u: any, ebookId: number) =>
|
||||
mayUpload(u, (await pool.query('SELECT poet_id FROM ebooks WHERE id = $1', [ebookId])).rows[0]?.poet_id ?? 0);
|
||||
const mayChange = (u: any, r: { entity: string; entity_id: number }) =>
|
||||
const mayDetails = (u: any, kind: DetailKind, id: number) =>
|
||||
kind === 'poet' ? can(u, 'edit', 'poets', { poetId: id }) : can(u, 'edit', 'books', { categoryId: id });
|
||||
const mayChange = (u: any, r: { entity: string; entity_id: number }) => isDetail(r.entity) ? mayDetails(u, r.entity, r.entity_id) :
|
||||
r.entity === 'order' ? mayArrange(u, r.entity_id) : r.entity.startsWith('tags-') ? mayTag(u, tagKind(r), r.entity_id)
|
||||
: r.entity === 'ebook' ? mayEbook(u, r.entity_id) : mayEdit(u, r.entity_id);
|
||||
const tagKind = (r: { entity: string }) => r.entity.slice('tags-'.length) as TagTarget;
|
||||
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook')`;
|
||||
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook', 'poet', 'book')`;
|
||||
const mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r));
|
||||
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)
|
||||
@ -82,10 +92,11 @@ async function current(poemId: number) {
|
||||
|
||||
// what a revision changes: a work, or a book/section's order (work_title/work_url name either)
|
||||
const TARGET = `LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id
|
||||
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id
|
||||
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id`;
|
||||
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category', 'book') AND c.id = r.entity_id
|
||||
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id
|
||||
LEFT JOIN poets pt ON r.entity = 'poet' AND pt.id = r.entity_id`;
|
||||
// (not named URL: that would hide the global URL used for the default divan-data folder)
|
||||
const TARGET_TITLE = `coalesce(p.title, c.title, b.title)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id)`;
|
||||
const TARGET_TITLE = `coalesce(p.title, c.title, b.title, pt.nickname)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id, pt.url)`;
|
||||
const TARGET_COLS = `${TARGET_TITLE} AS work_title, ${TARGET_URL} AS work_url`;
|
||||
|
||||
async function revision(id: number) {
|
||||
@ -107,17 +118,18 @@ 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 });
|
||||
// the section's contents may have changed since (a new work from the sync): the arrangement must be redone
|
||||
const details = isDetail(r.entity);
|
||||
const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content)
|
||||
: ebook ? checkEbook(r.content) : null;
|
||||
: ebook ? checkEbook(r.content) : details ? checkDetails(r.entity, r.content) : null;
|
||||
if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
|
||||
const doc = order || tags || ebook ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
|
||||
const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}`
|
||||
const doc = order || tags || ebook || details ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
|
||||
const title = 'details' in cur ? `${r.entity === 'poet' ? 'شاعر' : 'عنوان'}: ${parseDetails(r.content)[r.entity === 'poet' ? 'تخلص' : 'عنوان']}`
|
||||
: 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}`
|
||||
: 'ebook' in cur ? `ای بک: ${cur.ebook.poet}، ${cur.ebook.title}` : doc!.meta['عنوان'] || cur.poem.title;
|
||||
const version = cur.version + 1, at = new Date().toISOString();
|
||||
// who did it, by public name (divan-data is public: never email addresses)
|
||||
@ -126,7 +138,8 @@ async function publish(r: any, u: any, comment?: string) {
|
||||
const who = (p: any) => p && { id: Number(p.id), name: publicName(p) };
|
||||
const credits = { by: who(author)?.name ?? 'موڈریٹر', reviewedBy: who(reviewer)?.name ?? null, publishedBy: publicName(u) };
|
||||
// divan-data first (the published record, committed to git), then the site's database
|
||||
const files = 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
|
||||
const files = 'details' in cur ? [await writeDetails(dataDir(), r.entity, cur.details.url, r.content)]
|
||||
: 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
|
||||
: 'target' in cur ? [await writeTags(dataDir(), cur.target.url, r.content)]
|
||||
: 'ebook' in cur ? [await writeEbook(dataDir(), cur.ebook, r.content)]
|
||||
: await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
|
||||
@ -142,6 +155,7 @@ async function publish(r: any, u: any, comment?: string) {
|
||||
if (order) await applyOrder(client, r.entity_id, r.content);
|
||||
else if (tags) await applyTags(client, tagKind(r), r.entity_id, r.content);
|
||||
else if (ebook) await applyEbook(client, r.entity_id, r.content);
|
||||
else if (details) await applyDetails(client, r.entity, r.entity_id, r.content);
|
||||
else {
|
||||
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(' '))]);
|
||||
@ -162,6 +176,21 @@ 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)
|
||||
: isDetail(entity) ? currentDetails(entity, 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', 'poet', 'book'];
|
||||
// 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) {
|
||||
@ -171,7 +200,8 @@ export function moderationRoutes(app: FastifyInstance) {
|
||||
if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false, ebooks: false };
|
||||
if (categoryId) {
|
||||
const poetId = (await pool.query('SELECT poet_id FROM categories WHERE id = $1', [categoryId])).rows[0]?.poet_id ?? 0;
|
||||
return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId) };
|
||||
return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId),
|
||||
book: await mayDetails(u, 'book', categoryId), poet: await mayDetails(u, 'poet', poetId) };
|
||||
}
|
||||
return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: poemId }), publish: u.role === 'admin',
|
||||
tags: await mayTag(u, 'work', poemId) };
|
||||
@ -283,6 +313,34 @@ export function moderationRoutes(app: FastifyInstance) {
|
||||
return { id: Number(rows[0].id) };
|
||||
});
|
||||
|
||||
app.get<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const { kind } = req.params, cur = isDetail(kind) ? await currentDetails(kind, Number(req.params.id) || 0) : null;
|
||||
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 r WHERE entity = $1 AND entity_id = $2 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [kind, cur.details.id]);
|
||||
return { kind, target: cur.details, version: cur.version, content: cur.content, fields: parseDetails(cur.content),
|
||||
history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { edit: await mayDetails(u, kind as DetailKind, cur.details.id) } };
|
||||
});
|
||||
|
||||
app.post<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id/draft', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const { kind } = req.params, id = Number(req.params.id) || 0;
|
||||
if (!isDetail(kind)) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||
if (!(await mayDetails(u, kind, id))) return reply.code(403).send({ error: 'اس میں ترمیم کی اجازت نہیں' });
|
||||
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`, [kind, id, u.id])).rows[0];
|
||||
if (open) return { id: Number(open.id) };
|
||||
const cur = await currentDetails(kind, id);
|
||||
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 ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`, [kind, id, 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);
|
||||
@ -302,8 +360,9 @@ export function moderationRoutes(app: FastifyInstance) {
|
||||
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 (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook') {
|
||||
if (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook' || isDetail(r.entity)) {
|
||||
const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : r.entity === 'ebook' ? checkEbook(content)
|
||||
: isDetail(r.entity) ? checkDetails(r.entity, content)
|
||||
: await checkTags(tagKind(r), r.entity_id, content);
|
||||
if (bad) return reply.code(400).send({ error: bad });
|
||||
} else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
|
||||
@ -311,7 +370,7 @@ export function moderationRoutes(app: FastifyInstance) {
|
||||
// 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 norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : r.entity.startsWith('tags-') ? tagsText(parseTags(t) as Tag[])
|
||||
: r.entity === 'ebook' ? t.trim() : toText(parse(t)));
|
||||
: r.entity === 'ebook' ? t.trim() : isDetail(r.entity) ? detailsText(r.entity, parseDetails(t)) : toText(parse(t)));
|
||||
const same = norm(content) === norm(r.base_content);
|
||||
if (same && r.status === 'draft') {
|
||||
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
|
||||
@ -358,14 +417,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 ?? cur.details, 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 };
|
||||
});
|
||||
}
|
||||
|
||||
@ -36,10 +36,10 @@ test('arranging: its own permission; L2 reorders, L1 approves, admin publishes t
|
||||
const grant = (m: any, actions: string[]) =>
|
||||
pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, '{works}', $3)`, [m.id, cat.id, actions]);
|
||||
await grant(l2, ['edit']);
|
||||
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false }, 'editing is not arranging');
|
||||
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false, book: false, poet: false }, 'editing is not arranging');
|
||||
assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403);
|
||||
await grant(l2, ['arrange']); await grant(l1, ['arrange']);
|
||||
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false });
|
||||
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false, book: false, poet: false });
|
||||
|
||||
const { id } = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json();
|
||||
const cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json();
|
||||
|
||||
@ -16,6 +16,7 @@ import { nameMatches } from './search.ts';
|
||||
import { lookup, PUNCT } from './dictionary.ts';
|
||||
import { authRoutes } from './auth.ts';
|
||||
import { adminRoutes } from './admin.ts';
|
||||
import { statsRoutes } from './stats.ts';
|
||||
import { permissionRoutes } from './permissions.ts';
|
||||
import { libraryRoutes } from './library.ts';
|
||||
import { moderationRoutes } from './moderation.ts';
|
||||
@ -220,6 +221,7 @@ siteRoutes(app);
|
||||
tagRoutes(app);
|
||||
ebookRoutes(app);
|
||||
feedRoutes(app);
|
||||
statsRoutes(app);
|
||||
|
||||
const port = Number(process.env.PORT ?? 4100);
|
||||
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
||||
|
||||
42
api/src/stats.test.ts
Normal file
42
api/src/stats.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { authRoutes } from './auth.ts';
|
||||
import { statsRoutes } from './stats.ts';
|
||||
import { pool } from './db.ts';
|
||||
|
||||
after(() => pool.end());
|
||||
|
||||
test('statistics: hits only from the site, counts without addresses, admin-only dashboard', async () => {
|
||||
const app = Fastify();
|
||||
authRoutes(app); statsRoutes(app);
|
||||
const run = Date.now(), path = `/stats-test-${run}`;
|
||||
const hit = (body: object, headers: object = {}) => app.inject({ method: 'POST', url: '/api/stats/hit', payload: body, headers: headers as any });
|
||||
try {
|
||||
process.env.DIVAN_SITE_KEY = 'site-key';
|
||||
assert.equal((await hit({ path, ip: '1.2.3.4', ua: 'x' })).statusCode, 403, 'not from the site');
|
||||
const site = { 'x-site-key': 'site-key' };
|
||||
for (const ip of ['1.2.3.4', '1.2.3.4', '5.6.7.8']) assert.equal((await hit({ path, ip, ua: 'Mozilla', ref: `ref-${run}.pk` }, site)).statusCode, 200);
|
||||
const views = (await pool.query('SELECT views FROM stat_views WHERE path = $1', [path])).rows[0].views;
|
||||
assert.equal(views, 3);
|
||||
const hashes = (await pool.query('SELECT h FROM stat_visitors WHERE day = (now() AT TIME ZONE \'Asia/Karachi\')::date')).rows.map((r) => r.h);
|
||||
assert.ok(!hashes.some((h) => h.includes('1.2.3.4')), 'no addresses stored');
|
||||
assert.equal((await pool.query('SELECT n FROM stat_referrers WHERE host = $1', [`ref-${run}.pk`])).rows[0].n, 3);
|
||||
|
||||
const s = (await app.inject({ method: 'POST', url: '/api/auth/signup', payload: { email: `stats-${run}@divan.test`, password: 'pass-word-1' }, headers: { 'x-client-ip': `stats-${run}` } })).json();
|
||||
const get = () => app.inject({ method: 'GET', url: '/api/admin/stats?days=7', headers: { authorization: `Bearer ${s.token}` } });
|
||||
assert.equal((await get()).statusCode, 403, 'readers cannot see statistics');
|
||||
await pool.query(`UPDATE users SET role = 'admin' WHERE id = $1`, [s.user.id]);
|
||||
const st = (await get()).json();
|
||||
assert.equal(st.daily.length, 7);
|
||||
assert.ok(st.daily.at(-1).views >= 3 && st.daily.at(-1).visitors >= 2);
|
||||
assert.ok(st.pages.some((p: any) => p.path === path && p.views === 3));
|
||||
assert.ok(st.accounts.roles.admin >= 1 && st.accounts.new >= 1);
|
||||
} finally {
|
||||
delete process.env.DIVAN_SITE_KEY;
|
||||
await pool.query('DELETE FROM stat_views WHERE path = $1', [path]);
|
||||
await pool.query('DELETE FROM stat_referrers WHERE host = $1', [`ref-${run}.pk`]);
|
||||
await pool.query('DELETE FROM users WHERE email = $1', [`stats-${run}@divan.test`]);
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
63
api/src/stats.ts
Normal file
63
api/src/stats.ts
Normal file
@ -0,0 +1,63 @@
|
||||
// Privacy-friendly statistics for admins (#86): page views, visitors and where they came from, plus moderation and
|
||||
// account activity. No cookies and nothing personal is stored: the site reports a page view with the reader's address
|
||||
// and browser, and only a one-day salted hash of them is kept (to count visitors per day); the salt is never stored.
|
||||
// POST /api/stats/hit {path, ref, ip, ua} from the site only (x-site-key)
|
||||
// GET /api/admin/stats?days=30 the admin dashboard's numbers
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { pool } from './db.ts';
|
||||
import { fromSite } from './auth.ts';
|
||||
import { requireAdmin } from './admin.ts';
|
||||
|
||||
const TODAY = `(now() AT TIME ZONE 'Asia/Karachi')::date`;
|
||||
// ponytail: the salt is per process; a restart mid-day counts a returning visitor once more that day
|
||||
let salt = { day: '', key: randomBytes(32) };
|
||||
const visitor = (ip: string, ua: string) => {
|
||||
const day = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Karachi' });
|
||||
if (salt.day !== day) salt = { day, key: randomBytes(32) };
|
||||
return createHash('sha256').update(salt.key).update(ip).update('\n').update(ua).digest('base64url').slice(0, 22);
|
||||
};
|
||||
|
||||
export function statsRoutes(app: FastifyInstance) {
|
||||
app.post<{ Body: { path?: string; ref?: string; ip?: string; ua?: string } }>('/api/stats/hit', async (req, reply) => {
|
||||
if (!fromSite(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||
const { path = '', ref = '', ip = '', ua = '' } = req.body ?? {};
|
||||
if (!path.startsWith('/') || path.length > 300) return reply.code(400).send({ error: 'path' });
|
||||
await pool.query(`INSERT INTO stat_views (day, path, views) VALUES (${TODAY}, $1, 1)
|
||||
ON CONFLICT (day, path) DO UPDATE SET views = stat_views.views + 1`, [path]);
|
||||
await pool.query(`INSERT INTO stat_visitors (day, h) VALUES (${TODAY}, $1) ON CONFLICT DO NOTHING`, [visitor(ip, ua)]);
|
||||
if (ref) await pool.query(`INSERT INTO stat_referrers (day, host, n) VALUES (${TODAY}, $1, 1)
|
||||
ON CONFLICT (day, host) DO UPDATE SET n = stat_referrers.n + 1`, [ref.slice(0, 200)]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { days?: string } }>('/api/admin/stats', async (req, reply) => {
|
||||
if (!(await requireAdmin(req, reply))) return;
|
||||
const days = Math.min(365, Math.max(1, Number(req.query.days) || 30));
|
||||
const since = `${TODAY} - ${days - 1}`;
|
||||
const q = async (sql: string) => (await pool.query(sql)).rows;
|
||||
const [daily, pages, referrers, actions, people, waiting, roles, accounts, library, content] = await Promise.all([
|
||||
q(`SELECT d::date::text AS day, coalesce(v.views, 0)::int AS views, coalesce(u.visitors, 0)::int AS visitors
|
||||
FROM generate_series(${since}, ${TODAY}, interval '1 day') d
|
||||
LEFT JOIN (SELECT day, sum(views) AS views FROM stat_views GROUP BY day) v ON v.day = d::date
|
||||
LEFT JOIN (SELECT day, count(*) AS visitors FROM stat_visitors GROUP BY day) u ON u.day = d::date ORDER BY d`),
|
||||
q(`SELECT s.path, sum(s.views)::int AS views, coalesce(p.title, c.title, t.nickname) AS title
|
||||
FROM stat_views s LEFT JOIN poems p ON p.url = s.path LEFT JOIN categories c ON c.url = s.path LEFT JOIN poets t ON t.url = s.path
|
||||
WHERE s.day >= ${since} GROUP BY s.path, p.title, c.title, t.nickname ORDER BY views DESC LIMIT 20`),
|
||||
q(`SELECT host, sum(n)::int AS n FROM stat_referrers WHERE day >= ${since} GROUP BY host ORDER BY n DESC LIMIT 10`),
|
||||
q(`SELECT action, count(*)::int AS n FROM revision_events WHERE at >= ${since} GROUP BY action ORDER BY n DESC`),
|
||||
q(`SELECT actor_email AS email, count(*)::int AS n, count(*) FILTER (WHERE action = 'published')::int AS published
|
||||
FROM revision_events WHERE at >= ${since} AND actor_email <> 'server' GROUP BY actor_email ORDER BY n DESC LIMIT 10`),
|
||||
q(`SELECT status, count(*)::int AS n FROM revisions WHERE status IN ('submitted', 'approved') GROUP BY status`),
|
||||
q(`SELECT role, count(*)::int AS n FROM users GROUP BY role`),
|
||||
q(`SELECT count(*) FILTER (WHERE created_at >= ${since})::int AS new,
|
||||
(SELECT count(DISTINCT user_id) FROM sessions WHERE created_at >= ${since})::int AS signed_in FROM users`),
|
||||
q(`SELECT kind, count(*)::int AS n FROM library GROUP BY kind`),
|
||||
q(`SELECT (SELECT count(*) FROM ebooks WHERE published)::int AS ebooks, (SELECT count(*) FROM tags)::int AS tags,
|
||||
(SELECT count(*) FROM revisions WHERE status = 'published')::int AS versions`),
|
||||
]);
|
||||
return { days, daily, pages, referrers, moderation: { actions, people, waiting: Object.fromEntries(waiting.map((r) => [r.status, r.n])) },
|
||||
accounts: { ...accounts[0], roles: Object.fromEntries(roles.map((r) => [r.role, r.n])) },
|
||||
library: Object.fromEntries(library.map((r) => [r.kind, r.n])), content: content[0] };
|
||||
});
|
||||
}
|
||||
@ -253,3 +253,9 @@ ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS coauthor_ids integer[] NOT NULL DEFA
|
||||
CREATE INDEX IF NOT EXISTS ebooks_coauthors ON ebooks USING gin (coauthor_ids);
|
||||
ALTER TABLE entity_tags DROP CONSTRAINT IF EXISTS entity_tags_entity_check;
|
||||
ALTER TABLE entity_tags ADD CONSTRAINT entity_tags_entity_check CHECK (entity IN ('category', 'work', 'ebook'));
|
||||
|
||||
-- visitor statistics (api/src/stats.ts, #86): counts only; no cookies, no addresses. A visitor is a hash of their
|
||||
-- address and browser with a salt that lives one day in memory, so it cannot be traced back or followed across days.
|
||||
CREATE TABLE IF NOT EXISTS stat_views (day date NOT NULL, path text NOT NULL, views integer NOT NULL DEFAULT 0, PRIMARY KEY (day, path));
|
||||
CREATE TABLE IF NOT EXISTS stat_visitors (day date NOT NULL, h text NOT NULL, PRIMARY KEY (day, h));
|
||||
CREATE TABLE IF NOT EXISTS stat_referrers (day date NOT NULL, host text NOT NULL, n integer NOT NULL DEFAULT 0, PRIMARY KEY (day, host));
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
---
|
||||
const { current } = Astro.props as { current: 'users' | 'audit' };
|
||||
const { current } = Astro.props as { current: 'users' | 'audit' | 'stats' };
|
||||
---
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" aria-current={current === 'users' ? 'page' : undefined}>صارفین</a>
|
||||
<a href="/admin/audit" aria-current={current === 'audit' ? 'page' : undefined}>ریکارڈ</a>
|
||||
<a href="/admin/stats" aria-current={current === 'stats' ? 'page' : undefined}>اعداد و شمار</a>
|
||||
</nav>
|
||||
|
||||
36
web/src/components/History.astro
Normal file
36
web/src/components/History.astro
Normal 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>}
|
||||
@ -1,12 +1,14 @@
|
||||
---
|
||||
// a page's tags, folded away at the end of its content (#52): "ٹیگ" and how many; open to see the chips
|
||||
import Tags from './Tags.astro';
|
||||
// a page's tags at the end of its content (#52): «ٹیگ» and how many, at the start of the line; opened, the tags follow it
|
||||
// on the same line and wrap across the content's width. A visually hidden checkbox holds open/closed (no script needed)
|
||||
import { ud } from '../lib/urdu';
|
||||
const { tags } = Astro.props as { tags: { type: string; name: string }[] };
|
||||
const href = (t: { type: string; name: string }) => `/tag/${encodeURIComponent(t.type)}/${encodeURIComponent(t.name)}`;
|
||||
---
|
||||
{tags.length > 0 && (
|
||||
<details class="tags-box">
|
||||
<summary>ٹیگ <small class="muted">{ud(tags.length)}</small></summary>
|
||||
<Tags tags={tags} />
|
||||
</details>
|
||||
<div class="tags-box">
|
||||
<input type="checkbox" id="tags-open" class="sr-only" />
|
||||
<label for="tags-open" class="tags-toggle">ٹیگ <small class="muted">{ud(tags.length)}</small></label>
|
||||
{tags.map((t) => <a class="tag-chip" href={href(t)}><small>{t.type}</small> {t.name}</a>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
---
|
||||
// fonts are self-hosted (no Google Fonts request; works with Brave Shields and offline)
|
||||
import '@fontsource/noto-naskh-arabic/400.css';
|
||||
import '@fontsource/noto-naskh-arabic/500.css';
|
||||
import '@fontsource/noto-naskh-arabic/600.css';
|
||||
import '@fontsource/noto-naskh-arabic/700.css';
|
||||
import '@fontsource/noto-nastaliq-urdu/400.css';
|
||||
import '@fontsource/noto-nastaliq-urdu/700.css';
|
||||
@ -52,6 +54,10 @@ import { siteUrl } from '../lib/site-url';
|
||||
const f = new URLSearchParams(location.search).get('font');
|
||||
if (f === 'nastaliq' || f === 'naskh') localStorage.setItem('divan-font', f);
|
||||
if (localStorage.getItem('divan-font') === 'nastaliq') document.documentElement.classList.add('nastaliq');
|
||||
const w = localStorage.getItem('divan-weight'); // Naskh's weight (400-700), heavier for small screens
|
||||
if (w) document.documentElement.style.setProperty('--naskh-weight', w);
|
||||
const z = localStorage.getItem('divan-size'); // text size in % (100 by default), every page scales with it
|
||||
if (z) document.documentElement.style.setProperty('--text-size', z + '%');
|
||||
const t = localStorage.getItem('divan-theme');
|
||||
if (t) document.documentElement.dataset.theme = t;
|
||||
const pal = localStorage.getItem('divan-palette'); // sepia when unset
|
||||
@ -90,6 +96,10 @@ import { siteUrl } from '../lib/site-url';
|
||||
<div class="display-panel">
|
||||
<p>خط</p>
|
||||
<div class="seg"><button type="button" data-font="naskh">نسخ</button><button type="button" data-font="nastaliq" class="f-nastaliq">نستعلیق</button></div>
|
||||
<label class="weight"><span>نسخ کی موٹائی</span>
|
||||
<input type="range" min="400" max="700" step="100" value="400" data-weight aria-label="نسخ کی موٹائی" /></label>
|
||||
<label class="weight"><span>حروف کا سائز</span>
|
||||
<input type="range" min="90" max="150" step="10" value="100" data-size aria-label="حروف کا سائز" /></label>
|
||||
<p>رنگ</p>
|
||||
<div class="swatches">
|
||||
{[['', 'سپیا', '#fbf6ea', '#bf3f2a'], ['teal', 'فیروزی', '#e2f0ee', '#0b4f6c'], ['lapis', 'نیلگوں', '#e6ebf8', '#1d3b8b'],
|
||||
@ -181,13 +191,30 @@ import { siteUrl } from '../lib/site-url';
|
||||
const marks = () => {
|
||||
const on = { font: root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh', palette: root.dataset.palette ?? '', theme: root.dataset.theme ?? '' };
|
||||
panel.querySelectorAll('[data-font]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.font === on.font)));
|
||||
weight.value = root.style.getPropertyValue('--naskh-weight') || '400';
|
||||
weight.disabled = on.font === 'nastaliq'; // Nastaliq comes in regular and bold only
|
||||
size.value = parseInt(root.style.getPropertyValue('--text-size')) || '100';
|
||||
panel.querySelectorAll('[data-palette]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.palette === on.palette)));
|
||||
panel.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === on.theme)));
|
||||
panel.querySelectorAll('[data-digits]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.digits === (root.dataset.digits ?? ''))));
|
||||
};
|
||||
const weight = panel.querySelector('[data-weight]');
|
||||
weight.addEventListener('input', () => {
|
||||
if (weight.value === '400') root.style.removeProperty('--naskh-weight'); else root.style.setProperty('--naskh-weight', weight.value);
|
||||
store('divan-weight', weight.value === '400' ? '' : weight.value);
|
||||
});
|
||||
const size = panel.querySelector('[data-size]');
|
||||
size.addEventListener('input', () => {
|
||||
if (size.value === '100') root.style.removeProperty('--text-size'); else root.style.setProperty('--text-size', size.value + '%');
|
||||
store('divan-size', size.value === '100' ? '' : size.value);
|
||||
});
|
||||
panel.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
if (b.dataset.font) { root.classList.toggle('nastaliq', b.dataset.font === 'nastaliq'); store('divan-font', b.dataset.font); }
|
||||
if (b.dataset.font) {
|
||||
// switching to Nastaliq: a short note that it is for the text (the interface stays Naskh, by design)
|
||||
if (b.dataset.font === 'nastaliq' && !root.classList.contains('nastaliq')) toast('نستعلیق صرف کلام اور متن پر؛ باقی سائٹ نسخ میں رہتی ہے', 'info');
|
||||
root.classList.toggle('nastaliq', b.dataset.font === 'nastaliq'); store('divan-font', b.dataset.font);
|
||||
}
|
||||
if (b.dataset.palette != null) { if (b.dataset.palette) root.dataset.palette = b.dataset.palette; else delete root.dataset.palette; store('divan-palette', b.dataset.palette); }
|
||||
if (b.dataset.digits != null) {
|
||||
// numerals: a cookie, so the server sends pages with them; switching back to Eastern reloads the page
|
||||
@ -305,11 +332,14 @@ import { siteUrl } from '../lib/site-url';
|
||||
document.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return;
|
||||
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
|
||||
// in an open dialog (the note box) the header can't be reached: the keyboard moves under the box, and back when it closes
|
||||
const box = b.dataset.urduKb && target.closest('dialog');
|
||||
if (box) { kb.classList.remove('kb-float'); target.closest('.in-box-field')?.after(kb); }
|
||||
else if (b.dataset.urduKb && target.isContentEditable) { kb.classList.add('kb-float'); document.body.append(kb); } // the editor: floating
|
||||
else if (b.dataset.urduKb) home();
|
||||
// a field's own keyboard opens right under that field (in view while typing, also in a dialog), and goes back to
|
||||
// the header when closed; open under another field, it moves here
|
||||
if (b.dataset.urduKb && target.isContentEditable) { kb.classList.add('kb-float'); document.body.append(kb); } // the editor: floating
|
||||
else if (b.dataset.urduKb) {
|
||||
const at = target.closest('.in-box-field') ?? target;
|
||||
if (!kb.hidden && kb.previousElementSibling !== at) closeKb();
|
||||
kb.classList.remove('kb-float'); at.after(kb);
|
||||
}
|
||||
(b.dataset.urduKb ? kbBtn : romBtn).click();
|
||||
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
|
||||
});
|
||||
|
||||
@ -7,5 +7,5 @@ export const brand = {
|
||||
// where readers write: questions, corrections, copyright claims (privacy and terms pages)
|
||||
contact: { text: 'ہم سے رابطہ کریں', url: 'https://git.anasrashid.net/anas/divan/issues' },
|
||||
// the attribution forks keep (NOTICE, GPL v3 section 7(b))
|
||||
basedOn: { text: 'Divan by Anas Rashid', url: 'https://git.anasrashid.net/anas/divan' },
|
||||
basedOn: { text: 'Divan by Muhammad Anas Rashid', url: 'https://git.anasrashid.net/anas/divan' },
|
||||
};
|
||||
|
||||
@ -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', ' ');
|
||||
|
||||
@ -4,6 +4,21 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { auth, COOKIE } from './lib/auth';
|
||||
|
||||
// visitor statistics (#86): each reading page shown is reported to the API without waiting; the API keeps only counts
|
||||
// and a one-day hash. Bots, link previews, private pages (accounts, moderation) and errors are not counted.
|
||||
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
|
||||
const PRIVATE = /^\/(api|mod|admin|account|library|notes|words|writers|signin|signup|_astro|ebooks\/file|health)(\/|$)/;
|
||||
const BOT = /bot|crawl|spider|slurp|preview|fetch|curl|wget|python|java\/|go-http|headless|lighthouse|monitor/i;
|
||||
function count(ctx: any, res: Response) {
|
||||
const ua = ctx.request.headers.get('user-agent') ?? '', path = ctx.url.pathname;
|
||||
if (ctx.request.method !== 'GET' || res.status !== 200 || !res.headers.get('content-type')?.includes('text/html') || !ua || BOT.test(ua) || PRIVATE.test(path)) return;
|
||||
let ip = '', ref = '';
|
||||
try { ip = ctx.clientAddress; } catch {}
|
||||
try { const h = new URL(ctx.request.headers.get('referer') ?? '').hostname; if (h !== ctx.url.hostname) ref = h.replace(/^www\./, ''); } catch {}
|
||||
fetch(`${API}/api/stats/hit`, { method: 'POST', headers: { 'content-type': 'application/json', ...(process.env.DIVAN_SITE_KEY && { 'x-site-key': process.env.DIVAN_SITE_KEY }) },
|
||||
body: JSON.stringify({ path, ref, ip, ua }) }).catch(() => {});
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (ctx, next) => {
|
||||
const token = ctx.cookies.get(COOKIE)?.value;
|
||||
ctx.locals.user = null;
|
||||
@ -21,6 +36,7 @@ export const onRequest = defineMiddleware(async (ctx, next) => {
|
||||
msg: String(err?.message ?? err), stack: String(err?.stack ?? '').split('\n').slice(0, 8).join('\n') }));
|
||||
throw err;
|
||||
}
|
||||
count(ctx, res);
|
||||
if (ctx.cookies.get('divan-digits')?.value !== 'latn' || !res.headers.get('content-type')?.includes('text/html')) return res;
|
||||
// text only: scripts and styles are left alone (the page's own scripts look for Eastern digits)
|
||||
const html = (await res.text()).split(/(<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>)/)
|
||||
|
||||
@ -76,7 +76,8 @@ const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'
|
||||
// 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 };
|
||||
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41)
|
||||
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41); with the edit permission
|
||||
// on poets or books, "تفصیل" (a poet's name, years, intro) or "عنوان" (a book or section's title) (#32)
|
||||
const catCan = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
|
||||
? (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data : {};
|
||||
const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : catCan.tags;
|
||||
@ -113,18 +114,18 @@ import TagsBox from '../components/TagsBox.astro';
|
||||
|
||||
{page.type === 'poet' && (
|
||||
<>
|
||||
<h1 data-readable>{page.poet.name}</h1>
|
||||
<h1 class="lit" data-readable>{page.poet.name}</h1>
|
||||
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
|
||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
|
||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}{catCan.poet && <> <form method="post" action={`/mod/details/poet/${poetId}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> تفصیل</button></form></>}</div>
|
||||
<p class="muted">{years(page.poet)}</p>
|
||||
{page.poet.description && <div class="intro">{page.poet.description}</div>}
|
||||
{page.poet.description && <div class="intro">{page.poet.description.split(/\n\s*\n/).map((p: string) => <p>{p}</p>)}</div>}
|
||||
</>
|
||||
)}
|
||||
{page.type === 'category' && (
|
||||
<>
|
||||
<h1 data-readable>{page.category.title}</h1>
|
||||
<h1 class="lit" data-readable>{page.category.title}</h1>
|
||||
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
|
||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
|
||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}{catCan.book && <> <form method="post" action={`/mod/details/book/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> عنوان</button></form></>}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -147,7 +148,7 @@ import TagsBox from '../components/TagsBox.astro';
|
||||
{page.type === 'poem' && (
|
||||
<>
|
||||
<article class="work" data-readable>
|
||||
<h1><Seg s={show(page.poem.title)} /></h1>
|
||||
<h1 class="lit"><Seg s={show(page.poem.title)} /></h1>
|
||||
<p class="byline">{page.poet.nickname}</p>
|
||||
{re && (
|
||||
<p class="hl-note muted">
|
||||
|
||||
@ -48,8 +48,8 @@ import Icon from '../components/Icon.astro';
|
||||
<form method="post" class="account-form">
|
||||
<h2>پروفائل</h2>
|
||||
<input type="hidden" name="act" value="profile" />
|
||||
<label>پورا نام<input name="full_name" value={user.full_name} maxlength="100" dir="auto" autocomplete="name" /></label>
|
||||
<label>تعارف<textarea name="bio" maxlength="1000" rows="4" dir="auto">{user.bio}</textarea></label>
|
||||
<label>پورا نام<span class="in-box-field"><input id="p-name" name="full_name" value={user.full_name} maxlength="100" dir="auto" autocomplete="name" data-urdu /><button type="button" class="in-box rom" data-urdu-rom="p-name" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ">اب</button><button type="button" class="in-box" data-urdu-kb="p-name" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button></span></label>
|
||||
<label>تعارف<span class="in-box-field note-field"><textarea id="p-bio" name="bio" maxlength="1000" rows="4" dir="auto" data-urdu>{user.bio}</textarea><button type="button" class="in-box rom" data-urdu-rom="p-bio" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ">اب</button><button type="button" class="in-box" data-urdu-kb="p-bio" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button></span></label>
|
||||
<button type="submit" class="btn primary"><Icon name="save" size={18} /> محفوظ کریں</button>
|
||||
</form>
|
||||
|
||||
|
||||
85
web/src/pages/admin/stats.astro
Normal file
85
web/src/pages/admin/stats.astro
Normal file
@ -0,0 +1,85 @@
|
||||
---
|
||||
// Admin: statistics (#86): readers (page views and visitors per day, top pages, where they came from), moderation
|
||||
// (steps taken, by whom, waiting) and accounts. Counts only: nothing here identifies a reader (see /privacy).
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import AdminNav from '../../components/AdminNav.astro';
|
||||
import { asUser, COOKIE } from '../../lib/auth';
|
||||
import { ud } from '../../lib/urdu';
|
||||
import { ROLE } from '../../lib/roles';
|
||||
import { EVENT } from '../../lib/mod';
|
||||
|
||||
const me = Astro.locals.user;
|
||||
if (!me) return Astro.redirect('/signin?next=/admin/stats');
|
||||
if (me.role !== 'admin') return new Response('صرف ایڈمن کے لیے', { status: 403 });
|
||||
const days = [7, 30, 90, 365].includes(Number(Astro.url.searchParams.get('days'))) ? Number(Astro.url.searchParams.get('days')) : 30;
|
||||
const s = (await asUser(Astro.cookies.get(COOKIE)!.value, `/api/admin/stats?days=${days}`)).data;
|
||||
const sum = (k: 'views' | 'visitors') => s.daily.reduce((n: number, d: any) => n + d[k], 0);
|
||||
const max = Math.max(1, ...s.daily.map((d: any) => d.views));
|
||||
const LIB: Record<string, string> = { poet: 'شاعر', category: 'کتابیں', poem: 'کلام', couplet: 'اشعار', phrase: 'ٹکڑے', word: 'الفاظ', page: 'صفحات' };
|
||||
const label = (d: string) => ud(d.slice(5).replace('-', '/'));
|
||||
---
|
||||
<Base title="ایڈمن: اعداد و شمار">
|
||||
<h1>ایڈمن</h1>
|
||||
<AdminNav current="stats" />
|
||||
<nav class="stats-range" aria-label="مدت">
|
||||
{[7, 30, 90, 365].map((d) => <a href={`?days=${d}`} aria-current={d === days ? 'page' : undefined} class="chip">{d === 365 ? 'ایک سال' : `${ud(d)} دن`}</a>)}
|
||||
</nav>
|
||||
<nav class="stat-tiles" aria-label="خلاصہ">
|
||||
<div class="stat"><strong>{ud(sum('views'))}</strong><span>صفحات دیکھے گئے</span></div>
|
||||
<div class="stat"><strong>{ud(sum('visitors'))}</strong><span>قارئین (روزانہ کا مجموعہ)</span></div>
|
||||
<div class="stat"><strong>{ud(s.accounts.new)}</strong><span>نئے اکاؤنٹ</span></div>
|
||||
<div class="stat"><strong>{ud(s.moderation.actions.find((a: any) => a.action === 'published')?.n ?? 0)}</strong><span>اشاعتیں</span></div>
|
||||
</nav>
|
||||
|
||||
<section class="lib-section">
|
||||
<h2>روزانہ</h2>
|
||||
<div class="bars" role="img" aria-label={`روزانہ صفحات: ${s.daily.map((d: any) => `${label(d.day)} ${ud(d.views)}`).join('، ')}`}>
|
||||
{s.daily.map((d: any) => (
|
||||
<div class="bar" title={`${label(d.day)}: ${ud(d.views)} صفحات، ${ud(d.visitors)} قارئین`}>
|
||||
<span style={`height:${(d.views / max) * 100}%`}></span><i style={`height:${(d.visitors / max) * 100}%`}></i>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p class="muted center"><span class="key-views"></span> صفحات · <span class="key-visitors"></span> قارئین · {label(s.daily[0].day)} تا {label(s.daily.at(-1).day)}</p>
|
||||
</section>
|
||||
|
||||
<div class="stats-grid">
|
||||
<section class="lib-section">
|
||||
<h2>سب سے زیادہ پڑھے گئے</h2>
|
||||
{s.pages.length ? <table class="admin-table"><tbody>
|
||||
{s.pages.map((p: any) => <tr><td><a href={p.path}>{p.title ?? p.path}</a></td><td>{ud(p.views)}</td></tr>)}
|
||||
</tbody></table> : <p class="muted">ابھی کچھ نہیں۔</p>}
|
||||
</section>
|
||||
<section class="lib-section">
|
||||
<h2>کہاں سے آئے</h2>
|
||||
{s.referrers.length ? <table class="admin-table"><tbody>
|
||||
{s.referrers.map((r: any) => <tr><td><bdi dir="ltr">{r.host}</bdi></td><td>{ud(r.n)}</td></tr>)}
|
||||
</tbody></table> : <p class="muted">ابھی کچھ نہیں (سیدھے آنے والے یہاں نہیں گنے جاتے)۔</p>}
|
||||
</section>
|
||||
<section class="lib-section">
|
||||
<h2>موڈریشن</h2>
|
||||
<p>جائزے کے منتظر: <strong>{ud(s.moderation.waiting.submitted ?? 0)}</strong> · اشاعت کے منتظر: <strong>{ud(s.moderation.waiting.approved ?? 0)}</strong>
|
||||
· کل شائع شدہ ورژن: {ud(s.content.versions)}</p>
|
||||
<table class="admin-table"><tbody>
|
||||
{s.moderation.actions.map((a: any) => <tr><td>{EVENT[a.action] ?? a.action}</td><td>{ud(a.n)}</td></tr>)}
|
||||
</tbody></table>
|
||||
{s.moderation.people.length > 0 && <>
|
||||
<h3>سب سے زیادہ کام</h3>
|
||||
<table class="admin-table"><thead><tr><th>موڈریٹر</th><th>قدم</th><th>اشاعتیں</th></tr></thead><tbody>
|
||||
{s.moderation.people.map((p: any) => <tr><td><bdi dir="ltr">{p.email}</bdi></td><td>{ud(p.n)}</td><td>{ud(p.published)}</td></tr>)}
|
||||
</tbody></table>
|
||||
</>}
|
||||
</section>
|
||||
<section class="lib-section">
|
||||
<h2>اکاؤنٹ اور محفوظ شدہ</h2>
|
||||
<p>اس مدت میں لاگ ان کرنے والے: <strong>{ud(s.accounts.signed_in)}</strong></p>
|
||||
<table class="admin-table"><tbody>
|
||||
{Object.entries(s.accounts.roles).map(([r, n]: any) => <tr><td>{ROLE[r] ?? r}</td><td>{ud(n)}</td></tr>)}
|
||||
{Object.entries(s.library).map(([k, n]: any) => <tr><td>محفوظ: {LIB[k] ?? k}</td><td>{ud(n)}</td></tr>)}
|
||||
<tr><td>ای بکس</td><td>{ud(s.content.ebooks)}</td></tr>
|
||||
<tr><td>ٹیگ</td><td>{ud(s.content.tags)}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</div>
|
||||
<p class="muted center">ان اعداد میں کسی قاری کی شناخت نہیں: نہ کوکی، نہ پتا محفوظ ہوتا ہے (<a href="/privacy">رازداری</a>)۔</p>
|
||||
</Base>
|
||||
@ -3,6 +3,7 @@
|
||||
// Switch palette, light/dark and font from the header's display settings.
|
||||
import Base from '../layouts/Base.astro';
|
||||
import Icon from '../components/Icon.astro';
|
||||
import TagsBox from '../components/TagsBox.astro';
|
||||
if (import.meta.env.PROD) return new Response(null, { status: 404 });
|
||||
const colours = [
|
||||
['surfaces', ['surface-page', 'surface-raised', 'surface-poem', 'surface-sunken']],
|
||||
@ -72,6 +73,9 @@ const radii = ['sm', 'md', 'lg', 'pill'];
|
||||
<span class="status s-published">شائع شدہ</span><span class="status s-returned">واپس</span>
|
||||
</p>
|
||||
|
||||
<h2>ٹیگ (صفحے کے آخر میں)</h2>
|
||||
<TagsBox tags={['عشق', 'فراق', 'وصال', 'تصوف', 'شراب', 'بہار', 'خزاں', 'دل', 'حسن', 'وفا', 'جفا', 'تقدیر', 'دنیا', 'موت', 'زندگی', 'یاد'].map((name, i) => ({ type: i % 3 ? 'موضوع' : 'صنف', name }))} />
|
||||
|
||||
<h2>خانے</h2>
|
||||
<form class="account-form" onsubmit="return false">
|
||||
<label>نام <input value="مرزا غالب" /></label>
|
||||
|
||||
@ -23,7 +23,7 @@ import Icon from '../../components/Icon.astro';
|
||||
<Base title={`${b.title} · ${b.poet}`} author={b.poet} ogType="book" description={`${b.title}: ${b.poet}${b.coauthors.length ? '، ' + b.coauthors.map((c: any) => c.name).join('، ') : ''} کی کتاب، ${KIND[b.kind]} میں`}
|
||||
jsonld={{ '@context': 'https://schema.org', '@type': 'Book', name: b.title, inLanguage: 'ur', author: [b.poet, ...b.coauthors.map((c: any) => c.name)].map((name) => ({ '@type': 'Person', name })) }}>
|
||||
<nav class="crumbs"><a href={b.poet_url}>{b.poet}</a> » <a href={`${b.poet_url}/ebooks?kind=${b.kind}`}>ای بکس</a></nav>
|
||||
<h1>{b.title}</h1>
|
||||
<h1 class="lit">{b.title}</h1>
|
||||
{!b.published && <p class="form-error center">یہ کتاب ابھی جائزے میں ہے؛ صرف موڈریٹر دیکھ سکتے ہیں۔</p>}
|
||||
<p class="center muted">
|
||||
{KIND[b.kind]}{mb && <> · {mb}</>}{b.source && <> · ماخذ: {b.source}</>}{b.licence && <> · {b.licence}</>}
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
---
|
||||
// How to use the site: each feature in a line or two, for readers (moderators have /mod/guide)
|
||||
// How to use the site: each feature in a line or two. Readers' part for everyone; moderators and admins also see how
|
||||
// their tools work (the writing rules are in /mod/guide)
|
||||
import Base from '../layouts/Base.astro';
|
||||
import Icon from '../components/Icon.astro';
|
||||
import { brand } from '../lib/brand';
|
||||
const parts: [any, string, string[]][] = [
|
||||
['user', 'اکاؤنٹ', [
|
||||
'پڑھنے کے لیے اکاؤنٹ ضروری نہیں۔ محفوظ کرنے، نوٹس اور ذخیرۂ الفاظ کے لیے «نیا اکاؤنٹ» بنائیں: صرف ای میل اور پاس ورڈ۔',
|
||||
'«میرا اکاؤنٹ» میں پورا نام اور تعارف لکھ سکتے ہیں، پاس ورڈ بدل سکتے ہیں، اور اکاؤنٹ اپنے سب ڈیٹا سمیت ختم کر سکتے ہیں۔',
|
||||
]],
|
||||
['search', 'تلاش', [
|
||||
'اوپر کے خانے میں کوئی شعر، لفظ یا عبارت لکھیں: شاعر، کتابیں اور کلام الگ الگ ملیں گے، اور نتائج کو شاعر کے لحاظ سے محدود کر سکتے ہیں۔',
|
||||
'کسی موضوع کا کلام: tag:عشق یا ٹیگ:عشق لکھیں (کئی لفظوں کا نام ہو تو کوٹس میں)۔',
|
||||
'اردو کی بورڈ نہ ہو تو «اب» دبا کر رومن حروف میں لکھیں (dil، ghazal)، یا کی بورڈ کا نشان دبا کر اسکرین والا اردو کی بورڈ کھولیں۔',
|
||||
]],
|
||||
['dict', 'لفظ کے معنی', [
|
||||
@ -40,16 +46,73 @@ const parts: [any, string, string[]][] = [
|
||||
'نئے ورژن اور نئی ای بکس RSS میں آتی ہیں، کسی بھی فیڈ ریڈر میں۔',
|
||||
]],
|
||||
];
|
||||
// moderators (L2, L1) and admins: their tools, step by step
|
||||
const modParts: [any, string, string[]][] = [
|
||||
['send', 'موڈریشن کا طریقہ', [
|
||||
'ہر تبدیلی پہلے مسودہ ہوتی ہے۔ موڈریٹر (L2) لکھ کر جائزے کے لیے بھیجتا ہے؛ سینئر موڈریٹر (L1) منظور کرتا ہے یا وجہ لکھ کر واپس بھیجتا ہے؛ ایڈمن شائع کرتا ہے۔ L1 کا اپنا کام سیدھا ایڈمن کو جاتا ہے۔',
|
||||
'شائع ہونے پر تبدیلی دیوان کے کھلے ڈیٹا (divan-data) میں آپ کے عوامی نام سے درج ہوتی ہے، ای میل کبھی نہیں۔ قارئین صرف شائع شدہ ورژن دیکھتے ہیں۔',
|
||||
'«موڈریشن» میں آپ کے مسودے اور جائزے یا اشاعت کے منتظر کام ہیں؛ «سرگرمی» میں کس نے کب کیا کیا، شخص، قسم اور قدم کے لحاظ سے چھانٹ کر۔',
|
||||
]],
|
||||
['edit', 'کلام میں ترمیم', [
|
||||
'کلام کے صفحے پر «ترمیم کریں»: ایڈیٹر کھلتا ہے جس میں متن نستعلیق یا نسخ میں ویسا ہی دکھتا ہے جیسا شائع ہوگا۔ «سورس» میں وہی متن دیوان کی نشانیوں کے ساتھ۔',
|
||||
'نیچے والا اردو کی بورڈ لکھتے ہوئے ساتھ رہتا ہے؛ مطلع، مقطع اور حسنِ مطلع کے بٹن، «+ معلومات» (ماخذ اور دوسری تفصیل) اور «+ ٹیگ» اوپر ہیں۔',
|
||||
'محفوظ کریں اور خلاصہ لکھیں (کیا اور کیوں بدلا)۔ کچھ نہ بدلا ہو تو مسودہ نہیں رکھا جاتا۔ لکھنے کے اصول: «رہنمائی»۔',
|
||||
]],
|
||||
['history', 'تاریخچہ، موازنہ اور واپسی', [
|
||||
'ہر کلام، ترتیب اور ٹیگ کا تاریخچہ: ہر ورژن، کس نے لکھا، کس نے منظور اور شائع کیا۔ «فرق» سے ہر ورژن کی تبدیلی، «موازنہ کریں» سے کوئی دو ورژن۔',
|
||||
'کوئی پرانا ورژن «واپس لائیں» سے مسودہ بن جاتا ہے، اور عام ترمیم کی طرح جائزے اور اشاعت سے گزرتا ہے۔',
|
||||
]],
|
||||
['user', 'شاعر اور کتابیں', [
|
||||
'شاعر کے صفحے پر «تفصیل»: نام، تخلص، پیدائش اور وفات کے سال (عیسوی) اور تعارف۔ کتاب یا باب کے صفحے پر «عنوان»۔ یہ بھی مسودہ بن کر جائزے اور اشاعت سے گزرتے ہیں، اور ان کا بھی تاریخچہ رہتا ہے۔',
|
||||
]],
|
||||
['arrange', 'ترتیب', [
|
||||
'شاعر یا کتاب کے صفحے پر «ترتیب دیں»: کلام، باب اور کتابیں اوپر نیچے کریں، یا کلام ایک حصے سے دوسرے میں لے جائیں۔ اس کی اجازت ترمیم سے الگ ہے۔',
|
||||
]],
|
||||
['tag', 'ٹیگ', [
|
||||
'صفحے کے آخر میں ٹیگ کے حصے سے «ٹیگ لگائیں»: موضوع، صنف، بحر، شخصیت، مقام، دور یا آزاد ٹیگ، پورے کلام، کتاب یا کسی ایک شعر پر۔',
|
||||
]],
|
||||
['upload', 'ای بکس', [
|
||||
'شاعر کے «ای بکس» صفحے پر «ای بک شامل کریں»: پی ڈی ایف، ای پب، متن یا archive.org کا ربط؛ زبان، پڑھنے کی سمت، مصنف اور شریک مصنفین، ٹیگ۔',
|
||||
'سرورق خود بنتا ہے، یا «اپنی تصویر سرورق بنائیں»۔ کتاب بانٹنے کا حق (عوامی ملکیت یا اجازت) ضروری ہے؛ جائزے کے بعد ہی شائع ہوتی ہے۔',
|
||||
]],
|
||||
['home', 'سرورق', [
|
||||
'جنھیں سائٹ کی اجازت ہو وہ «سرورق کے حصے» میں صفحۂ اول کے حصے بنا، نام بدل اور ترتیب دے سکتے ہیں (حروفِ تہجی یا زمانے کے لحاظ سے)۔',
|
||||
]],
|
||||
];
|
||||
const adminParts: [any, string, string[]][] = [
|
||||
['admin', 'صارفین اور کردار', [
|
||||
'«ایڈمن» میں صارفین نام یا ای میل سے ڈھونڈیں اور کردار دیں: قاری، موڈریٹر (L2)، سینئر موڈریٹر (L1) یا ایڈمن۔',
|
||||
'«معطل کریں» سے کوئی لاگ ان نہیں کر سکتا؛ «پاس ورڈ ری سیٹ» ایک عارضی پاس ورڈ ایک بار دکھاتا ہے جو آپ قاری کو دیتے ہیں۔ ہر قدم «ریکارڈ» میں درج ہوتا ہے۔',
|
||||
]],
|
||||
['key', 'اجازتیں', [
|
||||
'موڈریٹر کے صفحے پر «نئی اجازت»: دائرہ (سب، کوئی شاعر، کتاب یا کلام)، مواد (کلام، شاعر، کتابیں، لغت، سائٹ، ٹیگ، ای بکس) اور کام (بنانا، ترمیم، حذف، ترتیب)۔ اجازت کے بغیر موڈریٹر کچھ نہیں بدل سکتا۔',
|
||||
]],
|
||||
['scale', 'اعداد و شمار', [
|
||||
'«اعداد و شمار» میں روزانہ صفحات اور قارئین، سب سے زیادہ پڑھا گیا کلام، قارئین کہاں سے آئے، موڈریشن کا کام اور اکاؤنٹ۔ بغیر کوکی کے، کسی کی شناخت کے بغیر۔',
|
||||
]],
|
||||
];
|
||||
const role = Astro.locals.user?.role ?? '';
|
||||
const sections: [string, string, typeof parts][] = [
|
||||
['readers', 'پڑھنے والوں کے لیے', parts],
|
||||
...(['mod-l2', 'mod-l1', 'admin'].includes(role) ? [['mods', 'موڈریٹروں کے لیے', modParts] as [string, string, typeof parts]] : []),
|
||||
...(role === 'admin' ? [['admins', 'ایڈمن کے لیے', adminParts] as [string, string, typeof parts]] : []),
|
||||
];
|
||||
---
|
||||
<Base title="استعمال کا طریقہ" description={`${brand.name} کی سہولتیں: تلاش، معنی، محفوظ، نوٹس، مطالعہ، ای بکس`}>
|
||||
<h1>استعمال کا طریقہ</h1>
|
||||
<p class="muted center">{brand.name} پر پڑھنے، ڈھونڈنے اور یاد رکھنے کی سب سہولتیں، مختصراً</p>
|
||||
<div class="help">
|
||||
{parts.map(([icon, title, lines]) => (
|
||||
<section>
|
||||
<h2><span class="help-icon" aria-hidden="true"><Icon name={icon} size={20} /></span>{title}</h2>
|
||||
{lines.map((l) => <p>{l}</p>)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{sections.length > 1 && <nav class="center">{sections.map(([id, title], i) => <>{i > 0 && ' · '}<a href={`#${id}`}>{title}</a></>)}</nav>}
|
||||
{sections.map(([id, title, list]) => (
|
||||
<>
|
||||
{sections.length > 1 && <h2 id={id} class="center">{title}</h2>}
|
||||
<div class="help">
|
||||
{list.map(([icon, t, lines]) => (
|
||||
<section>
|
||||
<h3><span class="help-icon" aria-hidden="true"><Icon name={icon} size={20} /></span>{t}</h3>
|
||||
{lines.map((l) => <p>{l}</p>)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</Base>
|
||||
|
||||
44
web/src/pages/mod/compare/[entity]/[id].astro
Normal file
44
web/src/pages/mod/compare/[entity]/[id].astro
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
// 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}`,
|
||||
poet: `/mod/details/poet/${id}`, book: `/mod/details/book/${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>
|
||||
39
web/src/pages/mod/details/[kind]/[id].astro
Normal file
39
web/src/pages/mod/details/[kind]/[id].astro
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
// A poet's details or a book/section's title (#32): what they are now, their versions and drafts; "ترمیم کریں" starts a
|
||||
// draft (also reached by POST from the poet or book page)
|
||||
import Base from '../../../../layouts/Base.astro';
|
||||
import ModNav from '../../../../components/ModNav.astro';
|
||||
import History from '../../../../components/History.astro';
|
||||
import { asUser, COOKIE } from '../../../../lib/auth';
|
||||
import { ud } from '../../../../lib/urdu';
|
||||
import Icon from '../../../../components/Icon.astro';
|
||||
|
||||
const me = Astro.locals.user;
|
||||
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
|
||||
const token = Astro.cookies.get(COOKIE)!.value, { kind, id } = Astro.params;
|
||||
let error = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
const r = await asUser(token, `/api/mod/details/${kind}/${id}/draft`, {});
|
||||
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
|
||||
error = r.data.error ?? 'کچھ غلط ہو گیا';
|
||||
}
|
||||
const res = await asUser(token, `/api/mod/details/${kind}/${id}`);
|
||||
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
|
||||
const { target, version, fields, history, may } = res.data;
|
||||
const poet = kind === 'poet';
|
||||
---
|
||||
<Base title={`${poet ? 'شاعر' : 'عنوان'}: ${target.title}`}>
|
||||
<h1>{poet ? 'شاعر' : 'عنوان'}: {target.title}</h1>
|
||||
<ModNav />
|
||||
<p class="center"><a href={target.url}>صفحہ</a> · {version ? `موجودہ: ورژن ${ud(version)}` : 'ویکی ماخذ اور ویکیپیڈیا سے (ابھی دیوان کا کوئی ورژن نہیں)'}</p>
|
||||
<dl class="eb-meta">
|
||||
{poet ? <>
|
||||
<dt>نام</dt><dd>{fields['نام']}</dd><dt>تخلص</dt><dd>{fields['تخلص']}</dd>
|
||||
<dt>پیدائش</dt><dd>{fields['پیدائش'] ? ud(fields['پیدائش']) : '—'}</dd><dt>وفات</dt><dd>{fields['وفات'] ? ud(fields['وفات']) : '—'}</dd>
|
||||
</> : <><dt>عنوان</dt><dd>{fields['عنوان']}</dd></>}
|
||||
</dl>
|
||||
{poet && fields['تعارف'] && <div class="poet-intro">{fields['تعارف'].split(/\n\s*\n/).map((p: string) => <p>{p}</p>)}</div>}
|
||||
{error && <p class="form-error" role="alert">{error}</p>}
|
||||
{may.edit && <form method="post" class="center"><button class="save-like"><Icon name="edit" size={17} /> ترمیم کریں</button></form>}
|
||||
<History entity={kind!} id={target.id} history={history} version={version} empty="ابھی کوئی ترمیم نہیں۔" />
|
||||
</Base>
|
||||
@ -13,11 +13,13 @@ 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: 'ای بک: ' };
|
||||
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ', poet: 'شاعر: ', book: 'عنوان: ' };
|
||||
// each kind of change has its icon; each event its tone on the timeline
|
||||
const ICON: Record<string, any> = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload' };
|
||||
const ICON: Record<string, any> = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload', poet: 'user', book: 'edit' };
|
||||
const TONE: Record<string, string> = { published: 'ok', approved: 'action', submitted: 'accent', returned: 'danger', rejected: 'danger' };
|
||||
const groups = queue ? [
|
||||
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
|
||||
@ -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', 'ای بکس'], ['poet', 'شاعر'], ['book', 'کتاب کا عنوان']].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) => (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -17,7 +17,12 @@ 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') });
|
||||
// a poet's or book's details come as fields (#32): one "label: value" per line, the intro last
|
||||
const clean = (k: string) => String(f.get(k) ?? '').replace(/\s+/g, ' ').trim();
|
||||
const content = f.get('details') === 'book' ? `عنوان: ${clean('عنوان')}`
|
||||
: f.get('details') === 'poet' ? `${['نام', 'تخلص', 'پیدائش', 'وفات'].map((k) => `${k}: ${clean(k)}`).join('\n')}\nتعارف:\n${String(f.get('تعارف') ?? '').trim()}`
|
||||
: f.get('content');
|
||||
if (act === 'save' || act === 'submit') r = await asUser(token, `/api/mod/revisions/${id}/save`, { 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 ?? 'کچھ غلط ہو گیا';
|
||||
@ -39,6 +44,15 @@ const couplets = tagging && tagKind === 'work' ? (await asUser(token, `/api/mod/
|
||||
const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : [];
|
||||
// an e-book's details: title, source, copyright, note
|
||||
const ebook = rev.entity === 'ebook';
|
||||
// a poet's details or a book/section's title (#32)
|
||||
const details = ['poet', 'book'].includes(rev.entity) ? rev.entity : '';
|
||||
const dv: Record<string, string> = {};
|
||||
if (details) {
|
||||
const lines = rev.content.split('\n'), intro = lines.findIndex((l: string) => /^تعارف\s*:/.test(l));
|
||||
for (const l of intro < 0 ? lines : lines.slice(0, intro)) { const m = l.match(/^([^:]+):\s*(.*)$/); if (m) dv[m[1].trim()] = m[2].trim(); }
|
||||
if (intro >= 0) dv['تعارف'] = [lines[intro].replace(/^تعارف\s*:\s*/, ''), ...lines.slice(intro + 1)].join('\n').trim();
|
||||
}
|
||||
const keys = (i: string) => `<button type="button" class="in-box rom" data-urdu-rom="${i}" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو">اب</button><button type="button" class="in-box" data-urdu-kb="${i}" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg></button>`;
|
||||
const EB: [string, string][] = [['title', 'عنوان'], ['writer', 'مصنف'], ['coauthors', 'شریک مصنفین'], ['language', 'زبان'], ['direction', 'سمت'],
|
||||
['tags', 'ٹیگ'], ['source', 'ماخذ'], ['licence', 'اجازت'], ['note', 'تفصیل']];
|
||||
const ebMeta = ebook ? Object.fromEntries(EB.map(([k, label]) => [k, (rev.content.split('\n').find((l: string) => l.startsWith(label + ':')) ?? '').slice(label.length + 1).trim()])) : {};
|
||||
@ -48,18 +62,38 @@ const acts = ([['approve', 'منظور کریں', 'ok', 'check'], ['return', 'و
|
||||
import Icon from '../../../components/Icon.astro';
|
||||
---
|
||||
<Base title={`مسودہ: ${rev.work_title}`}>
|
||||
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{ebook && 'ای بک: '}{rev.work_title}</h1>
|
||||
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{ebook && 'ای بک: '}{details === 'poet' && 'شاعر: '}{details === 'book' && 'عنوان: '}{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}>{order ? 'حصہ' : tagging ? 'صفحہ' : ebook ? 'کتاب دیکھیں' : 'کلام'}</a>{!ebook && <> · <a href={tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a></>}
|
||||
· <a href={rev.work_url}>{order ? 'حصہ' : tagging || details ? 'صفحہ' : ebook ? 'کتاب دیکھیں' : 'کلام'}</a>{!ebook && <> · <a href={details ? `/mod/details/${details}/${rev.entity_id}` : tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : '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 && ebook ? (
|
||||
{may.save && details ? (
|
||||
<form method="post" class="mod-edit account-form">
|
||||
<input type="hidden" name="details" value={details} />
|
||||
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
|
||||
{details === 'book' ? (
|
||||
<label>عنوان<span class="in-box-field"><input id="d-title" name="عنوان" value={dv['عنوان'] ?? ''} required maxlength="200" data-urdu /><Fragment set:html={keys('d-title')} /></span></label>
|
||||
) : <>
|
||||
<label>نام<span class="in-box-field"><input id="d-name" name="نام" value={dv['نام'] ?? ''} required maxlength="100" data-urdu /><Fragment set:html={keys('d-name')} /></span></label>
|
||||
<label>تخلص (جو سائٹ پر ہر جگہ دکھتا ہے)<span class="in-box-field"><input id="d-nick" name="تخلص" value={dv['تخلص'] ?? ''} required maxlength="100" data-urdu /><Fragment set:html={keys('d-nick')} /></span></label>
|
||||
<div class="year-pair">
|
||||
<label>پیدائش (سنِ عیسوی)<input name="پیدائش" value={dv['پیدائش'] ?? ''} inputmode="numeric" maxlength="4" placeholder="۱۸۷۷" /></label>
|
||||
<label>وفات (سنِ عیسوی)<input name="وفات" value={dv['وفات'] ?? ''} inputmode="numeric" maxlength="4" placeholder="۱۹۳۸" /></label>
|
||||
</div>
|
||||
<label>تعارف (پیراگراف خالی سطر سے الگ کریں)<span class="in-box-field note-field"><textarea id="d-intro" name="تعارف" rows="10" maxlength="5000" dir="auto" data-urdu>{dv['تعارف'] ?? ''}</textarea><Fragment set:html={keys('d-intro')} /></span></label>
|
||||
</>}
|
||||
<div class="filter-actions">
|
||||
<button name="act" value="save" class="btn primary"><Icon name="save" size={18} /> محفوظ کریں</button>
|
||||
<button name="act" value="submit" class="btn primary"><Icon name={me.role === 'admin' ? 'upload' : 'send'} size={18} /> {me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
|
||||
</div>
|
||||
</form>
|
||||
) : may.save && ebook ? (
|
||||
<form method="post" class="mod-edit account-form">
|
||||
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
|
||||
<EbookFields v={ebMeta} />
|
||||
@ -181,6 +215,8 @@ import Icon from '../../../components/Icon.astro';
|
||||
<section class="lib-section">
|
||||
<h2>پیش نظارہ</h2>
|
||||
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol>
|
||||
: details ? <><dl class="eb-meta">{Object.entries(dv).filter(([k, v]) => k !== 'تعارف' && v).map(([k, v]) => <><dt>{k}</dt><dd>{ud(v)}</dd></>)}</dl>
|
||||
{dv['تعارف'] && <div class="poet-intro">{dv['تعارف'].split(/\n\s*\n/).map((p) => <p>{p}</p>)}</div>}</>
|
||||
: ebook ? <dl class="eb-meta">{EB.map(([k, label]) => ebMeta[k] && <><dt>{label}</dt><dd>{ebMeta[k]}</dd></>)}</dl>
|
||||
: tagging ? (tagRows.length ? tagRows.map((t: any) => <div class="tag-preview">{t.couplet > 0 && <small class="muted">شعر {ud(t.couplet)}:</small>}<Tags tags={[t]} /></div>) : <p class="muted center">کوئی ٹیگ نہیں</p>)
|
||||
: <DivanPreview text={rev.content} />}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -6,7 +6,7 @@ import { brand } from '../lib/brand';
|
||||
<Base title="رازداری" description={`${brand.name} آپ کے بارے میں کیا محفوظ رکھتا ہے، کیوں، اور اسے کیسے مٹایا جائے`}>
|
||||
<article class="guide about">
|
||||
<h1>رازداری</h1>
|
||||
<p class="muted">آخری تبدیلی: ۹ اکتوبر ۲۰۲۶ء</p>
|
||||
<p class="muted">آخری تبدیلی: ۱۰ اکتوبر ۲۰۲۶ء</p>
|
||||
<p>
|
||||
{brand.name} پڑھنے کے لیے کسی اکاؤنٹ کی ضرورت نہیں۔ ہم صرف وہی محفوظ رکھتے ہیں جو سائٹ کے چلنے کے لیے ضروری ہے، اور اسے
|
||||
کسی کو بیچتے یا دیتے نہیں۔ نہ اشتہار ہیں، نہ اشتہاری یا جاسوس کوکیز، نہ کسی اور کمپنی کے ٹریکر۔
|
||||
@ -16,6 +16,7 @@ import { brand } from '../lib/brand';
|
||||
<ul>
|
||||
<li>آپ کی پسند (خط، رنگ، روشنی، پڑھنے کا انداز) آپ کے اپنے براؤزر میں رہتی ہے، ہمارے سرور پر نہیں۔ ہندسوں کی پسند ایک چھوٹی کوکی میں رہتی ہے تاکہ صفحہ انھی ہندسوں کے ساتھ آئے۔</li>
|
||||
<li>لاگ ان اور اکاؤنٹ بنانے کی کوششوں کی حد کے لیے سرور آپ کا انٹرنیٹ پتہ (IP) تھوڑی دیر کے لیے یاد رکھتا ہے، اور غلطیوں کے ریکارڈ میں صفحے کا پتہ اور وقت درج ہوتا ہے۔ یہ صرف سائٹ کی حفاظت اور خرابیاں ٹھیک کرنے کے لیے ہیں اور کچھ دن بعد مٹ جاتے ہیں۔</li>
|
||||
<li><strong>گنتی:</strong> کون سا صفحہ کتنی بار پڑھا گیا، روزانہ کتنے قارئین آئے، اور کس ویب سائٹ سے (صرف اس کا نام) — صرف گنتی، بغیر کوکی کے۔ قارئین گننے کے لیے انٹرنیٹ پتے اور براؤزر کی ایک نہ پلٹنے والی شکل بنتی ہے جس کی کنجی ہر روز بدلتی ہے اور کہیں محفوظ نہیں ہوتی، اس لیے کسی کو پہچانا یا ایک دن سے دوسرے دن تک پیچھا نہیں کیا جا سکتا۔</li>
|
||||
</ul>
|
||||
|
||||
<h2>اکاؤنٹ کے ساتھ</h2>
|
||||
|
||||
@ -63,7 +63,15 @@
|
||||
:root.nastaliq { --font: 'Libron', 'Noto Nastaliq Urdu', var(--font-ui); --verse-lh: 2.7; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--surface-page); color: var(--text); font-family: var(--font); line-height: 1.9; }
|
||||
/* Naskh's weight from the display settings (400 by default; heavier reads better on small screens) */
|
||||
:root:not(.nastaliq) body { font-weight: var(--naskh-weight, 400); }
|
||||
/* text size from the display settings: everything is in rem, so the whole page scales (90%-150%) */
|
||||
html { font-size: var(--text-size, 100%); }
|
||||
body { margin: 0; background: var(--surface-page); color: var(--text); font-family: var(--font-ui); line-height: 1.9; }
|
||||
/* The rule: the interface (menus, buttons, forms, paths, footers, admin and moderation) is always Naskh, compact and
|
||||
calm; the reading font (Naskh or Nastaliq, the reader's choice) is only for the literature: verses and prose, work,
|
||||
book and poet titles, contents lists, intros, excerpts, the editor and its preview. */
|
||||
.lit, .poem, .preview, .snippet, .intro, .toc, .lib-couplet, .ebook-text, .dv-editor, .masthead-text, .radif-head { font-family: var(--font); }
|
||||
:where(a) { color: var(--link); text-decoration: none; }
|
||||
:where(a):hover { color: var(--active); }
|
||||
.wrap { max-width: 960px; margin: 0 auto; padding: 0 16px; }
|
||||
@ -107,6 +115,8 @@ body { margin: 0; background: var(--surface-page); color: var(--text); font-fami
|
||||
main { padding: 16px 0 48px; }
|
||||
h1, h2 { line-height: 1.6; margin: .2em 0 .4em; font-weight: 700; text-align: center; color: var(--heading); }
|
||||
h1 { font-size: clamp(1.3rem, 5vw, var(--fs-2xl)); }
|
||||
/* Nastaliq titles: its tall letters (ک، گ، ط) and deep descents need room, or they reach into the path line above */
|
||||
:root.nastaliq h1.lit { line-height: 2.4; margin-top: .6em; }
|
||||
h1::after { content: ''; display: block; width: 140px; height: 2px; background: var(--brand); margin: 8px auto 0; }
|
||||
|
||||
/* Ganjoor's gold pill headings and links (.century / .part-title-block) */
|
||||
@ -181,7 +191,7 @@ h1::after { content: ''; display: block; width: 140px; height: 2px; background:
|
||||
.couplet p { margin: 0; width: fit-content; max-width: 100%; justify-self: end; text-align: center; }
|
||||
.couplet[data-mark]::before { content: attr(data-mark); grid-column: 1 / -1; justify-self: center; font-size: .72rem; line-height: 1.6; color: var(--brand); border: 1px solid var(--line); border-radius: 999px; padding: 0 10px; margin-bottom: 2px; }
|
||||
.takhallus { color: var(--brand); }
|
||||
.radif { text-align: center; margin-top: -6px; }
|
||||
.radif { text-align: center; margin-top: 8px; }
|
||||
.radif-head { text-align: center; font-size: 1.05rem; color: var(--brand); margin: 22px 0 4px; }
|
||||
.couplet p + p { justify-self: start; }
|
||||
.couplet::after { content: '✤'; grid-column: 1 / -1; text-align: center; color: var(--brand); font-size: .72rem; opacity: .75; line-height: 1.2; }
|
||||
@ -238,10 +248,11 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.search-form { order: 3; flex-basis: 100%; }
|
||||
}
|
||||
.site-header .kb:not([hidden]) { display: block; padding-bottom: 10px; } /* not the header's flex row */
|
||||
.kb div { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px; margin-top: 4px; }
|
||||
.kb button { font-size: var(--fs-lg); min-width: 2.4em; padding: 2px 6px; background: var(--surface-raised); border-radius: var(--r-sm); }
|
||||
.kb .wide { min-width: 12em; }
|
||||
.kb .fn { min-width: 3.4em; background: var(--surface-sunken); }
|
||||
.kb { width: 100%; max-width: 640px; margin-inline: auto; box-sizing: border-box; } /* one keyboard everywhere (header, under a field, in a dialog, docked): */
|
||||
.kb div { display: flex; justify-content: center; gap: 4px; margin-top: 4px; } /* rows never wrap; a letter is a tenth of the row, */
|
||||
.kb button { flex: 0 1 calc((100% - 36px) / 10); min-width: 0; padding: 3px 0; font-size: var(--fs-lg); background: var(--surface-raised); border-radius: var(--r-sm); }
|
||||
.kb .fn { flex-basis: calc((100% - 36px) / 10 * 1.5); background: var(--surface-sunken); } /* shift and delete a bit wider, */
|
||||
.kb .wide { flex: 1 1 auto; } /* and the space bar takes the rest of its row, as on a phone */
|
||||
/* the content editor's keyboard: docked at the bottom of the window; the page keeps the writing line above it */
|
||||
.kb.kb-float { position: fixed; z-index: 40; inset-inline: 0; bottom: 0; margin: 0 auto; max-width: min(720px, 100vw); padding: 6px 10px 12px;
|
||||
background: var(--surface-raised); border: 1.5px solid var(--border); border-bottom: 0; border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
@ -291,7 +302,7 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.account-form > button { font-size: inherit; padding: 4px 14px; }
|
||||
/* boxes with the keyboard buttons inside them: full width, and the small in-box buttons keep their own look */
|
||||
.account-form .in-box-field { display: block; position: relative; }
|
||||
.account-form .in-box-field input { width: 100%; box-sizing: border-box; }
|
||||
.account-form .in-box-field :is(input, textarea) { width: 100%; box-sizing: border-box; }
|
||||
.account-form .in-box { padding: 4px; border: 0; border-radius: 6px; background: none; color: var(--text-muted); }
|
||||
.account-form.danger button { border-color: var(--danger); color: var(--danger); }
|
||||
.form-error { color: var(--danger); text-align: center; }
|
||||
@ -412,6 +423,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; }
|
||||
@ -500,6 +515,12 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.eb-meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; max-width: 700px; margin: 0 auto; }
|
||||
.eb-meta dt { color: var(--text-muted); }
|
||||
.eb-meta dd { margin: 0; }
|
||||
/* poet details (#32): two years side by side; an intro in paragraphs */
|
||||
.year-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.year-pair label { min-width: 0; }
|
||||
.year-pair input { width: 100%; box-sizing: border-box; }
|
||||
.poet-intro { max-width: 700px; margin: 10px auto; line-height: 2; }
|
||||
.intro p { margin: 0 0 .6em; }
|
||||
/* e-books: tabs, cover grid, right-to-left PDF reader */
|
||||
.ebook-tabs a span[lang] { font-size: .8em; }
|
||||
.ebook-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px; margin: 16px 0; }
|
||||
@ -559,6 +580,10 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.display-panel p { margin: 6px 0 4px; color: var(--text-muted); font-size: .8rem; }
|
||||
.display-panel .seg { display: flex; gap: 4px; }
|
||||
.display-panel button { flex: 1; gap: 4px; padding: 2px 6px; border-radius: var(--r-md); }
|
||||
.display-panel .weight { display: grid; grid-template-columns: 6.5em 1fr; align-items: center; gap: 8px; margin: 6px 0 2px; font-size: .8rem; color: var(--text-muted); } /* the same label width, so both sliders are the same length */
|
||||
.display-panel .weight span { white-space: nowrap; }
|
||||
.display-panel .weight input { width: 100%; min-width: 0; margin: 0; accent-color: var(--action); }
|
||||
.display-panel .weight:has(input:disabled) { opacity: .5; }
|
||||
.display-panel .f-nastaliq { font-family: 'Noto Nastaliq Urdu', serif; line-height: 2.2; }
|
||||
.display-panel .swatches { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
|
||||
.display-panel .swatches button { flex-direction: column; font-size: .7rem; padding: 4px 2px; }
|
||||
@ -661,6 +686,7 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
font: var(--fs-sm)/1.8 var(--font-ui); animation: toast-in .25s ease-out; }
|
||||
.toast::before { content: '✓ '; color: var(--ok); font-weight: 700; }
|
||||
.toast.error::before { content: '! '; color: var(--danger); }
|
||||
.toast.info::before { content: 'ⓘ '; color: var(--accent); }
|
||||
.toast.out { animation: toast-out .3s ease-in forwards; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(14px); } }
|
||||
@keyframes toast-out { to { opacity: 0; transform: translateY(14px); } }
|
||||
@ -730,7 +756,6 @@ p, li { text-wrap: pretty; }
|
||||
.note-field .in-box { top: 20px; } /* at the first line of the box, not its middle */
|
||||
.note-field textarea { padding-left: 80px; } /* the buttons sit on the left whatever the note's language (the box follows what is typed) */
|
||||
.note-box .kb { padding: 0 0 10px; }
|
||||
.note-box .kb button { min-width: 2em; font-size: 1rem; }
|
||||
.note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); }
|
||||
.note-others button { all: unset; display: block; cursor: pointer; font-size: var(--fs-sm); color: var(--text-muted); padding: 2px 0; }
|
||||
.note-others button:hover { color: var(--active); }
|
||||
@ -912,19 +937,31 @@ p, li { text-wrap: pretty; }
|
||||
.c-menu i { width: 17px; height: 17px; background-color: var(--text-muted); }
|
||||
.c-menu .g-bm { --m: var(--bm-outline); } .c-menu [data-on] .g-bm { --m: var(--bm-filled); background-color: var(--active); }
|
||||
.c-menu .g-nt { --m: var(--nt-outline); } .c-menu .g-sh { --m: var(--sh-mask); } .c-menu .g-dl { --m: var(--dl-mask); }
|
||||
/* a page's tags, folded at the end of its content */
|
||||
.tags-box { max-width: 760px; margin: 16px auto 10px; text-align: center; }
|
||||
.tags-box > summary { display: inline-flex; align-items: center; gap: 6px; padding: 1px 14px; border: 1.5px solid var(--border); border-radius: var(--r-pill);
|
||||
background: var(--surface-sunken); font: var(--fs-sm)/1.8 var(--font-ui); cursor: pointer; list-style: none; }
|
||||
.tags-box > summary::-webkit-details-marker { display: none; }
|
||||
.tags-box > summary::after { content: '⌄'; transition: transform .2s; }
|
||||
.tags-box[open] > summary::after { transform: rotate(180deg); }
|
||||
.tags-box > summary:hover { border-color: var(--active); color: var(--active); }
|
||||
.tags-box .tag-chips { margin-top: 10px; }
|
||||
/* a page's tags at the end of its content: «ٹیگ» at the start of the line; opened, the tags follow it and wrap across the width */
|
||||
.tags-box { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 10px; }
|
||||
.tags-box > .tag-chip { display: none; }
|
||||
.tags-box > :checked ~ .tag-chip { display: inline-flex; animation: rise .2s ease-out both; }
|
||||
.tags-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 1px 14px; border: 1.5px solid var(--border); border-radius: var(--r-pill);
|
||||
background: var(--surface-sunken); font: var(--fs-sm)/1.8 var(--font-ui); cursor: pointer; user-select: none; }
|
||||
.tags-toggle::after { content: '⌄'; transition: transform .2s; }
|
||||
.tags-box > :checked + .tags-toggle { border-color: var(--active); color: var(--active); }
|
||||
.tags-box > :checked + .tags-toggle::after { transform: rotate(-90deg); } /* points along the line, towards the tags */
|
||||
.tags-toggle:hover { border-color: var(--active); color: var(--active); }
|
||||
.tags-box > :focus-visible + .tags-toggle { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
/* how to use the site */
|
||||
.help { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }
|
||||
.help section { padding: 10px 16px 6px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--surface-raised); }
|
||||
.help h2 { display: flex; align-items: center; gap: 10px; text-align: start; font-size: var(--fs-lg); margin: 0 0 4px; }
|
||||
.help h2::after { display: none; }
|
||||
.help h3 { display: flex; align-items: center; gap: 10px; text-align: start; font-size: var(--fs-lg); margin: 0 0 4px; }
|
||||
.help h3::after { display: none; }
|
||||
.help-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); flex: none; }
|
||||
.help p { margin: 0 0 8px; line-height: 2; }
|
||||
|
||||
/* admin statistics (#86): daily bars, views behind and visitors in front */
|
||||
.bars { display: flex; align-items: flex-end; gap: 2px; height: 160px; padding: 8px; border: 1px solid var(--border); border-radius: 10px; direction: ltr; }
|
||||
.bar { flex: 1; height: 100%; position: relative; display: flex; align-items: flex-end; }
|
||||
.bar span, .bar i { position: absolute; inset-inline: 0; bottom: 0; border-radius: 3px 3px 0 0; }
|
||||
.bar span, .key-views { background: color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||
.bar i, .key-visitors { background: var(--accent); inset-inline: 25%; }
|
||||
.key-views, .key-visitors { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: middle; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 0 24px; }
|
||||
.stats-range { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin: 10px 0; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user