feat: add user centric features

This commit is contained in:
gosangam
2026-05-30 23:44:08 +05:30
parent 7bd2952caa
commit dcdd2383c6
63 changed files with 16783 additions and 1735 deletions
+241
View File
@@ -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");
}