Home page sections (site permission): rename, add, remove and reorder sections; chosen poets; sort by hand, alphabetically or by timeline

- home_sections: chosen-poets sections (مقبول شعرا) and century sections (every poet born in that Hijri century,
  so new poets appear on their own); first use seeds the layout the home page had.
- New content type 'site' for grants (scope all, action edit); admins always. Changes apply at once and are audited.
- /mod/site editor (tab سرورق): names, ↑ ↓, remove, sort, add/remove/order poets with the Urdu name box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-09 14:24:43 +02:00
parent d4d8c92711
commit 771ba54863
10 changed files with 315 additions and 32 deletions

View File

@ -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];

View File

@ -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' });

56
api/src/site.test.ts Normal file
View File

@ -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();
}
});

102
api/src/site.ts Normal file
View File

@ -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<Section[]> {
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() };
});
}

View File

@ -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 '{}'
);

View File

@ -1,8 +1,9 @@
---
const { current } = Astro.props as { current?: 'queue' | 'log' | 'guide' };
const { current } = Astro.props as { current?: 'queue' | 'log' | 'guide' | 'site' };
---
<nav class="admin-nav">
<a href="/mod" aria-current={current === 'queue' ? 'page' : undefined}>موڈریشن</a>
<a href="/mod?log=1" aria-current={current === 'log' ? 'page' : undefined}>سرگرمی</a>
<a href="/mod/site" aria-current={current === 'site' ? 'page' : undefined}>سرورق</a>
<a href="/mod/guide" aria-current={current === 'guide' ? 'page' : undefined}>رہنمائی</a>
</nav>

View File

@ -1,5 +1,5 @@
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts)
export const ROLE: Record<string, string> = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت' };
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)' };
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };

View File

