+
# دیوان · 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.
diff --git a/api/src/admin.test.ts b/api/src/admin.test.ts
index 2935a877..33797699 100644
--- a/api/src/admin.test.ts
+++ b/api/src/admin.test.ts
@@ -31,6 +31,11 @@ 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();
diff --git a/api/src/admin.ts b/api/src/admin.ts
index 7b56af4f..5acd92eb 100644
--- a/api/src/admin.ts
+++ b/api/src/admin.ts
@@ -1,7 +1,8 @@
// 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=&page= users (search by email), newest first
+// 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
// 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'}
@@ -33,18 +34,28 @@ 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; page?: string } }>('/api/admin/users', async (req, reply) => {
+ app.get<{ Querystring: { q?: string; role?: 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 [count, rows] = await Promise.all([
- pool.query('SELECT count(*)::int AS n FROM users WHERE email LIKE $1', [like]),
+ 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),
pool.query(
- `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]),
+ `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]),
]);
- return { total: count.rows[0].n, page, pageSize: PAGE, users: rows.rows.map((r) => ({ ...r, id: Number(r.id) })) };
+ const by: Record = { 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) })) };
});
// one target user, never the acting admin themself for actions that could lock them out
diff --git a/api/src/permissions.ts b/api/src/permissions.ts
index 36f1e4ff..ca16efaa 100644
--- a/api/src/permissions.ts
+++ b/api/src/permissions.ts
@@ -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, role, disabled_at FROM users WHERE id = $1', [Number(req.params.id) || 0])).rows[0];
+ 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];
if (!u) return reply.code(404).send({ error: 'صارف نہیں ملا' });
return { user: { ...u, id: Number(u.id) }, grants: await grantsOf(u.id) };
});
diff --git a/docs/logo-concepts/A-full.svg b/docs/logo-concepts/A-full.svg
new file mode 100644
index 00000000..8f66fcfd
--- /dev/null
+++ b/docs/logo-concepts/A-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-concepts/A-icon.svg b/docs/logo-concepts/A-icon.svg
new file mode 100644
index 00000000..e7d6d3d7
--- /dev/null
+++ b/docs/logo-concepts/A-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-concepts/B-full.svg b/docs/logo-concepts/B-full.svg
new file mode 100644
index 00000000..e89f057a
--- /dev/null
+++ b/docs/logo-concepts/B-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-concepts/B-icon.svg b/docs/logo-concepts/B-icon.svg
new file mode 100644
index 00000000..06c282c2
--- /dev/null
+++ b/docs/logo-concepts/B-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-concepts/C-full.svg b/docs/logo-concepts/C-full.svg
new file mode 100644
index 00000000..bd1440e5
--- /dev/null
+++ b/docs/logo-concepts/C-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-concepts/C-icon.svg b/docs/logo-concepts/C-icon.svg
new file mode 100644
index 00000000..45f591ff
--- /dev/null
+++ b/docs/logo-concepts/C-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-concepts/D-full.svg b/docs/logo-concepts/D-full.svg
new file mode 100644
index 00000000..ea527dee
--- /dev/null
+++ b/docs/logo-concepts/D-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-concepts/D-icon.svg b/docs/logo-concepts/D-icon.svg
new file mode 100644
index 00000000..daba6beb
--- /dev/null
+++ b/docs/logo-concepts/D-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-concepts/preview.png b/docs/logo-concepts/preview.png
new file mode 100644
index 00000000..cec78221
Binary files /dev/null and b/docs/logo-concepts/preview.png differ
diff --git a/docs/logo-concepts/round2.png b/docs/logo-concepts/round2.png
new file mode 100644
index 00000000..26598da7
Binary files /dev/null and b/docs/logo-concepts/round2.png differ
diff --git a/docs/logo-concepts/round3.png b/docs/logo-concepts/round3.png
new file mode 100644
index 00000000..35539318
Binary files /dev/null and b/docs/logo-concepts/round3.png differ
diff --git a/reader/index.html b/reader/index.html
index 163a5f81..bd60a994 100644
--- a/reader/index.html
+++ b/reader/index.html
@@ -4,7 +4,8 @@
دیوان · قاری
-
+
+
diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png
new file mode 100644
index 00000000..8f646af7
Binary files /dev/null and b/web/public/apple-touch-icon.png differ
diff --git a/web/public/favicon-32.png b/web/public/favicon-32.png
new file mode 100644
index 00000000..9d96e69e
Binary files /dev/null and b/web/public/favicon-32.png differ
diff --git a/web/public/favicon.ico b/web/public/favicon.ico
new file mode 100644
index 00000000..4cd4aafe
Binary files /dev/null and b/web/public/favicon.ico differ
diff --git a/web/public/favicon.svg b/web/public/favicon.svg
index 917760d1..6e89cb18 100644
--- a/web/public/favicon.svg
+++ b/web/public/favicon.svg
@@ -1 +1 @@
-
+
diff --git a/web/public/fonts/libron/COPYRIGHT b/web/public/fonts/libron/COPYRIGHT
new file mode 100644
index 00000000..67ab6c86
--- /dev/null
+++ b/web/public/fonts/libron/COPYRIGHT
@@ -0,0 +1,6 @@
+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.
diff --git a/web/public/fonts/libron/LICENSE b/web/public/fonts/libron/LICENSE
new file mode 100644
index 00000000..1a332be8
--- /dev/null
+++ b/web/public/fonts/libron/LICENSE
@@ -0,0 +1,95 @@
+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.
diff --git a/web/public/fonts/libron/Libron-Bold.woff2 b/web/public/fonts/libron/Libron-Bold.woff2
new file mode 100644
index 00000000..eebee575
Binary files /dev/null and b/web/public/fonts/libron/Libron-Bold.woff2 differ
diff --git a/web/public/fonts/libron/Libron-BoldItalic.woff2 b/web/public/fonts/libron/Libron-BoldItalic.woff2
new file mode 100644
index 00000000..487a8967
Binary files /dev/null and b/web/public/fonts/libron/Libron-BoldItalic.woff2 differ
diff --git a/web/public/fonts/libron/Libron-Italic.woff2 b/web/public/fonts/libron/Libron-Italic.woff2
new file mode 100644
index 00000000..1d698bff
Binary files /dev/null and b/web/public/fonts/libron/Libron-Italic.woff2 differ
diff --git a/web/public/fonts/libron/Libron-Regular.woff2 b/web/public/fonts/libron/Libron-Regular.woff2
new file mode 100644
index 00000000..2de64e6f
Binary files /dev/null and b/web/public/fonts/libron/Libron-Regular.woff2 differ
diff --git a/web/public/icon-192.png b/web/public/icon-192.png
new file mode 100644
index 00000000..539d8352
Binary files /dev/null and b/web/public/icon-192.png differ
diff --git a/web/public/icon-512.png b/web/public/icon-512.png
new file mode 100644
index 00000000..f59aea67
Binary files /dev/null and b/web/public/icon-512.png differ
diff --git a/web/public/icon-maskable-512.png b/web/public/icon-maskable-512.png
new file mode 100644
index 00000000..81327d9a
Binary files /dev/null and b/web/public/icon-maskable-512.png differ
diff --git a/web/public/logo/turanj-icon.svg b/web/public/logo/turanj-icon.svg
new file mode 100644
index 00000000..6e89cb18
--- /dev/null
+++ b/web/public/logo/turanj-icon.svg
@@ -0,0 +1 @@
+
diff --git a/web/public/logo/turanj.svg b/web/public/logo/turanj.svg
new file mode 100644
index 00000000..27f180a2
--- /dev/null
+++ b/web/public/logo/turanj.svg
@@ -0,0 +1 @@
+
diff --git a/web/public/og-image.png b/web/public/og-image.png
new file mode 100644
index 00000000..6e38a1ea
Binary files /dev/null and b/web/public/og-image.png differ
diff --git a/web/public/site.webmanifest b/web/public/site.webmanifest
new file mode 100644
index 00000000..4d19a57a
--- /dev/null
+++ b/web/public/site.webmanifest
@@ -0,0 +1,16 @@
+{
+ "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" }
+ ]
+}
diff --git a/web/src/components/Bayt.astro b/web/src/components/Bayt.astro
new file mode 100644
index 00000000..7d2fe26b
--- /dev/null
+++ b/web/src/components/Bayt.astro
@@ -0,0 +1,9 @@
+---
+// 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`;
+---
+
diff --git a/web/src/components/Icon.astro b/web/src/components/Icon.astro
index 29459cf9..0e779352 100644
--- a/web/src/components/Icon.astro
+++ b/web/src/components/Icon.astro
@@ -50,6 +50,14 @@ const PATHS = {
down: '',
top: '',
bottom: '',
+ // admin
+ ban: '',
+ search: '',
+ chevron: '',
+ // footer
+ info: '',
+ globe: '',
+ database: '',
};
---
diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro
index ec139c2d..19e81f0f 100644
--- a/web/src/layouts/Base.astro
+++ b/web/src/layouts/Base.astro
@@ -6,9 +6,10 @@ 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
---
@@ -18,6 +19,23 @@ import Icon from '../components/Icon.astro';
{fullTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+