Merge pull request 'Personal library: saved poets, works, couplets and words' (#45) from feature/library into main
Reviewed-on: #45
This commit is contained in:
commit
c05ad94b53
@ -56,6 +56,8 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI
|
||||
| `GET /api/page?url=/p238/...` | the poet, category or poem at a site URL (breadcrumbs, children, verses, prev/next) |
|
||||
| `GET /api/search?q=&poet=&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`) |
|
||||
| `GET /api/word?w=` | one word's meanings and pronunciation from the local Wiktionary data (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none) |
|
||||
| `/api/auth/*` | accounts: sign-up, sign-in (returns a Bearer token), profile, password, delete |
|
||||
| `/api/library/*` | the signed-in reader's library: toggle poets, works, couplets and words; list with full paths; notes |
|
||||
| `GET /health` | database check |
|
||||
|
||||
Search normalises both stored text and queries: Arabic ي/ك/ه → Urdu ی/ک/ہ, ۂ/ۓ, diacritics and the Urdu full stop removed; do-chashmi ھ stays distinct.
|
||||
|
||||
84
api/src/library.test.ts
Normal file
84
api/src/library.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { authRoutes } from './auth.ts';
|
||||
import { libraryRoutes, cleanWord, cleanPhrase } from './library.ts';
|
||||
import { pool } from './db.ts';
|
||||
|
||||
after(() => pool.end());
|
||||
|
||||
test('saved words are cleaned like the sidebar does', () => {
|
||||
assert.equal(cleanWord(' دل، '), 'دل');
|
||||
assert.equal(cleanWord('غالبؔ'), 'غالب');
|
||||
assert.equal(cleanWord('love'), null);
|
||||
assert.equal(cleanPhrase(' وصال یار '), 'وصال یار');
|
||||
assert.equal(cleanPhrase('ی'), null);
|
||||
assert.equal(cleanWord(''), null);
|
||||
});
|
||||
|
||||
test('library: save and unsave poets, works, couplets and words; full paths; notes; isolation', async () => {
|
||||
const app = Fastify();
|
||||
authRoutes(app); libraryRoutes(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': `lib-${run}` } });
|
||||
const me = (await call('POST', '/api/auth/signup', { email: `lib-${run}@divan.test`, password: 'pass-word-1' })).json();
|
||||
const other = (await call('POST', '/api/auth/signup', { email: `lib2-${run}@divan.test`, password: 'pass-word-1' })).json();
|
||||
const t = me.token, toggle = (body: object, tk = t) => call('POST', '/api/library/toggle', body, tk);
|
||||
|
||||
const poem = (await pool.query(`SELECT p.id FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal' LIMIT 1`)).rows[0].id;
|
||||
|
||||
assert.equal((await call('GET', '/api/library')).statusCode, 401, 'signed out');
|
||||
assert.deepEqual((await toggle({ kind: 'poet', poetId: 266 })).json().saved, true);
|
||||
assert.deepEqual((await toggle({ kind: 'poem', poemId: poem })).json().saved, true);
|
||||
assert.deepEqual((await toggle({ kind: 'couplet', poemId: poem, couplet: 0 })).json().saved, true);
|
||||
assert.deepEqual((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, true);
|
||||
assert.deepEqual((await toggle({ kind: 'word', word: 'وصال،', poemId: poem, couplet: 0 })).json().saved, true);
|
||||
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 9999 })).statusCode, 404);
|
||||
assert.equal((await toggle({ kind: 'poet', poetId: 999999 })).statusCode, 404);
|
||||
assert.equal((await toggle({ kind: 'word', word: 'hello' })).statusCode, 400);
|
||||
|
||||
// phrases: part of a couplet (also across its two misras), must be in that couplet
|
||||
const lines0 = (await pool.query('SELECT text FROM verses WHERE poem_id = $1 AND couplet = 0 ORDER BY vorder', [poem])).rows.map((r) => r.text);
|
||||
const across = lines0[0].split(' ').slice(-2).join(' ') + ' ' + lines0[1].split(' ')[0];
|
||||
assert.equal((await toggle({ kind: 'phrase', poemId: poem, couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') })).json().saved, true);
|
||||
assert.equal((await toggle({ kind: 'phrase', poemId: poem, couplet: 0, phrase: across })).json().saved, true, 'across the two misras');
|
||||
assert.equal((await toggle({ kind: 'phrase', poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں' })).statusCode, 400);
|
||||
// marks for contents lists: the work, its book and its poet
|
||||
const marks = (await call('GET', '/api/library/marks', undefined, t)).json();
|
||||
assert.deepEqual(marks.poems[poem], { fav: true, bm: 4 }, 'two couplets and two phrases');
|
||||
const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id;
|
||||
assert.ok(marks.categories.includes(book) && marks.poets.includes(266));
|
||||
// state for a page
|
||||
assert.deepEqual((await call('GET', `/api/library/state?poet=266&poem=${poem}`, undefined, t)).json(), { poet: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }] });
|
||||
|
||||
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true });
|
||||
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false });
|
||||
// toggling again removes
|
||||
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false);
|
||||
|
||||
const lib = (await call('GET', '/api/library', undefined, t)).json();
|
||||
assert.equal(lib.poets[0].poet.url, '/p266');
|
||||
assert.deepEqual(lib.poems[0].poem.path.map((c: any) => c.url), ['/p266', '/p266/ghazal'], 'poet » book path');
|
||||
assert.equal(lib.couplets.length, 1);
|
||||
assert.equal(lib.phrases.length, 2);
|
||||
assert.deepEqual(lib.phrases[0].poem.path.map((c: any) => c.url), ['/p266', '/p266/ghazal']);
|
||||
assert.equal(lib.couplets[0].lines.length, 2, 'both misras');
|
||||
assert.equal(lib.words[0].word, 'وصال');
|
||||
assert.equal(lib.words[0].source.couplet, 0);
|
||||
|
||||
// notes, deletion, and readers cannot touch each other's items
|
||||
const cid = lib.couplets[0].id;
|
||||
assert.equal((await call('POST', `/api/library/${cid}/note`, { note: 'مطلع' }, other.token)).statusCode, 404);
|
||||
assert.equal((await call('POST', `/api/library/${cid}/note`, { note: ' مطلع ' }, t)).statusCode, 200);
|
||||
assert.equal((await call('GET', '/api/library', undefined, t)).json().couplets[0].note, 'مطلع');
|
||||
assert.equal((await call('POST', `/api/library/${cid}/delete`, undefined, other.token)).statusCode, 404);
|
||||
assert.equal((await call('GET', '/api/library', undefined, other.token)).json().poets.length, 0);
|
||||
assert.equal((await call('POST', `/api/library/${cid}/delete`, undefined, t)).statusCode, 200);
|
||||
|
||||
// deleting the account deletes the library
|
||||
await call('POST', '/api/auth/delete', { password: 'pass-word-1' }, t);
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM library WHERE user_id = $1', [me.user.id])).rows[0].n, 0);
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [other.user.id]);
|
||||
await app.close();
|
||||
});
|
||||
173
api/src/library.ts
Normal file
173
api/src/library.ts
Normal file
@ -0,0 +1,173 @@
|
||||
// Personal library (#24, #25): a reader's saved poets and works, bookmarked couplets and phrases, saved words.
|
||||
// JSON in and out with Bearer tokens, so the site and mobile apps (#44) use the same endpoints.
|
||||
// GET /api/library everything, each item with its path in the site
|
||||
// GET /api/library/state?poet=&poem=&word= what is saved on one page (poet, poem, couplets, phrases) or a word
|
||||
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items
|
||||
// POST /api/library/toggle {kind, poetId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
|
||||
// POST /api/library/:id/note {note}
|
||||
// POST /api/library/:id/delete
|
||||
// kind: poet {poetId} | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
|
||||
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word)
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { pool } from './db.ts';
|
||||
import { sessionUser } from './auth.ts';
|
||||
import { PUNCT } from './dictionary.ts';
|
||||
|
||||
const KINDS = ['poet', 'poem', 'couplet', 'phrase', 'word'] as const;
|
||||
|
||||
// a bookmarked phrase: spaces collapsed, 2 to 300 characters
|
||||
export const cleanPhrase = (p: unknown) => {
|
||||
const s = String(p ?? '').normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').trim();
|
||||
return s.length >= 2 && s.length <= 300 ? s : null;
|
||||
};
|
||||
|
||||
async function reader(req: FastifyRequest, reply: FastifyReply) {
|
||||
const u = await sessionUser(req);
|
||||
if (!u) reply.code(401).send({ error: 'لاگ ان کریں' });
|
||||
return u;
|
||||
}
|
||||
|
||||
// one saved word: punctuation and spaces dropped (as in the word sidebar); Arabic script only
|
||||
export const cleanWord = (w: unknown) => {
|
||||
const s = String(w ?? '').normalize('NFC').replace(PUNCT, '').replace(/ؔ/g, '');
|
||||
return /^[\p{Script=Arabic}\p{M}]{1,40}$/u.test(s) ? s : null;
|
||||
};
|
||||
|
||||
// the full path of each work: poet » book » section » … (the poet's root category carries the poet's name)
|
||||
export async function paths(poemIds: number[]) {
|
||||
if (!poemIds.length) return new Map<number, { title: string; url: string }[]>();
|
||||
const { rows } = await pool.query(
|
||||
`WITH RECURSIVE up AS (
|
||||
SELECT p.id AS poem_id, c.id, c.parent_id, c.title, c.url, 0 AS depth
|
||||
FROM poems p JOIN categories c ON c.id = p.category_id WHERE p.id = ANY($1)
|
||||
UNION ALL
|
||||
SELECT up.poem_id, c.id, c.parent_id, c.title, c.url, up.depth + 1 FROM categories c JOIN up ON c.id = up.parent_id)
|
||||
SELECT poem_id, title, url FROM up ORDER BY poem_id, depth DESC`, [poemIds]);
|
||||
const out = new Map<number, { title: string; url: string }[]>();
|
||||
for (const r of rows) (out.get(r.poem_id) ?? out.set(r.poem_id, []).get(r.poem_id)!).push({ title: r.title, url: r.url });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function libraryRoutes(app: FastifyInstance) {
|
||||
app.get('/api/library', async (req, reply) => {
|
||||
const u = await reader(req, reply); if (!u) return;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT l.id, l.kind, l.poet_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at,
|
||||
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet
|
||||
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
|
||||
WHERE l.user_id = $1 ORDER BY l.created_at DESC, l.id DESC`, [u.id]);
|
||||
const poemIds = [...new Set(rows.filter((r) => r.poem_id).map((r) => r.poem_id))];
|
||||
const [crumbs, verses] = await Promise.all([
|
||||
paths(poemIds),
|
||||
pool.query(`SELECT poem_id, couplet, text FROM verses WHERE (poem_id, couplet) IN
|
||||
(SELECT poem_id, couplet FROM library WHERE user_id = $1 AND poem_id IS NOT NULL AND couplet IS NOT NULL) ORDER BY vorder`, [u.id]),
|
||||
]);
|
||||
const lines = (p: number, c: number) => verses.rows.filter((v) => v.poem_id === p && v.couplet === c).map((v) => v.text);
|
||||
const where = (r: any) => r.poem_id && { url: r.poem_url, title: r.poem_title, path: crumbs.get(r.poem_id) ?? [], couplet: r.couplet };
|
||||
const item = (r: any) => ({ id: Number(r.id), note: r.note, saved_at: r.created_at });
|
||||
return {
|
||||
user: { full_name: u.full_name ?? '', bio: u.bio ?? '' },
|
||||
poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })),
|
||||
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
|
||||
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||||
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||||
words: rows.filter((r) => r.kind === 'word').map((r) => ({
|
||||
...item(r), word: r.word, source: r.poem_url ? { ...where(r), lines: r.couplet != null ? lines(r.poem_id, r.couplet) : [] } : null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { poet?: string; poem?: string; word?: string } }>('/api/library/state', async (req, reply) => {
|
||||
const u = await reader(req, reply); if (!u) return;
|
||||
if (req.query.word != null) {
|
||||
const w = cleanWord(req.query.word);
|
||||
return { word: !!w && !!(await pool.query(`SELECT 1 FROM library WHERE user_id = $1 AND kind = 'word' AND word = $2`, [u.id, w])).rowCount };
|
||||
}
|
||||
const poet = Number(req.query.poet) || 0, poem = Number(req.query.poem) || 0;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $3))`,
|
||||
[u.id, poet, poem]);
|
||||
return {
|
||||
poet: rows.some((r) => r.kind === 'poet'), poem: rows.some((r) => r.kind === 'poem'),
|
||||
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet),
|
||||
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
|
||||
};
|
||||
});
|
||||
|
||||
// for contents lists and cards: works that are favourites (fav) or hold bookmarks (bm), and the books,
|
||||
// sections and poets containing them
|
||||
app.get('/api/library/marks', async (req, reply) => {
|
||||
const u = await reader(req, reply); if (!u) return;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT poem_id, bool_or(kind = 'poem') AS fav, count(*) FILTER (WHERE kind IN ('couplet', 'phrase'))::int AS bm
|
||||
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') GROUP BY poem_id`, [u.id]);
|
||||
const ids = rows.map((r) => r.poem_id);
|
||||
const [cats, poets] = await Promise.all([
|
||||
pool.query(`WITH RECURSIVE up AS (SELECT c.id, c.parent_id FROM poems p JOIN categories c ON c.id = p.category_id WHERE p.id = ANY($1)
|
||||
UNION SELECT c.id, c.parent_id FROM categories c JOIN up ON c.id = up.parent_id) SELECT id FROM up`, [ids]),
|
||||
pool.query('SELECT DISTINCT poet_id FROM poems WHERE id = ANY($1) UNION SELECT poet_id FROM library WHERE user_id = $2 AND kind = $3', [ids, u.id, 'poet']),
|
||||
]);
|
||||
return {
|
||||
poems: Object.fromEntries(rows.map((r) => [r.poem_id, { fav: r.fav, bm: r.bm }])),
|
||||
categories: cats.rows.map((r) => r.id), poets: poets.rows.map((r) => r.poet_id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Body: { kind?: string; poetId?: number; poemId?: number; couplet?: number; word?: string } }>('/api/library/toggle', async (req, reply) => {
|
||||
const u = await reader(req, reply); if (!u) return;
|
||||
const b = req.body ?? {}, kind = String(b.kind);
|
||||
if (!(KINDS as readonly string[]).includes(kind)) return reply.code(400).send({ error: 'نامعلوم قسم' });
|
||||
const poetId = Number(b.poetId) || null, poemId = Number(b.poemId) || null;
|
||||
const couplet = Number.isInteger(b.couplet) ? b.couplet! : null;
|
||||
let key: [string, unknown[]];
|
||||
if (kind === 'word') {
|
||||
const word = cleanWord(b.word);
|
||||
if (!word) return reply.code(400).send({ error: 'ایک لفظ منتخب کریں' });
|
||||
key = [`kind = 'word' AND word = $2`, [word]];
|
||||
const hit = (await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${key[0]} RETURNING id`, [u.id, ...key[1]])).rows[0];
|
||||
if (hit) return { saved: false };
|
||||
// the source couplet is kept only if it exists
|
||||
const src = poemId && couplet != null && (await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet])).rowCount ? [poemId, couplet] : [null, null];
|
||||
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, word, poem_id, couplet) VALUES ($1, 'word', $2, $3, $4) RETURNING id`, [u.id, word, ...src]);
|
||||
return { saved: true, id: Number(rows[0].id) };
|
||||
}
|
||||
if (kind === 'phrase') {
|
||||
const phrase = cleanPhrase(b.phrase);
|
||||
if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' });
|
||||
// the phrase must be in that couplet or paragraph (its lines, or across its two misras)
|
||||
const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t;
|
||||
if (!text) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||
if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return reply.code(400).send({ error: 'یہ عبارت اس شعر میں نہیں' });
|
||||
const args = [u.id, poemId, couplet, phrase];
|
||||
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4 RETURNING id`, args)).rowCount)
|
||||
return { saved: false };
|
||||
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args);
|
||||
return { saved: true, id: Number(rows[0].id) };
|
||||
}
|
||||
// poet, poem or couplet: the target must exist
|
||||
const exists = kind === 'poet' ? await pool.query('SELECT 1 FROM poets WHERE id = $1', [poetId])
|
||||
: kind === 'poem' ? await pool.query('SELECT 1 FROM poems WHERE id = $1', [poemId])
|
||||
: await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet]);
|
||||
if (!exists.rowCount) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||
const cols = kind === 'poet' ? { poet_id: poetId, poem_id: null, couplet: null } : { poet_id: null, poem_id: poemId, couplet: kind === 'couplet' ? couplet : null };
|
||||
const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(poem_id, 0) = coalesce($4::int, 0) AND coalesce(couplet, -1) = coalesce($5::int, -1)`;
|
||||
const args = [u.id, kind, cols.poet_id, cols.poem_id, cols.couplet];
|
||||
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${match} RETURNING id`, args)).rowCount) return { saved: false };
|
||||
const { rows } = await pool.query(
|
||||
'INSERT INTO library (user_id, kind, poet_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING RETURNING id', args);
|
||||
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => {
|
||||
const u = await reader(req, reply); if (!u) return;
|
||||
const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
|
||||
const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3', [note, Number(req.params.id) || 0, u.id]);
|
||||
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/library/:id/delete', async (req, reply) => {
|
||||
const u = await reader(req, reply); if (!u) return;
|
||||
const r = await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [Number(req.params.id) || 0, u.id]);
|
||||
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
|
||||
});
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
// GET /api/word?w= Wiktionary meanings and pronunciation (sidebar)
|
||||
// /api/auth/* accounts (see auth.ts)
|
||||
// /api/admin/* admin panel (see admin.ts)
|
||||
// /api/library/* a reader's saved poets, works, couplets and words (see library.ts)
|
||||
// GET /health
|
||||
import Fastify from 'fastify';
|
||||
import { pool } from './db.ts';
|
||||
@ -13,6 +14,7 @@ import { lookup, PUNCT } from './dictionary.ts';
|
||||
import { authRoutes } from './auth.ts';
|
||||
import { adminRoutes } from './admin.ts';
|
||||
import { permissionRoutes } from './permissions.ts';
|
||||
import { libraryRoutes } from './library.ts';
|
||||
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
||||
const PAGE_SIZE = 20;
|
||||
@ -72,12 +74,12 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
|
||||
SELECT id AS root, id FROM categories WHERE parent_id = $1
|
||||
UNION ALL
|
||||
SELECT tree.root, c.id FROM categories c JOIN tree ON c.parent_id = tree.id)
|
||||
SELECT c.url, c.title, count(p.id)::int AS poems
|
||||
SELECT c.id, c.url, c.title, count(p.id)::int AS poems
|
||||
FROM categories c JOIN tree ON tree.root = c.id LEFT JOIN poems p ON p.category_id = tree.id
|
||||
GROUP BY c.id ORDER BY c.position, c.id`,
|
||||
[cat.id],
|
||||
),
|
||||
pool.query('SELECT url, title, radif_letter FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]),
|
||||
pool.query('SELECT id, url, title, radif_letter FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]),
|
||||
]);
|
||||
return {
|
||||
type: cat.parent_id === null ? 'poet' : 'category',
|
||||
@ -141,6 +143,7 @@ app.get<{ Querystring: { w?: string } }>('/api/word', async (req, reply) => {
|
||||
authRoutes(app);
|
||||
adminRoutes(app);
|
||||
permissionRoutes(app);
|
||||
libraryRoutes(app);
|
||||
|
||||
const port = Number(process.env.PORT ?? 4100);
|
||||
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
||||
|
||||
@ -126,3 +126,23 @@ CREATE INDEX IF NOT EXISTS grants_user ON grants(user_id);
|
||||
-- profile (owner request): full name and bio, usually in Urdu
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name text; -- up to 100 characters
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text; -- up to 1,000 characters
|
||||
-- personal library (api/src/library.ts): saved poets and works, bookmarked couplets and phrases, saved words
|
||||
CREATE TABLE IF NOT EXISTS library (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL, -- poet | poem | couplet | phrase | word
|
||||
poet_id integer, -- poet
|
||||
poem_id integer, -- poem, couplet, phrase, or a word's source work
|
||||
couplet integer, -- couplet, or a word's source couplet
|
||||
word text, -- word
|
||||
phrase text, -- phrase: the bookmarked part of a couplet or paragraph
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE library ADD COLUMN IF NOT EXISTS phrase text;
|
||||
ALTER TABLE library DROP CONSTRAINT IF EXISTS library_kind_check;
|
||||
ALTER TABLE library ADD CONSTRAINT library_kind_check CHECK (kind IN ('poet', 'poem', 'couplet', 'phrase', 'word'));
|
||||
DROP INDEX IF EXISTS library_items;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS library_places ON library (user_id, kind, coalesce(poet_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind IN ('poet', 'poem', 'couplet');
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS library_phrases ON library (user_id, poem_id, couplet, phrase) WHERE kind = 'phrase';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS library_words ON library (user_id, word) WHERE kind = 'word';
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
---
|
||||
// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | '']
|
||||
const { s } = Astro.props as { s: [string, string][] };
|
||||
// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?]
|
||||
const { s } = Astro.props as { s: [string, string, string?][] };
|
||||
---
|
||||
{s.map(([t, k]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span> : t))}
|
||||
{s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span>
|
||||
: k === 'bmk' ? <span class="bmk" data-save={d} data-confirm="یہ نشان ہٹائیں؟" title="نشان زدہ عبارت (ہٹانے کے لیے دبائیں)">{t}</span> : t))}
|
||||
|
||||
@ -29,7 +29,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body data-user={Astro.locals.user ? '1' : undefined}>
|
||||
<header class="site-header">
|
||||
<div class="wrap">
|
||||
<a class="wordmark" href="/">دیوان</a>
|
||||
@ -42,6 +42,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
</form>
|
||||
<div class="toggles">
|
||||
{Astro.locals.user?.role === 'admin' && <a class="account-link" href="/admin">ایڈمن</a>}
|
||||
{Astro.locals.user && <a class="account-link" href="/library">لائبریری</a>}
|
||||
{Astro.locals.user
|
||||
? <a class="account-link" href="/account" title={Astro.locals.user.email}>{Astro.locals.user.full_name.split(' ')[0] || 'اکاؤنٹ'}</a>
|
||||
: <a class="account-link" href={`/signin?next=${encodeURIComponent(Astro.url.pathname)}`}>لاگ ان</a>}
|
||||
@ -54,6 +55,8 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
<main class="wrap"><slot /></main>
|
||||
<div id="selmenu" class="selmenu" role="toolbar" aria-label="منتخب عبارت" hidden>
|
||||
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
|
||||
<button type="button" data-act="saveword" title="لفظ لغت کے ساتھ محفوظ کریں">محفوظ</button>
|
||||
<button type="button" data-act="bookmark" title="اس عبارت پر نشان لگائیں">نشان</button>
|
||||
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
|
||||
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
|
||||
<button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button>
|
||||
@ -141,6 +144,28 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
return e;
|
||||
};
|
||||
let current = '';
|
||||
// personal library: ♡ poets and works, 🔖 couplets, saved words (signed-out readers go to sign-in)
|
||||
let source = {}; // the couplet the selected word came from
|
||||
async function save(item) {
|
||||
if (!document.body.dataset.user) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return null; }
|
||||
const r = await fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(item) });
|
||||
if (r.status === 401) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return null; }
|
||||
return r.ok ? r.json() : null;
|
||||
}
|
||||
document.addEventListener('click', async (e) => {
|
||||
const b = e.target.closest('[data-save]'); if (!b) return;
|
||||
if (getSelection()?.toString().trim()) return; // selecting text, not pressing
|
||||
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
|
||||
const item = JSON.parse(b.dataset.save), d = await save(item);
|
||||
if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page
|
||||
else if (d) b.setAttribute('aria-pressed', String(d.saved));
|
||||
});
|
||||
const wordSave = (w) => { // sidebar button, with the word's saved state
|
||||
const b = el('button', 'save', 'لفظ محفوظ کریں', { type: 'button', 'aria-pressed': 'false' });
|
||||
b.onclick = async () => { const d = await save({ kind: 'word', word: w, ...source }); if (d) b.setAttribute('aria-pressed', String(d.saved)); };
|
||||
if (document.body.dataset.user) fetch('/api/library?word=' + encodeURIComponent(w)).then((r) => r.ok && r.json()).then((d) => d && b.setAttribute('aria-pressed', String(d.word)));
|
||||
return b;
|
||||
};
|
||||
async function showWord(w) {
|
||||
if (w === current && !dict.hidden) return;
|
||||
current = w; dict.hidden = false; document.body.classList.add('dict-open');
|
||||
@ -160,7 +185,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
};
|
||||
const words = (title, items, into) => { if (items.length) into.push(el('h3', null, title), buttons(items)); };
|
||||
const list = (items, cls, attrs) => { const ol = el('ol', cls, null, attrs); items.forEach((x) => ol.append(el('li', null, x))); return ol; };
|
||||
const out = [el('h2', null, w)];
|
||||
const out = [el('h2', null, w), wordSave(w)];
|
||||
if (!d || !d.found) {
|
||||
out.push(el('p', 'muted', 'ویکی لغت میں یہ لفظ نہیں ملا۔'));
|
||||
words('ملتے جلتے الفاظ', [...(d?.similar ?? []), ...(d?.variants ?? [])], out);
|
||||
@ -282,6 +307,10 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
selText = inText ? sel.toString().replace(/\u0614/g, '').trim() : '';
|
||||
if (!selText) return hideMenu();
|
||||
const w = oneWord(selText);
|
||||
const block = sel.anchorNode.parentElement?.closest('[data-c]');
|
||||
source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.c) } : {};
|
||||
menu.querySelector('[data-act=saveword]').hidden = !w;
|
||||
menu.querySelector('[data-act=bookmark]').hidden = !(source.poemId && Number.isInteger(source.couplet));
|
||||
menu.querySelector('[data-act=meaning]').hidden = !w;
|
||||
menu.hidden = false;
|
||||
// below the selection, centred, kept inside the viewport (absolute: moves with the page)
|
||||
@ -302,6 +331,9 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
const q = /\s/.test(selText) ? `"${selText}"` : selText, enc = encodeURIComponent(selText);
|
||||
switch (b.dataset.act) {
|
||||
case 'meaning': showWord(oneWord(selText)); break;
|
||||
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
|
||||
.then((d) => d && (d.saved ? location.reload() : flash(b, 'نشان پہلے سے تھا، ہٹا دیا'))); break;
|
||||
case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'محفوظ ہو گیا' : 'ہٹا دیا گیا')); break;
|
||||
case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
|
||||
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
|
||||
case 'copy':
|
||||
|
||||
10
web/src/lib/phrases.test.mjs
Normal file
10
web/src/lib/phrases.test.mjs
Normal file
@ -0,0 +1,10 @@
|
||||
// node web/src/lib/phrases.test.mjs (plain assert; the API has the main test suite)
|
||||
import assert from 'node:assert/strict';
|
||||
const { phraseParts, phraseRe } = await import('./phrases.ts');
|
||||
const lines = ['یہ مسائل تصوف یہ ترا بیان غالبؔ', 'تجھے ہم ولی سمجھتے جو نہ بادہ خوار ہوتا'];
|
||||
assert.deepEqual(phraseParts(lines, 'مسائل تصوف'), ['مسائل تصوف', null]);
|
||||
assert.deepEqual(phraseParts(lines, 'بیان غالب'), ['بیان غالب', null], 'takhallus sign ignored');
|
||||
assert.ok(phraseRe('بیان غالب').test(lines[0]));
|
||||
assert.deepEqual(phraseParts(lines, 'بیان غالب تجھے ہم'), ['بیان غالب', 'تجھے ہم'], 'across the misras');
|
||||
assert.deepEqual(phraseParts(lines, 'کہیں نہیں'), [null, null]);
|
||||
console.log('ok');
|
||||
18
web/src/lib/phrases.ts
Normal file
18
web/src/lib/phrases.ts
Normal file
@ -0,0 +1,18 @@
|
||||
// Where a bookmarked phrase sits in a couplet's lines: the part of each line to mark. A phrase can run
|
||||
// across the two misras (end of the first + start of the second). The pen-name sign ؔ is ignored.
|
||||
const bare = (s: string) => s.replace(/ؔ/g, '');
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// the phrase as a regex over original text: ؔ allowed after any letter, any run of spaces between words
|
||||
export const phraseRe = (p: string) => new RegExp([...p].map((c) => (c === ' ' ? '\\s+' : esc(c) + '\\u0614?')).join(''), 'u');
|
||||
|
||||
export function phraseParts(lines: string[], phrase: string): (string | null)[] {
|
||||
const parts: (string | null)[] = lines.map(() => null);
|
||||
const i = lines.findIndex((l) => phraseRe(phrase).test(l));
|
||||
if (i >= 0) { parts[i] = phrase; return parts; }
|
||||
const words = phrase.split(' ');
|
||||
for (let k = words.length - 1; k > 0 && lines.length > 1; k--) { // first k words end line 0, the rest start line 1
|
||||
const head = words.slice(0, k).join(' '), tail = words.slice(k).join(' ');
|
||||
if (bare(lines[0]).trimEnd().endsWith(head) && bare(lines[1]).trimStart().startsWith(tail)) return [head, tail];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@ -3,6 +3,8 @@
|
||||
import Base from '../layouts/Base.astro';
|
||||
import Seg from '../components/Seg.astro';
|
||||
import { api } from '../lib/api';
|
||||
import { asUser, COOKIE } from '../lib/auth';
|
||||
import { phraseParts, phraseRe } from '../lib/phrases';
|
||||
import { ud, years, highlighter, parts } from '../lib/urdu';
|
||||
|
||||
const url = '/' + (Astro.params.path ?? '');
|
||||
@ -11,23 +13,31 @@ if (!page) return new Response(null, { status: 404, statusText: 'Not found' });
|
||||
|
||||
// group verses into couplets / single lines / paragraphs for rendering
|
||||
type Verse = { position: string; couplet: number; text: string };
|
||||
const blocks: { kind: 'couplet' | 'single' | 'para'; lines: string[]; mark?: string }[] = [];
|
||||
const blocks: { kind: 'couplet' | 'single' | 'para'; lines: string[]; c: number; mark?: string }[] = [];
|
||||
if (page.type === 'poem') {
|
||||
const { matla, maqta } = page.poem;
|
||||
for (const v of page.verses as Verse[]) {
|
||||
if (v.position === 'Right') blocks.push({ kind: 'couplet', lines: [v.text],
|
||||
if (v.position === 'Right') blocks.push({ kind: 'couplet', lines: [v.text], c: v.couplet,
|
||||
mark: v.couplet === matla ? 'مطلع' : v.couplet === maqta ? 'مقطع' : undefined });
|
||||
else if (v.position === 'Left' && blocks.at(-1)?.kind === 'couplet' && blocks.at(-1)!.lines.length === 1) blocks.at(-1)!.lines.push(v.text);
|
||||
else if (v.position === 'Paragraph') blocks.push({ kind: 'para', lines: [v.text] });
|
||||
else blocks.push({ kind: 'single', lines: [v.text] });
|
||||
else if (v.position === 'Paragraph') blocks.push({ kind: 'para', lines: [v.text], c: v.couplet });
|
||||
else blocks.push({ kind: 'single', lines: [v.text], c: v.couplet });
|
||||
}
|
||||
}
|
||||
// pen name (marked ؔ in the text) set apart; split() with a capture group puts matches at odd indexes
|
||||
// ?hl= (from search results) marks the searched words on the page; segments: [text, 'mark' | 'pen' | '']
|
||||
const hlq = Astro.url.searchParams.get('hl') ?? '';
|
||||
const re = highlighter(hlq);
|
||||
const show = (line: string) => parts(line, re).flatMap((s, i): [string, string][] =>
|
||||
i % 2 ? [[s, 'mark']] : s.split(/(\S*\u0614\S*)/).map((t, j) => [t, j % 2 ? 'pen' : '']));
|
||||
// bookmarked phrases first ('bmk', carrying what to un-bookmark), then search hits, then the pen name
|
||||
type Mark = { part: string; save: string };
|
||||
const show = (line: string, marks: Mark[] = []): [string, string, string?][] => {
|
||||
const plain = (text: string) => parts(text, re).flatMap((s, i): [string, string][] =>
|
||||
i % 2 ? [[s, 'mark']] : s.split(/(\S*\u0614\S*)/).map((t, j) => [t, j % 2 ? 'pen' : '']));
|
||||
if (!marks.length) return plain(line);
|
||||
const bm = new RegExp(`(${marks.map((m) => phraseRe(m.part).source).join('|')})`, 'u');
|
||||
return line.split(bm).flatMap((s, i): [string, string, string?][] =>
|
||||
i % 2 ? [[s, 'bmk', marks.find((m) => phraseRe(m.part).test(s))?.save]] : plain(s));
|
||||
};
|
||||
// contents of a divan grouped by radif letter ("ردیف الف … ی"); ے is filed under ی as in printed divans
|
||||
const LETTER = (l: string) => (l === 'ا' ? 'الف' : l === 'ے' ? 'ی' : l);
|
||||
const groups: { letter: string | null; poems: any[] }[] = [];
|
||||
@ -37,6 +47,23 @@ for (const p of page.poems ?? []) {
|
||||
groups.at(-1)!.poems.push(p);
|
||||
}
|
||||
const crumbs = page.breadcrumbs ?? [];
|
||||
// what the signed-in reader has saved on this page (♡ poet/work, 🔖 couplets)
|
||||
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
|
||||
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
|
||||
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&poem=${poemId ?? ''}`)).data : { poet: false, poem: false, couplets: [], phrases: [] };
|
||||
// on contents pages: which works are favourites (♥) or hold bookmarks (🔖), and which books/sections hold them
|
||||
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
|
||||
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
|
||||
// the marked part of each line of a block, for its bookmarked phrases
|
||||
const lineMarks = (c: number, lines: string[]) => {
|
||||
const out: Mark[][] = lines.map(() => []);
|
||||
for (const p of saved.phrases.filter((x: any) => x.couplet === c)) {
|
||||
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase });
|
||||
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
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;
|
||||
---
|
||||
<Base title={page.type === 'poem' ? `${page.poem.title} · ${page.poet.nickname}` : title} description={page.type === 'poet' ? page.poet.description?.slice(0, 160) : undefined}>
|
||||
@ -51,6 +78,7 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
|
||||
{page.type === 'poet' && (
|
||||
<>
|
||||
<h1>{page.poet.name}</h1>
|
||||
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)}>پسندیدہ شاعر</button></p>
|
||||
<p class="muted">{years(page.poet)}</p>
|
||||
{page.poet.description && <div class="intro">{page.poet.description}</div>}
|
||||
</>
|
||||
@ -60,14 +88,14 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
|
||||
{page.type !== 'poem' && page.children.length > 0 && (
|
||||
<div class="cards">
|
||||
{page.children.map((c: any) => (
|
||||
<a class="card" href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a>
|
||||
<a class={`card${marks.categories.includes(c.id) ? ' mark-bm' : ''}`} href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{page.type !== 'poem' && groups.map((g) => (
|
||||
<>
|
||||
{g.letter && <h2 class="radif-head">ردیف {g.letter}</h2>}
|
||||
<ol class="toc">{g.poems.map((p: any) => <li><a 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>
|
||||
</>
|
||||
))}
|
||||
|
||||
@ -79,13 +107,16 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
|
||||
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
|
||||
</p>
|
||||
)}
|
||||
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)}>پسندیدہ کلام</button></p>
|
||||
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
|
||||
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`}>
|
||||
{blocks.map((b) =>
|
||||
b.kind === 'couplet' ? <div class="couplet" data-mark={b.mark}>{b.lines.map((l) => <p><Seg s={show(l)} /></p>)}</div>
|
||||
: b.kind === 'para' ? <p class="para"><Seg s={show(b.lines[0])} /></p>
|
||||
: <p class="single"><Seg s={show(b.lines[0])} /></p>
|
||||
)}
|
||||
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
|
||||
{blocks.map((b) => {
|
||||
const bm = <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="نشان لگائیں" />;
|
||||
const 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>)}</div>
|
||||
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
|
||||
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
|
||||
})}
|
||||
</article>
|
||||
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p>
|
||||
<nav class="poem-nav">
|
||||
|
||||
22
web/src/pages/api/library.ts
Normal file
22
web/src/pages/api/library.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// Browser -> API for the save buttons (♡, 🔖, word): the reader's session cookie becomes the Bearer token.
|
||||
// JSON posts from other sites are refused: the Origin must be this site (and SameSite cookies are not sent).
|
||||
import type { APIRoute } from 'astro';
|
||||
import { asUser, COOKIE } from '../../lib/auth';
|
||||
|
||||
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies, url }) => {
|
||||
const origin = request.headers.get('origin');
|
||||
if (origin && new URL(origin).host !== url.host) return json({ error: 'forbidden' }, 403);
|
||||
const token = cookies.get(COOKIE)?.value;
|
||||
if (!token) return json({ error: 'لاگ ان کریں' }, 401);
|
||||
const r = await asUser(token, '/api/library/toggle', await request.json().catch(() => ({})));
|
||||
return json(r.data, r.status);
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ cookies, url }) => {
|
||||
const token = cookies.get(COOKIE)?.value;
|
||||
if (!token) return json({ error: 'لاگ ان کریں' }, 401);
|
||||
const r = await asUser(token, `/api/library/state?word=${encodeURIComponent(url.searchParams.get('word') ?? '')}`);
|
||||
return json(r.data, r.status);
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { api } from '../lib/api';
|
||||
import { asUser, COOKIE } from '../lib/auth';
|
||||
import { centuryOf, centuryName, years, ud } from '../lib/urdu';
|
||||
|
||||
type Poet = { id: number; url: string; nickname: string; birth_year_ah: number | null; death_year_ah: number | null; pin_order: number | null };
|
||||
@ -15,6 +16,10 @@ for (const p of poets) {
|
||||
}
|
||||
const ordered = [...groups.entries()].sort(([a], [b]) => (a ?? 99) - (b ?? 99));
|
||||
const pinned = poets.filter((p) => p.pin_order).sort((a, b) => a.pin_order! - b.pin_order!);
|
||||
// poets whose works hold the signed-in reader's favourites or bookmarks
|
||||
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
|
||||
const marked: number[] = token ? (await asUser(token, '/api/library/marks')).data.poets ?? [] : [];
|
||||
const card = (p: Poet) => `poet-card${marked.includes(p.id) ? ' mark-bm' : ''}`;
|
||||
---
|
||||
<Base>
|
||||
<section class="hero">
|
||||
@ -29,7 +34,7 @@ const pinned = poets.filter((p) => p.pin_order).sort((a, b) => a.pin_order! - b.
|
||||
<h2>مقبول شعرا</h2>
|
||||
<div class="poets">
|
||||
{pinned.map((p) => (
|
||||
<a class="poet-card" href={p.url}>{p.nickname}<small>{years(p)}</small></a>
|
||||
<a class={card(p)} href={p.url}>{p.nickname}<small>{years(p)}</small></a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@ -40,7 +45,7 @@ const pinned = poets.filter((p) => p.pin_order).sort((a, b) => a.pin_order! - b.
|
||||
<h2>{centuryName(century)}</h2>
|
||||
<div class="poets">
|
||||
{list.map((p) => (
|
||||
<a class="poet-card" href={p.url}>
|
||||
<a class={card(p)} href={p.url}>
|
||||
{p.nickname}
|
||||
<small>{years(p)}</small>
|
||||
</a>
|
||||
|
||||
133
web/src/pages/library.astro
Normal file
133
web/src/pages/library.astro
Normal file
@ -0,0 +1,133 @@
|
||||
---
|
||||
// میری لائبریری: the reader's saved poets and works, bookmarked couplets and saved words,
|
||||
// each with its full path in the site (poet » book » section » work) and a link to the couplet
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { asUser, COOKIE } from '../lib/auth';
|
||||
import { ud } from '../lib/urdu';
|
||||
import { phraseParts, phraseRe } from '../lib/phrases';
|
||||
|
||||
const me = Astro.locals.user;
|
||||
if (!me) return Astro.redirect('/signin?next=/library');
|
||||
const token = Astro.cookies.get(COOKIE)!.value;
|
||||
if (Astro.request.method === 'POST') {
|
||||
const f = await Astro.request.formData(), id = Number(f.get('id')) || 0;
|
||||
if (f.get('act') === 'delete') await asUser(token, `/api/library/${id}/delete`, {});
|
||||
if (f.get('act') === 'note') await asUser(token, `/api/library/${id}/note`, { note: f.get('note') });
|
||||
return Astro.redirect(`/library#i${id}`, 303);
|
||||
}
|
||||
const lib = (await asUser(token, '/api/library')).data;
|
||||
const total = lib.poets.length + lib.poems.length + lib.couplets.length + lib.phrases.length + lib.words.length;
|
||||
// a bookmarked phrase shown inside its couplet: [before, phrase, after] per line
|
||||
const marked = (lines: string[], phrase: string) => phraseParts(lines, phrase).map((part, i) => {
|
||||
if (!part) return [lines[i], '', ''];
|
||||
const m = lines[i].match(phraseRe(part))!;
|
||||
return [lines[i].slice(0, m.index), m[0], lines[i].slice(m.index! + m[0].length)];
|
||||
});
|
||||
const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != null ? `#c${item.poem.couplet}` : ''}` : '#');
|
||||
---
|
||||
<Base title="میری لائبریری">
|
||||
<h1>{me.full_name || 'میری لائبریری'}</h1>
|
||||
{me.bio && <p class="profile-bio">{me.bio}</p>}
|
||||
<p class="muted center">میری لائبریری · {ud(total)} اندراجات · <a href="/account">پروفائل</a></p>
|
||||
|
||||
{total === 0 && (
|
||||
<p class="muted center">ابھی کچھ محفوظ نہیں۔ کسی شاعر یا کلام پر "پسندیدہ" دبائیں، کسی شعر کے ساتھ 🔖 لگائیں، کوئی عبارت منتخب کر کے "نشان" لگائیں، یا لفظ منتخب کر کے "محفوظ" کریں۔</p>
|
||||
)}
|
||||
|
||||
{lib.poets.length > 0 && (
|
||||
<section class="lib-section">
|
||||
<h2>پسندیدہ شعرا</h2>
|
||||
<div class="poets">
|
||||
{lib.poets.map((p: any) => p.poet && <a class="poet-card" id={`i${p.id}`} href={p.poet.url}>{p.poet.name}</a>)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{lib.poems.length > 0 && (
|
||||
<section class="lib-section">
|
||||
<h2>پسندیدہ کلام</h2>
|
||||
<ul class="lib-list">
|
||||
{lib.poems.map((p: any) => (
|
||||
<li id={`i${p.id}`}>
|
||||
{p.poem ? <>
|
||||
<nav class="crumbs">{p.poem.path.map((c: any) => <span><a href={c.url}>{c.title}</a></span>)}</nav>
|
||||
<a class="lib-title" href={p.poem.url}>{p.poem.title}</a>
|
||||
</> : <span class="muted">یہ کلام اب دستیاب نہیں</span>}
|
||||
<form method="post" class="lib-remove"><input type="hidden" name="id" value={p.id} /><button name="act" value="delete" aria-label="ہٹائیں">✕</button></form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{lib.couplets.length > 0 && (
|
||||
<section class="lib-section">
|
||||
<h2>نشان زدہ اشعار</h2>
|
||||
<ul class="lib-list">
|
||||
{lib.couplets.map((c: any) => (
|
||||
<li id={`i${c.id}`}>
|
||||
{c.poem ? <>
|
||||
<nav class="crumbs">{[...c.poem.path, { title: c.poem.title, url: c.poem.url }].map((x: any) => <span><a href={x.url}>{x.title}</a></span>)}</nav>
|
||||
<a class="lib-couplet" href={at(c)}>{c.lines.map((l: string) => <p>{l}</p>)}</a>
|
||||
</> : <span class="muted">یہ شعر اب دستیاب نہیں</span>}
|
||||
<form method="post" class="lib-note">
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<input name="note" value={c.note ?? ''} placeholder="نوٹ" maxlength="1000" dir="auto" />
|
||||
<button name="act" value="note">محفوظ</button>
|
||||
</form>
|
||||
<form method="post" class="lib-remove"><input type="hidden" name="id" value={c.id} /><button name="act" value="delete" aria-label="ہٹائیں">✕</button></form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{lib.phrases.length > 0 && (
|
||||
<section class="lib-section">
|
||||
<h2>نشان زدہ عبارتیں</h2>
|
||||
<ul class="lib-list">
|
||||
{lib.phrases.map((p: any) => (
|
||||
<li id={`i${p.id}`}>
|
||||
{p.poem ? <>
|
||||
<nav class="crumbs">{[...p.poem.path, { title: p.poem.title, url: p.poem.url }].map((x: any) => <span><a href={x.url}>{x.title}</a></span>)}</nav>
|
||||
<a class="lib-couplet" href={at(p)}>{marked(p.lines, p.phrase).map(([a, m, z]) => <p>{a}{m && <span class="bmk">{m}</span>}{z}</p>)}</a>
|
||||
</> : <span class="muted">یہ عبارت اب دستیاب نہیں</span>}
|
||||
<form method="post" class="lib-note">
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input name="note" value={p.note ?? ''} placeholder="نوٹ" maxlength="1000" dir="auto" />
|
||||
<button name="act" value="note">محفوظ</button>
|
||||
</form>
|
||||
<form method="post" class="lib-remove"><input type="hidden" name="id" value={p.id} /><button name="act" value="delete" aria-label="ہٹائیں">✕</button></form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{lib.words.length > 0 && (
|
||||
<section class="lib-section">
|
||||
<h2>محفوظ الفاظ</h2>
|
||||
<ul class="lib-list">
|
||||
{lib.words.map((w: any) => (
|
||||
<li id={`i${w.id}`}>
|
||||
<button type="button" class="lib-word" data-word={w.word} title="معنی اور تلفظ">{w.word}</button>
|
||||
{w.source && <>
|
||||
<nav class="crumbs">{[...w.source.path, { title: w.source.title, url: w.source.url }].map((x: any) => <span><a href={x.url}>{x.title}</a></span>)}</nav>
|
||||
{w.source.lines.length > 0 && <a class="lib-couplet small" href={at({ poem: w.source })}>{w.source.lines.map((l: string) => <p>{l}</p>)}</a>}
|
||||
</>}
|
||||
<form method="post" class="lib-note">
|
||||
<input type="hidden" name="id" value={w.id} />
|
||||
<input name="note" value={w.note ?? ''} placeholder="نوٹ" maxlength="1000" dir="auto" />
|
||||
<button name="act" value="note">محفوظ</button>
|
||||
</form>
|
||||
<form method="post" class="lib-remove"><input type="hidden" name="id" value={w.id} /><button name="act" value="delete" aria-label="ہٹائیں">✕</button></form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
<script is:inline>
|
||||
// a saved word opens the word sidebar (showWord lives in the layout)
|
||||
document.querySelectorAll('.lib-word').forEach((b) => (b.onclick = () => showWord(b.dataset.word)));
|
||||
</script>
|
||||
</Base>
|
||||
@ -212,3 +212,44 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.grant-form fieldset { border: 1px dashed var(--gold-light); border-radius: 10px; display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 6px 12px; }
|
||||
.grant-form legend { font-size: .85rem; padding: 0 6px; }
|
||||
.grant-form label.check { flex-direction: row; align-items: center; gap: 6px; }
|
||||
|
||||
/* personal library: ♡ save buttons, 🔖 couplet bookmarks */
|
||||
.save { font: inherit; font-size: .85rem; padding: 1px 14px; border: 1.5px solid var(--border); border-radius: 999px; background: var(--inner); color: var(--ink); cursor: pointer; }
|
||||
.save::before { content: '♡ '; color: var(--brand); }
|
||||
.save[aria-pressed='true'] { border-color: var(--brand); color: var(--brand); }
|
||||
.save[aria-pressed='true']::before { content: '♥ '; }
|
||||
.dict .save { margin-bottom: 6px; }
|
||||
.couplet, .poem .para, .poem .single { position: relative; }
|
||||
.bm { position: absolute; inset-inline-end: -26px; top: .5em; width: 20px; height: 22px; padding: 0; border: 0; background: none; cursor: pointer;
|
||||
opacity: 0; transition: opacity .15s; user-select: none; }
|
||||
.bm::before { content: '🔖'; font-size: .85rem; filter: grayscale(1); opacity: .6; }
|
||||
.bm[aria-pressed='true'] { opacity: 1; }
|
||||
.bm[aria-pressed='true']::before { filter: none; opacity: 1; }
|
||||
.couplet:hover .bm, .poem .para:hover .bm, .poem .single:hover .bm, .bm:focus-visible { opacity: 1; }
|
||||
@media (hover: none) { .bm { opacity: 1; } }
|
||||
@media (max-width: 700px) { .bm { inset-inline-end: -4px; top: 0; } }
|
||||
:target { background: var(--gold-subtle); border-radius: 6px; }
|
||||
|
||||
.lib-section { margin: 22px 0; }
|
||||
.lib-section h2 { font-size: 1.1rem; text-align: center; color: var(--brand); }
|
||||
.lib-list { list-style: none; padding: 0; max-width: 760px; margin: 0 auto; }
|
||||
.lib-list li { position: relative; padding: 10px 14px 10px 40px; margin: 8px 0; border: 1px dashed var(--gold-light); border-radius: 12px; }
|
||||
.lib-list .crumbs { text-align: start; margin: 0 0 4px; font-size: .8rem; }
|
||||
.lib-title { font-weight: 700; }
|
||||
.lib-couplet { display: block; color: var(--ink); text-decoration: none; text-align: center; line-height: var(--verse-lh, 2.2); }
|
||||
.lib-couplet p { margin: 0; }
|
||||
.lib-couplet.small { font-size: .9rem; color: var(--muted); }
|
||||
.lib-word { font: inherit; font-size: 1.25rem; font-weight: 700; color: var(--lapis); background: none; border: 0; padding: 0; cursor: pointer; }
|
||||
.lib-note { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.lib-note input { flex: 1; font: inherit; font-size: .85rem; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
||||
.lib-note button, .lib-remove button { font: inherit; font-size: .8rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); cursor: pointer; }
|
||||
.lib-remove { position: absolute; left: 8px; top: 8px; margin: 0; }
|
||||
.lib-remove button { color: var(--brand); }
|
||||
|
||||
/* bookmarked phrases, and glyphs where the reader's favourites (♥) and bookmarks (🔖) are */
|
||||
.bmk { text-decoration: underline dotted var(--brand) 2px; text-underline-offset: 6px; cursor: pointer; background: var(--gold-subtle); border-radius: 4px; }
|
||||
.bmk::after { content: '🔖'; font-size: .55em; vertical-align: super; margin-inline-start: 1px; user-select: none; }
|
||||
.mark-fav::after, .mark-bm::after { font-size: .65em; margin-inline-start: 6px; vertical-align: middle; user-select: none; }
|
||||
.mark-fav::after { content: '♥'; color: var(--brand); }
|
||||
.mark-bm::after { content: '🔖'; }
|
||||
.mark-fav.mark-bm::after { content: '♥ 🔖'; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user