diff --git a/app/layout.tsx b/app/layout.tsx index 19ffeffd..5e791069 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ThemeProvider } from "@/components/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; - import { SiteFooter } from "@/components/site-footer"; +import { TextSelectionToolbar } from "@/components/text-selection-toolbar"; import "./globals.css"; const geistSans = Geist({ @@ -42,6 +42,7 @@ export default function RootLayout({ > {children} + diff --git a/components/text-selection-toolbar.tsx b/components/text-selection-toolbar.tsx new file mode 100644 index 00000000..a9bb238b --- /dev/null +++ b/components/text-selection-toolbar.tsx @@ -0,0 +1,1059 @@ +"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" + ) { + // Try to get the title from the h1 + const h1 = document.querySelector("h1"); + const title = h1?.textContent?.trim() || "Letter"; + return { title: "Letters of Śrīla Prabhupāda", detail: title, 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, +}: { + open: boolean; + position: Position; + onClose: () => void; + onBookmark: () => void; + onSaveToCollection: () => void; + onMention: () => void; +}) { + const ref = useRef(null); + const { copied, copy } = useCopyFeedback(); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose(); + } + } + // Delay to avoid the same click that opened it from immediately closing + const timer = setTimeout(() => { + document.addEventListener("mousedown", handleClick); + }, 10); + return () => { + clearTimeout(timer); + document.removeEventListener("mousedown", handleClick); + }; + }, [open, onClose]); + + if (!open) return null; + + const actions = [ + { + key: "bookmark", + label: "Add Bookmark", + icon: , + onClick: onBookmark, + }, + { + key: "collection", + label: "Save to Collection", + icon: , + onClick: onSaveToCollection, + }, + { + key: "copy-link", + label: copied ? "Link Copied!" : "Copy Link", + icon: copied ? ( + + ) : ( + + ), + onClick: () => copy(window.location.href), + }, + { + key: "mention", + label: "Mention", + icon: , + onClick: onMention, + }, + ]; + + // Position the popover — clamp to all 4 viewport edges + const popoverW = 192; // w-48 = 12rem = 192px + const popoverH = actions.length * 36 + 8; // approximate height + const pad = 8; + + let popLeft = position.x; + let popTop = position.y + 8; + + // Right edge + if (popLeft + popoverW > window.innerWidth - pad) { + popLeft = window.innerWidth - popoverW - pad; + } + // Left edge + if (popLeft < pad) { + popLeft = pad; + } + // Bottom edge — if it overflows, place above the trigger instead + if (popTop + popoverH > window.innerHeight - pad) { + popTop = position.y - popoverH - 8; + } + // Top edge + 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, +}: { + position: Position; + actions: ToolbarAction[]; +}) { + // Calculate position — center above the selection, clamp to viewport + const toolbarWidth = actions.length * 44 + 8; // approximate + 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()} // prevent losing selection + > + {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} + /> +
+ ); +}