Bookmark icon like Android apps: grey outline when not set, filled brand colour when set

Material Design 'bookmark' / 'bookmark_border' (Apache-2.0) drawn as a CSS mask so it follows the
theme; replaces the 🔖 emoji on bookmark buttons, couplet bookmarks, bookmarked phrases, contents
lists and cards, and the نشانات tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-09 00:03:02 +02:00
parent 2b558ea494
commit cd404710f0
6 changed files with 19 additions and 15 deletions

View File

@ -3,7 +3,7 @@
const { current } = Astro.props as { current: 'bookmarks' | 'words' }; const { current } = Astro.props as { current: 'bookmarks' | 'words' };
--- ---
<nav class="admin-nav"> <nav class="admin-nav">
<a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}>🔖 نشانات</a> <a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}><span class="bm-icon on" aria-hidden="true"></span> نشانات</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>
</nav> </nav>

View File

@ -145,7 +145,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
return e; return e;
}; };
let current = ''; let current = '';
// reader's library: 🔖 bookmarks (poets, books/chapters, poems, couplets, phrases) and the word book (+); // reader's library: bookmarks (poets, books/chapters, poems, couplets, phrases) and the word book (+);
// signed-out readers go to sign-in // 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) {

View File

@ -47,13 +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 bookmarked on this page (🔖 poet, book/chapter, poem, couplets, phrases) // 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 categoryId = page.type === 'category' ? page.category.id : undefined; const categoryId = page.type === 'category' ? page.category.id : undefined;
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data
: { poet: false, category: false, poem: false, couplets: [], phrases: [] }; : { poet: false, category: false, poem: false, couplets: [], phrases: [] };
// on contents pages: which works are bookmarked or hold bookmarks (🔖), and which books/chapters do // on contents pages: which works are bookmarked or hold bookmarks, and which books/chapters do
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] }; const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : ''); const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
// the marked part of each line of a block, for its bookmarked phrases // the marked part of each line of a block, for its bookmarked phrases

View File

@ -1,4 +1,4 @@
// Browser -> API for the 🔖 bookmark buttons and the word book: 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';

View File

@ -33,7 +33,7 @@ const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != n
<p class="muted center">{ud(total)} نشانات</p> <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 && (

View File

@ -214,11 +214,17 @@ 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; }
/* bookmarks (🔖): poets, books/chapters, poems, couplets; the word book is separate (+) */ /* bookmarks: an Android-style bookmark icon (Material Design 'bookmark', Apache-2.0), grey outline when not set,
filled in the brand colour when set; drawn as a mask so it follows the theme. The word book is separate (+). */
:root { --bm-outline: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2zm0 15-5-2.18L7 18V5h10v13z'/%3E%3C/svg%3E"); --bm-filled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z'/%3E%3C/svg%3E"); }
.bm-icon, .save::before, .bm::before, .bmk::after, .mark-fav::after, .mark-bm::after {
content: ''; display: inline-block; width: 1.05em; height: 1.05em; vertical-align: -.18em; background-color: var(--muted);
-webkit-mask: var(--bm-outline) center / contain no-repeat; mask: var(--bm-outline) center / contain no-repeat; user-select: none; }
.save[aria-pressed='true']::before, .bm[aria-pressed='true']::before, .bmk::after, .mark-fav::after, .mark-bm::after, .bm-icon.on {
background-color: var(--brand); -webkit-mask-image: var(--bm-filled); mask-image: var(--bm-filled); }
.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: '🔖 '; filter: grayscale(1); opacity: .6; } .save::before { margin-inline-end: 4px; }
.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 { 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); }
@ -227,9 +233,8 @@ h1 + .muted { text-align: center; margin-top: 0; }
.couplet, .poem .para, .poem .single { position: relative; } .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; .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; } opacity: 0; transition: opacity .15s; user-select: none; }
.bm::before { content: '🔖'; font-size: .85rem; filter: grayscale(1); opacity: .6; } .bm::before { width: 18px; height: 18px; }
.bm[aria-pressed='true'] { opacity: 1; } .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; } .couplet:hover .bm, .poem .para:hover .bm, .poem .single:hover .bm, .bm:focus-visible { opacity: 1; }
@media (hover: none) { .bm { opacity: 1; } } @media (hover: none) { .bm { opacity: 1; } }
@media (max-width: 700px) { .bm { inset-inline-end: -4px; top: 0; } } @media (max-width: 700px) { .bm { inset-inline-end: -4px; top: 0; } }
@ -251,8 +256,7 @@ 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 🔖 where the reader's bookmarks are */ /* bookmarked phrases, and the bookmark icon 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 { width: .7em; height: .7em; vertical-align: super; margin-inline-start: 2px; }
.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 { width: .85em; height: .85em; margin-inline-start: 6px; vertical-align: -.1em; }
.mark-fav::after, .mark-bm::after { content: '🔖'; }