Notes apart from bookmarks, and a notes page

- A note never bookmarks: notes and bookmarks share a row, bookmarked false is a note only (db: library.bookmarked). Un-bookmarking keeps a note; clearing the note of a row that is not bookmarked removes it; ✕ on the saved page keeps a note
- A phrase with a note but no bookmark is underlined only, with the note glyph
- /notes: every note with where it is and its couplet (the noted phrase marked), editable in place with the Urdu keyboard, deleted after asking; from a note icon in the header and a نوٹس tab
- API tests for all of it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-09 17:32:16 +02:00
parent b0c7cbccf1
commit 2efde6ee22
9 changed files with 158 additions and 41 deletions

View File

@ -59,20 +59,35 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true }); assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true });
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false }); assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false });
// notes on a couplet and on a phrase: saving a note bookmarks it; an empty note keeps the bookmark // notes on a couplet and on a phrase are separate from bookmarks: a note never bookmarks; un-bookmarking keeps the note
const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk); const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk);
assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { saved: true, note: 'پہلا نوٹ' }); const st = () => call('GET', `/api/library/state?poem=${poem}`, undefined, t).then((r) => r.json());
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { saved: true, note: 'نیا' }, 'not bookmarked yet: bookmarked with the note'); assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { note: 'پہلا نوٹ' }, 'on a bookmarked couplet');
const ph = lines0[0].split(' ').slice(0, 3).join(' '); assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { note: 'نیا' });
assert.equal((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json().saved, true); assert.ok(!(await st()).couplets.includes(1), 'a note does not bookmark');
const ph = lines0[0].split(' ').slice(1, 3).join(' ');
assert.deepEqual((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json(), { note: 'عبارت پر' });
assert.ok(!(await st()).phrases.some((p: any) => p.phrase === ph), 'nor a phrase');
assert.equal((await note({ poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں', note: 'x' })).statusCode, 400); assert.equal((await note({ poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں', note: 'x' })).statusCode, 400);
assert.equal((await note({ poemId: poem, couplet: 9999, note: 'x' })).statusCode, 404); assert.equal((await note({ poemId: poem, couplet: 9999, note: 'x' })).statusCode, 404);
assert.equal((await note({ poemId: poem, couplet: 1, note: 'x' }, other.token)).json().saved, true, "another reader's note is their own"); assert.equal((await note({ poemId: poem, couplet: 1, note: 'x' }, other.token)).statusCode, 200, "another reader's note is their own");
const st = (await call('GET', `/api/library/state?poem=${poem}`, undefined, t)).json(); assert.deepEqual((await st()).notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort());
assert.deepEqual(st.notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort()); // bookmarking a noted couplet keeps the note; un-bookmarking keeps it too
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: '' })).json(), { saved: true, note: null }, 'cleared, still bookmarked'); assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, true);
assert.ok((await st()).couplets.includes(1));
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false); assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false);
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: '' })).json(), { saved: false, note: null }, 'nothing to clear'); assert.ok(!(await st()).couplets.includes(1) && (await st()).notes.some((n: any) => n.couplet === 1), 'the note stays');
// clearing a note that has no bookmark removes it; on a bookmark it keeps the bookmark
await note({ poemId: poem, couplet: 1, note: '' });
assert.ok(!(await st()).notes.some((n: any) => n.couplet === 1));
await note({ poemId: poem, couplet: 0, phrase: ph, note: '' });
await note({ poemId: poem, couplet: 2, note: '' });
assert.ok((await st()).couplets.includes(2) && !(await st()).notes.length, 'bookmark kept, notes gone');
// the notes list (the notes page) holds notes only; the saved lists hold bookmarks only
await note({ poemId: poem, couplet: 3, note: 'صرف نوٹ' }).catch(() => {});
const listed = (await call('GET', '/api/library', undefined, t)).json();
assert.ok(listed.notes.every((n: any) => n.note) && !listed.couplets.some((c: any) => c.poem?.couplet === 3));
await note({ poemId: poem, couplet: 3, note: '' });
// toggling again removes // toggling again removes
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false); assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false);

View File

