diff --git a/api/src/permissions.ts b/api/src/permissions.ts index 09c4e0d0..fbb3759f 100644 --- a/api/src/permissions.ts +++ b/api/src/permissions.ts @@ -13,7 +13,7 @@ import { audit, requireAdmin } from './admin.ts'; export const MODERATORS = ['mod-l2', 'mod-l1'] as const; export const SCOPES = ['all', 'poet', 'category', 'poem'] as const; -export const CONTENT = ['poets', 'books', 'works', 'dictionary'] as const; +export const CONTENT = ['poets', 'books', 'works', 'dictionary', 'site'] as const; // site: the home page sections (site.ts) export const ACTIONS = ['create', 'edit', 'delete', 'arrange'] as const; export type Action = (typeof ACTIONS)[number]; export type Content = (typeof CONTENT)[number]; diff --git a/api/src/server.ts b/api/src/server.ts index 6a363279..39f8922e 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -19,6 +19,7 @@ import { adminRoutes } from './admin.ts'; import { permissionRoutes } from './permissions.ts'; import { libraryRoutes } from './library.ts'; import { moderationRoutes } from './moderation.ts'; +import { siteRoutes } from './site.ts'; const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }); const PAGE_SIZE = 20; @@ -165,6 +166,7 @@ adminRoutes(app); permissionRoutes(app); libraryRoutes(app); moderationRoutes(app); +siteRoutes(app); const port = Number(process.env.PORT ?? 4100); await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' }); diff --git a/api/src/site.test.ts b/api/src/site.test.ts new file mode 100644 index 00000000..82174095 --- /dev/null +++ b/api/src/site.test.ts @@ -0,0 +1,56 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { authRoutes } from './auth.ts'; +import { siteRoutes } from './site.ts'; +import { pool } from './db.ts'; + +after(() => pool.end()); + +test('home sections: default layout, the site permission, rename, sort, chosen poets, reorder', async () => { + const app = Fastify(); + authRoutes(app); siteRoutes(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': `site-${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 saved = (await pool.query('SELECT position, title, century, sort, poets FROM home_sections ORDER BY position')).rows; + const mod = await person('mod', 'mod-l2'), other = await person('other', 'mod-l1'); + try { + // the default layout: featured poets, then centuries of birth + await pool.query('DELETE FROM home_sections'); + const home = (await call('GET', '/api/home')).json(); + assert.equal(home[0].title, 'مقبول شعرا'); + assert.ok(home.slice(1).every((s: any) => s.century !== null && s.title === null && s.sort === 'timeline')); + const all = home.slice(1).reduce((n: number, s: any) => n + s.poets.length, 0); + assert.equal(all, Number((await pool.query('SELECT count(*) FROM poets')).rows[0].count), 'every poet in a century'); + + // only with the site permission + assert.equal((await call('GET', '/api/mod/site', undefined, other.token)).statusCode, 403); + await pool.query(`INSERT INTO grants (user_id, scope, content, actions) VALUES ($1, 'all', '{site}', '{edit}')`, [mod.id]); + const ed = (await call('GET', '/api/mod/site', undefined, mod.token)).json(); + + // rename the 13th century, sort it alphabetically, put it first; a new chosen section; no nameless list + const c13 = ed.sections.find((s: any) => s.century === 13); + const iqbal = ed.poets.find((p: any) => p.name === 'محمد اقبال').id, ghalib = ed.poets.find((p: any) => p.name === 'مرزا غالب').id; + const layout = [{ ...c13, title: 'تیرھویں صدی کے شعرا', sort: 'alpha' }, { title: 'نئی فہرست', century: null, sort: 'manual', poets: [iqbal, ghalib, iqbal, 999999] }, + ...ed.sections.filter((s: any) => s !== c13)]; + assert.equal((await call('POST', '/api/mod/site', { sections: [{ title: '', century: null, poets: [] }] }, mod.token)).statusCode, 400); + const res = (await call('POST', '/api/mod/site', { sections: layout }, mod.token)).json().sections; + assert.equal(res[0].title, 'تیرھویں صدی کے شعرا'); + const names = res[0].poets.map((p: any) => p.nickname); + assert.deepEqual(names, [...names].sort(new Intl.Collator('ur').compare), 'alphabetical'); + assert.deepEqual(res[1].poets.map((p: any) => p.id), [iqbal, ghalib], 'chosen order; repeats and unknown poets dropped'); + assert.equal((await pool.query(`SELECT count(*) FROM audit_log WHERE action = 'site-sections' AND actor_email = $1`, [`mod-${run}@divan.test`])).rows[0].count, '1'); + } finally { + await pool.query('DELETE FROM home_sections'); + for (const s of saved) await pool.query('INSERT INTO home_sections (position, title, century, sort, poets) VALUES ($1, $2, $3, $4, $5)', [s.position, s.title, s.century, s.sort, s.poets]); + 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', [`%-${run}@divan.test`]); + await app.close(); + } +}); diff --git a/api/src/site.ts b/api/src/site.ts new file mode 100644 index 00000000..4e4ffea6 --- /dev/null +++ b/api/src/site.ts @@ -0,0 +1,102 @@ +// Home page sections (owner request): moderators with the 'site' permission (admins always) rename, add, remove and +// reorder the sections, choose their poets (e.g. مقبول شعرا) and how each is sorted: by hand, alphabetically or by +// timeline. A century section lists every poet born in that Hijri century, so new poets appear on their own. +// Changes apply at once and go to the audit log. +// GET /api/home the sections with their poets (public) +// GET /api/mod/site the sections as stored, and every poet (for the editor) +// POST /api/mod/site {sections: [{title, century, sort, poets}]}: the whole layout, in order +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { pool } from './db.ts'; +import { sessionUser } from './auth.ts'; +import { can } from './permissions.ts'; +import { audit } from './admin.ts'; + +type Section = { id?: number; title: string | null; century: number | null; sort: string; poets: number[] }; +const SORTS = ['manual', 'alpha', 'timeline']; +const centuryOf = (y: number | null) => (y ? Math.floor(y / 100) + 1 : 0); // 0: year unknown +const urdu = new Intl.Collator('ur'); + +async function replace(list: Section[]) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('DELETE FROM home_sections'); + for (const [i, s] of list.entries()) + await client.query('INSERT INTO home_sections (position, title, century, sort, poets) VALUES ($1, $2, $3, $4, $5)', + [i, s.title, s.century, s.sort, s.poets]); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + +// the stored sections; the first time, the layout the home page always had (featured poets, then a section per +// century of birth, each by timeline) +async function sections(): Promise { + const q = () => pool.query('SELECT id, title, century, sort, poets FROM home_sections ORDER BY position, id'); + let { rows } = await q(); + if (rows.length) return rows; + const pinned = (await pool.query('SELECT id FROM poets WHERE pin_order IS NOT NULL ORDER BY pin_order')).rows.map((r) => r.id); + const centuries = (await pool.query('SELECT DISTINCT coalesce(birth_year_ah / 100 + 1, 0) AS c FROM poets')).rows + .map((r) => r.c as number).sort((a, b) => (a || 99) - (b || 99)); + // ponytail: two first requests at once could both seed; replace() runs in a transaction, the last one wins + await replace([{ title: 'مقبول شعرا', century: null, sort: 'manual', poets: pinned }, + ...centuries.map((c) => ({ title: null, century: c, sort: 'timeline', poets: [] }))]); + return (await q()).rows; +} + +// each section with its poets in order (sections with nobody in them are left out) +export async function home() { + const poets = (await pool.query( + 'SELECT id, url, nickname, birth_year_ah, death_year_ah, birth_year_ce, death_year_ce FROM poets')).rows; + const byId = new Map(poets.map((p) => [p.id, p])); + const timeline = (a: any, b: any) => (a.birth_year_ah ?? 1e9) - (b.birth_year_ah ?? 1e9) || urdu.compare(a.nickname, b.nickname); + return (await sections()).map((s) => { + const chosen = s.poets.map((id) => byId.get(id)).filter(Boolean); + let list = s.century === null ? chosen : [ + ...chosen.filter((p) => centuryOf(p.birth_year_ah) === s.century), + ...poets.filter((p) => centuryOf(p.birth_year_ah) === s.century && !s.poets.includes(p.id)).sort(timeline)]; + if (s.sort === 'alpha') list = [...list].sort((a, b) => urdu.compare(a.nickname, b.nickname)); + if (s.sort === 'timeline') list = [...list].sort(timeline); + return { id: s.id, title: s.title, century: s.century, sort: s.sort, poets: list }; + }).filter((s) => s.poets.length); +} + +async function siteEditor(req: FastifyRequest, reply: FastifyReply) { + const u = await sessionUser(req); + if (!u) return void reply.code(401).send({ error: 'لاگ ان کریں' }); + if (!(await can(u, 'edit', 'site'))) return void reply.code(403).send({ error: 'سائٹ کے حصے بدلنے کی اجازت نہیں' }); + return u; +} + +export function siteRoutes(app: FastifyInstance) { + app.get('/api/home', async () => home()); + + app.get('/api/mod/site', async (req, reply) => { + if (!(await siteEditor(req, reply))) return; + const poets = (await pool.query('SELECT id, nickname AS name, birth_year_ah FROM poets ORDER BY nickname')).rows; + return { sections: await sections(), poets }; + }); + + app.post<{ Body: { sections?: unknown } }>('/api/mod/site', async (req, reply) => { + const u = await siteEditor(req, reply); if (!u) return; + const raw = Array.isArray(req.body?.sections) ? req.body.sections : null; + if (!raw || !raw.length || raw.length > 100) return reply.code(400).send({ error: 'حصوں کی فہرست درست نہیں' }); + const known = new Set((await pool.query('SELECT id FROM poets')).rows.map((r) => r.id)); + const list: Section[] = []; + for (const s of raw as any[]) { + const century = s?.century === null || s?.century === undefined || s?.century === '' ? null : Number(s.century); + if (century !== null && !(Number.isInteger(century) && century >= 0 && century <= 20)) return reply.code(400).send({ error: 'صدی درست نہیں' }); + const title = String(s?.title ?? '').trim().slice(0, 100) || null; + if (!title && century === null) return reply.code(400).send({ error: 'ہر حصے کا نام ضروری ہے' }); + const poets = [...new Set((Array.isArray(s?.poets) ? s.poets : []).map(Number).filter((id: number) => known.has(id)))].slice(0, 500) as number[]; + list.push({ title, century, sort: SORTS.includes(s?.sort) ? s.sort : 'manual', poets }); + } + await replace(list); + await audit(u, 'site-sections', null, { sections: list.map((s) => s.title ?? `صدی ${s.century}`) }); + return { sections: await home() }; + }); +} diff --git a/db/schema.sql b/db/schema.sql index 42ab7eb8..22411f0d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -187,3 +187,15 @@ CREATE TABLE IF NOT EXISTS revision_events ( -- who did what: created, sav ); CREATE INDEX IF NOT EXISTS revision_events_revision ON revision_events(revision_id); CREATE INDEX IF NOT EXISTS revision_events_at ON revision_events(at DESC); +-- home page sections (api/src/site.ts), edited by moderators with the 'site' permission. A section either lists +-- chosen poets (poets, in order) or every poet born in a Hijri century (century; 0 = year unknown). Sorting: +-- manual (the list's order; a century section lists its chosen poets first), alpha (Urdu alphabetical) or +-- timeline (by birth year). title NULL = the default name (the century's name). +CREATE TABLE IF NOT EXISTS home_sections ( + id serial PRIMARY KEY, + position integer NOT NULL, + title text, + century integer, + sort text NOT NULL DEFAULT 'manual' CHECK (sort IN ('manual', 'alpha', 'timeline')), + poets integer[] NOT NULL DEFAULT '{}' +); diff --git a/web/src/components/ModNav.astro b/web/src/components/ModNav.astro index 611f4d02..d594ff96 100644 --- a/web/src/components/ModNav.astro +++ b/web/src/components/ModNav.astro @@ -1,8 +1,9 @@ --- -const { current } = Astro.props as { current?: 'queue' | 'log' | 'guide' }; +const { current } = Astro.props as { current?: 'queue' | 'log' | 'guide' | 'site' }; --- diff --git a/web/src/lib/roles.ts b/web/src/lib/roles.ts index bfb5ffd0..f98f1e76 100644 --- a/web/src/lib/roles.ts +++ b/web/src/lib/roles.ts @@ -1,5 +1,5 @@ // Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts) export const ROLE: Record = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' }; export const SCOPE: Record = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' }; -export const CONTENT: Record = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت' }; +export const CONTENT: Record = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)' }; export const ACTION: Record = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' }; diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 39d45788..403fe175 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -2,20 +2,12 @@ import Base from '../layouts/Base.astro'; import { api } from '../lib/api'; import { asUser, COOKIE } from '../lib/auth'; -import { centuryOf, centuryName, yearLines, ud } from '../lib/urdu'; +import { centuryName, yearLines, ud } from '../lib/urdu'; -type Poet = { id: number; url: string; nickname: string; birth_year_ah: number | null; death_year_ah: number | null; pin_order: number | null }; -const poets = (await api('/api/poets')) ?? []; - -// group by Hijri century of birth (poets without a known year last) -const groups = new Map(); -for (const p of poets) { - const c = centuryOf(p.birth_year_ah); - if (!groups.has(c)) groups.set(c, []); - groups.get(c)!.push(p); -} -const ordered = [...groups.entries()].sort(([a], [b]) => (a ?? 99) - (b ?? 99)); -const pinned = poets.filter((p) => p.pin_order).sort((a, b) => a.pin_order! - b.pin_order!); +type Poet = { id: number; url: string; nickname: string; birth_year_ah: number | null; death_year_ah: number | null }; +// the home page sections, as arranged by moderators with the site permission (api/src/site.ts) +const sections = (await api<{ id: number; title: string | null; century: number | null; poets: Poet[] }[]>('/api/home')) ?? []; +const count = new Set(sections.flatMap((s) => s.poets.map((p) => p.id))).size; // poets whose works hold the signed-in reader's favourites or bookmarks const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined; const marked: number[] = token ? (await asUser(token, '/api/library/marks')).data.poets ?? [] : []; @@ -24,27 +16,16 @@ const card = (p: Poet) => `poet-card${marked.includes(p.id) ? ' mark-bm' : ''}`;

