= {
adi: "Ādi-līlā",
madhya: "Madhya-līlā",
antya: "Antya-līlā",
};
/**
* Parse the current page URL into a human-readable source reference.
* Returns { title, detail, url } or null if on a non-content page.
*/
function getSourceReference(): {
title: string;
detail: string | null;
url: string;
} | null {
const url = window.location.href;
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
// /books/bg/1/1 → Bhagavad-gītā As It Is, Chapter 1, Verse 1
if (parts[0] === "books" && parts.length >= 2) {
const code = parts[1];
const bookName = BOOK_NAMES[code] || code.toUpperCase();
// Verse-based: /books/bg/[chapter]/[verse]
if (code === "bg" && parts.length >= 4) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// SB: /books/sb/[canto]/[chapter]/[verse]
if (code === "sb" && parts.length >= 5) {
return {
title: bookName,
detail: `Canto ${parts[2]}, Chapter ${parts[3]}, Verse ${parts[4]}`,
url,
};
}
// CC: /books/cc/[lila]/[chapter]/[verse]
if (code === "cc" && parts.length >= 5) {
const lila = CC_LILAS[parts[2]] || parts[2];
return {
title: bookName,
detail: `${lila}, Chapter ${parts[3]}, Verse ${parts[4]}`,
url,
};
}
// CC lila index: /books/cc/[lila]
if (code === "cc" && parts.length === 3) {
const lila = CC_LILAS[parts[2]] || parts[2];
return { title: bookName, detail: lila, url };
}
// SB1962: /books/sb1962/[chapter]/[verse]
if (code === "sb1962" && parts.length >= 4) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// ISO/NOI: /books/iso/[mantra]
if ((code === "iso" || code === "noi") && parts.length >= 3) {
const num = parts[2];
const label = code === "iso" ? "Mantra" : "Text";
return { title: bookName, detail: `${label} ${num}`, url };
}
// BS/MM/NBS: /books/{code}/[chapter]/[verse]
if (
(code === "bs" || code === "mm" || code === "nbs") &&
parts.length >= 4
) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// KB: /books/kb/[volume]/[slug]
if (code === "kb" && parts.length >= 4) {
return {
title: bookName,
detail: `Volume ${parts[2]}, Chapter ${parts[3]}`,
url,
};
}
// Generic prose: /books/{code}/[chapter]
if (parts.length >= 3) {
const chSlug = parts[2];
const chNum = parseInt(chSlug, 10);
const detail = isNaN(chNum)
? chSlug.replace(/-/g, " ")
: `Chapter ${chNum}`;
return { title: bookName, detail, url };
}
// Book index page
return { title: bookName, detail: null, url };
}
// /letters/[slug]
if (
parts[0] === "letters" &&
parts.length === 2 &&
parts[1] !== "year" &&
parts[1] !== "recipient" &&
parts[1] !== "location"
) {
// h1 contains "Letter to Mukunda" — use it as the title
const h1 = document.querySelector("h1");
const title = h1?.textContent?.trim() || "Letter";
// The sibling after h1 contains "15 January, 1968 · Calcutta"
const metaP = document.querySelector(
"header p.text-muted-foreground, header p[class*='muted']",
);
const meta = metaP?.textContent?.trim() || null;
return { title, detail: meta, url };
}
// /essays/[slug]
if (parts[0] === "essays" && parts.length === 2) {
const h1 = document.querySelector("h1");
const title = h1?.textContent?.trim() || parts[1].replace(/-/g, " ");
return { title, detail: "Essay by Śrīla Prabhupāda", url };
}
// /journals/[slug]/[chapter]
if (parts[0] === "journals" && parts.length >= 3) {
const backLink = document.querySelector("header a[href^='/journals/']");
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Journal";
const h1 = document.querySelector("h1");
const chapterTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: chapterTitle, url };
}
// /magazines/[slug]/[issue]
if (parts[0] === "magazines" && parts.length >= 3) {
const backLink = document.querySelector("header a[href^='/magazines/']");
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Magazine";
const h1 = document.querySelector("h1");
const issueTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: issueTitle, url };
}
// /other-documents/[slug]/[chapter]
if (parts[0] === "other-documents" && parts.length >= 3) {
const backLink = document.querySelector(
"header a[href^='/other-documents/']",
);
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Document";
const h1 = document.querySelector("h1");
const chapterTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: chapterTitle, url };
}
// /glossary — general
if (parts[0] === "glossary") {
return { title: "Glossary", detail: null, url };
}
// Fallback: try the h1
const h1 = document.querySelector("h1");
if (h1) {
return {
title: h1.textContent?.trim() || "Prabhupāda Says",
detail: null,
url,
};
}
return null;
}
/**
* Format selected text with source attribution appended.
*/
function formatCopyText(selectedText: string): string {
const ref = getSourceReference();
if (!ref) return selectedText;
let source = `\n\nSource:\n${ref.title}`;
if (ref.detail) source += `\n${ref.detail}`;
source += `\n${ref.url}`;
return selectedText + source;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Position = { x: number; y: number };
type ToolbarAction = {
key: string;
label: string;
icon: ReactNode;
onClick: () => void;
className?: string;
};
// ---------------------------------------------------------------------------
// Hook: useMediaQuery
// ---------------------------------------------------------------------------
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
// ---------------------------------------------------------------------------
// Hook: copy to clipboard with success feedback
// ---------------------------------------------------------------------------
function useCopyFeedback() {
const [copied, setCopied] = useState(false);
const timerRef = useRef | null>(null);
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} catch {
// Fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
}
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return { copied, copy };
}
// ---------------------------------------------------------------------------
// Pseudo collection data (placeholder for future backend)
// ---------------------------------------------------------------------------
const PSEUDO_COLLECTIONS = [
{ id: "favorites", name: "Favorites", count: 12 },
{ id: "study-notes", name: "Study Notes", count: 8 },
{ id: "share-worthy", name: "Share Worthy", count: 23 },
{ id: "memorize", name: "To Memorize", count: 5 },
{ id: "daily-reading", name: "Daily Reading", count: 17 },
];
// ---------------------------------------------------------------------------
// Share Drawer/Modal
// ---------------------------------------------------------------------------
function ShareModal({
open,
onClose,
selectedText,
isMobile,
}: {
open: boolean;
onClose: () => void;
selectedText: string;
isMobile: boolean;
}) {
const { copied, copy } = useCopyFeedback();
const shareOptions = [
{
key: "feed",
label: "Share to Feed",
icon: ,
description: "Post this quote to your feed",
onClick: () => {
// TODO: Implement share to feed
onClose();
},
},
{
key: "link",
label: "Share Link",
icon: ,
description: "Copy a shareable link",
onClick: () => {
copy(window.location.href);
},
},
];
if (isMobile) {
return (
!o && onClose()}>
Share
{selectedText && (
“{selectedText.slice(0, 120)}
{selectedText.length > 120 ? "…" : ""}”
)}
{shareOptions.map((opt) => (
))}
);
}
// Desktop modal
if (!open) return null;
return createPortal(
<>
Share
{selectedText && (
“{selectedText.slice(0, 120)}
{selectedText.length > 120 ? "…" : ""}”
)}
{shareOptions.map((opt) => (
))}
>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Collections Drawer
// ---------------------------------------------------------------------------
function CollectionsDrawer({
open,
onClose,
isMobile,
}: {
open: boolean;
onClose: () => void;
isMobile: boolean;
}) {
const direction = isMobile ? "bottom" : "right";
return (
!o && onClose()}
direction={direction}
>
Save to Collection
{PSEUDO_COLLECTIONS.map((col) => (
))}
{/* Create new collection */}
);
}
// ---------------------------------------------------------------------------
// More Popover
// ---------------------------------------------------------------------------
function MorePopover({
open,
position,
onClose,
onBookmark,
onSaveToCollection,
onMention,
isMobile,
}: {
open: boolean;
position: Position;
onClose: () => void;
onBookmark: () => void;
onSaveToCollection: () => void;
onMention: () => void;
isMobile: boolean;
}) {
const ref = useRef(null);
const { copied, copy } = useCopyFeedback();
useEffect(() => {
if (!open || isMobile) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleClick);
}, 10);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleClick);
};
}, [open, onClose, isMobile]);
if (!open) return null;
const actions = [
{
key: "bookmark",
label: "Add Bookmark",
description: "Save your place",
icon: ,
onClick: onBookmark,
},
{
key: "collection",
label: "Save to Collection",
description: "Organize into a collection",
icon: ,
onClick: onSaveToCollection,
},
{
key: "copy-link",
label: copied ? "Link Copied!" : "Copy Link",
description: "Copy a link to this page",
icon: copied ? (
) : (
),
onClick: () => copy(window.location.href),
},
{
key: "mention",
label: "Mention",
description: "Tag someone with this quote",
icon: ,
onClick: onMention,
},
];
// Mobile: bottom drawer
if (isMobile) {
return (
!o && onClose()}>
More Options
{actions.map((action) => (
))}
);
}
// Desktop: floating popover
const popoverW = 220;
const popoverH = actions.length * 44 + 8;
const pad = 8;
let popLeft = position.x;
let popTop = position.y + 8;
if (popLeft + popoverW > window.innerWidth - pad) {
popLeft = window.innerWidth - popoverW - pad;
}
if (popLeft < pad) {
popLeft = pad;
}
if (popTop + popoverH > window.innerHeight - pad) {
popTop = position.y - popoverH - 8;
}
if (popTop < pad) {
popTop = pad;
}
const style: React.CSSProperties = {
position: "fixed",
zIndex: 90,
left: popLeft,
top: popTop,
};
return createPortal(
{actions.map((action) => (
))}
,
document.body,
);
}
// ---------------------------------------------------------------------------
// Floating Toolbar
// ---------------------------------------------------------------------------
function FloatingToolbar({
position,
actions,
isMobile,
}: {
position: Position;
actions: ToolbarAction[];
isMobile: boolean;
}) {
if (isMobile) {
// Mobile: fixed bottom bar
return createPortal(
e.preventDefault()}
>
{actions.map((action) => (
))}
,
document.body,
);
}
// Desktop: floating above the selection
const toolbarWidth = actions.length * 44 + 8;
const left = Math.max(
8,
Math.min(
position.x - toolbarWidth / 2,
window.innerWidth - toolbarWidth - 8,
),
);
const top = Math.max(8, position.y - 48);
return createPortal(
e.preventDefault()}
>
{actions.map((action) => (
))}
,
document.body,
);
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function TextSelectionToolbar() {
const [selectedText, setSelectedText] = useState("");
const [toolbarPos, setToolbarPos] = useState(null);
const [showMore, setShowMore] = useState(false);
const [morePos, setMorePos] = useState({ x: 0, y: 0 });
const [showShare, setShowShare] = useState(false);
const [showCollections, setShowCollections] = useState(false);
const { copied, copy } = useCopyFeedback();
const isMobile = useMediaQuery("(max-width: 767px)");
// Track whether the user is interacting with our toolbar/popovers
const interactingRef = useRef(false);
const clearSelection = useCallback(() => {
setSelectedText("");
setToolbarPos(null);
setShowMore(false);
}, []);
// Listen for selection changes
useEffect(() => {
function handleSelectionChange() {
// Don't dismiss while interacting with toolbar menus
if (interactingRef.current || showMore || showShare || showCollections) {
return;
}
const sel = window.getSelection();
const text = sel?.toString().trim() ?? "";
if (text.length < 2) {
clearSelection();
return;
}
// Check that the selection is inside a content area (not in nav, sidebar, inputs, etc.)
const anchor = sel?.anchorNode;
if (!anchor) {
clearSelection();
return;
}
const el =
anchor.nodeType === Node.ELEMENT_NODE
? (anchor as Element)
: anchor.parentElement;
if (!el) {
clearSelection();
return;
}
// Don't show toolbar for selections inside inputs, textareas, or toolbar itself
const tag = el.closest(
"input, textarea, select, [data-selection-toolbar], [data-slot=card-header], nav, header, aside, [role=dialog]",
);
if (tag) {
clearSelection();
return;
}
// Must be inside main content area
const isInContent = el.closest(
"main, [data-slot=card-content], article, .prose, [data-content-area]",
);
if (!isInContent) {
clearSelection();
return;
}
// Get the position of the selection
const range = sel?.getRangeAt(0);
if (!range) {
clearSelection();
return;
}
const rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
clearSelection();
return;
}
setSelectedText(text);
setToolbarPos({
x: rect.left + rect.width / 2,
y:
rect.top + window.scrollY - window.scrollY + rect.top >= 60
? rect.top
: rect.bottom,
});
}
function handleMouseUp() {
// Small delay to let the selection finalize
setTimeout(handleSelectionChange, 10);
}
function handleTouchEnd() {
// Longer delay on mobile for selection handles
setTimeout(handleSelectionChange, 300);
}
function handleKeyUp(e: KeyboardEvent) {
if (e.key === "Escape") {
clearSelection();
window.getSelection()?.removeAllRanges();
return;
}
// Shift+arrow keys can change selection
if (e.shiftKey) {
setTimeout(handleSelectionChange, 10);
}
}
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchend", handleTouchEnd);
document.addEventListener("keyup", handleKeyUp);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchend", handleTouchEnd);
document.removeEventListener("keyup", handleKeyUp);
};
}, [clearSelection, showMore, showShare, showCollections]);
// Intercept native Ctrl+C / Cmd+C to append source reference
useEffect(() => {
function handleCopy(e: ClipboardEvent) {
const sel = window.getSelection();
const text = sel?.toString().trim() ?? "";
if (text.length < 2) return;
// Check that selection is inside a content area
const anchor = sel?.anchorNode;
if (!anchor) return;
const el =
anchor.nodeType === Node.ELEMENT_NODE
? (anchor as Element)
: anchor.parentElement;
if (!el) return;
// Skip if inside inputs, textareas, or non-content areas
const inInput = el.closest("input, textarea, select, [role=dialog]");
if (inInput) return;
const inContent = el.closest(
"main, [data-slot=card-content], article, .prose, [data-content-area]",
);
if (!inContent) return;
// Skip RTF viewer
if (window.location.pathname.startsWith("/rtfs/")) return;
// Prevent default and write formatted text with source reference
e.preventDefault();
const formatted = formatCopyText(text);
e.clipboardData?.setData("text/plain", formatted);
}
document.addEventListener("copy", handleCopy);
return () => document.removeEventListener("copy", handleCopy);
}, []);
// Close everything when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
const target = e.target as Element;
if (target.closest("[data-selection-toolbar]")) return;
if (showMore || showShare || showCollections) return;
// If no text is selected anymore, dismiss
const sel = window.getSelection();
const text = sel?.toString().trim() ?? "";
if (text.length < 2) {
clearSelection();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [clearSelection, showMore, showShare, showCollections]);
// Don't render if nothing selected or if we're on the RTF viewer
if (!toolbarPos || !selectedText) return null;
// Check if current path is an RTF viewer
if (
typeof window !== "undefined" &&
window.location.pathname.startsWith("/rtfs/")
) {
return null;
}
const toolbarActions: ToolbarAction[] = [
{
key: "ask",
label: "Ask Kautilya",
icon: ,
onClick: () => {
// TODO: Implement Ask Kautilya — open AI companion with the selected text
clearSelection();
},
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
},
{
key: "share",
label: "Share",
icon: ,
onClick: () => {
interactingRef.current = true;
setShowShare(true);
},
},
{
key: "copy",
label: copied ? "Copied!" : "Copy",
icon: copied ? (
) : (
),
onClick: () => {
copy(formatCopyText(selectedText));
},
className: copied
? "text-green-400 bg-green-400/15"
: "text-white/70 hover:bg-white/10 hover:text-white",
},
{
key: "more",
label: "More",
icon: ,
onClick: () => {
interactingRef.current = true;
setMorePos({
x: toolbarPos.x + 60,
y: toolbarPos.y,
});
setShowMore(true);
},
},
];
return (
{/* Floating toolbar */}
{/* More popover */}
{
setShowMore(false);
interactingRef.current = false;
}}
onBookmark={() => {
// TODO: Implement bookmark
setShowMore(false);
interactingRef.current = false;
}}
onSaveToCollection={() => {
setShowMore(false);
setShowCollections(true);
}}
onMention={() => {
// TODO: Implement mention
setShowMore(false);
interactingRef.current = false;
}}
/>
{/* Share modal/drawer */}
{
setShowShare(false);
interactingRef.current = false;
}}
selectedText={selectedText}
isMobile={isMobile}
/>
{/* Collections drawer */}
{
setShowCollections(false);
interactingRef.current = false;
}}
isMobile={isMobile}
/>
);
}