@ -6,9 +6,11 @@
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items // GET /api/library/marks which works, books/sections and poets hold the reader's saved items
// POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?} // POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
// POST /api/library/:id/note {note} // POST /api/library/:id/note {note}
// POST /api/library/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it: bookmarks it if needed; an empty // POST /api/library/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it; never bookmarks it (a
// note clears the note and keeps the bookmark -> {saved, note} // note and a bookmark are separate); an empty note clears it -> {note}
// POST /api/library/:id/delete // notes and bookmarks share a row: bookmarked false is a note only; un-bookmarking keeps the note, clearing the note of a
// row that is not bookmarked removes the row
// POST /api/library/:id/delete (a couplet or phrase with a note keeps the note)
// kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a // kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book) // couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book)
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
@ -32,6 +34,17 @@ async function phraseIn(poemId: number, couplet: number, phrase: string) {
return null; return null;
} }
// an existing couplet or phrase row: a bookmark goes (the row stays if it holds a note), a note only becomes a bookmark;
// null when there is no row
async function flipBookmark(match: string, args: unknown[]) {
const row = (await pool.query(`SELECT id, bookmarked, note FROM library WHERE user_id = $1 AND ${match}`, args)).rows[0];
if (!row) return null;
if (!row.bookmarked) { await pool.query('UPDATE library SET bookmarked = true WHERE id = $1', [row.id]); return { saved: true, id: Number(row.id) }; }
if (row.note) await pool.query('UPDATE library SET bookmarked = false WHERE id = $1', [row.id]);
else await pool.query('DELETE FROM library WHERE id = $1', [row.id]);
return { saved: false };
}
async function reader(req: FastifyRequest, reply: FastifyReply) { async function reader(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req); const u = await sessionUser(req);
if (!u) reply.code(401).send({ error: 'لاگ ان کریں' }); if (!u) reply.code(401).send({ error: 'لاگ ان کریں' });
@ -76,7 +89,7 @@ export function libraryRoutes(app: FastifyInstance) {
app.get('/api/library', async (req, reply) => { app.get('/api/library', async (req, reply) => {
const u = await reader(req, reply); if (!u) return; const u = await reader(req, reply); if (!u) return;
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page, `SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page, l.bookmarked,
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet, pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet,
eb.title AS ebook_title, ep.nickname AS ebook_poet, ep.url AS ebook_poet_url eb.title AS ebook_title, ep.nickname AS ebook_poet, ep.url AS ebook_poet_url
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
@ -97,8 +110,11 @@ export function libraryRoutes(app: FastifyInstance) {
poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })), poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })),
categories: rows.filter((r) => r.kind === 'category').map((r) => ({ ...item(r), path: chapters.get(r.category_id) ?? [] })), categories: rows.filter((r) => r.kind === 'category').map((r) => ({ ...item(r), path: chapters.get(r.category_id) ?? [] })),
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })), poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
// every note on a couplet or phrase, bookmarked or not (the notes page)
notes: rows.filter((r) => (r.kind === 'couplet' || r.kind === 'phrase') && r.note).map((r) => ({
...item(r), phrase: r.phrase, bookmarked: r.bookmarked, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
pages: rows.filter((r) => r.kind === 'page' && r.ebook_title).map((r) => ({ pages: rows.filter((r) => r.kind === 'page' && r.ebook_title).map((r) => ({
...item(r), page: r.page, ebook: { id: r.ebook_id, title: r.ebook_title, poet: r.ebook_poet, poet_url: r.ebook_poet_url } })), ...item(r), page: r.page, ebook: { id: r.ebook_id, title: r.ebook_title, poet: r.ebook_poet, poet_url: r.ebook_poet_url } })),
words: rows.filter((r) => r.kind === 'word').map((r) => ({ words: rows.filter((r) => r.kind === 'word').map((r) => ({
@ -117,13 +133,13 @@ export function libraryRoutes(app: FastifyInstance) {
} }
const poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0; const poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0;
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT kind, couplet, phrase, note FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) `SELECT kind, couplet, phrase, note, bookmarked FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2)
OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`, OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`,
[u.id, poet, category, poem]); [u.id, poet, category, poem]);
return { return {
poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'), poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'),
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet), couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => r.couplet),
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })), phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
notes: rows.filter((r) => r.note && (r.kind === 'couplet' || r.kind === 'phrase')).map((r) => ({ couplet: r.couplet, phrase: r.phrase, note: r.note })), notes: rows.filter((r) => r.note && (r.kind === 'couplet' || r.kind === 'phrase')).map((r) => ({ couplet: r.couplet, phrase: r.phrase, note: r.note })),
}; };
}); });
@ -134,7 +150,7 @@ export function libraryRoutes(app: FastifyInstance) {
const u = await reader(req, reply); if (!u) return; const u = await reader(req, reply); if (!u) return;
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT poem_id, bool_or(kind = 'poem') AS fav, count(*) FILTER (WHERE kind IN ('couplet', 'phrase'))::int AS bm `SELECT poem_id, bool_or(kind = 'poem') AS fav, count(*) FILTER (WHERE kind IN ('couplet', 'phrase'))::int AS bm
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') GROUP BY poem_id`, [u.id]); FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') AND bookmarked GROUP BY poem_id`, [u.id]);
const ids = rows.map((r) => r.poem_id); const ids = rows.map((r) => r.poem_id);
const [cats, poets] = await Promise.all([ const [cats, poets] = await Promise.all([
pool.query(`WITH RECURSIVE up AS ( pool.query(`WITH RECURSIVE up AS (
@ -184,8 +200,8 @@ export function libraryRoutes(app: FastifyInstance) {
const where = await phraseIn(poemId, couplet, phrase); const where = await phraseIn(poemId, couplet, phrase);
if (where) return reply.code(where.status).send({ error: where.error }); if (where) return reply.code(where.status).send({ error: where.error });
const args = [u.id, poemId, couplet, phrase]; const args = [u.id, poemId, couplet, phrase];
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4 RETURNING id`, args)).rowCount) const flip = await flipBookmark(`kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`, args);
return { saved: false }; if (flip) return flip;
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args); const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args);
return { saved: true, id: Number(rows[0].id) }; return { saved: true, id: Number(rows[0].id) };
} }
@ -200,7 +216,8 @@ export function libraryRoutes(app: FastifyInstance) {
const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(category_id, 0) = coalesce($4::int, 0) const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(category_id, 0) = coalesce($4::int, 0)
AND coalesce(poem_id, 0) = coalesce($5::int, 0) AND coalesce(couplet, -1) = coalesce($6::int, -1)`; AND coalesce(poem_id, 0) = coalesce($5::int, 0) AND coalesce(couplet, -1) = coalesce($6::int, -1)`;
const args = [u.id, kind, ...cols]; const args = [u.id, kind, ...cols];
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${match} RETURNING id`, args)).rowCount) return { saved: false }; const flip = await flipBookmark(match, args);
if (flip) return flip;
const { rows } = await pool.query( const { rows } = await pool.query(
'INSERT INTO library (user_id, kind, poet_id, category_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id', args); 'INSERT INTO library (user_id, kind, poet_id, category_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id', args);
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined }; return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
@ -222,22 +239,28 @@ export function libraryRoutes(app: FastifyInstance) {
return reply.code(404).send({ error: 'نہیں ملا' }); return reply.code(404).send({ error: 'نہیں ملا' });
} }
const n = args.length + 1; const n = args.length + 1;
if ((await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id`, [...args, note])).rowCount) return { saved: true, note }; const row = (await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id, bookmarked`, [...args, note])).rows[0];
if (!note) return { saved: false, note: null }; // nothing saved and nothing to write if (row) { if (!note && !row.bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [row.id]); return { note }; }
await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note) VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n})`, [...args, note]); if (note) await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note, bookmarked)
return { saved: true, note }; VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n}, false)`, [...args, note]);
return { note };
}); });
app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => { app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => {
const u = await reader(req, reply); if (!u) return; const u = await reader(req, reply); if (!u) return;
const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null; const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3', [note, Number(req.params.id) || 0, u.id]); const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3 RETURNING bookmarked', [note, Number(req.params.id) || 0, u.id]);
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' }); if (!r.rowCount) return reply.code(404).send({ error: 'نہیں ملا' });
if (!note && !r.rows[0].bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [Number(req.params.id)]); // a note only, now empty
return { ok: true };
}); });
app.post<{ Params: { id: string } }>('/api/library/:id/delete', async (req, reply) => { app.post<{ Params: { id: string } }>('/api/library/:id/delete', async (req, reply) => {
const u = await reader(req, reply); if (!u) return; const u = await reader(req, reply); if (!u) return;
const r = await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [Number(req.params.id) || 0, u.id]); // removing a bookmarked couplet or phrase keeps its note (the row stays as a note only)
const id = Number(req.params.id) || 0;
const kept = await pool.query(`UPDATE library SET bookmarked = false WHERE id = $1 AND user_id = $2 AND kind IN ('couplet', 'phrase') AND note IS NOT NULL AND bookmarked`, [id, u.id]);
const r = kept.rowCount ? kept : await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [id, u.id]);
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' }); return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
}); });
} }