@ -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<Poet[]>('/api/poets')) ?? [];
// group by Hijri century of birth (poets without a known year last)
const groups = new Map<number | null, Poet[]>();
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' : ''}`;
<Base>
<section class="hero">
<h1>اردو کی کلاسیکی شاعری اور نثر</h1>
<p>{ud(poets.length)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
<p>{ud(count)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
</section>
{poets.length === 0 && <p class="muted">مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔</p>}
{sections.length === 0 && <p class="muted">مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔</p>}
{pinned.length > 0 && (
{sections.map((s) => (
<section class="century">
<h2>مقبول شعرا</h2>
<h2>{s.title ?? centuryName(s.century || null)}</h2>
<div class="poets">
{pinned.map((p) => (
<a class={card(p)} href={p.url} title={p.nickname}><span class="name">{p.nickname}</span>{yearLines(p).map((y) => <small>{y}</small>)}</a>
))}
</div>
</section>
)}
{ordered.map(([century, list]) => (
<section class="century">
<h2>{centuryName(century)}</h2>
<div class="poets">
{list.map((p) => (
{s.poets.map((p) => (
<a class={card(p)} href={p.url} title={p.nickname}>
<span class="name">{p.nickname}</span>
{yearLines(p).map((y) => <small>{y}</small>)}

View File

@ -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)]));
---
<Base title="سرورق کے حصے">
<h1>سرورق کے حصے</h1>
<ModNav current="site" />
{!res.ok ? <p class="form-error center" role="alert">{res.data.error ?? 'یہ صفحہ دیکھنے کی اجازت نہیں'}</p> : (
<>
{error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done} · <a href="/">سرورق دیکھیں</a></p>}
<p class="muted center">
حصوں کے نام بدلیں، ↑ ↓ سے ان کی جگہ بدلیں، اور ہر حصے کی ترتیب چنیں: دستی، حروفِ تہجی یا زمانی (پیدائش کے سال سے)۔
صدی کے حصے میں اس صدی میں پیدا ہونے والے تمام شعرا خود آتے ہیں؛ «منتخب شعرا» کے حصے میں آپ شعرا چن کر ترتیب دیتے ہیں۔
</p>
<form method="post" id="site-form">
<div id="sections"></div>
<p class="filter-actions">
<button type="button" class="tool" id="add-list">+ منتخب شعرا کا حصہ</button>
<select id="add-century" class="tool" aria-label="صدی کا حصہ">
<option value="">+ صدی کا حصہ</option>
{Object.entries(centuries).map(([c, name]) => <option value={c}>{name}</option>)}
</select>
</p>
<input type="hidden" name="layout" id="layout" />
<p class="filter-actions"><button>محفوظ کریں</button> <a href="/">سرورق</a></p>
</form>
<script type="application/json" id="site-data" set:html={JSON.stringify({ ...res.data, centuries })} />
</>
)}
<script>
// the layout lives in `state`; every change redraws the list (a few hundred elements at most)
const el = document.getElementById('site-data');
if (el) {
const data = JSON.parse(el.textContent!);
const names = new Map<number, string>(data.poets.map((p: any) => [p.id, p.name]));
const state: any[] = data.sections.map((s: any) => ({ title: s.title ?? '', century: s.century, sort: s.sort, poets: [...s.poets] }));
const host = document.getElementById('sections')!;
const norm = (s: string) => s.normalize('NFC').replace(/[ً-ْٰٔ]/g, '').replace(/[يى]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ');
const btn = (label: string, title: string, fn: () => void) => {
const b = document.createElement('button'); b.type = 'button'; b.className = 'tool'; b.textContent = label; b.title = title;
b.onclick = () => { fn(); draw(); }; return b;
};
const move = (xs: any[], i: number, d: number) => { const j = i + d; if (j >= 0 && j < xs.length) [xs[i], xs[j]] = [xs[j], xs[i]]; };
const SORTS: [string, string][] = [['manual', 'دستی'], ['alpha', 'حروفِ تہجی'], ['timeline', 'زمانی']];
function draw() {
host.replaceChildren(...state.map((s, i) => {
const box = document.createElement('fieldset'); box.className = 'site-section';
const head = document.createElement('div'); head.className = 'site-head';
const title = document.createElement('input'); title.value = s.title; title.setAttribute('data-urdu', '');
title.placeholder = s.century === null ? 'حصے کا نام' : data.centuries[s.century]; title.setAttribute('aria-label', 'حصے کا نام');
title.oninput = () => (s.title = title.value);
const sort = document.createElement('select'); sort.className = 'tool'; sort.setAttribute('aria-label', 'ترتیب');
for (const [v, l] of SORTS) if (v !== 'manual' || s.century === null) sort.append(new Option(l, v, false, s.sort === v));
sort.onchange = () => { s.sort = sort.value; draw(); };
const kind = document.createElement('small'); kind.className = 'muted';
kind.textContent = s.century === null ? `منتخب شعرا (${s.poets.length})` : `صدی: ${data.centuries[s.century]}`;
head.append(title, sort, kind,
btn('↑', 'حصہ اوپر', () => move(state, i, -1)), btn('↓', 'حصہ نیچے', () => move(state, i, 1)),
btn('ہٹائیں', 'یہ حصہ ہٹائیں', () => { if (confirm('یہ حصہ ہٹا دیں؟')) state.splice(i, 1); }));
box.append(head);
if (s.century === null) box.append(poetList(s));
return box;
}));
}
// a chosen-poets section: its poets (↑ ↓ ✕ when sorted by hand) and a name box to add one
function poetList(s: any) {
const wrap = document.createElement('div');
const ol = document.createElement('ol'); ol.className = 'site-poets';
s.poets.forEach((id: number, j: number) => {
const li = document.createElement('li'); li.append(names.get(id) ?? String(id));
if (s.sort === 'manual') li.append(btn('↑', 'اوپر', () => move(s.poets, j, -1)), btn('↓', 'نیچے', () => move(s.poets, j, 1)));
li.append(btn('✕', 'ہٹائیں', () => s.poets.splice(j, 1)));
ol.append(li);
});
const find = document.createElement('div'); find.className = 'author-find';
const box = document.createElement('input'); box.setAttribute('data-urdu', ''); box.placeholder = 'شاعر یا ادیب کا نام لکھ کر شامل کریں'; box.autocomplete = 'off';
const list = document.createElement('div'); list.className = 'author-suggest'; list.hidden = true;
box.oninput = () => {
const t = norm(box.value.trim());
const hits = t ? data.poets.filter((p: any) => !s.poets.includes(p.id) && norm(p.name).includes(t)).slice(0, 10) : [];
list.replaceChildren(...hits.map((p: any) => btn(p.name, 'شامل کریں', () => s.poets.push(p.id))));
list.hidden = !hits.length;
};
box.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); (list.querySelector('button') as HTMLButtonElement | null)?.click(); } };
find.append(box, list);
wrap.append(ol, find);
return wrap;
}
document.getElementById('add-list')!.onclick = () => { state.push({ title: '', century: null, sort: 'manual', poets: [] }); draw(); host.lastElementChild?.querySelector('input')?.focus(); };
const addC = document.getElementById('add-century') as HTMLSelectElement;
addC.onchange = () => { if (addC.value !== '') state.push({ title: '', century: Number(addC.value), sort: 'timeline', poets: [] }); addC.value = ''; draw(); };
document.getElementById('site-form')!.addEventListener('submit', () => {
(document.getElementById('layout') as HTMLInputElement).value = JSON.stringify(state);
});
draw();
}
</script>
</Base>

View File

@ -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); }