Files
spsays-training/components/social/page-bookmarks.tsx
T
2026-05-31 06:50:05 +05:30

242 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* PageBookmarks — persistent in-text bookmark highlighting + floating X.
*
* For every bookmark the logged-in user has whose `source_url` path
* matches the current pathname, we:
*
* 1. Wrap the passage in a `<mark data-bookmark-id="…">` with a violet
* wash so the text stays highlighted across the whole session.
* 2. Anchor a floating "Bookmarked × remove" pill 32px above the first
* mark, scrolling with the page.
* 3. Remove both the mark AND the pill when the user clicks ×.
*
* Cross-component sync: the selection toolbar dispatches
* `spsays:bookmarks-changed` whenever it creates a bookmark; we re-fetch
* and re-mark on that event, so a freshly-bookmarked passage lights up
* immediately without a page reload.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePathname } from "next/navigation";
import { Bookmark, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import {
bookmarks as bookmarksApi,
type Bookmark as BookmarkRec,
} from "@/lib/social";
import {
decodeTextFragment,
markFragmentAsBookmark,
removeBookmarkMarks,
} from "@/lib/text-highlight";
type Marker = {
bookmark: BookmarkRec;
top: number;
left: number;
};
export function PageBookmarks() {
const { user, loading } = useAuth();
const pathname = usePathname();
const [list, setList] = useState<BookmarkRec[]>([]);
const [markers, setMarkers] = useState<Marker[]>([]);
// Track which bookmark ids have already been applied to the DOM so we
// don't double-wrap when state churns (e.g. an optimistic delete that
// then errors and gets restored).
const appliedIdsRef = useRef<Set<string>>(new Set());
const onThisPage = useMemo(
() => list.filter((b) => pathOf(b.source_url) === pathname),
[list, pathname],
);
// Fetch bookmarks once per user. The toolbar fires a custom event when
// it creates a new one so we refresh without polling.
useEffect(() => {
if (loading) return;
if (!user) {
setList([]);
return;
}
let cancelled = false;
const refetch = () =>
bookmarksApi
.list()
.then((rows) => {
if (!cancelled) setList(rows ?? []);
})
.catch(() => {
/* overlay is non-essential — swallow errors */
});
refetch();
const onChanged = () => {
void refetch();
};
window.addEventListener("spsays:bookmarks-changed", onChanged);
return () => {
cancelled = true;
window.removeEventListener("spsays:bookmarks-changed", onChanged);
};
}, [user, loading]);
// Apply marks + measure icon positions. Runs whenever the relevant
// bookmarks for this page change, OR when the viewport resizes so a
// reflowed paragraph doesn't strand the floating icon.
const applyAndMeasure = useCallback(() => {
if (typeof window === "undefined") return;
// Garbage-collect marks for bookmarks that no longer exist on this
// page (deleted, or navigated to a different URL).
const wantedIds = new Set(onThisPage.map((b) => b.id));
for (const id of appliedIdsRef.current) {
if (!wantedIds.has(id)) {
removeBookmarkMarks(id);
appliedIdsRef.current.delete(id);
}
}
const next: Marker[] = [];
for (const bookmark of onThisPage) {
const hl = hlOf(bookmark.source_url);
if (!hl) continue;
const fragment = decodeTextFragment(hl);
if (!fragment) continue;
// Wrap text in <mark> if not already wrapped. Idempotent.
const marks = markFragmentAsBookmark(fragment, bookmark.id);
if (marks.length === 0) continue;
appliedIdsRef.current.add(bookmark.id);
// Anchor the floating icon to the first mark's bounding rect.
const rect = marks[0].getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
next.push({
bookmark,
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
});
}
setMarkers(next);
}, [onThisPage]);
// Run the apply/measure cycle when this page's bookmarks change. Some
// pages stream content in (the verses page), so we retry a few times to
// catch text nodes added shortly after mount.
useEffect(() => {
if (onThisPage.length === 0) {
// No bookmarks for this page — strip any leftover marks (e.g. from
// a stale render before list rehydrated) and stop.
removeBookmarkMarks();
appliedIdsRef.current.clear();
setMarkers([]);
return;
}
let attempts = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = () => {
applyAndMeasure();
attempts++;
if (attempts < 6) timer = setTimeout(tick, 300);
};
tick();
window.addEventListener("resize", applyAndMeasure);
return () => {
if (timer) clearTimeout(timer);
window.removeEventListener("resize", applyAndMeasure);
};
}, [onThisPage, applyAndMeasure]);
// Belt-and-braces: when this component unmounts (route change), tear
// down every mark so the next page starts clean.
useEffect(() => {
return () => {
removeBookmarkMarks();
appliedIdsRef.current.clear();
};
}, []);
async function remove(id: string) {
const previous = list;
setList((rows) => rows.filter((b) => b.id !== id));
// Yank the in-text mark immediately for snappy feedback. The
// applyAndMeasure effect would also catch this, but we don't want a
// visible flash of the still-highlighted text while it runs.
removeBookmarkMarks(id);
appliedIdsRef.current.delete(id);
try {
await bookmarksApi.delete(id);
toast.success("Bookmark removed");
} catch {
setList(previous);
toast.error("Could not remove bookmark");
}
}
if (typeof document === "undefined") return null;
if (markers.length === 0) return null;
return createPortal(
<>
{markers.map((m) => (
<div
key={m.bookmark.id}
style={{
position: "absolute",
// top = top-of-text minus pill height (~26) minus tail (6) minus
// a 4px breathing gap so the tail tip floats just above the
// highlighted line.
top: m.top - 36,
left: m.left,
zIndex: 5,
}}
className="pointer-events-none"
>
<div className="pointer-events-auto relative inline-flex items-center gap-1 rounded-full bg-violet-500 shadow-lg px-2.5 py-1 animate-in fade-in-0 zoom-in-95">
<Bookmark className="size-3.5 text-white" />
<span className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold text-white">
Bookmarked
</span>
<button
type="button"
onClick={() => remove(m.bookmark.id)}
aria-label="Remove bookmark"
className="size-5 ml-0.5 inline-flex items-center justify-center rounded-full text-white/80 hover:bg-white/20 hover:text-white transition-colors"
>
<X className="size-3" />
</button>
{/* Teardrop pointer to the text below. CSS triangle made with
borders: transparent sides + violet top create a down-arrow
that visually connects the pill to its highlighted passage. */}
<span
aria-hidden
className="absolute left-3 top-full size-0 border-x-[5px] border-x-transparent border-t-[6px] border-t-violet-500"
/>
</div>
</div>
))}
</>,
document.body,
);
}
// --- helpers ---
function pathOf(sourceURL: string): string {
const q = sourceURL.indexOf("?");
return q < 0 ? sourceURL : sourceURL.slice(0, q);
}
function hlOf(sourceURL: string): string | null {
const q = sourceURL.indexOf("?");
if (q < 0) return null;
const params = new URLSearchParams(sourceURL.slice(q + 1));
return params.get("hl");
}