"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 `` 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([]); const [markers, setMarkers] = useState([]); // 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>(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 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 | 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(() => { const appliedIds = appliedIdsRef.current; return () => { removeBookmarkMarks(); appliedIds.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) => (
Bookmarked {/* 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. */}
))} , 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"); }