feat: add user centric features
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"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: 40,
|
||||
}}
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user