Bookmarks with 🔖 for poets, books/chapters and poems; word book on its own page
Owner direction: the word collection is separate from bookmarks, and bookmarks use the bookmark glyph, not hearts. - 🔖 نشان buttons on poet, book/chapter (new) and poem pages; contents lists and cards show 🔖. - /library (نشانات): bookmarked poets, books and chapters, poems, couplets, phrases. - /words (ذخیرۂ الفاظ): the word book on its own page; tabs between the two; header links. - Word button: 'ذخیرۂ الفاظ میں ڈالیں' (+), 'ذخیرۂ الفاظ میں ہے' (✓) once added. - API: library kind 'category' (books and chapters) in toggle, state, marks and the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b3d228f49
commit
98585962e2
@ -31,6 +31,9 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
|
|||||||
assert.equal((await call('GET', '/api/library')).statusCode, 401, 'signed out');
|
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: 'poet', poetId: 266 })).json().saved, true);
|
||||||
assert.deepEqual((await toggle({ kind: 'poem', poemId: poem })).json().saved, true);
|
assert.deepEqual((await toggle({ kind: 'poem', poemId: poem })).json().saved, true);
|
||||||
|
const ghazals = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id;
|
||||||
|
assert.deepEqual((await toggle({ kind: 'category', categoryId: ghazals })).json().saved, true, 'a book/chapter');
|
||||||
|
assert.equal((await toggle({ kind: 'category', categoryId: 999999 })).statusCode, 404);
|
||||||
assert.deepEqual((await toggle({ kind: 'couplet', poemId: poem, couplet: 0 })).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: 'couplet', poemId: poem, couplet: 2 })).json().saved, true);
|
||||||
assert.deepEqual((await toggle({ kind: 'word', word: 'وصال،', poemId: poem, couplet: 0 })).json().saved, true);
|
assert.deepEqual((await toggle({ kind: 'word', word: 'وصال،', poemId: poem, couplet: 0 })).json().saved, true);
|
||||||
@ -50,7 +53,7 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
|
|||||||
const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id;
|
const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id;
|
||||||
assert.ok(marks.categories.includes(book) && marks.poets.includes(266));
|
assert.ok(marks.categories.includes(book) && marks.poets.includes(266));
|
||||||
// state for a page
|
// state for a page
|
||||||
assert.deepEqual((await call('GET', `/api/library/state?poet=266&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?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }] });
|
||||||
|
|
||||||
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true });
|
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true });
|
||||||
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false });
|
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false });
|
||||||
@ -60,6 +63,7 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
|
|||||||
const lib = (await call('GET', '/api/library', undefined, t)).json();
|
const lib = (await call('GET', '/api/library', undefined, t)).json();
|
||||||
assert.equal(lib.poets[0].poet.url, '/p266');
|
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.deepEqual(lib.poems[0].poem.path.map((c: any) => c.url), ['/p266', '/p266/ghazal'], 'poet » book path');
|
||||||
|
assert.deepEqual(lib.categories[0].path.map((c: any) => c.url), ['/p266', '/p266/ghazal']);
|
||||||
assert.equal(lib.couplets.length, 1);
|
assert.equal(lib.couplets.length, 1);
|
||||||
assert.equal(lib.phrases.length, 2);
|
assert.equal(lib.phrases.length, 2);
|
||||||
assert.deepEqual(lib.phrases[0].poem.path.map((c: any) => c.url), ['/p266', '/p266/ghazal']);
|
assert.deepEqual(lib.phrases[0].poem.path.map((c: any) => c.url), ['/p266', '/p266/ghazal']);
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
// Personal library (#24, #25): a reader's saved poets and works, bookmarked couplets and phrases, saved words.
|
// 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.
|
// 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 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/state?poet=&category=&poem=&word= what is saved on one page, or whether a word is in the word book
|
||||||
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items
|
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items
|
||||||
// POST /api/library/toggle {kind, poetId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
|
// POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
|
||||||
// POST /api/library/:id/note {note}
|
// POST /api/library/:id/note {note}
|
||||||
// POST /api/library/:id/delete
|
// POST /api/library/:id/delete
|
||||||
// kind: poet {poetId} | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
|
// kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
|
||||||
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word)
|
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word)
|
||||||
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';
|
||||||
import { PUNCT } from './dictionary.ts';
|
import { PUNCT } from './dictionary.ts';
|
||||||
|
|
||||||
const KINDS = ['poet', 'poem', 'couplet', 'phrase', 'word'] as const;
|
const KINDS = ['poet', 'category', 'poem', 'couplet', 'phrase', 'word'] as const;
|
||||||
|
|
||||||
// a bookmarked phrase: spaces collapsed, 2 to 300 characters
|
// a bookmarked phrase: spaces collapsed, 2 to 300 characters
|
||||||
export const cleanPhrase = (p: unknown) => {
|
export const cleanPhrase = (p: unknown) => {
|
||||||
@ -48,19 +48,33 @@ export async function paths(poemIds: number[]) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the path of each book/chapter: poet » book » … » itself
|
||||||
|
async function categoryPaths(ids: number[]) {
|
||||||
|
if (!ids.length) return new Map<number, { title: string; url: string }[]>();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`WITH RECURSIVE up AS (
|
||||||
|
SELECT c.id AS start, c.id, c.parent_id, c.title, c.url, 0 AS depth FROM categories c WHERE c.id = ANY($1)
|
||||||
|
UNION ALL SELECT up.start, c.id, c.parent_id, c.title, c.url, up.depth + 1 FROM categories c JOIN up ON c.id = up.parent_id)
|
||||||
|
SELECT start, title, url FROM up ORDER BY start, depth DESC`, [ids]);
|
||||||
|
const out = new Map<number, { title: string; url: string }[]>();
|
||||||
|
for (const r of rows) (out.get(r.start) ?? out.set(r.start, []).get(r.start)!).push({ title: r.title, url: r.url });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function libraryRoutes(app: FastifyInstance) {
|
export function libraryRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/library', async (req, reply) => {
|
app.get('/api/library', async (req, reply) => {
|
||||||
const u = await reader(req, reply); if (!u) return;
|
const u = await reader(req, reply); if (!u) return;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT l.id, l.kind, l.poet_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at,
|
`SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at,
|
||||||
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet
|
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet
|
||||||
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
|
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
|
||||||
WHERE l.user_id = $1 ORDER BY l.created_at DESC, l.id DESC`, [u.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 poemIds = [...new Set(rows.filter((r) => r.poem_id).map((r) => r.poem_id))];
|
||||||
const [crumbs, verses] = await Promise.all([
|
const [crumbs, verses, chapters] = await Promise.all([
|
||||||
paths(poemIds),
|
paths(poemIds),
|
||||||
pool.query(`SELECT poem_id, couplet, text FROM verses WHERE (poem_id, couplet) IN
|
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]),
|
(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]),
|
||||||
|
categoryPaths(rows.filter((r) => r.kind === 'category').map((r) => r.category_id)),
|
||||||
]);
|
]);
|
||||||
const lines = (p: number, c: number) => verses.rows.filter((v) => v.poem_id === p && v.couplet === c).map((v) => v.text);
|
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 where = (r: any) => r.poem_id && { url: r.poem_url, title: r.poem_title, path: crumbs.get(r.poem_id) ?? [], couplet: r.couplet };
|
||||||
@ -68,6 +82,7 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
return {
|
return {
|
||||||
user: { full_name: u.full_name ?? '', bio: u.bio ?? '' },
|
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 } })),
|
poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })),
|
||||||
|
categories: rows.filter((r) => r.kind === 'category').map((r) => ({ ...item(r), path: chapters.get(r.category_id) ?? [] })),
|
||||||
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
|
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
|
||||||
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||||||
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||||||
@ -77,25 +92,26 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Querystring: { poet?: string; poem?: string; word?: string } }>('/api/library/state', async (req, reply) => {
|
app.get<{ Querystring: { poet?: string; category?: string; poem?: string; word?: string } }>('/api/library/state', async (req, reply) => {
|
||||||
const u = await reader(req, reply); if (!u) return;
|
const u = await reader(req, reply); if (!u) return;
|
||||||
if (req.query.word != null) {
|
if (req.query.word != null) {
|
||||||
const w = cleanWord(req.query.word);
|
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 };
|
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 poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $3))`,
|
`SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2)
|
||||||
[u.id, poet, poem]);
|
OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`,
|
||||||
|
[u.id, poet, category, poem]);
|
||||||
return {
|
return {
|
||||||
poet: rows.some((r) => r.kind === 'poet'), poem: rows.some((r) => r.kind === 'poem'),
|
poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'),
|
||||||
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet),
|
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet),
|
||||||
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
|
phrases: rows.filter((r) => r.kind === 'phrase').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,
|
// for contents lists and cards: works that are bookmarked (fav) or hold bookmarked couplets/phrases (bm), and
|
||||||
// sections and poets containing them
|
// the books, chapters and poets that are bookmarked or contain them
|
||||||
app.get('/api/library/marks', async (req, reply) => {
|
app.get('/api/library/marks', async (req, reply) => {
|
||||||
const u = await reader(req, reply); if (!u) return;
|
const u = await reader(req, reply); if (!u) return;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
@ -103,9 +119,12 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') GROUP BY poem_id`, [u.id]);
|
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') GROUP BY poem_id`, [u.id]);
|
||||||
const ids = rows.map((r) => r.poem_id);
|
const ids = rows.map((r) => r.poem_id);
|
||||||
const [cats, poets] = await Promise.all([
|
const [cats, poets] = await Promise.all([
|
||||||
pool.query(`WITH RECURSIVE up AS (SELECT c.id, c.parent_id FROM poems p JOIN categories c ON c.id = p.category_id WHERE p.id = ANY($1)
|
pool.query(`WITH RECURSIVE up AS (
|
||||||
UNION SELECT c.id, c.parent_id FROM categories c JOIN up ON c.id = up.parent_id) SELECT id FROM up`, [ids]),
|
SELECT c.id, c.parent_id FROM poems p JOIN categories c ON c.id = p.category_id WHERE p.id = ANY($1)
|
||||||
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']),
|
UNION SELECT c.id, c.parent_id FROM library l JOIN categories c ON c.id = l.category_id WHERE l.user_id = $2 AND l.kind = 'category'
|
||||||
|
UNION SELECT c.id, c.parent_id FROM categories c JOIN up ON c.id = up.parent_id) SELECT id FROM up`, [ids, u.id]),
|
||||||
|
pool.query(`SELECT poet_id FROM poems WHERE id = ANY($1) UNION SELECT poet_id FROM library WHERE user_id = $2 AND kind = 'poet'
|
||||||
|
UNION SELECT c.poet_id FROM library l JOIN categories c ON c.id = l.category_id WHERE l.user_id = $2 AND l.kind = 'category'`, [ids, u.id]),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
poems: Object.fromEntries(rows.map((r) => [r.poem_id, { fav: r.fav, bm: r.bm }])),
|
poems: Object.fromEntries(rows.map((r) => [r.poem_id, { fav: r.fav, bm: r.bm }])),
|
||||||
@ -113,11 +132,11 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Body: { kind?: string; poetId?: number; poemId?: number; couplet?: number; word?: string } }>('/api/library/toggle', async (req, reply) => {
|
app.post<{ Body: { kind?: string; poetId?: number; categoryId?: number; poemId?: number; couplet?: number; phrase?: string; word?: string } }>('/api/library/toggle', async (req, reply) => {
|
||||||
const u = await reader(req, reply); if (!u) return;
|
const u = await reader(req, reply); if (!u) return;
|
||||||
const b = req.body ?? {}, kind = String(b.kind);
|
const b = req.body ?? {}, kind = String(b.kind);
|
||||||
if (!(KINDS as readonly string[]).includes(kind)) return reply.code(400).send({ error: 'نامعلوم قسم' });
|
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 poetId = Number(b.poetId) || null, categoryId = Number(b.categoryId) || null, poemId = Number(b.poemId) || null;
|
||||||
const couplet = Number.isInteger(b.couplet) ? b.couplet! : null;
|
const couplet = Number.isInteger(b.couplet) ? b.couplet! : null;
|
||||||
let key: [string, unknown[]];
|
let key: [string, unknown[]];
|
||||||
if (kind === 'word') {
|
if (kind === 'word') {
|
||||||
@ -144,17 +163,20 @@ export function libraryRoutes(app: FastifyInstance) {
|
|||||||
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args);
|
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args);
|
||||||
return { saved: true, id: Number(rows[0].id) };
|
return { saved: true, id: Number(rows[0].id) };
|
||||||
}
|
}
|
||||||
// poet, poem or couplet: the target must exist
|
// poet, book/chapter, poem or couplet: the target must exist
|
||||||
const exists = kind === 'poet' ? await pool.query('SELECT 1 FROM poets WHERE id = $1', [poetId])
|
const exists = kind === 'poet' ? await pool.query('SELECT 1 FROM poets WHERE id = $1', [poetId])
|
||||||
|
: kind === 'category' ? await pool.query('SELECT 1 FROM categories WHERE id = $1', [categoryId])
|
||||||
: kind === 'poem' ? await pool.query('SELECT 1 FROM poems WHERE id = $1', [poemId])
|
: 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]);
|
: 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: 'نہیں ملا' });
|
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 cols = kind === 'poet' ? [poetId, null, null, null] : kind === 'category' ? [null, categoryId, null, 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)`;
|
: [null, null, poemId, kind === 'couplet' ? couplet : null];
|
||||||
const args = [u.id, kind, cols.poet_id, cols.poem_id, cols.couplet];
|
const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(category_id, 0) = coalesce($4::int, 0)
|
||||||
|
AND coalesce(poem_id, 0) = coalesce($5::int, 0) AND coalesce(couplet, -1) = coalesce($6::int, -1)`;
|
||||||
|
const args = [u.id, kind, ...cols];
|
||||||
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${match} RETURNING id`, args)).rowCount) return { saved: false };
|
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(
|
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);
|
'INSERT INTO library (user_id, kind, poet_id, category_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id', args);
|
||||||
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
|
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -130,8 +130,9 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text; -- up to 1,000 char
|
|||||||
CREATE TABLE IF NOT EXISTS library (
|
CREATE TABLE IF NOT EXISTS library (
|
||||||
id bigserial PRIMARY KEY,
|
id bigserial PRIMARY KEY,
|
||||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
kind text NOT NULL, -- poet | poem | couplet | phrase | word
|
kind text NOT NULL, -- poet | category | poem | couplet | phrase | word
|
||||||
poet_id integer, -- poet
|
poet_id integer, -- poet
|
||||||
|
category_id integer, -- category: a book or chapter/section
|
||||||
poem_id integer, -- poem, couplet, phrase, or a word's source work
|
poem_id integer, -- poem, couplet, phrase, or a word's source work
|
||||||
couplet integer, -- couplet, or a word's source couplet
|
couplet integer, -- couplet, or a word's source couplet
|
||||||
word text, -- word
|
word text, -- word
|
||||||
@ -141,8 +142,10 @@ CREATE TABLE IF NOT EXISTS library (
|
|||||||
);
|
);
|
||||||
ALTER TABLE library ADD COLUMN IF NOT EXISTS phrase text;
|
ALTER TABLE library ADD COLUMN IF NOT EXISTS phrase text;
|
||||||
ALTER TABLE library DROP CONSTRAINT IF EXISTS library_kind_check;
|
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'));
|
ALTER TABLE library ADD COLUMN IF NOT EXISTS category_id integer;
|
||||||
|
ALTER TABLE library ADD CONSTRAINT library_kind_check CHECK (kind IN ('poet', 'category', 'poem', 'couplet', 'phrase', 'word'));
|
||||||
DROP INDEX IF EXISTS library_items;
|
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');
|
DROP INDEX IF EXISTS library_places;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS library_bookmarks ON library (user_id, kind, coalesce(poet_id, 0), coalesce(category_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind IN ('poet', 'category', '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_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';
|
CREATE UNIQUE INDEX IF NOT EXISTS library_words ON library (user_id, word) WHERE kind = 'word';
|
||||||
|
|||||||
9
web/src/components/LibraryNav.astro
Normal file
9
web/src/components/LibraryNav.astro
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
// tabs between the reader's bookmarks and their word book (kept separate)
|
||||||
|
const { current } = Astro.props as { current: 'bookmarks' | 'words' };
|
||||||
|
---
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}>🔖 نشانات</a>
|
||||||
|
<a href="/words" aria-current={current === 'words' ? 'page' : undefined}>ذخیرۂ الفاظ</a>
|
||||||
|
<a href="/account">پروفائل</a>
|
||||||
|
</nav>
|
||||||
@ -42,7 +42,8 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
|||||||
</form>
|
</form>
|
||||||
<div class="toggles">
|
<div class="toggles">
|
||||||
{Astro.locals.user?.role === 'admin' && <a class="account-link" href="/admin">ایڈمن</a>}
|
{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="/library">نشانات</a>}
|
||||||
|
{Astro.locals.user && <a class="account-link" href="/words">ذخیرۂ الفاظ</a>}
|
||||||
{Astro.locals.user
|
{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="/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>}
|
: <a class="account-link" href={`/signin?next=${encodeURIComponent(Astro.url.pathname)}`}>لاگ ان</a>}
|
||||||
@ -55,7 +56,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
|||||||
<main class="wrap"><slot /></main>
|
<main class="wrap"><slot /></main>
|
||||||
<div id="selmenu" class="selmenu" role="toolbar" aria-label="منتخب عبارت" hidden>
|
<div id="selmenu" class="selmenu" role="toolbar" aria-label="منتخب عبارت" hidden>
|
||||||
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
|
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
|
||||||
<button type="button" data-act="saveword" class="plus" title="ذخیرۂ الفاظ میں شامل کریں">لفظ</button>
|
<button type="button" data-act="saveword" class="plus" title="ذخیرۂ الفاظ میں ڈالیں">ذخیرۂ الفاظ</button>
|
||||||
<button type="button" data-act="bookmark" title="اس عبارت پر نشان لگائیں">نشان</button>
|
<button type="button" data-act="bookmark" title="اس عبارت پر نشان لگائیں">نشان</button>
|
||||||
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
|
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
|
||||||
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
|
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
|
||||||
@ -144,7 +145,8 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
|||||||
return e;
|
return e;
|
||||||
};
|
};
|
||||||
let current = '';
|
let current = '';
|
||||||
// personal library: ♡ poets and works, 🔖 couplets, saved words (signed-out readers go to sign-in)
|
// reader's library: 🔖 bookmarks (poets, books/chapters, poems, couplets, phrases) and the word book (+);
|
||||||
|
// signed-out readers go to sign-in
|
||||||
let source = {}; // the couplet the selected word came from
|
let source = {}; // the couplet the selected word came from
|
||||||
async function save(item) {
|
async function save(item) {
|
||||||
if (!document.body.dataset.user) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return null; }
|
if (!document.body.dataset.user) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return null; }
|
||||||
@ -161,9 +163,11 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
|||||||
else if (d) b.setAttribute('aria-pressed', String(d.saved));
|
else if (d) b.setAttribute('aria-pressed', String(d.saved));
|
||||||
});
|
});
|
||||||
const wordSave = (w) => { // sidebar button, with the word's saved state
|
const wordSave = (w) => { // sidebar button, with the word's saved state
|
||||||
const b = el('button', 'wordbook', 'ذخیرۂ الفاظ میں', { type: 'button', 'aria-pressed': 'false', title: 'اپنے ذخیرۂ الفاظ میں شامل کریں' });
|
const b = el('button', 'wordbook', null, { type: 'button' });
|
||||||
b.onclick = async () => { const d = await save({ kind: 'word', word: w, ...source }); if (d) b.setAttribute('aria-pressed', String(d.saved)); };
|
const set = (on) => { b.setAttribute('aria-pressed', String(on)); b.textContent = on ? 'ذخیرۂ الفاظ میں ہے' : 'ذخیرۂ الفاظ میں ڈالیں'; };
|
||||||
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)));
|
set(false);
|
||||||
|
b.onclick = async () => { const d = await save({ kind: 'word', word: w, ...source }); if (d) set(d.saved); };
|
||||||
|
if (document.body.dataset.user) fetch('/api/library?word=' + encodeURIComponent(w)).then((r) => r.ok && r.json()).then((d) => d && set(d.word));
|
||||||
return b;
|
return b;
|
||||||
};
|
};
|
||||||
async function showWord(w) {
|
async function showWord(w) {
|
||||||
@ -333,7 +337,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
|||||||
case 'meaning': showWord(oneWord(selText)); break;
|
case 'meaning': showWord(oneWord(selText)); break;
|
||||||
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
|
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
|
||||||
.then((d) => d && (d.saved ? location.reload() : flash(b, 'نشان پہلے سے تھا، ہٹا دیا'))); break;
|
.then((d) => d && (d.saved ? location.reload() : flash(b, 'نشان پہلے سے تھا، ہٹا دیا'))); break;
|
||||||
case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? '✓ شامل' : 'ہٹا دیا')); break;
|
case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'ذخیرۂ الفاظ میں ڈال دیا' : 'ذخیرۂ الفاظ سے نکال دیا')); break;
|
||||||
case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
|
case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
|
||||||
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
|
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
|
||||||
case 'copy':
|
case 'copy':
|
||||||
|
|||||||
@ -47,11 +47,13 @@ for (const p of page.poems ?? []) {
|
|||||||
groups.at(-1)!.poems.push(p);
|
groups.at(-1)!.poems.push(p);
|
||||||
}
|
}
|
||||||
const crumbs = page.breadcrumbs ?? [];
|
const crumbs = page.breadcrumbs ?? [];
|
||||||
// what the signed-in reader has saved on this page (♡ poet/work, 🔖 couplets)
|
// what the signed-in reader has bookmarked on this page (🔖 poet, book/chapter, poem, couplets, phrases)
|
||||||
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
|
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
|
||||||
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
|
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
|
||||||
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&poem=${poemId ?? ''}`)).data : { poet: false, poem: false, couplets: [], phrases: [] };
|
const categoryId = page.type === 'category' ? page.category.id : undefined;
|
||||||
// on contents pages: which works are favourites (♥) or hold bookmarks (🔖), and which books/sections hold them
|
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data
|
||||||
|
: { poet: false, category: false, poem: false, couplets: [], phrases: [] };
|
||||||
|
// on contents pages: which works are bookmarked or hold bookmarks (🔖), and which books/chapters do
|
||||||
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
|
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
|
||||||
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
|
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
|
||||||
// the marked part of each line of a block, for its bookmarked phrases
|
// the marked part of each line of a block, for its bookmarked phrases
|
||||||
@ -78,12 +80,17 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
|
|||||||
{page.type === 'poet' && (
|
{page.type === 'poet' && (
|
||||||
<>
|
<>
|
||||||
<h1>{page.poet.name}</h1>
|
<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="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="اس شاعر پر نشان لگائیں">نشان</button></p>
|
||||||
<p class="muted">{years(page.poet)}</p>
|
<p class="muted">{years(page.poet)}</p>
|
||||||
{page.poet.description && <div class="intro">{page.poet.description}</div>}
|
{page.poet.description && <div class="intro">{page.poet.description}</div>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{page.type === 'category' && <h1>{page.category.title}</h1>}
|
{page.type === 'category' && (
|
||||||
|
<>
|
||||||
|
<h1>{page.category.title}</h1>
|
||||||
|
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="اس کتاب یا باب پر نشان لگائیں">نشان</button></p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{page.type !== 'poem' && page.children.length > 0 && (
|
{page.type !== 'poem' && page.children.length > 0 && (
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
@ -107,7 +114,7 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
|
|||||||
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
|
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)}>پسندیدہ کلام</button></p>
|
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="اس کلام پر نشان لگائیں">نشان</button></p>
|
||||||
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
|
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
|
||||||
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
|
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
|
||||||
{blocks.map((b) => {
|
{blocks.map((b) => {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// Browser -> API for the save buttons (♡, 🔖, word): the reader's session cookie becomes the Bearer token.
|
// Browser -> API for the 🔖 bookmark buttons and the word book: 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).
|
// 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 type { APIRoute } from 'astro';
|
||||||
import { asUser, COOKIE } from '../../lib/auth';
|
import { asUser, COOKIE } from '../../lib/auth';
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
// میری لائبریری: the reader's saved poets and works, bookmarked couplets and saved words,
|
// نشانات: the reader's bookmarks (poets, books/chapters, works, couplets, phrases), each with its full path in
|
||||||
// each with its full path in the site (poet » book » section » work) and a link to the couplet
|
// the site (poet » book » chapter » work) and a link to the couplet. The word book is separate: /words.
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import { asUser, COOKIE } from '../lib/auth';
|
import { asUser, COOKIE } from '../lib/auth';
|
||||||
import { ud } from '../lib/urdu';
|
import { ud } from '../lib/urdu';
|
||||||
import { phraseParts, phraseRe } from '../lib/phrases';
|
import { phraseParts, phraseRe } from '../lib/phrases';
|
||||||
|
import LibraryNav from '../components/LibraryNav.astro';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
if (!me) return Astro.redirect('/signin?next=/library');
|
if (!me) return Astro.redirect('/signin?next=/library');
|
||||||
@ -16,7 +17,7 @@ if (Astro.request.method === 'POST') {
|
|||||||
return Astro.redirect(`/library#i${id}`, 303);
|
return Astro.redirect(`/library#i${id}`, 303);
|
||||||
}
|
}
|
||||||
const lib = (await asUser(token, '/api/library')).data;
|
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;
|
const total = lib.poets.length + lib.categories.length + lib.poems.length + lib.couplets.length + lib.phrases.length;
|
||||||
// a bookmarked phrase shown inside its couplet: [before, phrase, after] per line
|
// a bookmarked phrase shown inside its couplet: [before, phrase, after] per line
|
||||||
const marked = (lines: string[], phrase: string) => phraseParts(lines, phrase).map((part, i) => {
|
const marked = (lines: string[], phrase: string) => phraseParts(lines, phrase).map((part, i) => {
|
||||||
if (!part) return [lines[i], '', ''];
|
if (!part) return [lines[i], '', ''];
|
||||||
@ -25,27 +26,45 @@ const marked = (lines: string[], phrase: string) => phraseParts(lines, phrase).m
|
|||||||
});
|
});
|
||||||
const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != null ? `#c${item.poem.couplet}` : ''}` : '#');
|
const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != null ? `#c${item.poem.couplet}` : ''}` : '#');
|
||||||
---
|
---
|
||||||
<Base title="میری لائبریری">
|
<Base title="نشانات">
|
||||||
<h1>{me.full_name || 'میری لائبریری'}</h1>
|
<h1>{me.full_name || 'نشانات'}</h1>
|
||||||
{me.bio && <p class="profile-bio">{me.bio}</p>}
|
{me.bio && <p class="profile-bio">{me.bio}</p>}
|
||||||
<p class="muted center">میری لائبریری · {ud(total)} اندراجات · <a href="/account">پروفائل</a></p>
|
<LibraryNav current="bookmarks" />
|
||||||
|
<p class="muted center">{ud(total)} نشانات</p>
|
||||||
|
|
||||||
{total === 0 && (
|
{total === 0 && (
|
||||||
<p class="muted center">ابھی کچھ محفوظ نہیں۔ کسی شاعر یا کلام پر "پسندیدہ" دبائیں، کسی شعر کے ساتھ 🔖 لگائیں، کوئی عبارت منتخب کر کے "نشان" لگائیں، یا لفظ منتخب کر کے "+ لفظ" سے ذخیرۂ الفاظ میں شامل کریں۔</p>
|
<p class="muted center">ابھی کوئی نشان نہیں۔ کسی شاعر، کتاب، باب یا کلام پر 🔖 "نشان" دبائیں، کسی شعر کے ساتھ 🔖 لگائیں، یا کوئی عبارت منتخب کر کے "نشان" لگائیں۔</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lib.poets.length > 0 && (
|
{lib.poets.length > 0 && (
|
||||||
<section class="lib-section">
|
<section class="lib-section">
|
||||||
<h2>پسندیدہ شعرا</h2>
|
<h2>نشان زد شعرا</h2>
|
||||||
<div class="poets">
|
<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>)}
|
{lib.poets.map((p: any) => p.poet && <a class="poet-card" id={`i${p.id}`} href={p.poet.url}>{p.poet.name}</a>)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{lib.categories.length > 0 && (
|
||||||
|
<section class="lib-section">
|
||||||
|
<h2>نشان زد کتابیں اور ابواب</h2>
|
||||||
|
<ul class="lib-list">
|
||||||
|
{lib.categories.map((c: any) => (
|
||||||
|
<li id={`i${c.id}`}>
|
||||||
|
{c.path.length ? <>
|
||||||
|
<nav class="crumbs">{c.path.slice(0, -1).map((x: any) => <span><a href={x.url}>{x.title}</a></span>)}</nav>
|
||||||
|
<a class="lib-title" href={c.path.at(-1).url}>{c.path.at(-1).title}</a>
|
||||||
|
</> : <span class="muted">یہ کتاب اب دستیاب نہیں</span>}
|
||||||
|
<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.poems.length > 0 && (
|
{lib.poems.length > 0 && (
|
||||||
<section class="lib-section">
|
<section class="lib-section">
|
||||||
<h2>پسندیدہ کلام</h2>
|
<h2>نشان زد کلام</h2>
|
||||||
<ul class="lib-list">
|
<ul class="lib-list">
|
||||||
{lib.poems.map((p: any) => (
|
{lib.poems.map((p: any) => (
|
||||||
<li id={`i${p.id}`}>
|
<li id={`i${p.id}`}>
|
||||||
@ -62,7 +81,7 @@ const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != n
|
|||||||
|
|
||||||
{lib.couplets.length > 0 && (
|
{lib.couplets.length > 0 && (
|
||||||
<section class="lib-section">
|
<section class="lib-section">
|
||||||
<h2>نشان زدہ اشعار</h2>
|
<h2>نشان زد اشعار</h2>
|
||||||
<ul class="lib-list">
|
<ul class="lib-list">
|
||||||
{lib.couplets.map((c: any) => (
|
{lib.couplets.map((c: any) => (
|
||||||
<li id={`i${c.id}`}>
|
<li id={`i${c.id}`}>
|
||||||
@ -84,7 +103,7 @@ const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != n
|
|||||||
|
|
||||||
{lib.phrases.length > 0 && (
|
{lib.phrases.length > 0 && (
|
||||||
<section class="lib-section">
|
<section class="lib-section">
|
||||||
<h2>نشان زدہ عبارتیں</h2>
|
<h2>نشان زد عبارتیں</h2>
|
||||||
<ul class="lib-list">
|
<ul class="lib-list">
|
||||||
{lib.phrases.map((p: any) => (
|
{lib.phrases.map((p: any) => (
|
||||||
<li id={`i${p.id}`}>
|
<li id={`i${p.id}`}>
|
||||||
@ -104,30 +123,4 @@ const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != n
|
|||||||
</section>
|
</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>
|
</Base>
|
||||||
|
|||||||
53
web/src/pages/words.astro
Normal file
53
web/src/pages/words.astro
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
// ذخیرۂ الفاظ: the reader's word book, kept apart from their bookmarks (/library). Each word shows the couplet
|
||||||
|
// it was added from, with its full path; clicking a word opens its meanings in the word sidebar.
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import LibraryNav from '../components/LibraryNav.astro';
|
||||||
|
import { asUser, COOKIE } from '../lib/auth';
|
||||||
|
import { ud } from '../lib/urdu';
|
||||||
|
|
||||||
|
const me = Astro.locals.user;
|
||||||
|
if (!me) return Astro.redirect('/signin?next=/words');
|
||||||
|
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(`/words#i${id}`, 303);
|
||||||
|
}
|
||||||
|
const lib = (await asUser(token, '/api/library')).data;
|
||||||
|
const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != null ? `#c${item.poem.couplet}` : ''}` : '#');
|
||||||
|
---
|
||||||
|
<Base title="ذخیرۂ الفاظ">
|
||||||
|
<h1>ذخیرۂ الفاظ</h1>
|
||||||
|
<LibraryNav current="words" />
|
||||||
|
<p class="muted center">{ud(lib.words.length)} الفاظ</p>
|
||||||
|
{lib.words.length === 0 && (
|
||||||
|
<p class="muted center">ابھی کوئی لفظ نہیں۔ کسی لفظ کو منتخب کریں اور لغت میں "ذخیرۂ الفاظ میں ڈالیں" دبائیں۔</p>
|
||||||
|
)}
|
||||||
|
{lib.words.length > 0 && (
|
||||||
|
<section class="lib-section">
|
||||||
|
<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 word opens the word sidebar (showWord lives in the layout)
|
||||||
|
document.querySelectorAll('.lib-word').forEach((b) => (b.onclick = () => showWord(b.dataset.word)));
|
||||||
|
</script>
|
||||||
|
</Base>
|
||||||
@ -214,11 +214,11 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.grant-form legend { font-size: .85rem; padding: 0 6px; }
|
.grant-form legend { font-size: .85rem; padding: 0 6px; }
|
||||||
.grant-form label.check { flex-direction: row; align-items: center; gap: 6px; }
|
.grant-form label.check { flex-direction: row; align-items: center; gap: 6px; }
|
||||||
|
|
||||||
/* personal library: ♡ save buttons, 🔖 couplet bookmarks */
|
/* bookmarks (🔖): poets, books/chapters, poems, couplets; the word book is separate (+) */
|
||||||
.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 { 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::before { content: '🔖 '; filter: grayscale(1); opacity: .6; }
|
||||||
.save[aria-pressed='true'] { border-color: var(--brand); color: var(--brand); }
|
.save[aria-pressed='true'] { border-color: var(--brand); color: var(--brand); }
|
||||||
.save[aria-pressed='true']::before { content: '♥ '; }
|
.save[aria-pressed='true']::before { filter: none; opacity: 1; }
|
||||||
/* word book (ذخیرۂ الفاظ): + to add, ✓ when added (hearts are for poets and works) */
|
/* word book (ذخیرۂ الفاظ): + to add, ✓ when added (hearts are for poets and works) */
|
||||||
.wordbook { font: inherit; font-size: .85rem; padding: 1px 14px; margin-bottom: 6px; border: 1.5px solid var(--border); border-radius: 999px; background: var(--inner); color: var(--ink); cursor: pointer; }
|
.wordbook { font: inherit; font-size: .85rem; padding: 1px 14px; margin-bottom: 6px; border: 1.5px solid var(--border); border-radius: 999px; background: var(--inner); color: var(--ink); cursor: pointer; }
|
||||||
.wordbook::before { content: '+ '; font-weight: 700; color: var(--gold); }
|
.wordbook::before { content: '+ '; font-weight: 700; color: var(--gold); }
|
||||||
@ -251,10 +251,8 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.lib-remove { position: absolute; left: 8px; top: 8px; margin: 0; }
|
.lib-remove { position: absolute; left: 8px; top: 8px; margin: 0; }
|
||||||
.lib-remove button { color: var(--brand); }
|
.lib-remove button { color: var(--brand); }
|
||||||
|
|
||||||
/* bookmarked phrases, and glyphs where the reader's favourites (♥) and bookmarks (🔖) are */
|
/* bookmarked phrases, and 🔖 where the reader's bookmarks are */
|
||||||
.bmk { text-decoration: underline dotted var(--brand) 2px; text-underline-offset: 6px; cursor: pointer; background: var(--gold-subtle); border-radius: 4px; }
|
.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; }
|
.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, .mark-bm::after { font-size: .65em; margin-inline-start: 6px; vertical-align: middle; user-select: none; }
|
||||||
.mark-fav::after { content: '♥'; color: var(--brand); }
|
.mark-fav::after, .mark-bm::after { content: '🔖'; }
|
||||||
.mark-bm::after { content: '🔖'; }
|
|
||||||
.mark-fav.mark-bm::after { content: '♥ 🔖'; }
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user