View File

@ -239,7 +239,9 @@ CREATE INDEX IF NOT EXISTS ebooks_search ON ebooks USING gin (search_text gin_tr
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '<sha256>.jpg|png|webp' in the file store ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '<sha256>.jpg|png|webp' in the file store
-- library: bookmarked pages of e-books (kind 'page': ebook_id, page) -- library: bookmarked pages of e-books (kind 'page': ebook_id, page)
ALTER TABLE library ADD COLUMN IF NOT EXISTS ebook_id integer; ALTER TABLE library ADD COLUMN IF NOT EXISTS ebook_id integer;
ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer; -- kinds: see library_kind_check above ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer;
-- a couplet or phrase can hold a note without being bookmarked (bookmarked false: a note only)
ALTER TABLE library ADD COLUMN IF NOT EXISTS bookmarked boolean NOT NULL DEFAULT true; -- kinds: see library_kind_check above
-- e-book details (owner request): language, reading direction, the writer's name as printed, co-authors (names; -- e-book details (owner request): language, reading direction, the writer's name as printed, co-authors (names;
-- coauthor_ids = those who are poets on the site, so the book is listed on their pages too). Extra tags are -- coauthor_ids = those who are poets on the site, so the book is listed on their pages too). Extra tags are
-- entity_tags with entity 'ebook'. -- entity_tags with entity 'ebook'.

