Compare commits
68 Commits
feature/us
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 12567631a5 | |||
|
|
c8f5459ad7 | ||
|
|
07e85d7a54 | ||
|
|
cc797fd262 | ||
| e16b4b412e | |||
|
|
804cf0dc09 | ||
| be7140841a | |||
|
|
6874a4e4a2 | ||
| a165f63ece | |||
| e9eb3c6f88 | |||
|
|
24fcbccccd | ||
|
|
61321b5130 | ||
| 77c07c1552 | |||
| fdb42e78d8 | |||
| 0694a2ed4f | |||
| 586f49f836 | |||
| 4fc4e644d6 | |||
|
|
f037968ef2 | ||
|
|
de2e3eb751 | ||
|
|
de1d7eb0a0 | ||
|
|
b2461e228a | ||
|
|
86b26ba737 | ||
| 650b684ae8 | |||
|
|
5c8559c8e9 | ||
| 709b5d4594 | |||
|
|
46b3372433 | ||
| 7d398cc19d | |||
|
|
aa57dcd78c | ||
| faf8931f44 | |||
|
|
f20cc25080 | ||
| 6fddb3b2e8 | |||
|
|
bba1b93320 | ||
|
|
c2766922b5 | ||
|
|
b7facedf95 | ||
|
|
65762f286f | ||
|
|
7077524dcb | ||
|
|
93ccabdf29 | ||
|
|
575ef530a7 | ||
| 9f61da990d | |||
|
|
0aa3d5979e | ||
| 415459d55f | |||
| ce1adc4dcd | |||
| b6f6c708a1 | |||
|
|
13eaf9fe90 | ||
|
|
0511f7d2ee | ||
|
|
c20cbc17bf | ||
|
|
19aace12ff | ||
|
|
8ca681fc5b | ||
| dd5771a128 | |||
| 1de1da5410 | |||
|
|
6a62df9ee0 | ||
|
|
c1cf7c01a8 | ||
| bc51aa66ab | |||
|
|
d7da7458c0 | ||
| 30d66517bd | |||
|
|
ad7f3cb666 | ||
|
|
7c375d70b8 | ||
|
|
792b68979b | ||
|
|
9f401fe2af | ||
|
|
2efde6ee22 | ||
|
|
b0c7cbccf1 | ||
|
|
f122eb8f10 | ||
|
|
bab7ffa37f | ||
|
|
86be5db6ea | ||
|
|
8d0a6aa36b | ||
|
|
1485781fb7 | ||
| 2bdf6c9dde | |||
| ce9adbe9e1 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
# node_modules as a folder or a link (links were once committed by mistake)
|
||||||
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
.astro/
|
.astro/
|
||||||
.env
|
.env
|
||||||
|
|||||||
25
NOTICE
Normal file
25
NOTICE
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
Divan (دیوان)
|
||||||
|
Copyright (C) 2026 Muhammad Anas Rashid and Divan contributors
|
||||||
|
|
||||||
|
This program is free software under the GNU General Public License version 3 (see LICENSE), with the
|
||||||
|
additional terms below, which the GPL allows in its section 7. They apply to the code written for Divan by its
|
||||||
|
authors. (The earlier .NET code at the tag dotnet-final is a fork of GanjoorService and stays under that
|
||||||
|
project's licence; these terms do not apply to it.)
|
||||||
|
|
||||||
|
Additional terms under section 7 of the GNU GPL version 3
|
||||||
|
|
||||||
|
1. Attribution (section 7(b)). If you convey this work or a work based on it, keep this attribution in the
|
||||||
|
Appropriate Legal Notices it displays (for a website: its about page or footer):
|
||||||
|
"Based on Divan (https://git.anasrashid.net/anas/divan) by Muhammad Anas Rashid, under the GNU GPL v3."
|
||||||
|
|
||||||
|
2. Origin (section 7(c)). A modified version must be marked as different from the original, and must not
|
||||||
|
be presented as Divan, or as made, approved or endorsed by Divan or its authors.
|
||||||
|
|
||||||
|
3. Names and logos (section 7(e)). This licence grants no rights under trademark law to the names, logos
|
||||||
|
and other brand features of Divan, listed in TRADEMARKS.md: among them the name دیوان / Divan for this
|
||||||
|
project and site, the shamsa and ترنج logos and icons, the tagline "ایک پاکستانی کی طرف سے، پاکستان کے
|
||||||
|
لیے", and the masthead and share-card designs. A modified version made available to the public must use
|
||||||
|
its own name and logo (see "Making your own site" in TRADEMARKS.md).
|
||||||
|
|
||||||
|
The texts shown on the site are in the public domain; the Divan data is CC BY-SA 4.0 (see the divan-data
|
||||||
|
repository). These terms concern only the software and Divan's brand.
|
||||||
@ -43,6 +43,8 @@ The import upserts, so re-running it after a divan-data sync applies the changes
|
|||||||
|
|
||||||
**File store.** Uploaded files are kept in `DIVAN_FILES_DIR` (default `../divan-files` beside the repo) under their SHA-256, `ab/cd/<sha256>.<ext>`, so a file uploaded twice is stored once and the folder spreads evenly. On the server, point it at storage that can grow: an attached block-storage volume (e.g. Vultr Block Storage, resizable later) mounted at `/srv/divan-files`, or an S3-compatible bucket mounted with rclone. Back it up with the database. The database keeps the details, indexed by poet and by a trigram index on the title (and the text of text books), so lists and search stay fast.
|
**File store.** Uploaded files are kept in `DIVAN_FILES_DIR` (default `../divan-files` beside the repo) under their SHA-256, `ab/cd/<sha256>.<ext>`, so a file uploaded twice is stored once and the folder spreads evenly. On the server, point it at storage that can grow: an attached block-storage volume (e.g. Vultr Block Storage, resizable later) mounted at `/srv/divan-files`, or an S3-compatible bucket mounted with rclone. Back it up with the database. The database keeps the details, indexed by poet and by a trigram index on the title (and the text of text books), so lists and search stay fast.
|
||||||
|
|
||||||
|
**Readers' addresses and rate limits.** Sign-in and sign-up are limited per address. The site passes the reader's address to the API in `x-client-ip`, which the API believes only from the site: set the same random `DIVAN_SITE_KEY` for both (e.g. `openssl rand -hex 32`); without it, only requests from the same machine count as the site. Keep the API off the internet (only the site talks to it), and let Caddy be the only way in: it replaces any `X-Forwarded-For` a visitor sends with their real address, which is what the site reads.
|
||||||
|
|
||||||
## Daily content sync (server)
|
## Daily content sync (server)
|
||||||
|
|
||||||
`deploy/sync.sh` keeps a server current: it updates a divan-data checkout, fetches new and edited works from Wikisource (incremental, about a minute), rebuilds the export and upserts it into PostgreSQL. The site shows new content immediately. Runs are locked so they never overlap.
|
`deploy/sync.sh` keeps a server current: it updates a divan-data checkout, fetches new and edited works from Wikisource (incremental, about a minute), rebuilds the export and upserts it into PostgreSQL. The site shows new content immediately. Runs are locked so they never overlap.
|
||||||
@ -77,4 +79,4 @@ v2 is reaching parity with Ganjoor's features in phases: reading, accounts, comm
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GPL-3.0 (see `LICENSE`). Texts are public domain; the divan-data compilation is CC BY-SA 4.0 (Urdu Wikisource and Wikipedia contributors).
|
GPL-3.0 (see `LICENSE`), with additional terms under its section 7 (see `NOTICE`): keep the attribution "Based on Divan by Muhammad Anas Rashid", mark modified versions as different, and do not use Divan's name and logos for your own public site ([TRADEMARKS.md](TRADEMARKS.md); set your own in `web/src/lib/brand.ts`). Texts are public domain; the divan-data compilation is CC BY-SA 4.0 (Urdu Wikisource and Wikipedia contributors, and Divan).
|
||||||
|
|||||||
37
TRADEMARKS.md
Normal file
37
TRADEMARKS.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Divan's name and logos
|
||||||
|
|
||||||
|
The code is free software (GNU GPL v3, with the additional terms in [NOTICE](NOTICE)), and you are welcome to
|
||||||
|
study it, change it and run your own site with it. Divan's **name and logos are not part of that licence**:
|
||||||
|
they identify this project and its site, so that readers know what is Divan and what is not.
|
||||||
|
|
||||||
|
## What belongs to Divan
|
||||||
|
|
||||||
|
- The name **دیوان / Divan** used for this project, its site and its apps.
|
||||||
|
- The logos and icons: the shamsa (illuminated medallion) and the ترنج (lobed medallion), in every form:
|
||||||
|
`web/public/logo/`, `web/public/favicon.*`, `web/public/icon-*.png`, `web/public/apple-touch-icon.png`,
|
||||||
|
`web/public/og-image.png`, and the logo drawn in the header and home page.
|
||||||
|
- The tagline **ایک پاکستانی کی طرف سے، پاکستان کے لیے** with Pakistan's flag in the footer.
|
||||||
|
- The masthead (the logo beside **اردو کا کلاسیکی ادب** between the couplet lines) and the share-card design.
|
||||||
|
|
||||||
|
## What you may do
|
||||||
|
|
||||||
|
- Say that your work is **based on Divan**, link to it, and describe what Divan is.
|
||||||
|
- Run Divan unchanged for yourself or for testing, privately.
|
||||||
|
- Use the name when writing about the project (articles, reviews, talks).
|
||||||
|
|
||||||
|
## What you may not do
|
||||||
|
|
||||||
|
- Run a public site, app or service from a modified copy under the Divan name or with its logos.
|
||||||
|
- Use a name, domain, logo or design that could make people think your site is Divan or is endorsed by it.
|
||||||
|
- Remove the attribution required by [NOTICE](NOTICE).
|
||||||
|
|
||||||
|
## Making your own site
|
||||||
|
|
||||||
|
1. Set your name and taglines in `web/src/lib/brand.ts`.
|
||||||
|
2. Replace the logo files listed above with your own, and the logo in the header (`web/src/layouts/Base.astro`)
|
||||||
|
and home page (`web/src/pages/index.astro`).
|
||||||
|
3. Rewrite the about page (`web/src/pages/about.astro`) for your project, keeping the attribution
|
||||||
|
"Based on Divan by Muhammad Anas Rashid" from [NOTICE](NOTICE).
|
||||||
|
4. Update `web/public/site.webmanifest`.
|
||||||
|
|
||||||
|
Questions or permission requests: open an issue on the Divan repository.
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { test, after } from 'node:test';
|
import { test, after } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { hashPassword, verifyPassword, normaliseEmail, validEmail, passwordProblem, limiter, authRoutes } from './auth.ts';
|
import { hashPassword, verifyPassword, normaliseEmail, validEmail, passwordProblem, limiter, authRoutes, ip } from './auth.ts';
|
||||||
import { pool } from './db.ts';
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
after(() => pool.end());
|
after(() => pool.end());
|
||||||
@ -27,6 +27,22 @@ test('rate limiter: max per window, then resets', () => {
|
|||||||
assert.equal(allow('ip', 1001), true);
|
assert.equal(allow('ip', 1001), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client address: x-client-ip is trusted only from the site, so a made-up one cannot dodge the limits', () => {
|
||||||
|
const req = (from: string, headers: Record<string, string>) => ({ ip: from, headers }) as any;
|
||||||
|
const saved = process.env.DIVAN_SITE_KEY;
|
||||||
|
try {
|
||||||
|
delete process.env.DIVAN_SITE_KEY; // local development: only this machine may pass an address
|
||||||
|
assert.equal(ip(req('127.0.0.1', { 'x-client-ip': '5.5.5.5' })), '5.5.5.5');
|
||||||
|
assert.equal(ip(req('203.0.113.9', { 'x-client-ip': '5.5.5.5' })), '203.0.113.9', 'a stranger is limited by their own address');
|
||||||
|
process.env.DIVAN_SITE_KEY = 'site-secret-1234';
|
||||||
|
assert.equal(ip(req('10.0.0.3', { 'x-client-ip': '5.5.5.5', 'x-site-key': 'site-secret-1234' })), '5.5.5.5', 'the site, with the key');
|
||||||
|
assert.equal(ip(req('10.0.0.3', { 'x-client-ip': '5.5.5.5', 'x-site-key': 'wrong' })), '10.0.0.3');
|
||||||
|
assert.equal(ip(req('127.0.0.1', { 'x-client-ip': '5.5.5.5' })), '127.0.0.1', 'with a key set, even this machine needs it');
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) delete process.env.DIVAN_SITE_KEY; else process.env.DIVAN_SITE_KEY = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('HTTP flow: sign up, sign in, wrong password, change password, delete', async () => {
|
test('HTTP flow: sign up, sign in, wrong password, change password, delete', async () => {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
authRoutes(app);
|
authRoutes(app);
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
// Accounts: email address and password only (no email is sent; owner decision 2026-10-08).
|
// Accounts: email address and password only (no email is sent; owner decision 2026-10-08).
|
||||||
// Passwords: scrypt with a per-user salt. Sessions: a random token held by the site in an HTTP-only
|
// Passwords: scrypt with a per-user salt. Sessions: a random token held by the site in an HTTP-only
|
||||||
// cookie; the database stores only its SHA-256, so a database leak does not give working sessions.
|
// cookie; the database stores only its SHA-256, so a database leak does not give working sessions.
|
||||||
// The API is private (only the site calls it), so the site passes the reader's IP in x-client-ip.
|
// Rate limits go by the reader's address. The site passes it in x-client-ip, and only the site may: it proves itself
|
||||||
|
// with DIVAN_SITE_KEY in x-site-key (set the same key for the API and the site in production); with no key set
|
||||||
|
// (local development) only requests from this machine may. Anyone else is limited by their own address, so a
|
||||||
|
// made-up x-client-ip cannot get round the limits.
|
||||||
// POST /api/auth/signup {email, password} -> {token, user}
|
// POST /api/auth/signup {email, password} -> {token, user}
|
||||||
// POST /api/auth/signin {email, password} -> {token, user}
|
// POST /api/auth/signin {email, password} -> {token, user}
|
||||||
// GET /api/auth/me Bearer token -> {user}
|
// GET /api/auth/me Bearer token -> {user}
|
||||||
@ -85,7 +88,14 @@ export async function sessionUser(req: FastifyRequest) {
|
|||||||
return u ?? null;
|
return u ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = (req: FastifyRequest) => (req.headers['x-client-ip'] as string) || req.ip;
|
const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
|
||||||
|
export const fromSite = (req: FastifyRequest) => {
|
||||||
|
const key = process.env.DIVAN_SITE_KEY;
|
||||||
|
if (!key) return LOOPBACK.includes(req.ip);
|
||||||
|
const given = Buffer.from(String(req.headers['x-site-key'] ?? '')), want = Buffer.from(key);
|
||||||
|
return given.length === want.length && timingSafeEqual(given, want);
|
||||||
|
};
|
||||||
|
export const ip = (req: FastifyRequest) => (fromSite(req) && (req.headers['x-client-ip'] as string)) || req.ip;
|
||||||
|
|
||||||
export function authRoutes(app: FastifyInstance) {
|
export function authRoutes(app: FastifyInstance) {
|
||||||
app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signup', async (req, reply) => {
|
app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signup', async (req, reply) => {
|
||||||
|
|||||||
91
api/src/details.test.ts
Normal file
91
api/src/details.test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { test, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { authRoutes } from './auth.ts';
|
||||||
|
import { adminRoutes } from './admin.ts';
|
||||||
|
import { permissionRoutes } from './permissions.ts';
|
||||||
|
import { moderationRoutes } from './moderation.ts';
|
||||||
|
import { checkDetails, parseDetails } from './details.ts';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
after(() => pool.end());
|
||||||
|
|
||||||
|
test('details text: fields, the intro to the end, checks', () => {
|
||||||
|
const d = parseDetails('نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nپہلی سطر\n\nدوسری: سطر');
|
||||||
|
assert.equal(d['تعارف'], 'پہلی سطر\n\nدوسری: سطر', 'a colon in the intro is text');
|
||||||
|
assert.equal(checkDetails('poet', 'نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nx'), null, 'Urdu digits are years');
|
||||||
|
assert.match(checkDetails('poet', 'نام: \nتخلص: اقبال')!, /ضروری/);
|
||||||
|
assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: 1900\nوفات: 1800')!, /پہلے/);
|
||||||
|
assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: سن')!, /عیسوی/);
|
||||||
|
assert.match(checkDetails('book', 'عنوان:')!, /ضروری/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poet and book editors: permissions, publishing to divan-data and the site, history and revert', async () => {
|
||||||
|
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
|
||||||
|
process.env.DIVAN_DATA_DIR = data;
|
||||||
|
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
|
||||||
|
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
|
||||||
|
const app = Fastify();
|
||||||
|
authRoutes(app); adminRoutes(app); permissionRoutes(app); moderationRoutes(app);
|
||||||
|
const run = Date.now();
|
||||||
|
const call = (method: string, url: string, body?: object, token?: string) =>
|
||||||
|
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `det-${run}` } });
|
||||||
|
const person = async (name: string, role: string) => {
|
||||||
|
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
|
||||||
|
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
|
||||||
|
return { ...s, id: s.user.id };
|
||||||
|
};
|
||||||
|
const admin = await person('admin', 'admin'), l2 = await person('l2', 'mod-l2');
|
||||||
|
const poet = (await pool.query(`SELECT * FROM poets WHERE url = '/p266'`)).rows[0];
|
||||||
|
const root = (await pool.query(`SELECT id, title FROM categories WHERE poet_id = $1 AND parent_id IS NULL`, [poet.id])).rows[0];
|
||||||
|
const book = (await pool.query(`SELECT id, url, title FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
|
||||||
|
try {
|
||||||
|
assert.equal((await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).statusCode, 403, 'no grant yet');
|
||||||
|
await call('POST', `/api/admin/users/${l2.id}/grants`, { scope: 'poet', target: '/p266', content: ['poets', 'books'], actions: ['edit'] }, admin.token);
|
||||||
|
const can = (await call('GET', `/api/mod/can?category=${book.id}`, undefined, l2.token)).json();
|
||||||
|
assert.deepEqual([can.poet, can.book], [true, true]);
|
||||||
|
|
||||||
|
// the poet: L2 drafts a new intro and years; the admin publishes
|
||||||
|
const cur = (await call('GET', `/api/mod/details/poet/${poet.id}`, undefined, l2.token)).json();
|
||||||
|
assert.equal(cur.fields['تخلص'], poet.nickname);
|
||||||
|
const id = (await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).json().id;
|
||||||
|
const text = `نام: ${poet.name}\nتخلص: ${poet.nickname}\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف\nدوسرا پیراگراف`;
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'نام:\nتخلص: x' }, l2.token)).statusCode, 400);
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: text, summary: 'تعارف' }, l2.token)).statusCode, 200);
|
||||||
|
await pool.query(`UPDATE revisions SET status = 'approved' WHERE id = $1`, [id]); // the L1 step is tested in moderation.test.ts
|
||||||
|
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
|
||||||
|
const now = (await pool.query('SELECT description, birth_year_ce, death_year_ce, birth_year_ah FROM poets WHERE id = $1', [poet.id])).rows[0];
|
||||||
|
assert.deepEqual([now.description, now.birth_year_ce, now.death_year_ce, now.birth_year_ah], ['نیا تعارف\nدوسرا پیراگراف', 1797, 1869, 1212]);
|
||||||
|
assert.match(await readFile(join(data, 'divan', 'p266.poet'), 'utf8'), /^نام: .+\nتخلص: .+\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف/);
|
||||||
|
assert.match(git('log', '-1', '--format=%s'), new RegExp(`^شاعر: ${poet.nickname}: تعارف \\(ورژن 1\\)`));
|
||||||
|
|
||||||
|
// the book's title, by the admin directly
|
||||||
|
const b = (await call('POST', `/api/mod/details/book/${book.id}/draft`, undefined, admin.token)).json().id;
|
||||||
|
await call('POST', `/api/mod/revisions/${b}/save`, { content: 'عنوان: غزلیات (نیا)' }, admin.token);
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${b}/submit`, {}, admin.token)).json().version, 1);
|
||||||
|
assert.equal((await pool.query('SELECT title FROM categories WHERE id = $1', [book.id])).rows[0].title, 'غزلیات (نیا)');
|
||||||
|
assert.equal((await readFile(join(data, 'divan', 'p266', 'ghazal.book'), 'utf8')), 'عنوان: غزلیات (نیا)\n');
|
||||||
|
|
||||||
|
// history: compare with the text before Divan's version, and bring it back as a draft
|
||||||
|
const cmp = (await call('GET', `/api/mod/compare/poet/${poet.id}?a=0&b=1`, undefined, l2.token)).json();
|
||||||
|
assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text === 'نیا تعارف'));
|
||||||
|
const back = (await call('POST', `/api/mod/revert/book/${book.id}`, { version: 0 }, l2.token)).json().id;
|
||||||
|
assert.equal((await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json().revision.content, `عنوان: ${book.title}`);
|
||||||
|
const log = (await call('GET', `/api/mod/log?who=l2-${run}`, undefined, admin.token)).json().entries;
|
||||||
|
assert.ok(log.some((e: any) => e.entity === 'poet' && e.title === poet.nickname && e.url === '/p266'), 'the log names the poet');
|
||||||
|
} finally {
|
||||||
|
await pool.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6, birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`,
|
||||||
|
[poet.id, poet.name, poet.nickname, poet.description, poet.birth_year_ce, poet.death_year_ce, poet.birth_year_ah, poet.death_year_ah]);
|
||||||
|
await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [root.id, root.title]);
|
||||||
|
await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [book.id, book.title]);
|
||||||
|
await pool.query(`DELETE FROM revisions WHERE entity IN ('poet', 'book') AND author_email LIKE $1`, [`%-${run}@divan.test`]);
|
||||||
|
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
|
||||||
|
await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1', [`%-${run}@divan.test`]);
|
||||||
|
await rm(data, { recursive: true, force: true });
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
84
api/src/details.ts
Normal file
84
api/src/details.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// Poet and book details (#32): a poet's name, pen name, years and intro, and a book or section's title, edited in
|
||||||
|
// Divan. Each is a revision (entity 'poet', entity_id = poets.id; 'book', entity_id = categories.id) through the same
|
||||||
|
// pipeline as a work. The text is one "label: value" per line; a poet's intro follows "تعارف:" to the end:
|
||||||
|
// نام: محمد اقبال
|
||||||
|
// تخلص: اقبال
|
||||||
|
// پیدائش: 1877
|
||||||
|
// وفات: 1938
|
||||||
|
// تعارف:
|
||||||
|
// ڈاکٹر سر علامہ محمد اقبال …
|
||||||
|
// Published details live in divan-data as divan/<url>.poet and divan/<url>.book, which the export
|
||||||
|
// (export_divan.py) prefers to Wikisource's and Wikipedia's, so the daily sync keeps them.
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
export type DetailKind = 'poet' | 'book';
|
||||||
|
export const isDetail = (entity: string): entity is DetailKind => entity === 'poet' || entity === 'book';
|
||||||
|
const POET = ['نام', 'تخلص', 'پیدائش', 'وفات'];
|
||||||
|
const hijri = (ce: number | null) => (ce ? Math.round(((ce - 622) * 33) / 32) : null); // as export_divan.py
|
||||||
|
|
||||||
|
export function parseDetails(text: string) {
|
||||||
|
const lines = text.replace(/\r\n?/g, '\n').split('\n'), out: Record<string, string> = {};
|
||||||
|
const intro = lines.findIndex((l) => /^تعارف\s*:/.test(l));
|
||||||
|
for (const l of intro < 0 ? lines : lines.slice(0, intro)) {
|
||||||
|
const m = l.match(/^([^:]+):\s*(.*)$/);
|
||||||
|
if (m) out[m[1].trim()] = m[2].trim();
|
||||||
|
}
|
||||||
|
if (intro >= 0) out['تعارف'] = [lines[intro].replace(/^تعارف\s*:\s*/, ''), ...lines.slice(intro + 1)].join('\n').trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const detailsText = (kind: DetailKind, d: Record<string, string>) => kind === 'book' ? `عنوان: ${d['عنوان'] ?? ''}`
|
||||||
|
: `${POET.map((l) => `${l}: ${d[l] ?? ''}`).join('\n')}\nتعارف:\n${d['تعارف'] ?? ''}`;
|
||||||
|
|
||||||
|
// what the details are now (the site's values, which follow the latest published version) and that version
|
||||||
|
export async function currentDetails(kind: DetailKind, id: number) {
|
||||||
|
const row = kind === 'poet'
|
||||||
|
? (await pool.query('SELECT id, url, nickname AS title, name, nickname, description, birth_year_ce, death_year_ce FROM poets WHERE id = $1', [id])).rows[0]
|
||||||
|
: (await pool.query('SELECT id, url, title FROM categories WHERE id = $1', [id])).rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
const last = (await pool.query(
|
||||||
|
`SELECT max(version) AS v FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published'`, [kind, id])).rows[0];
|
||||||
|
const content = detailsText(kind, kind === 'book' ? { عنوان: row.title } : {
|
||||||
|
نام: row.name, تخلص: row.nickname, پیدائش: row.birth_year_ce ?? '', وفات: row.death_year_ce ?? '', تعارف: row.description ?? '' });
|
||||||
|
return { details: { kind, id: row.id, url: row.url, title: row.title }, version: Number(last?.v ?? 0), content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = (s: string | undefined) => (s ? Number(s.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x6f0))) : null);
|
||||||
|
|
||||||
|
// what is wrong with the text, or null
|
||||||
|
export function checkDetails(kind: DetailKind, text: string) {
|
||||||
|
const d = parseDetails(text);
|
||||||
|
if (kind === 'book') {
|
||||||
|
if (!d['عنوان']) return 'عنوان ضروری ہے';
|
||||||
|
return d['عنوان'].length > 200 ? 'عنوان بہت لمبا ہے' : null;
|
||||||
|
}
|
||||||
|
if (!d['نام'] || !d['تخلص']) return 'نام اور تخلص ضروری ہیں';
|
||||||
|
if (d['نام'].length > 100 || d['تخلص'].length > 100) return 'نام بہت لمبا ہے';
|
||||||
|
const [born, died] = [year(d['پیدائش']), year(d['وفات'])];
|
||||||
|
for (const y of [born, died]) if (y !== null && !(Number.isInteger(y) && y >= 500 && y <= 2100)) return 'سال عیسوی میں لکھیں، جیسے ۱۸۷۷';
|
||||||
|
if (born && died && died < born) return 'وفات کا سال پیدائش سے پہلے ہے';
|
||||||
|
return (d['تعارف'] ?? '').length > 5000 ? 'تعارف بہت لمبا ہے' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyDetails(client: PoolClient, kind: DetailKind, id: number, text: string) {
|
||||||
|
const d = parseDetails(text);
|
||||||
|
if (kind === 'book') return void (await client.query('UPDATE categories SET title = $2 WHERE id = $1', [id, d['عنوان']]));
|
||||||
|
const [born, died] = [year(d['پیدائش']), year(d['وفات'])];
|
||||||
|
await client.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6,
|
||||||
|
birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`,
|
||||||
|
[id, d['نام'], d['تخلص'], d['تعارف'] || null, born, died, hijri(born), hijri(died)]);
|
||||||
|
// the poet's top level carries their name (as the export makes it)
|
||||||
|
await client.query('UPDATE categories SET title = $2 WHERE poet_id = $1 AND parent_id IS NULL', [id, d['نام']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeDetails(dataDir: string, kind: DetailKind, url: string, text: string) {
|
||||||
|
const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.' + kind);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
const d = parseDetails(text);
|
||||||
|
for (const k of ['پیدائش', 'وفات']) if (d[k]) d[k] = String(year(d[k])); // plain digits for the export
|
||||||
|
await writeFile(path, detailsText(kind, d).trim() + '\n');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
33
api/src/feeds.test.ts
Normal file
33
api/src/feeds.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { test, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { feedRoutes } from './feeds.ts';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
after(() => pool.end());
|
||||||
|
|
||||||
|
test('sitemap and feed: every public page; a published version appears with its date, summary, author and lines', async () => {
|
||||||
|
const app = Fastify(); feedRoutes(app);
|
||||||
|
const work = (await pool.query(`SELECT id, url FROM poems WHERE url LIKE '/p266/ghazal/%' ORDER BY id LIMIT 1`)).rows[0];
|
||||||
|
const when = new Date(Date.now() + 60_000).toISOString(); // newer than anything else, so it comes first
|
||||||
|
const rev = (await pool.query(
|
||||||
|
`INSERT INTO revisions (entity, entity_id, version, base_version, content, status, author_email, summary, published_at, credits)
|
||||||
|
VALUES ('work', $1, 99, 98, '', 'published', 'feed-test@divan.test', 'آزمائشی ترمیم', $2, $3) RETURNING id`,
|
||||||
|
[work.id, when, { by: 'آزمائشی موڈریٹر', publishedBy: 'ایڈمن' }])).rows[0].id;
|
||||||
|
try {
|
||||||
|
const s = (await app.inject('/api/sitemap')).json();
|
||||||
|
assert.ok(s.poets.length > 300 && s.categories.length > 500 && s.works.length > 10000, 'all the public pages');
|
||||||
|
assert.ok(s.poets.includes('/p266'));
|
||||||
|
assert.equal(new Date(s.works.find((w: any) => w.url === work.url).updated).toISOString(), when, "a work's date is its latest version");
|
||||||
|
|
||||||
|
const feed = (await app.inject('/api/feed?limit=5')).json();
|
||||||
|
assert.ok(feed.length >= 1 && feed.length <= 5);
|
||||||
|
const first = feed[0];
|
||||||
|
assert.deepEqual([first.kind, first.url, first.version, first.summary, first.by], ['work', work.url, 99, 'آزمائشی ترمیم', 'آزمائشی موڈریٹر']);
|
||||||
|
assert.ok(first.lines.length >= 1, 'the opening lines');
|
||||||
|
assert.equal((await app.inject('/api/feed?limit=999')).json().length <= 100, true, 'at most 100');
|
||||||
|
} finally {
|
||||||
|
await pool.query('DELETE FROM revisions WHERE id = $1', [rev]);
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
60
api/src/feeds.ts
Normal file
60
api/src/feeds.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// What the site's sitemap.xml and rss.xml are made from (#83, #87).
|
||||||
|
// GET /api/sitemap every public page: poets, books/chapters, works (with the date of their latest Divan
|
||||||
|
// version), tags, e-books
|
||||||
|
// GET /api/feed?limit= the latest published changes, newest first: new versions of works (version, summary, the
|
||||||
|
// opening lines), e-books, arrangements and tags; who made each (public names)
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
export function feedRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/api/sitemap', async (_req, reply) => {
|
||||||
|
const [poets, cats, works, tags, ebooks] = await Promise.all([
|
||||||
|
pool.query('SELECT url FROM poets ORDER BY id'),
|
||||||
|
pool.query('SELECT url FROM categories WHERE parent_id IS NOT NULL ORDER BY id'),
|
||||||
|
pool.query(`SELECT p.url, max(r.published_at) AS updated FROM poems p
|
||||||
|
LEFT JOIN revisions r ON r.entity = 'work' AND r.entity_id = p.id AND r.status = 'published'
|
||||||
|
GROUP BY p.id ORDER BY p.id`),
|
||||||
|
pool.query('SELECT DISTINCT t.type, t.name FROM tags t JOIN entity_tags e ON e.tag_id = t.id ORDER BY t.type, t.name'),
|
||||||
|
pool.query('SELECT b.id, t.url AS poet_url FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.published ORDER BY b.id'),
|
||||||
|
]);
|
||||||
|
reply.header('cache-control', 'public, max-age=3600');
|
||||||
|
return {
|
||||||
|
poets: poets.rows.map((r) => r.url),
|
||||||
|
categories: cats.rows.map((r) => r.url),
|
||||||
|
works: works.rows.map((r) => ({ url: r.url, updated: r.updated })),
|
||||||
|
tags: tags.rows,
|
||||||
|
ebooks: ebooks.rows,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { limit?: string } }>('/api/feed', async (req, reply) => {
|
||||||
|
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 100);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT r.id, r.entity, r.entity_id, r.version, r.summary, r.published_at, r.credits,
|
||||||
|
p.title AS work_title, p.url AS work_url, pp.nickname AS work_poet,
|
||||||
|
c.title AS cat_title, c.url AS cat_url, cp.nickname AS cat_poet,
|
||||||
|
b.title AS ebook_title, bp.nickname AS ebook_poet
|
||||||
|
FROM revisions r
|
||||||
|
LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id LEFT JOIN poets pp ON pp.id = p.poet_id
|
||||||
|
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id LEFT JOIN poets cp ON cp.id = c.poet_id
|
||||||
|
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id LEFT JOIN poets bp ON bp.id = b.poet_id
|
||||||
|
WHERE r.status = 'published' AND r.published_at IS NOT NULL
|
||||||
|
ORDER BY r.published_at DESC, r.id DESC LIMIT $1`, [limit]);
|
||||||
|
// the opening lines of each new version of a work, for the feed's description
|
||||||
|
const workIds = rows.filter((r) => r.entity === 'work').map((r) => r.entity_id);
|
||||||
|
const lines = workIds.length ? (await pool.query(
|
||||||
|
`SELECT poem_id, array_agg(text ORDER BY vorder) AS lines FROM verses WHERE poem_id = ANY($1) AND vorder <= 2 GROUP BY poem_id`,
|
||||||
|
[workIds])).rows : [];
|
||||||
|
reply.header('cache-control', 'public, max-age=300');
|
||||||
|
return rows.map((r) => {
|
||||||
|
const kind = r.entity === 'work' ? 'work' : r.entity === 'ebook' ? 'ebook' : r.entity === 'order' ? 'order' : 'tags';
|
||||||
|
const title = r.work_title ?? r.cat_title ?? r.ebook_title ?? '';
|
||||||
|
const url = r.work_url ?? r.cat_url ?? (r.entity === 'ebook' ? `/ebook/${r.entity_id}` : '/');
|
||||||
|
return {
|
||||||
|
id: Number(r.id), kind, version: r.version, title, url, poet: r.work_poet ?? r.cat_poet ?? r.ebook_poet ?? null,
|
||||||
|
summary: r.summary ?? null, published: r.published_at, by: r.credits?.by ?? null,
|
||||||
|
lines: lines.find((l) => l.poem_id === r.entity_id)?.lines ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -55,10 +55,39 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
|
|||||||
const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id;
|
const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id;
|
||||||
assert.ok(marks.categories.includes(book) && marks.poets.includes(266));
|
assert.ok(marks.categories.includes(book) && marks.poets.includes(266));
|
||||||
// state for a page
|
// state for a page
|
||||||
assert.deepEqual((await call('GET', `/api/library/state?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }] });
|
assert.deepEqual((await call('GET', `/api/library/state?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }], notes: [] });
|
||||||
|
|
||||||
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true });
|
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true });
|
||||||
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false });
|
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false });
|
||||||
|
// notes on a couplet and on a phrase are separate from bookmarks: a note never bookmarks; un-bookmarking keeps the note
|
||||||
|
const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk);
|
||||||
|
const st = () => call('GET', `/api/library/state?poem=${poem}`, undefined, t).then((r) => r.json());
|
||||||
|
assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { note: 'پہلا نوٹ' }, 'on a bookmarked couplet');
|
||||||
|
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { note: 'نیا' });
|
||||||
|
assert.ok(!(await st()).couplets.includes(1), 'a note does not bookmark');
|
||||||
|
const ph = lines0[0].split(' ').slice(1, 3).join(' ');
|
||||||
|
assert.deepEqual((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json(), { note: 'عبارت پر' });
|
||||||
|
assert.ok(!(await st()).phrases.some((p: any) => p.phrase === ph), 'nor a phrase');
|
||||||
|
assert.equal((await note({ poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں', note: 'x' })).statusCode, 400);
|
||||||
|
assert.equal((await note({ poemId: poem, couplet: 9999, note: 'x' })).statusCode, 404);
|
||||||
|
assert.equal((await note({ poemId: poem, couplet: 1, note: 'x' }, other.token)).statusCode, 200, "another reader's note is their own");
|
||||||
|
assert.deepEqual((await st()).notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort());
|
||||||
|
// bookmarking a noted couplet keeps the note; un-bookmarking keeps it too
|
||||||
|
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, true);
|
||||||
|
assert.ok((await st()).couplets.includes(1));
|
||||||
|
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false);
|
||||||
|
assert.ok(!(await st()).couplets.includes(1) && (await st()).notes.some((n: any) => n.couplet === 1), 'the note stays');
|
||||||
|
// clearing a note that has no bookmark removes it; on a bookmark it keeps the bookmark
|
||||||
|
await note({ poemId: poem, couplet: 1, note: '' });
|
||||||
|
assert.ok(!(await st()).notes.some((n: any) => n.couplet === 1));
|
||||||
|
await note({ poemId: poem, couplet: 0, phrase: ph, note: '' });
|
||||||
|
await note({ poemId: poem, couplet: 2, note: '' });
|
||||||
|
assert.ok((await st()).couplets.includes(2) && !(await st()).notes.length, 'bookmark kept, notes gone');
|
||||||
|
// the notes list (the notes page) holds notes only; the saved lists hold bookmarks only
|
||||||
|
await note({ poemId: poem, couplet: 3, note: 'صرف نوٹ' }).catch(() => {});
|
||||||
|
const listed = (await call('GET', '/api/library', undefined, t)).json();
|
||||||
|
assert.ok(listed.notes.every((n: any) => n.note) && !listed.couplets.some((c: any) => c.poem?.couplet === 3));
|
||||||
|
await note({ poemId: poem, couplet: 3, note: '' });
|
||||||
// toggling again removes
|
// toggling again removes
|
||||||
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false);
|
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false);
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,11 @@
|
|||||||
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items
|
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items
|
||||||
// POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
|
// POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
|
||||||
// POST /api/library/:id/note {note}
|
// POST /api/library/:id/note {note}
|
||||||
// POST /api/library/:id/delete
|
// POST /api/library/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it; never bookmarks it (a
|
||||||
|
// note and a bookmark are separate); an empty note clears it -> {note}
|
||||||
|
// notes and bookmarks share a row: bookmarked false is a note only; un-bookmarking keeps the note, clearing the note of a
|
||||||
|
// row that is not bookmarked removes the row
|
||||||
|
// POST /api/library/:id/delete (a couplet or phrase with a note keeps the note)
|
||||||
// kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
|
// kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
|
||||||
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book)
|
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book)
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
@ -22,6 +26,25 @@ export const cleanPhrase = (p: unknown) => {
|
|||||||
return s.length >= 2 && s.length <= 300 ? s : null;
|
return s.length >= 2 && s.length <= 300 ? s : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// a phrase must be in its couplet or paragraph (its lines, or across its two misras); null when it is
|
||||||
|
async function phraseIn(poemId: number, couplet: number, phrase: string) {
|
||||||
|
const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t;
|
||||||
|
if (!text) return { status: 404, error: 'نہیں ملا' };
|
||||||
|
if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return { status: 400, error: 'یہ عبارت اس شعر میں نہیں' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// an existing couplet or phrase row: a bookmark goes (the row stays if it holds a note), a note only becomes a bookmark;
|
||||||
|
// null when there is no row
|
||||||
|
async function flipBookmark(match: string, args: unknown[]) {
|
||||||
|
const row = (await pool.query(`SELECT id, bookmarked, note FROM library WHERE user_id = $1 AND ${match}`, args)).rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
if (!row.bookmarked) { await pool.query('UPDATE library SET bookmarked = true WHERE id = $1', [row.id]); return { saved: true, id: Number(row.id) }; }
|
||||||
|
if (row.note) await pool.query('UPDATE library SET bookmarked = false WHERE id = $1', [row.id]);
|
||||||
|
else await pool.query('DELETE FROM library WHERE id = $1', [row.id]);
|
||||||
|
return { saved: false };
|
||||||
|
}
|
||||||
|
|
||||||
async function reader(req: FastifyRequest, reply: FastifyReply) {
|
async function reader(req: FastifyRequest, reply: FastifyReply) {
|
||||||
const u = await sessionUser(req);
|
const u = await sessionUser(req);
|
||||||
if (!u) reply.code(401).send({ error: 'لاگ ان کریں' });
|
if (!u) reply.code(401).send({ error: 'لاگ ان کریں' });
|
||||||
@ -66,7 +89,7 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
app.get('/api/library', async (req, reply) => {
|
app.get('/api/library', async (req, reply) => {
|
||||||
const u = await reader(req, reply); if (!u) return;
|
const u = await reader(req, reply); if (!u) return;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page,
|
`SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page, l.bookmarked,
|
||||||
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet,
|
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet,
|
||||||
eb.title AS ebook_title, ep.nickname AS ebook_poet, ep.url AS ebook_poet_url
|
eb.title AS ebook_title, ep.nickname AS ebook_poet, ep.url AS ebook_poet_url
|
||||||
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
|
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
|
||||||
@ -87,8 +110,11 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })),
|
poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })),
|
||||||
categories: rows.filter((r) => r.kind === 'category').map((r) => ({ ...item(r), path: chapters.get(r.category_id) ?? [] })),
|
categories: rows.filter((r) => r.kind === 'category').map((r) => ({ ...item(r), path: chapters.get(r.category_id) ?? [] })),
|
||||||
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
|
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
|
||||||
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||||||
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||||||
|
// every note on a couplet or phrase, bookmarked or not (the notes page)
|
||||||
|
notes: rows.filter((r) => (r.kind === 'couplet' || r.kind === 'phrase') && r.note).map((r) => ({
|
||||||
|
...item(r), phrase: r.phrase, bookmarked: r.bookmarked, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||||||
pages: rows.filter((r) => r.kind === 'page' && r.ebook_title).map((r) => ({
|
pages: rows.filter((r) => r.kind === 'page' && r.ebook_title).map((r) => ({
|
||||||
...item(r), page: r.page, ebook: { id: r.ebook_id, title: r.ebook_title, poet: r.ebook_poet, poet_url: r.ebook_poet_url } })),
|
...item(r), page: r.page, ebook: { id: r.ebook_id, title: r.ebook_title, poet: r.ebook_poet, poet_url: r.ebook_poet_url } })),
|
||||||
words: rows.filter((r) => r.kind === 'word').map((r) => ({
|
words: rows.filter((r) => r.kind === 'word').map((r) => ({
|
||||||
@ -107,13 +133,14 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
const poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0;
|
const poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2)
|
`SELECT kind, couplet, phrase, note, bookmarked FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2)
|
||||||
OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`,
|
OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`,
|
||||||
[u.id, poet, category, poem]);
|
[u.id, poet, category, poem]);
|
||||||
return {
|
return {
|
||||||
poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'),
|
poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'),
|
||||||
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet),
|
couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => r.couplet),
|
||||||
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
|
phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
|
||||||
|
notes: rows.filter((r) => r.note && (r.kind === 'couplet' || r.kind === 'phrase')).map((r) => ({ couplet: r.couplet, phrase: r.phrase, note: r.note })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -123,7 +150,7 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
const u = await reader(req, reply); if (!u) return;
|
const u = await reader(req, reply); if (!u) return;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT poem_id, bool_or(kind = 'poem') AS fav, count(*) FILTER (WHERE kind IN ('couplet', 'phrase'))::int AS bm
|
`SELECT poem_id, bool_or(kind = 'poem') AS fav, count(*) FILTER (WHERE kind IN ('couplet', 'phrase'))::int AS bm
|
||||||
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') GROUP BY poem_id`, [u.id]);
|
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') AND bookmarked GROUP BY poem_id`, [u.id]);
|
||||||
const ids = rows.map((r) => r.poem_id);
|
const ids = rows.map((r) => r.poem_id);
|
||||||
const [cats, poets] = await Promise.all([
|
const [cats, poets] = await Promise.all([
|
||||||
pool.query(`WITH RECURSIVE up AS (
|
pool.query(`WITH RECURSIVE up AS (
|
||||||
@ -170,12 +197,11 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
const phrase = cleanPhrase(b.phrase);
|
const phrase = cleanPhrase(b.phrase);
|
||||||
if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' });
|
if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' });
|
||||||
// the phrase must be in that couplet or paragraph (its lines, or across its two misras)
|
// the phrase must be in that couplet or paragraph (its lines, or across its two misras)
|
||||||
const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t;
|
const where = await phraseIn(poemId, couplet, phrase);
|
||||||
if (!text) return reply.code(404).send({ error: 'نہیں ملا' });
|
if (where) return reply.code(where.status).send({ error: where.error });
|
||||||
if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return reply.code(400).send({ error: 'یہ عبارت اس شعر میں نہیں' });
|
|
||||||
const args = [u.id, poemId, couplet, phrase];
|
const args = [u.id, poemId, couplet, phrase];
|
||||||
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4 RETURNING id`, args)).rowCount)
|
const flip = await flipBookmark(`kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`, args);
|
||||||
return { saved: false };
|
if (flip) return flip;
|
||||||
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args);
|
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args);
|
||||||
return { saved: true, id: Number(rows[0].id) };
|
return { saved: true, id: Number(rows[0].id) };
|
||||||
}
|
}
|
||||||
@ -190,22 +216,51 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(category_id, 0) = coalesce($4::int, 0)
|
const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(category_id, 0) = coalesce($4::int, 0)
|
||||||
AND coalesce(poem_id, 0) = coalesce($5::int, 0) AND coalesce(couplet, -1) = coalesce($6::int, -1)`;
|
AND coalesce(poem_id, 0) = coalesce($5::int, 0) AND coalesce(couplet, -1) = coalesce($6::int, -1)`;
|
||||||
const args = [u.id, kind, ...cols];
|
const args = [u.id, kind, ...cols];
|
||||||
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${match} RETURNING id`, args)).rowCount) return { saved: false };
|
const flip = await flipBookmark(match, args);
|
||||||
|
if (flip) return flip;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'INSERT INTO library (user_id, kind, poet_id, category_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id', args);
|
'INSERT INTO library (user_id, kind, poet_id, category_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id', args);
|
||||||
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
|
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: { poemId?: number; couplet?: number; phrase?: string; note?: string } }>('/api/library/note', async (req, reply) => {
|
||||||
|
const u = await reader(req, reply); if (!u) return;
|
||||||
|
const b = req.body ?? {}, poemId = Number(b.poemId) || 0, couplet = Number.isInteger(b.couplet) ? b.couplet! : null;
|
||||||
|
const note = String(b.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
|
||||||
|
if (!poemId || couplet == null) return reply.code(400).send({ error: 'شعر منتخب کریں' });
|
||||||
|
let kind = 'couplet', match = `kind = 'couplet' AND poem_id = $2 AND couplet = $3`, args: unknown[] = [u.id, poemId, couplet];
|
||||||
|
if (b.phrase != null) {
|
||||||
|
const phrase = cleanPhrase(b.phrase);
|
||||||
|
if (!phrase) return reply.code(400).send({ error: 'عبارت منتخب کریں' });
|
||||||
|
const where = await phraseIn(poemId, couplet, phrase);
|
||||||
|
if (where) return reply.code(where.status).send({ error: where.error });
|
||||||
|
kind = 'phrase'; match = `kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`; args.push(phrase);
|
||||||
|
} else if (!(await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet])).rowCount) {
|
||||||
|
return reply.code(404).send({ error: 'نہیں ملا' });
|
||||||
|
}
|
||||||
|
const n = args.length + 1;
|
||||||
|
const row = (await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id, bookmarked`, [...args, note])).rows[0];
|
||||||
|
if (row) { if (!note && !row.bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [row.id]); return { note }; }
|
||||||
|
if (note) await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note, bookmarked)
|
||||||
|
VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n}, false)`, [...args, note]);
|
||||||
|
return { note };
|
||||||
|
});
|
||||||
|
|
||||||
app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => {
|
app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => {
|
||||||
const u = await reader(req, reply); if (!u) return;
|
const u = await reader(req, reply); if (!u) return;
|
||||||
const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
|
const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
|
||||||
const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3', [note, Number(req.params.id) || 0, u.id]);
|
const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3 RETURNING bookmarked', [note, Number(req.params.id) || 0, u.id]);
|
||||||
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
|
if (!r.rowCount) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||||
|
if (!note && !r.rows[0].bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [Number(req.params.id)]); // a note only, now empty
|
||||||
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Params: { id: string } }>('/api/library/:id/delete', async (req, reply) => {
|
app.post<{ Params: { id: string } }>('/api/library/:id/delete', async (req, reply) => {
|
||||||
const u = await reader(req, reply); if (!u) return;
|
const u = await reader(req, reply); if (!u) return;
|
||||||
const r = await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [Number(req.params.id) || 0, u.id]);
|
// removing a bookmarked couplet or phrase keeps its note (the row stays as a note only)
|
||||||
|
const id = Number(req.params.id) || 0;
|
||||||
|
const kept = await pool.query(`UPDATE library SET bookmarked = false WHERE id = $1 AND user_id = $2 AND kind IN ('couplet', 'phrase') AND note IS NOT NULL AND bookmarked`, [id, u.id]);
|
||||||
|
const r = kept.rowCount ? kept : await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [id, u.id]);
|
||||||
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
|
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,6 +124,25 @@ test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permi
|
|||||||
const hist = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l1.token)).json();
|
const hist = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l1.token)).json();
|
||||||
assert.deepEqual(hist.history.filter((h: any) => h.version).map((h: any) => h.version), [2, 1]);
|
assert.deepEqual(hist.history.filter((h: any) => h.version).map((h: any) => h.version), [2, 1]);
|
||||||
assert.ok((await call('GET', '/api/mod/log', undefined, l2.token)).json().entries.length >= 10);
|
assert.ok((await call('GET', '/api/mod/log', undefined, l2.token)).json().entries.length >= 10);
|
||||||
|
|
||||||
|
// version history (#51): compare any two versions, bring an old one back through the pipeline, filter the log
|
||||||
|
const cmp = (await call('GET', `/api/mod/compare/work/${poem.id}`, undefined, l2.token)).json();
|
||||||
|
assert.deepEqual([cmp.a, cmp.b, cmp.changes], [1, 2, 2], 'by default the latest version against the one before');
|
||||||
|
assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text.includes('(ترمیم دوم)')));
|
||||||
|
const v0 = (await call('GET', `/api/mod/compare/work/${poem.id}?a=0&b=2`, undefined, l2.token)).json();
|
||||||
|
assert.ok(v0.diff.some((d: any) => d.op === '-' && d.text === misra), 'version 0 is the Wikisource text');
|
||||||
|
assert.equal((await call('GET', `/api/mod/compare/work/${poem.id}?a=7`, undefined, l2.token)).statusCode, 404);
|
||||||
|
assert.equal((await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 0 }, other.token)).statusCode, 403, 'outside the grant');
|
||||||
|
assert.equal((await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 2 }, l2.token)).statusCode, 400, 'already the current version');
|
||||||
|
const back = (await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 0 }, l2.token)).json().id;
|
||||||
|
const br = (await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json();
|
||||||
|
assert.deepEqual([br.revision.status, br.revision.base_version, br.revision.summary], ['draft', 2, 'ویکی ماخذ کا متن واپس لایا']);
|
||||||
|
assert.ok(br.diff.some((d: any) => d.op === '+' && d.text === misra), 'the draft brings the old line back');
|
||||||
|
assert.deepEqual(br.events.map((e: any) => e.action), ['created', 'reverted']);
|
||||||
|
assert.deepEqual((await call('POST', `/api/mod/revisions/${back}/submit`, {}, l2.token)).json().status, 'submitted', 'then reviewed like any edit');
|
||||||
|
const mine = (await call('GET', `/api/mod/log?who=l2-${run}&action=reverted`, undefined, l1.token)).json().entries;
|
||||||
|
assert.deepEqual(mine.map((e: any) => e.revision), [back], 'log filtered by person and step');
|
||||||
|
assert.equal((await call('GET', `/api/mod/log?who=l2-${run}&kind=order`, undefined, l1.token)).json().entries.length, 0);
|
||||||
} finally {
|
} finally {
|
||||||
// restore the work and remove the test people and their revisions
|
// restore the work and remove the test people and their revisions
|
||||||
await pool.query('DELETE FROM verses WHERE poem_id = $1', [poem.id]);
|
await pool.query('DELETE FROM verses WHERE poem_id = $1', [poem.id]);
|
||||||
|
|||||||
@ -11,7 +11,9 @@
|
|||||||
// If another version was published after the draft started, publishing is refused until the draft is redone.
|
// If another version was published after the draft started, publishing is refused until the draft is redone.
|
||||||
// Arranging a book or section (order.ts, entity 'order') uses the same revisions, steps and publishing; it needs
|
// Arranging a book or section (order.ts, entity 'order') uses the same revisions, steps and publishing; it needs
|
||||||
// the separate 'arrange' permission. Tagging (tags.ts, entity 'tags-category' / 'tags-work') likewise, with the 'tags'
|
// the separate 'arrange' permission. Tagging (tags.ts, entity 'tags-category' / 'tags-work') likewise, with the 'tags'
|
||||||
// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission.
|
// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission. A poet's details
|
||||||
|
// (name, pen name, years, intro) and a book or section's title (details.ts, entities 'poet' and 'book') likewise, with
|
||||||
|
// the edit permission on poets or books (#32).
|
||||||
// GET /api/mod/can?poem= what the reader may do on a work
|
// GET /api/mod/can?poem= what the reader may do on a work
|
||||||
// GET /api/mod/queue my drafts, drafts to review, drafts to publish
|
// GET /api/mod/queue my drafts, drafts to review, drafts to publish
|
||||||
// GET /api/mod/work/:id a work's current text and its history
|
// GET /api/mod/work/:id a work's current text and its history
|
||||||
@ -20,10 +22,15 @@
|
|||||||
// GET /api/mod/order/:id a book/section's current order and its history
|
// GET /api/mod/order/:id a book/section's current order and its history
|
||||||
// POST /api/mod/order/:id/draft start (or reopen) my arrangement draft
|
// POST /api/mod/order/:id/draft start (or reopen) my arrangement draft
|
||||||
// GET /api/mod/tags/:kind/:id a book/section's (kind category) or work's tags and their history
|
// GET /api/mod/tags/:kind/:id a book/section's (kind category) or work's tags and their history
|
||||||
|
// GET /api/mod/details/:kind/:id a poet's details (kind poet) or a book/section's title (kind book), history
|
||||||
|
// POST /api/mod/details/:kind/:id/draft start (or reopen) my draft of them
|
||||||
// POST /api/mod/tags/:kind/:id/draft start (or reopen) my tagging draft
|
// POST /api/mod/tags/:kind/:id/draft start (or reopen) my tagging draft
|
||||||
// POST /api/mod/revisions/:id/save {content, summary}
|
// POST /api/mod/revisions/:id/save {content, summary}
|
||||||
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment}
|
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment}
|
||||||
// GET /api/mod/log?page= who did what, newest first
|
// GET /api/mod/log?page=&who=&kind=&action= who did what, newest first; filtered by person, kind of change, step
|
||||||
|
// GET /api/mod/compare/:entity/:id?a=&b= two published versions of a work, order or tags and their diff (0 = the
|
||||||
|
// Wikisource text before Divan's first version)
|
||||||
|
// POST /api/mod/revert/:entity/:id {version}: a draft that brings back that version, through the pipeline
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { pool } from './db.ts';
|
import { pool } from './db.ts';
|
||||||
import { sessionUser } from './auth.ts';
|
import { sessionUser } from './auth.ts';
|
||||||
@ -37,6 +44,7 @@ import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './
|
|||||||
import { currentTags, checkTags, applyTags, writeTags, parseTags, tagsText, type Tag, type TagTarget } from './tags.ts';
|
import { currentTags, checkTags, applyTags, writeTags, parseTags, tagsText, type Tag, type TagTarget } from './tags.ts';
|
||||||
import { currentEbook, checkEbook, applyEbook, writeEbook, mayUpload } from './ebooks.ts';
|
import { currentEbook, checkEbook, applyEbook, writeEbook, mayUpload } from './ebooks.ts';
|
||||||
import { publicName } from './auth.ts';
|
import { publicName } from './auth.ts';
|
||||||
|
import { isDetail, currentDetails, checkDetails, applyDetails, writeDetails, parseDetails, detailsText, type DetailKind } from './details.ts';
|
||||||
|
|
||||||
const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname;
|
const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname;
|
||||||
const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
|
const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
|
||||||
@ -47,11 +55,13 @@ const mayTag = (u: any, kind: TagTarget, id: number) => can(u, 'edit', 'tags', k
|
|||||||
// may change what this revision changes (a work's text, a book/section's order, tags); reviewing needs L1 or admin
|
// may change what this revision changes (a work's text, a book/section's order, tags); reviewing needs L1 or admin
|
||||||
const mayEbook = async (u: any, ebookId: number) =>
|
const mayEbook = async (u: any, ebookId: number) =>
|
||||||
mayUpload(u, (await pool.query('SELECT poet_id FROM ebooks WHERE id = $1', [ebookId])).rows[0]?.poet_id ?? 0);
|
mayUpload(u, (await pool.query('SELECT poet_id FROM ebooks WHERE id = $1', [ebookId])).rows[0]?.poet_id ?? 0);
|
||||||
const mayChange = (u: any, r: { entity: string; entity_id: number }) =>
|
const mayDetails = (u: any, kind: DetailKind, id: number) =>
|
||||||
|
kind === 'poet' ? can(u, 'edit', 'poets', { poetId: id }) : can(u, 'edit', 'books', { categoryId: id });
|
||||||
|
const mayChange = (u: any, r: { entity: string; entity_id: number }) => isDetail(r.entity) ? mayDetails(u, r.entity, r.entity_id) :
|
||||||
r.entity === 'order' ? mayArrange(u, r.entity_id) : r.entity.startsWith('tags-') ? mayTag(u, tagKind(r), r.entity_id)
|
r.entity === 'order' ? mayArrange(u, r.entity_id) : r.entity.startsWith('tags-') ? mayTag(u, tagKind(r), r.entity_id)
|
||||||
: r.entity === 'ebook' ? mayEbook(u, r.entity_id) : mayEdit(u, r.entity_id);
|
: r.entity === 'ebook' ? mayEbook(u, r.entity_id) : mayEdit(u, r.entity_id);
|
||||||
const tagKind = (r: { entity: string }) => r.entity.slice('tags-'.length) as TagTarget;
|
const tagKind = (r: { entity: string }) => r.entity.slice('tags-'.length) as TagTarget;
|
||||||
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook')`;
|
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook', 'poet', 'book')`;
|
||||||
const mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r));
|
const mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r));
|
||||||
const OPEN = ['draft', 'returned'];
|
const OPEN = ['draft', 'returned'];
|
||||||
// a draft whose text is still the text it started from is not shown anywhere (opening the editor is not a change)
|
// a draft whose text is still the text it started from is not shown anywhere (opening the editor is not a change)
|
||||||
@ -82,10 +92,11 @@ async function current(poemId: number) {
|
|||||||
|
|
||||||
// what a revision changes: a work, or a book/section's order (work_title/work_url name either)
|
// what a revision changes: a work, or a book/section's order (work_title/work_url name either)
|
||||||
const TARGET = `LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id
|
const TARGET = `LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id
|
||||||
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id
|
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category', 'book') AND c.id = r.entity_id
|
||||||
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id`;
|
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id
|
||||||
|
LEFT JOIN poets pt ON r.entity = 'poet' AND pt.id = r.entity_id`;
|
||||||
// (not named URL: that would hide the global URL used for the default divan-data folder)
|
// (not named URL: that would hide the global URL used for the default divan-data folder)
|
||||||
const TARGET_TITLE = `coalesce(p.title, c.title, b.title)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id)`;
|
const TARGET_TITLE = `coalesce(p.title, c.title, b.title, pt.nickname)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id, pt.url)`;
|
||||||
const TARGET_COLS = `${TARGET_TITLE} AS work_title, ${TARGET_URL} AS work_url`;
|
const TARGET_COLS = `${TARGET_TITLE} AS work_title, ${TARGET_URL} AS work_url`;
|
||||||
|
|
||||||
async function revision(id: number) {
|
async function revision(id: number) {
|
||||||
@ -107,17 +118,18 @@ async function actions(u: any, r: any) {
|
|||||||
|
|
||||||
async function publish(r: any, u: any, comment?: string) {
|
async function publish(r: any, u: any, comment?: string) {
|
||||||
const order = r.entity === 'order', tags = r.entity.startsWith('tags-'), ebook = r.entity === 'ebook';
|
const order = r.entity === 'order', tags = r.entity.startsWith('tags-'), ebook = r.entity === 'ebook';
|
||||||
const cur = order ? await currentOrder(r.entity_id) : tags ? await currentTags(tagKind(r), r.entity_id)
|
const cur = await currentOf(r.entity, r.entity_id);
|
||||||
: ebook ? await currentEbook(r.entity_id) : await current(r.entity_id);
|
|
||||||
if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
|
if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
|
||||||
if (cur.version !== r.base_version)
|
if (cur.version !== r.base_version)
|
||||||
throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
|
throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
|
||||||
// the section's contents may have changed since (a new work from the sync): the arrangement must be redone
|
// the section's contents may have changed since (a new work from the sync): the arrangement must be redone
|
||||||
|
const details = isDetail(r.entity);
|
||||||
const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content)
|
const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content)
|
||||||
: ebook ? checkEbook(r.content) : null;
|
: ebook ? checkEbook(r.content) : details ? checkDetails(r.entity, r.content) : null;
|
||||||
if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
|
if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
|
||||||
const doc = order || tags || ebook ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
|
const doc = order || tags || ebook || details ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
|
||||||
const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}`
|
const title = 'details' in cur ? `${r.entity === 'poet' ? 'شاعر' : 'عنوان'}: ${parseDetails(r.content)[r.entity === 'poet' ? 'تخلص' : 'عنوان']}`
|
||||||
|
: 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}`
|
||||||
: 'ebook' in cur ? `ای بک: ${cur.ebook.poet}، ${cur.ebook.title}` : doc!.meta['عنوان'] || cur.poem.title;
|
: 'ebook' in cur ? `ای بک: ${cur.ebook.poet}، ${cur.ebook.title}` : doc!.meta['عنوان'] || cur.poem.title;
|
||||||
const version = cur.version + 1, at = new Date().toISOString();
|
const version = cur.version + 1, at = new Date().toISOString();
|
||||||
// who did it, by public name (divan-data is public: never email addresses)
|
// who did it, by public name (divan-data is public: never email addresses)
|
||||||
@ -126,7 +138,8 @@ async function publish(r: any, u: any, comment?: string) {
|
|||||||
const who = (p: any) => p && { id: Number(p.id), name: publicName(p) };
|
const who = (p: any) => p && { id: Number(p.id), name: publicName(p) };
|
||||||
const credits = { by: who(author)?.name ?? 'موڈریٹر', reviewedBy: who(reviewer)?.name ?? null, publishedBy: publicName(u) };
|
const credits = { by: who(author)?.name ?? 'موڈریٹر', reviewedBy: who(reviewer)?.name ?? null, publishedBy: publicName(u) };
|
||||||
// divan-data first (the published record, committed to git), then the site's database
|
// divan-data first (the published record, committed to git), then the site's database
|
||||||
const files = 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
|
const files = 'details' in cur ? [await writeDetails(dataDir(), r.entity, cur.details.url, r.content)]
|
||||||
|
: 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
|
||||||
: 'target' in cur ? [await writeTags(dataDir(), cur.target.url, r.content)]
|
: 'target' in cur ? [await writeTags(dataDir(), cur.target.url, r.content)]
|
||||||
: 'ebook' in cur ? [await writeEbook(dataDir(), cur.ebook, r.content)]
|
: 'ebook' in cur ? [await writeEbook(dataDir(), cur.ebook, r.content)]
|
||||||
: await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
|
: await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
|
||||||
@ -142,6 +155,7 @@ async function publish(r: any, u: any, comment?: string) {
|
|||||||
if (order) await applyOrder(client, r.entity_id, r.content);
|
if (order) await applyOrder(client, r.entity_id, r.content);
|
||||||
else if (tags) await applyTags(client, tagKind(r), r.entity_id, r.content);
|
else if (tags) await applyTags(client, tagKind(r), r.entity_id, r.content);
|
||||||
else if (ebook) await applyEbook(client, r.entity_id, r.content);
|
else if (ebook) await applyEbook(client, r.entity_id, r.content);
|
||||||
|
else if (details) await applyDetails(client, r.entity, r.entity_id, r.content);
|
||||||
else {
|
else {
|
||||||
await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1',
|
await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1',
|
||||||
[r.entity_id, title, normalise([title, ...verses.map((v) => v.Text)].join(' '))]);
|
[r.entity_id, title, normalise([title, ...verses.map((v) => v.Text)].join(' '))]);
|
||||||
@ -162,6 +176,21 @@ async function publish(r: any, u: any, comment?: string) {
|
|||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// what an entity is now (its latest published version), for any kind of revision
|
||||||
|
const currentOf = (entity: string, id: number): Promise<any> => entity === 'order' ? currentOrder(id)
|
||||||
|
: entity.startsWith('tags-') ? currentTags(tagKind({ entity }), id) : entity === 'ebook' ? currentEbook(id)
|
||||||
|
: isDetail(entity) ? currentDetails(entity, id) : current(id);
|
||||||
|
// the kinds of change with versions to compare and bring back (an e-book's details are a one-off)
|
||||||
|
const VERSIONED = ['work', 'order', 'tags-work', 'tags-category', 'poet', 'book'];
|
||||||
|
// a published version's content; version 0 is what the first Divan draft started from (the Wikisource text)
|
||||||
|
async function versionText(entity: string, id: number, v: number, cur: { version: number; content: string }) {
|
||||||
|
if (v === cur.version) return cur.content;
|
||||||
|
const { rows } = await pool.query(v
|
||||||
|
? `SELECT content FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published' AND version = $3`
|
||||||
|
: `SELECT base_content AS content FROM revisions WHERE entity = $1 AND entity_id = $2 AND base_version = 0 AND $3 = 0 ORDER BY id LIMIT 1`, [entity, id, v]);
|
||||||
|
return rows[0]?.content as string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const identityOf = (p: any) => identity({ id: Number(p.id), name: publicName(p) });
|
const identityOf = (p: any) => identity({ id: Number(p.id), name: publicName(p) });
|
||||||
|
|
||||||
export function moderationRoutes(app: FastifyInstance) {
|
export function moderationRoutes(app: FastifyInstance) {
|
||||||
@ -171,7 +200,8 @@ export function moderationRoutes(app: FastifyInstance) {
|
|||||||
if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false, ebooks: false };
|
if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false, ebooks: false };
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
const poetId = (await pool.query('SELECT poet_id FROM categories WHERE id = $1', [categoryId])).rows[0]?.poet_id ?? 0;
|
const poetId = (await pool.query('SELECT poet_id FROM categories WHERE id = $1', [categoryId])).rows[0]?.poet_id ?? 0;
|
||||||
return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId) };
|
return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId),
|
||||||
|
book: await mayDetails(u, 'book', categoryId), poet: await mayDetails(u, 'poet', poetId) };
|
||||||
}
|
}
|
||||||
return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: poemId }), publish: u.role === 'admin',
|
return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: poemId }), publish: u.role === 'admin',
|
||||||
tags: await mayTag(u, 'work', poemId) };
|
tags: await mayTag(u, 'work', poemId) };
|
||||||
@ -283,6 +313,34 @@ export function moderationRoutes(app: FastifyInstance) {
|
|||||||
return { id: Number(rows[0].id) };
|
return { id: Number(rows[0].id) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id', async (req, reply) => {
|
||||||
|
const u = await moderator(req, reply); if (!u) return;
|
||||||
|
const { kind } = req.params, cur = isDetail(kind) ? await currentDetails(kind, Number(req.params.id) || 0) : null;
|
||||||
|
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, version, base_version, status, summary, author_email, reviewer_email, publisher_email, created_at, published_at
|
||||||
|
FROM revisions r WHERE entity = $1 AND entity_id = $2 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [kind, cur.details.id]);
|
||||||
|
return { kind, target: cur.details, version: cur.version, content: cur.content, fields: parseDetails(cur.content),
|
||||||
|
history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { edit: await mayDetails(u, kind as DetailKind, cur.details.id) } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id/draft', async (req, reply) => {
|
||||||
|
const u = await moderator(req, reply); if (!u) return;
|
||||||
|
const { kind } = req.params, id = Number(req.params.id) || 0;
|
||||||
|
if (!isDetail(kind)) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||||
|
if (!(await mayDetails(u, kind, id))) return reply.code(403).send({ error: 'اس میں ترمیم کی اجازت نہیں' });
|
||||||
|
const open = (await pool.query(
|
||||||
|
`SELECT id FROM revisions WHERE entity = $1 AND entity_id = $2 AND author_id = $3 AND status IN ('draft', 'returned') LIMIT 1`, [kind, id, u.id])).rows[0];
|
||||||
|
if (open) return { id: Number(open.id) };
|
||||||
|
const cur = await currentDetails(kind, id);
|
||||||
|
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
|
||||||
|
VALUES ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`, [kind, id, cur.version, cur.content, u.id, u.email]);
|
||||||
|
await event(rows[0].id, u, 'created');
|
||||||
|
return { id: Number(rows[0].id) };
|
||||||
|
});
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/api/mod/revisions/:id', async (req, reply) => {
|
app.get<{ Params: { id: string } }>('/api/mod/revisions/:id', async (req, reply) => {
|
||||||
const u = await moderator(req, reply); if (!u) return;
|
const u = await moderator(req, reply); if (!u) return;
|
||||||
const r = await revision(Number(req.params.id) || 0);
|
const r = await revision(Number(req.params.id) || 0);
|
||||||
@ -302,8 +360,9 @@ export function moderationRoutes(app: FastifyInstance) {
|
|||||||
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
|
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
|
||||||
if (!(await actions(u, r)).save) return reply.code(403).send({ error: 'یہ مسودہ اب محفوظ نہیں کیا جا سکتا' });
|
if (!(await actions(u, r)).save) return reply.code(403).send({ error: 'یہ مسودہ اب محفوظ نہیں کیا جا سکتا' });
|
||||||
const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n');
|
const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n');
|
||||||
if (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook') {
|
if (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook' || isDetail(r.entity)) {
|
||||||
const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : r.entity === 'ebook' ? checkEbook(content)
|
const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : r.entity === 'ebook' ? checkEbook(content)
|
||||||
|
: isDetail(r.entity) ? checkDetails(r.entity, content)
|
||||||
: await checkTags(tagKind(r), r.entity_id, content);
|
: await checkTags(tagKind(r), r.entity_id, content);
|
||||||
if (bad) return reply.code(400).send({ error: bad });
|
if (bad) return reply.code(400).send({ error: bad });
|
||||||
} else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
|
} else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
|
||||||
@ -311,7 +370,7 @@ export function moderationRoutes(app: FastifyInstance) {
|
|||||||
// no change from the text it started from (compared as Divan text, so layout-only differences don't count):
|
// no change from the text it started from (compared as Divan text, so layout-only differences don't count):
|
||||||
// a plain draft is dropped rather than kept
|
// a plain draft is dropped rather than kept
|
||||||
const norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : r.entity.startsWith('tags-') ? tagsText(parseTags(t) as Tag[])
|
const norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : r.entity.startsWith('tags-') ? tagsText(parseTags(t) as Tag[])
|
||||||
: r.entity === 'ebook' ? t.trim() : toText(parse(t)));
|
: r.entity === 'ebook' ? t.trim() : isDetail(r.entity) ? detailsText(r.entity, parseDetails(t)) : toText(parse(t)));
|
||||||
const same = norm(content) === norm(r.base_content);
|
const same = norm(content) === norm(r.base_content);
|
||||||
if (same && r.status === 'draft') {
|
if (same && r.status === 'draft') {
|
||||||
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
|
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
|
||||||
@ -358,14 +417,53 @@ export function moderationRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Querystring: { page?: string } }>('/api/mod/log', async (req, reply) => {
|
app.get<{ Querystring: { page?: string; who?: string; kind?: string; action?: string } }>('/api/mod/log', async (req, reply) => {
|
||||||
const u = await moderator(req, reply); if (!u) return;
|
const u = await moderator(req, reply); if (!u) return;
|
||||||
const page = Math.max(1, Number(req.query.page) || 1);
|
const page = Math.max(1, Number(req.query.page) || 1), { who, kind, action } = req.query;
|
||||||
|
// filters: a person (part of their address), a kind of change (tags covers both), a step; empty = all
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.entity, r.version, ${TARGET_TITLE} AS title, ${TARGET_URL} AS url
|
`SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.entity, r.version, ${TARGET_TITLE} AS title, ${TARGET_URL} AS url
|
||||||
FROM revision_events e JOIN revisions r ON r.id = e.revision_id ${TARGET}
|
FROM revision_events e JOIN revisions r ON r.id = e.revision_id ${TARGET}
|
||||||
WHERE r.entity IN ${ENTITIES} AND ${CHANGED}
|
WHERE r.entity IN ${ENTITIES} AND ${CHANGED}
|
||||||
ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`);
|
AND ($1 = '' OR e.actor_email ILIKE '%' || $1 || '%') AND ($2 = '' OR r.entity = $2 OR r.entity LIKE $2 || '-%') AND ($3 = '' OR e.action = $3)
|
||||||
|
ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`, [who?.trim() ?? '', kind ?? '', action ?? '']);
|
||||||
return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) };
|
return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { entity: string; id: string }; Querystring: { a?: string; b?: string } }>('/api/mod/compare/:entity/:id', async (req, reply) => {
|
||||||
|
const u = await moderator(req, reply); if (!u) return;
|
||||||
|
const { entity } = req.params, id = Number(req.params.id) || 0;
|
||||||
|
const cur = VERSIONED.includes(entity) ? await currentOf(entity, id) : null;
|
||||||
|
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||||
|
const pick = (q: string | undefined, d: number) => (q === undefined || q === '' ? d : Number(q));
|
||||||
|
const a = pick(req.query.a, Math.max(0, cur.version - 1)), b = pick(req.query.b, cur.version);
|
||||||
|
const [ta, tb] = await Promise.all([a, b].map((v) => Number.isInteger(v) && v >= 0 && v <= cur.version ? versionText(entity, id, v, cur) : undefined));
|
||||||
|
if (ta === undefined || tb === undefined) return reply.code(404).send({ error: 'یہ ورژن نہیں ملا' });
|
||||||
|
const target = cur.poem ?? cur.cat ?? cur.target ?? cur.details, diff = diffLines(ta, tb);
|
||||||
|
return { entity, id, title: target.title, url: target.url, version: cur.version, a, b, diff, changes: changed(diff),
|
||||||
|
may: { revert: await mayChange(u, { entity, entity_id: id }) } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { entity: string; id: string }; Body: { version?: number } }>('/api/mod/revert/:entity/:id', async (req, reply) => {
|
||||||
|
const u = await moderator(req, reply); if (!u) return;
|
||||||
|
const { entity } = req.params, id = Number(req.params.id) || 0, v = Number(req.body?.version);
|
||||||
|
const cur = VERSIONED.includes(entity) ? await currentOf(entity, id) : null;
|
||||||
|
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||||
|
if (!(await mayChange(u, { entity, entity_id: id }))) return reply.code(403).send({ error: 'اس میں تبدیلی کی اجازت نہیں' });
|
||||||
|
const content = Number.isInteger(v) && v >= 0 && v < cur.version ? await versionText(entity, id, v, cur) : undefined;
|
||||||
|
if (content === undefined) return reply.code(400).send({ error: 'یہ ورژن واپس نہیں لایا جا سکتا' });
|
||||||
|
if (content === cur.content) return reply.code(400).send({ error: 'یہ ورژن موجودہ ورژن جیسا ہی ہے' });
|
||||||
|
// the reverted text goes into my open draft (or a new one) and then through review like any edit
|
||||||
|
const summary = v ? `ورژن ${v} واپس لایا` : 'ویکی ماخذ کا متن واپس لایا';
|
||||||
|
const open = (await pool.query(
|
||||||
|
`SELECT id FROM revisions WHERE entity = $1 AND entity_id = $2 AND author_id = $3 AND status IN ('draft', 'returned') LIMIT 1`, [entity, id, u.id])).rows[0];
|
||||||
|
const revId = open ? Number(open.id) : Number((await pool.query(
|
||||||
|
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
|
||||||
|
VALUES ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`, [entity, id, cur.version, cur.content, u.id, u.email])).rows[0].id);
|
||||||
|
if (!open) await event(revId, u, 'created');
|
||||||
|
await pool.query(`UPDATE revisions SET content = $2, summary = $3, base_version = $4, base_content = $5, updated_at = now() WHERE id = $1`,
|
||||||
|
[revId, content, summary, cur.version, cur.content]);
|
||||||
|
await event(revId, u, 'reverted', summary);
|
||||||
|
return { id: revId };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,10 +36,10 @@ test('arranging: its own permission; L2 reorders, L1 approves, admin publishes t
|
|||||||
const grant = (m: any, actions: string[]) =>
|
const grant = (m: any, actions: string[]) =>
|
||||||
pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, '{works}', $3)`, [m.id, cat.id, actions]);
|
pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, '{works}', $3)`, [m.id, cat.id, actions]);
|
||||||
await grant(l2, ['edit']);
|
await grant(l2, ['edit']);
|
||||||
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false }, 'editing is not arranging');
|
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false, book: false, poet: false }, 'editing is not arranging');
|
||||||
assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403);
|
assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403);
|
||||||
await grant(l2, ['arrange']); await grant(l1, ['arrange']);
|
await grant(l2, ['arrange']); await grant(l1, ['arrange']);
|
||||||
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false });
|
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false, book: false, poet: false });
|
||||||
|
|
||||||
const { id } = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json();
|
const { id } = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json();
|
||||||
const cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json();
|
const cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json();
|
||||||
|
|||||||
@ -13,7 +13,7 @@ type Verse = { Position: string; Text: string; CoupletIndex: number };
|
|||||||
// a work (ganjoor-data poem JSON) as Divan text: verse in <poem> (blank line between couplets, stanzas and
|
// a work (ganjoor-data poem JSON) as Divan text: verse in <poem> (blank line between couplets, stanzas and
|
||||||
// lines), prose as paragraphs
|
// lines), prose as paragraphs
|
||||||
export function fromPoem(poem: { Title: string; Verses: Verse[]; SourceUrl?: string }, meta: Record<string, string> = {}) {
|
export function fromPoem(poem: { Title: string; Verses: Verse[]; SourceUrl?: string }, meta: Record<string, string> = {}) {
|
||||||
const head = Object.entries({ عنوان: poem.Title, ...meta, ...(poem.SourceUrl && { ماخذ: 'ویکی ماخذ', ماخذ_ربط: poem.SourceUrl }) })
|
const head = Object.entries({ عنوان: poem.Title, ...meta, ...(poem.SourceUrl && { ماخذ: 'ویکی ماخذ', ماخذ_لنک: poem.SourceUrl }) })
|
||||||
.map(([k, v]) => `| ${k} = ${v}`).join('\n');
|
.map(([k, v]) => `| ${k} = ${v}`).join('\n');
|
||||||
const units = new Map<number, Verse[]>();
|
const units = new Map<number, Verse[]>();
|
||||||
for (const v of poem.Verses) (units.get(v.CoupletIndex) ?? units.set(v.CoupletIndex, []).get(v.CoupletIndex)!).push(v);
|
for (const v of poem.Verses) (units.get(v.CoupletIndex) ?? units.set(v.CoupletIndex, []).get(v.CoupletIndex)!).push(v);
|
||||||
|
|||||||
@ -16,14 +16,35 @@ import { nameMatches } from './search.ts';
|
|||||||
import { lookup, PUNCT } from './dictionary.ts';
|
import { lookup, PUNCT } from './dictionary.ts';
|
||||||
import { authRoutes } from './auth.ts';
|
import { authRoutes } from './auth.ts';
|
||||||
import { adminRoutes } from './admin.ts';
|
import { adminRoutes } from './admin.ts';
|
||||||
|
import { statsRoutes } from './stats.ts';
|
||||||
import { permissionRoutes } from './permissions.ts';
|
import { permissionRoutes } from './permissions.ts';
|
||||||
import { libraryRoutes } from './library.ts';
|
import { libraryRoutes } from './library.ts';
|
||||||
import { moderationRoutes } from './moderation.ts';
|
import { moderationRoutes } from './moderation.ts';
|
||||||
import { siteRoutes } from './site.ts';
|
import { siteRoutes } from './site.ts';
|
||||||
import { tagRoutes, pageTags } from './tags.ts';
|
import { tagRoutes, pageTags } from './tags.ts';
|
||||||
import { ebookRoutes } from './ebooks.ts';
|
import { ebookRoutes } from './ebooks.ts';
|
||||||
|
import { feedRoutes } from './feeds.ts';
|
||||||
|
|
||||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
||||||
|
|
||||||
|
// Errors: readers get a short message, never internal details; the log gets the whole error (one JSON line each,
|
||||||
|
// with the method and address), which is what the server's error report reads (divan-deploy scripts/errors.sh).
|
||||||
|
app.setErrorHandler((err: any, req, reply) => {
|
||||||
|
const status = err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 500;
|
||||||
|
if (status === 500) req.log.error({ err, method: req.method, url: req.url }, 'request failed');
|
||||||
|
reply.code(status).send({ error: status === 500 ? 'سرور میں خرابی۔ تھوڑی دیر بعد کوشش کریں۔' : err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
// for the site's /health (uptime monitoring): the API answers and the database too
|
||||||
|
app.get('/api/health', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
return { ok: true, db: 'ok' };
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, 'health: database unreachable');
|
||||||
|
return reply.code(503).send({ ok: false, db: 'down' });
|
||||||
|
}
|
||||||
|
});
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
app.get('/health', async () => {
|
app.get('/health', async () => {
|
||||||
@ -199,6 +220,8 @@ moderationRoutes(app);
|
|||||||
siteRoutes(app);
|
siteRoutes(app);
|
||||||
tagRoutes(app);
|
tagRoutes(app);
|
||||||
ebookRoutes(app);
|
ebookRoutes(app);
|
||||||
|
feedRoutes(app);
|
||||||
|
statsRoutes(app);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 4100);
|
const port = Number(process.env.PORT ?? 4100);
|
||||||
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
||||||
|
|||||||
42
api/src/stats.test.ts
Normal file
42
api/src/stats.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { test, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { authRoutes } from './auth.ts';
|
||||||
|
import { statsRoutes } from './stats.ts';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
after(() => pool.end());
|
||||||
|
|
||||||
|
test('statistics: hits only from the site, counts without addresses, admin-only dashboard', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
authRoutes(app); statsRoutes(app);
|
||||||
|
const run = Date.now(), path = `/stats-test-${run}`;
|
||||||
|
const hit = (body: object, headers: object = {}) => app.inject({ method: 'POST', url: '/api/stats/hit', payload: body, headers: headers as any });
|
||||||
|
try {
|
||||||
|
process.env.DIVAN_SITE_KEY = 'site-key';
|
||||||
|
assert.equal((await hit({ path, ip: '1.2.3.4', ua: 'x' })).statusCode, 403, 'not from the site');
|
||||||
|
const site = { 'x-site-key': 'site-key' };
|
||||||
|
for (const ip of ['1.2.3.4', '1.2.3.4', '5.6.7.8']) assert.equal((await hit({ path, ip, ua: 'Mozilla', ref: `ref-${run}.pk` }, site)).statusCode, 200);
|
||||||
|
const views = (await pool.query('SELECT views FROM stat_views WHERE path = $1', [path])).rows[0].views;
|
||||||
|
assert.equal(views, 3);
|
||||||
|
const hashes = (await pool.query('SELECT h FROM stat_visitors WHERE day = (now() AT TIME ZONE \'Asia/Karachi\')::date')).rows.map((r) => r.h);
|
||||||
|
assert.ok(!hashes.some((h) => h.includes('1.2.3.4')), 'no addresses stored');
|
||||||
|
assert.equal((await pool.query('SELECT n FROM stat_referrers WHERE host = $1', [`ref-${run}.pk`])).rows[0].n, 3);
|
||||||
|
|
||||||
|
const s = (await app.inject({ method: 'POST', url: '/api/auth/signup', payload: { email: `stats-${run}@divan.test`, password: 'pass-word-1' }, headers: { 'x-client-ip': `stats-${run}` } })).json();
|
||||||
|
const get = () => app.inject({ method: 'GET', url: '/api/admin/stats?days=7', headers: { authorization: `Bearer ${s.token}` } });
|
||||||
|
assert.equal((await get()).statusCode, 403, 'readers cannot see statistics');
|
||||||
|
await pool.query(`UPDATE users SET role = 'admin' WHERE id = $1`, [s.user.id]);
|
||||||
|
const st = (await get()).json();
|
||||||
|
assert.equal(st.daily.length, 7);
|
||||||
|
assert.ok(st.daily.at(-1).views >= 3 && st.daily.at(-1).visitors >= 2);
|
||||||
|
assert.ok(st.pages.some((p: any) => p.path === path && p.views === 3));
|
||||||
|
assert.ok(st.accounts.roles.admin >= 1 && st.accounts.new >= 1);
|
||||||
|
} finally {
|
||||||
|
delete process.env.DIVAN_SITE_KEY;
|
||||||
|
await pool.query('DELETE FROM stat_views WHERE path = $1', [path]);
|
||||||
|
await pool.query('DELETE FROM stat_referrers WHERE host = $1', [`ref-${run}.pk`]);
|
||||||
|
await pool.query('DELETE FROM users WHERE email = $1', [`stats-${run}@divan.test`]);
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
63
api/src/stats.ts
Normal file
63
api/src/stats.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// Privacy-friendly statistics for admins (#86): page views, visitors and where they came from, plus moderation and
|
||||||
|
// account activity. No cookies and nothing personal is stored: the site reports a page view with the reader's address
|
||||||
|
// and browser, and only a one-day salted hash of them is kept (to count visitors per day); the salt is never stored.
|
||||||
|
// POST /api/stats/hit {path, ref, ip, ua} from the site only (x-site-key)
|
||||||
|
// GET /api/admin/stats?days=30 the admin dashboard's numbers
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
import { fromSite } from './auth.ts';
|
||||||
|
import { requireAdmin } from './admin.ts';
|
||||||
|
|
||||||
|
const TODAY = `(now() AT TIME ZONE 'Asia/Karachi')::date`;
|
||||||
|
// ponytail: the salt is per process; a restart mid-day counts a returning visitor once more that day
|
||||||
|
let salt = { day: '', key: randomBytes(32) };
|
||||||
|
const visitor = (ip: string, ua: string) => {
|
||||||
|
const day = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Karachi' });
|
||||||
|
if (salt.day !== day) salt = { day, key: randomBytes(32) };
|
||||||
|
return createHash('sha256').update(salt.key).update(ip).update('\n').update(ua).digest('base64url').slice(0, 22);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function statsRoutes(app: FastifyInstance) {
|
||||||
|
app.post<{ Body: { path?: string; ref?: string; ip?: string; ua?: string } }>('/api/stats/hit', async (req, reply) => {
|
||||||
|
if (!fromSite(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||||
|
const { path = '', ref = '', ip = '', ua = '' } = req.body ?? {};
|
||||||
|
if (!path.startsWith('/') || path.length > 300) return reply.code(400).send({ error: 'path' });
|
||||||
|
await pool.query(`INSERT INTO stat_views (day, path, views) VALUES (${TODAY}, $1, 1)
|
||||||
|
ON CONFLICT (day, path) DO UPDATE SET views = stat_views.views + 1`, [path]);
|
||||||
|
await pool.query(`INSERT INTO stat_visitors (day, h) VALUES (${TODAY}, $1) ON CONFLICT DO NOTHING`, [visitor(ip, ua)]);
|
||||||
|
if (ref) await pool.query(`INSERT INTO stat_referrers (day, host, n) VALUES (${TODAY}, $1, 1)
|
||||||
|
ON CONFLICT (day, host) DO UPDATE SET n = stat_referrers.n + 1`, [ref.slice(0, 200)]);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { days?: string } }>('/api/admin/stats', async (req, reply) => {
|
||||||
|
if (!(await requireAdmin(req, reply))) return;
|
||||||
|
const days = Math.min(365, Math.max(1, Number(req.query.days) || 30));
|
||||||
|
const since = `${TODAY} - ${days - 1}`;
|
||||||
|
const q = async (sql: string) => (await pool.query(sql)).rows;
|
||||||
|
const [daily, pages, referrers, actions, people, waiting, roles, accounts, library, content] = await Promise.all([
|
||||||
|
q(`SELECT d::date::text AS day, coalesce(v.views, 0)::int AS views, coalesce(u.visitors, 0)::int AS visitors
|
||||||
|
FROM generate_series(${since}, ${TODAY}, interval '1 day') d
|
||||||
|
LEFT JOIN (SELECT day, sum(views) AS views FROM stat_views GROUP BY day) v ON v.day = d::date
|
||||||
|
LEFT JOIN (SELECT day, count(*) AS visitors FROM stat_visitors GROUP BY day) u ON u.day = d::date ORDER BY d`),
|
||||||
|
q(`SELECT s.path, sum(s.views)::int AS views, coalesce(p.title, c.title, t.nickname) AS title
|
||||||
|
FROM stat_views s LEFT JOIN poems p ON p.url = s.path LEFT JOIN categories c ON c.url = s.path LEFT JOIN poets t ON t.url = s.path
|
||||||
|
WHERE s.day >= ${since} GROUP BY s.path, p.title, c.title, t.nickname ORDER BY views DESC LIMIT 20`),
|
||||||
|
q(`SELECT host, sum(n)::int AS n FROM stat_referrers WHERE day >= ${since} GROUP BY host ORDER BY n DESC LIMIT 10`),
|
||||||
|
q(`SELECT action, count(*)::int AS n FROM revision_events WHERE at >= ${since} GROUP BY action ORDER BY n DESC`),
|
||||||
|
q(`SELECT actor_email AS email, count(*)::int AS n, count(*) FILTER (WHERE action = 'published')::int AS published
|
||||||
|
FROM revision_events WHERE at >= ${since} AND actor_email <> 'server' GROUP BY actor_email ORDER BY n DESC LIMIT 10`),
|
||||||
|
q(`SELECT status, count(*)::int AS n FROM revisions WHERE status IN ('submitted', 'approved') GROUP BY status`),
|
||||||
|
q(`SELECT role, count(*)::int AS n FROM users GROUP BY role`),
|
||||||
|
q(`SELECT count(*) FILTER (WHERE created_at >= ${since})::int AS new,
|
||||||
|
(SELECT count(DISTINCT user_id) FROM sessions WHERE created_at >= ${since})::int AS signed_in FROM users`),
|
||||||
|
q(`SELECT kind, count(*)::int AS n FROM library GROUP BY kind`),
|
||||||
|
q(`SELECT (SELECT count(*) FROM ebooks WHERE published)::int AS ebooks, (SELECT count(*) FROM tags)::int AS tags,
|
||||||
|
(SELECT count(*) FROM revisions WHERE status = 'published')::int AS versions`),
|
||||||
|
]);
|
||||||
|
return { days, daily, pages, referrers, moderation: { actions, people, waiting: Object.fromEntries(waiting.map((r) => [r.status, r.n])) },
|
||||||
|
accounts: { ...accounts[0], roles: Object.fromEntries(roles.map((r) => [r.role, r.n])) },
|
||||||
|
library: Object.fromEntries(library.map((r) => [r.kind, r.n])), content: content[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -239,7 +239,9 @@ CREATE INDEX IF NOT EXISTS ebooks_search ON ebooks USING gin (search_text gin_tr
|
|||||||
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '<sha256>.jpg|png|webp' in the file store
|
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '<sha256>.jpg|png|webp' in the file store
|
||||||
-- library: bookmarked pages of e-books (kind 'page': ebook_id, page)
|
-- library: bookmarked pages of e-books (kind 'page': ebook_id, page)
|
||||||
ALTER TABLE library ADD COLUMN IF NOT EXISTS ebook_id integer;
|
ALTER TABLE library ADD COLUMN IF NOT EXISTS ebook_id integer;
|
||||||
ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer; -- kinds: see library_kind_check above
|
ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer;
|
||||||
|
-- a couplet or phrase can hold a note without being bookmarked (bookmarked false: a note only)
|
||||||
|
ALTER TABLE library ADD COLUMN IF NOT EXISTS bookmarked boolean NOT NULL DEFAULT true; -- kinds: see library_kind_check above
|
||||||
-- e-book details (owner request): language, reading direction, the writer's name as printed, co-authors (names;
|
-- e-book details (owner request): language, reading direction, the writer's name as printed, co-authors (names;
|
||||||
-- coauthor_ids = those who are poets on the site, so the book is listed on their pages too). Extra tags are
|
-- coauthor_ids = those who are poets on the site, so the book is listed on their pages too). Extra tags are
|
||||||
-- entity_tags with entity 'ebook'.
|
-- entity_tags with entity 'ebook'.
|
||||||
@ -251,3 +253,9 @@ ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS coauthor_ids integer[] NOT NULL DEFA
|
|||||||
CREATE INDEX IF NOT EXISTS ebooks_coauthors ON ebooks USING gin (coauthor_ids);
|
CREATE INDEX IF NOT EXISTS ebooks_coauthors ON ebooks USING gin (coauthor_ids);
|
||||||
ALTER TABLE entity_tags DROP CONSTRAINT IF EXISTS entity_tags_entity_check;
|
ALTER TABLE entity_tags DROP CONSTRAINT IF EXISTS entity_tags_entity_check;
|
||||||
ALTER TABLE entity_tags ADD CONSTRAINT entity_tags_entity_check CHECK (entity IN ('category', 'work', 'ebook'));
|
ALTER TABLE entity_tags ADD CONSTRAINT entity_tags_entity_check CHECK (entity IN ('category', 'work', 'ebook'));
|
||||||
|
|
||||||
|
-- visitor statistics (api/src/stats.ts, #86): counts only; no cookies, no addresses. A visitor is a hash of their
|
||||||
|
-- address and browser with a salt that lives one day in memory, so it cannot be traced back or followed across days.
|
||||||
|
CREATE TABLE IF NOT EXISTS stat_views (day date NOT NULL, path text NOT NULL, views integer NOT NULL DEFAULT 0, PRIMARY KEY (day, path));
|
||||||
|
CREATE TABLE IF NOT EXISTS stat_visitors (day date NOT NULL, h text NOT NULL, PRIMARY KEY (day, h));
|
||||||
|
CREATE TABLE IF NOT EXISTS stat_referrers (day date NOT NULL, host text NOT NULL, n integer NOT NULL DEFAULT 0, PRIMARY KEY (day, host));
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
| صنف = غزل
|
| صنف = غزل
|
||||||
| ردیف = ہوتا
|
| ردیف = ہوتا
|
||||||
| ماخذ = ویکی ماخذ
|
| ماخذ = ویکی ماخذ
|
||||||
| ماخذ_ربط = https://ur.wikisource.org/wiki/%DB%8C%DB%81_%D9%86%DB%81_%D8%AA%DA%BE%DB%8C_%DB%81%D9%85%D8%A7%D8%B1%DB%8C_%D9%82%D8%B3%D9%85%D8%AA_%DA%A9%DB%81_%D9%88%D8%B5%D8%A7%D9%84_%DB%8C%D8%A7%D8%B1_%DB%81%D9%88%D8%AA%D8%A7
|
| ماخذ_لنک = https://ur.wikisource.org/wiki/%DB%8C%DB%81_%D9%86%DB%81_%D8%AA%DA%BE%DB%8C_%DB%81%D9%85%D8%A7%D8%B1%DB%8C_%D9%82%D8%B3%D9%85%D8%AA_%DA%A9%DB%81_%D9%88%D8%B5%D8%A7%D9%84_%DB%8C%D8%A7%D8%B1_%DB%81%D9%88%D8%AA%D8%A7
|
||||||
}}
|
}}
|
||||||
<poem>
|
<poem>
|
||||||
{{مطلع}} یہ نہ تھی ہماری قسمت کہ [[وصال]] یار ہوتا
|
{{مطلع}} یہ نہ تھی ہماری قسمت کہ [[وصال]] یار ہوتا
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
| مصنف = سید احمد خان
|
| مصنف = سید احمد خان
|
||||||
| صنف = مضمون
|
| صنف = مضمون
|
||||||
| ماخذ = ویکی ماخذ
|
| ماخذ = ویکی ماخذ
|
||||||
| ماخذ_ربط = https://ur.wikisource.org/wiki/%D8%A2%D8%B2%D8%A7%D8%AF%DB%8C_%D8%B1%D8%A7%D8%A6%DB%92
|
| ماخذ_لنک = https://ur.wikisource.org/wiki/%D8%A2%D8%B2%D8%A7%D8%AF%DB%8C_%D8%B1%D8%A7%D8%A6%DB%92
|
||||||
}}
|
}}
|
||||||
== آزادی رائے ==
|
== آزادی رائے ==
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
---
|
---
|
||||||
const { current } = Astro.props as { current: 'users' | 'audit' };
|
const { current } = Astro.props as { current: 'users' | 'audit' | 'stats' };
|
||||||
---
|
---
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/admin" aria-current={current === 'users' ? 'page' : undefined}>صارفین</a>
|
<a href="/admin" aria-current={current === 'users' ? 'page' : undefined}>صارفین</a>
|
||||||
<a href="/admin/audit" aria-current={current === 'audit' ? 'page' : undefined}>ریکارڈ</a>
|
<a href="/admin/audit" aria-current={current === 'audit' ? 'page' : undefined}>ریکارڈ</a>
|
||||||
|
<a href="/admin/stats" aria-current={current === 'stats' ? 'page' : undefined}>اعداد و شمار</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
36
web/src/components/History.astro
Normal file
36
web/src/components/History.astro
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
// A work's, arrangement's or tags' versions (#51): compare any two published versions, see what each one changed
|
||||||
|
// (فرق), and from there bring an old one back. Version 0 is the Wikisource text before Divan's first version.
|
||||||
|
import { STATUS, when } from '../lib/mod';
|
||||||
|
import { ud } from '../lib/urdu';
|
||||||
|
interface Props { entity: string; id: number; history: any[]; version: number; empty: string }
|
||||||
|
const { entity, id, history, version, empty } = Astro.props;
|
||||||
|
const base = `/mod/compare/${entity}/${id}`;
|
||||||
|
const versions = Array.from({ length: version + 1 }, (_, v) => v);
|
||||||
|
const label = (v: number) => (v ? `ورژن ${ud(v)}` : 'ویکی ماخذ');
|
||||||
|
---
|
||||||
|
{version > 0 && (
|
||||||
|
<form method="get" action={base} class="version-tools">
|
||||||
|
<label>ورژن <select name="a">{versions.map((v) => <option value={v} selected={v === version - 1}>{label(v)}</option>)}</select></label>
|
||||||
|
<label>اور <select name="b">{versions.map((v) => <option value={v} selected={v === version}>{label(v)}</option>)}</select></label>
|
||||||
|
<button>موازنہ کریں</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>ورژن</th><th>حالت</th><th>خلاصہ</th><th>لکھنے والے</th><th>جائزہ</th><th>اشاعت</th><th>تاریخ</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{history.map((h: any) => (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/mod/rev/${h.id}`}>{h.version ? ud(h.version) : '—'}</a></td>
|
||||||
|
<td><span class={`status s-${h.status}`}>{STATUS[h.status]}</span></td>
|
||||||
|
<td>{h.summary}</td>
|
||||||
|
<td><bdi dir="ltr">{h.author_email}</bdi></td>
|
||||||
|
<td><bdi dir="ltr">{h.reviewer_email ?? ''}</bdi></td>
|
||||||
|
<td><bdi dir="ltr">{h.publisher_email ?? ''}</bdi></td>
|
||||||
|
<td><bdi dir="ltr">{when(h.published_at ?? h.created_at)}</bdi></td>
|
||||||
|
<td>{h.version && <a href={`${base}?a=${h.version - 1}&b=${h.version}`}>فرق</a>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{history.length === 0 && <p class="muted center">{empty}</p>}
|
||||||
@ -55,9 +55,14 @@ const PATHS = {
|
|||||||
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m20 20-4.9-4.9"/>',
|
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"/>',
|
chevron: '<path d="m15 18-6-6 6-6"/>',
|
||||||
// footer
|
// footer
|
||||||
|
rss: '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/>',
|
||||||
|
home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||||
|
lock: '<rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||||
|
scale: '<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>',
|
||||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
||||||
|
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
<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]} />
|
<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]} />
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
// tabs of the reader's own space: saved texts, favourite writers, the word book (kept separate), profile
|
// tabs of the reader's own space: saved texts, favourite writers, the word book (kept separate), profile
|
||||||
const { current } = Astro.props as { current: 'bookmarks' | 'writers' | 'words' };
|
const { current } = Astro.props as { current: 'bookmarks' | 'notes' | 'writers' | 'words' };
|
||||||
---
|
---
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}><span class="bm-icon on" aria-hidden="true"></span> محفوظ شدہ</a>
|
<a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}><span class="bm-icon on" aria-hidden="true"></span> محفوظ شدہ</a>
|
||||||
|
<a href="/notes" aria-current={current === 'notes' ? 'page' : undefined}>نوٹس</a>
|
||||||
<a href="/writers" aria-current={current === 'writers' ? 'page' : undefined}>پسندیدہ لکھاری</a>
|
<a href="/writers" aria-current={current === 'writers' ? 'page' : undefined}>پسندیدہ لکھاری</a>
|
||||||
<a href="/words" aria-current={current === 'words' ? 'page' : undefined}>ذخیرۂ الفاظ</a>
|
<a href="/words" aria-current={current === 'words' ? 'page' : undefined}>ذخیرۂ الفاظ</a>
|
||||||
<a href="/account">پروفائل</a>
|
<a href="/account">پروفائل</a>
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
---
|
---
|
||||||
// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?]
|
// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?]
|
||||||
|
// a bookmarked phrase opens its note when pressed (the poem page's note box), where it can also be removed
|
||||||
const { s } = Astro.props as { s: [string, string, string?][] };
|
const { s } = Astro.props as { s: [string, string, string?][] };
|
||||||
|
const noteOf = (d?: string) => (d ? (JSON.parse(d).note as string | undefined) : undefined);
|
||||||
|
const noteOnly = (d?: string) => !!d && !!JSON.parse(d).noteOnly; // a phrase with a note but no bookmark
|
||||||
---
|
---
|
||||||
{s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span>
|
{s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span>
|
||||||
: k === 'bmk' ? <span class="bmk" data-save={d} data-confirm="یہ عبارت محفوظ شدہ سے ہٹائیں؟" title="محفوظ شدہ عبارت (ہٹانے کے لیے دبائیں)">{t}</span> : t))}
|
: k === 'bmk' ? <span class:list={['bmk', { 'has-note': noteOf(d), 'note-only': noteOnly(d) }]} data-phrase={d} role="button" tabindex="0"
|
||||||
|
title={noteOf(d) ? `نوٹ: ${noteOf(d)}` : 'محفوظ شدہ عبارت: نوٹ لکھیں یا ہٹائیں'}>{t}</span> : t))}
|
||||||
|
|||||||
14
web/src/components/TagsBox.astro
Normal file
14
web/src/components/TagsBox.astro
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
// a page's tags at the end of its content (#52): «ٹیگ» and how many, at the start of the line; opened, the tags follow it
|
||||||
|
// on the same line and wrap across the content's width. A visually hidden checkbox holds open/closed (no script needed)
|
||||||
|
import { ud } from '../lib/urdu';
|
||||||
|
const { tags } = Astro.props as { tags: { type: string; name: string }[] };
|
||||||
|
const href = (t: { type: string; name: string }) => `/tag/${encodeURIComponent(t.type)}/${encodeURIComponent(t.name)}`;
|
||||||
|
---
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div class="tags-box">
|
||||||
|
<input type="checkbox" id="tags-open" class="sr-only" />
|
||||||
|
<label for="tags-open" class="tags-toggle">ٹیگ <small class="muted">{ud(tags.length)}</small></label>
|
||||||
|
{tags.map((t) => <a class="tag-chip" href={href(t)}><small>{t.type}</small> {t.name}</a>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
@ -1,15 +1,22 @@
|
|||||||
---
|
---
|
||||||
// fonts are self-hosted (no Google Fonts request; works with Brave Shields and offline)
|
// fonts are self-hosted (no Google Fonts request; works with Brave Shields and offline)
|
||||||
import '@fontsource/noto-naskh-arabic/400.css';
|
import '@fontsource/noto-naskh-arabic/400.css';
|
||||||
|
import '@fontsource/noto-naskh-arabic/500.css';
|
||||||
|
import '@fontsource/noto-naskh-arabic/600.css';
|
||||||
import '@fontsource/noto-naskh-arabic/700.css';
|
import '@fontsource/noto-naskh-arabic/700.css';
|
||||||
import '@fontsource/noto-nastaliq-urdu/400.css';
|
import '@fontsource/noto-nastaliq-urdu/400.css';
|
||||||
import '@fontsource/noto-nastaliq-urdu/700.css';
|
import '@fontsource/noto-nastaliq-urdu/700.css';
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
interface Props { title?: string; description?: string; q?: string }
|
interface Props { title?: string; description?: string; q?: string; author?: string; jsonld?: object; ogType?: string }
|
||||||
const { title, description = 'اردو کا کلاسیکی ادب', q = '' } = Astro.props;
|
// author and jsonld describe a work for search engines and the browser's own reader view
|
||||||
const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو کا کلاسیکی ادب';
|
const { title, description = 'اردو کا کلاسیکی ادب', q = '', author, jsonld, ogType = 'website' } = Astro.props;
|
||||||
|
// one address per page for search engines: the public origin, no query (?hl=, ?font=, ?kind= are views of the same page)
|
||||||
|
const canonical = siteUrl(Astro.url) + Astro.url.pathname;
|
||||||
|
const fullTitle = title ? `${title} · ${brand.name}` : `${brand.name} · ${brand.tagline}`;
|
||||||
import Icon from '../components/Icon.astro';
|
import Icon from '../components/Icon.astro';
|
||||||
import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can turn its rays
|
import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can turn its rays
|
||||||
|
import { brand } from '../lib/brand';
|
||||||
|
import { siteUrl } from '../lib/site-url';
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ur-PK" dir="rtl">
|
<html lang="ur-PK" dir="rtl">
|
||||||
@ -18,24 +25,28 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{fullTitle}</title>
|
<title>{fullTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title={`${brand.name}: نئی تبدیلیاں`} href="/rss.xml" />
|
||||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta name="theme-color" content="#fbf6ea" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#fbf6ea" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="theme-color" content="#14120f" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#14120f" media="(prefers-color-scheme: dark)" />
|
||||||
<!-- link previews (WhatsApp, Facebook, X, Slack): the logo and headline -->
|
<!-- link previews (WhatsApp, Facebook, X, Slack): the logo and headline -->
|
||||||
<meta property="og:site_name" content="دیوان" />
|
<meta property="og:site_name" content={brand.name} />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content={ogType} />
|
||||||
<meta property="og:locale" content="ur_PK" />
|
<meta property="og:locale" content="ur_PK" />
|
||||||
<meta property="og:title" content={fullTitle} />
|
<meta property="og:title" content={fullTitle} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:url" content={new URL(Astro.url.pathname, Astro.url.origin).href} />
|
<meta property="og:url" content={canonical} />
|
||||||
<meta property="og:image" content={new URL('/og-image.png', 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:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta property="og:image:alt" content="دیوان: اردو کا کلاسیکی ادب" />
|
<meta property="og:image:alt" content={`${brand.name}: ${brand.tagline}`} />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
{author && <meta name="author" content={author} />}
|
||||||
|
{jsonld && <script type="application/ld+json" set:html={JSON.stringify(jsonld).replace(/</g, '\\u003c')} />}
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
// font, palette and light/dark applied before paint (Naskh, sepia and the system's light/dark by default)
|
// font, palette and light/dark applied before paint (Naskh, sepia and the system's light/dark by default)
|
||||||
try {
|
try {
|
||||||
@ -43,11 +54,16 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
const f = new URLSearchParams(location.search).get('font');
|
const f = new URLSearchParams(location.search).get('font');
|
||||||
if (f === 'nastaliq' || f === 'naskh') localStorage.setItem('divan-font', f);
|
if (f === 'nastaliq' || f === 'naskh') localStorage.setItem('divan-font', f);
|
||||||
if (localStorage.getItem('divan-font') === 'nastaliq') document.documentElement.classList.add('nastaliq');
|
if (localStorage.getItem('divan-font') === 'nastaliq') document.documentElement.classList.add('nastaliq');
|
||||||
|
const w = localStorage.getItem('divan-weight'); // Naskh's weight (400-700), heavier for small screens
|
||||||
|
if (w) document.documentElement.style.setProperty('--naskh-weight', w);
|
||||||
|
const z = localStorage.getItem('divan-size'); // text size in % (100 by default), every page scales with it
|
||||||
|
if (z) document.documentElement.style.setProperty('--text-size', z + '%');
|
||||||
const t = localStorage.getItem('divan-theme');
|
const t = localStorage.getItem('divan-theme');
|
||||||
if (t) document.documentElement.dataset.theme = t;
|
if (t) document.documentElement.dataset.theme = t;
|
||||||
const pal = localStorage.getItem('divan-palette'); // sepia when unset
|
const pal = localStorage.getItem('divan-palette'); // sepia when unset
|
||||||
if (pal) document.documentElement.dataset.palette = pal;
|
if (pal) document.documentElement.dataset.palette = pal;
|
||||||
if (document.cookie.includes('divan-digits=latn')) document.documentElement.dataset.digits = 'latn'; // Western numerals
|
if (document.cookie.includes('divan-digits=latn')) document.documentElement.dataset.digits = 'latn'; // Western numerals
|
||||||
|
if (localStorage.getItem('divan-reading')) document.documentElement.dataset.reading = ''; // reading mode, kept across pages
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
@ -65,10 +81,12 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<button type="button" class="icon-btn reading-exit" data-reading-toggle title="مطالعے کے انداز سے باہر" aria-label="مطالعے کے انداز سے باہر"><Icon name="close" /></button>
|
||||||
<nav class="toggles" aria-label="سائٹ">
|
<nav class="toggles" aria-label="سائٹ">
|
||||||
{Astro.locals.user?.role === 'admin' && <a class="icon-btn" href="/admin" title="ایڈمن" aria-label="ایڈمن"><Icon name="admin" /></a>}
|
{Astro.locals.user?.role === 'admin' && <a class="icon-btn" href="/admin" title="ایڈمن" aria-label="ایڈمن"><Icon name="admin" /></a>}
|
||||||
{['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="icon-btn" href="/mod" title="موڈریشن" aria-label="موڈریشن"><Icon name="edit" /></a>}
|
{['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="icon-btn" href="/mod" title="موڈریشن" aria-label="موڈریشن"><Icon name="edit" /></a>}
|
||||||
{Astro.locals.user && <a class="icon-btn" href="/library" title="محفوظ شدہ" aria-label="محفوظ شدہ"><Icon name="bookmark" /></a>}
|
{Astro.locals.user && <a class="icon-btn" href="/library" title="محفوظ شدہ" aria-label="محفوظ شدہ"><Icon name="bookmark" /></a>}
|
||||||
|
{Astro.locals.user && <a class="icon-btn" href="/notes" title="میرے نوٹس" aria-label="میرے نوٹس"><Icon name="note" /></a>}
|
||||||
{Astro.locals.user && <a class="icon-btn" href="/words" title="ذخیرۂ الفاظ" aria-label="ذخیرۂ الفاظ"><span class="glyph-icon"><Icon name="words" /><span aria-hidden="true">ع</span></span></a>}
|
{Astro.locals.user && <a class="icon-btn" href="/words" title="ذخیرۂ الفاظ" aria-label="ذخیرۂ الفاظ"><span class="glyph-icon"><Icon name="words" /><span aria-hidden="true">ع</span></span></a>}
|
||||||
{Astro.locals.user
|
{Astro.locals.user
|
||||||
? <a class="icon-btn" href="/account" title={`اکاؤنٹ: ${Astro.locals.user.full_name || Astro.locals.user.email}`} aria-label="اکاؤنٹ"><Icon name="user" /></a>
|
? <a class="icon-btn" href="/account" title={`اکاؤنٹ: ${Astro.locals.user.full_name || Astro.locals.user.email}`} aria-label="اکاؤنٹ"><Icon name="user" /></a>
|
||||||
@ -78,6 +96,10 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
<div class="display-panel">
|
<div class="display-panel">
|
||||||
<p>خط</p>
|
<p>خط</p>
|
||||||
<div class="seg"><button type="button" data-font="naskh">نسخ</button><button type="button" data-font="nastaliq" class="f-nastaliq">نستعلیق</button></div>
|
<div class="seg"><button type="button" data-font="naskh">نسخ</button><button type="button" data-font="nastaliq" class="f-nastaliq">نستعلیق</button></div>
|
||||||
|
<label class="weight"><span>نسخ کی موٹائی</span>
|
||||||
|
<input type="range" min="400" max="700" step="100" value="400" data-weight aria-label="نسخ کی موٹائی" /></label>
|
||||||
|
<label class="weight"><span>حروف کا سائز</span>
|
||||||
|
<input type="range" min="90" max="150" step="10" value="100" data-size aria-label="حروف کا سائز" /></label>
|
||||||
<p>رنگ</p>
|
<p>رنگ</p>
|
||||||
<div class="swatches">
|
<div class="swatches">
|
||||||
{[['', 'سپیا', '#fbf6ea', '#bf3f2a'], ['teal', 'فیروزی', '#e2f0ee', '#0b4f6c'], ['lapis', 'نیلگوں', '#e6ebf8', '#1d3b8b'],
|
{[['', 'سپیا', '#fbf6ea', '#bf3f2a'], ['teal', 'فیروزی', '#e2f0ee', '#0b4f6c'], ['lapis', 'نیلگوں', '#e6ebf8', '#1d3b8b'],
|
||||||
@ -110,6 +132,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
|
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
|
||||||
<button type="button" data-act="saveword" class="plus" title="ذخیرۂ الفاظ میں ڈالیں">ذخیرۂ الفاظ</button>
|
<button type="button" data-act="saveword" class="plus" title="ذخیرۂ الفاظ میں ڈالیں">ذخیرۂ الفاظ</button>
|
||||||
<button type="button" data-act="bookmark" title="یہ عبارت محفوظ کریں">محفوظ کریں</button>
|
<button type="button" data-act="bookmark" title="یہ عبارت محفوظ کریں">محفوظ کریں</button>
|
||||||
|
<button type="button" data-act="note" title="اس عبارت پر نوٹ لکھیں">نوٹ</button>
|
||||||
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
|
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
|
||||||
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
|
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
|
||||||
<button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button>
|
<button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button>
|
||||||
@ -121,11 +144,28 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
</aside>
|
</aside>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<a class="footer-logo" href="/" title="سرورق"><img src="/logo/turanj.svg" width="56" height="56" alt="دیوان" /></a>
|
<div class="footer-brand">
|
||||||
|
<a class="footer-logo" href="/" title="سرورق"><img src="/logo/turanj.svg" width="64" height="64" alt={brand.name} /></a>
|
||||||
|
<div>
|
||||||
|
<p class="footer-tagline">{brand.tagline}</p>
|
||||||
|
</div>
|
||||||
|
<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" />
|
||||||
|
<circle cx="19.2" cy="10" r="6" fill="#fff" /><circle cx="20.6" cy="8.7" r="5.4" fill="#01411c" />
|
||||||
|
<polygon fill="#fff" points="23.65,5.99 23.12,7.39 24.25,8.39 22.75,8.32 22.15,9.69 21.75,8.25 20.26,8.11 21.51,7.28 21.19,5.82 22.36,6.75" />
|
||||||
|
</svg>
|
||||||
|
{brand.madeBy}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<nav class="footer-links" aria-label="سائٹ کے بارے میں">
|
<nav class="footer-links" aria-label="سائٹ کے بارے میں">
|
||||||
<div><h2>دیوان</h2><ul>
|
<div><h2>{brand.name}</h2><ul>
|
||||||
<li><a href="/about"><Icon name="info" size={18} /><span>دیوان کے بارے میں<small>مقصد اور طریقۂ کار</small></span></a></li>
|
<li><a href="/about"><Icon name="info" size={18} /><span>{brand.name} کے بارے میں<small>مقصد اور طریقۂ کار</small></span></a></li>
|
||||||
|
<li><a href="/help"><Icon name="help" size={18} /><span>استعمال کا طریقہ<small>سائٹ کی سہولتیں</small></span></a></li>
|
||||||
<li><a href="/tags"><Icon name="tag" size={18} /><span>ٹیگ<small>موضوعات سے کلام</small></span></a></li>
|
<li><a href="/tags"><Icon name="tag" size={18} /><span>ٹیگ<small>موضوعات سے کلام</small></span></a></li>
|
||||||
|
<li><a href="/rss.xml"><Icon name="rss" size={18} /><span>نئی تبدیلیاں<small>RSS: نئے ورژن اور ای بکس</small></span></a></li>
|
||||||
|
<li><a href="/privacy"><Icon name="lock" size={18} /><span>رازداری<small>ہم کیا محفوظ رکھتے ہیں</small></span></a></li>
|
||||||
|
<li><a href="/terms"><Icon name="scale" size={18} /><span>استعمال کی شرائط<small>لائسنس اور ذمہ داریاں</small></span></a></li>
|
||||||
</ul></div>
|
</ul></div>
|
||||||
<div><h2>ماخذ</h2><ul>
|
<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.wikisource.org" rel="noopener"><Icon name="dict" size={18} /><span>ویکی ماخذ<small>متن · عوامی ملکیت</small></span><Icon name="external" size={13} class="ext" /></a></li>
|
||||||
@ -136,13 +176,10 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
<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>
|
<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>
|
</ul></div>
|
||||||
</nav>
|
</nav>
|
||||||
<p class="made-in">
|
<p class="footer-bar">
|
||||||
<svg class="flag" viewBox="0 0 30 20" width="27" height="18" role="img" aria-label="پاکستان کا پرچم">
|
<span>متن: عوامی ملکیت · کوڈ: <span lang="en" dir="ltr">GNU GPL v3</span></span>
|
||||||
<rect width="30" height="20" fill="#fff" /><rect x="7.5" width="22.5" height="20" fill="#01411c" />
|
<a href={brand.contact.url} rel="noopener">{brand.contact.text}</a>
|
||||||
<circle cx="19.2" cy="10" r="6" fill="#fff" /><circle cx="20.6" cy="8.7" r="5.4" fill="#01411c" />
|
<a href={brand.basedOn.url} rel="noopener" lang="en" dir="ltr">{brand.basedOn.text}</a>
|
||||||
<polygon fill="#fff" points="23.65,5.99 23.12,7.39 24.25,8.39 22.75,8.32 22.15,9.69 21.75,8.25 20.26,8.11 21.51,7.28 21.19,5.82 22.36,6.75" />
|
|
||||||
</svg>
|
|
||||||
ایک پاکستانی کی طرف سے، پاکستان کے لیے
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@ -154,13 +191,30 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
const marks = () => {
|
const marks = () => {
|
||||||
const on = { font: root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh', palette: root.dataset.palette ?? '', theme: root.dataset.theme ?? '' };
|
const on = { font: root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh', palette: root.dataset.palette ?? '', theme: root.dataset.theme ?? '' };
|
||||||
panel.querySelectorAll('[data-font]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.font === on.font)));
|
panel.querySelectorAll('[data-font]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.font === on.font)));
|
||||||
|
weight.value = root.style.getPropertyValue('--naskh-weight') || '400';
|
||||||
|
weight.disabled = on.font === 'nastaliq'; // Nastaliq comes in regular and bold only
|
||||||
|
size.value = parseInt(root.style.getPropertyValue('--text-size')) || '100';
|
||||||
panel.querySelectorAll('[data-palette]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.palette === on.palette)));
|
panel.querySelectorAll('[data-palette]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.palette === on.palette)));
|
||||||
panel.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === on.theme)));
|
panel.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === on.theme)));
|
||||||
panel.querySelectorAll('[data-digits]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.digits === (root.dataset.digits ?? ''))));
|
panel.querySelectorAll('[data-digits]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.digits === (root.dataset.digits ?? ''))));
|
||||||
};
|
};
|
||||||
|
const weight = panel.querySelector('[data-weight]');
|
||||||
|
weight.addEventListener('input', () => {
|
||||||
|
if (weight.value === '400') root.style.removeProperty('--naskh-weight'); else root.style.setProperty('--naskh-weight', weight.value);
|
||||||
|
store('divan-weight', weight.value === '400' ? '' : weight.value);
|
||||||
|
});
|
||||||
|
const size = panel.querySelector('[data-size]');
|
||||||
|
size.addEventListener('input', () => {
|
||||||
|
if (size.value === '100') root.style.removeProperty('--text-size'); else root.style.setProperty('--text-size', size.value + '%');
|
||||||
|
store('divan-size', size.value === '100' ? '' : size.value);
|
||||||
|
});
|
||||||
panel.addEventListener('click', (e) => {
|
panel.addEventListener('click', (e) => {
|
||||||
const b = e.target.closest('button'); if (!b) return;
|
const b = e.target.closest('button'); if (!b) return;
|
||||||
if (b.dataset.font) { root.classList.toggle('nastaliq', b.dataset.font === 'nastaliq'); store('divan-font', b.dataset.font); }
|
if (b.dataset.font) {
|
||||||
|
// switching to Nastaliq: a short note that it is for the text (the interface stays Naskh, by design)
|
||||||
|
if (b.dataset.font === 'nastaliq' && !root.classList.contains('nastaliq')) toast('نستعلیق صرف کلام اور متن پر؛ باقی سائٹ نسخ میں رہتی ہے', 'info');
|
||||||
|
root.classList.toggle('nastaliq', b.dataset.font === 'nastaliq'); store('divan-font', b.dataset.font);
|
||||||
|
}
|
||||||
if (b.dataset.palette != null) { if (b.dataset.palette) root.dataset.palette = b.dataset.palette; else delete root.dataset.palette; store('divan-palette', b.dataset.palette); }
|
if (b.dataset.palette != null) { if (b.dataset.palette) root.dataset.palette = b.dataset.palette; else delete root.dataset.palette; store('divan-palette', b.dataset.palette); }
|
||||||
if (b.dataset.digits != null) {
|
if (b.dataset.digits != null) {
|
||||||
// numerals: a cookie, so the server sends pages with them; switching back to Eastern reloads the page
|
// numerals: a cookie, so the server sends pages with them; switching back to Eastern reloads the page
|
||||||
@ -195,6 +249,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
',':'،', '.':'۔', '?':'؟', ';':'؛',
|
',':'،', '.':'۔', '?':'؟', ';':'؛',
|
||||||
};
|
};
|
||||||
const q = document.getElementById('q'), kb = document.getElementById('kb'), kbBtn = document.getElementById('kb-toggle'), romBtn = document.getElementById('rom-toggle');
|
const q = document.getElementById('q'), kb = document.getElementById('kb'), kbBtn = document.getElementById('kb-toggle'), romBtn = document.getElementById('rom-toggle');
|
||||||
|
const kbHome = [kb.parentElement, kb.nextSibling]; // the keyboard's place in the header
|
||||||
let shift = false;
|
let shift = false;
|
||||||
const key = (ch) => ch ? `<button type="button" data-ch="${ch}">${ch}</button>` : '';
|
const key = (ch) => ch ? `<button type="button" data-ch="${ch}">${ch}</button>` : '';
|
||||||
const render = () => {
|
const render = () => {
|
||||||
@ -202,7 +257,8 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
kb.innerHTML = `<div>${L[0].map((c) => key(c)).join('')}</div><div>${L[1].map((c) => key(c)).join('')}</div>` +
|
kb.innerHTML = `<div>${L[0].map((c) => key(c)).join('')}</div><div>${L[1].map((c) => key(c)).join('')}</div>` +
|
||||||
`<div><button type="button" data-act="shift" class="fn${shift ? ' on' : ''}" aria-label="شفٹ">⇧</button>${L[2].map((c) => key(c)).join('')}` +
|
`<div><button type="button" data-act="shift" class="fn${shift ? ' on' : ''}" aria-label="شفٹ">⇧</button>${L[2].map((c) => key(c)).join('')}` +
|
||||||
'<button type="button" data-act="bs" class="fn" aria-label="مٹائیں">⌫</button></div>' +
|
'<button type="button" data-act="bs" class="fn" aria-label="مٹائیں">⌫</button></div>' +
|
||||||
`<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}</div>`;
|
`<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}` +
|
||||||
|
'<button type="button" data-act="close" class="fn" aria-label="کی بورڈ بند کریں" title="بند کریں">✕</button></div>';
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
// the Urdu keyboard and Roman typing work on the search box and on any input marked data-urdu
|
// the Urdu keyboard and Roman typing work on the search box and on any input marked data-urdu
|
||||||
@ -223,6 +279,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
kb.onclick = (ev) => {
|
kb.onclick = (ev) => {
|
||||||
const b = ev.target.closest('button'); if (!b) return;
|
const b = ev.target.closest('button'); if (!b) return;
|
||||||
if (b.dataset.act === 'shift') { shift = !shift; render(); return; }
|
if (b.dataset.act === 'shift') { shift = !shift; render(); return; }
|
||||||
|
if (b.dataset.act === 'close') { closeKb(); return; }
|
||||||
if (b.dataset.act === 'bs') {
|
if (b.dataset.act === 'bs') {
|
||||||
live();
|
live();
|
||||||
if (target.isContentEditable) { target.focus(); document.execCommand('delete'); return; }
|
if (target.isContentEditable) { target.focus(); document.execCommand('delete'); return; }
|
||||||
@ -235,15 +292,60 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
};
|
};
|
||||||
kbBtn.onclick = () => {
|
kbBtn.onclick = () => {
|
||||||
kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); target.focus();
|
kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); target.focus();
|
||||||
|
if (kb.hidden) home(); else place();
|
||||||
};
|
};
|
||||||
|
// back to its place in the header (not floating)
|
||||||
|
const home = () => { kb.classList.remove('kb-float'); document.body.style.paddingBottom = ''; if (kb.parentElement !== kbHome[0]) kbHome[0].insertBefore(kb, kbHome[1]); };
|
||||||
|
const closeKb = () => {
|
||||||
|
kb.hidden = true; kbBtn.setAttribute('aria-pressed', 'false'); home();
|
||||||
|
document.querySelectorAll('[data-urdu-kb][aria-pressed=true]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
|
||||||
|
};
|
||||||
|
// For the content editor the keyboard is docked at the bottom of the window (like a phone's) and the page follows
|
||||||
|
// the writing cursor: the line being written is kept in view just above the keyboard, never under it. The page
|
||||||
|
// gets room at the bottom while it is open, so the last lines can come up above it.
|
||||||
|
const caret = () => {
|
||||||
|
const sel = getSelection();
|
||||||
|
if (sel?.rangeCount && target.contains(sel.anchorNode)) {
|
||||||
|
const r = sel.getRangeAt(0), rect = r.getClientRects()[0] ?? r.getBoundingClientRect();
|
||||||
|
if (rect && (rect.width || rect.height)) return rect;
|
||||||
|
const el = sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement;
|
||||||
|
if (el) return el.getBoundingClientRect(); // an empty line has no caret box of its own
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let placing = 0;
|
||||||
|
function place() {
|
||||||
|
if (kb.hidden || !kb.classList.contains('kb-float')) return;
|
||||||
|
cancelAnimationFrame(placing);
|
||||||
|
placing = requestAnimationFrame(() => {
|
||||||
|
document.body.style.paddingBottom = `${kb.offsetHeight + 24}px`;
|
||||||
|
const c = caret(); if (!c) return;
|
||||||
|
const room = kb.getBoundingClientRect().top - 16, top = 90; // keep the line between the header area and the keyboard
|
||||||
|
if (c.bottom > room) scrollBy({ top: c.bottom - room + 40, behavior: 'smooth' });
|
||||||
|
else if (c.top < top) scrollBy({ top: c.top - top - 40, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('selectionchange', place);
|
||||||
|
addEventListener('resize', place);
|
||||||
romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); target.focus(); };
|
romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); target.focus(); };
|
||||||
// other inputs can open the same keyboard / Roman typing with their own buttons
|
// other inputs can open the same keyboard / Roman typing with their own buttons
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return;
|
const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return;
|
||||||
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
|
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
|
||||||
|
// a field's own keyboard opens right under that field (in view while typing, also in a dialog), and goes back to
|
||||||
|
// the header when closed; open under another field, it moves here
|
||||||
|
if (b.dataset.urduKb && target.isContentEditable) { kb.classList.add('kb-float'); document.body.append(kb); } // the editor: floating
|
||||||
|
else if (b.dataset.urduKb) {
|
||||||
|
const at = target.closest('.in-box-field') ?? target;
|
||||||
|
if (!kb.hidden && kb.previousElementSibling !== at) closeKb();
|
||||||
|
kb.classList.remove('kb-float'); at.after(kb);
|
||||||
|
}
|
||||||
(b.dataset.urduKb ? kbBtn : romBtn).click();
|
(b.dataset.urduKb ? kbBtn : romBtn).click();
|
||||||
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
|
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
|
||||||
});
|
});
|
||||||
|
document.addEventListener('close', (ev) => { // a dialog closing takes nothing of the header's with it
|
||||||
|
if (ev.target.contains?.(kb)) closeKb();
|
||||||
|
}, true);
|
||||||
document.addEventListener('keydown', (ev) => {
|
document.addEventListener('keydown', (ev) => {
|
||||||
if (!ev.target.matches?.('[data-urdu]')) return;
|
if (!ev.target.matches?.('[data-urdu]')) return;
|
||||||
if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
||||||
@ -277,7 +379,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
document.addEventListener('click', async (e) => {
|
document.addEventListener('click', async (e) => {
|
||||||
const b = e.target.closest('[data-save]'); if (!b) return;
|
const b = e.target.closest('[data-save]'); if (!b) return;
|
||||||
if (getSelection()?.toString().trim()) return; // selecting text, not pressing
|
if (getSelection()?.toString().trim()) return; // selecting text, not pressing
|
||||||
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
|
if (b.dataset.confirm && !(await ask(b.dataset.confirm, { danger: true, ok: 'ہٹائیں' }))) return;
|
||||||
const item = JSON.parse(b.dataset.save), d = await save(item);
|
const item = JSON.parse(b.dataset.save), d = await save(item);
|
||||||
if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page
|
if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page
|
||||||
else if (d) {
|
else if (d) {
|
||||||
@ -441,7 +543,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
const block = sel.anchorNode.parentElement?.closest('[data-c]');
|
const block = sel.anchorNode.parentElement?.closest('[data-c]');
|
||||||
source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.c) } : {};
|
source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.c) } : {};
|
||||||
menu.querySelector('[data-act=saveword]').hidden = !w;
|
menu.querySelector('[data-act=saveword]').hidden = !w;
|
||||||
menu.querySelector('[data-act=bookmark]').hidden = !(source.poemId && Number.isInteger(source.couplet));
|
menu.querySelector('[data-act=bookmark]').hidden = menu.querySelector('[data-act=note]').hidden = !(source.poemId && Number.isInteger(source.couplet));
|
||||||
menu.querySelector('[data-act=meaning]').hidden = !w;
|
menu.querySelector('[data-act=meaning]').hidden = !w;
|
||||||
menu.hidden = false;
|
menu.hidden = false;
|
||||||
// below the selection, centred, kept inside the viewport (absolute: moves with the page)
|
// below the selection, centred, kept inside the viewport (absolute: moves with the page)
|
||||||
@ -464,6 +566,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
case 'meaning': showWord(oneWord(selText)); break;
|
case 'meaning': showWord(oneWord(selText)); break;
|
||||||
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
|
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
|
||||||
.then((d) => d && (d.saved ? location.reload() : flash(b, 'پہلے سے محفوظ تھی، ہٹا دی'))); break;
|
.then((d) => d && (d.saved ? location.reload() : flash(b, 'پہلے سے محفوظ تھی، ہٹا دی'))); break;
|
||||||
|
case 'note': hideMenu(); document.dispatchEvent(new CustomEvent('divan:note', { detail: { couplet: source.couplet, phrase: selText } })); break; // the poem page opens its note box
|
||||||
case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'ذخیرۂ الفاظ میں ڈال دیا' : 'ذخیرۂ الفاظ سے نکال دیا')); break;
|
case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'ذخیرۂ الفاظ میں ڈال دیا' : 'ذخیرۂ الفاظ سے نکال دیا')); break;
|
||||||
case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
|
case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
|
||||||
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
|
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
|
||||||
@ -485,5 +588,75 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
<!-- shared UI: the confirm dialog (instead of the browser's English confirm), toasts, busy buttons -->
|
||||||
|
<dialog class="ask" id="ask" aria-labelledby="ask-msg">
|
||||||
|
<form method="dialog">
|
||||||
|
<p id="ask-msg"></p>
|
||||||
|
<div class="ask-actions"><button class="btn primary" value="ok">ہاں</button><button class="btn" value="">نہیں</button></div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<div class="toasts" aria-live="polite"></div>
|
||||||
|
<script is:inline>
|
||||||
|
// the sticky header: its height for anchors and toolbars (--header-h), and a shadow once the page is scrolled
|
||||||
|
(() => {
|
||||||
|
const root = document.documentElement, header = document.querySelector('.site-header');
|
||||||
|
new ResizeObserver(() => root.style.setProperty('--header-h', header.offsetHeight + 'px')).observe(header);
|
||||||
|
// phones: the header steps aside while scrolling down and returns on the way up (not while it is in use)
|
||||||
|
const phone = matchMedia('(max-width: 640px)');
|
||||||
|
let last = scrollY;
|
||||||
|
const mark = () => {
|
||||||
|
scrollY > 8 ? (root.dataset.scrolled = '') : delete root.dataset.scrolled;
|
||||||
|
const busy = header.contains(document.activeElement) || header.querySelector('details[open]') || !document.getElementById('kb').hidden;
|
||||||
|
if (!phone.matches || busy || scrollY < header.offsetHeight) delete root.dataset.headerHidden;
|
||||||
|
else if (scrollY > last + 6) root.dataset.headerHidden = '';
|
||||||
|
else if (scrollY < last - 6) delete root.dataset.headerHidden;
|
||||||
|
if (Math.abs(scrollY - last) > 6) last = scrollY;
|
||||||
|
};
|
||||||
|
addEventListener('scroll', mark, { passive: true }); mark();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script is:inline>
|
||||||
|
// reading mode: only the text, the breadcrumbs and the logo; remembered across pages until switched off
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('[data-reading-toggle]')) return;
|
||||||
|
const root = document.documentElement, on = !('reading' in root.dataset);
|
||||||
|
const flip = () => { on ? (root.dataset.reading = '') : delete root.dataset.reading; };
|
||||||
|
document.startViewTransition ? document.startViewTransition(flip) : flip();
|
||||||
|
try { on ? localStorage.setItem('divan-reading', '1') : localStorage.removeItem('divan-reading'); } catch (err) {}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script is:inline>
|
||||||
|
(() => {
|
||||||
|
const dlg = document.getElementById('ask'), ok = dlg.querySelector('[value=ok]');
|
||||||
|
// ask(message, {danger, ok}) -> Promise<boolean>; the destructive answer is red and named for what it does
|
||||||
|
window.ask = (message, o = {}) => new Promise((done) => {
|
||||||
|
dlg.querySelector('#ask-msg').textContent = message;
|
||||||
|
ok.textContent = o.ok || 'ہاں'; ok.classList.toggle('danger', !!o.danger); ok.classList.toggle('primary', !o.danger);
|
||||||
|
dlg.returnValue = ''; dlg.showModal(); dlg.querySelector('[value=""]').focus();
|
||||||
|
dlg.addEventListener('close', () => done(dlg.returnValue === 'ok'), { once: true });
|
||||||
|
});
|
||||||
|
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); }); // a click outside the box is "no"
|
||||||
|
// toast(message, kind): slides in, leaves by itself
|
||||||
|
const box = document.querySelector('.toasts');
|
||||||
|
window.toast = (message, kind = 'ok') => {
|
||||||
|
const t = document.createElement('p'); t.className = 'toast ' + kind; t.textContent = message; box.append(t);
|
||||||
|
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 350); }, 4200); // removed on a timer: with reduced motion there is no animation to end
|
||||||
|
};
|
||||||
|
// a page's "done" line becomes a toast
|
||||||
|
document.querySelectorAll('.form-done').forEach((p) => { if (p.textContent.trim()) { toast(p.textContent.trim()); p.hidden = true; } });
|
||||||
|
// forms: data-confirm asks first; a submitting button shows it is busy
|
||||||
|
document.addEventListener('submit', async (e) => {
|
||||||
|
const f = e.target; if (f.method === 'dialog') return;
|
||||||
|
const btn = e.submitter;
|
||||||
|
if (f.dataset.confirm && !f.dataset.asked) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!(await ask(f.dataset.confirm, { danger: 'danger' in f.dataset, ok: f.dataset.ok }))) return;
|
||||||
|
f.dataset.asked = '1'; f.requestSubmit(btn); delete f.dataset.asked; return;
|
||||||
|
}
|
||||||
|
if (btn && !e.defaultPrevented && !f.hasAttribute('data-js')) btn.setAttribute('aria-busy', 'true');
|
||||||
|
});
|
||||||
|
addEventListener('pageshow', () => document.querySelectorAll('[aria-busy=true]').forEach((b) => b.removeAttribute('aria-busy'))); // back button
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export async function auth(path: string, opts: { token?: string; body?: object;
|
|||||||
...(opts.body && { 'content-type': 'application/json' }),
|
...(opts.body && { 'content-type': 'application/json' }),
|
||||||
...(opts.token && { authorization: `Bearer ${opts.token}` }),
|
...(opts.token && { authorization: `Bearer ${opts.token}` }),
|
||||||
...(opts.ip && { 'x-client-ip': opts.ip }),
|
...(opts.ip && { 'x-client-ip': opts.ip }),
|
||||||
|
...(process.env.DIVAN_SITE_KEY && { 'x-site-key': process.env.DIVAN_SITE_KEY }), // lets the API trust x-client-ip
|
||||||
},
|
},
|
||||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
11
web/src/lib/brand.ts
Normal file
11
web/src/lib/brand.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// The site's name and taglines in one place. They are Divan's brand (TRADEMARKS.md): a fork that runs its own
|
||||||
|
// public site sets its own here, replaces the logo files, and keeps the attribution required by NOTICE.
|
||||||
|
export const brand = {
|
||||||
|
name: 'دیوان',
|
||||||
|
tagline: 'اردو کا کلاسیکی ادب',
|
||||||
|
madeBy: 'ایک پاکستانی کی طرف سے، پاکستان کے لیے', // footer, beside Pakistan's flag
|
||||||
|
// where readers write: questions, corrections, copyright claims (privacy and terms pages)
|
||||||
|
contact: { text: 'ہم سے رابطہ کریں', url: 'https://git.anasrashid.net/anas/divan/issues' },
|
||||||
|
// the attribution forks keep (NOTICE, GPL v3 section 7(b))
|
||||||
|
basedOn: { text: 'Divan by Muhammad Anas Rashid', url: 'https://git.anasrashid.net/anas/divan' },
|
||||||
|
};
|
||||||
@ -4,6 +4,6 @@ export const STATUS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
export const EVENT: Record<string, string> = {
|
export const EVENT: Record<string, string> = {
|
||||||
created: 'مسودہ بنایا', saved: 'محفوظ کیا', submitted: 'جائزے کے لیے بھیجا', approved: 'منظور کیا', returned: 'واپس بھیجا',
|
created: 'مسودہ بنایا', saved: 'محفوظ کیا', submitted: 'جائزے کے لیے بھیجا', approved: 'منظور کیا', returned: 'واپس بھیجا',
|
||||||
rejected: 'مسترد کیا', published: 'شائع کیا',
|
rejected: 'مسترد کیا', published: 'شائع کیا', reverted: 'پرانا ورژن واپس لایا',
|
||||||
};
|
};
|
||||||
export const when = (d: string) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
|
export const when = (d: string) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
|
||||||
|
|||||||
4
web/src/lib/site-url.ts
Normal file
4
web/src/lib/site-url.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// The site's public address for links in feeds and the sitemap: SITE_URL if set (e.g. https://divan.pk), else the
|
||||||
|
// address the request came to (behind Caddy that is the public one).
|
||||||
|
export const siteUrl = (url: URL) => (process.env.SITE_URL ?? url.origin).replace(/\/$/, '');
|
||||||
|
export const xml = (s: unknown) => String(s ?? '').replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]!);
|
||||||
@ -1,9 +1,24 @@
|
|||||||
// The signed-in reader (or null) for every page, from the session cookie; and the reader's numerals: pages are
|
// The signed-in reader (or null) for every page, from the session cookie; and the reader's numerals: pages are
|
||||||
// written with Eastern Arabic (Urdu) digits, and a reader who chose Western digits (cookie divan-digits=latn, set in
|
// written with Eastern Arabic (Urdu) digits, and a reader who chose Western digits (cookie divan-digits=latn, set in
|
||||||
// the display settings) gets them in the page as sent, so nothing flashes
|
// the display settings) gets them in the page as sent, so nothing flashes. Errors are logged as one JSON line each.
|
||||||
import { defineMiddleware } from 'astro:middleware';
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
import { auth, COOKIE } from './lib/auth';
|
import { auth, COOKIE } from './lib/auth';
|
||||||
|
|
||||||
|
// visitor statistics (#86): each reading page shown is reported to the API without waiting; the API keeps only counts
|
||||||
|
// and a one-day hash. Bots, link previews, private pages (accounts, moderation) and errors are not counted.
|
||||||
|
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
|
||||||
|
const PRIVATE = /^\/(api|mod|admin|account|library|notes|words|writers|signin|signup|_astro|ebooks\/file|health)(\/|$)/;
|
||||||
|
const BOT = /bot|crawl|spider|slurp|preview|fetch|curl|wget|python|java\/|go-http|headless|lighthouse|monitor/i;
|
||||||
|
function count(ctx: any, res: Response) {
|
||||||
|
const ua = ctx.request.headers.get('user-agent') ?? '', path = ctx.url.pathname;
|
||||||
|
if (ctx.request.method !== 'GET' || res.status !== 200 || !res.headers.get('content-type')?.includes('text/html') || !ua || BOT.test(ua) || PRIVATE.test(path)) return;
|
||||||
|
let ip = '', ref = '';
|
||||||
|
try { ip = ctx.clientAddress; } catch {}
|
||||||
|
try { const h = new URL(ctx.request.headers.get('referer') ?? '').hostname; if (h !== ctx.url.hostname) ref = h.replace(/^www\./, ''); } catch {}
|
||||||
|
fetch(`${API}/api/stats/hit`, { method: 'POST', headers: { 'content-type': 'application/json', ...(process.env.DIVAN_SITE_KEY && { 'x-site-key': process.env.DIVAN_SITE_KEY }) },
|
||||||
|
body: JSON.stringify({ path, ref, ip, ua }) }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export const onRequest = defineMiddleware(async (ctx, next) => {
|
export const onRequest = defineMiddleware(async (ctx, next) => {
|
||||||
const token = ctx.cookies.get(COOKIE)?.value;
|
const token = ctx.cookies.get(COOKIE)?.value;
|
||||||
ctx.locals.user = null;
|
ctx.locals.user = null;
|
||||||
@ -12,7 +27,16 @@ export const onRequest = defineMiddleware(async (ctx, next) => {
|
|||||||
if (r.ok) ctx.locals.user = r.data.user;
|
if (r.ok) ctx.locals.user = r.data.user;
|
||||||
else if (r.status === 401) ctx.cookies.delete(COOKIE, { path: '/' }); // expired or signed out elsewhere
|
else if (r.status === 401) ctx.cookies.delete(COOKIE, { path: '/' }); // expired or signed out elsewhere
|
||||||
}
|
}
|
||||||
const res = await next();
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await next();
|
||||||
|
} catch (err: any) {
|
||||||
|
// one JSON line per error (the server's error report reads these); the reader sees the Urdu error page (500.astro)
|
||||||
|
console.error(JSON.stringify({ level: 'error', time: new Date().toISOString(), method: ctx.request.method, path: ctx.url.pathname,
|
||||||
|
msg: String(err?.message ?? err), stack: String(err?.stack ?? '').split('\n').slice(0, 8).join('\n') }));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
count(ctx, res);
|
||||||
if (ctx.cookies.get('divan-digits')?.value !== 'latn' || !res.headers.get('content-type')?.includes('text/html')) return res;
|
if (ctx.cookies.get('divan-digits')?.value !== 'latn' || !res.headers.get('content-type')?.includes('text/html')) return res;
|
||||||
// text only: scripts and styles are left alone (the page's own scripts look for Eastern digits)
|
// text only: scripts and styles are left alone (the page's own scripts look for Eastern digits)
|
||||||
const html = (await res.text()).split(/(<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>)/)
|
const html = (await res.text()).split(/(<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>)/)
|
||||||
|
|||||||
18
web/src/pages/404.astro
Normal file
18
web/src/pages/404.astro
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
// صفحہ نہیں ملا: any address that is not a page, and pages that answer "not found" (a missing poet, work or book)
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import Icon from '../components/Icon.astro';
|
||||||
|
Astro.response.status = 404;
|
||||||
|
---
|
||||||
|
<Base title="صفحہ نہیں ملا">
|
||||||
|
<section class="error-page">
|
||||||
|
<p class="error-code" aria-hidden="true">۴۰۴</p>
|
||||||
|
<h1>یہ صفحہ نہیں ملا</h1>
|
||||||
|
<p class="muted">ہو سکتا ہے پتہ بدل گیا ہو یا اس میں کوئی غلطی ہو۔ اوپر کے خانے میں تلاش کیجیے، یا یہاں سے شروع کیجیے:</p>
|
||||||
|
<p class="error-links">
|
||||||
|
<a class="btn primary" href="/"><Icon name="home" size={18} /> سرورق</a>
|
||||||
|
<a class="btn" href="/search"><Icon name="search" size={18} /> تلاش</a>
|
||||||
|
<a class="btn" href="/tags"><Icon name="tag" size={18} /> ٹیگ</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
16
web/src/pages/500.astro
Normal file
16
web/src/pages/500.astro
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
// کچھ غلط ہو گیا: an error in the site; nothing internal is shown (the error is in the server's log, see /health)
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import Icon from '../components/Icon.astro';
|
||||||
|
---
|
||||||
|
<Base title="کچھ غلط ہو گیا">
|
||||||
|
<section class="error-page">
|
||||||
|
<p class="error-code" aria-hidden="true">!</p>
|
||||||
|
<h1>معذرت، کچھ غلط ہو گیا</h1>
|
||||||
|
<p class="muted">یہ ہماری طرف کی خرابی ہے اور درج ہو گئی ہے۔ تھوڑی دیر بعد صفحہ دوبارہ کھولیے۔</p>
|
||||||
|
<p class="error-links">
|
||||||
|
<a class="btn primary" href="javascript:location.reload()"><Icon name="undo" size={18} /> دوبارہ کوشش کریں</a>
|
||||||
|
<a class="btn" href="/"><Icon name="home" size={18} /> سرورق</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
@ -53,15 +53,21 @@ const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
|
|||||||
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
|
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
|
||||||
const categoryId = page.type === 'category' ? page.category.id : undefined;
|
const categoryId = page.type === 'category' ? page.category.id : undefined;
|
||||||
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data
|
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data
|
||||||
: { poet: false, category: false, poem: false, couplets: [], phrases: [] };
|
: { poet: false, category: false, poem: false, couplets: [], phrases: [], notes: [] };
|
||||||
|
// the reader's notes on this work: on a couplet (phrase null) or on a bookmarked phrase in it
|
||||||
|
const notes: { couplet: number; phrase: string | null; note: string }[] = saved.notes ?? [];
|
||||||
|
const noteOf = (c: number) => notes.find((n) => n.couplet === c && !n.phrase)?.note;
|
||||||
// on contents pages: which works are bookmarked or hold bookmarks, and which books/chapters do
|
// on contents pages: which works are bookmarked or hold bookmarks, and which books/chapters do
|
||||||
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
|
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
|
||||||
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
|
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
|
||||||
// the marked part of each line of a block, for its bookmarked phrases
|
// the marked part of each line of a block, for its bookmarked phrases
|
||||||
const lineMarks = (c: number, lines: string[]) => {
|
const lineMarks = (c: number, lines: string[]) => {
|
||||||
const out: Mark[][] = lines.map(() => []);
|
const out: Mark[][] = lines.map(() => []);
|
||||||
for (const p of saved.phrases.filter((x: any) => x.couplet === c)) {
|
const noteOnly = notes.filter((n) => n.couplet === c && n.phrase && !saved.phrases.some((p: any) => p.couplet === c && p.phrase === n.phrase))
|
||||||
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase });
|
.map((n) => ({ couplet: c, phrase: n.phrase, noteOnly: true }));
|
||||||
|
for (const p of [...saved.phrases.filter((x: any) => x.couplet === c), ...noteOnly]) {
|
||||||
|
const note = notes.find((n) => n.couplet === c && n.phrase === p.phrase)?.note;
|
||||||
|
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase, note, noteOnly: p.noteOnly });
|
||||||
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
|
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@ -70,7 +76,8 @@ const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'
|
|||||||
// moderators who may edit this work get edit and history links
|
// moderators who may edit this work get edit and history links
|
||||||
const modCan = token && poemId && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
|
const modCan = token && poemId && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
|
||||||
? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false };
|
? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false };
|
||||||
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41)
|
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41); with the edit permission
|
||||||
|
// on poets or books, "تفصیل" (a poet's name, years, intro) or "عنوان" (a book or section's title) (#32)
|
||||||
const catCan = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
|
const catCan = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
|
||||||
? (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data : {};
|
? (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data : {};
|
||||||
const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : catCan.tags;
|
const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : catCan.tags;
|
||||||
@ -82,9 +89,21 @@ const ebookCount = page.type === 'poet' ? ((await api<any[]>(`/api/ebooks?poet=$
|
|||||||
const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`;
|
const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`;
|
||||||
const bookmark = (c: number) => JSON.stringify({ kind: 'couplet', poemId, couplet: c });
|
const bookmark = (c: number) => JSON.stringify({ kind: 'couplet', poemId, couplet: c });
|
||||||
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
|
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
|
||||||
|
// for search engines and link previews: a work's opening lines, a poet's intro, a book's contents (#83)
|
||||||
|
const clip = (t: string) => (t.length > 160 ? t.slice(0, 159) + '…' : t);
|
||||||
|
const description = clip(page.type === 'poem' ? `${blocks[0]?.lines.join(' / ') ?? page.poem.title} (${page.poet.nickname})`
|
||||||
|
: page.type === 'poet' ? page.poet.description || `${page.poet.nickname}: کلام، کتابیں اور ای بکس`
|
||||||
|
: `${page.category.title} · ${page.poet.nickname}: ${page.poems?.length ? `${ud(page.poems.length)} تخلیقات` : `${ud(page.children?.length ?? 0)} حصے`}`);
|
||||||
|
const here = siteUrl(Astro.url) + url, poetLd = { '@type': 'Person', name: page.poet?.nickname, url: siteUrl(Astro.url) + (crumbs[0]?.url ?? '') };
|
||||||
|
const jsonld = { '@context': 'https://schema.org', inLanguage: 'ur', url: here, ...(
|
||||||
|
page.type === 'poem' ? { '@type': 'CreativeWork', name: page.poem.title, author: poetLd, isPartOf: crumbs.at(-1)?.title }
|
||||||
|
: page.type === 'poet' ? { ...poetLd, url: here, description: page.poet.description || undefined }
|
||||||
|
: { '@type': 'CollectionPage', name: page.category.title, author: poetLd }) };
|
||||||
|
import { siteUrl } from '../lib/site-url';
|
||||||
import Icon from '../components/Icon.astro';
|
import Icon from '../components/Icon.astro';
|
||||||
|
import TagsBox from '../components/TagsBox.astro';
|
||||||
---
|
---
|
||||||
<Base title={page.type === 'poem' ? `${page.poem.title} · ${page.poet.nickname}` : title} description={page.type === 'poet' ? page.poet.description?.slice(0, 160) : undefined}>
|
<Base author={page.poet?.nickname} jsonld={jsonld} title={page.type === 'poem' ? `${page.poem.title} · ${page.poet.nickname}` : title} description={description} ogType={page.type === 'poem' ? 'article' : 'website'}>
|
||||||
{page.type !== 'poet' && (
|
{page.type !== 'poet' && (
|
||||||
<nav class="crumbs">
|
<nav class="crumbs">
|
||||||
{crumbs.map((c: any, i: number) => (
|
{crumbs.map((c: any, i: number) => (
|
||||||
@ -95,20 +114,18 @@ import Icon from '../components/Icon.astro';
|
|||||||
|
|
||||||
{page.type === 'poet' && (
|
{page.type === 'poet' && (
|
||||||
<>
|
<>
|
||||||
<h1>{page.poet.name}</h1>
|
<h1 class="lit" data-readable>{page.poet.name}</h1>
|
||||||
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
|
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
|
||||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
|
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}{catCan.poet && <> <form method="post" action={`/mod/details/poet/${poetId}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> تفصیل</button></form></>}</div>
|
||||||
<p class="muted">{years(page.poet)}</p>
|
<p class="muted">{years(page.poet)}</p>
|
||||||
<Tags tags={pageTags} />
|
{page.poet.description && <div class="intro">{page.poet.description.split(/\n\s*\n/).map((p: string) => <p>{p}</p>)}</div>}
|
||||||
{page.poet.description && <div class="intro">{page.poet.description}</div>}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{page.type === 'category' && (
|
{page.type === 'category' && (
|
||||||
<>
|
<>
|
||||||
<h1>{page.category.title}</h1>
|
<h1 class="lit" data-readable>{page.category.title}</h1>
|
||||||
<Tags tags={pageTags} />
|
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
|
||||||
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
|
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}{catCan.book && <> <form method="post" action={`/mod/details/book/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> عنوان</button></form></>}</div>
|
||||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -126,30 +143,65 @@ import Icon from '../components/Icon.astro';
|
|||||||
<ol class="toc">{g.poems.map((p: any) => <li><a class={glyph(p.id)} href={p.url}>{p.title}</a></li>)}</ol>
|
<ol class="toc">{g.poems.map((p: any) => <li><a class={glyph(p.id)} href={p.url}>{p.title}</a></li>)}</ol>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
{page.type !== 'poem' && <TagsBox tags={pageTags} />}
|
||||||
|
|
||||||
{page.type === 'poem' && (
|
{page.type === 'poem' && (
|
||||||
<>
|
<>
|
||||||
<h1><Seg s={show(page.poem.title)} /></h1>
|
<article class="work" data-readable>
|
||||||
|
<h1 class="lit"><Seg s={show(page.poem.title)} /></h1>
|
||||||
|
<p class="byline">{page.poet.nickname}</p>
|
||||||
{re && (
|
{re && (
|
||||||
<p class="hl-note muted">
|
<p class="hl-note muted">
|
||||||
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
|
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button>
|
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>
|
||||||
{modCan.edit && <> <form method="post" action={`/mod/work/${poemId}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> ترمیم کریں</button></form>
|
{modCan.edit && <> <form method="post" action={`/mod/work/${poemId}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> ترمیم کریں</button></form>
|
||||||
<a class="icon-btn small" href={`/mod/work/${poemId}`} title="تاریخچہ" aria-label="تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
|
<a class="icon-btn small" href={`/mod/work/${poemId}`} title="تاریخچہ" aria-label="تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
|
||||||
<Tags tags={pageTags} />
|
|
||||||
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
|
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
|
||||||
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
|
<div class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
|
||||||
{blocks.map((b) => {
|
{blocks.map((b) => {
|
||||||
const bm = <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />;
|
const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c);
|
||||||
|
// the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note)
|
||||||
|
const bm = <span class="c-tools">
|
||||||
|
{/* wide screens: four icons on hover; phones: one ⋯ that opens a menu (the CSS shows one or the other) */}
|
||||||
|
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />
|
||||||
|
<button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
|
||||||
|
<button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" />
|
||||||
|
<button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" />
|
||||||
|
<button type="button" class="more" data-for={b.c} data-bm={saved.couplets.includes(b.c) ? '' : undefined} data-nt={any ? '' : undefined}
|
||||||
|
aria-haspopup="menu" aria-label="محفوظ، نوٹ، بھیجیں" title={cn ? `نوٹ: ${cn}` : 'محفوظ، نوٹ، بھیجیں'}><i class="g-bm" /><i class="g-nt" /><i class="g-dots" /></button>
|
||||||
|
</span>;
|
||||||
const lm = lineMarks(b.c, b.lines);
|
const lm = lineMarks(b.c, b.lines);
|
||||||
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
|
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
|
||||||
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
|
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
|
||||||
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
|
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p>
|
<div id="c-menu" class="c-menu" popover role="menu" aria-label="شعر">
|
||||||
|
<button type="button" role="menuitem" data-act="bm"><i class="g-bm" /><span>محفوظ کریں</span></button>
|
||||||
|
<button type="button" role="menuitem" data-act="nt"><i class="g-nt" /><span>نوٹ</span></button>
|
||||||
|
<button type="button" role="menuitem" data-act="sh"><i class="g-sh" /><span>بھیجیں</span></button>
|
||||||
|
<button type="button" role="menuitem" data-act="dl"><i class="g-dl" /><span>تصویر ڈاؤن لوڈ</span></button>
|
||||||
|
</div>
|
||||||
|
<dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
|
||||||
|
<form method="dialog">
|
||||||
|
<p id="note-for" class="note-for"></p>
|
||||||
|
<div class="in-box-field note-field">
|
||||||
|
<textarea id="note-text" name="note" rows="4" maxlength="1000" dir="auto" placeholder="آپ کا نوٹ، کسی بھی زبان میں…" aria-label="نوٹ" data-urdu></textarea>
|
||||||
|
<button type="button" class="in-box rom" data-urdu-rom="note-text" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
|
||||||
|
<button type="button" class="in-box" data-urdu-kb="note-text" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<ul class="note-others" hidden></ul>
|
||||||
|
<div class="ask-actions">
|
||||||
|
<button class="btn primary" value="save">محفوظ کریں</button><button class="btn" value="">بند کریں</button>
|
||||||
|
<button class="btn danger note-clear" value="clear">نوٹ ہٹائیں</button><button class="btn note-unmark" value="unmark">ہائی لائٹ ہٹائیں</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<script type="application/json" id="notes-data" set:html={JSON.stringify({ poemId, notes, phrases: saved.phrases, signedIn: !!token })} />
|
||||||
|
<TagsBox tags={pageTags} />
|
||||||
<nav class="poem-nav">
|
<nav class="poem-nav">
|
||||||
{page.prev && <a class="icon-btn" href={page.prev} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
|
{page.prev && <a class="icon-btn" href={page.prev} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
|
||||||
{page.next && <a class="icon-btn" href={page.next} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
|
{page.next && <a class="icon-btn" href={page.next} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
|
||||||
@ -168,3 +220,151 @@ import Icon from '../components/Icon.astro';
|
|||||||
)}
|
)}
|
||||||
{re && <script is:inline>document.querySelector('.poem mark')?.scrollIntoView({ block: 'center' });</script>}
|
{re && <script is:inline>document.querySelector('.poem mark')?.scrollIntoView({ block: 'center' });</script>}
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import nq700 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-700-normal.woff2?url';
|
||||||
|
import nq400 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-400-normal.woff2?url';
|
||||||
|
let cardFonts: Promise<unknown> | null = null;
|
||||||
|
// notes on couplets and phrases: the glyph beside a couplet's bookmark, a highlighted phrase, or نوٹ in the selection menu
|
||||||
|
// open one box; a note never bookmarks (bookmarks are the bookmark button's)
|
||||||
|
const data = document.getElementById('notes-data');
|
||||||
|
if (data) {
|
||||||
|
const { poemId, notes, phrases, signedIn } = JSON.parse(data.textContent!) as { poemId: number; notes: { couplet: number; phrase: string | null; note: string }[]; phrases: { couplet: number; phrase: string }[]; signedIn: boolean };
|
||||||
|
const box = document.getElementById('note-box') as HTMLDialogElement, text = box.querySelector('textarea')!;
|
||||||
|
const others = box.querySelector('.note-others') as HTMLUListElement;
|
||||||
|
let at: { couplet: number; phrase: string | null } = { couplet: 0, phrase: null };
|
||||||
|
const post = (body: object) => fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
.then((r) => (r.status === 401 ? (location.href = '/signin?next=' + encodeURIComponent(location.pathname), null) : r.ok ? r.json() : null));
|
||||||
|
const linesOf = (c: number) => [...document.querySelectorAll(`[data-c="${c}"] > p:not(.tag-chips), p[data-c="${c}"]`)].map((p) => p.textContent!.replace(/\s+/g, ' ').trim());
|
||||||
|
const lineOf = (c: number) => linesOf(c).join(' / ');
|
||||||
|
const open = (couplet: number, phrase: string | null) => {
|
||||||
|
if (!signedIn) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return; }
|
||||||
|
at = { couplet, phrase };
|
||||||
|
const mine = notes.find((n) => n.couplet === couplet && n.phrase === phrase);
|
||||||
|
box.querySelector('#note-for')!.textContent = phrase ? `«${phrase}»` : lineOf(couplet);
|
||||||
|
text.value = mine?.note ?? '';
|
||||||
|
(box.querySelector('.note-clear') as HTMLElement).hidden = !mine;
|
||||||
|
(box.querySelector('.note-unmark') as HTMLElement).hidden = !phrases.some((p) => p.couplet === couplet && p.phrase === phrase); // only a highlighted phrase
|
||||||
|
// a couplet's box also lists the notes on phrases in it; pressing one opens that note
|
||||||
|
const inIt = phrase ? [] : notes.filter((n) => n.couplet === couplet && n.phrase);
|
||||||
|
others.hidden = !inIt.length;
|
||||||
|
others.replaceChildren(...inIt.map((n) => { const li = document.createElement('li'); const b = document.createElement('button'); b.type = 'button';
|
||||||
|
b.textContent = `«${n.phrase}»: ${n.note}`; b.onclick = () => { box.close(); open(couplet, n.phrase); }; li.append(b); return li; }));
|
||||||
|
box.showModal(); text.focus();
|
||||||
|
};
|
||||||
|
const mark = (couplet: number) => { // the glyph follows the notes on this couplet
|
||||||
|
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
|
||||||
|
const nt = document.querySelector(`.nt[data-for="${couplet}"]`), more = document.querySelector(`.more[data-for="${couplet}"]`) as HTMLElement | null;
|
||||||
|
nt?.setAttribute('aria-pressed', String(any)); nt?.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں');
|
||||||
|
if (more) { any ? (more.dataset.nt = '') : delete more.dataset.nt; more.title = cn ? `نوٹ: ${cn}` : 'محفوظ، نوٹ، بھیجیں'; } // the bookmark is separate
|
||||||
|
};
|
||||||
|
box.addEventListener('close', async () => {
|
||||||
|
const act = box.returnValue, { couplet, phrase } = at;
|
||||||
|
if (!act) return;
|
||||||
|
if (act === 'unmark') { if (await post({ kind: 'phrase', poemId, couplet, phrase })) location.reload(); return; }
|
||||||
|
const note = act === 'clear' ? '' : text.value.trim();
|
||||||
|
const d = await post({ note, poemId, couplet, ...(phrase ? { phrase } : {}) });
|
||||||
|
if (!d) return (window as any).toast('نوٹ محفوظ نہیں ہو سکا', 'error');
|
||||||
|
if (phrase) { location.reload(); return; } // a phrase's highlight and note glyph are drawn by the page
|
||||||
|
const i = notes.findIndex((n) => n.couplet === couplet && !n.phrase);
|
||||||
|
if (i >= 0) notes.splice(i, 1);
|
||||||
|
if (d.note) notes.push({ couplet, phrase: null, note: d.note });
|
||||||
|
mark(couplet); (window as any).toast(d.note ? 'نوٹ محفوظ ہو گیا' : 'نوٹ ہٹا دیا');
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
const nt = t.closest('.nt') as HTMLElement | null; if (nt) return open(Number(nt.dataset.for), null);
|
||||||
|
const ph = t.closest('.bmk[data-phrase]') as HTMLElement | null;
|
||||||
|
if (ph && !getSelection()?.toString().trim()) { const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); }
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => { const ph = (e.target as HTMLElement).closest?.('.bmk[data-phrase]') as HTMLElement | null;
|
||||||
|
if (ph && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); } });
|
||||||
|
// share a couplet as an image: a square card (frame, logo, the lines, the poet), drawn here and handed to the share sheet
|
||||||
|
const share = async (c: number, download = false) => {
|
||||||
|
const [poet, title] = (document.querySelector('[data-cite]') as HTMLElement).dataset.cite!.split('، ');
|
||||||
|
const prose = !!document.querySelector(`p.para[data-c="${c}"]`);
|
||||||
|
const lines = linesOf(c), S = 1080, cv = document.createElement('canvas'); cv.width = cv.height = S;
|
||||||
|
const g = cv.getContext('2d')!; // the card is always on paper, whatever the page's palette
|
||||||
|
// the card's own Nastaliq faces, loaded here: the page may not have used bold Nastaliq yet, and a fallback font is
|
||||||
|
// wider (lines would wrap) and not Nastaliq
|
||||||
|
const NQ = '"Divan Card"';
|
||||||
|
if (!cardFonts) cardFonts = Promise.all([[nq700, '700'], [nq400, '400']].map(([url, weight]) => new FontFace('Divan Card', `url(${url})`, { weight }).load()
|
||||||
|
.then((f) => { document.fonts.add(f); })));
|
||||||
|
await cardFonts;
|
||||||
|
g.fillStyle = '#fbf6ea'; g.fillRect(0, 0, S, S);
|
||||||
|
g.strokeStyle = '#b88828'; g.lineWidth = 18; g.strokeRect(9, 9, S - 18, S - 18); g.lineWidth = 3; g.strokeRect(40, 40, S - 80, S - 80);
|
||||||
|
const logo = new Image(); logo.src = '/logo/turanj.svg'; await logo.decode().catch(() => {});
|
||||||
|
if (logo.complete) g.drawImage(logo, S / 2 - 70, 80, 140, 140);
|
||||||
|
g.direction = 'rtl'; g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||||
|
// the lines: one per misra; prose wraps; the size shrinks until everything fits the card
|
||||||
|
let size = 72, rows: string[] = [];
|
||||||
|
const wrap = (t: string, w: number) => { const out: string[] = []; let row = '';
|
||||||
|
for (const word of t.split(' ')) { const next = row ? row + ' ' + word : word; if (g.measureText(next).width > w && row) { out.push(row); row = word; } else row = next; }
|
||||||
|
return row ? [...out, row] : out; };
|
||||||
|
for (; size >= (prose ? 30 : 22); size -= 2) { // verse shrinks until each misra fits on one line
|
||||||
|
g.font = `700 ${size}px ${NQ}`;
|
||||||
|
rows = lines.flatMap((l) => wrap(l, S - 200));
|
||||||
|
// verse keeps each misra on one line (prose wraps); Nastaliq is tall: lines 2.6 sizes apart leave room for the ✤ between misras
|
||||||
|
if ((prose || rows.length === lines.length) && rows.length * size * 2.6 <= 560) break;
|
||||||
|
}
|
||||||
|
const lh = size * 2.6, top = 250 + (560 - rows.length * lh) / 2;
|
||||||
|
g.fillStyle = '#17130f'; rows.forEach((r, i) => g.fillText(r, S / 2, top + lh * (i + .5)));
|
||||||
|
// the couplet line (as on the home page) between the verse and the poet: two tapering half-lines and a ✤
|
||||||
|
const y = S - 260, half = (from: number, to: number) => { g.beginPath(); g.moveTo(from, y); g.quadraticCurveTo((from + to) / 2, y - 6, to, y - 4); g.lineTo(to, y + 4); g.quadraticCurveTo((from + to) / 2, y + 6, from, y); g.fill(); };
|
||||||
|
g.fillStyle = '#1f4e79'; half(S / 2 - 30, S / 2 - 230); half(S / 2 + 30, S / 2 + 230);
|
||||||
|
g.fillStyle = '#bf3f2a'; g.font = '700 34px "Noto Naskh Arabic"'; g.fillText('✤', S / 2, y + 7);
|
||||||
|
g.fillStyle = '#bf3f2a'; g.font = `700 40px ${NQ}`; g.fillText(poet, S / 2, S - 190);
|
||||||
|
g.fillStyle = '#635543'; g.font = `400 30px ${NQ}`; g.fillText(`${title} · دیوان`, S / 2, S - 115);
|
||||||
|
const blob: Blob = await new Promise((done) => cv.toBlob((b) => done(b!), 'image/png'));
|
||||||
|
const file = new File([blob], `divan-${poemId}-${c + 1}.png`, { type: 'image/png' });
|
||||||
|
const url = `${location.origin}${location.pathname}#c${c}`;
|
||||||
|
if (download) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); (window as any).toast('تصویر ڈاؤن لوڈ ہو گئی'); return; }
|
||||||
|
// the share menu: with the picture where the browser can share files, otherwise the couplet and its link; never a download
|
||||||
|
const text = `${lines.join('\n')}\n— ${poet}`;
|
||||||
|
if (navigator.canShare?.({ files: [file] })) await navigator.share({ files: [file], text: url }).catch(() => {});
|
||||||
|
else if (navigator.share) await navigator.share({ title: document.title, text, url }).catch(() => {});
|
||||||
|
else { try { await navigator.clipboard.writeText(`${text}\n${url}`); (window as any).toast('یہ براؤزر شیئر نہیں کر سکتا: شعر اور لنک کاپی ہو گئے'); } catch { (window as any).toast('شیئر نہیں ہو سکا', 'error'); } }
|
||||||
|
};
|
||||||
|
document.addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('.sh, .dl') as HTMLElement | null; if (b) share(Number(b.dataset.for), b.classList.contains('dl')); });
|
||||||
|
// phones: one ⋯ per couplet opens the shared menu beside it: bookmark, note, share, download
|
||||||
|
const menu = document.getElementById('c-menu') as HTMLElement & { showPopover(): void; hidePopover(): void };
|
||||||
|
let forC = 0, from: HTMLElement | null = null;
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const more = (e.target as HTMLElement).closest('.more') as HTMLElement | null; if (!more) return;
|
||||||
|
forC = Number(more.dataset.for); from = more;
|
||||||
|
const on = 'bm' in more.dataset;
|
||||||
|
menu.querySelector('[data-act=bm] span')!.textContent = on ? 'محفوظ شدہ سے ہٹائیں' : 'محفوظ کریں';
|
||||||
|
menu.querySelector('[data-act=bm]')!.toggleAttribute('data-on', on);
|
||||||
|
menu.showPopover();
|
||||||
|
const r = more.getBoundingClientRect(), m = menu.getBoundingClientRect(); // below the button, kept on screen
|
||||||
|
menu.style.left = `${Math.max(8, Math.min(innerWidth - m.width - 8, r.left))}px`;
|
||||||
|
menu.style.top = `${r.bottom + 6 + m.height > innerHeight ? r.top - m.height - 6 : r.bottom + 6}px`;
|
||||||
|
(menu.querySelector('[role=menuitem]') as HTMLElement).focus();
|
||||||
|
});
|
||||||
|
menu.addEventListener('click', async (e) => {
|
||||||
|
const act = (e.target as HTMLElement).closest('[data-act]')?.getAttribute('data-act'); if (!act) return;
|
||||||
|
menu.hidePopover();
|
||||||
|
if (act === 'nt') return open(forC, null);
|
||||||
|
if (act === 'sh' || act === 'dl') return share(forC, act === 'dl');
|
||||||
|
const d = await post({ kind: 'couplet', poemId, couplet: forC }); if (!d || !from) return;
|
||||||
|
d.saved ? (from.dataset.bm = '') : delete from.dataset.bm;
|
||||||
|
from.parentElement?.querySelector('.bm')?.setAttribute('aria-pressed', String(d.saved)); // the wide-screen icon too
|
||||||
|
(window as any).toast(d.saved ? 'شعر محفوظ ہو گیا' : 'محفوظ شدہ سے ہٹا دیا');
|
||||||
|
});
|
||||||
|
menu.addEventListener('keydown', (e) => { // arrows move through the menu
|
||||||
|
const items = [...menu.querySelectorAll<HTMLElement>('[role=menuitem]')], i = items.indexOf(document.activeElement as HTMLElement);
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus(); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
|
||||||
|
});
|
||||||
|
// touch screens: tapping a couplet shows its tools (and hides the last one's); links, buttons and selections are left alone
|
||||||
|
if (matchMedia('(hover: none)').matches) document.querySelector('.poem')?.addEventListener('click', (e) => {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (t.closest('a, button, .bmk') || getSelection()?.toString().trim()) return;
|
||||||
|
const block = t.closest('.couplet, .para, .single'); if (!block) return;
|
||||||
|
const was = block.classList.contains('is-active');
|
||||||
|
document.querySelectorAll('.poem .is-active').forEach((x) => x.classList.remove('is-active'));
|
||||||
|
if (!was) block.classList.add('is-active');
|
||||||
|
});
|
||||||
|
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const mayAdd = token && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user
|
|||||||
&& (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data.ebooks;
|
&& (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data.ebooks;
|
||||||
import Icon from '../../components/Icon.astro';
|
import Icon from '../../components/Icon.astro';
|
||||||
---
|
---
|
||||||
<Base title={`ای بکس · ${page.poet.nickname}`}>
|
<Base title={`ای بکس · ${page.poet.nickname}`} description={`${page.poet.nickname} کی ای بکس: پی ڈی ایف، ای پب اور یونیکوڈ متن`}>
|
||||||
<nav class="crumbs"><a href={page.poet.url}>{page.poet.nickname}</a></nav>
|
<nav class="crumbs"><a href={page.poet.url}>{page.poet.nickname}</a></nav>
|
||||||
<h1>ای بکس</h1>
|
<h1>ای بکس</h1>
|
||||||
{mayAdd && <p class="center"><a class="btn primary" href={`/mod/ebook/new?poet=${page.poet.id}`}><Icon name="plus" size={18} /> ای بک شامل کریں</a></p>}
|
{mayAdd && <p class="center"><a class="btn primary" href={`/mod/ebook/new?poet=${page.poet.id}`}><Icon name="plus" size={18} /> ای بک شامل کریں</a></p>}
|
||||||
|
|||||||
@ -48,8 +48,8 @@ import Icon from '../components/Icon.astro';
|
|||||||
<form method="post" class="account-form">
|
<form method="post" class="account-form">
|
||||||
<h2>پروفائل</h2>
|
<h2>پروفائل</h2>
|
||||||
<input type="hidden" name="act" value="profile" />
|
<input type="hidden" name="act" value="profile" />
|
||||||
<label>پورا نام<input name="full_name" value={user.full_name} maxlength="100" dir="auto" autocomplete="name" /></label>
|
<label>پورا نام<span class="in-box-field"><input id="p-name" name="full_name" value={user.full_name} maxlength="100" dir="auto" autocomplete="name" data-urdu /><button type="button" class="in-box rom" data-urdu-rom="p-name" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ">اب</button><button type="button" class="in-box" data-urdu-kb="p-name" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button></span></label>
|
||||||
<label>تعارف<textarea name="bio" maxlength="1000" rows="4" dir="auto">{user.bio}</textarea></label>
|
<label>تعارف<span class="in-box-field note-field"><textarea id="p-bio" name="bio" maxlength="1000" rows="4" dir="auto" data-urdu>{user.bio}</textarea><button type="button" class="in-box rom" data-urdu-rom="p-bio" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ">اب</button><button type="button" class="in-box" data-urdu-kb="p-bio" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button></span></label>
|
||||||
<button type="submit" class="btn primary"><Icon name="save" size={18} /> محفوظ کریں</button>
|
<button type="submit" class="btn primary"><Icon name="save" size={18} /> محفوظ کریں</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Icon from '../../components/Icon.astro';
|
|||||||
import AdminNav from '../../components/AdminNav.astro';
|
import AdminNav from '../../components/AdminNav.astro';
|
||||||
import { asUser, COOKIE } from '../../lib/auth';
|
import { asUser, COOKIE } from '../../lib/auth';
|
||||||
import { ud } from '../../lib/urdu';
|
import { ud } from '../../lib/urdu';
|
||||||
import { ROLE, CONTENT, ACTION as DO } from '../../lib/roles';
|
import { ROLE, CONTENT, ACTION as DO, ago } from '../../lib/roles';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
if (!me) return Astro.redirect('/signin?next=/admin/audit');
|
if (!me) return Astro.redirect('/signin?next=/admin/audit');
|
||||||
@ -18,25 +18,28 @@ const ACTION: Record<string, string> = {
|
|||||||
grant: 'اجازت دی', revoke: 'اجازت واپس لی',
|
grant: 'اجازت دی', revoke: 'اجازت واپس لی',
|
||||||
};
|
};
|
||||||
const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' '));
|
const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' '));
|
||||||
|
const TONE: Record<string, string> = { grant: 'ok', enable: 'ok', role: 'action', promote: 'action', 'password-reset': 'accent', disable: 'danger', delete: 'danger', revoke: 'danger' };
|
||||||
|
const detail = (e: any) => e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '')
|
||||||
|
: e.detail?.content ? [e.detail.target, e.detail.content.map((c: string) => CONTENT[c]).join('، '), e.detail.actions.map((a: string) => DO[a]).join('، ')].filter(Boolean).join(' · ') : '';
|
||||||
|
const kinds = [...new Set(log.entries.map((e: any) => e.action as string))];
|
||||||
---
|
---
|
||||||
<Base title="ایڈمن: ریکارڈ">
|
<Base title="ایڈمن: ریکارڈ">
|
||||||
<h1>ایڈمن</h1>
|
<h1>ایڈمن</h1>
|
||||||
<AdminNav current="audit" />
|
<AdminNav current="audit" />
|
||||||
<table class="admin-table">
|
{kinds.length > 1 && (
|
||||||
<thead><tr><th>وقت (UTC)</th><th>ایڈمن</th><th>عمل</th><th>صارف</th><th>تفصیل</th></tr></thead>
|
<div class="chips role-filter audit-filter" role="group" aria-label="عمل سے چھانٹیں">
|
||||||
<tbody>
|
{kinds.map((k) => <label class="chip"><input type="checkbox" value={k} checked /> {ACTION[k] ?? k}</label>)}
|
||||||
{log.entries.map((e: any) => (
|
</div>
|
||||||
<tr>
|
)}
|
||||||
<td>{when(e.at)}</td>
|
<ol class="timeline">
|
||||||
<td><bdi dir="ltr">{e.actor_email}</bdi></td>
|
{log.entries.map((e: any) => (
|
||||||
<td>{ACTION[e.action] ?? e.action}</td>
|
<li data-tone={TONE[e.action] ?? 'muted'} data-action={e.action}>
|
||||||
<td><bdi dir="ltr">{e.target_email ?? '—'}</bdi></td>
|
<header><bdi dir="ltr" class="who">{e.actor_email}</bdi><time class="muted" datetime={e.at} title={when(e.at)}>{ago(e.at)}</time></header>
|
||||||
<td>{e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '')
|
<p><strong>{ACTION[e.action] ?? e.action}</strong>{e.target_email && <>: <bdi dir="ltr" class="target">{e.target_email}</bdi></>}</p>
|
||||||
: e.detail?.content ? `${e.detail.target ?? ''} · ${e.detail.content.map((c: string) => CONTENT[c]).join('، ')} · ${e.detail.actions.map((a: string) => DO[a]).join('، ')}` : ''}</td>
|
{detail(e) && <p class="muted small-line">{detail(e)}</p>}
|
||||||
</tr>
|
</li>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</ol>
|
||||||
</table>
|
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
{page > 1 && <a class="icon-btn" href={`/admin/audit?page=${page - 1}`} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
|
{page > 1 && <a class="icon-btn" href={`/admin/audit?page=${page - 1}`} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
|
||||||
@ -44,4 +47,11 @@ const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T
|
|||||||
{page < pages && <a class="icon-btn" href={`/admin/audit?page=${page + 1}`} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
|
{page < pages && <a class="icon-btn" href={`/admin/audit?page=${page + 1}`} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
<script>
|
||||||
|
// the action chips show and hide entries on this page
|
||||||
|
document.querySelector('.audit-filter')?.addEventListener('change', (e) => {
|
||||||
|
const on = new Set([...document.querySelectorAll<HTMLInputElement>('.audit-filter input:checked')].map((i) => i.value));
|
||||||
|
document.querySelectorAll<HTMLElement>('.timeline li').forEach((li) => (li.hidden = !on.has(li.dataset.action!)));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
85
web/src/pages/admin/stats.astro
Normal file
85
web/src/pages/admin/stats.astro
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
// Admin: statistics (#86): readers (page views and visitors per day, top pages, where they came from), moderation
|
||||||
|
// (steps taken, by whom, waiting) and accounts. Counts only: nothing here identifies a reader (see /privacy).
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import AdminNav from '../../components/AdminNav.astro';
|
||||||
|
import { asUser, COOKIE } from '../../lib/auth';
|
||||||
|
import { ud } from '../../lib/urdu';
|
||||||
|
import { ROLE } from '../../lib/roles';
|
||||||
|
import { EVENT } from '../../lib/mod';
|
||||||
|
|
||||||
|
const me = Astro.locals.user;
|
||||||
|
if (!me) return Astro.redirect('/signin?next=/admin/stats');
|
||||||
|
if (me.role !== 'admin') return new Response('صرف ایڈمن کے لیے', { status: 403 });
|
||||||
|
const days = [7, 30, 90, 365].includes(Number(Astro.url.searchParams.get('days'))) ? Number(Astro.url.searchParams.get('days')) : 30;
|
||||||
|
const s = (await asUser(Astro.cookies.get(COOKIE)!.value, `/api/admin/stats?days=${days}`)).data;
|
||||||
|
const sum = (k: 'views' | 'visitors') => s.daily.reduce((n: number, d: any) => n + d[k], 0);
|
||||||
|
const max = Math.max(1, ...s.daily.map((d: any) => d.views));
|
||||||
|
const LIB: Record<string, string> = { poet: 'شاعر', category: 'کتابیں', poem: 'کلام', couplet: 'اشعار', phrase: 'ٹکڑے', word: 'الفاظ', page: 'صفحات' };
|
||||||
|
const label = (d: string) => ud(d.slice(5).replace('-', '/'));
|
||||||
|
---
|
||||||
|
<Base title="ایڈمن: اعداد و شمار">
|
||||||
|
<h1>ایڈمن</h1>
|
||||||
|
<AdminNav current="stats" />
|
||||||
|
<nav class="stats-range" aria-label="مدت">
|
||||||
|
{[7, 30, 90, 365].map((d) => <a href={`?days=${d}`} aria-current={d === days ? 'page' : undefined} class="chip">{d === 365 ? 'ایک سال' : `${ud(d)} دن`}</a>)}
|
||||||
|
</nav>
|
||||||
|
<nav class="stat-tiles" aria-label="خلاصہ">
|
||||||
|
<div class="stat"><strong>{ud(sum('views'))}</strong><span>صفحات دیکھے گئے</span></div>
|
||||||
|
<div class="stat"><strong>{ud(sum('visitors'))}</strong><span>قارئین (روزانہ کا مجموعہ)</span></div>
|
||||||
|
<div class="stat"><strong>{ud(s.accounts.new)}</strong><span>نئے اکاؤنٹ</span></div>
|
||||||
|
<div class="stat"><strong>{ud(s.moderation.actions.find((a: any) => a.action === 'published')?.n ?? 0)}</strong><span>اشاعتیں</span></div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="lib-section">
|
||||||
|
<h2>روزانہ</h2>
|
||||||
|
<div class="bars" role="img" aria-label={`روزانہ صفحات: ${s.daily.map((d: any) => `${label(d.day)} ${ud(d.views)}`).join('، ')}`}>
|
||||||
|
{s.daily.map((d: any) => (
|
||||||
|
<div class="bar" title={`${label(d.day)}: ${ud(d.views)} صفحات، ${ud(d.visitors)} قارئین`}>
|
||||||
|
<span style={`height:${(d.views / max) * 100}%`}></span><i style={`height:${(d.visitors / max) * 100}%`}></i>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p class="muted center"><span class="key-views"></span> صفحات · <span class="key-visitors"></span> قارئین · {label(s.daily[0].day)} تا {label(s.daily.at(-1).day)}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<section class="lib-section">
|
||||||
|
<h2>سب سے زیادہ پڑھے گئے</h2>
|
||||||
|
{s.pages.length ? <table class="admin-table"><tbody>
|
||||||
|
{s.pages.map((p: any) => <tr><td><a href={p.path}>{p.title ?? p.path}</a></td><td>{ud(p.views)}</td></tr>)}
|
||||||
|
</tbody></table> : <p class="muted">ابھی کچھ نہیں۔</p>}
|
||||||
|
</section>
|
||||||
|
<section class="lib-section">
|
||||||
|
<h2>کہاں سے آئے</h2>
|
||||||
|
{s.referrers.length ? <table class="admin-table"><tbody>
|
||||||
|
{s.referrers.map((r: any) => <tr><td><bdi dir="ltr">{r.host}</bdi></td><td>{ud(r.n)}</td></tr>)}
|
||||||
|
</tbody></table> : <p class="muted">ابھی کچھ نہیں (سیدھے آنے والے یہاں نہیں گنے جاتے)۔</p>}
|
||||||
|
</section>
|
||||||
|
<section class="lib-section">
|
||||||
|
<h2>موڈریشن</h2>
|
||||||
|
<p>جائزے کے منتظر: <strong>{ud(s.moderation.waiting.submitted ?? 0)}</strong> · اشاعت کے منتظر: <strong>{ud(s.moderation.waiting.approved ?? 0)}</strong>
|
||||||
|
· کل شائع شدہ ورژن: {ud(s.content.versions)}</p>
|
||||||
|
<table class="admin-table"><tbody>
|
||||||
|
{s.moderation.actions.map((a: any) => <tr><td>{EVENT[a.action] ?? a.action}</td><td>{ud(a.n)}</td></tr>)}
|
||||||
|
</tbody></table>
|
||||||
|
{s.moderation.people.length > 0 && <>
|
||||||
|
<h3>سب سے زیادہ کام</h3>
|
||||||
|
<table class="admin-table"><thead><tr><th>موڈریٹر</th><th>قدم</th><th>اشاعتیں</th></tr></thead><tbody>
|
||||||
|
{s.moderation.people.map((p: any) => <tr><td><bdi dir="ltr">{p.email}</bdi></td><td>{ud(p.n)}</td><td>{ud(p.published)}</td></tr>)}
|
||||||
|
</tbody></table>
|
||||||
|
</>}
|
||||||
|
</section>
|
||||||
|
<section class="lib-section">
|
||||||
|
<h2>اکاؤنٹ اور محفوظ شدہ</h2>
|
||||||
|
<p>اس مدت میں لاگ ان کرنے والے: <strong>{ud(s.accounts.signed_in)}</strong></p>
|
||||||
|
<table class="admin-table"><tbody>
|
||||||
|
{Object.entries(s.accounts.roles).map(([r, n]: any) => <tr><td>{ROLE[r] ?? r}</td><td>{ud(n)}</td></tr>)}
|
||||||
|
{Object.entries(s.library).map(([k, n]: any) => <tr><td>محفوظ: {LIB[k] ?? k}</td><td>{ud(n)}</td></tr>)}
|
||||||
|
<tr><td>ای بکس</td><td>{ud(s.content.ebooks)}</td></tr>
|
||||||
|
<tr><td>ٹیگ</td><td>{ud(s.content.tags)}</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<p class="muted center">ان اعداد میں کسی قاری کی شناخت نہیں: نہ کوکی، نہ پتا محفوظ ہوتا ہے (<a href="/privacy">رازداری</a>)۔</p>
|
||||||
|
</Base>
|
||||||
@ -70,7 +70,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>کردار</h2>
|
<h2>کردار</h2>
|
||||||
{self ? <p class="muted">اپنا کردار خود نہیں بدل سکتے۔</p> : (
|
{self ? <p class="muted">اپنا کردار خود نہیں بدل سکتے۔</p> : (
|
||||||
<form method="post" class="role-pick" onsubmit={`return confirm(${JSON.stringify(isMod ? 'کردار بدلیں؟ موڈریٹر نہ رہنے پر اس کی اجازتیں ختم ہو جائیں گی۔' : 'کردار بدلیں؟')})`}>
|
<form method="post" class="role-pick" data-confirm={isMod ? 'کردار بدلیں؟ موڈریٹر نہ رہنے پر اس کی اجازتیں ختم ہو جائیں گی۔' : 'کردار بدلیں؟'} data-ok="کردار بدلیں">
|
||||||
<input type="hidden" name="act" value="role" />
|
<input type="hidden" name="act" value="role" />
|
||||||
{Object.entries(ROLE).map(([k, v]) => (
|
{Object.entries(ROLE).map(([k, v]) => (
|
||||||
<button class="btn" name="role" value={k} aria-pressed={k === user.role ? 'true' : 'false'} disabled={k === user.role}>
|
<button class="btn" name="role" value={k} aria-pressed={k === user.role ? 'true' : 'false'} disabled={k === user.role}>
|
||||||
@ -92,7 +92,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
|
|||||||
<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="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>
|
<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>
|
</div>
|
||||||
<form method="post" onsubmit="return confirm('یہ اجازت واپس لیں؟')">
|
<form method="post" data-confirm="یہ اجازت واپس لیں؟" data-danger data-ok="واپس لیں">
|
||||||
<input type="hidden" name="grant" value={g.id} />
|
<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>
|
<button class="icon-btn small danger" name="act" value="revoke" title="اجازت واپس لیں" aria-label="اجازت واپس لیں"><Icon name="trash" size={16} /></button>
|
||||||
</form>
|
</form>
|
||||||
@ -129,7 +129,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>اکاؤنٹ</h2>
|
<h2>اکاؤنٹ</h2>
|
||||||
<div class="account-actions">
|
<div class="account-actions">
|
||||||
<form method="post" onsubmit="return confirm('اس صارف کا پاس ورڈ ری سیٹ کریں؟ اس کے سب سیشن ختم ہو جائیں گے۔')">
|
<form method="post" data-confirm="اس صارف کا پاس ورڈ ری سیٹ کریں؟ اس کے سب سیشن ختم ہو جائیں گے۔" data-ok="ری سیٹ کریں">
|
||||||
<button class="btn" name="act" value="reset"><Icon name="key" size={16} /> پاس ورڈ ری سیٹ</button>
|
<button class="btn" name="act" value="reset"><Icon name="key" size={16} /> پاس ورڈ ری سیٹ</button>
|
||||||
</form>
|
</form>
|
||||||
{!self && (
|
{!self && (
|
||||||
@ -141,7 +141,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!self && (
|
{!self && (
|
||||||
<form method="post" class="danger-zone" onsubmit="return confirm('یہ اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔ جاری رکھیں؟')">
|
<form method="post" class="danger-zone" data-confirm="یہ اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔ جاری رکھیں؟" data-danger data-ok="حذف کریں">
|
||||||
<p class="muted small-line">اکاؤنٹ اور اس کا تمام ڈیٹا (محفوظ شدہ، ذخیرۂ الفاظ) مستقل طور پر حذف۔ واپسی ممکن نہیں۔</p>
|
<p class="muted small-line">اکاؤنٹ اور اس کا تمام ڈیٹا (محفوظ شدہ، ذخیرۂ الفاظ) مستقل طور پر حذف۔ واپسی ممکن نہیں۔</p>
|
||||||
<button class="btn danger" name="act" value="delete"><Icon name="trash" size={16} /> اکاؤنٹ حذف کریں</button>
|
<button class="btn danger" name="act" value="delete"><Icon name="trash" size={16} /> اکاؤنٹ حذف کریں</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -10,7 +10,8 @@ export const POST: APIRoute = async ({ request, cookies, url }) => {
|
|||||||
if (origin && new URL(origin).host !== url.host) return json({ error: 'forbidden' }, 403);
|
if (origin && new URL(origin).host !== url.host) return json({ error: 'forbidden' }, 403);
|
||||||
const token = cookies.get(COOKIE)?.value;
|
const token = cookies.get(COOKIE)?.value;
|
||||||
if (!token) return json({ error: 'لاگ ان کریں' }, 401);
|
if (!token) return json({ error: 'لاگ ان کریں' }, 401);
|
||||||
const r = await asUser(token, '/api/library/toggle', await request.json().catch(() => ({})));
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const r = await asUser(token, 'note' in body ? '/api/library/note' : '/api/library/toggle', body); // {note, poemId, couplet, phrase?}: a note
|
||||||
return json(r.data, r.status);
|
return json(r.data, r.status);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
// Switch palette, light/dark and font from the header's display settings.
|
// Switch palette, light/dark and font from the header's display settings.
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import Icon from '../components/Icon.astro';
|
import Icon from '../components/Icon.astro';
|
||||||
|
import TagsBox from '../components/TagsBox.astro';
|
||||||
if (import.meta.env.PROD) return new Response(null, { status: 404 });
|
if (import.meta.env.PROD) return new Response(null, { status: 404 });
|
||||||
const colours = [
|
const colours = [
|
||||||
['surfaces', ['surface-page', 'surface-raised', 'surface-poem', 'surface-sunken']],
|
['surfaces', ['surface-page', 'surface-raised', 'surface-poem', 'surface-sunken']],
|
||||||
@ -72,6 +73,9 @@ const radii = ['sm', 'md', 'lg', 'pill'];
|
|||||||
<span class="status s-published">شائع شدہ</span><span class="status s-returned">واپس</span>
|
<span class="status s-published">شائع شدہ</span><span class="status s-returned">واپس</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2>ٹیگ (صفحے کے آخر میں)</h2>
|
||||||
|
<TagsBox tags={['عشق', 'فراق', 'وصال', 'تصوف', 'شراب', 'بہار', 'خزاں', 'دل', 'حسن', 'وفا', 'جفا', 'تقدیر', 'دنیا', 'موت', 'زندگی', 'یاد'].map((name, i) => ({ type: i % 3 ? 'موضوع' : 'صنف', name }))} />
|
||||||
|
|
||||||
<h2>خانے</h2>
|
<h2>خانے</h2>
|
||||||
<form class="account-form" onsubmit="return false">
|
<form class="account-form" onsubmit="return false">
|
||||||
<label>نام <input value="مرزا غالب" /></label>
|
<label>نام <input value="مرزا غالب" /></label>
|
||||||
|
|||||||
@ -20,9 +20,10 @@ const marks = token && b.published && b.kind === 'pdf' ? (await asUser(token, `/
|
|||||||
const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size / 1024)))} KB` : `${ud((b.size / 1048576).toFixed(1))} MB`;
|
const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size / 1024)))} KB` : `${ud((b.size / 1048576).toFixed(1))} MB`;
|
||||||
import Icon from '../../components/Icon.astro';
|
import Icon from '../../components/Icon.astro';
|
||||||
---
|
---
|
||||||
<Base title={`${b.title} · ${b.poet}`}>
|
<Base title={`${b.title} · ${b.poet}`} author={b.poet} ogType="book" description={`${b.title}: ${b.poet}${b.coauthors.length ? '، ' + b.coauthors.map((c: any) => c.name).join('، ') : ''} کی کتاب، ${KIND[b.kind]} میں`}
|
||||||
|
jsonld={{ '@context': 'https://schema.org', '@type': 'Book', name: b.title, inLanguage: 'ur', author: [b.poet, ...b.coauthors.map((c: any) => c.name)].map((name) => ({ '@type': 'Person', name })) }}>
|
||||||
<nav class="crumbs"><a href={b.poet_url}>{b.poet}</a> » <a href={`${b.poet_url}/ebooks?kind=${b.kind}`}>ای بکس</a></nav>
|
<nav class="crumbs"><a href={b.poet_url}>{b.poet}</a> » <a href={`${b.poet_url}/ebooks?kind=${b.kind}`}>ای بکس</a></nav>
|
||||||
<h1>{b.title}</h1>
|
<h1 class="lit">{b.title}</h1>
|
||||||
{!b.published && <p class="form-error center">یہ کتاب ابھی جائزے میں ہے؛ صرف موڈریٹر دیکھ سکتے ہیں۔</p>}
|
{!b.published && <p class="form-error center">یہ کتاب ابھی جائزے میں ہے؛ صرف موڈریٹر دیکھ سکتے ہیں۔</p>}
|
||||||
<p class="center muted">
|
<p class="center muted">
|
||||||
{KIND[b.kind]}{mb && <> · {mb}</>}{b.source && <> · ماخذ: {b.source}</>}{b.licence && <> · {b.licence}</>}
|
{KIND[b.kind]}{mb && <> · {mb}</>}{b.source && <> · ماخذ: {b.source}</>}{b.licence && <> · {b.licence}</>}
|
||||||
|
|||||||
20
web/src/pages/health.ts
Normal file
20
web/src/pages/health.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// /health for an uptime monitor (divan-deploy README): 200 when the site, the API and the database all answer,
|
||||||
|
// 503 otherwise, with which part is down. Never cached.
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
let api = 'down', db = 'down';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/health`, { signal: AbortSignal.timeout(3000) });
|
||||||
|
api = 'ok';
|
||||||
|
db = (await r.json().catch(() => ({}))).db ?? 'down';
|
||||||
|
} catch {}
|
||||||
|
const ok = api === 'ok' && db === 'ok';
|
||||||
|
if (!ok) console.error(JSON.stringify({ level: 'error', time: new Date().toISOString(), msg: 'health check failed', api, db }));
|
||||||
|
return new Response(JSON.stringify({ ok, site: 'ok', api, db, ms: Date.now() - started }), {
|
||||||
|
status: ok ? 200 : 503, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
||||||
|
});
|
||||||
|
};
|
||||||
118
web/src/pages/help.astro
Normal file
118
web/src/pages/help.astro
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
// How to use the site: each feature in a line or two. Readers' part for everyone; moderators and admins also see how
|
||||||
|
// their tools work (the writing rules are in /mod/guide)
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import Icon from '../components/Icon.astro';
|
||||||
|
import { brand } from '../lib/brand';
|
||||||
|
const parts: [any, string, string[]][] = [
|
||||||
|
['user', 'اکاؤنٹ', [
|
||||||
|
'پڑھنے کے لیے اکاؤنٹ ضروری نہیں۔ محفوظ کرنے، نوٹس اور ذخیرۂ الفاظ کے لیے «نیا اکاؤنٹ» بنائیں: صرف ای میل اور پاس ورڈ۔',
|
||||||
|
'«میرا اکاؤنٹ» میں پورا نام اور تعارف لکھ سکتے ہیں، پاس ورڈ بدل سکتے ہیں، اور اکاؤنٹ اپنے سب ڈیٹا سمیت ختم کر سکتے ہیں۔',
|
||||||
|
]],
|
||||||
|
['search', 'تلاش', [
|
||||||
|
'اوپر کے خانے میں کوئی شعر، لفظ یا عبارت لکھیں: شاعر، کتابیں اور کلام الگ الگ ملیں گے، اور نتائج کو شاعر کے لحاظ سے محدود کر سکتے ہیں۔',
|
||||||
|
'کسی موضوع کا کلام: tag:عشق یا ٹیگ:عشق لکھیں (کئی لفظوں کا نام ہو تو کوٹس میں)۔',
|
||||||
|
'اردو کی بورڈ نہ ہو تو «اب» دبا کر رومن حروف میں لکھیں (dil، ghazal)، یا کی بورڈ کا نشان دبا کر اسکرین والا اردو کی بورڈ کھولیں۔',
|
||||||
|
]],
|
||||||
|
['dict', 'لفظ کے معنی', [
|
||||||
|
'کلام میں کسی ایک لفظ کو منتخب کریں: ساتھ ہی اس کے معنی، تلفظ اور اصل (اردو، فارسی، عربی لغات سے) نظر آئیں گے۔',
|
||||||
|
'کوئی عبارت منتخب کریں تو ایک چھوٹا مینو کھلتا ہے: معنی، ذخیرۂ الفاظ، محفوظ کریں، نوٹ، تلاش، کاپی اور شیئر۔',
|
||||||
|
]],
|
||||||
|
['bookmark', 'شعر کے ساتھ والے نشان', [
|
||||||
|
'کسی شعر پر ماؤس لائیں تو اس کے نیچے بائیں طرف چار نشان آتے ہیں: محفوظ کریں، نوٹ، تصویر بنا کر بھیجیں، اور تصویر ڈاؤن لوڈ۔',
|
||||||
|
'فون پر شعر کو چھوئیں، پھر ⋯ دبائیں: یہی چاروں کام ایک مینو میں ملیں گے۔',
|
||||||
|
'تصویر میں شعر نستعلیق میں، شاعر کے نام اور دیوان کے نشان کے ساتھ بنتا ہے، واٹس ایپ یا انسٹاگرام پر بھیجنے کے لیے۔',
|
||||||
|
]],
|
||||||
|
['note', 'نوٹس', [
|
||||||
|
'کسی شعر یا منتخب عبارت پر اپنا نوٹ لکھیں، کسی بھی زبان میں۔ نوٹ محفوظ کرنے سے شعر محفوظ شدہ میں نہیں جاتا: دونوں الگ ہیں۔',
|
||||||
|
'آپ کے سب نوٹس «نوٹس» کے صفحے پر ہیں (اوپر کا نشان)، جہاں انھیں بدل یا ہٹا سکتے ہیں۔',
|
||||||
|
]],
|
||||||
|
['words', 'محفوظ شدہ اور ذخیرۂ الفاظ', [
|
||||||
|
'شاعر، کتابیں، کلام، اشعار، عبارتیں اور ای بکس کے صفحات «محفوظ شدہ» میں جمع ہوتے ہیں؛ پسندیدہ شعرا «پسندیدہ لکھاری» میں۔',
|
||||||
|
'جو الفاظ یاد رکھنے ہوں انھیں «ذخیرۂ الفاظ» میں ڈالیں: ہر لفظ اپنے شعر کے ساتھ رہتا ہے۔ ان سب کے لیے اکاؤنٹ چاہیے۔',
|
||||||
|
]],
|
||||||
|
['dict', 'مطالعے کا انداز', [
|
||||||
|
'کلام، کتاب یا شاعر کے صفحے پر کتاب کا نشان دبائیں: صرف متن، راستہ اور دیوان کا نشان رہ جاتے ہیں، بڑے حروف میں۔ اوپر ✕ سے واپس آئیں۔',
|
||||||
|
'براؤزر کا اپنا ریڈر (سفاری ریڈر، فائرفاکس ریڈر ویو) بھی کام کرتا ہے، اور پرنٹ کرنے پر صرف متن چھپتا ہے۔',
|
||||||
|
]],
|
||||||
|
['settings', 'خط اور رنگ', [
|
||||||
|
'اوپر کے ترتیبات والے نشان سے: نسخ یا نستعلیق، کئی رنگ، مشرقی (۱۲۳) یا مغربی (123) ہندسے، اور روشن، تاریک یا خودکار۔ آپ کی پسند یاد رہتی ہے۔',
|
||||||
|
]],
|
||||||
|
['upload', 'ای بکس', [
|
||||||
|
'ہر شاعر کے صفحے پر «ای بکس»: پی ڈی ایف دائیں سے بائیں صفحہ بہ صفحہ، ای پب اور سادہ متن۔ کسی صفحے کو بعد کے لیے محفوظ کر سکتے ہیں۔',
|
||||||
|
]],
|
||||||
|
['tag', 'ٹیگ اور نئی تبدیلیاں', [
|
||||||
|
'کلام موضوعات سے بھی ملتا ہے: ہر صفحے کے آخر میں «ٹیگ» کھولیں، یا ٹیگ کا صفحہ دیکھیں۔',
|
||||||
|
'نئے ورژن اور نئی ای بکس RSS میں آتی ہیں، کسی بھی فیڈ ریڈر میں۔',
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
// moderators (L2, L1) and admins: their tools, step by step
|
||||||
|
const modParts: [any, string, string[]][] = [
|
||||||
|
['send', 'موڈریشن کا طریقہ', [
|
||||||
|
'ہر تبدیلی پہلے مسودہ ہوتی ہے۔ موڈریٹر (L2) لکھ کر جائزے کے لیے بھیجتا ہے؛ سینئر موڈریٹر (L1) منظور کرتا ہے یا وجہ لکھ کر واپس بھیجتا ہے؛ ایڈمن شائع کرتا ہے۔ L1 کا اپنا کام سیدھا ایڈمن کو جاتا ہے۔',
|
||||||
|
'شائع ہونے پر تبدیلی دیوان کے کھلے ڈیٹا (divan-data) میں آپ کے عوامی نام سے درج ہوتی ہے، ای میل کبھی نہیں۔ قارئین صرف شائع شدہ ورژن دیکھتے ہیں۔',
|
||||||
|
'«موڈریشن» میں آپ کے مسودے اور جائزے یا اشاعت کے منتظر کام ہیں؛ «سرگرمی» میں کس نے کب کیا کیا، شخص، قسم اور قدم کے لحاظ سے چھانٹ کر۔',
|
||||||
|
]],
|
||||||
|
['edit', 'کلام میں ترمیم', [
|
||||||
|
'کلام کے صفحے پر «ترمیم کریں»: ایڈیٹر کھلتا ہے جس میں متن نستعلیق یا نسخ میں ویسا ہی دکھتا ہے جیسا شائع ہوگا۔ «سورس» میں وہی متن دیوان کی نشانیوں کے ساتھ۔',
|
||||||
|
'نیچے والا اردو کی بورڈ لکھتے ہوئے ساتھ رہتا ہے؛ مطلع، مقطع اور حسنِ مطلع کے بٹن، «+ معلومات» (ماخذ اور دوسری تفصیل) اور «+ ٹیگ» اوپر ہیں۔',
|
||||||
|
'محفوظ کریں اور خلاصہ لکھیں (کیا اور کیوں بدلا)۔ کچھ نہ بدلا ہو تو مسودہ نہیں رکھا جاتا۔ لکھنے کے اصول: «رہنمائی»۔',
|
||||||
|
]],
|
||||||
|
['history', 'تاریخچہ، موازنہ اور واپسی', [
|
||||||
|
'ہر کلام، ترتیب اور ٹیگ کا تاریخچہ: ہر ورژن، کس نے لکھا، کس نے منظور اور شائع کیا۔ «فرق» سے ہر ورژن کی تبدیلی، «موازنہ کریں» سے کوئی دو ورژن۔',
|
||||||
|
'کوئی پرانا ورژن «واپس لائیں» سے مسودہ بن جاتا ہے، اور عام ترمیم کی طرح جائزے اور اشاعت سے گزرتا ہے۔',
|
||||||
|
]],
|
||||||
|
['user', 'شاعر اور کتابیں', [
|
||||||
|
'شاعر کے صفحے پر «تفصیل»: نام، تخلص، پیدائش اور وفات کے سال (عیسوی) اور تعارف۔ کتاب یا باب کے صفحے پر «عنوان»۔ یہ بھی مسودہ بن کر جائزے اور اشاعت سے گزرتے ہیں، اور ان کا بھی تاریخچہ رہتا ہے۔',
|
||||||
|
]],
|
||||||
|
['arrange', 'ترتیب', [
|
||||||
|
'شاعر یا کتاب کے صفحے پر «ترتیب دیں»: کلام، باب اور کتابیں اوپر نیچے کریں، یا کلام ایک حصے سے دوسرے میں لے جائیں۔ اس کی اجازت ترمیم سے الگ ہے۔',
|
||||||
|
]],
|
||||||
|
['tag', 'ٹیگ', [
|
||||||
|
'صفحے کے آخر میں ٹیگ کے حصے سے «ٹیگ لگائیں»: موضوع، صنف، بحر، شخصیت، مقام، دور یا آزاد ٹیگ، پورے کلام، کتاب یا کسی ایک شعر پر۔',
|
||||||
|
]],
|
||||||
|
['upload', 'ای بکس', [
|
||||||
|
'شاعر کے «ای بکس» صفحے پر «ای بک شامل کریں»: پی ڈی ایف، ای پب، متن یا archive.org کا ربط؛ زبان، پڑھنے کی سمت، مصنف اور شریک مصنفین، ٹیگ۔',
|
||||||
|
'سرورق خود بنتا ہے، یا «اپنی تصویر سرورق بنائیں»۔ کتاب بانٹنے کا حق (عوامی ملکیت یا اجازت) ضروری ہے؛ جائزے کے بعد ہی شائع ہوتی ہے۔',
|
||||||
|
]],
|
||||||
|
['home', 'سرورق', [
|
||||||
|
'جنھیں سائٹ کی اجازت ہو وہ «سرورق کے حصے» میں صفحۂ اول کے حصے بنا، نام بدل اور ترتیب دے سکتے ہیں (حروفِ تہجی یا زمانے کے لحاظ سے)۔',
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
const adminParts: [any, string, string[]][] = [
|
||||||
|
['admin', 'صارفین اور کردار', [
|
||||||
|
'«ایڈمن» میں صارفین نام یا ای میل سے ڈھونڈیں اور کردار دیں: قاری، موڈریٹر (L2)، سینئر موڈریٹر (L1) یا ایڈمن۔',
|
||||||
|
'«معطل کریں» سے کوئی لاگ ان نہیں کر سکتا؛ «پاس ورڈ ری سیٹ» ایک عارضی پاس ورڈ ایک بار دکھاتا ہے جو آپ قاری کو دیتے ہیں۔ ہر قدم «ریکارڈ» میں درج ہوتا ہے۔',
|
||||||
|
]],
|
||||||
|
['key', 'اجازتیں', [
|
||||||
|
'موڈریٹر کے صفحے پر «نئی اجازت»: دائرہ (سب، کوئی شاعر، کتاب یا کلام)، مواد (کلام، شاعر، کتابیں، لغت، سائٹ، ٹیگ، ای بکس) اور کام (بنانا، ترمیم، حذف، ترتیب)۔ اجازت کے بغیر موڈریٹر کچھ نہیں بدل سکتا۔',
|
||||||
|
]],
|
||||||
|
['scale', 'اعداد و شمار', [
|
||||||
|
'«اعداد و شمار» میں روزانہ صفحات اور قارئین، سب سے زیادہ پڑھا گیا کلام، قارئین کہاں سے آئے، موڈریشن کا کام اور اکاؤنٹ۔ بغیر کوکی کے، کسی کی شناخت کے بغیر۔',
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
const role = Astro.locals.user?.role ?? '';
|
||||||
|
const sections: [string, string, typeof parts][] = [
|
||||||
|
['readers', 'پڑھنے والوں کے لیے', parts],
|
||||||
|
...(['mod-l2', 'mod-l1', 'admin'].includes(role) ? [['mods', 'موڈریٹروں کے لیے', modParts] as [string, string, typeof parts]] : []),
|
||||||
|
...(role === 'admin' ? [['admins', 'ایڈمن کے لیے', adminParts] as [string, string, typeof parts]] : []),
|
||||||
|
];
|
||||||
|
---
|
||||||
|
<Base title="استعمال کا طریقہ" description={`${brand.name} کی سہولتیں: تلاش، معنی، محفوظ، نوٹس، مطالعہ، ای بکس`}>
|
||||||
|
<h1>استعمال کا طریقہ</h1>
|
||||||
|
<p class="muted center">{brand.name} پر پڑھنے، ڈھونڈنے اور یاد رکھنے کی سب سہولتیں، مختصراً</p>
|
||||||
|
{sections.length > 1 && <nav class="center">{sections.map(([id, title], i) => <>{i > 0 && ' · '}<a href={`#${id}`}>{title}</a></>)}</nav>}
|
||||||
|
{sections.map(([id, title, list]) => (
|
||||||
|
<>
|
||||||
|
{sections.length > 1 && <h2 id={id} class="center">{title}</h2>}
|
||||||
|
<div class="help">
|
||||||
|
{list.map(([icon, t, lines]) => (
|
||||||
|
<section>
|
||||||
|
<h3><span class="help-icon" aria-hidden="true"><Icon name={icon} size={20} /></span>{t}</h3>
|
||||||
|
{lines.map((l) => <p>{l}</p>)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Base>
|
||||||
44
web/src/pages/mod/compare/[entity]/[id].astro
Normal file
44
web/src/pages/mod/compare/[entity]/[id].astro
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
// Two published versions side by side as a diff (#51); "یہ ورژن واپس لائیں" makes a draft of the older one, which then
|
||||||
|
// goes through review like any edit
|
||||||
|
import Base from '../../../../layouts/Base.astro';
|
||||||
|
import ModNav from '../../../../components/ModNav.astro';
|
||||||
|
import { asUser, COOKIE } from '../../../../lib/auth';
|
||||||
|
import { ud } from '../../../../lib/urdu';
|
||||||
|
|
||||||
|
const me = Astro.locals.user;
|
||||||
|
if (!me) return Astro.redirect(`/signin?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`);
|
||||||
|
const token = Astro.cookies.get(COOKIE)!.value, { entity, id } = Astro.params;
|
||||||
|
let error = '';
|
||||||
|
if (Astro.request.method === 'POST') {
|
||||||
|
const r = await asUser(token, `/api/mod/revert/${entity}/${id}`, { version: Number((await Astro.request.formData()).get('version')) });
|
||||||
|
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
|
||||||
|
error = r.data.error ?? 'کچھ غلط ہو گیا';
|
||||||
|
}
|
||||||
|
const res = await asUser(token, `/api/mod/compare/${entity}/${id}${Astro.url.search}`);
|
||||||
|
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
|
||||||
|
const { title, url, version, a, b, diff, changes, may } = res.data;
|
||||||
|
const label = (v: number) => (v ? `ورژن ${ud(v)}` : 'ویکی ماخذ کا متن');
|
||||||
|
const HISTORY: Record<string, string> = { work: `/mod/work/${id}`, order: `/mod/order/${id}`, 'tags-work': `/mod/tags/work/${id}`, 'tags-category': `/mod/tags/category/${id}`,
|
||||||
|
poet: `/mod/details/poet/${id}`, book: `/mod/details/book/${id}` };
|
||||||
|
const older = Math.min(a, b);
|
||||||
|
---
|
||||||
|
<Base title={`موازنہ: ${title}`}>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<ModNav />
|
||||||
|
<p class="center"><a href={url}>صفحہ</a> · <a href={HISTORY[entity]}>تاریخچہ</a> · موجودہ: {label(version)}</p>
|
||||||
|
<p class="center"><span class="del-key">−</span> {label(a)} <span class="add-key">+</span> {label(b)} · {ud(changes)} سطریں بدلیں</p>
|
||||||
|
{error && <p class="form-error" role="alert">{error}</p>}
|
||||||
|
{changes === 0 ? <p class="muted center">دونوں ورژن ایک جیسے ہیں۔</p> : (
|
||||||
|
<div class="diff" dir="rtl">
|
||||||
|
{diff.map((d: any) => d.op === '=' ? null : <div class={d.op === '+' ? 'add' : 'del'}><span>{d.op === '+' ? '+' : '−'}</span>{d.text || ' '}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{may.revert && older < version && (
|
||||||
|
<form method="post" class="center">
|
||||||
|
<input type="hidden" name="version" value={older} />
|
||||||
|
<button class="save-like">{label(older)} واپس لائیں</button>
|
||||||
|
<p class="muted">ایک مسودہ بنے گا جو عام ترمیم کی طرح جائزے اور اشاعت سے گزرے گا۔</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Base>
|
||||||
39
web/src/pages/mod/details/[kind]/[id].astro
Normal file
39
web/src/pages/mod/details/[kind]/[id].astro
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
// A poet's details or a book/section's title (#32): what they are now, their versions and drafts; "ترمیم کریں" starts a
|
||||||
|
// draft (also reached by POST from the poet or book page)
|
||||||
|
import Base from '../../../../layouts/Base.astro';
|
||||||
|
import ModNav from '../../../../components/ModNav.astro';
|
||||||
|
import History from '../../../../components/History.astro';
|
||||||
|
import { asUser, COOKIE } from '../../../../lib/auth';
|
||||||
|
import { ud } from '../../../../lib/urdu';
|
||||||
|
import Icon from '../../../../components/Icon.astro';
|
||||||
|
|
||||||
|
const me = Astro.locals.user;
|
||||||
|
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
|
||||||
|
const token = Astro.cookies.get(COOKIE)!.value, { kind, id } = Astro.params;
|
||||||
|
let error = '';
|
||||||
|
if (Astro.request.method === 'POST') {
|
||||||
|
const r = await asUser(token, `/api/mod/details/${kind}/${id}/draft`, {});
|
||||||
|
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
|
||||||
|
error = r.data.error ?? 'کچھ غلط ہو گیا';
|
||||||
|
}
|
||||||
|
const res = await asUser(token, `/api/mod/details/${kind}/${id}`);
|
||||||
|
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
|
||||||
|
const { target, version, fields, history, may } = res.data;
|
||||||
|
const poet = kind === 'poet';
|
||||||
|
---
|
||||||
|
<Base title={`${poet ? 'شاعر' : 'عنوان'}: ${target.title}`}>
|
||||||
|
<h1>{poet ? 'شاعر' : 'عنوان'}: {target.title}</h1>
|
||||||
|
<ModNav />
|
||||||
|
<p class="center"><a href={target.url}>صفحہ</a> · {version ? `موجودہ: ورژن ${ud(version)}` : 'ویکی ماخذ اور ویکیپیڈیا سے (ابھی دیوان کا کوئی ورژن نہیں)'}</p>
|
||||||
|
<dl class="eb-meta">
|
||||||
|
{poet ? <>
|
||||||
|
<dt>نام</dt><dd>{fields['نام']}</dd><dt>تخلص</dt><dd>{fields['تخلص']}</dd>
|
||||||
|
<dt>پیدائش</dt><dd>{fields['پیدائش'] ? ud(fields['پیدائش']) : '—'}</dd><dt>وفات</dt><dd>{fields['وفات'] ? ud(fields['وفات']) : '—'}</dd>
|
||||||
|
</> : <><dt>عنوان</dt><dd>{fields['عنوان']}</dd></>}
|
||||||
|
</dl>
|
||||||
|
{poet && fields['تعارف'] && <div class="poet-intro">{fields['تعارف'].split(/\n\s*\n/).map((p: string) => <p>{p}</p>)}</div>}
|
||||||
|
{error && <p class="form-error" role="alert">{error}</p>}
|
||||||
|
{may.edit && <form method="post" class="center"><button class="save-like"><Icon name="edit" size={17} /> ترمیم کریں</button></form>}
|
||||||
|
<History entity={kind!} id={target.id} history={history} version={version} empty="ابھی کوئی ترمیم نہیں۔" />
|
||||||
|
</Base>
|
||||||
@ -13,7 +13,7 @@ const ex = {
|
|||||||
| ردیف = یاد ہے
|
| ردیف = یاد ہے
|
||||||
| قافیہ = بہانا، زمانا
|
| قافیہ = بہانا، زمانا
|
||||||
| ماخذ = ویکی ماخذ
|
| ماخذ = ویکی ماخذ
|
||||||
| ماخذ_ربط = https://ur.wikisource.org/…
|
| ماخذ_لنک = https://ur.wikisource.org/…
|
||||||
}}`,
|
}}`,
|
||||||
ghazal: `<poem>
|
ghazal: `<poem>
|
||||||
چپکے چپکے رات دن آنسو بہانا یاد ہے
|
چپکے چپکے رات دن آنسو بہانا یاد ہے
|
||||||
|
|||||||
@ -5,15 +5,22 @@ import ModNav from '../../components/ModNav.astro';
|
|||||||
import { asUser, COOKIE } from '../../lib/auth';
|
import { asUser, COOKIE } from '../../lib/auth';
|
||||||
import { STATUS, EVENT, when } from '../../lib/mod';
|
import { STATUS, EVENT, when } from '../../lib/mod';
|
||||||
import { ud } from '../../lib/urdu';
|
import { ud } from '../../lib/urdu';
|
||||||
|
import { ago } from '../../lib/roles';
|
||||||
|
import Icon from '../../components/Icon.astro';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
if (!me) return Astro.redirect('/signin?next=/mod');
|
if (!me) return Astro.redirect('/signin?next=/mod');
|
||||||
if (!['mod-l2', 'mod-l1', 'admin'].includes(me.role)) return new Response('صرف موڈریٹرز کے لیے', { status: 403 });
|
if (!['mod-l2', 'mod-l1', 'admin'].includes(me.role)) return new Response('صرف موڈریٹرز کے لیے', { status: 403 });
|
||||||
const token = Astro.cookies.get(COOKIE)!.value, showLog = Astro.url.searchParams.has('log');
|
const token = Astro.cookies.get(COOKIE)!.value, showLog = Astro.url.searchParams.has('log');
|
||||||
const queue = showLog ? null : (await asUser(token, '/api/mod/queue')).data;
|
const queue = showLog ? null : (await asUser(token, '/api/mod/queue')).data;
|
||||||
const log = showLog ? (await asUser(token, '/api/mod/log')).data : null;
|
// the log filtered by person, kind of change and step (#51)
|
||||||
|
const f = Object.fromEntries(['who', 'kind', 'action'].map((k) => [k, Astro.url.searchParams.get(k) ?? '']));
|
||||||
|
const log = showLog ? (await asUser(token, `/api/mod/log?${new URLSearchParams(f)}`)).data : null;
|
||||||
// what kind of change a row is (a work's text needs no label)
|
// what kind of change a row is (a work's text needs no label)
|
||||||
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ' };
|
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ', poet: 'شاعر: ', book: 'عنوان: ' };
|
||||||
|
// each kind of change has its icon; each event its tone on the timeline
|
||||||
|
const ICON: Record<string, any> = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload', poet: 'user', book: 'edit' };
|
||||||
|
const TONE: Record<string, string> = { published: 'ok', approved: 'action', submitted: 'accent', returned: 'danger', rejected: 'danger' };
|
||||||
const groups = queue ? [
|
const groups = queue ? [
|
||||||
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
|
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
|
||||||
...(me.role !== 'mod-l2' ? [['جائزے کے منتظر', queue.review, 'جائزے کے لیے کچھ نہیں۔']] : []),
|
...(me.role !== 'mod-l2' ? [['جائزے کے منتظر', queue.review, 'جائزے کے لیے کچھ نہیں۔']] : []),
|
||||||
@ -24,36 +31,54 @@ const groups = queue ? [
|
|||||||
<h1>موڈریشن</h1>
|
<h1>موڈریشن</h1>
|
||||||
<ModNav current={showLog ? 'log' : 'queue'} />
|
<ModNav current={showLog ? 'log' : 'queue'} />
|
||||||
{Astro.url.searchParams.has('unchanged') && <p class="form-done" role="status">متن میں کوئی تبدیلی نہیں تھی، اس لیے مسودہ نہیں رکھا گیا۔</p>}
|
{Astro.url.searchParams.has('unchanged') && <p class="form-done" role="status">متن میں کوئی تبدیلی نہیں تھی، اس لیے مسودہ نہیں رکھا گیا۔</p>}
|
||||||
{groups.map(([title, items, empty]) => (
|
{queue && (
|
||||||
<section class="lib-section">
|
<nav class="stat-tiles" aria-label="خلاصہ">
|
||||||
|
{groups.map(([title, items], i) => <a class="stat" href={`#q${i}`}><strong>{ud(items.length)}</strong><span>{title}</span></a>)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
{groups.map(([title, items, empty], i) => (
|
||||||
|
<section class="queue" id={`q${i}`}>
|
||||||
<h2>{title} <small class="muted">{ud(items.length)}</small></h2>
|
<h2>{title} <small class="muted">{ud(items.length)}</small></h2>
|
||||||
{items.length === 0 ? <p class="muted center">{empty}</p> : (
|
{items.length === 0 ? <p class="empty"><span aria-hidden="true">✤</span>{empty}</p> : (
|
||||||
<ul class="lib-list">
|
<ul class="queue-list">
|
||||||
{items.map((r) => (
|
{items.map((r) => (
|
||||||
<li>
|
<li><a class="queue-card" href={`/mod/rev/${r.id}`}>
|
||||||
<a class="lib-title" href={`/mod/rev/${r.id}`}>{KIND[r.entity] ?? ''}{r.title}</a>
|
<span class="kind" aria-hidden="true"><Icon name={ICON[r.entity] ?? 'edit'} size={18} /></span>
|
||||||
|
<span class="what"><strong>{KIND[r.entity] ?? ''}{r.title}</strong>
|
||||||
|
<small class="muted">{r.summary || 'بغیر خلاصہ'} · <bdi dir="ltr">{r.author_email}</bdi></small></span>
|
||||||
<span class={`status s-${r.status}`}>{STATUS[r.status]}</span>
|
<span class={`status s-${r.status}`}>{STATUS[r.status]}</span>
|
||||||
<p class="muted small-line">{r.summary || 'بغیر خلاصہ'} · <bdi dir="ltr">{r.author_email}</bdi> · <bdi dir="ltr">{when(r.updated_at)}</bdi></p>
|
<span class="seen muted" title={when(r.updated_at)}>{ago(r.updated_at)}</span>
|
||||||
</li>
|
</a></li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
{log && (
|
{log && (
|
||||||
<table class="admin-table">
|
<form method="get" class="version-tools" aria-label="ریکارڈ چھانیں">
|
||||||
<thead><tr><th>وقت (UTC)</th><th>کس نے</th><th>کیا</th><th>کلام</th><th>تبصرہ</th></tr></thead>
|
<input type="hidden" name="log" />
|
||||||
<tbody>
|
<input type="search" name="who" value={f.who} placeholder="کس نے (ای میل)" aria-label="کس نے" dir="auto" />
|
||||||
{log.entries.map((e: any) => (
|
<select name="kind" aria-label="کس چیز میں">
|
||||||
<tr>
|
{[['', 'سب تبدیلیاں'], ['work', 'متن'], ['order', 'ترتیب'], ['tags', 'ٹیگ'], ['ebook', 'ای بکس'], ['poet', 'شاعر'], ['book', 'کتاب کا عنوان']].map(([v, t]) => <option value={v} selected={f.kind === v}>{t}</option>)}
|
||||||
<td><bdi dir="ltr">{when(e.at)}</bdi></td>
|
</select>
|
||||||
<td><bdi dir="ltr">{e.actor_email}</bdi></td>
|
<select name="action" aria-label="کیا کیا">
|
||||||
<td>{EVENT[e.action] ?? e.action}{e.version && e.action === 'published' ? ` (ورژن ${ud(e.version)})` : ''}</td>
|
<option value="">ہر قدم</option>
|
||||||
<td><a href={`/mod/rev/${e.revision}`}>{KIND[e.entity] ?? ''}{e.title}</a></td>
|
{Object.entries(EVENT).map(([v, t]) => <option value={v} selected={f.action === v}>{t}</option>)}
|
||||||
<td>{e.comment}</td>
|
</select>
|
||||||
</tr>
|
<button>دکھائیں</button>
|
||||||
))}
|
</form>
|
||||||
</tbody>
|
)}
|
||||||
</table>
|
{log && log.entries.length === 0 && <p class="muted center">اس چھانٹی میں کچھ نہیں۔</p>}
|
||||||
|
{log && (
|
||||||
|
<ol class="timeline">
|
||||||
|
{log.entries.map((e: any) => (
|
||||||
|
<li data-tone={TONE[e.action] ?? 'muted'}>
|
||||||
|
<header><bdi dir="ltr" class="who">{e.actor_email}</bdi><time class="muted" datetime={e.at} title={when(e.at)}>{ago(e.at)}</time></header>
|
||||||
|
<p><strong>{EVENT[e.action] ?? e.action}{e.version && e.action === 'published' ? ` (ورژن ${ud(e.version)})` : ''}</strong>:
|
||||||
|
<a href={`/mod/rev/${e.revision}`}>{KIND[e.entity] ?? ''}{e.title}</a></p>
|
||||||
|
{e.comment && <blockquote>{e.comment}</blockquote>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
)}
|
)}
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
// A book/section's arrangements (#41): its current order, published versions and drafts; "ترتیب دیں" starts a draft
|
// A book/section's arrangements (#41): its current order, published versions and drafts; "ترتیب دیں" starts a draft
|
||||||
import Base from '../../../layouts/Base.astro';
|
import Base from '../../../layouts/Base.astro';
|
||||||
|
import History from '../../../components/History.astro';
|
||||||
import ModNav from '../../../components/ModNav.astro';
|
import ModNav from '../../../components/ModNav.astro';
|
||||||
import { asUser, COOKIE } from '../../../lib/auth';
|
import { asUser, COOKIE } from '../../../lib/auth';
|
||||||
import { STATUS, when } from '../../../lib/mod';
|
|
||||||
import { ud } from '../../../lib/urdu';
|
import { ud } from '../../../lib/urdu';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
@ -26,21 +26,5 @@ const { section, version, history, count, may } = res.data;
|
|||||||
{version ? `موجودہ ترتیب: ورژن ${ud(version)}` : 'ویکی ماخذ کی ترتیب (ابھی دیوان کی کوئی ترتیب نہیں)'}</p>
|
{version ? `موجودہ ترتیب: ورژن ${ud(version)}` : 'ویکی ماخذ کی ترتیب (ابھی دیوان کی کوئی ترتیب نہیں)'}</p>
|
||||||
{error && <p class="form-error" role="alert">{error}</p>}
|
{error && <p class="form-error" role="alert">{error}</p>}
|
||||||
{may.arrange && count > 1 && <form method="post" class="center"><button class="save-like">ترتیب دیں</button></form>}
|
{may.arrange && count > 1 && <form method="post" class="center"><button class="save-like">ترتیب دیں</button></form>}
|
||||||
<table class="admin-table">
|
<History entity="order" id={section.id} history={history} version={version} empty="ابھی ترتیب میں کوئی تبدیلی نہیں۔" />
|
||||||
<thead><tr><th>ورژن</th><th>حالت</th><th>خلاصہ</th><th>لکھنے والے</th><th>جائزہ</th><th>اشاعت</th><th>تاریخ</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{history.map((h: any) => (
|
|
||||||
<tr>
|
|
||||||
<td><a href={`/mod/rev/${h.id}`}>{h.version ? ud(h.version) : '—'}</a></td>
|
|
||||||
<td><span class={`status s-${h.status}`}>{STATUS[h.status]}</span></td>
|
|
||||||
<td>{h.summary}</td>
|
|
||||||
<td><bdi dir="ltr">{h.author_email}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{h.reviewer_email ?? ''}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{h.publisher_email ?? ''}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{when(h.published_at ?? h.created_at)}</bdi></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{history.length === 0 && <p class="muted center">ابھی ترتیب میں کوئی تبدیلی نہیں۔</p>}
|
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@ -17,7 +17,12 @@ let error = '', done = '';
|
|||||||
if (Astro.request.method === 'POST') {
|
if (Astro.request.method === 'POST') {
|
||||||
const f = await Astro.request.formData(), act = String(f.get('act'));
|
const f = await Astro.request.formData(), act = String(f.get('act'));
|
||||||
let r: any = { ok: true, data: {} };
|
let r: any = { ok: true, data: {} };
|
||||||
if (act === 'save' || act === 'submit') r = await asUser(token, `/api/mod/revisions/${id}/save`, { content: f.get('content'), summary: f.get('summary') });
|
// a poet's or book's details come as fields (#32): one "label: value" per line, the intro last
|
||||||
|
const clean = (k: string) => String(f.get(k) ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
const content = f.get('details') === 'book' ? `عنوان: ${clean('عنوان')}`
|
||||||
|
: f.get('details') === 'poet' ? `${['نام', 'تخلص', 'پیدائش', 'وفات'].map((k) => `${k}: ${clean(k)}`).join('\n')}\nتعارف:\n${String(f.get('تعارف') ?? '').trim()}`
|
||||||
|
: f.get('content');
|
||||||
|
if (act === 'save' || act === 'submit') r = await asUser(token, `/api/mod/revisions/${id}/save`, { content, summary: f.get('summary') });
|
||||||
if (r.ok && r.data.discarded) return Astro.redirect('/mod?unchanged=1'); // nothing changed: no draft kept
|
if (r.ok && r.data.discarded) return Astro.redirect('/mod?unchanged=1'); // nothing changed: no draft kept
|
||||||
if (r.ok && act !== 'save') r = await asUser(token, `/api/mod/revisions/${id}/${act}`, { comment: f.get('comment') ?? '' });
|
if (r.ok && act !== 'save') r = await asUser(token, `/api/mod/revisions/${id}/${act}`, { comment: f.get('comment') ?? '' });
|
||||||
if (!r.ok) error = r.data.error ?? 'کچھ غلط ہو گیا';
|
if (!r.ok) error = r.data.error ?? 'کچھ غلط ہو گیا';
|
||||||
@ -39,6 +44,15 @@ const couplets = tagging && tagKind === 'work' ? (await asUser(token, `/api/mod/
|
|||||||
const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : [];
|
const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : [];
|
||||||
// an e-book's details: title, source, copyright, note
|
// an e-book's details: title, source, copyright, note
|
||||||
const ebook = rev.entity === 'ebook';
|
const ebook = rev.entity === 'ebook';
|
||||||
|
// a poet's details or a book/section's title (#32)
|
||||||
|
const details = ['poet', 'book'].includes(rev.entity) ? rev.entity : '';
|
||||||
|
const dv: Record<string, string> = {};
|
||||||
|
if (details) {
|
||||||
|
const lines = rev.content.split('\n'), intro = lines.findIndex((l: string) => /^تعارف\s*:/.test(l));
|
||||||
|
for (const l of intro < 0 ? lines : lines.slice(0, intro)) { const m = l.match(/^([^:]+):\s*(.*)$/); if (m) dv[m[1].trim()] = m[2].trim(); }
|
||||||
|
if (intro >= 0) dv['تعارف'] = [lines[intro].replace(/^تعارف\s*:\s*/, ''), ...lines.slice(intro + 1)].join('\n').trim();
|
||||||
|
}
|
||||||
|
const keys = (i: string) => `<button type="button" class="in-box rom" data-urdu-rom="${i}" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو">اب</button><button type="button" class="in-box" data-urdu-kb="${i}" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg></button>`;
|
||||||
const EB: [string, string][] = [['title', 'عنوان'], ['writer', 'مصنف'], ['coauthors', 'شریک مصنفین'], ['language', 'زبان'], ['direction', 'سمت'],
|
const EB: [string, string][] = [['title', 'عنوان'], ['writer', 'مصنف'], ['coauthors', 'شریک مصنفین'], ['language', 'زبان'], ['direction', 'سمت'],
|
||||||
['tags', 'ٹیگ'], ['source', 'ماخذ'], ['licence', 'اجازت'], ['note', 'تفصیل']];
|
['tags', 'ٹیگ'], ['source', 'ماخذ'], ['licence', 'اجازت'], ['note', 'تفصیل']];
|
||||||
const ebMeta = ebook ? Object.fromEntries(EB.map(([k, label]) => [k, (rev.content.split('\n').find((l: string) => l.startsWith(label + ':')) ?? '').slice(label.length + 1).trim()])) : {};
|
const ebMeta = ebook ? Object.fromEntries(EB.map(([k, label]) => [k, (rev.content.split('\n').find((l: string) => l.startsWith(label + ':')) ?? '').slice(label.length + 1).trim()])) : {};
|
||||||
@ -48,18 +62,38 @@ const acts = ([['approve', 'منظور کریں', 'ok', 'check'], ['return', 'و
|
|||||||
import Icon from '../../../components/Icon.astro';
|
import Icon from '../../../components/Icon.astro';
|
||||||
---
|
---
|
||||||
<Base title={`مسودہ: ${rev.work_title}`}>
|
<Base title={`مسودہ: ${rev.work_title}`}>
|
||||||
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{ebook && 'ای بک: '}{rev.work_title}</h1>
|
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{ebook && 'ای بک: '}{details === 'poet' && 'شاعر: '}{details === 'book' && 'عنوان: '}{rev.work_title}</h1>
|
||||||
<ModNav />
|
<ModNav />
|
||||||
<p class="center">
|
<p class="center">
|
||||||
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
|
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
|
||||||
· <bdi dir="ltr">{rev.author_email}</bdi>
|
· <bdi dir="ltr">{rev.author_email}</bdi>
|
||||||
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
|
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
|
||||||
· <a href={rev.work_url}>{order ? 'حصہ' : tagging ? 'صفحہ' : ebook ? 'کتاب دیکھیں' : 'کلام'}</a>{!ebook && <> · <a href={tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a></>}
|
· <a href={rev.work_url}>{order ? 'حصہ' : tagging || details ? 'صفحہ' : ebook ? 'کتاب دیکھیں' : 'کلام'}</a>{!ebook && <> · <a href={details ? `/mod/details/${details}/${rev.entity_id}` : tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a></>}
|
||||||
</p>
|
</p>
|
||||||
{error && <p class="form-error" role="alert">{error}</p>}
|
{error && <p class="form-error" role="alert">{error}</p>}
|
||||||
{done && <p class="form-done" role="status">{done}</p>}
|
{done && <p class="form-done" role="status">{done}</p>}
|
||||||
|
|
||||||
{may.save && ebook ? (
|
{may.save && details ? (
|
||||||
|
<form method="post" class="mod-edit account-form">
|
||||||
|
<input type="hidden" name="details" value={details} />
|
||||||
|
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
|
||||||
|
{details === 'book' ? (
|
||||||
|
<label>عنوان<span class="in-box-field"><input id="d-title" name="عنوان" value={dv['عنوان'] ?? ''} required maxlength="200" data-urdu /><Fragment set:html={keys('d-title')} /></span></label>
|
||||||
|
) : <>
|
||||||
|
<label>نام<span class="in-box-field"><input id="d-name" name="نام" value={dv['نام'] ?? ''} required maxlength="100" data-urdu /><Fragment set:html={keys('d-name')} /></span></label>
|
||||||
|
<label>تخلص (جو سائٹ پر ہر جگہ دکھتا ہے)<span class="in-box-field"><input id="d-nick" name="تخلص" value={dv['تخلص'] ?? ''} required maxlength="100" data-urdu /><Fragment set:html={keys('d-nick')} /></span></label>
|
||||||
|
<div class="year-pair">
|
||||||
|
<label>پیدائش (سنِ عیسوی)<input name="پیدائش" value={dv['پیدائش'] ?? ''} inputmode="numeric" maxlength="4" placeholder="۱۸۷۷" /></label>
|
||||||
|
<label>وفات (سنِ عیسوی)<input name="وفات" value={dv['وفات'] ?? ''} inputmode="numeric" maxlength="4" placeholder="۱۹۳۸" /></label>
|
||||||
|
</div>
|
||||||
|
<label>تعارف (پیراگراف خالی سطر سے الگ کریں)<span class="in-box-field note-field"><textarea id="d-intro" name="تعارف" rows="10" maxlength="5000" dir="auto" data-urdu>{dv['تعارف'] ?? ''}</textarea><Fragment set:html={keys('d-intro')} /></span></label>
|
||||||
|
</>}
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button name="act" value="save" class="btn primary"><Icon name="save" size={18} /> محفوظ کریں</button>
|
||||||
|
<button name="act" value="submit" class="btn primary"><Icon name={me.role === 'admin' ? 'upload' : 'send'} size={18} /> {me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : may.save && ebook ? (
|
||||||
<form method="post" class="mod-edit account-form">
|
<form method="post" class="mod-edit account-form">
|
||||||
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
|
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
|
||||||
<EbookFields v={ebMeta} />
|
<EbookFields v={ebMeta} />
|
||||||
@ -181,6 +215,8 @@ import Icon from '../../../components/Icon.astro';
|
|||||||
<section class="lib-section">
|
<section class="lib-section">
|
||||||
<h2>پیش نظارہ</h2>
|
<h2>پیش نظارہ</h2>
|
||||||
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol>
|
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol>
|
||||||
|
: details ? <><dl class="eb-meta">{Object.entries(dv).filter(([k, v]) => k !== 'تعارف' && v).map(([k, v]) => <><dt>{k}</dt><dd>{ud(v)}</dd></>)}</dl>
|
||||||
|
{dv['تعارف'] && <div class="poet-intro">{dv['تعارف'].split(/\n\s*\n/).map((p) => <p>{p}</p>)}</div>}</>
|
||||||
: ebook ? <dl class="eb-meta">{EB.map(([k, label]) => ebMeta[k] && <><dt>{label}</dt><dd>{ebMeta[k]}</dd></>)}</dl>
|
: ebook ? <dl class="eb-meta">{EB.map(([k, label]) => ebMeta[k] && <><dt>{label}</dt><dd>{ebMeta[k]}</dd></>)}</dl>
|
||||||
: tagging ? (tagRows.length ? tagRows.map((t: any) => <div class="tag-preview">{t.couplet > 0 && <small class="muted">شعر {ud(t.couplet)}:</small>}<Tags tags={[t]} /></div>) : <p class="muted center">کوئی ٹیگ نہیں</p>)
|
: tagging ? (tagRows.length ? tagRows.map((t: any) => <div class="tag-preview">{t.couplet > 0 && <small class="muted">شعر {ud(t.couplet)}:</small>}<Tags tags={[t]} /></div>) : <p class="muted center">کوئی ٹیگ نہیں</p>)
|
||||||
: <DivanPreview text={rev.content} />}
|
: <DivanPreview text={rev.content} />}
|
||||||
@ -270,7 +306,7 @@ import Icon from '../../../components/Icon.astro';
|
|||||||
if (host && source) {
|
if (host && source) {
|
||||||
const { editor, meta } = createEditor(host, source.value);
|
const { editor, meta } = createEditor(host, source.value);
|
||||||
const rows = document.getElementById('meta-rows')!;
|
const rows = document.getElementById('meta-rows')!;
|
||||||
const KNOWN = ['عنوان', 'شاعر', 'مصنف', 'صنف', 'ردیف', 'قافیہ', 'بحر', 'ماخذ', 'ماخذ_ربط'];
|
const KNOWN = ['عنوان', 'شاعر', 'مصنف', 'صنف', 'ردیف', 'قافیہ', 'بحر', 'ماخذ', 'ماخذ_لنک'];
|
||||||
const row = (k = '', v = '') => {
|
const row = (k = '', v = '') => {
|
||||||
const r = document.createElement('div'); r.className = 'meta-row';
|
const r = document.createElement('div'); r.className = 'meta-row';
|
||||||
r.innerHTML = '<input class="meta-k" list="meta-keys" aria-label="معلومات کا نام"><input class="meta-v" data-urdu aria-label="قدر"><button type="button" class="tool" title="ہٹائیں" aria-label="ہٹائیں"><svg class="icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>';
|
r.innerHTML = '<input class="meta-k" list="meta-keys" aria-label="معلومات کا نام"><input class="meta-v" data-urdu aria-label="قدر"><button type="button" class="tool" title="ہٹائیں" aria-label="ہٹائیں"><svg class="icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>';
|
||||||
|
|||||||
@ -57,11 +57,11 @@ const centuries = Object.fromEntries(Array.from({ length: 16 }, (_, c) => [c, ce
|
|||||||
const norm = (s: string) => s.normalize('NFC').replace(/[ً-ْٰٔ]/g, '').replace(/[يى]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ');
|
const norm = (s: string) => s.normalize('NFC').replace(/[ً-ْٰٔ]/g, '').replace(/[يى]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ');
|
||||||
// arrows, ✕ and ہٹائیں are drawn as icons (the same Lucide paths as Icon.astro); the words stay as tooltip and label
|
// arrows, ✕ and ہٹائیں are drawn as icons (the same Lucide paths as Icon.astro); the words stay as tooltip and label
|
||||||
const ICON: Record<string, string> = { '↑': '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>', '↓': '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>', '✕': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 'ہٹائیں': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>' };
|
const ICON: Record<string, string> = { '↑': '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>', '↓': '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>', '✕': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 'ہٹائیں': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>' };
|
||||||
const btn = (label: string, title: string, fn: () => void) => {
|
const btn = (label: string, title: string, fn: () => unknown) => {
|
||||||
const b = document.createElement('button'); b.type = 'button'; b.className = 'tool'; b.title = title;
|
const b = document.createElement('button'); b.type = 'button'; b.className = 'tool'; b.title = title;
|
||||||
if (ICON[label]) { b.innerHTML = `<svg class="icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[label]}</svg>`; b.setAttribute('aria-label', title); }
|
if (ICON[label]) { b.innerHTML = `<svg class="icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[label]}</svg>`; b.setAttribute('aria-label', title); }
|
||||||
else b.textContent = label;
|
else b.textContent = label;
|
||||||
b.onclick = () => { fn(); draw(); }; return b;
|
b.onclick = async () => { await fn(); draw(); }; return b; // fn may ask first
|
||||||
};
|
};
|
||||||
// a box with the Urdu keyboard and Roman typing buttons inside it, as in search (Base.astro handles them)
|
// a box with the Urdu keyboard and Roman typing buttons inside it, as in search (Base.astro handles them)
|
||||||
const KB = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg>';
|
const KB = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg>';
|
||||||
@ -96,7 +96,7 @@ const centuries = Object.fromEntries(Array.from({ length: 16 }, (_, c) => [c, ce
|
|||||||
kind.textContent = s.century === null ? `منتخب شعرا (${s.poets.length})` : `صدی: ${data.centuries[s.century]}`;
|
kind.textContent = s.century === null ? `منتخب شعرا (${s.poets.length})` : `صدی: ${data.centuries[s.century]}`;
|
||||||
head.append(keyed(title, `site-title-${i}`, 'site-title'), sort, kind,
|
head.append(keyed(title, `site-title-${i}`, 'site-title'), sort, kind,
|
||||||
btn('↑', 'حصہ اوپر', () => move(state, i, -1)), btn('↓', 'حصہ نیچے', () => move(state, i, 1)),
|
btn('↑', 'حصہ اوپر', () => move(state, i, -1)), btn('↓', 'حصہ نیچے', () => move(state, i, 1)),
|
||||||
btn('ہٹائیں', 'یہ حصہ ہٹائیں', () => { if (confirm('یہ حصہ ہٹا دیں؟')) state.splice(i, 1); }));
|
btn('ہٹائیں', 'یہ حصہ ہٹائیں', async () => { if (await (window as any).ask('یہ حصہ ہٹا دیں؟', { danger: true, ok: 'ہٹائیں' })) state.splice(i, 1); }));
|
||||||
box.append(head);
|
box.append(head);
|
||||||
if (s.century === null) box.append(poetList(s, i));
|
if (s.century === null) box.append(poetList(s, i));
|
||||||
return box;
|
return box;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
// A page's tags (#52): its current tags, published versions and drafts; "ٹیگ لگائیں" starts a draft
|
// A page's tags (#52): its current tags, published versions and drafts; "ٹیگ لگائیں" starts a draft
|
||||||
import Base from '../../../../layouts/Base.astro';
|
import Base from '../../../../layouts/Base.astro';
|
||||||
|
import History from '../../../../components/History.astro';
|
||||||
import ModNav from '../../../../components/ModNav.astro';
|
import ModNav from '../../../../components/ModNav.astro';
|
||||||
import Tags from '../../../../components/Tags.astro';
|
import Tags from '../../../../components/Tags.astro';
|
||||||
import { asUser, COOKIE } from '../../../../lib/auth';
|
import { asUser, COOKIE } from '../../../../lib/auth';
|
||||||
import { STATUS, when } from '../../../../lib/mod';
|
|
||||||
import { ud } from '../../../../lib/urdu';
|
import { ud } from '../../../../lib/urdu';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
@ -28,20 +28,5 @@ const current = content.split('\n').filter(Boolean).map((l: string) => { const [
|
|||||||
<Tags tags={current} />
|
<Tags tags={current} />
|
||||||
{error && <p class="form-error" role="alert">{error}</p>}
|
{error && <p class="form-error" role="alert">{error}</p>}
|
||||||
{may.tags && <form method="post" class="center"><button class="save-like">ٹیگ لگائیں</button></form>}
|
{may.tags && <form method="post" class="center"><button class="save-like">ٹیگ لگائیں</button></form>}
|
||||||
<table class="admin-table">
|
<History entity={`tags-${kind}`} id={target.id} history={history} version={version} empty="ابھی ٹیگ میں کوئی تبدیلی نہیں۔" />
|
||||||
<thead><tr><th>ورژن</th><th>حالت</th><th>خلاصہ</th><th>لکھنے والے</th><th>جائزہ</th><th>اشاعت</th><th>تاریخ</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{history.map((h: any) => (
|
|
||||||
<tr>
|
|
||||||
<td><a href={`/mod/rev/${h.id}`}>{h.version ? ud(h.version) : '—'}</a></td>
|
|
||||||
<td><span class={`status s-${h.status}`}>{STATUS[h.status]}</span></td>
|
|
||||||
<td>{h.summary}</td>
|
|
||||||
<td><bdi dir="ltr">{h.author_email}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{h.reviewer_email ?? ''}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{h.publisher_email ?? ''}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{when(h.published_at ?? h.created_at)}</bdi></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
// A work's versions: published versions and drafts, with who, when, summary and status
|
// A work's versions: published versions and drafts, with who, when, summary and status
|
||||||
import Base from '../../../layouts/Base.astro';
|
import Base from '../../../layouts/Base.astro';
|
||||||
|
import History from '../../../components/History.astro';
|
||||||
import ModNav from '../../../components/ModNav.astro';
|
import ModNav from '../../../components/ModNav.astro';
|
||||||
import { asUser, COOKIE } from '../../../lib/auth';
|
import { asUser, COOKIE } from '../../../lib/auth';
|
||||||
import { STATUS, when } from '../../../lib/mod';
|
|
||||||
import { ud } from '../../../lib/urdu';
|
import { ud } from '../../../lib/urdu';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
@ -22,21 +22,5 @@ const { work, version, history, may } = res.data;
|
|||||||
<ModNav />
|
<ModNav />
|
||||||
<p class="center"><a href={work.url}>کلام</a> · {version ? `موجودہ ورژن ${ud(version)}` : 'ویکی ماخذ کا متن (ابھی دیوان کا کوئی ورژن نہیں)'}</p>
|
<p class="center"><a href={work.url}>کلام</a> · {version ? `موجودہ ورژن ${ud(version)}` : 'ویکی ماخذ کا متن (ابھی دیوان کا کوئی ورژن نہیں)'}</p>
|
||||||
{may.edit && <form method="post" class="center"><button class="save-like">ترمیم کریں</button></form>}
|
{may.edit && <form method="post" class="center"><button class="save-like">ترمیم کریں</button></form>}
|
||||||
<table class="admin-table">
|
<History entity="work" id={work.id} history={history} version={version} empty="ابھی کوئی ترمیم نہیں۔" />
|
||||||
<thead><tr><th>ورژن</th><th>حالت</th><th>خلاصہ</th><th>لکھنے والے</th><th>جائزہ</th><th>اشاعت</th><th>تاریخ</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{history.map((h: any) => (
|
|
||||||
<tr>
|
|
||||||
<td><a href={`/mod/rev/${h.id}`}>{h.version ? ud(h.version) : '—'}</a></td>
|
|
||||||
<td><span class={`status s-${h.status}`}>{STATUS[h.status]}</span></td>
|
|
||||||
<td>{h.summary}</td>
|
|
||||||
<td><bdi dir="ltr">{h.author_email}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{h.reviewer_email ?? ''}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{h.publisher_email ?? ''}</bdi></td>
|
|
||||||
<td><bdi dir="ltr">{when(h.published_at ?? h.created_at)}</bdi></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{history.length === 0 && <p class="muted center">ابھی کوئی ترمیم نہیں۔</p>}
|
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
64
web/src/pages/notes.astro
Normal file
64
web/src/pages/notes.astro
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
// نوٹس: the reader's notes on couplets and on phrases in them, newest first, each with where it is and the couplet
|
||||||
|
// (the phrase marked). A note is separate from a bookmark: deleting a note never removes a bookmark.
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import Icon from '../components/Icon.astro';
|
||||||
|
import LibraryNav from '../components/LibraryNav.astro';
|
||||||
|
import { asUser, COOKIE } from '../lib/auth';
|
||||||
|
import { ud } from '../lib/urdu';
|
||||||
|
import { ago } from '../lib/roles';
|
||||||
|
import { phraseParts, phraseRe } from '../lib/phrases';
|
||||||
|
|
||||||
|
const me = Astro.locals.user;
|
||||||
|
if (!me) return Astro.redirect('/signin?next=/notes');
|
||||||
|
const token = Astro.cookies.get(COOKIE)!.value;
|
||||||
|
if (Astro.request.method === 'POST') {
|
||||||
|
const f = await Astro.request.formData(), id = Number(f.get('id')) || 0;
|
||||||
|
const r = await asUser(token, `/api/library/${id}/note`, { note: f.get('act') === 'delete' ? '' : f.get('note') });
|
||||||
|
return Astro.redirect(r.ok ? `/notes?done=${f.get('act')}${f.get('act') === 'save' ? `#n${id}` : ''}` : '/notes', 303);
|
||||||
|
}
|
||||||
|
const notes: any[] = (await asUser(token, '/api/library')).data.notes ?? [];
|
||||||
|
const done = Astro.url.searchParams.get('done');
|
||||||
|
// the noted phrase inside its couplet: [before, phrase, after] per line
|
||||||
|
const marked = (lines: string[], phrase: string | null) => !phrase ? lines.map((l) => [l, '', '']) : phraseParts(lines, phrase).map((part, i) => {
|
||||||
|
if (!part) return [lines[i], '', ''];
|
||||||
|
const m = lines[i].match(phraseRe(part))!;
|
||||||
|
return [lines[i].slice(0, m.index), m[0], lines[i].slice(m.index! + m[0].length)];
|
||||||
|
});
|
||||||
|
---
|
||||||
|
<Base title="نوٹس">
|
||||||
|
<h1>{me.full_name || 'نوٹس'}</h1>
|
||||||
|
<LibraryNav current="notes" />
|
||||||
|
{done && <p class="form-done" role="status">{done === 'delete' ? 'نوٹ ہٹا دیا' : 'نوٹ محفوظ ہو گیا'}</p>}
|
||||||
|
<p class="muted center">{ud(notes.length)} نوٹس</p>
|
||||||
|
{notes.length === 0 && <p class="empty"><span aria-hidden="true">✤</span>ابھی کوئی نوٹ نہیں۔ کلام پڑھتے ہوئے کسی شعر کے ساتھ والا نوٹ کا نشان دبائیں، یا کوئی عبارت منتخب کر کے "نوٹ" چنیں۔</p>}
|
||||||
|
<ul class="note-list">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li id={`n${n.id}`} class="note-card">
|
||||||
|
{n.poem ? <>
|
||||||
|
<header>
|
||||||
|
<nav class="crumbs">{[...n.poem.path, { title: n.poem.title, url: n.poem.url }].map((x: any) => <span><a href={x.url}>{x.title}</a></span>)}</nav>
|
||||||
|
{n.bookmarked && <span class="badge plain" title="یہ شعر محفوظ شدہ میں بھی ہے">محفوظ شدہ</span>}
|
||||||
|
</header>
|
||||||
|
<a class="lib-couplet" href={`${n.poem.url}#c${n.poem.couplet}`}>{marked(n.lines, n.phrase).map(([a, m, z]) => <p>{a}{m && <mark>{m}</mark>}{z}</p>)}</a>
|
||||||
|
</> : <span class="muted">یہ شعر اب دستیاب نہیں</span>}
|
||||||
|
<form method="post" class="note-edit">
|
||||||
|
<input type="hidden" name="id" value={n.id} />
|
||||||
|
<div class="in-box-field note-field">
|
||||||
|
<textarea id={`note-${n.id}`} name="note" rows="2" maxlength="1000" dir="auto" aria-label="نوٹ" data-urdu required>{n.note}</textarea>
|
||||||
|
<button type="button" class="in-box rom" data-urdu-rom={`note-${n.id}`} aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ">اب</button>
|
||||||
|
<button type="button" class="in-box" data-urdu-kb={`note-${n.id}`} aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div class="note-actions">
|
||||||
|
<small class="muted" title={new Date(n.saved_at).toISOString().slice(0, 16).replace('T', ' ')}>{ago(n.saved_at)}</small>
|
||||||
|
<button class="btn" name="act" value="save"><Icon name="check" size={16} /> محفوظ کریں</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" class="note-delete" data-confirm="یہ نوٹ ہٹائیں؟ شعر محفوظ شدہ میں ہو تو وہیں رہے گا۔" data-danger data-ok="ہٹائیں">
|
||||||
|
<input type="hidden" name="id" value={n.id} />
|
||||||
|
<button class="icon-btn small danger" name="act" value="delete" title="نوٹ ہٹائیں" aria-label="نوٹ ہٹائیں"><Icon name="trash" size={16} /></button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Base>
|
||||||
52
web/src/pages/privacy.astro
Normal file
52
web/src/pages/privacy.astro
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
// رازداری (privacy): what Divan keeps about readers and moderators, why, what it does not do, and how to remove it
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { brand } from '../lib/brand';
|
||||||
|
---
|
||||||
|
<Base title="رازداری" description={`${brand.name} آپ کے بارے میں کیا محفوظ رکھتا ہے، کیوں، اور اسے کیسے مٹایا جائے`}>
|
||||||
|
<article class="guide about">
|
||||||
|
<h1>رازداری</h1>
|
||||||
|
<p class="muted">آخری تبدیلی: ۱۰ اکتوبر ۲۰۲۶ء</p>
|
||||||
|
<p>
|
||||||
|
{brand.name} پڑھنے کے لیے کسی اکاؤنٹ کی ضرورت نہیں۔ ہم صرف وہی محفوظ رکھتے ہیں جو سائٹ کے چلنے کے لیے ضروری ہے، اور اسے
|
||||||
|
کسی کو بیچتے یا دیتے نہیں۔ نہ اشتہار ہیں، نہ اشتہاری یا جاسوس کوکیز، نہ کسی اور کمپنی کے ٹریکر۔
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>بغیر اکاؤنٹ کے پڑھتے ہوئے</h2>
|
||||||
|
<ul>
|
||||||
|
<li>آپ کی پسند (خط، رنگ، روشنی، پڑھنے کا انداز) آپ کے اپنے براؤزر میں رہتی ہے، ہمارے سرور پر نہیں۔ ہندسوں کی پسند ایک چھوٹی کوکی میں رہتی ہے تاکہ صفحہ انھی ہندسوں کے ساتھ آئے۔</li>
|
||||||
|
<li>لاگ ان اور اکاؤنٹ بنانے کی کوششوں کی حد کے لیے سرور آپ کا انٹرنیٹ پتہ (IP) تھوڑی دیر کے لیے یاد رکھتا ہے، اور غلطیوں کے ریکارڈ میں صفحے کا پتہ اور وقت درج ہوتا ہے۔ یہ صرف سائٹ کی حفاظت اور خرابیاں ٹھیک کرنے کے لیے ہیں اور کچھ دن بعد مٹ جاتے ہیں۔</li>
|
||||||
|
<li><strong>گنتی:</strong> کون سا صفحہ کتنی بار پڑھا گیا، روزانہ کتنے قارئین آئے، اور کس ویب سائٹ سے (صرف اس کا نام) — صرف گنتی، بغیر کوکی کے۔ قارئین گننے کے لیے انٹرنیٹ پتے اور براؤزر کی ایک نہ پلٹنے والی شکل بنتی ہے جس کی کنجی ہر روز بدلتی ہے اور کہیں محفوظ نہیں ہوتی، اس لیے کسی کو پہچانا یا ایک دن سے دوسرے دن تک پیچھا نہیں کیا جا سکتا۔</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>اکاؤنٹ کے ساتھ</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>اکاؤنٹ:</strong> ای میل پتہ، اور پاس ورڈ کی ایک نہ پلٹنے والی شکل (اصل پاس ورڈ کہیں محفوظ نہیں ہوتا)۔ اگر آپ لکھیں تو پورا نام اور تعارف۔</li>
|
||||||
|
<li><strong>آپ کا ذخیرہ:</strong> محفوظ شدہ شعرا، کتابیں، کلام، اشعار، عبارتیں اور کتابوں کے صفحات؛ نوٹ؛ ذخیرۂ الفاظ۔ یہ صرف آپ کو نظر آتے ہیں۔</li>
|
||||||
|
<li><strong>لاگ ان کی کوکی:</strong> ایک کوکی جو ۳۰ دن آپ کو لاگ ان رکھتی ہے۔ سرور پر اس کی صرف ایک نہ پلٹنے والی شکل رہتی ہے۔</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>موڈریٹروں کا کام عوامی ہے</h2>
|
||||||
|
<p>
|
||||||
|
جو تبدیلی شائع ہوتی ہے وہ {brand.name} کے کھلے ڈیٹا کے ذخیرے میں درج ہوتی ہے: کس نے لکھی، کس نے منظور کی، کب۔ وہاں آپ کا
|
||||||
|
<strong>عوامی نام</strong> لکھا جاتا ہے (پورا نام، ورنہ «موڈریٹر» اور ایک نمبر)، ای میل کبھی نہیں۔ یہ تاریخ کھلی اور مستقل ہے،
|
||||||
|
اکاؤنٹ ختم کرنے کے بعد بھی شائع شدہ کام کے ساتھ رہتی ہے۔ مسودے اور جائزے کے تبصرے صرف موڈریٹر دیکھتے ہیں۔
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>دوسری ویب سائٹیں</h2>
|
||||||
|
<p>
|
||||||
|
خط اور تمام فائلیں ہمارے اپنے سرور سے آتی ہیں۔ دوسری سائٹ سے صرف تب رابطہ ہوتا ہے جب آپ خود چنیں: لغت میں تلفظ کی آواز
|
||||||
|
ویکی میڈیا سے چلتی ہے، archive.org کی کتاب archive.org کا ریڈر کھولتی ہے، اور «گوگل» نئی ٹیب میں گوگل کھولتا ہے۔ ان سائٹوں
|
||||||
|
کی اپنی رازداری کی پالیسیاں ہیں۔
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>آپ کا اختیار</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/account">اکاؤنٹ</a> میں نام اور تعارف بدل سکتے ہیں اور اکاؤنٹ اپنے تمام ذخیرے سمیت مستقل طور پر ختم کر سکتے ہیں۔</li>
|
||||||
|
<li>محفوظ کی ہوئی ہر چیز اور ہر نوٹ اپنی جگہ سے ہٹایا جا سکتا ہے۔</li>
|
||||||
|
<li>اپنے ڈیٹا کی نقل یا کسی سوال کے لیے <a href={brand.contact.url}>{brand.contact.text}</a>۔</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="muted">یہ پالیسی بدلے تو یہی صفحہ بدلے گا، تاریخ کے ساتھ۔ دیکھیے: <a href="/terms">استعمال کی شرائط</a>۔</p>
|
||||||
|
</article>
|
||||||
|
</Base>
|
||||||
15
web/src/pages/robots.txt.ts
Normal file
15
web/src/pages/robots.txt.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// robots.txt: search engines may read the reading pages, not accounts, moderation or search results (endless and
|
||||||
|
// private). DIVAN_NOINDEX=1 (the private preview) keeps everything out.
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { siteUrl } from '../lib/site-url';
|
||||||
|
|
||||||
|
export const GET: APIRoute = ({ url }) => {
|
||||||
|
const closed = process.env.DIVAN_NOINDEX === '1';
|
||||||
|
const body = closed ? 'User-agent: *\nDisallow: /\n' : [
|
||||||
|
'User-agent: *',
|
||||||
|
...['/admin', '/mod', '/account', '/library', '/writers', '/words', '/notes', '/signin', '/signup', '/search', '/api/', '/ebooks/file/', '/health']
|
||||||
|
.map((p) => `Disallow: ${p}`),
|
||||||
|
'', `Sitemap: ${siteUrl(url)}/sitemap.xml`, '',
|
||||||
|
].join('\n');
|
||||||
|
return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'public, max-age=3600' } });
|
||||||
|
};
|
||||||
33
web/src/pages/rss.xml.ts
Normal file
33
web/src/pages/rss.xml.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// rss.xml (RSS 2.0): the latest published changes, to follow or share: new versions of works, new e-books,
|
||||||
|
// arrangements and tags, each with who made it (public names) and, for a work, its opening lines.
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { brand } from '../lib/brand';
|
||||||
|
import { siteUrl, xml } from '../lib/site-url';
|
||||||
|
import { ud } from '../lib/urdu';
|
||||||
|
|
||||||
|
const KIND: Record<string, string> = { work: 'نیا ورژن', ebook: 'نئی ای بک', order: 'نئی ترتیب', tags: 'ٹیگ' };
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const base = siteUrl(url), items = (await api<any[]>('/api/feed?limit=50')) ?? [];
|
||||||
|
const item = (i: any) => {
|
||||||
|
const title = `${KIND[i.kind]}: ${i.title}${i.poet ? ` (${i.poet})` : ''}${i.kind === 'work' && i.version ? ` · ورژن ${ud(i.version)}` : ''}`;
|
||||||
|
const text = [i.lines.join(' / '), i.summary, i.by && `از ${i.by}`].filter(Boolean).join(' — ');
|
||||||
|
return `<item><title>${xml(title)}</title><link>${xml(base + encodeURI(i.url))}</link>` +
|
||||||
|
`<guid isPermaLink="false">divan-${i.id}</guid><pubDate>${new Date(i.published).toUTCString()}</pubDate>` +
|
||||||
|
`${i.by ? `<dc:creator>${xml(i.by)}</dc:creator>` : ''}<category>${xml(KIND[i.kind])}</category><description>${xml(text)}</description></item>`;
|
||||||
|
};
|
||||||
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<channel>
|
||||||
|
<title>${xml(`${brand.name}: نئی تبدیلیاں`)}</title>
|
||||||
|
<link>${xml(base)}/</link>
|
||||||
|
<description>${xml(`${brand.name} (${brand.tagline}) میں شائع ہونے والے نئے ورژن، ای بکس اور تبدیلیاں`)}</description>
|
||||||
|
<language>ur-PK</language>
|
||||||
|
<atom:link href="${xml(base)}/rss.xml" rel="self" type="application/rss+xml" />
|
||||||
|
${items.length ? `<lastBuildDate>${new Date(items[0].published).toUTCString()}</lastBuildDate>\n` : ''}${items.map(item).join('\n')}
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
`;
|
||||||
|
return new Response(body, { headers: { 'content-type': 'application/rss+xml; charset=utf-8', 'cache-control': 'public, max-age=300' } });
|
||||||
|
};
|
||||||
@ -28,6 +28,7 @@ if (Astro.request.method === 'POST') {
|
|||||||
<label>پاس ورڈ (کم از کم ۸ حروف)<input type="password" name="password" required minlength="8" autocomplete="new-password" dir="ltr" /></label>
|
<label>پاس ورڈ (کم از کم ۸ حروف)<input type="password" name="password" required minlength="8" autocomplete="new-password" dir="ltr" /></label>
|
||||||
<label>پاس ورڈ دوبارہ<input type="password" name="password2" required minlength="8" autocomplete="new-password" dir="ltr" /></label>
|
<label>پاس ورڈ دوبارہ<input type="password" name="password2" required minlength="8" autocomplete="new-password" dir="ltr" /></label>
|
||||||
<button type="submit">اکاؤنٹ بنائیں</button>
|
<button type="submit">اکاؤنٹ بنائیں</button>
|
||||||
|
<p class="muted small-line">اکاؤنٹ بنا کر آپ <a href="/terms">استعمال کی شرائط</a> مانتے ہیں؛ ہم کیا محفوظ رکھتے ہیں، یہ <a href="/privacy">رازداری</a> میں ہے۔</p>
|
||||||
<p class="muted">پہلے سے اکاؤنٹ ہے؟ <a href="/signin">لاگ ان کریں</a></p>
|
<p class="muted">پہلے سے اکاؤنٹ ہے؟ <a href="/signin">لاگ ان کریں</a></p>
|
||||||
</form>
|
</form>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
22
web/src/pages/sitemap.xml.ts
Normal file
22
web/src/pages/sitemap.xml.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// sitemap.xml: every public page (poets, books/chapters, works, tags, e-books, and the fixed pages), for search
|
||||||
|
// engines. A work's date is when its latest Divan version was published. About 13,000 addresses (one file holds 50,000).
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { siteUrl, xml } from '../lib/site-url';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const base = siteUrl(url), s = await api<any>('/api/sitemap');
|
||||||
|
const loc = (path: string, updated?: string | null) =>
|
||||||
|
`<url><loc>${xml(base + encodeURI(path))}</loc>${updated ? `<lastmod>${new Date(updated).toISOString().slice(0, 10)}</lastmod>` : ''}</url>`;
|
||||||
|
const urls = [
|
||||||
|
...['/', '/about', '/tags', '/privacy', '/terms'].map((p) => loc(p)),
|
||||||
|
...s.poets.map((p: string) => loc(p)),
|
||||||
|
...s.poets.map((p: string) => loc(`${p}/ebooks`)),
|
||||||
|
...s.categories.map((c: string) => loc(c)),
|
||||||
|
...s.works.map((w: any) => loc(w.url, w.updated)),
|
||||||
|
...s.tags.map((t: any) => loc(`/tag/${t.type}/${t.name}`)),
|
||||||
|
...s.ebooks.map((b: any) => loc(`/ebook/${b.id}`)),
|
||||||
|
];
|
||||||
|
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
|
||||||
|
return new Response(body, { headers: { 'content-type': 'application/xml; charset=utf-8', 'cache-control': 'public, max-age=3600' } });
|
||||||
|
};
|
||||||
@ -10,7 +10,7 @@ if (!res) return new Response(null, { status: 404, statusText: 'Not found' });
|
|||||||
const { tag, categories, works, ebooks = [] } = res;
|
const { tag, categories, works, ebooks = [] } = res;
|
||||||
const count = categories.length + works.length + ebooks.length;
|
const count = categories.length + works.length + ebooks.length;
|
||||||
---
|
---
|
||||||
<Base title={`${tag.type}: ${tag.name}`}>
|
<Base title={`${tag.type}: ${tag.name}`} description={`${tag.type} «${tag.name}»: ${ud(count)} شاعر، کتابیں، تخلیقات اور اشعار`}>
|
||||||
<h1><small class="muted">{tag.type}:</small> {tag.name}</h1>
|
<h1><small class="muted">{tag.type}:</small> {tag.name}</h1>
|
||||||
<p class="center muted">{ud(count)} جگہ · <a href={`/search?q=${encodeURIComponent(`tag:"${tag.name}"`)}`}>تلاش میں دیکھیں</a> · <a href="/tags">تمام ٹیگ</a></p>
|
<p class="center muted">{ud(count)} جگہ · <a href={`/search?q=${encodeURIComponent(`tag:"${tag.name}"`)}`}>تلاش میں دیکھیں</a> · <a href="/tags">تمام ٹیگ</a></p>
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
|
|||||||
45
web/src/pages/terms.astro
Normal file
45
web/src/pages/terms.astro
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
// استعمال کی شرائط (terms): licences of the content and the code, accounts, moderators' and uploaders' responsibilities,
|
||||||
|
// reporting problems and copyright claims
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { brand } from '../lib/brand';
|
||||||
|
---
|
||||||
|
<Base title="استعمال کی شرائط" description={`${brand.name} کے استعمال کی شرائط: مواد کے لائسنس، اکاؤنٹ، اپ لوڈ اور شکایت`}>
|
||||||
|
<article class="guide about">
|
||||||
|
<h1>استعمال کی شرائط</h1>
|
||||||
|
<p class="muted">آخری تبدیلی: ۹ اکتوبر ۲۰۲۶ء</p>
|
||||||
|
<p>{brand.name} مفت ہے اور سب کے لیے کھلا ہے۔ سائٹ استعمال کر کے آپ ان شرائط کو مانتے ہیں۔ ساتھ <a href="/privacy">رازداری</a> کا صفحہ بھی دیکھیے۔</p>
|
||||||
|
|
||||||
|
<h2>مواد</h2>
|
||||||
|
<ul>
|
||||||
|
<li>کلاسیکی متن <strong>عوامی ملکیت</strong> میں ہیں: انھیں پڑھنا، نقل کرنا اور آگے بانٹنا سب کے لیے آزاد ہے۔</li>
|
||||||
|
<li>{brand.name} کا ڈیٹا (متن کی ترتیب، تعارف، تبدیلیاں، ٹیگ) <a href="https://creativecommons.org/licenses/by-sa/4.0/deed.ur" lang="en" dir="ltr">CC BY-SA 4.0</a> کے تحت ہے: استعمال کریں تو ویکی ماخذ، ویکیپیڈیا اور {brand.name} کا حوالہ دیں اور اسی لائسنس کے ساتھ بانٹیں۔</li>
|
||||||
|
<li>سائٹ کا کوڈ <span lang="en" dir="ltr">GNU GPL v3</span> کے تحت کھلا ہے۔ نام «{brand.name}»، اس کا لوگو اور شناخت اس لائسنس میں شامل نہیں: اپنی سائٹ بنائیں تو اپنا نام اور لوگو رکھیں (کوڈ کے ساتھ <span lang="en" dir="ltr">NOTICE</span> اور <span lang="en" dir="ltr">TRADEMARKS.md</span>)۔</li>
|
||||||
|
<li>ای بکس اپنے ناشر یا مصنف کے حقوق کے ساتھ ہیں جیسا کہ ہر کتاب پر لکھا ہے۔ archive.org کی کتابیں وہیں سے دکھائی جاتی ہیں۔</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>اکاؤنٹ</h2>
|
||||||
|
<ul>
|
||||||
|
<li>اپنا پاس ورڈ محفوظ رکھیے؛ اکاؤنٹ سے ہونے والا کام آپ کی ذمہ داری ہے۔</li>
|
||||||
|
<li>سائٹ کو نقصان پہنچانا، بے جا بوجھ ڈالنا، دوسروں کے اکاؤنٹ میں دخل دینا یا خودکار طریقے سے اکاؤنٹ بنانا منع ہے۔ ایسی صورت میں اکاؤنٹ بند کیا جا سکتا ہے۔</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>موڈریٹر اور اپ لوڈ</h2>
|
||||||
|
<ul>
|
||||||
|
<li>موڈریٹر کی ہر تبدیلی جائزے کے مراحل سے گزرتی ہے اور شائع ہونے پر کھلے ڈیٹا میں ان کے عوامی نام کے ساتھ درج ہوتی ہے۔</li>
|
||||||
|
<li>ای بک اپ لوڈ کرنے والا تصدیق کرتا ہے کہ اسے یہ کتاب بانٹنے کا حق ہے (عوامی ملکیت یا اجازت) اور ماخذ و حقوق درست لکھتا ہے۔ جائزہ لینے والے اس کی تصدیق کرتے ہیں؛ شک ہو تو کتاب شائع نہیں ہوتی۔</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>شکایت اور حقوق کا دعویٰ</h2>
|
||||||
|
<p>
|
||||||
|
کسی متن میں غلطی، کسی کتاب کے حقوق پر اعتراض، یا کسی اور مسئلے کے لیے <a href={brand.contact.url}>{brand.contact.text}</a>۔ حقوق کے
|
||||||
|
دعوے میں کتاب یا صفحے کا پتہ اور اپنی ملکیت کی تفصیل لکھیے؛ دعویٰ درست ہو تو مواد جلد ہٹا دیا جاتا ہے۔
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>ذمہ داری کی حد</h2>
|
||||||
|
<p>
|
||||||
|
{brand.name} جیسا ہے ویسا مہیا کیا جاتا ہے۔ ہم متن کو درست رکھنے کی پوری کوشش کرتے ہیں، مگر کسی غلطی یا سائٹ کے بند رہنے سے
|
||||||
|
ہونے والے نقصان کی ذمہ داری نہیں لیتے۔ یہ شرائط بدلیں تو یہی صفحہ تاریخ کے ساتھ بدلے گا۔
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</Base>
|
||||||
@ -63,7 +63,15 @@
|
|||||||
:root.nastaliq { --font: 'Libron', '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; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; background: var(--surface-page); color: var(--text); font-family: var(--font); line-height: 1.9; }
|
/* Naskh's weight from the display settings (400 by default; heavier reads better on small screens) */
|
||||||
|
:root:not(.nastaliq) body { font-weight: var(--naskh-weight, 400); }
|
||||||
|
/* text size from the display settings: everything is in rem, so the whole page scales (90%-150%) */
|
||||||
|
html { font-size: var(--text-size, 100%); }
|
||||||
|
body { margin: 0; background: var(--surface-page); color: var(--text); font-family: var(--font-ui); line-height: 1.9; }
|
||||||
|
/* The rule: the interface (menus, buttons, forms, paths, footers, admin and moderation) is always Naskh, compact and
|
||||||
|
calm; the reading font (Naskh or Nastaliq, the reader's choice) is only for the literature: verses and prose, work,
|
||||||
|
book and poet titles, contents lists, intros, excerpts, the editor and its preview. */
|
||||||
|
.lit, .poem, .preview, .snippet, .intro, .toc, .lib-couplet, .ebook-text, .dv-editor, .masthead-text, .radif-head { font-family: var(--font); }
|
||||||
:where(a) { color: var(--link); text-decoration: none; }
|
:where(a) { color: var(--link); text-decoration: none; }
|
||||||
:where(a):hover { color: var(--active); }
|
:where(a):hover { color: var(--active); }
|
||||||
.wrap { max-width: 960px; margin: 0 auto; padding: 0 16px; }
|
.wrap { max-width: 960px; margin: 0 auto; padding: 0 16px; }
|
||||||
@ -89,7 +97,7 @@ body { margin: 0; background: var(--surface-page); color: var(--text); font-fami
|
|||||||
.site-header .wrap { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 12px; }
|
.site-header .wrap { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 12px; }
|
||||||
.wordmark { display: flex; flex: none; }
|
.wordmark { display: flex; flex: none; }
|
||||||
/* the logo: the shamsa with دیوان inside, sized with the viewport */
|
/* 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; }
|
.wordmark .logo { width: clamp(48px, 12vw, 60px); 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 */
|
/* 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; }
|
.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-long { animation: rays 9s linear infinite; }
|
||||||
@ -107,6 +115,8 @@ body { margin: 0; background: var(--surface-page); color: var(--text); font-fami
|
|||||||
main { padding: 16px 0 48px; }
|
main { padding: 16px 0 48px; }
|
||||||
h1, h2 { line-height: 1.6; margin: .2em 0 .4em; font-weight: 700; text-align: center; color: var(--heading); }
|
h1, h2 { line-height: 1.6; margin: .2em 0 .4em; font-weight: 700; text-align: center; color: var(--heading); }
|
||||||
h1 { font-size: clamp(1.3rem, 5vw, var(--fs-2xl)); }
|
h1 { font-size: clamp(1.3rem, 5vw, var(--fs-2xl)); }
|
||||||
|
/* Nastaliq titles: its tall letters (ک، گ، ط) and deep descents need room, or they reach into the path line above */
|
||||||
|
:root.nastaliq h1.lit { line-height: 2.4; margin-top: .6em; }
|
||||||
h1::after { content: ''; display: block; width: 140px; height: 2px; background: var(--brand); margin: 8px auto 0; }
|
h1::after { content: ''; display: block; width: 140px; height: 2px; background: var(--brand); margin: 8px auto 0; }
|
||||||
|
|
||||||
/* Ganjoor's gold pill headings and links (.century / .part-title-block) */
|
/* Ganjoor's gold pill headings and links (.century / .part-title-block) */
|
||||||
@ -181,7 +191,7 @@ h1::after { content: ''; display: block; width: 140px; height: 2px; background:
|
|||||||
.couplet p { margin: 0; width: fit-content; max-width: 100%; justify-self: end; text-align: center; }
|
.couplet p { margin: 0; width: fit-content; max-width: 100%; justify-self: end; text-align: center; }
|
||||||
.couplet[data-mark]::before { content: attr(data-mark); grid-column: 1 / -1; justify-self: center; font-size: .72rem; line-height: 1.6; color: var(--brand); border: 1px solid var(--line); border-radius: 999px; padding: 0 10px; margin-bottom: 2px; }
|
.couplet[data-mark]::before { content: attr(data-mark); grid-column: 1 / -1; justify-self: center; font-size: .72rem; line-height: 1.6; color: var(--brand); border: 1px solid var(--line); border-radius: 999px; padding: 0 10px; margin-bottom: 2px; }
|
||||||
.takhallus { color: var(--brand); }
|
.takhallus { color: var(--brand); }
|
||||||
.radif { text-align: center; margin-top: -6px; }
|
.radif { text-align: center; margin-top: 8px; }
|
||||||
.radif-head { text-align: center; font-size: 1.05rem; color: var(--brand); margin: 22px 0 4px; }
|
.radif-head { text-align: center; font-size: 1.05rem; color: var(--brand); margin: 22px 0 4px; }
|
||||||
.couplet p + p { justify-self: start; }
|
.couplet p + p { justify-self: start; }
|
||||||
.couplet::after { content: '✤'; grid-column: 1 / -1; text-align: center; color: var(--brand); font-size: .72rem; opacity: .75; line-height: 1.2; }
|
.couplet::after { content: '✤'; grid-column: 1 / -1; text-align: center; color: var(--brand); font-size: .72rem; opacity: .75; line-height: 1.2; }
|
||||||
@ -205,14 +215,8 @@ mark { background: var(--accent-soft); color: inherit; border-radius: 3px; paddi
|
|||||||
.pager { display: flex; gap: 12px; justify-content: center; margin-top: 20px; }
|
.pager { display: flex; gap: 12px; justify-content: center; margin-top: 20px; }
|
||||||
|
|
||||||
/* made by a Pakistani for Pakistan: the flag beside the line (an SVG: the emoji is "PK" on Windows) */
|
/* made by a Pakistani for Pakistan: the flag beside the line (an SVG: the emoji is "PK" on Windows) */
|
||||||
.made-in { margin: 10px 0 0; display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
||||||
font-family: 'Noto Nastaliq Urdu', serif; font-size: 1.05rem; line-height: 2; color: var(--text); } /* a tagline */
|
|
||||||
.made-in .flag { flex: none; border-radius: 2px; box-shadow: 0 0 0 1px rgb(0 0 0 / .15); }
|
|
||||||
.site-footer { border-top: 1.5px solid var(--border); padding: 20px 0 16px; margin-top: 16px; font-size: var(--fs-sm); color: var(--text-muted); background: var(--surface-raised); }
|
.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: 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 { 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 h2::after { display: none; }
|
||||||
.footer-links ul { list-style: none; margin: 0; padding: 0; }
|
.footer-links ul { list-style: none; margin: 0; padding: 0; }
|
||||||
@ -222,7 +226,6 @@ mark { background: var(--accent-soft); color: inherit; border-radius: 3px; paddi
|
|||||||
.footer-links a span { display: flex; flex-direction: column; line-height: 1.5; }
|
.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 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; }
|
.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; }
|
h1 + .muted { text-align: center; margin-top: 0; }
|
||||||
|
|
||||||
@ -245,10 +248,15 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.search-form { order: 3; flex-basis: 100%; }
|
.search-form { order: 3; flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
.site-header .kb:not([hidden]) { display: block; padding-bottom: 10px; } /* not the header's flex row */
|
.site-header .kb:not([hidden]) { display: block; padding-bottom: 10px; } /* not the header's flex row */
|
||||||
.kb div { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px; margin-top: 4px; }
|
.kb { width: 100%; max-width: 640px; margin-inline: auto; box-sizing: border-box; } /* one keyboard everywhere (header, under a field, in a dialog, docked): */
|
||||||
.kb button { font-size: var(--fs-lg); min-width: 2.4em; padding: 2px 6px; background: var(--surface-raised); border-radius: var(--r-sm); }
|
.kb div { display: flex; justify-content: center; gap: 4px; margin-top: 4px; } /* rows never wrap; a letter is a tenth of the row, */
|
||||||
.kb .wide { min-width: 12em; }
|
.kb button { flex: 0 1 calc((100% - 36px) / 10); min-width: 0; padding: 3px 0; font-size: var(--fs-lg); background: var(--surface-raised); border-radius: var(--r-sm); }
|
||||||
.kb .fn { min-width: 3.4em; background: var(--surface-sunken); }
|
.kb .fn { flex-basis: calc((100% - 36px) / 10 * 1.5); background: var(--surface-sunken); } /* shift and delete a bit wider, */
|
||||||
|
.kb .wide { flex: 1 1 auto; } /* and the space bar takes the rest of its row, as on a phone */
|
||||||
|
/* the content editor's keyboard: docked at the bottom of the window; the page keeps the writing line above it */
|
||||||
|
.kb.kb-float { position: fixed; z-index: 40; inset-inline: 0; bottom: 0; margin: 0 auto; max-width: min(720px, 100vw); padding: 6px 10px 12px;
|
||||||
|
background: var(--surface-raised); border: 1.5px solid var(--border); border-bottom: 0; border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||||
|
box-shadow: 0 -8px 28px rgb(0 0 0 / .18); }
|
||||||
|
|
||||||
/* word sidebar (Wiktionary): left side on wide screens, bottom sheet on phones */
|
/* word sidebar (Wiktionary): left side on wide screens, bottom sheet on phones */
|
||||||
.dict { position: fixed; top: 0; left: 0; bottom: 0; width: min(340px, 92vw); z-index: 20; overflow-y: auto; padding: 14px 18px 24px;
|
.dict { position: fixed; top: 0; left: 0; bottom: 0; width: min(340px, 92vw); z-index: 20; overflow-y: auto; padding: 14px 18px 24px;
|
||||||
@ -294,7 +302,7 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.account-form > button { font-size: inherit; padding: 4px 14px; }
|
.account-form > button { font-size: inherit; padding: 4px 14px; }
|
||||||
/* boxes with the keyboard buttons inside them: full width, and the small in-box buttons keep their own look */
|
/* boxes with the keyboard buttons inside them: full width, and the small in-box buttons keep their own look */
|
||||||
.account-form .in-box-field { display: block; position: relative; }
|
.account-form .in-box-field { display: block; position: relative; }
|
||||||
.account-form .in-box-field input { width: 100%; box-sizing: border-box; }
|
.account-form .in-box-field :is(input, textarea) { width: 100%; box-sizing: border-box; }
|
||||||
.account-form .in-box { padding: 4px; border: 0; border-radius: 6px; background: none; color: var(--text-muted); }
|
.account-form .in-box { padding: 4px; border: 0; border-radius: 6px; background: none; color: var(--text-muted); }
|
||||||
.account-form.danger button { border-color: var(--danger); color: var(--danger); }
|
.account-form.danger button { border-color: var(--danger); color: var(--danger); }
|
||||||
.form-error { color: var(--danger); text-align: center; }
|
.form-error { color: var(--danger); text-align: center; }
|
||||||
@ -415,6 +423,10 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.timeline li { margin: 4px 0; }
|
.timeline li { margin: 4px 0; }
|
||||||
.mod-h { text-align: center; color: var(--brand); }
|
.mod-h { text-align: center; color: var(--brand); }
|
||||||
.inline-form { display: inline; }
|
.inline-form { display: inline; }
|
||||||
|
.version-tools { display: flex; flex-wrap: wrap; gap: 8px 12px; justify-content: center; align-items: center; margin: 10px 0; }
|
||||||
|
.add-key, .del-key { font-weight: 700; padding: 0 6px; border-radius: 4px; }
|
||||||
|
.add-key { background: color-mix(in srgb, var(--ok) 14%, transparent); }
|
||||||
|
.del-key { background: color-mix(in srgb, var(--danger) 12%, transparent); }
|
||||||
/* a page's action row (save, edit, history, tags): spaced as one row of buttons, wrapping on phones */
|
/* 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; }
|
.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; }
|
.mod-history { font-size: .85rem; margin-inline-start: 6px; }
|
||||||
@ -503,6 +515,12 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.eb-meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; max-width: 700px; margin: 0 auto; }
|
.eb-meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; max-width: 700px; margin: 0 auto; }
|
||||||
.eb-meta dt { color: var(--text-muted); }
|
.eb-meta dt { color: var(--text-muted); }
|
||||||
.eb-meta dd { margin: 0; }
|
.eb-meta dd { margin: 0; }
|
||||||
|
/* poet details (#32): two years side by side; an intro in paragraphs */
|
||||||
|
.year-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.year-pair label { min-width: 0; }
|
||||||
|
.year-pair input { width: 100%; box-sizing: border-box; }
|
||||||
|
.poet-intro { max-width: 700px; margin: 10px auto; line-height: 2; }
|
||||||
|
.intro p { margin: 0 0 .6em; }
|
||||||
/* e-books: tabs, cover grid, right-to-left PDF reader */
|
/* e-books: tabs, cover grid, right-to-left PDF reader */
|
||||||
.ebook-tabs a span[lang] { font-size: .8em; }
|
.ebook-tabs a span[lang] { font-size: .8em; }
|
||||||
.ebook-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px; margin: 16px 0; }
|
.ebook-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px; margin: 16px 0; }
|
||||||
@ -562,6 +580,10 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.display-panel p { margin: 6px 0 4px; color: var(--text-muted); font-size: .8rem; }
|
.display-panel p { margin: 6px 0 4px; color: var(--text-muted); font-size: .8rem; }
|
||||||
.display-panel .seg { display: flex; gap: 4px; }
|
.display-panel .seg { display: flex; gap: 4px; }
|
||||||
.display-panel button { flex: 1; gap: 4px; padding: 2px 6px; border-radius: var(--r-md); }
|
.display-panel button { flex: 1; gap: 4px; padding: 2px 6px; border-radius: var(--r-md); }
|
||||||
|
.display-panel .weight { display: grid; grid-template-columns: 6.5em 1fr; align-items: center; gap: 8px; margin: 6px 0 2px; font-size: .8rem; color: var(--text-muted); } /* the same label width, so both sliders are the same length */
|
||||||
|
.display-panel .weight span { white-space: nowrap; }
|
||||||
|
.display-panel .weight input { width: 100%; min-width: 0; margin: 0; accent-color: var(--action); }
|
||||||
|
.display-panel .weight:has(input:disabled) { opacity: .5; }
|
||||||
.display-panel .f-nastaliq { font-family: 'Noto Nastaliq Urdu', serif; line-height: 2.2; }
|
.display-panel .f-nastaliq { font-family: 'Noto Nastaliq Urdu', serif; line-height: 2.2; }
|
||||||
.display-panel .swatches { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
|
.display-panel .swatches { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
|
||||||
.display-panel .swatches button { flex-direction: column; font-size: .7rem; padding: 4px 2px; }
|
.display-panel .swatches button { flex-direction: column; font-size: .7rem; padding: 4px 2px; }
|
||||||
@ -644,3 +666,302 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.account-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
.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 { 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; }
|
.danger-zone p { margin: 0 0 6px; }
|
||||||
|
|
||||||
|
/* ── motion: purposeful and short; all of it off when the reader's device asks for reduced motion ── */
|
||||||
|
/* page to page: a cross-fade, the logo stays put */
|
||||||
|
@view-transition { navigation: auto; }
|
||||||
|
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; }
|
||||||
|
.wordmark .logo { view-transition-name: logo; }
|
||||||
|
/* the confirm dialog */
|
||||||
|
.ask { border: 1.5px solid var(--border); border-radius: var(--r-lg); padding: 18px 22px 14px; max-width: min(420px, 92vw); background: var(--surface-raised); color: var(--text);
|
||||||
|
box-shadow: var(--shadow-lg); font-family: var(--font-ui); }
|
||||||
|
.ask::backdrop { background: rgb(0 0 0 / .35); backdrop-filter: blur(2px); }
|
||||||
|
.ask p { margin: 0 0 14px; line-height: 1.9; }
|
||||||
|
.ask-actions { display: flex; gap: 8px; justify-content: flex-start; }
|
||||||
|
.ask[open] { animation: pop-in .18s ease-out; }
|
||||||
|
@keyframes pop-in { from { opacity: 0; transform: scale(.96) translateY(6px); } }
|
||||||
|
/* toasts: bottom centre, above everything */
|
||||||
|
.toasts { position: fixed; inset-inline: 0; bottom: 20px; z-index: 50; display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; }
|
||||||
|
.toast { margin: 0; padding: 8px 18px; border-radius: var(--r-pill); background: var(--text); color: var(--surface-page); box-shadow: var(--shadow-lg);
|
||||||
|
font: var(--fs-sm)/1.8 var(--font-ui); animation: toast-in .25s ease-out; }
|
||||||
|
.toast::before { content: '✓ '; color: var(--ok); font-weight: 700; }
|
||||||
|
.toast.error::before { content: '! '; color: var(--danger); }
|
||||||
|
.toast.info::before { content: 'ⓘ '; color: var(--accent); }
|
||||||
|
.toast.out { animation: toast-out .3s ease-in forwards; }
|
||||||
|
@keyframes toast-in { from { opacity: 0; transform: translateY(14px); } }
|
||||||
|
@keyframes toast-out { to { opacity: 0; transform: translateY(14px); } }
|
||||||
|
/* errors stay by the form and nudge once */
|
||||||
|
.form-error:not(:empty) { animation: nudge .4s ease-in-out; }
|
||||||
|
@keyframes nudge { 25% { transform: translateX(-5px); } 50% { transform: translateX(5px); } 75% { transform: translateX(-3px); } }
|
||||||
|
/* a submitted button is busy until the page answers */
|
||||||
|
[aria-busy='true'] { position: relative; color: transparent !important; pointer-events: none; }
|
||||||
|
[aria-busy='true']::after { content: ''; position: absolute; inset: 0; margin: auto; width: 1em; height: 1em; border-radius: 50%;
|
||||||
|
border: 2px solid var(--text-muted); border-top-color: transparent; animation: spin .7s linear infinite; }
|
||||||
|
.btn.primary[aria-busy='true']::after { border-color: var(--action-ink); border-top-color: transparent; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
/* reading: the couplets rise in, one after another, on first load */
|
||||||
|
.poem > .couplet, .poem > .para { animation: rise .45s ease-out both; }
|
||||||
|
.poem > .couplet:nth-of-type(2), .poem > .para:nth-of-type(2) { animation-delay: 45ms; }
|
||||||
|
.poem > .couplet:nth-of-type(3), .poem > .para:nth-of-type(3) { animation-delay: 90ms; }
|
||||||
|
.poem > .couplet:nth-of-type(4), .poem > .para:nth-of-type(4) { animation-delay: 135ms; }
|
||||||
|
.poem > .couplet:nth-of-type(5), .poem > .para:nth-of-type(5) { animation-delay: 180ms; }
|
||||||
|
.poem > .couplet:nth-of-type(6), .poem > .para:nth-of-type(6) { animation-delay: 225ms; }
|
||||||
|
.poem > .couplet:nth-of-type(7), .poem > .para:nth-of-type(7) { animation-delay: 270ms; }
|
||||||
|
.poem > .couplet:nth-of-type(8), .poem > .para:nth-of-type(8) { animation-delay: 315ms; }
|
||||||
|
.poem > .couplet:nth-of-type(9), .poem > .para:nth-of-type(9) { animation-delay: 360ms; }
|
||||||
|
.poem > .couplet:nth-of-type(10), .poem > .para:nth-of-type(10) { animation-delay: 405ms; }
|
||||||
|
.poem > .couplet:nth-of-type(11), .poem > .para:nth-of-type(11) { animation-delay: 450ms; }
|
||||||
|
.poem > .couplet:nth-of-type(12), .poem > .para:nth-of-type(12) { animation-delay: 495ms; }
|
||||||
|
.poem > .couplet:nth-of-type(13), .poem > .para:nth-of-type(13) { animation-delay: 540ms; }
|
||||||
|
.poem > .couplet:nth-of-type(14), .poem > .para:nth-of-type(14) { animation-delay: 585ms; }
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
|
||||||
|
/* saving: the bookmark gives a small pop when it fills */
|
||||||
|
.save[aria-pressed='true']::before, .bm[aria-pressed='true']::before, .wordbook[aria-pressed='true'] { animation: pop .35s ease-out; }
|
||||||
|
@keyframes pop { 40% { transform: scale(1.35); } }
|
||||||
|
/* the word sidebar slides in (on phones it rises from the bottom) */
|
||||||
|
.dict { transition: transform .25s ease-out, opacity .25s; }
|
||||||
|
@starting-style { .dict { transform: translateX(-100%); opacity: 0; } }
|
||||||
|
@media (max-width: 700px) { @starting-style { .dict { transform: translateY(100%); } } }
|
||||||
|
/* menus and folding sections open smoothly */
|
||||||
|
.display-menu[open] .display-panel { animation: pop-in .16s ease-out; transform-origin: top left; }
|
||||||
|
:root { interpolate-size: allow-keywords; }
|
||||||
|
details::details-content { transition: block-size .25s, content-visibility .25s allow-discrete; overflow: clip; }
|
||||||
|
/* headings break into even lines */
|
||||||
|
h1, h2, h3, .ask p { text-wrap: balance; }
|
||||||
|
p, li { text-wrap: pretty; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after, ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
|
.ask .btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
/* notes: a glyph below each couplet's bookmark (outline: write a note; filled gold: this couplet or a phrase in it has one) */
|
||||||
|
:root { --nt-outline: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M21%2015a2%202%200%200%201-2%202H7l-4%204V5a2%202%200%200%201%202-2h14a2%202%200%200%201%202%202z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M7 8h10M7 12h6' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); --nt-filled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M21%2015a2%202%200%200%201-2%202H7l-4%204V5a2%202%200%200%201%202-2h14a2%202%200%200%201%202%202z' fill='black' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M7 8h10M7 12h6' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||||
|
.nt { position: absolute; inset-inline-end: -26px; top: calc(.5em + 26px); width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer;
|
||||||
|
opacity: 0; transition: opacity .15s; }
|
||||||
|
.nt::before { content: ''; display: block; width: 17px; height: 17px; margin: auto; background-color: var(--text-muted);
|
||||||
|
-webkit-mask: var(--nt-outline) center / contain no-repeat; mask: var(--nt-outline) center / contain no-repeat; }
|
||||||
|
.nt[aria-pressed='true'] { opacity: 1; }
|
||||||
|
.nt[aria-pressed='true']::before { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); animation: pop .35s ease-out; }
|
||||||
|
.couplet:hover .nt, .poem .para:hover .nt, .poem .single:hover .nt, .nt:focus-visible { opacity: 1; }
|
||||||
|
@media (hover: none) { .nt { opacity: .55; } .nt[aria-pressed='true'] { opacity: 1; } }
|
||||||
|
@media (max-width: 700px) { .nt { inset-inline-end: -4px; top: 26px; } }
|
||||||
|
/* a highlighted phrase with a note: the note glyph instead of the bookmark */
|
||||||
|
.bmk.note-only { background: none; text-decoration-color: var(--accent); } /* a note without a bookmark: underline only */
|
||||||
|
.bmk.has-note::after { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); }
|
||||||
|
.bmk:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||||
|
/* the note box */
|
||||||
|
.note-box { width: min(480px, 92vw); }
|
||||||
|
.note-for { font-size: var(--fs-md); color: var(--text-muted); max-height: 6.5em; overflow: auto; }
|
||||||
|
.note-box textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; margin-bottom: 10px; }
|
||||||
|
.note-field .in-box { top: 20px; } /* at the first line of the box, not its middle */
|
||||||
|
.note-field textarea { padding-left: 80px; } /* the buttons sit on the left whatever the note's language (the box follows what is typed) */
|
||||||
|
.note-box .kb { padding: 0 0 10px; }
|
||||||
|
.note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); }
|
||||||
|
.note-others button { all: unset; display: block; cursor: pointer; font-size: var(--fs-sm); color: var(--text-muted); padding: 2px 0; }
|
||||||
|
.note-others button:hover { color: var(--active); }
|
||||||
|
.ask-actions { flex-wrap: wrap; }
|
||||||
|
.note-box .note-clear { margin-inline-start: auto; }
|
||||||
|
/* share a couplet as an image: under the note glyph, shown on hover like the bookmark */
|
||||||
|
.sh { position: absolute; inset-inline-end: -26px; top: calc(.5em + 52px); width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer; opacity: 0; transition: opacity .15s; }
|
||||||
|
.sh::before { content: ''; display: block; width: 16px; height: 16px; margin: auto; background-color: var(--text-muted); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='m16 6-4-4-4 4'/%3E%3Cpath d='M12 2v13'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='m16 6-4-4-4 4'/%3E%3Cpath d='M12 2v13'/%3E%3C/svg%3E") center / contain no-repeat; }
|
||||||
|
.sh:hover::before { background-color: var(--active); }
|
||||||
|
.couplet:hover .sh, .poem .para:hover .sh, .poem .single:hover .sh, .sh:focus-visible { opacity: 1; }
|
||||||
|
@media (hover: none) { .sh { opacity: .55; } }
|
||||||
|
@media (max-width: 700px) { .sh { inset-inline-end: -4px; top: 52px; } }
|
||||||
|
|
||||||
|
/* moderation dashboard: count tiles that jump to their queue; queue items as cards */
|
||||||
|
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; max-width: 760px; margin: 0 auto 18px; }
|
||||||
|
.stat { display: flex; flex-direction: column; align-items: center; padding: 12px 10px 8px; border: 1.5px solid var(--border); border-radius: var(--r-lg);
|
||||||
|
background: var(--surface-raised); color: var(--text); transition: transform .2s, border-color .2s; }
|
||||||
|
.stat:hover { transform: translateY(-2px); border-color: var(--active); color: var(--text); }
|
||||||
|
.stat strong { font: 700 2.2rem/1.4 var(--font-ui); color: var(--action); }
|
||||||
|
.stat span { font-size: var(--fs-sm); color: var(--text-muted); }
|
||||||
|
.queue { max-width: 760px; margin: 0 auto 22px; scroll-margin-top: 16px; }
|
||||||
|
.queue h2 { text-align: start; font-size: var(--fs-lg); }
|
||||||
|
.queue h2::after { display: none; }
|
||||||
|
.queue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.queue-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--r-md);
|
||||||
|
background: var(--surface-raised); color: var(--text); transition: transform .2s, border-color .2s; }
|
||||||
|
.queue-card:hover { transform: translateY(-1px); border-color: var(--active); color: var(--text); }
|
||||||
|
.queue-card .kind { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.queue-card .what { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.6; }
|
||||||
|
.queue-card .what small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-xs); }
|
||||||
|
@media (max-width: 560px) { .queue-card { flex-wrap: wrap; } .queue-card .status { margin-inline-start: 46px; } }
|
||||||
|
/* an empty queue or list: a quiet line with the ✤ */
|
||||||
|
.empty { text-align: center; color: var(--text-muted); padding: 14px; border: 1px dashed var(--line); border-radius: var(--r-md); }
|
||||||
|
.empty span { display: block; color: var(--brand); opacity: .6; font-size: 1.3rem; }
|
||||||
|
/* timelines (moderation activity, admin record): a dot per entry, coloured by what happened */
|
||||||
|
.timeline { list-style: none; max-width: 760px; margin: 8px auto; padding: 0; padding-inline-start: 22px; border-inline-start: 2px solid var(--line); }
|
||||||
|
.timeline li { position: relative; margin: 0 0 12px; padding: 8px 14px; border-radius: var(--r-md); background: var(--surface-raised); border: 1px solid var(--line); }
|
||||||
|
.timeline li::before { content: ''; position: absolute; inset-inline-start: -30px; top: 16px; width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: var(--tone); box-shadow: 0 0 0 3px var(--surface-page); }
|
||||||
|
.timeline li p { margin: 0; line-height: 1.8; }
|
||||||
|
.timeline .who { font: var(--fs-sm) var(--font-latin); color: var(--text-muted); }
|
||||||
|
.timeline header { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
|
||||||
|
.timeline time { font-size: var(--fs-xs); white-space: nowrap; }
|
||||||
|
.timeline .target { font-family: var(--font-latin); }
|
||||||
|
.timeline blockquote { margin: 4px 0; padding-inline-start: 10px; border-inline-start: 2px solid var(--tone); color: var(--text-muted); font-size: var(--fs-sm); }
|
||||||
|
[data-tone] { --tone: var(--text-muted); }
|
||||||
|
[data-tone='ok'] { --tone: var(--ok); } [data-tone='action'] { --tone: var(--action); } [data-tone='accent'] { --tone: var(--accent); } [data-tone='danger'] { --tone: var(--danger); }
|
||||||
|
|
||||||
|
/* a couplet's bookmark, note, share and download sit in one row at its left edge, on the bottom line (the ✤ line, where
|
||||||
|
there is never text): clear of the misras however long they are, the same on phones */
|
||||||
|
.c-tools { position: absolute; left: 0; bottom: -4px; z-index: 1; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.c-tools > button { position: static; inset: auto; top: auto; }
|
||||||
|
.poem .para > .c-tools, .poem .single > .c-tools { bottom: -20px; }
|
||||||
|
.poem .para, .poem .single { margin-bottom: 26px; }
|
||||||
|
@media (max-width: 760px) { .c-tools { bottom: 2px; } .couplet { padding-bottom: 28px; } }
|
||||||
|
/* download the couplet's picture: beside share */
|
||||||
|
.dl { width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer; opacity: 0; transition: opacity .15s; }
|
||||||
|
.dl::before { content: ''; display: block; width: 16px; height: 16px; margin: auto; background-color: var(--text-muted); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E") center / contain no-repeat; }
|
||||||
|
.dl:hover::before { background-color: var(--active); }
|
||||||
|
.couplet:hover .dl, .poem .para:hover .dl, .poem .single:hover .dl, .dl:focus-visible { opacity: 1; }
|
||||||
|
@media (hover: none) { .dl { opacity: .55; } }
|
||||||
|
/* the notes page: one card per note, the couplet with the noted phrase marked, the note editable in place */
|
||||||
|
.note-list { list-style: none; padding: 0; max-width: 760px; margin: 12px auto; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.note-card { position: relative; padding: 10px 16px 12px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface-raised); }
|
||||||
|
.note-card header { display: flex; align-items: center; gap: 8px; padding-inline-end: 40px; }
|
||||||
|
.note-card .crumbs { text-align: start; margin: 0; font-size: var(--fs-xs); flex: 1; }
|
||||||
|
.note-card .lib-couplet { margin: 6px 0 8px; }
|
||||||
|
.note-card mark { background: var(--accent-soft); box-shadow: inset 0 -2px var(--accent); }
|
||||||
|
.note-edit textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; background: var(--surface-page); }
|
||||||
|
.note-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; }
|
||||||
|
.note-delete { position: absolute; top: 10px; left: 12px; margin: 0; }
|
||||||
|
|
||||||
|
/* ── reading mode: the text, the breadcrumbs and the logo; nothing else. On readable pages only (poems, books, poets),
|
||||||
|
kept across pages until switched off from the ✕ in the header ── */
|
||||||
|
.byline, .reading-exit { display: none; }
|
||||||
|
:root[data-reading]:has([data-readable]) {
|
||||||
|
.site-header .search-form, .site-header .toggles, .site-header .kb, .site-footer, .center:has(> .save), .c-tools, .tag-chips,
|
||||||
|
.hint, .hl-note, .divan-edit, .source, .dict { display: none !important; }
|
||||||
|
.reading-exit { display: inline-flex; margin-inline-start: auto; }
|
||||||
|
.site-header .wrap { flex-wrap: nowrap; padding-top: 10px; }
|
||||||
|
.wordmark .logo { width: 44px; }
|
||||||
|
main { max-width: 760px; padding-top: 4px; }
|
||||||
|
.crumbs { font-size: var(--fs-sm); }
|
||||||
|
.byline { display: block; text-align: center; margin: -4px 0 14px; color: var(--brand); font-family: 'Noto Nastaliq Urdu', serif; line-height: 2; }
|
||||||
|
.poem { background: none; border: 0; box-shadow: none; padding-inline: 0; }
|
||||||
|
.couplet, .poem .single { font-size: 1.28rem; }
|
||||||
|
.poem .para { font-size: 1.15rem; }
|
||||||
|
.couplet:hover { background: none; }
|
||||||
|
.intro { background: none; border: 0; padding-inline: 0; }
|
||||||
|
}
|
||||||
|
@media print { /* printing is reading too: the text and its source */
|
||||||
|
.site-header .search-form, .site-header .toggles, .site-footer, .center:has(> .save), .c-tools, .tag-chips, .hint, .poem-nav, .dict, .toasts { display: none !important; }
|
||||||
|
.byline { display: block; text-align: center; }
|
||||||
|
.poem { background: none; border: 0; }
|
||||||
|
}
|
||||||
|
/* 404 and error pages */
|
||||||
|
.error-page { text-align: center; padding: 32px 0 48px; }
|
||||||
|
.error-code { font: 700 4.5rem/1 'Noto Naskh Arabic', serif; color: var(--accent, var(--brand)); opacity: .35; margin: 0 0 8px; }
|
||||||
|
.error-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 18px; }
|
||||||
|
|
||||||
|
/* ── the header stays at the top for everyone. Its height never changes while scrolling (that would make the page jump):
|
||||||
|
once scrolled it only gains a translucent, blurred background and a hairline shadow. --header-h (set by Base) lets
|
||||||
|
anchors and sticky toolbars clear it ── */
|
||||||
|
.site-header { position: sticky; top: 0; z-index: 15; background: var(--surface-page); transition: box-shadow .2s, background .2s; }
|
||||||
|
.site-header .wrap { padding-bottom: 10px; }
|
||||||
|
:root[data-scrolled] .site-header { background: color-mix(in srgb, var(--surface-page) 86%, transparent); backdrop-filter: saturate(1.3) blur(10px);
|
||||||
|
box-shadow: 0 1px 0 var(--line), 0 8px 20px rgb(0 0 0 / .06); }
|
||||||
|
:root { scroll-padding-top: calc(var(--header-h, 80px) + 12px); } /* #c5 and other anchors land below the header */
|
||||||
|
.toolbar { top: var(--header-h, 0px); }
|
||||||
|
.live-code { top: calc(var(--header-h, 0px) + 8px); }
|
||||||
|
|
||||||
|
/* ── footer: the brand beside three groups of links across the page's width; licence and attribution in a thin bar ── */
|
||||||
|
.site-footer .wrap { display: grid; grid-template-columns: minmax(300px, 1.2fr) 2fr; gap: 18px 40px; align-items: start; }
|
||||||
|
.footer-brand { display: flex; flex-wrap: wrap; align-items: center; gap: 0 14px; }
|
||||||
|
.footer-brand .made-in { flex-basis: 100%; margin-top: 14px; padding-inline-start: 5px; } /* its own line below the logo, starting at the
|
||||||
|
logo's visible edge (the medallion's solid lobes sit about 5px inside the image) */
|
||||||
|
.footer-logo { flex: none; display: block; }
|
||||||
|
.footer-logo img { display: block; width: 64px; height: 64px; }
|
||||||
|
.footer-tagline { margin: 4px 0 0; font: 700 1.15rem/2.2 'Noto Nastaliq Urdu', serif; color: var(--action); } /* the logo says دیوان; the tagline beside it */
|
||||||
|
.made-in { margin: 8px 0 0; display: flex; align-items: center; gap: 8px; font-family: 'Noto Nastaliq Urdu', serif; font-size: .9rem; line-height: 2; color: var(--text); }
|
||||||
|
.made-in .flag { flex: none; border-radius: 2px; box-shadow: 0 0 0 1px rgb(0 0 0 / .15); }
|
||||||
|
.footer-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
|
||||||
|
.footer-links > div:first-child { grid-row: span 2; } /* the site's five links down one column, sources and code stacked beside them */
|
||||||
|
.footer-bar { grid-column: 1 / -1; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 18px; margin: 0; padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--line); font-size: var(--fs-xs); color: var(--text-muted); }
|
||||||
|
.footer-bar a { color: var(--text-muted); }
|
||||||
|
.footer-bar a:hover { color: var(--active); }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.site-footer .wrap { grid-template-columns: 1fr; }
|
||||||
|
.footer-bar { justify-content: center; text-align: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── phones ── */
|
||||||
|
/* e-book format tabs: one line each; the English in brackets only where there is room */
|
||||||
|
.ebook-tabs a { white-space: nowrap; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.ebook-tabs { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
|
||||||
|
.ebook-tabs a span[lang] { display: none; }
|
||||||
|
}
|
||||||
|
/* touch screens have no hover: a couplet's tools show when it is tapped (the couplet is lightly marked); a set bookmark
|
||||||
|
or note stays visible; couplets without either do not keep room for the row */
|
||||||
|
@media (hover: none) {
|
||||||
|
.poem .couplet.is-active, .poem .para.is-active, .poem .single.is-active { background: color-mix(in srgb, var(--accent) 14%, transparent); border-radius: var(--r-sm); }
|
||||||
|
}
|
||||||
|
/* phones: the header slides away while scrolling down and comes back on the way up (a transform: nothing below jumps) */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.site-header { transition: transform .25s ease, box-shadow .2s, background .2s; }
|
||||||
|
:root[data-header-hidden] .site-header { transform: translateY(-100%); box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── a couplet's tools: one small button (⋯, with a filled bookmark and/or note glyph when set) that opens one shared
|
||||||
|
menu: bookmark, note, share, download. The button shows on hover (on touch screens when the couplet is tapped); a set
|
||||||
|
bookmark or note keeps it visible ── */
|
||||||
|
:root { --dots: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='5' cy='12' r='2.2'/%3E%3Ccircle cx='12' cy='12' r='2.2'/%3E%3Ccircle cx='19' cy='12' r='2.2'/%3E%3C/svg%3E"); --sh-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='m16 6-4-4-4 4'/%3E%3Cpath d='M12 2v13'/%3E%3C/svg%3E"); --dl-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E"); }
|
||||||
|
.c-tools > .more { align-items: center; gap: 3px; height: 24px; padding: 0 6px; border: 0; border-radius: var(--r-pill); background: none;
|
||||||
|
color: var(--text-muted); cursor: pointer; opacity: 0; transition: opacity .15s, background .15s; }
|
||||||
|
.more i, .c-menu i { flex: none; width: 15px; height: 15px; background-color: currentColor; -webkit-mask: var(--m) center / contain no-repeat; mask: var(--m) center / contain no-repeat; }
|
||||||
|
.more i { display: none; }
|
||||||
|
.more .g-dots { display: block; --m: var(--dots); }
|
||||||
|
.more[data-bm] .g-bm { display: block; --m: var(--bm-filled); background-color: var(--active); }
|
||||||
|
.more[data-nt] .g-nt { display: block; --m: var(--nt-filled); background-color: var(--accent); }
|
||||||
|
.more[data-bm], .more[data-nt] { opacity: 1; }
|
||||||
|
.more[data-bm] .g-dots, .more[data-nt] .g-dots { opacity: .45; }
|
||||||
|
:is(.couplet, .para, .single):hover .more, .is-active .more, .more:focus-visible { opacity: 1; }
|
||||||
|
.more:hover { background: var(--accent-soft); color: var(--active); }
|
||||||
|
/* the ⋯ and its menu are for phones; wider screens keep the four icons */
|
||||||
|
.more { display: none; }
|
||||||
|
@media (max-width: 640px), (hover: none) and (pointer: coarse) {
|
||||||
|
.c-tools > .bm, .c-tools > .nt, .c-tools > .sh, .c-tools > .dl { display: none; }
|
||||||
|
.c-tools > .more { display: inline-flex; }
|
||||||
|
.c-tools { bottom: 2px; } .poem .couplet { padding-bottom: 26px; } /* room for the button: it never sits on the text */
|
||||||
|
}
|
||||||
|
.c-menu { position: fixed; inset: auto; margin: 0; min-width: 180px; padding: 6px; border: 1px solid var(--border); border-radius: var(--r-md);
|
||||||
|
background: var(--surface-raised); color: var(--text); box-shadow: var(--shadow-lg); }
|
||||||
|
.c-menu:popover-open { display: flex; flex-direction: column; animation: pop-in .14s ease-out; }
|
||||||
|
.c-menu button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 5px 10px; border: 0; border-radius: var(--r-sm); background: none;
|
||||||
|
color: var(--text); font: var(--fs-sm)/1.9 var(--font-ui); text-align: start; cursor: pointer; }
|
||||||
|
.c-menu button:hover, .c-menu button:focus-visible { background: var(--accent-soft); color: var(--active); outline: none; }
|
||||||
|
.c-menu i { width: 17px; height: 17px; background-color: var(--text-muted); }
|
||||||
|
.c-menu .g-bm { --m: var(--bm-outline); } .c-menu [data-on] .g-bm { --m: var(--bm-filled); background-color: var(--active); }
|
||||||
|
.c-menu .g-nt { --m: var(--nt-outline); } .c-menu .g-sh { --m: var(--sh-mask); } .c-menu .g-dl { --m: var(--dl-mask); }
|
||||||
|
/* a page's tags at the end of its content: «ٹیگ» at the start of the line; opened, the tags follow it and wrap across the width */
|
||||||
|
.tags-box { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 10px; }
|
||||||
|
.tags-box > .tag-chip { display: none; }
|
||||||
|
.tags-box > :checked ~ .tag-chip { display: inline-flex; animation: rise .2s ease-out both; }
|
||||||
|
.tags-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 1px 14px; border: 1.5px solid var(--border); border-radius: var(--r-pill);
|
||||||
|
background: var(--surface-sunken); font: var(--fs-sm)/1.8 var(--font-ui); cursor: pointer; user-select: none; }
|
||||||
|
.tags-toggle::after { content: '⌄'; transition: transform .2s; }
|
||||||
|
.tags-box > :checked + .tags-toggle { border-color: var(--active); color: var(--active); }
|
||||||
|
.tags-box > :checked + .tags-toggle::after { transform: rotate(-90deg); } /* points along the line, towards the tags */
|
||||||
|
.tags-toggle:hover { border-color: var(--active); color: var(--active); }
|
||||||
|
.tags-box > :focus-visible + .tags-toggle { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||||
|
/* how to use the site */
|
||||||
|
.help { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }
|
||||||
|
.help section { padding: 10px 16px 6px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--surface-raised); }
|
||||||
|
.help h3 { display: flex; align-items: center; gap: 10px; text-align: start; font-size: var(--fs-lg); margin: 0 0 4px; }
|
||||||
|
.help h3::after { display: none; }
|
||||||
|
.help-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); flex: none; }
|
||||||
|
.help p { margin: 0 0 8px; line-height: 2; }
|
||||||
|
|
||||||
|
/* admin statistics (#86): daily bars, views behind and visitors in front */
|
||||||
|
.bars { display: flex; align-items: flex-end; gap: 2px; height: 160px; padding: 8px; border: 1px solid var(--border); border-radius: 10px; direction: ltr; }
|
||||||
|
.bar { flex: 1; height: 100%; position: relative; display: flex; align-items: flex-end; }
|
||||||
|
.bar span, .bar i { position: absolute; inset-inline: 0; bottom: 0; border-radius: 3px 3px 0 0; }
|
||||||
|
.bar span, .key-views { background: color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||||
|
.bar i, .key-visitors { background: var(--accent); inset-inline: 25%; }
|
||||||
|
.key-views, .key-visitors { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: middle; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 0 24px; }
|
||||||
|
.stats-range { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin: 10px 0; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user