Compare commits

...

47 Commits

Author SHA1 Message Date
12567631a5 Merge pull request 'Nastaliq note on switching; titles clear of the path line; sliders the same length' (#108) from feature/nastaliq-hint into main
Reviewed-on: #108
2026-10-09 23:20:04 +00:00
Anas Rashid
c8f5459ad7 Display settings: both sliders the same length (labels share one width)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:19:26 +02:00
Anas Rashid
07e85d7a54 Nastaliq titles: more line height and space above, so tall letters no longer reach into the path line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:18:33 +02:00
Anas Rashid
cc797fd262 Switching to Nastaliq shows a short note that it is for the text and the rest of the site stays Naskh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:17:45 +02:00
e16b4b412e Merge pull request 'Fonts: interface always Naskh, reading font for the literature; radif spacing' (#107) from feature/ui-naskh into main
Reviewed-on: #107
2026-10-09 23:09:40 +00:00
Anas Rashid
804cf0dc09 Fonts, one rule: the interface is always Naskh (menus, forms, paths, footer, moderation); the reading font only for the literature (verses, prose, titles, contents, intros, excerpts, the editor). The radif line no longer overlaps the buttons above it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:09:19 +02:00
be7140841a Merge pull request 'Poet and book editors: details, intro and titles through the pipeline' (#106) from feature/poet-book-editors into main
Reviewed-on: #106
2026-10-09 23:08:07 +00:00
Anas Rashid
6874a4e4a2 Poet and book editors (#32): a poet's name, pen name, years and intro, and a book or section's title, as drafts through the pipeline; published to divan-data (divan/<url>.poet, .book) and the site; history, compare and revert like works; intros in paragraphs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:05:32 +02:00
a165f63ece Merge pull request 'One keyboard design across the site' (#105) from feature/unified-keyboard into main
Reviewed-on: #105
2026-10-09 23:00:56 +00:00
e9eb3c6f88 Merge pull request 'Text size slider in the display settings' (#104) from feature/text-size into main
Reviewed-on: #104
2026-10-09 23:00:44 +00:00
Anas Rashid
24fcbccccd One keyboard design everywhere: fixed rows that never wrap, a letter a tenth of the row, shift and delete a bit wider, the space bar filling its row; up to 640px wide and centred, in the header, under a field, in the note dialog and docked in the editor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:00:03 +02:00
Anas Rashid
61321b5130 Display settings: text size slider (90%-150%) beside Naskh's weight; the whole page scales, remembered, applied before paint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:58:01 +02:00
77c07c1552 Merge pull request 'Attribution: Divan by Muhammad Anas Rashid' (#103) from feature/owner-name into main
Reviewed-on: #103
2026-10-09 22:54:57 +00:00
fdb42e78d8 Merge pull request 'Profile: Urdu keyboard on name and bio; field keyboards open under the field' (#102) from feature/profile-keyboard into main
Reviewed-on: #102
2026-10-09 22:54:42 +00:00
0694a2ed4f Merge pull request 'Naskh weight slider in the display settings' (#101) from feature/naskh-weight into main
Reviewed-on: #101
2026-10-09 22:54:33 +00:00
586f49f836 Merge pull request 'How to use the site: readers, moderators and admins' (#100) from feature/help-guide into main
Reviewed-on: #100
2026-10-09 22:54:27 +00:00
4fc4e644d6 Merge pull request 'Statistics for admins: views, visitors, moderation, accounts (no cookies)' (#99) from feature/stats into main
Reviewed-on: #99
2026-10-09 22:54:19 +00:00
Anas Rashid
f037968ef2 Attribution: Divan by Muhammad Anas Rashid (NOTICE, TRADEMARKS, README, brand.ts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:53:33 +02:00
Anas Rashid
de2e3eb751 Profile: the Urdu keyboard and Roman typing on the name and bio; a field's keyboard opens right under that field (was at the top of the page, out of sight) and goes back to the header when closed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:53:08 +02:00
Anas Rashid
de1d7eb0a0 Display settings: a slider for Naskh's weight (regular to bold), heavier text for reading on phones; remembered, applied before paint; off for Nastaliq
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:50:24 +02:00
Anas Rashid
b2461e228a How to use the site: accounts and tag search for readers; moderators' tools (pipeline, editing, history and revert, arranging, tags, e-books, home page) and admins' (users, roles, permissions, statistics), shown to those roles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:56 +02:00
Anas Rashid
86b26ba737 Statistics for admins (#86): page views, daily visitors and referring sites without cookies or stored addresses (one-day salted hash); moderation activity, accounts and library counts; /admin/stats; privacy page updated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:45:08 +02:00
650b684ae8 Merge pull request 'Version history: compare versions, revert through the pipeline, log filters' (#98) from feature/version-history into main
Reviewed-on: #98
2026-10-09 22:43:15 +00:00
Anas Rashid
5c8559c8e9 Version history (#51): compare any two versions of a work, arrangement or tags (0 = the Wikisource text), bring an old version back as a draft through the pipeline, filter the moderation log by person, kind and step; one history table for the three pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:42:26 +02:00
709b5d4594 Merge pull request 'SEO: page descriptions, canonical URLs, structured data' (#97) from feature/seo-pages into main
Reviewed-on: #97
2026-10-09 22:36:51 +00:00
Anas Rashid
46b3372433 SEO (#83): descriptions for works (opening couplet), books, poets, e-books, e-book and tag pages; canonical URLs on every page; structured data for poets (Person), books (CollectionPage) and e-books (Book)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:36:09 +02:00
7d398cc19d Merge pull request 'Tags at the end of a page: «ٹیگ» at the start of the line, the tags following it on the same line and wrapping across the width when opened (a sample on /design)' (#96) from anas/tags-help into main
Reviewed-on: #96
2026-10-09 22:25:43 +00:00
Anas Rashid
aa57dcd78c Tags at the end of a page: «ٹیگ» at the start of the line, the tags following it on the same line and wrapping across the width when opened (a sample on /design)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:24:30 +02:00
faf8931f44 Merge pull request 'Tags folded at the end of a page; a how-to-use page instead of the hint' (#95) from anas/tags-help into main
Reviewed-on: #95
2026-10-09 22:16:23 +00:00
Anas Rashid
f20cc25080 Tags folded at the end of a page; a "how to use" page instead of the hint
- A page's tags (poem, book, poet) move from the top into a closed «ٹیگ» section with their count at the end of the content, above previous/next; couplets keep their own tags
- The line under each poem ("select a word…") is gone; /help (استعمال کا طریقہ, linked in the footer) explains the features: search and Roman typing, meanings and the selection menu, a couplet's tools (and ⋯ on phones), notes, saved items and the word book, reading mode, fonts and colours, e-books, tags and RSS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:13:45 +02:00
6fddb3b2e8 Merge pull request 'Phones: couplet tools on tap, e-book tabs on one line, a header that steps aside' (#94) from anas/mobile-polish into main
Reviewed-on: #94
2026-10-09 22:03:22 +00:00
Anas Rashid
bba1b93320 Phones: one ⋯ per couplet instead of four icons
On phones (up to 640px, or touch without a mouse) each couplet has one small ⋯ button, shown when the couplet is tapped; a set bookmark or note shows as a tiny glyph on it and keeps it visible. It opens one shared menu (a native popover, light-dismiss, arrow keys): bookmark or remove, note, share, download. Wider screens keep the four icons on hover. Couplets keep room under them on narrow screens, so the tools never sit on the text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:02:56 +02:00
Anas Rashid
c2766922b5 Phones: couplet tools on tap, e-book tabs on one line, a header that steps aside
- Touch screens: a couplet's tools show when it is tapped (the couplet lightly marked; tapping again or another hides them); a set bookmark or note stays visible; couplets without either keep no room for the row (from a phone screenshot: every line had four icons under it)
- E-book format tabs stay on one line each; on phones without the English in brackets, scrolling sideways if needed
- Phones: the sticky header slides away while scrolling down and returns on the way up (a transform, so nothing jumps; not while the search box, keyboard or display menu is in use)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:59:46 +02:00
Anas Rashid
b7facedf95 Merge branch 'feature/sticky-header-footer' into main: sticky header, a footer that uses its width (#93) 2026-10-09 23:52:24 +02:00
Anas Rashid
65762f286f Footer: the made-in line starts at the logo's visible edge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:52:15 +02:00
Anas Rashid
7077524dcb Footer: the made-in line on its own row below the logo and tagline
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:51:06 +02:00
Anas Rashid
93ccabdf29 Footer: no red دیوان heading (the logo says it; its tall letters collided with the tagline); the tagline beside the logo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:50:35 +02:00
Anas Rashid
575ef530a7 Sticky header and a footer that uses its width
- The header stays at the top for readers and admins alike; its height never changes while scrolling (no jumps), it gains a translucent blurred background and a hairline shadow once scrolled. --header-h keeps anchors (#c6) and the editor's sticky toolbar and code panel clear of it. The header logo is a little smaller (48–60px)
- Footer: the brand (logo, name, tagline, the flag line) beside the links, the site's five links down one column with sources and code stacked beside them; licence, contact and attribution in a thin bottom bar. On phones the brand on top and the links in two columns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:33:06 +02:00
9f61da990d Merge pull request 'robots.txt, sitemap.xml and an RSS feed of new content' (#92) from feature/robots-sitemap-rss into main
Reviewed-on: #92
2026-10-09 19:55:29 +00:00
Anas Rashid
0aa3d5979e Merge remote-tracking branch 'gitea/main' into feature/robots-sitemap-rss
# Conflicts:
#	web/src/components/Icon.astro
#	web/src/layouts/Base.astro
2026-10-09 21:50:15 +02:00
415459d55f Merge pull request 'Monitoring: /health for uptime checks, structured error logging, API errors without internal details' (#90) from feature/monitoring into main
Reviewed-on: #90
2026-10-09 19:49:08 +00:00
ce1adc4dcd Merge pull request 'Fix: stop tracking node_modules links (committed by mistake in #78)' (#89) from fix/untrack-node-modules into main
Reviewed-on: #89
2026-10-09 19:48:58 +00:00
b6f6c708a1 Merge pull request 'Launch: privacy and terms pages, Urdu 404 and error pages' (#88) from feature/launch-pages into main
Reviewed-on: #88
2026-10-09 19:48:33 +00:00
Anas Rashid
13eaf9fe90 robots.txt, sitemap.xml and an RSS feed of new versions, e-books, arrangements and tags (rss.xml, linked in the head and footer)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:32:55 +02:00
Anas Rashid
c20cbc17bf gitignore: comment on its own line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:29:40 +02:00
Anas Rashid
19aace12ff Stop tracking api/node_modules and web/node_modules: links to a local folder were committed by mistake in #78; .gitignore now ignores node_modules whether a folder or a link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:29:27 +02:00
Anas Rashid
8ca681fc5b Launch pages: privacy (رازداری) and terms (استعمال کی شرائط) in Urdu, Urdu 404 and error pages; footer and sign-up links; contact in brand.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:26:45 +02:00
51 changed files with 1482 additions and 146 deletions

3
.gitignore vendored
View File

@ -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

4
NOTICE
View File

@ -1,5 +1,5 @@
Divan (دیوان) Divan (دیوان)
Copyright (C) 2026 Anas Rashid and Divan contributors 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 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 additional terms below, which the GPL allows in its section 7. They apply to the code written for Divan by its
@ -10,7 +10,7 @@ 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 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): Appropriate Legal Notices it displays (for a website: its about page or footer):
"Based on Divan (https://git.anasrashid.net/anas/divan) by Anas Rashid, under the GNU GPL v3." "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 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. be presented as Divan, or as made, approved or endorsed by Divan or its authors.

View File

@ -79,4 +79,4 @@ v2 is reaching parity with Ganjoor's features in phases: reading, accounts, comm
## License ## License
GPL-3.0 (see `LICENSE`), with additional terms under its section 7 (see `NOTICE`): keep the attribution "Based on Divan by 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). 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).

View File

@ -31,7 +31,7 @@ they identify this project and its site, so that readers know what is Divan and
2. Replace the logo files listed above with your own, and the logo in the header (`web/src/layouts/Base.astro`) 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`). and home page (`web/src/pages/index.astro`).
3. Rewrite the about page (`web/src/pages/about.astro`) for your project, keeping the attribution 3. Rewrite the about page (`web/src/pages/about.astro`) for your project, keeping the attribution
"Based on Divan by Anas Rashid" from [NOTICE](NOTICE). "Based on Divan by Muhammad Anas Rashid" from [NOTICE](NOTICE).
4. Update `web/public/site.webmanifest`. 4. Update `web/public/site.webmanifest`.
Questions or permission requests: open an issue on the Divan repository. Questions or permission requests: open an issue on the Divan repository.

View File

@ -1 +0,0 @@
/Users/anasrashid/divan/api/node_modules

View File

@ -89,7 +89,7 @@ export async function sessionUser(req: FastifyRequest) {
} }
const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1']; const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
const fromSite = (req: FastifyRequest) => { export const fromSite = (req: FastifyRequest) => {
const key = process.env.DIVAN_SITE_KEY; const key = process.env.DIVAN_SITE_KEY;
if (!key) return LOOPBACK.includes(req.ip); if (!key) return LOOPBACK.includes(req.ip);
const given = Buffer.from(String(req.headers['x-site-key'] ?? '')), want = Buffer.from(key); const given = Buffer.from(String(req.headers['x-site-key'] ?? '')), want = Buffer.from(key);

91
api/src/details.test.ts Normal file
View 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
View 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
View 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
View 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 ?? [],
};
});
});
}

View File

@ -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]);

View File

@ -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 };
});
} }

View File

@ -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();

View File

@ -16,12 +16,14 @@ 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' } });
@ -218,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
View 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
View 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] };
});
}

View File

@ -253,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));

View File

@ -1 +0,0 @@
/Users/anasrashid/divan/web/node_modules

View File

@ -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>

View 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>}

View File

@ -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]} />

View 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>
)}

View File

@ -1,17 +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; author?: string; jsonld?: object } interface Props { title?: string; description?: string; q?: string; author?: string; jsonld?: object; ogType?: string }
// author and jsonld describe a work for search engines and the browser's own reader view // author and jsonld describe a work for search engines and the browser's own reader view
const { title, description = 'اردو کا کلاسیکی ادب', q = '', author, jsonld } = Astro.props; 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}`; 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 { 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">
@ -20,7 +25,9 @@ import { brand } from '../lib/brand';
<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" />
@ -28,11 +35,11 @@ import { brand } from '../lib/brand';
<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={brand.name} /> <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" />
@ -47,6 +54,10 @@ import { brand } from '../lib/brand';
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
@ -85,6 +96,10 @@ import { brand } from '../lib/brand';
<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'],
@ -129,11 +144,28 @@ import { brand } from '../lib/brand';
</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={brand.name} /></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>{brand.name}</h2><ul> <div><h2>{brand.name}</h2><ul>
<li><a href="/about"><Icon name="info" size={18} /><span>{brand.name} کے بارے میں<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>
@ -144,13 +176,10 @@ import { brand } from '../lib/brand';
<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>
{brand.madeBy}
</p> </p>
</div> </div>
</footer> </footer>
@ -162,13 +191,30 @@ import { brand } from '../lib/brand';
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
@ -286,11 +332,14 @@ import { brand } from '../lib/brand';
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);
// in an open dialog (the note box) the header can't be reached: the keyboard moves under the box, and back when it closes // a field's own keyboard opens right under that field (in view while typing, also in a dialog), and goes back to
const box = b.dataset.urduKb && target.closest('dialog'); // the header when closed; open under another field, it moves here
if (box) { kb.classList.remove('kb-float'); target.closest('.in-box-field')?.after(kb); } if (b.dataset.urduKb && target.isContentEditable) { kb.classList.add('kb-float'); document.body.append(kb); } // the editor: floating
else if (b.dataset.urduKb && target.isContentEditable) { kb.classList.add('kb-float'); document.body.append(kb); } // the editor: floating else if (b.dataset.urduKb) {
else if (b.dataset.urduKb) home(); 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'));
}); });
@ -547,6 +596,25 @@ import { brand } from '../lib/brand';
</form> </form>
</dialog> </dialog>
<div class="toasts" aria-live="polite"></div> <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> <script is:inline>
// reading mode: only the text, the breadcrumbs and the logo; remembered across pages until switched off // reading mode: only the text, the breadcrumbs and the logo; remembered across pages until switched off
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {

View File

@ -4,6 +4,8 @@ export const brand = {
name: 'دیوان', name: 'دیوان',
tagline: 'اردو کا کلاسیکی ادب', tagline: 'اردو کا کلاسیکی ادب',
madeBy: 'ایک پاکستانی کی طرف سے، پاکستان کے لیے', // footer, beside Pakistan's flag 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)) // the attribution forks keep (NOTICE, GPL v3 section 7(b))
basedOn: { text: 'Divan by Anas Rashid', url: 'https://git.anasrashid.net/anas/divan' }, basedOn: { text: 'Divan by Muhammad Anas Rashid', url: 'https://git.anasrashid.net/anas/divan' },
}; };

View File

@ -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
View 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c]!);

View File

@ -4,6 +4,21 @@
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;
@ -21,6 +36,7 @@ export const onRequest = defineMiddleware(async (ctx, next) => {
msg: String(err?.message ?? err), stack: String(err?.stack ?? '').split('\n').slice(0, 8).join('\n') })); msg: String(err?.message ?? err), stack: String(err?.stack ?? '').split('\n').slice(0, 8).join('\n') }));
throw err; 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
View 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
View 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>

View File

@ -76,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;
@ -88,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 author={page.poet?.nickname} jsonld={page.type === 'poem' ? { '@context': 'https://schema.org', '@type': 'CreativeWork', name: page.poem.title, inLanguage: 'ur', author: { '@type': 'Person', name: page.poet.nickname }, isPartOf: crumbs.at(-1)?.title, url: Astro.url.href.split('?')[0] } : undefined} 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) => (
@ -101,20 +114,18 @@ import Icon from '../components/Icon.astro';
{page.type === 'poet' && ( {page.type === 'poet' && (
<> <>
<h1 data-readable>{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><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: '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 data-readable>{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><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.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>
</> </>
)} )}
@ -132,11 +143,12 @@ 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' && (
<> <>
<article class="work" data-readable> <article class="work" data-readable>
<h1><Seg s={show(page.poem.title)} /></h1> <h1 class="lit"><Seg s={show(page.poem.title)} /></h1>
<p class="byline">{page.poet.nickname}</p> <p class="byline">{page.poet.nickname}</p>
{re && ( {re && (
<p class="hl-note muted"> <p class="hl-note muted">
@ -146,17 +158,19 @@ import Icon from '../components/Icon.astro';
<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> <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>}
<div 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 cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c); 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) // 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"> 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="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="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="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" />
<button type="button" class="dl" 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>; </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>
@ -165,7 +179,12 @@ import Icon from '../components/Icon.astro';
})} })}
</div> </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"> <dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
<form method="dialog"> <form method="dialog">
<p id="note-for" class="note-for"></p> <p id="note-for" class="note-for"></p>
@ -182,6 +201,7 @@ import Icon from '../components/Icon.astro';
</form> </form>
</dialog> </dialog>
<script type="application/json" id="notes-data" set:html={JSON.stringify({ poemId, notes, phrases: saved.phrases, signedIn: !!token })} /> <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>}
@ -233,9 +253,10 @@ import Icon from '../components/Icon.astro';
box.showModal(); text.focus(); box.showModal(); text.focus();
}; };
const mark = (couplet: number) => { // the glyph follows the notes on this couplet const mark = (couplet: number) => { // the glyph follows the notes on this couplet
const nt = document.querySelector(`.nt[data-for="${couplet}"]`); if (!nt) return;
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet); const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'); // the bookmark is separate 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 () => { box.addEventListener('close', async () => {
const act = box.returnValue, { couplet, phrase } = at; const act = box.returnValue, { couplet, phrase } = at;
@ -305,6 +326,45 @@ import Icon from '../components/Icon.astro';
else { try { await navigator.clipboard.writeText(`${text}\n${url}`); (window as any).toast('یہ براؤزر شیئر نہیں کر سکتا: شعر اور لنک کاپی ہو گئے'); } catch { (window as any).toast('شیئر نہیں ہو سکا', 'error'); } } 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')); }); 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); }); document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
} }
</script> </script>

View File

@ -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>}

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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}</>}

118
web/src/pages/help.astro Normal file
View 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>

View 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>

View 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>

View File

@ -13,11 +13,13 @@ 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 // 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' }; 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 TONE: Record<string, string> = { published: 'ok', approved: 'action', submitted: 'accent', returned: 'danger', rejected: 'danger' };
const groups = queue ? [ const groups = queue ? [
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'], ['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
@ -52,6 +54,21 @@ const groups = queue ? [
)} )}
</section> </section>
))} ))}
{log && (
<form method="get" class="version-tools" aria-label="ریکارڈ چھانیں">
<input type="hidden" name="log" />
<input type="search" name="who" value={f.who} placeholder="کس نے (ای میل)" aria-label="کس نے" dir="auto" />
<select name="kind" aria-label="کس چیز میں">
{[['', 'سب تبدیلیاں'], ['work', 'متن'], ['order', 'ترتیب'], ['tags', 'ٹیگ'], ['ebook', 'ای بکس'], ['poet', 'شاعر'], ['book', 'کتاب کا عنوان']].map(([v, t]) => <option value={v} selected={f.kind === v}>{t}</option>)}
</select>
<select name="action" aria-label="کیا کیا">
<option value="">ہر قدم</option>
{Object.entries(EVENT).map(([v, t]) => <option value={v} selected={f.action === v}>{t}</option>)}
</select>
<button>دکھائیں</button>
</form>
)}
{log && log.entries.length === 0 && <p class="muted center">اس چھانٹی میں کچھ نہیں۔</p>}
{log && ( {log && (
<ol class="timeline"> <ol class="timeline">
{log.entries.map((e: any) => ( {log.entries.map((e: any) => (

View File

@ -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>

View File

@ -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} />}

View File

@ -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>

View File

@ -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>

View 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>

View 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
View 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' } });
};

View File

@ -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>

View 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' } });
};

View File

@ -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
View 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>

View File

@ -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,11 @@ 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 */ /* 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; .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; background: var(--surface-raised); border: 1.5px solid var(--border); border-bottom: 0; border-radius: var(--r-lg) var(--r-lg) 0 0;
@ -298,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; }
@ -419,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; }
@ -507,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; }
@ -566,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; }
@ -668,6 +686,7 @@ h1 + .muted { text-align: center; margin-top: 0; }
font: var(--fs-sm)/1.8 var(--font-ui); animation: toast-in .25s ease-out; } 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::before { content: '✓ '; color: var(--ok); font-weight: 700; }
.toast.error::before { content: '! '; color: var(--danger); } .toast.error::before { content: '! '; color: var(--danger); }
.toast.info::before { content: 'ⓘ '; color: var(--accent); }
.toast.out { animation: toast-out .3s ease-in forwards; } .toast.out { animation: toast-out .3s ease-in forwards; }
@keyframes toast-in { from { opacity: 0; transform: translateY(14px); } } @keyframes toast-in { from { opacity: 0; transform: translateY(14px); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(14px); } } @keyframes toast-out { to { opacity: 0; transform: translateY(14px); } }
@ -737,7 +756,6 @@ p, li { text-wrap: pretty; }
.note-field .in-box { top: 20px; } /* at the first line of the box, not its middle */ .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-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-box .kb { padding: 0 0 10px; }
.note-box .kb button { min-width: 2em; font-size: 1rem; }
.note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); } .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 { 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); } .note-others button:hover { color: var(--active); }
@ -833,3 +851,117 @@ p, li { text-wrap: pretty; }
.byline { display: block; text-align: center; } .byline { display: block; text-align: center; }
.poem { background: none; border: 0; } .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; }