View File

@ -1,9 +1,10 @@
--- ---
// tabs of the reader's own space: saved texts, favourite writers, the word book (kept separate), profile // tabs of the reader's own space: saved texts, favourite writers, the word book (kept separate), profile
const { current } = Astro.props as { current: 'bookmarks' | 'writers' | 'words' }; const { current } = Astro.props as { current: 'bookmarks' | 'notes' | 'writers' | 'words' };
--- ---
<nav class="admin-nav"> <nav class="admin-nav">
<a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}><span class="bm-icon on" aria-hidden="true"></span> محفوظ شدہ</a> <a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}><span class="bm-icon on" aria-hidden="true"></span> محفوظ شدہ</a>
<a href="/notes" aria-current={current === 'notes' ? 'page' : undefined}>نوٹس</a>
<a href="/writers" aria-current={current === 'writers' ? 'page' : undefined}>پسندیدہ لکھاری</a> <a href="/writers" aria-current={current === 'writers' ? 'page' : undefined}>پسندیدہ لکھاری</a>
<a href="/words" aria-current={current === 'words' ? 'page' : undefined}>ذخیرۂ الفاظ</a> <a href="/words" aria-current={current === 'words' ? 'page' : undefined}>ذخیرۂ الفاظ</a>
<a href="/account">پروفائل</a> <a href="/account">پروفائل</a>

View File

@ -3,7 +3,8 @@
// a bookmarked phrase opens its note when pressed (the poem page's note box), where it can also be removed // a bookmarked phrase opens its note when pressed (the poem page's note box), where it can also be removed
const { s } = Astro.props as { s: [string, string, string?][] }; const { s } = Astro.props as { s: [string, string, string?][] };
const noteOf = (d?: string) => (d ? (JSON.parse(d).note as string | undefined) : undefined); const noteOf = (d?: string) => (d ? (JSON.parse(d).note as string | undefined) : undefined);
const noteOnly = (d?: string) => !!d && !!JSON.parse(d).noteOnly; // a phrase with a note but no bookmark
--- ---
{s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span> {s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span>
: k === 'bmk' ? <span class:list={['bmk', { 'has-note': noteOf(d) }]} data-phrase={d} role="button" tabindex="0" : k === 'bmk' ? <span class:list={['bmk', { 'has-note': noteOf(d), 'note-only': noteOnly(d) }]} data-phrase={d} role="button" tabindex="0"
title={noteOf(d) ? `نوٹ: ${noteOf(d)}` : 'محفوظ شدہ عبارت: نوٹ لکھیں یا ہٹائیں'}>{t}</span> : t))} title={noteOf(d) ? `نوٹ: ${noteOf(d)}` : 'محفوظ شدہ عبارت: نوٹ لکھیں یا ہٹائیں'}>{t}</span> : t))}

