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:
parent
d4d8c92711
commit
771ba54863
@ -13,7 +13,7 @@ import { audit, requireAdmin } from './admin.ts';
|
|||||||
|
|
||||||
export const MODERATORS = ['mod-l2', 'mod-l1'] as const;
|
export const MODERATORS = ['mod-l2', 'mod-l1'] as const;
|
||||||
export const SCOPES = ['all', 'poet', 'category', 'poem'] 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 const ACTIONS = ['create', 'edit', 'delete', 'arrange'] as const;
|
||||||
export type Action = (typeof ACTIONS)[number];
|
export type Action = (typeof ACTIONS)[number];
|
||||||
export type Content = (typeof CONTENT)[number];
|
export type Content = (typeof CONTENT)[number];
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { adminRoutes } from './admin.ts';
|
|||||||
import { permissionRoutes } from './permissions.ts';
|
import { permissionRoutes } from './permissions.ts';
|
||||||
import { libraryRoutes } from './library.ts';
|
import { libraryRoutes } from './library.ts';
|
||||||
import { moderationRoutes } from './moderation.ts';
|
import { moderationRoutes } from './moderation.ts';
|
||||||
|
import { siteRoutes } from './site.ts';
|
||||||
|
|
||||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@ -165,6 +166,7 @@ adminRoutes(app);
|
|||||||
permissionRoutes(app);
|
permissionRoutes(app);
|
||||||
libraryRoutes(app);
|
libraryRoutes(app);
|
||||||
moderationRoutes(app);
|
moderationRoutes(app);
|
||||||
|
siteRoutes(app);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 4100);
|
const port = Number(process.env.PORT ?? 4100);
|
||||||
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
||||||
|
|||||||
56
api/src/site.test.ts
Normal file
56
api/src/site.test.ts
Normal 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
102
api/src/site.ts
Normal 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() };
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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_revision ON revision_events(revision_id);
|
||||||
CREATE INDEX IF NOT EXISTS revision_events_at ON revision_events(at DESC);
|
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 '{}'
|
||||||
|
);
|
||||||
|
|||||||
@ -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">
|
<nav class="admin-nav">
|
||||||
<a href="/mod" aria-current={current === 'queue' ? 'page' : undefined}>موڈریشن</a>
|
<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?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>
|
<a href="/mod/guide" aria-current={current === 'guide' ? 'page' : undefined}>رہنمائی</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts)
|
// 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 ROLE: Record<string, string> = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
|
||||||
export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
|
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: 'ترتیب' };
|
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };
|
||||||
|
|||||||
@ -2,20 +2,12 @@
|
|||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { asUser, COOKIE } from '../lib/auth';
|
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 };
|
type Poet = { id: number; url: string; nickname: string; birth_year_ah: number | null; death_year_ah: number | null };
|
||||||
const poets = (await api<Poet[]>('/api/poets')) ?? [];
|
// 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')) ?? [];
|
||||||
// group by Hijri century of birth (poets without a known year last)
|
const count = new Set(sections.flatMap((s) => s.poets.map((p) => p.id))).size;
|
||||||
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!);
|
|
||||||
// poets whose works hold the signed-in reader's favourites or bookmarks
|
// poets whose works hold the signed-in reader's favourites or bookmarks
|
||||||
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
|
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
|
||||||
const marked: number[] = token ? (await asUser(token, '/api/library/marks')).data.poets ?? [] : [];
|
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>
|
<Base>
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>اردو کی کلاسیکی شاعری اور نثر</h1>
|
<h1>اردو کی کلاسیکی شاعری اور نثر</h1>
|
||||||
<p>{ud(poets.length)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
|
<p>{ud(count)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{poets.length === 0 && <p class="muted">مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔</p>}
|
{sections.length === 0 && <p class="muted">مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔</p>}
|
||||||
|
|
||||||
{pinned.length > 0 && (
|
{sections.map((s) => (
|
||||||
<section class="century">
|
<section class="century">
|
||||||
<h2>مقبول شعرا</h2>
|
<h2>{s.title ?? centuryName(s.century || null)}</h2>
|
||||||
<div class="poets">
|
<div class="poets">
|
||||||
{pinned.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>)}</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ordered.map(([century, list]) => (
|
|
||||||
<section class="century">
|
|
||||||
<h2>{centuryName(century)}</h2>
|
|
||||||
<div class="poets">
|
|
||||||
{list.map((p) => (
|
|
||||||
<a class={card(p)} href={p.url} title={p.nickname}>
|
<a class={card(p)} href={p.url} title={p.nickname}>
|
||||||
<span class="name">{p.nickname}</span>
|
<span class="name">{p.nickname}</span>
|
||||||
{yearLines(p).map((y) => <small>{y}</small>)}
|
{yearLines(p).map((y) => <small>{y}</small>)}
|
||||||
|
|||||||
120
web/src/pages/mod/site.astro
Normal file
120
web/src/pages/mod/site.astro
Normal 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>
|
||||||
@ -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 .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; }
|
.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; } }
|
@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); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user