Compare commits

..

No commits in common. "ce9adbe9e11af85b122a2612a9ebd80dbe135641" and "ff8d8ea3ae573bb7576264b3abe4dbe743858aa4" have entirely different histories.

45 changed files with 169 additions and 550 deletions

View File

@ -1,7 +1,6 @@
{
"version": "0.0.1",
"configurations": [
{ "name": "api-dev", "runtimeExecutable": "node", "runtimeArgs": ["--watch", "api/src/server.ts"], "env": { "PORT": "4101", "HOST": "127.0.0.1" }, "port": 4101 },
{ "name": "web-dev", "runtimeExecutable": "npx", "runtimeArgs": ["--prefix", "web", "astro", "dev", "--root", "web", "--port", "4201"], "env": { "API_URL": "http://127.0.0.1:4101" }, "port": 4201 }
{ "name": "web-dev", "runtimeExecutable": "npx", "runtimeArgs": ["--prefix", "web", "astro", "dev", "--root", "web", "--port", "4201"], "port": 4201 }
]
}

View File

@ -1,5 +1,3 @@
<p align="center"><img src="web/public/logo/turanj.svg" width="140" alt="دیوان"></p>
# دیوان · Divan
An open-source site for reading and searching **classical Urdu poetry and prose**, in Urdu script (`ur-PK`). Content comes from [divan-data](https://github.com/anas-rashid/divan-data): public-domain texts from Urdu Wikisource, with short poet introductions from Urdu Wikipedia.

View File

@ -31,11 +31,6 @@ test('admin panel: only admins; reset, disable, role, delete, self-protection, a
const list = (await call('GET', `/api/admin/users?q=reader-${run}`, undefined, a.token)).json();
assert.deepEqual(list.users.map((u: any) => u.email), [`reader-${run}@divan.test`]);
const rid = r.user.id;
// role filter and counts: the search for this run's emails finds one admin and one reader
const both = (await call('GET', `/api/admin/users?q=-${run}@`, undefined, a.token)).json();
assert.equal(both.counts.all, 2); assert.equal(both.counts.admin, 1); assert.equal(both.counts.reader, 1);
assert.deepEqual((await call('GET', `/api/admin/users?q=-${run}@&role=admin`, undefined, a.token)).json().users.map((u: any) => u.email), [`admin-${run}@divan.test`]);
assert.equal(both.users[0].grants, 0);
// password reset: new temporary password works, the old one and old sessions do not
const { password } = (await call('POST', `/api/admin/users/${rid}/password`, undefined, a.token)).json();

View File

@ -1,8 +1,7 @@
// Admin panel API (admins only). No email server yet, so password resets are done here on a reader's
// request: a temporary password is generated, shown to the admin once, and the reader's sessions end.
// Every action is written to audit_log. Moderators' grants: permissions.ts.
// GET /api/admin/users?q=&role=&page= users (search by email or name; role: a role or 'disabled'), newest first,
// with counts per role and each moderator's number of grants
// GET /api/admin/users?q=&page= users (search by email), newest first
// POST /api/admin/users/:id/password -> {password} (temporary, shown once)
// POST /api/admin/users/:id/disable {disabled: boolean}
// POST /api/admin/users/:id/role {role: 'reader' | 'mod-l2' | 'mod-l1' | 'admin'}
@ -34,28 +33,18 @@ export const audit = (actor: any, action: string, target: any, detail?: object)
[actor?.id ?? null, actor?.email ?? 'server', action, target?.id ?? null, target?.email ?? null, detail ?? null]);
export function adminRoutes(app: FastifyInstance) {
app.get<{ Querystring: { q?: string; role?: string; page?: string } }>('/api/admin/users', async (req, reply) => {
app.get<{ Querystring: { q?: string; page?: string } }>('/api/admin/users', async (req, reply) => {
if (!(await requireAdmin(req, reply))) return;
const q = (req.query.q ?? '').trim().toLowerCase(), page = Math.max(1, Number(req.query.page) || 1);
const like = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%';
const role = req.query.role ?? '';
// the role filter: one role, or 'disabled' (any role); anything else lists everyone
const where = `(u.email LIKE $1 OR lower(coalesce(u.full_name, '')) LIKE $1)` +
(role === 'disabled' ? ' AND u.disabled_at IS NOT NULL' : ['reader', 'mod-l2', 'mod-l1', 'admin'].includes(role) ? ' AND u.role = $2' : '');
const args = ['reader', 'mod-l2', 'mod-l1', 'admin'].includes(role) ? [like, role] : [like];
const [count, rows, counts] = await Promise.all([
pool.query(`SELECT count(*)::int AS n FROM users u WHERE ${where}`, args),
const [count, rows] = await Promise.all([
pool.query('SELECT count(*)::int AS n FROM users WHERE email LIKE $1', [like]),
pool.query(
`SELECT u.id, u.email, u.full_name, u.role, u.created_at, u.disabled_at,
(SELECT max(s.created_at) FROM sessions s WHERE s.user_id = u.id) AS last_sign_in,
(SELECT count(*)::int FROM grants g WHERE g.user_id = u.id) AS grants
FROM users u WHERE ${where} ORDER BY u.created_at DESC LIMIT ${PAGE} OFFSET ${(page - 1) * PAGE}`, args),
pool.query(`SELECT u.role, count(*)::int AS n, count(u.disabled_at)::int AS disabled FROM users u
WHERE u.email LIKE $1 OR lower(coalesce(u.full_name, '')) LIKE $1 GROUP BY u.role`, [like]),
`SELECT u.id, u.email, u.role, u.created_at, u.disabled_at, max(s.created_at) AS last_sign_in
FROM users u LEFT JOIN sessions s ON s.user_id = u.id WHERE u.email LIKE $1
GROUP BY u.id ORDER BY u.created_at DESC LIMIT ${PAGE} OFFSET ${(page - 1) * PAGE}`, [like]),
]);
const by: Record<string, number> = { all: 0, disabled: 0 };
for (const r of counts.rows) { by[r.role] = r.n; by.all += r.n; by.disabled += r.disabled; }
return { total: count.rows[0].n, page, pageSize: PAGE, counts: by, users: rows.rows.map((r) => ({ ...r, id: Number(r.id) })) };
return { total: count.rows[0].n, page, pageSize: PAGE, users: rows.rows.map((r) => ({ ...r, id: Number(r.id) })) };
});
// one target user, never the acting admin themself for actions that could lock them out

View File

@ -95,7 +95,7 @@ export async function grantsOf(userId: number) {
export function permissionRoutes(app: FastifyInstance) {
app.get<{ Params: { id: string } }>('/api/admin/users/:id/grants', async (req, reply) => {
if (!(await requireAdmin(req, reply))) return;
const u = (await pool.query('SELECT id, email, full_name, role, created_at, disabled_at, (SELECT max(created_at) FROM sessions WHERE user_id = users.id) AS last_sign_in FROM users WHERE id = $1', [Number(req.params.id) || 0])).rows[0];
const u = (await pool.query('SELECT id, email, role, disabled_at FROM users WHERE id = $1', [Number(req.params.id) || 0])).rows[0];
if (!u) return reply.code(404).send({ error: 'صارف نہیں ملا' });
return { user: { ...u, id: Number(u.id) }, grants: await grantsOf(u.id) };
});

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><polygon fill="#b88828" points="32.00,1.00 36.96,7.07 43.86,3.36 46.12,10.86 53.92,10.08 53.14,17.88 60.64,20.14 56.93,27.04 63.00,32.00 56.93,36.96 60.64,43.86 53.14,46.12 53.92,53.92 46.12,53.14 43.86,60.64 36.96,56.93 32.00,63.00 27.04,56.93 20.14,60.64 17.88,53.14 10.08,53.92 10.86,46.12 3.36,43.86 7.07,36.96 1.00,32.00 7.07,27.04 3.36,20.14 10.86,17.88 10.08,10.08 17.88,10.86 20.14,3.36 27.04,7.07"/><polygon fill="#b88828" opacity=".55" points="37.62,3.72 41.49,9.09 48.02,8.03 49.54,14.46 55.97,15.98 54.91,22.51 60.28,26.38 56.80,32.00 60.28,37.62 54.91,41.49 55.97,48.02 49.54,49.54 48.02,55.97 41.49,54.91 37.62,60.28 32.00,56.80 26.38,60.28 22.51,54.91 15.98,55.97 14.46,49.54 8.03,48.02 9.09,41.49 3.72,37.62 7.20,32.00 3.72,26.38 9.09,22.51 8.03,15.98 14.46,14.46 15.98,8.03 22.51,9.09 26.38,3.72 32.00,7.20"/><circle cx="32" cy="32" r="22.94" fill="#1d3b8b"/><circle cx="32" cy="32" r="21.080000000000002" fill="none" stroke="#b88828" stroke-width="0.62"/><path transform="translate(27.6 37.5)" fill="#fbf6ea" d="M-1.41 2.9699999999999998 -1.65 2.2199999999999998Q0.21 1.26 1.7249999999999999 0.39Q3.2399999999999998 -0.48 4.41 -1.275Q5.58 -2.07 6.39 -2.76Q7.199999999999999 -3.4499999999999997 7.6049999999999995 -4.035Q8.01 -4.62 8.01 -5.069999999999999Q8.01 -5.43 7.5 -6.06Q6.989999999999999 -6.6899999999999995 5.76 -7.484999999999999Q4.53 -8.28 2.4 -9.15Q2.61 -9.9 2.985 -10.905000000000001Q3.36 -11.91 3.8099999999999996 -12.945Q4.26 -13.979999999999999 4.68 -14.82Q5.1 -15.66 5.3999999999999995 -16.05H5.819999999999999Q7.17 -15.36 8.219999999999999 -14.294999999999998Q9.27 -13.229999999999999 9.855 -11.834999999999999Q10.44 -10.44 10.44 -8.67Q10.44 -6.989999999999999 9.975 -5.444999999999999Q9.51 -3.9 8.805 -2.6399999999999997Q8.1 -1.38 7.365 -0.5399999999999999Q6.63 0.3 6.06 0.57Q5.31 0.9299999999999999 3.2849999999999997 1.62Q1.26 2.31 -1.41 2.9699999999999998Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="13" fill="#1d3b8b"/><path fill="#fbf6ea" d="M4,40 Q13.5,34.6 23,37.0 L23,43.0 Q13.5,45.4 4,40Z"/><path fill="#fbf6ea" d="M60,40 Q50.5,34.6 41,37.0 L41,43.0 Q50.5,45.4 60,40Z"/><g fill="#b88828"><path transform="translate(32 40) rotate(45)" d="M0,0 C4.5,-3.5 2.5,-9.0 0,-10 C-2.5,-9.0 -4.5,-3.5 0,0Z"/><path transform="translate(32 40) rotate(135)" d="M0,0 C4.5,-3.5 2.5,-9.0 0,-10 C-2.5,-9.0 -4.5,-3.5 0,0Z"/><path transform="translate(32 40) rotate(225)" d="M0,0 C4.5,-3.5 2.5,-9.0 0,-10 C-2.5,-9.0 -4.5,-3.5 0,0Z"/><path transform="translate(32 40) rotate(315)" d="M0,0 C4.5,-3.5 2.5,-9.0 0,-10 C-2.5,-9.0 -4.5,-3.5 0,0Z"/><circle cx="32" cy="40" r="1.60" fill="#fbf6ea"/></g><path fill="#fbf6ea" d="M32,12 L37.25,17 L32,22 L26.75,17Z"/></svg>

Before

Width:  |  Height:  |  Size: 861 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="13" fill="#1d3b8b"/><g transform="translate(16 50) rotate(-45)"><path fill="#fbf6ea" d="M0,0 L22,-5.0 L22,5.0 Z M22,-5.0 h19.8 v10 h-19.8Z"/><path stroke="#fbf6ea" stroke-width="0.90" d="M3.96,0 L16.5,0"/></g><path fill="#b88828" d="M44,14 L52.4,22 L44,30 L35.6,22Z"/></svg>

Before

Width:  |  Height:  |  Size: 390 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><path d="M8,60 L8,28 C8,13.7 16.64,13.04 27.2,8.64 C30.56,7.1 32.0,6 32.0,6 C32.0,6 33.44,7.1 36.8,8.64 C47.36,13.04 56,13.7 56,28 L56,60Z" fill="#b88828"/><path d="M13,60 L13,30 C13,18.3 19.84,17.759999999999998 28.2,14.16 C30.86,12.9 32.0,12 32.0,12 C32.0,12 33.14,12.9 35.8,14.16 C44.16,17.759999999999998 51,18.3 51,30 L51,60Z" fill="#1d3b8b"/><path transform="translate(28.2 47.7)" fill="#fbf6ea" d="M-1.222 2.574 -1.43 1.924Q0.182 1.0919999999999999 1.4949999999999999 0.33799999999999997Q2.808 -0.416 3.8219999999999996 -1.105Q4.835999999999999 -1.7939999999999998 5.537999999999999 -2.392Q6.239999999999999 -2.9899999999999998 6.590999999999999 -3.497Q6.941999999999999 -4.004 6.941999999999999 -4.394Q6.941999999999999 -4.7059999999999995 6.5 -5.252Q6.058 -5.798 4.992 -6.487Q3.9259999999999997 -7.175999999999999 2.08 -7.93Q2.262 -8.58 2.5869999999999997 -9.451Q2.912 -10.322 3.3019999999999996 -11.219Q3.6919999999999997 -12.116 4.056 -12.844Q4.42 -13.572 4.68 -13.91H5.044Q6.2139999999999995 -13.312 7.123999999999999 -12.389Q8.033999999999999 -11.466 8.541 -10.257Q9.048 -9.048 9.048 -7.513999999999999Q9.048 -6.058 8.645 -4.718999999999999Q8.241999999999999 -3.38 7.630999999999999 -2.288Q7.02 -1.196 6.382999999999999 -0.46799999999999997Q5.7459999999999996 0.26 5.252 0.494Q4.601999999999999 0.8059999999999999 2.8469999999999995 1.404Q1.0919999999999999 2.002 -1.222 2.574Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

View File

@ -4,8 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>دیوان · قاری</title>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsPSLYr9uM2YjYp9mGIj48dGl0bGU+2K/bjNmI2KfZhjwvdGl0bGU+PGcgY2xhc3M9InJheXMtbG9uZyI+PHBvbHlnb24gZmlsbD0iI2I4ODgyOCIgb3BhY2l0eT0iLjYiIHBvaW50cz0iNDAuMDIsMi4wNiA0NC4wOSwxMS4wNiA1My45MiwxMC4wOCA1Mi45NCwxOS45MSA2MS45NCwyMy45OCA1Ni4xOCwzMi4wMCA2MS45NCw0MC4wMiA1Mi45NCw0NC4wOSA1My45Miw1My45MiA0NC4wOSw1Mi45NCA0MC4wMiw2MS45NCAzMi4wMCw1Ni4xOCAyMy45OCw2MS45NCAxOS45MSw1Mi45NCAxMC4wOCw1My45MiAxMS4wNiw0NC4wOSAyLjA2LDQwLjAyIDcuODIsMzIuMDAgMi4wNiwyMy45OCAxMS4wNiwxOS45MSAxMC4wOCwxMC4wOCAxOS45MSwxMS4wNiAyMy45OCwyLjA2IDMyLjAwLDcuODIiLz48L2c+PGcgY2xhc3M9InJheXMtc2hvcnQiPjxjaXJjbGUgY3g9IjMyLjAwIiBjeT0iMTEuNTQiIHI9IjYuMjAiIGZpbGw9IiNiODg4MjgiLz48Y2lyY2xlIGN4PSI0Mi4yMyIgY3k9IjE0LjI4IiByPSI2LjIwIiBmaWxsPSIjYjg4ODI4Ii8+PGNpcmNsZSBjeD0iNDkuNzIiIGN5PSIyMS43NyIgcj0iNi4yMCIgZmlsbD0iI2I4ODgyOCIvPjxjaXJjbGUgY3g9IjUyLjQ2IiBjeT0iMzIuMDAiIHI9IjYuMjAiIGZpbGw9IiNiODg4MjgiLz48Y2lyY2xlIGN4PSI0OS43MiIgY3k9IjQyLjIzIiByPSI2LjIwIiBmaWxsPSIjYjg4ODI4Ii8+PGNpcmNsZSBjeD0iNDIuMjMiIGN5PSI0OS43MiIgcj0iNi4yMCIgZmlsbD0iI2I4ODgyOCIvPjxjaXJjbGUgY3g9IjMyLjAwIiBjeT0iNTIuNDYiIHI9IjYuMjAiIGZpbGw9IiNiODg4MjgiLz48Y2lyY2xlIGN4PSIyMS43NyIgY3k9IjQ5LjcyIiByPSI2LjIwIiBmaWxsPSIjYjg4ODI4Ii8+PGNpcmNsZSBjeD0iMTQuMjgiIGN5PSI0Mi4yMyIgcj0iNi4yMCIgZmlsbD0iI2I4ODgyOCIvPjxjaXJjbGUgY3g9IjExLjU0IiBjeT0iMzIuMDAiIHI9IjYuMjAiIGZpbGw9IiNiODg4MjgiLz48Y2lyY2xlIGN4PSIxNC4yOCIgY3k9IjIxLjc3IiByPSI2LjIwIiBmaWxsPSIjYjg4ODI4Ii8+PGNpcmNsZSBjeD0iMjEuNzciIGN5PSIxNC4yOCIgcj0iNi4yMCIgZmlsbD0iI2I4ODgyOCIvPjxwYXRoIGZpbGw9IiMxZDNiOGIiIGQ9Ik0zNy42LDEwLjMgTDM4LjQsMTEuMCBMMzcuNiwxMS44IEwzNi44LDExLjBaIi8+PHBhdGggZmlsbD0iIzFkM2I4YiIgZD0iTTQ3LjMsMTUuOSBMNDguMiwxNi43IEw0Ny4zLDE3LjQgTDQ2LjUsMTYuN1oiLz48cGF0aCBmaWxsPSIjMWQzYjhiIiBkPSJNNTMuMCwyNS42IEw1My44LDI2LjQgTDUzLjAsMjcuMiBMNTIuMSwyNi40WiIvPjxwYXRoIGZpbGw9IiMxZDNiOGIiIGQ9Ik01My4wLDM2LjggTDUzLjgsMzcuNiBMNTMuMCwzOC40IEw1Mi4xLDM3LjZaIi8+PHBhdGggZmlsbD0iIzFkM2I4YiIgZD0iTTQ3LjMsNDYuNiBMNDguMiw0Ny4zIEw0Ny4zLDQ4LjEgTDQ2LjUsNDcuM1oiLz48cGF0aCBmaWxsPSIjMWQzYjhiIiBkPSJNMzcuNiw1Mi4yIEwzOC40LDUzLjAgTDM3LjYsNTMuNyBMMzYuOCw1My4wWiIvPjxwYXRoIGZpbGw9IiMxZDNiOGIiIGQ9Ik0yNi40LDUyLjIgTDI3LjIsNTMuMCBMMjYuNCw1My43IEwyNS42LDUzLjBaIi8+PHBhdGggZmlsbD0iIzFkM2I4YiIgZD0iTTE2LjcsNDYuNiBMMTcuNSw0Ny4zIEwxNi43LDQ4LjEgTDE1LjgsNDcuM1oiLz48cGF0aCBmaWxsPSIjMWQzYjhiIiBkPSJNMTEuMCwzNi44IEwxMS45LDM3LjYgTDExLjAsMzguNCBMMTAuMiwzNy42WiIvPjxwYXRoIGZpbGw9IiMxZDNiOGIiIGQ9Ik0xMS4wLDI1LjYgTDExLjksMjYuNCBMMTEuMCwyNy4yIEwxMC4yLDI2LjRaIi8+PHBhdGggZmlsbD0iIzFkM2I4YiIgZD0iTTE2LjcsMTUuOSBMMTcuNSwxNi43IEwxNi43LDE3LjQgTDE1LjgsMTYuN1oiLz48cGF0aCBmaWxsPSIjMWQzYjhiIiBkPSJNMjYuNCwxMC4zIEwyNy4yLDExLjAgTDI2LjQsMTEuOCBMMjUuNiwxMS4wWiIvPjwvZz48Y2lyY2xlIGN4PSIzMiIgY3k9IjMyIiByPSIyMS4wOCIgZmlsbD0iI2I4ODgyOCIvPjxjaXJjbGUgY3g9IjMyIiBjeT0iMzIiIHI9IjE5LjIyIiBmaWxsPSIjMWQzYjhiIi8+PHBhdGggdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjUuNiA0MS42KSIgZmlsbD0iI2ZiZjZlYSIgZD0iTS0yLjA2IDQuMzUgLTIuNDIgMy4yNVEwLjMwIDEuODQgMi41MyAwLjU3UTQuNzUgLTAuNzAgNi40NiAtMS44N1E4LjE4IC0zLjAzIDkuMzcgLTQuMDRRMTAuNTYgLTUuMDYgMTEuMTUgLTUuOTFRMTEuNzQgLTYuNzcgMTEuNzQgLTcuNDNRMTEuNzQgLTcuOTYgMTEuMDAgLTguODhRMTAuMjUgLTkuODEgOC40NCAtMTAuOTdRNi42NCAtMTIuMTQgMy41MiAtMTMuNDJRMy44MiAtMTQuNTIgNC4zNyAtMTUuOTlRNC45MiAtMTcuNDYgNS41OCAtMTguOThRNi4yNCAtMjAuNTAgNi44NiAtMjEuNzNRNy40OCAtMjIuOTcgNy45MiAtMjMuNTRIOC41M1ExMC41MSAtMjIuNTMgMTIuMDUgLTIwLjk2UTEzLjU5IC0xOS40MCAxNC40NSAtMTcuMzVRMTUuMzEgLTE1LjMxIDE1LjMxIC0xMi43MVExNS4zMSAtMTAuMjUgMTQuNjMgLTcuOThRMTMuOTQgLTUuNzIgMTIuOTEgLTMuODdRMTEuODggLTIuMDIgMTAuODAgLTAuNzlROS43MiAwLjQ0IDguODggMC44M1E3Ljc4IDEuMzYgNC44MSAyLjM3UTEuODQgMy4zOCAtMi4wNiA0LjM1WiIvPjwvc3ZnPg==">
<meta name="description" content="دیوان کا سادہ قاری: اردو کا کلاسیکی ادب، براہِ راست divan-data سے۔">
<meta name="description" content="دیوان کا سادہ قاری: اردو کی کلاسیکی شاعری اور نثر، براہِ راست divan-data سے۔">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Nastaliq+Urdu:wght@400..700&family=Noto+Naskh+Arabic:wght@400..700&display=swap" rel="stylesheet">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="دیوان"><title>دیوان</title><g class="rays-long"><polygon fill="#b88828" opacity=".6" points="40.02,2.06 44.09,11.06 53.92,10.08 52.94,19.91 61.94,23.98 56.18,32.00 61.94,40.02 52.94,44.09 53.92,53.92 44.09,52.94 40.02,61.94 32.00,56.18 23.98,61.94 19.91,52.94 10.08,53.92 11.06,44.09 2.06,40.02 7.82,32.00 2.06,23.98 11.06,19.91 10.08,10.08 19.91,11.06 23.98,2.06 32.00,7.82"/></g><g class="rays-short"><circle cx="32.00" cy="11.54" r="6.20" fill="#b88828"/><circle cx="42.23" cy="14.28" r="6.20" fill="#b88828"/><circle cx="49.72" cy="21.77" r="6.20" fill="#b88828"/><circle cx="52.46" cy="32.00" r="6.20" fill="#b88828"/><circle cx="49.72" cy="42.23" r="6.20" fill="#b88828"/><circle cx="42.23" cy="49.72" r="6.20" fill="#b88828"/><circle cx="32.00" cy="52.46" r="6.20" fill="#b88828"/><circle cx="21.77" cy="49.72" r="6.20" fill="#b88828"/><circle cx="14.28" cy="42.23" r="6.20" fill="#b88828"/><circle cx="11.54" cy="32.00" r="6.20" fill="#b88828"/><circle cx="14.28" cy="21.77" r="6.20" fill="#b88828"/><circle cx="21.77" cy="14.28" r="6.20" fill="#b88828"/><path fill="#1d3b8b" d="M37.6,10.3 L38.4,11.0 L37.6,11.8 L36.8,11.0Z"/><path fill="#1d3b8b" d="M47.3,15.9 L48.2,16.7 L47.3,17.4 L46.5,16.7Z"/><path fill="#1d3b8b" d="M53.0,25.6 L53.8,26.4 L53.0,27.2 L52.1,26.4Z"/><path fill="#1d3b8b" d="M53.0,36.8 L53.8,37.6 L53.0,38.4 L52.1,37.6Z"/><path fill="#1d3b8b" d="M47.3,46.6 L48.2,47.3 L47.3,48.1 L46.5,47.3Z"/><path fill="#1d3b8b" d="M37.6,52.2 L38.4,53.0 L37.6,53.7 L36.8,53.0Z"/><path fill="#1d3b8b" d="M26.4,52.2 L27.2,53.0 L26.4,53.7 L25.6,53.0Z"/><path fill="#1d3b8b" d="M16.7,46.6 L17.5,47.3 L16.7,48.1 L15.8,47.3Z"/><path fill="#1d3b8b" d="M11.0,36.8 L11.9,37.6 L11.0,38.4 L10.2,37.6Z"/><path fill="#1d3b8b" d="M11.0,25.6 L11.9,26.4 L11.0,27.2 L10.2,26.4Z"/><path fill="#1d3b8b" d="M16.7,15.9 L17.5,16.7 L16.7,17.4 L15.8,16.7Z"/><path fill="#1d3b8b" d="M26.4,10.3 L27.2,11.0 L26.4,11.8 L25.6,11.0Z"/></g><circle cx="32" cy="32" r="21.08" fill="#b88828"/><circle cx="32" cy="32" r="19.22" fill="#1d3b8b"/><path transform="translate(25.6 41.6)" fill="#fbf6ea" d="M-2.06 4.35 -2.42 3.25Q0.30 1.84 2.53 0.57Q4.75 -0.70 6.46 -1.87Q8.18 -3.03 9.37 -4.04Q10.56 -5.06 11.15 -5.91Q11.74 -6.77 11.74 -7.43Q11.74 -7.96 11.00 -8.88Q10.25 -9.81 8.44 -10.97Q6.64 -12.14 3.52 -13.42Q3.82 -14.52 4.37 -15.99Q4.92 -17.46 5.58 -18.98Q6.24 -20.50 6.86 -21.73Q7.48 -22.97 7.92 -23.54H8.53Q10.51 -22.53 12.05 -20.96Q13.59 -19.40 14.45 -17.35Q15.31 -15.31 15.31 -12.71Q15.31 -10.25 14.63 -7.98Q13.94 -5.72 12.91 -3.87Q11.88 -2.02 10.80 -0.79Q9.72 0.44 8.88 0.83Q7.78 1.36 4.81 2.37Q1.84 3.38 -2.06 4.35Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#8b2e1f"/><text x="32" y="44" font-size="34" text-anchor="middle" fill="#f8f4ea" font-family="serif">د</text></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 220 B

View File

@ -1,6 +0,0 @@
Newsreader (c) 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
Readerly (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/readerly)
Libron (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/libron)
with Reserved Font Name Libron.
Libron is a modified version of Readerly with reduced serifs.

View File

@ -1,95 +0,0 @@
Newsreader (c) 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
Readerly (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/readerly)
Libron (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/libron)
with Reserved Font Name Libron.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="دیوان"><title>دیوان</title><g class="rays-long"><polygon fill="#b88828" opacity=".6" points="40.02,2.06 44.09,11.06 53.92,10.08 52.94,19.91 61.94,23.98 56.18,32.00 61.94,40.02 52.94,44.09 53.92,53.92 44.09,52.94 40.02,61.94 32.00,56.18 23.98,61.94 19.91,52.94 10.08,53.92 11.06,44.09 2.06,40.02 7.82,32.00 2.06,23.98 11.06,19.91 10.08,10.08 19.91,11.06 23.98,2.06 32.00,7.82"/></g><g class="rays-short"><circle cx="32.00" cy="11.54" r="6.20" fill="#b88828"/><circle cx="42.23" cy="14.28" r="6.20" fill="#b88828"/><circle cx="49.72" cy="21.77" r="6.20" fill="#b88828"/><circle cx="52.46" cy="32.00" r="6.20" fill="#b88828"/><circle cx="49.72" cy="42.23" r="6.20" fill="#b88828"/><circle cx="42.23" cy="49.72" r="6.20" fill="#b88828"/><circle cx="32.00" cy="52.46" r="6.20" fill="#b88828"/><circle cx="21.77" cy="49.72" r="6.20" fill="#b88828"/><circle cx="14.28" cy="42.23" r="6.20" fill="#b88828"/><circle cx="11.54" cy="32.00" r="6.20" fill="#b88828"/><circle cx="14.28" cy="21.77" r="6.20" fill="#b88828"/><circle cx="21.77" cy="14.28" r="6.20" fill="#b88828"/><path fill="#1d3b8b" d="M37.6,10.3 L38.4,11.0 L37.6,11.8 L36.8,11.0Z"/><path fill="#1d3b8b" d="M47.3,15.9 L48.2,16.7 L47.3,17.4 L46.5,16.7Z"/><path fill="#1d3b8b" d="M53.0,25.6 L53.8,26.4 L53.0,27.2 L52.1,26.4Z"/><path fill="#1d3b8b" d="M53.0,36.8 L53.8,37.6 L53.0,38.4 L52.1,37.6Z"/><path fill="#1d3b8b" d="M47.3,46.6 L48.2,47.3 L47.3,48.1 L46.5,47.3Z"/><path fill="#1d3b8b" d="M37.6,52.2 L38.4,53.0 L37.6,53.7 L36.8,53.0Z"/><path fill="#1d3b8b" d="M26.4,52.2 L27.2,53.0 L26.4,53.7 L25.6,53.0Z"/><path fill="#1d3b8b" d="M16.7,46.6 L17.5,47.3 L16.7,48.1 L15.8,47.3Z"/><path fill="#1d3b8b" d="M11.0,36.8 L11.9,37.6 L11.0,38.4 L10.2,37.6Z"/><path fill="#1d3b8b" d="M11.0,25.6 L11.9,26.4 L11.0,27.2 L10.2,26.4Z"/><path fill="#1d3b8b" d="M16.7,15.9 L17.5,16.7 L16.7,17.4 L15.8,16.7Z"/><path fill="#1d3b8b" d="M26.4,10.3 L27.2,11.0 L26.4,11.8 L25.6,11.0Z"/></g><circle cx="32" cy="32" r="21.08" fill="#b88828"/><circle cx="32" cy="32" r="19.22" fill="#1d3b8b"/><path transform="translate(25.6 41.6)" fill="#fbf6ea" d="M-2.06 4.35 -2.42 3.25Q0.30 1.84 2.53 0.57Q4.75 -0.70 6.46 -1.87Q8.18 -3.03 9.37 -4.04Q10.56 -5.06 11.15 -5.91Q11.74 -6.77 11.74 -7.43Q11.74 -7.96 11.00 -8.88Q10.25 -9.81 8.44 -10.97Q6.64 -12.14 3.52 -13.42Q3.82 -14.52 4.37 -15.99Q4.92 -17.46 5.58 -18.98Q6.24 -20.50 6.86 -21.73Q7.48 -22.97 7.92 -23.54H8.53Q10.51 -22.53 12.05 -20.96Q13.59 -19.40 14.45 -17.35Q15.31 -15.31 15.31 -12.71Q15.31 -10.25 14.63 -7.98Q13.94 -5.72 12.91 -3.87Q11.88 -2.02 10.80 -0.79Q9.72 0.44 8.88 0.83Q7.78 1.36 4.81 2.37Q1.84 3.38 -2.06 4.35Z"/></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,16 +0,0 @@
{
"name": "دیوان · اردو کا کلاسیکی ادب",
"short_name": "دیوان",
"lang": "ur-PK",
"dir": "rtl",
"start_url": "/",
"display": "standalone",
"background_color": "#fbf6ea",
"theme_color": "#fbf6ea",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}

View File

@ -1,9 +0,0 @@
---
// The couplet line (from the logo concepts): two half-lines tapering outward from a ✤. Decorative; coloured by the palette.
const s = 13, petal = `M0,0 C${.45 * s},${-.35 * s} ${.25 * s},${-.9 * s} 0,${-s} C${-.25 * s},${-.9 * s} ${-.45 * s},${-.35 * s} 0,0Z`;
const misra = (a: number, b: number, y = 16, h = 6) => `M${a},${y} Q${(a + b) / 2},${y - h * .9} ${b},${y - h / 2} L${b},${y + h / 2} Q${(a + b) / 2},${y + h * .9} ${a},${y}Z`;
---
<svg class="bayt" viewBox="0 0 280 32" aria-hidden="true">
<path class="misra" d={misra(124, 4)} /><path class="misra" d={misra(156, 276)} />
<g class="gul">{[45, 135, 225, 315].map((a) => <path transform={`translate(140 16) rotate(${a})`} d={petal} />)}<circle class="hole" cx="140" cy="16" r={s * .16} /></g>
</svg>

View File

@ -50,14 +50,6 @@ const PATHS = {
down: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
top: '<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>',
bottom: '<path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/>',
// admin
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m20 20-4.9-4.9"/>',
chevron: '<path d="m15 18-6-6 6-6"/>',
// footer
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
database: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
};
---
<svg class:list={['icon', cls]} viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" set:html={PATHS[name]} />

View File

@ -6,10 +6,9 @@ import '@fontsource/noto-nastaliq-urdu/400.css';
import '@fontsource/noto-nastaliq-urdu/700.css';
import '../styles/global.css';
interface Props { title?: string; description?: string; q?: string }
const { title, description = 'اردو کا کلاسیکی ادب', q = '' } = Astro.props;
const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو کا کلاسیکی ادب';
const { title, description = 'اردو کی کلاسیکی شاعری اور نثر', q = '' } = Astro.props;
const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو کی کلاسیکی شاعری اور نثر';
import Icon from '../components/Icon.astro';
import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can turn its rays
---
<!doctype html>
<html lang="ur-PK" dir="rtl">
@ -19,23 +18,6 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#fbf6ea" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#14120f" media="(prefers-color-scheme: dark)" />
<!-- link previews (WhatsApp, Facebook, X, Slack): the logo and headline -->
<meta property="og:site_name" content="دیوان" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="ur_PK" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={new URL(Astro.url.pathname, Astro.url.origin).href} />
<meta property="og:image" content={new URL('/og-image.png', Astro.url.origin).href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="دیوان: اردو کا کلاسیکی ادب" />
<meta name="twitter:card" content="summary_large_image" />
<script is:inline>
// font, palette and light/dark applied before paint (Naskh, sepia and the system's light/dark by default)
try {
@ -54,7 +36,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
<body data-user={Astro.locals.user ? '1' : undefined}>
<header class="site-header">
<div class="wrap">
<a class="wordmark" href="/" title="سرورق"><Fragment set:html={logo.replace('<svg ', '<svg class="logo" ')} /></a>
<a class="wordmark" href="/">دیوان</a>
<form class="search-form" action="/search" method="get" role="search">
<input type="search" name="q" id="q" data-urdu value={q} placeholder="شعر، لفظ یا عبارت تلاش کریں" aria-label="تلاش" autocomplete="off" />
<button type="button" id="rom-toggle" class="in-box rom" aria-label="رومن حروف سے اردو" aria-pressed="false" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
@ -121,21 +103,8 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
</aside>
<footer class="site-footer">
<div class="wrap">
<a class="footer-logo" href="/" title="سرورق"><img src="/logo/turanj.svg" width="56" height="56" alt="دیوان" /></a>
<nav class="footer-links" aria-label="سائٹ کے بارے میں">
<div><h2>دیوان</h2><ul>
<li><a href="/about"><Icon name="info" size={18} /><span>دیوان کے بارے میں<small>مقصد اور طریقۂ کار</small></span></a></li>
<li><a href="/tags"><Icon name="tag" size={18} /><span>ٹیگ<small>موضوعات سے کلام</small></span></a></li>
</ul></div>
<div><h2>ماخذ</h2><ul>
<li><a href="https://ur.wikisource.org" rel="noopener"><Icon name="dict" size={18} /><span>ویکی ماخذ<small>متن · عوامی ملکیت</small></span><Icon name="external" size={13} class="ext" /></a></li>
<li><a href="https://ur.wikipedia.org" rel="noopener"><Icon name="globe" size={18} /><span>ویکیپیڈیا<small>شعرا کے تعارف</small></span><Icon name="external" size={13} class="ext" /></a></li>
</ul></div>
<div><h2>کھلا منبع</h2><ul>
<li><a href="https://github.com/anas-rashid/divan-data" rel="noopener"><Icon name="database" size={18} /><span>ڈیٹا<small>تمام متن، آزادانہ</small></span><Icon name="external" size={13} class="ext" /></a></li>
<li><a href="https://github.com/anas-rashid/divan" rel="noopener"><Icon name="code" size={18} /><span>کوڈ<small>سائٹ کا سورس</small></span><Icon name="external" size={13} class="ext" /></a></li>
</ul></div>
</nav>
<a href="/about">دیوان کے بارے میں</a> · <a href="/tags">ٹیگ</a> · متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
<a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a> · <a href="https://github.com/anas-rashid/divan">کوڈ</a>
<p class="made-in">
<svg class="flag" viewBox="0 0 30 20" width="27" height="18" role="img" aria-label="پاکستان کا پرچم">
<rect width="30" height="20" fill="#fff" /><rect x="7.5" width="22.5" height="20" fill="#01411c" />

View File

@ -1,19 +1,5 @@
import { ud } from './urdu';
// 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: 'لغت', site: 'سائٹ (سرورق کے حصے)', tags: 'ٹیگ', ebooks: 'ای بکس' };
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };
// what each role may do, one line each (the user page's role picker)
export const ROLE_HINT: Record<string, string> = {
reader: 'پڑھ سکتے ہیں، محفوظ کر سکتے ہیں',
'mod-l2': 'دی گئی اجازتوں میں ترمیم بھیج سکتے ہیں',
'mod-l1': 'اپنے دائرے میں ترامیم منظور یا واپس کر سکتے ہیں',
admin: 'سب کچھ: صارفین، اجازتیں، اشاعت',
};
// a date as "today / yesterday / n days ago" for the last few weeks, the date after that
export const ago = (d: string | null) => {
if (!d) return 'کبھی نہیں';
const days = Math.floor((Date.now() - new Date(d).getTime()) / 864e5);
return days < 1 ? 'آج' : days < 2 ? 'کل' : days < 30 ? `${ud(days)} دن پہلے` : ud(new Date(d).toISOString().slice(0, 10));
};

View File

@ -1,72 +1,101 @@
---
// Admin: users. A list to find people (search, role filter); everything about one user is on their page (/admin/user/<id>).
// Admin: users. Password resets happen here on a reader's request (no email server yet).
import Base from '../../layouts/Base.astro';
import Icon from '../../components/Icon.astro';
import AdminNav from '../../components/AdminNav.astro';
import { asUser, COOKIE } from '../../lib/auth';
import { ud } from '../../lib/urdu';
import { ROLE, ago } from '../../lib/roles';
import { ROLE } from '../../lib/roles';
const me = Astro.locals.user;
if (!me) return Astro.redirect('/signin?next=/admin');
if (me.role !== 'admin') return new Response('صرف ایڈمن کے لیے', { status: 403 });
const token = Astro.cookies.get(COOKIE)!.value;
const q = Astro.url.searchParams.get('q') ?? '', role = Astro.url.searchParams.get('role') ?? '';
const page = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1);
const list = (await asUser(token, `/api/admin/users?q=${encodeURIComponent(q)}&role=${encodeURIComponent(role)}&page=${page}`)).data;
let error = '', done = '', tempPassword = '', tempFor = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const id = String(f.get('id')), email = String(f.get('email') ?? '');
const act = String(f.get('act'));
const r = act === 'reset' ? await asUser(token, `/api/admin/users/${id}/password`, {})
: act === 'disable' || act === 'enable' ? await asUser(token, `/api/admin/users/${id}/disable`, { disabled: act === 'disable' })
: act === 'role' ? await asUser(token, `/api/admin/users/${id}/role`, { role: f.get('role') })
: act === 'delete' ? await asUser(token, `/api/admin/users/${id}/delete`, {})
: { ok: false, data: { error: 'نامعلوم عمل' } };
if (!r.ok) error = r.data.error ?? 'کچھ غلط ہو گیا';
else if (act === 'reset') [tempPassword, tempFor] = [r.data.password, email];
else done = { disable: 'اکاؤنٹ معطل کر دیا گیا', enable: 'اکاؤنٹ بحال کر دیا گیا', role: 'کردار بدل دیا گیا', delete: 'اکاؤنٹ حذف کر دیا گیا' }[act] + ` (${email})`;
}
const q = Astro.url.searchParams.get('q') ?? '', page = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1);
const list = (await asUser(token, `/api/admin/users?q=${encodeURIComponent(q)}&page=${page}`)).data;
const pages = Math.ceil(list.total / list.pageSize);
const link = (o: { role?: string; page?: number }) => {
const p = new URLSearchParams();
if (q) p.set('q', q);
const r = o.role ?? role; if (r) p.set('role', r);
if ((o.page ?? 1) > 1) p.set('page', String(o.page));
return `/admin${p.size ? '?' + p : ''}`;
};
const FILTERS: [string, string][] = [['', 'سب'], ['admin', ROLE.admin], ['mod-l1', ROLE['mod-l1']], ['mod-l2', ROLE['mod-l2']], ['reader', ROLE.reader], ['disabled', 'معطل']];
const initial = (u: any) => [...(u.full_name || u.email)][0]?.toUpperCase();
const date = (d: string | null) => (d ? ud(new Date(d).toISOString().slice(0, 10)) : '—');
const link = (n: number) => `/admin?q=${encodeURIComponent(q)}&page=${n}`;
---
<Base title="ایڈمن: صارفین">
<h1>ایڈمن</h1>
<AdminNav current="users" />
{error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done}</p>}
{tempPassword && (
<div class="temp-password" role="status">
<p><bdi dir="ltr">{tempFor}</bdi> کا عارضی پاس ورڈ (صرف ایک بار دکھایا جا رہا ہے):</p>
<p><code dir="ltr">{tempPassword}</code></p>
<p class="muted">یہ پاس ورڈ صارف کو دیں؛ وہ لاگ ان کر کے اپنے اکاؤنٹ کے صفحے سے اسے بدل لیں۔</p>
</div>
)}
<form method="get" class="admin-search">
{role && <input type="hidden" name="role" value={role} />}
<span class="field-icon"><Icon name="search" size={18} /></span>
<input type="search" name="q" value={q} placeholder="نام یا ای میل" aria-label="نام یا ای میل سے تلاش" />
<input type="search" name="q" value={q} placeholder="ای میل سے تلاش" dir="ltr" />
<button type="submit">تلاش</button>
</form>
<nav class="role-filter" aria-label="کردار">
{FILTERS.map(([k, v]) => (
<a class="chip" href={link({ role: k })} aria-current={k === role ? 'page' : undefined}>{v} <small>{ud(list.counts?.[k || 'all'] ?? 0)}</small></a>
))}
</nav>
<p class="muted center">{ud(list.total)} صارفین</p>
{list.users.length === 0 && <p class="muted center">کوئی صارف نہیں ملا</p>}
<ul class="user-list">
{list.users.map((u: any) => (
<li class:list={['user-row', { disabled: u.disabled_at }]}>
<a href={`/admin/user/${u.id}`}>
<span class="avatar" data-role={u.role} aria-hidden="true">{initial(u)}</span>
<span class="who">
<span class="name" dir={u.full_name ? undefined : 'ltr'}>{u.full_name || u.email}{u.id === me.id && <small class="muted"> (آپ)</small>}</span>
{u.full_name && <bdi class="email" dir="ltr">{u.email}</bdi>}
</span>
<span class="tags">
<span class="badge" data-role={u.role}>{ROLE[u.role] ?? u.role}</span>
{u.role.startsWith('mod-') && <span class:list={['badge', 'plain', { warn: !u.grants }]} title="اجازتیں">{u.grants ? `${ud(u.grants)} اجازتیں` : 'کوئی اجازت نہیں'}</span>}
{u.disabled_at && <span class="badge danger">معطل</span>}
</span>
<span class="seen muted" title="آخری لاگ ان">{ago(u.last_sign_in)}</span>
<Icon name="chevron" size={18} class="go" />
</a>
</li>
))}
</ul>
<table class="admin-table">
<thead><tr><th>ای میل</th><th>کردار</th><th>شمولیت</th><th>آخری لاگ ان</th><th>حالت</th><th>اقدامات</th></tr></thead>
<tbody>
{list.users.map((u: any) => (
<tr class={u.disabled_at ? 'disabled' : ''}>
<td><bdi dir="ltr">{u.email}</bdi>{u.id === me.id && <small> (آپ)</small>}</td>
<td>{ROLE[u.role] ?? u.role}{u.role.startsWith('mod-') && <> · <a href={`/admin/user/${u.id}`}>اجازتیں</a></>}</td>
<td>{date(u.created_at)}</td>
<td>{date(u.last_sign_in)}</td>
<td>{u.disabled_at ? 'معطل' : 'فعال'}</td>
<td class="actions">
<form method="post" onsubmit="return confirm('اس صارف کا پاس ورڈ ری سیٹ کریں؟')">
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
<button name="act" value="reset">پاس ورڈ ری سیٹ</button>
</form>
{u.id !== me.id && (
<>
<form method="post">
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
<button name="act" value={u.disabled_at ? 'enable' : 'disable'}>{u.disabled_at ? 'بحال کریں' : 'معطل کریں'}</button>
</form>
<form method="post" class="role-form">
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
<select name="role" aria-label="کردار">
{Object.entries(ROLE).map(([k, v]) => <option value={k} selected={k === u.role}>{v}</option>)}
</select>
<button name="act" value="role">کردار بدلیں</button>
</form>
<form method="post" onsubmit="return confirm('یہ اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔ جاری رکھیں؟')">
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
<button name="act" value="delete" class="danger">حذف</button>
</form>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
{pages > 1 && (
<nav class="pager">
{page > 1 && <a class="icon-btn" href={link({ page: page - 1 })} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
{page > 1 && <a class="icon-btn" href={link(page - 1)} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
<span class="muted">صفحہ {ud(page)} از {ud(pages)}</span>
{page < pages && <a class="icon-btn" href={link({ page: page + 1 })} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
{page < pages && <a class="icon-btn" href={link(page + 1)} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
</nav>
)}
</Base>

View File

@ -1,150 +1,78 @@
---
// Admin: one user. Their role, their permissions (grants, for moderators) and account actions, each in its own section.
// Admin: one moderator's permissions (grants)
import Base from '../../../layouts/Base.astro';
import Icon from '../../../components/Icon.astro';
import AdminNav from '../../../components/AdminNav.astro';
import { api } from '../../../lib/api';
import { asUser, COOKIE } from '../../../lib/auth';
import { ud } from '../../../lib/urdu';
import { ROLE, ROLE_HINT, SCOPE, CONTENT, ACTION, ago } from '../../../lib/roles';
import { ROLE, SCOPE, CONTENT, ACTION } from '../../../lib/roles';
const me = Astro.locals.user;
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
if (me.role !== 'admin') return new Response('صرف ایڈمن کے لیے', { status: 403 });
const token = Astro.cookies.get(COOKIE)!.value, id = Number(Astro.params.id) || 0;
const poets: any[] = (await api('/api/poets')) ?? [];
let error = '', done = '', tempPassword = '';
let error = '', done = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData(), act = String(f.get('act'));
let target = String(f.get('target') ?? '').trim();
// a poet may be picked by name; the API takes a page link
const byName = poets.find((p) => p.name === target || p.nickname === target);
if (byName) target = byName.url;
const r = act === 'reset' ? await asUser(token, `/api/admin/users/${id}/password`, {})
: act === 'disable' || act === 'enable' ? await asUser(token, `/api/admin/users/${id}/disable`, { disabled: act === 'disable' })
: act === 'role' ? await asUser(token, `/api/admin/users/${id}/role`, { role: f.get('role') })
: act === 'delete' ? await asUser(token, `/api/admin/users/${id}/delete`, {})
: act === 'revoke' ? await asUser(token, `/api/admin/grants/${Number(f.get('grant'))}/delete`, {})
: act === 'grant' ? await asUser(token, `/api/admin/users/${id}/grants`, { scope: f.get('scope'), target, content: f.getAll('content'), actions: f.getAll('actions') })
: { ok: false, data: { error: 'نامعلوم عمل' } };
if (!r.ok) error = r.data.error ?? 'کچھ غلط ہو گیا';
else if (act === 'delete') return Astro.redirect('/admin');
else if (act === 'reset') tempPassword = r.data.password;
else done = { disable: 'اکاؤنٹ معطل کر دیا گیا', enable: 'اکاؤنٹ بحال کر دیا گیا', role: 'کردار بدل دیا گیا', revoke: 'اجازت واپس لے لی گئی', grant: 'اجازت دے دی گئی' }[act] ?? '';
const f = await Astro.request.formData();
const r = f.get('act') === 'revoke'
? await asUser(token, `/api/admin/grants/${Number(f.get('grant'))}/delete`, {})
: await asUser(token, `/api/admin/users/${id}/grants`, {
scope: f.get('scope'), target: f.get('target'), content: f.getAll('content'), actions: f.getAll('actions'),
});
r.ok ? (done = f.get('act') === 'revoke' ? 'اجازت واپس لے لی گئی' : 'اجازت دے دی گئی') : (error = r.data.error ?? 'کچھ غلط ہو گیا');
}
const res = await asUser(token, `/api/admin/users/${id}/grants`);
if (res.status === 404) return new Response('صارف نہیں ملا', { status: 404 });
const { user, grants } = res.data;
const isMod = user.role.startsWith('mod-'), self = user.id === me.id;
const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
const isMod = user.role.startsWith('mod-');
---
<Base title={`ایڈمن: ${user.full_name || user.email}`}>
<Base title="ایڈمن: اجازتیں">
<h1>اجازتیں</h1>
<AdminNav current="users" />
<p class="back"><a href="/admin"><Icon name="prev" size={16} /> صارفین</a></p>
<header class="user-head">
<span class="avatar big" data-role={user.role} aria-hidden="true">{initial}</span>
<div>
<h1>{user.full_name || <bdi dir="ltr">{user.email}</bdi>}</h1>
{user.full_name && <bdi class="email" dir="ltr">{user.email}</bdi>}
<p class="tags">
<span class="badge" data-role={user.role}>{ROLE[user.role] ?? user.role}</span>
{user.disabled_at && <span class="badge danger">معطل</span>}
{self && <span class="badge plain">آپ</span>}
</p>
<p class="muted small-line">شمولیت {ago(user.created_at)} · آخری لاگ ان {ago(user.last_sign_in)}</p>
</div>
</header>
<p class="center"><bdi dir="ltr">{user.email}</bdi> · {ROLE[user.role] ?? user.role}</p>
{error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done}</p>}
{tempPassword && (
<div class="temp-password" role="status">
<p>عارضی پاس ورڈ (صرف ایک بار دکھایا جا رہا ہے):</p>
<p><code dir="ltr">{tempPassword}</code></p>
<p class="muted">یہ پاس ورڈ صارف کو دیں؛ وہ لاگ ان کر کے اپنے اکاؤنٹ کے صفحے سے اسے بدل لیں۔</p>
</div>
)}
{!isMod && <p class="muted center">اجازتیں صرف موڈریٹرز کو دی جا سکتی ہیں۔ پہلے <a href="/admin">صارفین</a> میں کردار بدلیں۔</p>}
<section class="panel">
<h2>کردار</h2>
{self ? <p class="muted">اپنا کردار خود نہیں بدل سکتے۔</p> : (
<form method="post" class="role-pick" onsubmit={`return confirm(${JSON.stringify(isMod ? 'کردار بدلیں؟ موڈریٹر نہ رہنے پر اس کی اجازتیں ختم ہو جائیں گی۔' : 'کردار بدلیں؟')})`}>
<input type="hidden" name="act" value="role" />
{Object.entries(ROLE).map(([k, v]) => (
<button class="btn" name="role" value={k} aria-pressed={k === user.role ? 'true' : 'false'} disabled={k === user.role}>
<span class="badge" data-role={k}>{v}</span><small>{ROLE_HINT[k]}</small>
</button>
{grants.length > 0 && (
<table class="admin-table">
<thead><tr><th>دائرہ</th><th>مواد</th><th>اعمال</th><th></th></tr></thead>
<tbody>
{grants.map((g: any) => (
<tr>
<td>{SCOPE[g.scope]}{g.label && <>: {g.url ? <a href={g.url}>{g.label}</a> : g.label}</>}{g.poet && g.scope !== 'poet' && <small class="muted"> ({g.poet})</small>}</td>
<td>{g.content.map((c: string) => CONTENT[c]).join('، ')}</td>
<td>{g.actions.map((a: string) => ACTION[a]).join('، ')}</td>
<td>
<form method="post" onsubmit="return confirm('یہ اجازت واپس لیں؟')">
<input type="hidden" name="grant" value={g.id} />
<button name="act" value="revoke" class="danger">واپس لیں</button>
</form>
</td>
</tr>
))}
</form>
)}
</section>
</tbody>
</table>
)}
{isMod && (
<section class="panel">
<h2>اجازتیں <small class="muted">{ud(grants.length)}</small></h2>
{grants.length === 0 && <p class="muted">ابھی کوئی اجازت نہیں: یہ موڈریٹر کچھ نہیں بدل سکتا۔ نیچے سے اجازت دیں۔</p>}
<ul class="grants">
{grants.map((g: any) => (
<li class="grant">
<div>
<p class="grant-scope"><strong>{SCOPE[g.scope]}</strong>{g.label && <>: {g.url ? <a href={g.url}>{g.label}</a> : g.label}</>}{g.poet && g.scope !== 'poet' && <small class="muted"> ({g.poet})</small>}</p>
<p class="tag-chips">{g.content.map((c: string) => <span class="badge plain">{CONTENT[c]}</span>)}<span class="sep" aria-hidden="true"></span>{g.actions.map((a: string) => <span class="badge" data-role="mod-l2">{ACTION[a]}</span>)}</p>
</div>
<form method="post" onsubmit="return confirm('یہ اجازت واپس لیں؟')">
<input type="hidden" name="grant" value={g.id} />
<button class="icon-btn small danger" name="act" value="revoke" title="اجازت واپس لیں" aria-label="اجازت واپس لیں"><Icon name="trash" size={16} /></button>
</form>
</li>
))}
</ul>
<details class="new-grant" open={grants.length === 0}>
<summary class="btn primary"><Icon name="plus" size={16} /> نئی اجازت</summary>
<form method="post" class="grant-form">
<input type="hidden" name="act" value="grant" />
<fieldset><legend>کہاں</legend>
<div class="chips">{Object.entries(SCOPE).map(([k, v], i) => <label class="chip"><input type="radio" name="scope" value={k} checked={i === 1} required /> {v}</label>)}</div>
</fieldset>
<label class="target">کون سا
<input name="target" list="poet-names" placeholder="شاعر کا نام، یا کتاب / کلام کا لنک" autocomplete="off" />
<small class="muted">شاعر نام سے چنیں؛ کتاب یا کلام کے لیے اس صفحے کا لنک دیں (جیسے <bdi dir="ltr">/p266/ghazal</bdi>)</small>
</label>
<datalist id="poet-names">{poets.map((p) => <option value={p.nickname || p.name} />)}</datalist>
<fieldset><legend>کیا</legend>
<div class="chips">{Object.entries(CONTENT).map(([k, v]) => <label class="chip"><input type="checkbox" name="content" value={k} /> {v}</label>)}</div>
<small class="muted">لغت صرف "تمام شعرا" کے ساتھ</small>
</fieldset>
<fieldset><legend>کون سے کام</legend>
<div class="chips">{Object.entries(ACTION).map(([k, v]) => <label class="chip"><input type="checkbox" name="actions" value={k} checked={k === 'edit'} /> {v}</label>)}</div>
</fieldset>
<p><button class="btn primary"><Icon name="check" size={16} /> اجازت دیں</button></p>
</form>
</details>
</section>
<form method="post" class="account-form grant-form">
<h2>نئی اجازت</h2>
<label>دائرہ
<select name="scope" required>
{Object.entries(SCOPE).map(([k, v]) => <option value={k}>{v}</option>)}
</select>
</label>
<label>شاعر، کتاب یا کلام کا لنک (دائرہ "تمام" کے لیے خالی)
<input name="target" dir="ltr" placeholder="/p266 /p266/ghazal /p266/ghazal/sh7870" />
</label>
<fieldset><legend>مواد</legend>
{Object.entries(CONTENT).map(([k, v]) => <label class="check"><input type="checkbox" name="content" value={k} /> {v}</label>)}
</fieldset>
<fieldset><legend>اعمال</legend>
{Object.entries(ACTION).map(([k, v]) => <label class="check"><input type="checkbox" name="actions" value={k} /> {v}</label>)}
</fieldset>
<p class="muted note">لغت کی اجازت صرف "تمام" دائرے میں دی جا سکتی ہے۔</p>
<button name="act" value="grant">اجازت دیں</button>
</form>
)}
{!isMod && !self && <p class="muted center small-line">اجازتیں موڈریٹرز کے لیے ہیں: اوپر سے کردار "موڈریٹر" کریں۔</p>}
<section class="panel">
<h2>اکاؤنٹ</h2>
<div class="account-actions">
<form method="post" onsubmit="return confirm('اس صارف کا پاس ورڈ ری سیٹ کریں؟ اس کے سب سیشن ختم ہو جائیں گے۔')">
<button class="btn" name="act" value="reset"><Icon name="key" size={16} /> پاس ورڈ ری سیٹ</button>
</form>
{!self && (
<form method="post">
{user.disabled_at
? <button class="btn ok" name="act" value="enable"><Icon name="undo" size={16} /> بحال کریں</button>
: <button class="btn" name="act" value="disable"><Icon name="ban" size={16} /> معطل کریں</button>}
</form>
)}
</div>
{!self && (
<form method="post" class="danger-zone" onsubmit="return confirm('یہ اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔ جاری رکھیں؟')">
<p class="muted small-line">اکاؤنٹ اور اس کا تمام ڈیٹا (محفوظ شدہ، ذخیرۂ الفاظ) مستقل طور پر حذف۔ واپسی ممکن نہیں۔</p>
<button class="btn danger" name="act" value="delete"><Icon name="trash" size={16} /> اکاؤنٹ حذف کریں</button>
</form>
)}
</section>
</Base>

View File

@ -18,12 +18,6 @@ const radii = ['sm', 'md', 'lg', 'pill'];
<p class="muted">رنگ، خط اور روشنی اوپر کی ترتیبات سے بدلیں</p>
<section class="ds">
<h2>لوگو</h2>
<div class="ds-row ds-logos">
<figure><img src="/logo/turanj.svg" width="160" height="160" alt="" /><figcaption>ترنج · لوگو (<code>/logo/turanj.svg</code>)</figcaption></figure>
<figure><span class="ds-icons"><img src="/logo/turanj-icon.svg" width="64" height="64" alt="" /><img src="/logo/turanj-icon.svg" width="32" height="32" alt="" /><img src="/logo/turanj-icon.svg" width="16" height="16" alt="" /></span><figcaption>نشان اور فیو آئیکن (<code>/favicon.svg</code>)</figcaption></figure>
</div>
<h2>رنگ</h2>
{colours.map(([group, names]) => (
<div class="ds-row">
@ -107,7 +101,5 @@ const radii = ['sm', 'md', 'lg', 'pill'];
.ds code, .ds-type code { font: var(--fs-xs) ui-monospace, monospace; color: var(--text-muted); direction: ltr; }
.ds-type { margin: 4px 0; display: flex; gap: 16px; align-items: baseline; }
.ds-type code { width: 6em; flex: none; }
.ds-logos figure { margin: 0; text-align: center; } .ds-logos figcaption { font-size: var(--fs-xs); color: var(--text-muted); }
.ds-icons { display: flex; align-items: end; gap: 10px; height: 160px; }
.ds-radius { width: 90px; height: 56px; display: grid; place-items: center; border: 1.5px solid var(--border); background: var(--surface-raised); }
</style>

View File

@ -12,15 +12,12 @@ const count = new Set(sections.flatMap((s) => s.poets.map((p) => p.id))).size;
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const marked: number[] = token ? (await asUser(token, '/api/library/marks')).data.poets ?? [] : [];
const card = (p: Poet) => `poet-card${marked.includes(p.id) ? ' mark-bm' : ''}`;
import Bayt from '../components/Bayt.astro';
import logo from '../../public/logo/turanj.svg?raw'; // inline, so its rays can turn on hover
---
<Base>
<section class="masthead">
<Fragment set:html={logo.replace('<svg ', '<svg class="logo masthead-logo" aria-hidden="true" ')} />
<h1><span class="name">دیوان</span><Bayt /><span class="tagline">اردو کا کلاسیکی ادب</span></h1>
<section class="hero">
<h1>اردو کی کلاسیکی شاعری اور نثر</h1>
<p>{ud(count)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
</section>
<p class="hero-count">{ud(count)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
{sections.length === 0 && <p class="muted">مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔</p>}

View File

@ -19,7 +19,6 @@ if (Astro.request.method === 'POST') {
}
---
<Base title="لاگ ان">
<img class="hero-logo" src="/logo/turanj.svg" width="96" height="96" alt="" />
<h1>لاگ ان</h1>
<form method="post" class="account-form">
{error && <p class="form-error" role="alert">{error}</p>}

View File

@ -20,7 +20,6 @@ if (Astro.request.method === 'POST') {
}
---
<Base title="نیا اکاؤنٹ">
<img class="hero-logo" src="/logo/turanj.svg" width="96" height="96" alt="" />
<h1>نیا اکاؤنٹ</h1>
<form method="post" class="account-form">
{error && <p class="form-error" role="alert">{error}</p>}

View File

@ -1,15 +1,5 @@
/* Divan styles: Ganjoor's look (palette, gold pills, centred couplets with ✤), RTL, Naskh default. */
[hidden] { display: none !important; } /* layout rules (e.g. .site-header .wrap flex) must not unhide */
/* Libron (OFL, github.com/nicoverbruggen/libron) for Latin text: emails, the dictionary's English, Western digits. Limited to
Latin, so it can lead every font stack and Urdu falls through to Naskh or Nastaliq */
@font-face { font-family: 'Libron'; src: url('/fonts/libron/Libron-Regular.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap;
unicode-range: U+0000-024F, U+1E00-1EFF, U+2000-206F, U+20AC, U+2122, U+2190-2193; }
@font-face { font-family: 'Libron'; src: url('/fonts/libron/Libron-Bold.woff2') format('woff2'); font-weight: 700; font-style: normal; font-display: swap;
unicode-range: U+0000-024F, U+1E00-1EFF, U+2000-206F, U+20AC, U+2122, U+2190-2193; }
@font-face { font-family: 'Libron'; src: url('/fonts/libron/Libron-Italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap;
unicode-range: U+0000-024F, U+1E00-1EFF, U+2000-206F, U+20AC, U+2122, U+2190-2193; }
@font-face { font-family: 'Libron'; src: url('/fonts/libron/Libron-BoldItalic.woff2') format('woff2'); font-weight: 700; font-style: italic; font-display: swap;
unicode-range: U+0000-024F, U+1E00-1EFF, U+2000-206F, U+20AC, U+2122, U+2190-2193; }
/* Design tokens. Palettes set colours only, each as light-dark(light, dark); roles below derive from them.
color-scheme picks the half: the system's by default, or the reader's choice (data-theme on <html>). */
:root {
@ -31,7 +21,7 @@
--r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-pill: 999px;
--fs-xs: .75rem; --fs-sm: .85rem; --fs-md: .95rem; --fs-lg: 1.1rem; --fs-xl: 1.25rem; --fs-2xl: 1.7rem;
--shadow-sm: 0 2px 8px rgb(0 0 0 / .08); --shadow-lg: 0 8px 24px rgb(0 0 0 / .18);
--font: 'Libron', 'Noto Naskh Arabic', 'Noto Nastaliq Urdu', serif; --font-ui: 'Libron', 'Noto Naskh Arabic', serif; --font-latin: 'Libron', Georgia, serif; --verse-lh: 2.1;
--font: 'Noto Naskh Arabic', 'Noto Nastaliq Urdu', serif; --font-ui: 'Noto Naskh Arabic', serif; --verse-lh: 2.1;
}
:root[data-theme='light'] { color-scheme: light; }
:root[data-theme='dark'] { color-scheme: dark; }
@ -60,7 +50,7 @@
--brand: light-dark(#b0304a, #ef6f86); --accent: light-dark(#c27c88, #d792a0); --accent-soft: light-dark(#f7e8ec, #2a1d21);
--action: light-dark(#6b2c5e, #e3a3c3); --action-ink: light-dark(#fff, #1d1216);
}
:root.nastaliq { --font: 'Libron', 'Noto Nastaliq Urdu', var(--font-ui); --verse-lh: 2.7; }
:root.nastaliq { --font: '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; }
@ -87,18 +77,10 @@ body { margin: 0; background: var(--surface-page); color: var(--text); font-fami
/* header: logo + search + toggles in a gold-bordered bar */
.site-header .wrap { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 12px; }
.wordmark { display: flex; flex: none; }
/* the logo: the shamsa with دیوان inside, sized with the viewport */
.wordmark .logo { width: clamp(52px, 13vw, 72px); height: auto; filter: drop-shadow(0 1px 3px rgb(0 0 0 / .15)); overflow: visible; }
/* on hover the two layers of rays turn opposite ways; the blue disc and the word stay still */
.logo .rays-long, .logo .rays-short { transform-box: fill-box; transform-origin: center; }
:is(.wordmark:hover, .wordmark:focus-visible, .masthead-logo:hover) .rays-long { animation: rays 9s linear infinite; }
:is(.wordmark:hover, .wordmark:focus-visible, .masthead-logo:hover) .rays-short { animation: rays 9s linear infinite reverse; }
@keyframes rays { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .logo g { animation: none !important; } }
.wordmark { font-family: 'Noto Nastaliq Urdu', serif; font-size: 2rem; line-height: 1.5; color: var(--brand); font-weight: 700; }
.search-form { flex: 1; min-width: 0; display: flex; position: relative; }
.search-form input { background: var(--surface-sunken); }
.search-form input { flex: 1; min-width: 0; padding: 4px 14px; padding-inline-end: 116px; } /* min-width 0: shrinks instead of pushing past the icons */
.search-form input { flex: 1; min-width: 0; padding: 4px 14px; padding-inline-end: 106px; } /* min-width 0: shrinks instead of pushing past the icons */
.toggles { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } /* icon buttons with tooltips */
.toggles form { display: flex; margin: 0; } /* the log-out button sits in a form: no line box of its own */
/* controls keep Naskh at normal height whatever reading font is chosen */
@ -144,23 +126,7 @@ h1::after { content: ''; display: block; width: 140px; height: 2px; background:
.poet-card.mark-bm::after { top: 10px; left: 8px; }
}
.hero { text-align: center; }
.hero-logo { display: block; width: clamp(84px, 22vw, 120px); height: auto; margin: 4px auto 0; filter: drop-shadow(0 2px 6px rgb(0 0 0 / .12)); }
.hero p { margin: 0; }
/* the home page masthead (as the share image): the logo beside دیوان and the headline, in a gold double frame */
.masthead { display: flex; align-items: center; justify-content: center; gap: clamp(20px, 7vw, 72px); margin: 4px 0 10px;
padding: clamp(18px, 4vw, 34px) clamp(16px, 4vw, 40px); background: var(--surface-raised);
border: 6px solid var(--accent); outline: 1.5px solid var(--accent); outline-offset: -14px; border-radius: 4px; }
.masthead-logo { flex: none; width: clamp(110px, 24vw, 210px); height: auto; filter: drop-shadow(0 2px 8px rgb(0 0 0 / .12)); }
.masthead h1 { display: flex; flex-direction: column; align-items: center; margin: 0; font-weight: 700; }
.masthead h1::after { display: none; }
.masthead .name { font-family: 'Noto Nastaliq Urdu', serif; color: var(--brand); font-size: clamp(2.8rem, 10vw, 5.2rem); line-height: 1.5;
margin-top: -.4em; padding-bottom: .3em; } /* Nastaliq: empty room above the ink, ink .27em below the box (measured) */
/* the couplet line under the name: half-lines in the palette's blue, the ✤ in vermilion */
.bayt { display: block; width: clamp(150px, 70%, 280px); height: auto; margin: 2px auto 16px; }
.bayt .misra { fill: var(--action); } .bayt .gul { fill: var(--brand); } .bayt .hole { fill: var(--surface-raised); }
.masthead .tagline { font-family: 'Noto Nastaliq Urdu', serif; color: var(--action); /* the palette's blue, as in the share image */ font-size: clamp(1.15rem, 3.6vw, 1.9rem); line-height: 2.1; }
.hero-count { text-align: center; color: var(--text-muted); font-size: var(--fs-sm); margin: 0 0 6px; }
@media (max-width: 520px) { .masthead { flex-direction: column; gap: 4px; } }
/* poet / category */
.intro { background: var(--surface-raised); border: 1.5px solid var(--border); border-radius: 12px; padding: 10px 18px; white-space: pre-line; text-align: justify; }
@ -208,21 +174,7 @@ mark { background: var(--accent-soft); color: inherit; border-radius: 3px; paddi
.made-in { margin: 10px 0 0; display: flex; align-items: center; justify-content: center; gap: 10px;
font-family: 'Noto Nastaliq Urdu', serif; font-size: 1.05rem; line-height: 2; color: var(--text); } /* a tagline */
.made-in .flag { flex: none; border-radius: 2px; box-shadow: 0 0 0 1px rgb(0 0 0 / .15); }
.site-footer { border-top: 1.5px solid var(--border); padding: 20px 0 16px; margin-top: 16px; font-size: var(--fs-sm); color: var(--text-muted); background: var(--surface-raised); }
/* footer: three labelled groups of links, each with an icon and a one-line note; external links marked */
.footer-logo { display: block; width: fit-content; margin: 0 auto 14px; }
.footer-logo img { display: block; width: 56px; height: 56px; }
.footer-links { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px 24px; max-width: 760px; margin: 0 auto; }
.footer-links h2 { text-align: start; font-size: var(--fs-xs); font-weight: 700; color: var(--text-muted); letter-spacing: .02em; margin: 0 0 4px; }
.footer-links h2::after { display: none; }
.footer-links ul { list-style: none; margin: 0; padding: 0; }
.footer-links a { display: flex; align-items: center; gap: 10px; padding: 4px 8px; margin-inline-start: -8px; border-radius: var(--r-sm); color: var(--text); }
.footer-links a:hover { background: var(--accent-soft); color: var(--active); }
.footer-links a > .icon:first-child { color: var(--accent); }
.footer-links a span { display: flex; flex-direction: column; line-height: 1.5; }
.footer-links small { color: var(--text-muted); font-size: var(--fs-xs); }
.footer-links .ext { align-self: flex-start; margin-top: 6px; margin-inline-start: -4px; opacity: .5; }
.made-in { border-top: 1px dashed var(--line); padding-top: 12px; margin-top: 16px !important; }
.site-footer { border-top: 1.5px solid var(--border); padding: 16px 0; font-size: .85rem; color: var(--text-muted); text-align: center; }
h1 + .muted { text-align: center; margin-top: 0; }
@ -231,16 +183,17 @@ h1 + .muted { text-align: center; margin-top: 0; }
/* icons inside a text box, from the far left: search (top box only), keyboard, Roman-to-Urdu */
.in-box.rom { font: 600 .95rem/1 var(--font-ui); }
.search-form .in-box.go { inset-inline-end: 8px; }
.search-form #kb-toggle { inset-inline-end: 42px; }
.search-form #rom-toggle { inset-inline-end: 76px; } /* 34px apart: each icon its own tap target */
.search-form #kb-toggle { inset-inline-end: 38px; }
.search-form #rom-toggle { inset-inline-end: 68px; }
.in-box-field { position: relative; }
.in-box-field input { padding-inline-end: 80px !important; }
.in-box-field .in-box.rom { inset-inline-end: 42px; }
.in-box-field input { padding-inline-end: 76px !important; }
.in-box-field .in-box.rom { inset-inline-end: 38px; }
.in-box:hover { opacity: 1; }
.in-box[aria-pressed='true'] { color: var(--active); opacity: 1; }
/* phones: wordmark and icons on one row, the search box on its own full-width row below */
@media (max-width: 640px) {
.site-header .wrap { gap: 8px; }
.wordmark { font-size: 1.7rem; padding-bottom: .45em; margin-bottom: -.2em; } /* Nastaliq's descenders hang below the line box: room so they clear the search row */
.toggles { margin-inline-start: auto; gap: 4px; }
.search-form { order: 3; flex-basis: 100%; }
}
@ -257,9 +210,9 @@ h1 + .muted { text-align: center; margin-top: 0; }
.dict h2::after { display: none; }
.dict h3 { font-size: 1rem; margin: 14px 0 4px; padding-bottom: 2px; border-bottom: 1px dashed var(--line); }
.dict ol { margin: 4px 0; padding-inline-start: 22px; }
.dict .en { font-family: var(--font-latin); font-size: .88rem; line-height: 1.5; }
.dict .en { font-family: system-ui, sans-serif; font-size: .88rem; line-height: 1.5; }
.dict ol.en { text-align: left; }
.dict .ipa { font-family: var(--font-latin), system-ui, sans-serif; font-size: .88rem; color: var(--text-muted); }
.dict .ipa { font-family: system-ui, sans-serif; font-size: .88rem; color: var(--text-muted); }
.dict .pron { margin: 2px 0; }
.dict .pos { font-size: .8rem; color: var(--text-muted); margin: 8px 0 0; }
.dict .note { font-size: .8rem; margin: 0; }
@ -309,6 +262,8 @@ h1 + .muted { text-align: center; margin-top: 0; }
/* admin panel */
.admin-nav { display: flex; justify-content: center; gap: 8px; margin: 0 0 16px; }
.admin-search { display: flex; gap: 6px; max-width: 420px; margin: 0 auto 8px; }
.admin-search input { flex: 1; }
.admin-table button { font-size: var(--fs-xs); padding: 0 10px; }
.admin-table button.danger { color: var(--danger); }
.admin-table { width: 100%; border-collapse: collapse; font-size: .88rem; margin: 8px 0; }
@ -320,6 +275,11 @@ h1 + .muted { text-align: center; margin-top: 0; }
.temp-password code { font-size: 1.2rem; letter-spacing: .08em; user-select: all; }
@media (max-width: 700px) { .admin-table thead { display: none; } .admin-table tr { display: block; border-bottom: 1px dashed var(--line); padding: 6px 0; } .admin-table td { display: inline-block; border: 0; padding: 2px 6px; } }
.role-form { display: flex; gap: 4px; align-items: center; }
.role-form select, .grant-form select { font-size: var(--fs-sm); padding: 2px 8px; }
.grant-form fieldset { border: 1px dashed var(--line); border-radius: 10px; display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 6px 12px; }
.grant-form legend { font-size: .85rem; padding: 0 6px; }
.grant-form label.check { flex-direction: row; align-items: center; gap: 6px; }
/* bookmarks: our own rounded bookmark icon (soft corners and notch, like common app icon packs), a grey outline
when not set and filled in the brand colour when set; drawn as a mask so it follows the theme. The word book is
@ -348,7 +308,7 @@ h1 + .muted { text-align: center; margin-top: 0; }
.lib-section { margin: 22px 0; }
.lib-section h2 { font-size: 1.1rem; text-align: center; color: var(--brand); }
.lib-list { list-style: none; padding: 0; max-width: 760px; margin: 0 auto; }
.lib-list li { position: relative; padding: 10px 14px 10px 54px; margin: 8px 0; border: 1px dashed var(--line); border-radius: 12px; }
.lib-list li { position: relative; padding: 10px 14px 10px 40px; margin: 8px 0; border: 1px dashed var(--line); border-radius: 12px; }
.lib-list .crumbs { text-align: start; margin: 0 0 4px; font-size: .8rem; }
.lib-title { font-weight: 700; }
.lib-couplet { display: block; color: var(--text); text-decoration: none; text-align: center; line-height: var(--verse-lh, 2.2); }
@ -415,8 +375,6 @@ 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; }
/* 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; }
/* moderators' guide */
@ -483,8 +441,8 @@ h1 + .muted { text-align: center; margin-top: 0; }
.site-head .site-title { flex: 1 1 14em; }
.site-head input { width: 100%; padding: 2px 10px; }
.site-poets { list-style: urdu; margin: 8px 0; padding-inline-start: 2.5em; columns: 2 16em; }
.site-poets li { break-inside: avoid; padding: 3px 0; }
.site-poets .tool { min-width: 1.6em; padding: 0 4px; margin-inline-start: 6px; font-size: .85rem; }
.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%; padding: 2px 10px; }
/* tags (#52) */
@ -570,77 +528,3 @@ h1 + .muted { text-align: center; margin-top: 0; }
.glyph-icon { position: relative; display: inline-flex; }
.glyph-icon > span { position: absolute; inset: 0 0 4px 2px; display: flex; align-items: center; justify-content: center;
font: 700 11px/1 var(--font-ui); }
/* user access (admin): a list to find people, one page per user. Role colours: admin brand, senior moderator action,
moderator accent, reader muted; the avatar and the role badge share them */
[data-role] { --c: var(--text-muted); }
[data-role='admin'] { --c: var(--brand); } [data-role='mod-l1'] { --c: var(--action); } [data-role='mod-l2'] { --c: var(--accent); }
.badge { display: inline-flex; align-items: center; padding: 0 10px; border-radius: var(--r-pill); font: 600 var(--fs-xs)/1.9 var(--font-ui); white-space: nowrap;
color: var(--c, var(--text-muted)); background: color-mix(in srgb, var(--c, var(--text-muted)) 12%, transparent); }
.badge.plain { --c: var(--text-muted); font-weight: 400; }
.badge.warn { --c: var(--accent); }
.badge.danger { --c: var(--danger); }
.avatar { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; font: 700 1.1rem/1 var(--font-ui);
color: var(--surface-raised); background: var(--c); }
.avatar.big { width: 64px; height: 64px; font-size: 1.6rem; }
.admin-search { position: relative; max-width: 520px; margin: 0 auto 10px; }
.admin-search input { width: 100%; padding-inline-start: 40px; border-radius: var(--r-pill); }
.field-icon { position: absolute; inset-inline-start: 14px; top: 50%; transform: translateY(-50%); display: flex; color: var(--text-muted); pointer-events: none; }
.role-filter { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 0 0 16px; }
.role-filter small { opacity: .7; font-weight: 400; }
.user-list { list-style: none; padding: 0; margin: 0 auto; max-width: 760px; border: 1.5px solid var(--border); border-radius: var(--r-lg);
background: var(--surface-raised); overflow: hidden; }
.user-row + .user-row { border-top: 1px solid var(--line); }
.user-row > a { display: flex; align-items: center; gap: 12px; padding: 10px 14px; color: var(--text); }
.user-row > a:hover { background: var(--accent-soft); color: var(--text); }
.user-row.disabled .who, .user-row.disabled .avatar { opacity: .5; }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.6; }
.who .name, .who .email { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who [dir='ltr'], .who .email { direction: ltr; text-align: right; } /* an email is cut at its end, not its start */
.who .email, .user-head .email { font: var(--fs-sm) var(--font-latin); color: var(--text-muted); }
.tags { display: flex; flex-wrap: wrap; gap: 4px; }
.seen { font-size: var(--fs-xs); white-space: nowrap; min-width: 5.5em; text-align: end; }
.user-row .go { color: var(--text-muted); }
@media (max-width: 560px) {
.user-row > a { flex-wrap: wrap; row-gap: 4px; }
.user-row .tags { order: 4; flex-basis: 100%; padding-inline-start: 52px; }
.user-row .seen { min-width: 0; }
}
/* one user */
.back { margin: 0 0 8px; font-size: var(--fs-sm); }
.back a { display: inline-flex; align-items: center; gap: 4px; }
.user-head { display: flex; align-items: center; gap: 16px; max-width: 760px; margin: 0 auto 16px; }
.user-head h1 { text-align: start; margin: 0; font-size: var(--fs-xl); }
.user-head h1::after { display: none; }
.user-head p { margin: 4px 0 0; }
.panel { max-width: 760px; margin: 0 auto 16px; padding: 12px 18px 16px; border: 1.5px solid var(--border); border-radius: var(--r-lg); background: var(--surface-raised); }
.panel h2 { text-align: start; font-size: var(--fs-lg); margin: 0 0 10px; }
.panel h2::after { display: none; }
.role-pick { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }
.role-pick button { flex-direction: column; align-items: flex-start; gap: 2px; padding: 8px 12px; border-radius: var(--r-md); text-align: start; font-weight: 400; }
.role-pick button small { color: var(--text-muted); font-size: var(--fs-xs); line-height: 1.6; }
.role-pick button[aria-pressed='true'] { opacity: 1; cursor: default; border-color: var(--c, var(--active)); }
.role-pick button[aria-pressed='true'] .badge { color: var(--surface-raised); background: var(--c); }
.grants { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-direction: column; gap: 8px; }
.grant { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--surface-page); }
.grant > div { flex: 1; min-width: 0; }
.grant-scope { margin: 0 0 4px; }
.grant .tag-chips { justify-content: flex-start; margin: 0; gap: 4px; }
.grant .sep { width: 1px; background: var(--line); margin: 2px 4px; }
.new-grant > summary { list-style: none; width: fit-content; }
.new-grant > summary::-webkit-details-marker { display: none; }
.new-grant[open] > summary { display: none; } /* the form replaces the button */
.grant-form { display: flex; flex-direction: column; gap: 12px; }
.grant-form fieldset { border: 0; padding: 0; margin: 0; }
.grant-form legend { font-weight: 700; padding: 0; margin-bottom: 4px; }
.grant-form .target { display: flex; flex-direction: column; gap: 4px; font-weight: 700; }
.grant-form .target small { font-weight: 400; }
.grant-form:has(input[name='scope'][value='all']:checked) .target { display: none; } /* "all poets" needs no target */
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
/* toggle chips: the chip shows the state (checked = --active), the box itself is visually hidden but focusable */
.chips .chip input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.chips .chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 2px; }
.chips .chip:has(input:checked)::before { content: '✓'; }
.account-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.danger-zone { margin-top: 14px; padding-top: 12px; border-top: 1px dashed color-mix(in srgb, var(--danger) 40%, transparent); }
.danger-zone p { margin: 0 0 6px; }