View File

@ -69,6 +69,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
{Astro.locals.user?.role === 'admin' && <a class="icon-btn" href="/admin" title="ایڈمن" aria-label="ایڈمن"><Icon name="admin" /></a>} {Astro.locals.user?.role === 'admin' && <a class="icon-btn" href="/admin" title="ایڈمن" aria-label="ایڈمن"><Icon name="admin" /></a>}
{['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="icon-btn" href="/mod" title="موڈریشن" aria-label="موڈریشن"><Icon name="edit" /></a>} {['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="icon-btn" href="/mod" title="موڈریشن" aria-label="موڈریشن"><Icon name="edit" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/library" title="محفوظ شدہ" aria-label="محفوظ شدہ"><Icon name="bookmark" /></a>} {Astro.locals.user && <a class="icon-btn" href="/library" title="محفوظ شدہ" aria-label="محفوظ شدہ"><Icon name="bookmark" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/notes" title="میرے نوٹس" aria-label="میرے نوٹس"><Icon name="note" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/words" title="ذخیرۂ الفاظ" aria-label="ذخیرۂ الفاظ"><span class="glyph-icon"><Icon name="words" /><span aria-hidden="true">ع</span></span></a>} {Astro.locals.user && <a class="icon-btn" href="/words" title="ذخیرۂ الفاظ" aria-label="ذخیرۂ الفاظ"><span class="glyph-icon"><Icon name="words" /><span aria-hidden="true">ع</span></span></a>}
{Astro.locals.user {Astro.locals.user
? <a class="icon-btn" href="/account" title={`اکاؤنٹ: ${Astro.locals.user.full_name || Astro.locals.user.email}`} aria-label="اکاؤنٹ"><Icon name="user" /></a> ? <a class="icon-btn" href="/account" title={`اکاؤنٹ: ${Astro.locals.user.full_name || Astro.locals.user.email}`} aria-label="اکاؤنٹ"><Icon name="user" /></a>

View File

@ -63,9 +63,11 @@ const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '')
// the marked part of each line of a block, for its bookmarked phrases // the marked part of each line of a block, for its bookmarked phrases
const lineMarks = (c: number, lines: string[]) => { const lineMarks = (c: number, lines: string[]) => {
const out: Mark[][] = lines.map(() => []); const out: Mark[][] = lines.map(() => []);
for (const p of saved.phrases.filter((x: any) => x.couplet === c)) { const noteOnly = notes.filter((n) => n.couplet === c && n.phrase && !saved.phrases.some((p: any) => p.couplet === c && p.phrase === n.phrase))
.map((n) => ({ couplet: c, phrase: n.phrase, noteOnly: true }));
for (const p of [...saved.phrases.filter((x: any) => x.couplet === c), ...noteOnly]) {
const note = notes.find((n) => n.couplet === c && n.phrase === p.phrase)?.note; const note = notes.find((n) => n.couplet === c && n.phrase === p.phrase)?.note;
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase, note }); const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase, note, noteOnly: p.noteOnly });
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save })); phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
} }
return out; return out;
@ -149,8 +151,7 @@ import Icon from '../components/Icon.astro';
const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c); const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c);
// the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note) // the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note)
const bm = <span class="c-tools"> const bm = <span class="c-tools">
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />
data-confirm={cn ? 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔' : undefined} />
<button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} /> <button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
<button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" /> <button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" />
<button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" /> <button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" />
@ -202,7 +203,7 @@ import Icon from '../components/Icon.astro';
import nq400 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-400-normal.woff2?url'; import nq400 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-400-normal.woff2?url';
let cardFonts: Promise<unknown> | null = null; let cardFonts: Promise<unknown> | null = null;
// notes on couplets and phrases: the glyph beside a couplet's bookmark, a highlighted phrase, or نوٹ in the selection menu // notes on couplets and phrases: the glyph beside a couplet's bookmark, a highlighted phrase, or نوٹ in the selection menu
// open one box; saving a note bookmarks the couplet or phrase (the API does both) // open one box; a note never bookmarks (bookmarks are the bookmark button's)
const data = document.getElementById('notes-data'); const data = document.getElementById('notes-data');
if (data) { if (data) {
const { poemId, notes, phrases, signedIn } = JSON.parse(data.textContent!) as { poemId: number; notes: { couplet: number; phrase: string | null; note: string }[]; phrases: { couplet: number; phrase: string }[]; signedIn: boolean }; const { poemId, notes, phrases, signedIn } = JSON.parse(data.textContent!) as { poemId: number; notes: { couplet: number; phrase: string | null; note: string }[]; phrases: { couplet: number; phrase: string }[]; signedIn: boolean };
@ -231,9 +232,7 @@ import Icon from '../components/Icon.astro';
const mark = (couplet: number) => { // the glyph follows the notes on this couplet const mark = (couplet: number) => { // the glyph follows the notes on this couplet
const nt = document.querySelector(`.nt[data-for="${couplet}"]`); if (!nt) return; const nt = document.querySelector(`.nt[data-for="${couplet}"]`); if (!nt) return;
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet); const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'); nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'); // the bookmark is separate
const bm = document.querySelector(`#c${couplet} .bm`) as HTMLElement | null;
if (bm) { if (cn) { bm.setAttribute('aria-pressed', 'true'); bm.dataset.confirm = 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔'; } else delete bm.dataset.confirm; }
}; };
box.addEventListener('close', async () => { box.addEventListener('close', async () => {
const act = box.returnValue, { couplet, phrase } = at; const act = box.returnValue, { couplet, phrase } = at;

64
web/src/pages/notes.astro Normal file
View File

@ -0,0 +1,64 @@
---
// نوٹس: the reader's notes on couplets and on phrases in them, newest first, each with where it is and the couplet
// (the phrase marked). A note is separate from a bookmark: deleting a note never removes a bookmark.
import Base from '../layouts/Base.astro';
import Icon from '../components/Icon.astro';
import LibraryNav from '../components/LibraryNav.astro';
import { asUser, COOKIE } from '../lib/auth';
import { ud } from '../lib/urdu';
import { ago } from '../lib/roles';
import { phraseParts, phraseRe } from '../lib/phrases';
const me = Astro.locals.user;
if (!me) return Astro.redirect('/signin?next=/notes');
const token = Astro.cookies.get(COOKIE)!.value;
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData(), id = Number(f.get('id')) || 0;
const r = await asUser(token, `/api/library/${id}/note`, { note: f.get('act') === 'delete' ? '' : f.get('note') });
return Astro.redirect(r.ok ? `/notes?done=${f.get('act')}${f.get('act') === 'save' ? `#n${id}` : ''}` : '/notes', 303);
}
const notes: any[] = (await asUser(token, '/api/library')).data.notes ?? [];
const done = Astro.url.searchParams.get('done');
// the noted phrase inside its couplet: [before, phrase, after] per line
const marked = (lines: string[], phrase: string | null) => !phrase ? lines.map((l) => [l, '', '']) : phraseParts(lines, phrase).map((part, i) => {
if (!part) return [lines[i], '', ''];
const m = lines[i].match(phraseRe(part))!;
return [lines[i].slice(0, m.index), m[0], lines[i].slice(m.index! + m[0].length)];
});
---
<Base title="نوٹس">
<h1>{me.full_name || 'نوٹس'}</h1>
<LibraryNav current="notes" />
{done && <p class="form-done" role="status">{done === 'delete' ? 'نوٹ ہٹا دیا' : 'نوٹ محفوظ ہو گیا'}</p>}
<p class="muted center">{ud(notes.length)} نوٹس</p>
{notes.length === 0 && <p class="empty"><span aria-hidden="true">✤</span>ابھی کوئی نوٹ نہیں۔ کلام پڑھتے ہوئے کسی شعر کے ساتھ والا نوٹ کا نشان دبائیں، یا کوئی عبارت منتخب کر کے "نوٹ" چنیں۔</p>}
<ul class="note-list">
{notes.map((n) => (
<li id={`n${n.id}`} class="note-card">
{n.poem ? <>
<header>
<nav class="crumbs">{[...n.poem.path, { title: n.poem.title, url: n.poem.url }].map((x: any) => <span><a href={x.url}>{x.title}</a></span>)}</nav>
{n.bookmarked && <span class="badge plain" title="یہ شعر محفوظ شدہ میں بھی ہے">محفوظ شدہ</span>}
</header>
<a class="lib-couplet" href={`${n.poem.url}#c${n.poem.couplet}`}>{marked(n.lines, n.phrase).map(([a, m, z]) => <p>{a}{m && <mark>{m}</mark>}{z}</p>)}</a>
</> : <span class="muted">یہ شعر اب دستیاب نہیں</span>}
<form method="post" class="note-edit">
<input type="hidden" name="id" value={n.id} />
<div class="in-box-field note-field">
<textarea id={`note-${n.id}`} name="note" rows="2" maxlength="1000" dir="auto" aria-label="نوٹ" data-urdu required>{n.note}</textarea>
<button type="button" class="in-box rom" data-urdu-rom={`note-${n.id}`} aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ">اب</button>
<button type="button" class="in-box" data-urdu-kb={`note-${n.id}`} aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button>
</div>
<div class="note-actions">
<small class="muted" title={new Date(n.saved_at).toISOString().slice(0, 16).replace('T', ' ')}>{ago(n.saved_at)}</small>
<button class="btn" name="act" value="save"><Icon name="check" size={16} /> محفوظ کریں</button>
</div>
</form>
<form method="post" class="note-delete" data-confirm="یہ نوٹ ہٹائیں؟ شعر محفوظ شدہ میں ہو تو وہیں رہے گا۔" data-danger data-ok="ہٹائیں">
<input type="hidden" name="id" value={n.id} />
<button class="icon-btn small danger" name="act" value="delete" title="نوٹ ہٹائیں" aria-label="نوٹ ہٹائیں"><Icon name="trash" size={16} /></button>
</form>
</li>
))}
</ul>
</Base>

View File

@ -723,6 +723,7 @@ p, li { text-wrap: pretty; }
@media (hover: none) { .nt { opacity: .55; } .nt[aria-pressed='true'] { opacity: 1; } } @media (hover: none) { .nt { opacity: .55; } .nt[aria-pressed='true'] { opacity: 1; } }
@media (max-width: 700px) { .nt { inset-inline-end: -4px; top: 26px; } } @media (max-width: 700px) { .nt { inset-inline-end: -4px; top: 26px; } }
/* a highlighted phrase with a note: the note glyph instead of the bookmark */ /* a highlighted phrase with a note: the note glyph instead of the bookmark */
.bmk.note-only { background: none; text-decoration-color: var(--accent); } /* a note without a bookmark: underline only */
.bmk.has-note::after { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); } .bmk.has-note::after { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); }
.bmk:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } .bmk:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* the note box */ /* the note box */
@ -794,3 +795,13 @@ p, li { text-wrap: pretty; }
.dl:hover::before { background-color: var(--active); } .dl:hover::before { background-color: var(--active); }
.couplet:hover .dl, .poem .para:hover .dl, .poem .single:hover .dl, .dl:focus-visible { opacity: 1; } .couplet:hover .dl, .poem .para:hover .dl, .poem .single:hover .dl, .dl:focus-visible { opacity: 1; }
@media (hover: none) { .dl { opacity: .55; } } @media (hover: none) { .dl { opacity: .55; } }
/* the notes page: one card per note, the couplet with the noted phrase marked, the note editable in place */
.note-list { list-style: none; padding: 0; max-width: 760px; margin: 12px auto; display: flex; flex-direction: column; gap: 12px; }
.note-card { position: relative; padding: 10px 16px 12px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface-raised); }
.note-card header { display: flex; align-items: center; gap: 8px; padding-inline-end: 40px; }
.note-card .crumbs { text-align: start; margin: 0; font-size: var(--fs-xs); flex: 1; }
.note-card .lib-couplet { margin: 6px 0 8px; }
.note-card mark { background: var(--accent-soft); box-shadow: inset 0 -2px var(--accent); }
.note-edit textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; background: var(--surface-page); }
.note-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; }
.note-delete { position: absolute; top: 10px; left: 12px; margin: 0; }