اردو کی کلاسیکی شاعری اور نثر

-

{ud(poets.length)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون

+

{ud(count)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون

- {poets.length === 0 &&

مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔

} + {sections.length === 0 &&

مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔

} - {pinned.length > 0 && ( + {sections.map((s) => (
-

مقبول شعرا

+

{s.title ?? centuryName(s.century || null)}

- {pinned.map((p) => ( - {p.nickname}{yearLines(p).map((y) => {y})} - ))} -
-
- )} - - {ordered.map(([century, list]) => ( -
-

{centuryName(century)}

-
- {list.map((p) => ( + {s.poets.map((p) => ( {p.nickname} {yearLines(p).map((y) => {y})} diff --git a/web/src/pages/mod/site.astro b/web/src/pages/mod/site.astro new file mode 100644 index 00000000..9234c024 --- /dev/null +++ b/web/src/pages/mod/site.astro @@ -0,0 +1,120 @@ +--- +// Home page sections (site permission): rename, add, remove and reorder sections; choose and order the poets of a +// chosen-poets section (e.g. مقبول شعرا); sort a section by hand, alphabetically or by timeline. Saved all at once. +import Base from '../../layouts/Base.astro'; +import ModNav from '../../components/ModNav.astro'; +import { asUser, COOKIE } from '../../lib/auth'; +import { centuryName } from '../../lib/urdu'; + +const me = Astro.locals.user; +if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`); +const token = Astro.cookies.get(COOKIE)!.value; +let error = '', done = ''; +if (Astro.request.method === 'POST') { + let sections: unknown = null; + try { sections = JSON.parse(String((await Astro.request.formData()).get('layout'))); } catch {} + const r = await asUser(token, '/api/mod/site', { sections }); + if (r.ok) done = 'سرورق محفوظ ہو گیا'; + else error = r.data.error ?? 'کچھ غلط ہو گیا'; +} +const res = await asUser(token, '/api/mod/site'); +const centuries = Object.fromEntries(Array.from({ length: 16 }, (_, c) => [c, centuryName(c || null)])); +--- + +

سرورق کے حصے

+ + {!res.ok ? : ( + <> + {error && } + {done &&

{done} · سرورق دیکھیں

} +

+ حصوں کے نام بدلیں، ↑ ↓ سے ان کی جگہ بدلیں، اور ہر حصے کی ترتیب چنیں: دستی، حروفِ تہجی یا زمانی (پیدائش کے سال سے)۔ + صدی کے حصے میں اس صدی میں پیدا ہونے والے تمام شعرا خود آتے ہیں؛ «منتخب شعرا» کے حصے میں آپ شعرا چن کر ترتیب دیتے ہیں۔ +

+
+
+

+ + +

+ +

سرورق

+
+ + diff --git a/web/src/styles/global.css b/web/src/styles/global.css index bb992dff..b44c89fa 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -394,3 +394,12 @@ h1 + .muted { text-align: center; margin-top: 0; } .dv-editor .variant { border-bottom: 2px dashed var(--gold); cursor: pointer; } .dv-editor .is-empty::before { content: attr(data-placeholder); color: var(--muted); opacity: .6; pointer-events: none; float: inline-start; height: 0; } @media (max-width: 700px) { .dv-editor .unit:has(> .misra:nth-child(2):last-child) { grid-template-columns: 1fr; } .dv-editor .unit .misra { text-align: center !important; } .meta-row { grid-template-columns: 1fr; } } +/* home page sections editor (/mod/site) */ +.site-section { border: 1px solid var(--border); border-radius: 10px; background: var(--paper); margin: 10px 0; padding: 8px 12px; } +.site-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.site-head input { flex: 1 1 14em; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); } +.site-poets { list-style: urdu; margin: 8px 0; padding-inline-start: 2.5em; columns: 2 16em; } +.site-poets li { break-inside: avoid; } +.site-poets .tool { min-width: 1.6em; padding: 0 4px; margin-inline-start: 4px; font-size: .85rem; } +.site-section .author-find { position: relative; max-width: 26em; } +.site-section .author-find input { width: 100%; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); }