242 lines
7.9 KiB
TypeScript
242 lines
7.9 KiB
TypeScript
"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");
|
||
}
|