"use client"; import { useState, useEffect, useRef, useCallback, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { Share2, Copy, MoreHorizontal, Bookmark, FolderPlus, Link2, AtSign, X, MessageSquare, ExternalLink, Check, } from "lucide-react"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; // --------------------------------------------------------------------------- // Kautilya Icon // --------------------------------------------------------------------------- function KautilyaIcon({ className }: { className?: string }) { return ( ); } // --------------------------------------------------------------------------- // Source reference from URL // --------------------------------------------------------------------------- const BOOK_NAMES: Record = { bg: "Bhagavad-gītā As It Is", sb: "Śrīmad-Bhāgavatam", cc: "Śrī Caitanya-caritāmṛta", sb1962: "Śrīmad-Bhāgavatam (1962 Edition)", iso: "Śrī Īśopaniṣad", noi: "The Nectar of Instruction", nod: "The Nectar of Devotion", bs: "Śrī Brahma-saṁhitā", mm: "Mukunda-mālā-stotra", nbs: "Nārada-bhakti-sūtra", kb: "Kṛṣṇa, The Supreme Personality of Godhead", tlc: "Teachings of Lord Caitanya", ej: "Easy Journey to Other Planets", ek: "Elevation to Kṛṣṇa Consciousness", mg: "Kṛṣṇa Consciousness: The Matchless Gift", bbd: "Beyond Birth and Death", owk: "On the Way to Kṛṣṇa", rv: "Rāja-vidyā: The King of Knowledge", poy: "The Perfection of Yoga", pqpa: "Perfect Questions, Perfect Answers", ttp: "Teachings of Lord Kapila", ochkm: "On Chanting Hare Kṛṣṇa", hkh: "The Kṛṣṇa Consciousness Handbook", lcl: "Life Comes from Life", lob: "Light of the Bhāgavata", tlk: "Teachings of Lord Kapila", tqk: "Teachings of Queen Kuntī", gg: "Gītār Gān", mog: "Message of Godhead", cbhc: "Śrī Caitanya-bhāgavata Commentary", bk: "Bhagavāner Kathā", pk: "Prabhupāda-Kṛpā", ssr: "The Science of Self-Realization", ds: "Dialectic Spiritualism", sva: "Songs of the Vaiṣṇava Ācāryas", va: "Vedānta-sūtra Transcript", }; const CC_LILAS: Record = { 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} />
); }