Merge pull request 'User access redesign, Libron, spacing, footer, headline, logo concepts' (#72) from anas/user-access-footer-logo into main
Reviewed-on: #72
@ -1,6 +1,7 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{ "name": "web-dev", "runtimeExecutable": "npx", "runtimeArgs": ["--prefix", "web", "astro", "dev", "--root", "web", "--port", "4201"], "port": 4201 }
|
||||
{ "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 }
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
<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.
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<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) })) };
|
||||
});
|
||||
|
||||
// one target user, never the acting admin themself for actions that could lock them out
|
||||
|
||||
@ -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) };
|
||||
});
|
||||
|
||||
1
docs/logo-concepts/A-full.svg
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
1
docs/logo-concepts/A-icon.svg
Normal file
@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
docs/logo-concepts/B-full.svg
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
1
docs/logo-concepts/B-icon.svg
Normal file
@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 861 B |
1
docs/logo-concepts/C-full.svg
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
1
docs/logo-concepts/C-icon.svg
Normal file
@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 390 B |
1
docs/logo-concepts/D-full.svg
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
1
docs/logo-concepts/D-icon.svg
Normal file
@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
docs/logo-concepts/preview.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
docs/logo-concepts/round2.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
docs/logo-concepts/round3.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
@ -4,7 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>دیوان · قاری</title>
|
||||
<meta name="description" content="دیوان کا سادہ قاری: اردو کی کلاسیکی شاعری اور نثر، براہِ راست divan-data سے۔">
|
||||
<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 سے۔">
|
||||
<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">
|
||||
|
||||
BIN
web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
web/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
@ -1 +1 @@
|
||||
<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>
|
||||
<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: 220 B After Width: | Height: | Size: 2.6 KiB |
6
web/public/fonts/libron/COPYRIGHT
Normal file
@ -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.
|
||||
95
web/public/fonts/libron/LICENSE
Normal file
@ -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.
|
||||
BIN
web/public/fonts/libron/Libron-Bold.woff2
Normal file
BIN
web/public/fonts/libron/Libron-BoldItalic.woff2
Normal file
BIN
web/public/fonts/libron/Libron-Italic.woff2
Normal file
BIN
web/public/fonts/libron/Libron-Regular.woff2
Normal file
BIN
web/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
web/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
web/public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
1
web/public/logo/turanj-icon.svg
Normal file
@ -0,0 +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>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
1
web/public/logo/turanj.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
web/public/og-image.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
16
web/public/site.webmanifest
Normal file
@ -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" }
|
||||
]
|
||||
}
|
||||
9
web/src/components/Bayt.astro
Normal file
@ -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`;
|
||||
---
|
||||
<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>
|
||||
@ -50,6 +50,14 @@ 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]} />
|
||||
|
||||
@ -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
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ur-PK" dir="rtl">
|
||||
@ -18,6 +19,23 @@ import Icon from '../components/Icon.astro';
|
||||
<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 {
|
||||
@ -36,7 +54,7 @@ import Icon from '../components/Icon.astro';
|
||||
<body data-user={Astro.locals.user ? '1' : undefined}>
|
||||
<header class="site-header">
|
||||
<div class="wrap">
|
||||
<a class="wordmark" href="/">دیوان</a>
|
||||
<a class="wordmark" href="/" title="سرورق"><Fragment set:html={logo.replace('<svg ', '<svg class="logo" ')} /></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>
|
||||
@ -103,8 +121,21 @@ import Icon from '../components/Icon.astro';
|
||||
</aside>
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<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>
|
||||
<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>
|
||||
<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" />
|
||||
|
||||
@ -1,5 +1,19 @@
|
||||
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));
|
||||
};
|
||||
|
||||
@ -1,101 +1,72 @@
|
||||
---
|
||||
// Admin: users. Password resets happen here on a reader's request (no email server yet).
|
||||
// Admin: users. A list to find people (search, role filter); everything about one user is on their page (/admin/user/<id>).
|
||||
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 } from '../../lib/roles';
|
||||
import { ROLE, ago } 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;
|
||||
|
||||
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 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;
|
||||
const pages = Math.ceil(list.total / list.pageSize);
|
||||
const date = (d: string | null) => (d ? ud(new Date(d).toISOString().slice(0, 10)) : '—');
|
||||
const link = (n: number) => `/admin?q=${encodeURIComponent(q)}&page=${n}`;
|
||||
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();
|
||||
---
|
||||
<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">
|
||||
<input type="search" name="q" value={q} placeholder="ای میل سے تلاش" dir="ltr" />
|
||||
<button type="submit">تلاش</button>
|
||||
{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="نام یا ای میل سے تلاش" />
|
||||
</form>
|
||||
<p class="muted center">{ud(list.total)} صارفین</p>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
{pages > 1 && (
|
||||
<nav class="pager">
|
||||
{page > 1 && <a class="icon-btn" href={link(page - 1)} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
|
||||
{page > 1 && <a class="icon-btn" href={link({ page: 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 + 1)} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
|
||||
{page < pages && <a class="icon-btn" href={link({ page: page + 1 })} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
|
||||
</nav>
|
||||
)}
|
||||
</Base>
|
||||
|
||||
@ -1,78 +1,150 @@
|
||||
---
|
||||
// Admin: one moderator's permissions (grants)
|
||||
// Admin: one user. Their role, their permissions (grants, for moderators) and account actions, each in its own section.
|
||||
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 { ROLE, SCOPE, CONTENT, ACTION } from '../../../lib/roles';
|
||||
import { ud } from '../../../lib/urdu';
|
||||
import { ROLE, ROLE_HINT, SCOPE, CONTENT, ACTION, ago } 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 = '';
|
||||
let error = '', done = '', tempPassword = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
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 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 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-');
|
||||
const isMod = user.role.startsWith('mod-'), self = user.id === me.id;
|
||||
const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
|
||||
---
|
||||
<Base title="ایڈمن: اجازتیں">
|
||||
<h1>اجازتیں</h1>
|
||||
<Base title={`ایڈمن: ${user.full_name || user.email}`}>
|
||||
<AdminNav current="users" />
|
||||
<p class="center"><bdi dir="ltr">{user.email}</bdi> · {ROLE[user.role] ?? user.role}</p>
|
||||
<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>
|
||||
|
||||
{error && <p class="form-error" role="alert">{error}</p>}
|
||||
{done && <p class="form-done" role="status">{done}</p>}
|
||||
{!isMod && <p class="muted center">اجازتیں صرف موڈریٹرز کو دی جا سکتی ہیں۔ پہلے <a href="/admin">صارفین</a> میں کردار بدلیں۔</p>}
|
||||
|
||||
{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tempPassword && (
|
||||
<div class="temp-password" role="status">
|
||||
<p>عارضی پاس ورڈ (صرف ایک بار دکھایا جا رہا ہے):</p>
|
||||
<p><code dir="ltr">{tempPassword}</code></p>
|
||||
<p class="muted">یہ پاس ورڈ صارف کو دیں؛ وہ لاگ ان کر کے اپنے اکاؤنٹ کے صفحے سے اسے بدل لیں۔</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
))}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isMod && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{!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>
|
||||
|
||||
@ -18,6 +18,12 @@ 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">
|
||||
@ -101,5 +107,7 @@ 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>
|
||||
|
||||
@ -12,12 +12,15 @@ 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="hero">
|
||||
<h1>اردو کی کلاسیکی شاعری اور نثر</h1>
|
||||
<p>{ud(count)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
|
||||
<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>
|
||||
<p class="hero-count">{ud(count)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
|
||||
|
||||
{sections.length === 0 && <p class="muted">مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔</p>}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ 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>}
|
||||
|
||||
@ -20,6 +20,7 @@ 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>}
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
/* 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 {
|
||||
@ -21,7 +31,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: 'Noto Naskh Arabic', 'Noto Nastaliq Urdu', serif; --font-ui: 'Noto Naskh Arabic', serif; --verse-lh: 2.1;
|
||||
--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;
|
||||
}
|
||||
:root[data-theme='light'] { color-scheme: light; }
|
||||
:root[data-theme='dark'] { color-scheme: dark; }
|
||||
@ -50,7 +60,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: 'Noto Nastaliq Urdu', var(--font-ui); --verse-lh: 2.7; }
|
||||
:root.nastaliq { --font: 'Libron', '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; }
|
||||
@ -77,10 +87,18 @@ 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 { font-family: 'Noto Nastaliq Urdu', serif; font-size: 2rem; line-height: 1.5; color: var(--brand); font-weight: 700; }
|
||||
.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; } }
|
||||
.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: 106px; } /* min-width 0: shrinks instead of pushing past the icons */
|
||||
.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 */
|
||||
.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 */
|
||||
@ -126,7 +144,23 @@ 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; }
|
||||
@ -174,7 +208,21 @@ 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: 16px 0; font-size: .85rem; color: var(--text-muted); text-align: center; }
|
||||
.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; }
|
||||
|
||||
h1 + .muted { text-align: center; margin-top: 0; }
|
||||
|
||||
@ -183,17 +231,16 @@ 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: 38px; }
|
||||
.search-form #rom-toggle { inset-inline-end: 68px; }
|
||||
.search-form #kb-toggle { inset-inline-end: 42px; }
|
||||
.search-form #rom-toggle { inset-inline-end: 76px; } /* 34px apart: each icon its own tap target */
|
||||
.in-box-field { position: relative; }
|
||||
.in-box-field input { padding-inline-end: 76px !important; }
|
||||
.in-box-field .in-box.rom { inset-inline-end: 38px; }
|
||||
.in-box-field input { padding-inline-end: 80px !important; }
|
||||
.in-box-field .in-box.rom { inset-inline-end: 42px; }
|
||||
.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%; }
|
||||
}
|
||||
@ -210,9 +257,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: system-ui, sans-serif; font-size: .88rem; line-height: 1.5; }
|
||||
.dict .en { font-family: var(--font-latin); font-size: .88rem; line-height: 1.5; }
|
||||
.dict ol.en { text-align: left; }
|
||||
.dict .ipa { font-family: system-ui, sans-serif; font-size: .88rem; color: var(--text-muted); }
|
||||
.dict .ipa { font-family: var(--font-latin), 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; }
|
||||
@ -262,8 +309,6 @@ 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; }
|
||||
@ -275,11 +320,6 @@ 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
|
||||
@ -308,7 +348,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 40px; margin: 8px 0; border: 1px dashed var(--line); border-radius: 12px; }
|
||||
.lib-list li { position: relative; padding: 10px 14px 10px 54px; 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); }
|
||||
@ -375,6 +415,8 @@ 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 */
|
||||
@ -441,8 +483,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; }
|
||||
.site-poets .tool { min-width: 1.6em; padding: 0 4px; margin-inline-start: 4px; font-size: .85rem; }
|
||||
.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-section .author-find { position: relative; max-width: 26em; }
|
||||
.site-section .author-find input { width: 100%; padding: 2px 10px; }
|
||||
/* tags (#52) */
|
||||
@ -528,3 +570,77 @@ 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; }
|
||||
|
||||