diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx new file mode 100644 index 00000000..8063d3f5 --- /dev/null +++ b/app/api/og/route.tsx @@ -0,0 +1,218 @@ +import { ImageResponse } from "next/og"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + + const title = searchParams.get("title") || "Prabhupāda Says"; + const detail = searchParams.get("detail") || ""; + const quote = searchParams.get("quote") || ""; + + // Truncate for readability and to prevent overflow + const displayTitle = title.length > 60 ? title.slice(0, 57) + "..." : title; + const displayDetail = + detail.length > 80 ? detail.slice(0, 77) + "..." : detail; + const displayQuote = quote.length > 220 ? quote.slice(0, 217) + "..." : quote; + + // Load the Prabhupāda image as base64 + const imgData = await readFile( + join(process.cwd(), "public/images/og-left-prabhupada-reading.png"), + "base64", + ); + const imgSrc = `data:image/png;base64,${imgData}`; + + return new ImageResponse( +
+ {/* Left side — image + branding */} +
+ {/* Decorative quote mark */} +
+ “ +
+ + {/* Prabhupāda image */} + + + {/* Branding at the bottom */} +
+ + Prabhupāda + + + says + +
+
+ + {/* Right side — content */} +
+ {/* Title */} +
+ {displayTitle} +
+ + {/* Detail (chapter, verse, etc.) */} + {displayDetail && ( +
+ {displayDetail} +
+ )} + + {/* Quote text */} + {displayQuote && ( +
+
+ {displayQuote} +
+
+ )} + + {/* Read more */} +
+ + Read more at + + + prabhupadasays.org + +
+
+
, + { + width: 1200, + height: 630, + }, + ); +} diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fea..13aeb537 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/layout.tsx b/app/layout.tsx index 5e791069..6e71c131 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,12 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { Geist, Geist_Mono } from "next/font/google"; +import { ogDefault } from "@/lib/og"; 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 { TextHighlighter } from "@/components/text-highlighter"; import "./globals.css"; const geistSans = Geist({ @@ -20,6 +23,7 @@ export const metadata: Metadata = { title: "Prabhupāda Says", description: "Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda", + ...ogDefault(), }; export default function RootLayout({ @@ -43,6 +47,9 @@ export default function RootLayout({ {children} + + + diff --git a/app/letters/[slug]/page.tsx b/app/letters/[slug]/page.tsx index 5f3b55bb..a9c59c28 100644 --- a/app/letters/[slug]/page.tsx +++ b/app/letters/[slug]/page.tsx @@ -1,14 +1,29 @@ export const dynamic = "force-dynamic"; +import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { getLetter, getAdjacentLetters } from "@/lib/letters"; +import { ogForLetter } from "@/lib/og"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { buttonVariants } from "@/components/ui/button"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const letter = await getLetter(slug); + if (!letter) return {}; + + const meta = [letter.date, letter.location].filter(Boolean).join(" · "); + return ogForLetter(letter.recipientDisplay, meta || undefined); +} + export default async function LetterPage({ params, }: { diff --git a/components/selection-toolbar/collections-drawer.tsx b/components/selection-toolbar/collections-drawer.tsx new file mode 100644 index 00000000..81a3e744 --- /dev/null +++ b/components/selection-toolbar/collections-drawer.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { PSEUDO_COLLECTIONS } from "./shared"; +import { FolderPlus } from "lucide-react"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; + +export 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 */} + +
+
+
+ ); +} diff --git a/components/selection-toolbar/floating-toolbar.tsx b/components/selection-toolbar/floating-toolbar.tsx new file mode 100644 index 00000000..d3ef69cb --- /dev/null +++ b/components/selection-toolbar/floating-toolbar.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { type Position, type ToolbarAction } from "./shared"; + +export 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, + ); +} diff --git a/components/selection-toolbar/more-popover.tsx b/components/selection-toolbar/more-popover.tsx new file mode 100644 index 00000000..2b2e8c84 --- /dev/null +++ b/components/selection-toolbar/more-popover.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { useCopyFeedback, type Position } from "./shared"; +import { buildHighlightUrl } from "@/lib/text-highlight"; +import { cn } from "@/lib/utils"; +import { Bookmark, FolderPlus, Link2, AtSign, Check } from "lucide-react"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; + +export function MorePopover({ + open, + position, + onClose, + onBookmark, + onSaveToCollection, + onMention, + isMobile, + selectedText, +}: { + open: boolean; + position: Position; + onClose: () => void; + onBookmark: () => void; + onSaveToCollection: () => void; + onMention: () => void; + isMobile: boolean; + selectedText: string; +}) { + 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(buildHighlightUrl(selectedText)), + }, + { + 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, + ); +} diff --git a/components/selection-toolbar/share-modal.tsx b/components/selection-toolbar/share-modal.tsx new file mode 100644 index 00000000..43195c56 --- /dev/null +++ b/components/selection-toolbar/share-modal.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useCopyFeedback } from "./shared"; +import { buildHighlightUrl } from "@/lib/text-highlight"; +import { createPortal } from "react-dom"; +import { X, MessageSquare, ExternalLink } from "lucide-react"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; + +export 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(buildHighlightUrl(selectedText)); + }, + }, + ]; + + 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, + ); +} diff --git a/components/selection-toolbar/shared.tsx b/components/selection-toolbar/shared.tsx new file mode 100644 index 00000000..7f5b2c27 --- /dev/null +++ b/components/selection-toolbar/shared.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback, type ReactNode } from "react"; + +// --------------------------------------------------------------------------- +// Kautilya Icon +// --------------------------------------------------------------------------- + +export function KautilyaIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Position = { x: number; y: number }; + +export type ToolbarAction = { + key: string; + label: string; + icon: ReactNode; + onClick: () => void; + className?: string; +}; + +// --------------------------------------------------------------------------- +// Hook: useMediaQuery +// --------------------------------------------------------------------------- + +export 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 +// --------------------------------------------------------------------------- + +export function useCopyFeedback() { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + const copy = useCallback(async (text: string) => { + let success = false; + + try { + await navigator.clipboard.writeText(text); + success = true; + } catch { + // Fallback for iOS Safari and older browsers + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { + document.execCommand("copy"); + success = true; + } catch { + // silent fail + } + document.body.removeChild(ta); + } + + if (success) { + 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) +// --------------------------------------------------------------------------- + +export 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 }, +]; diff --git a/components/text-highlighter.tsx b/components/text-highlighter.tsx new file mode 100644 index 00000000..de5520d2 --- /dev/null +++ b/components/text-highlighter.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect } from "react"; +import { useSearchParams } from "next/navigation"; +import { decodeTextFragment, findAndHighlightText } from "@/lib/text-highlight"; + +/** + * TextHighlighter — processes incoming `?hl=` URLs on page load. + * + * When a user visits a URL with `?hl=prefix,suffix,length`, this component: + * 1. Decodes the text fragment from the query param + * 2. Searches the page's content areas for matching text + * 3. Wraps the matched text in a with a highlight animation + * 4. Scrolls to the highlighted text + * 5. Fades the highlight out after a few seconds + * + * This is rendered once in layout.tsx alongside TextSelectionToolbar. + * It only activates when the `hl` search param is present. + */ +export function TextHighlighter() { + const searchParams = useSearchParams(); + const hl = searchParams.get("hl"); + + useEffect(() => { + if (!hl) return; + + const fragment = decodeTextFragment(hl); + if (!fragment) return; + + // Wait for the page content to be fully rendered before searching. + // Use a series of attempts in case content loads asynchronously + // (e.g., client components hydrating, paginated verse loading, etc.) + let attempts = 0; + const maxAttempts = 10; + const interval = 500; // ms between attempts + + function tryHighlight() { + attempts++; + const found = findAndHighlightText(fragment!); + + if (!found && attempts < maxAttempts) { + setTimeout(tryHighlight, interval); + } + + if (found) { + // Clean the URL without triggering a navigation — remove the hl param + // so the user can share a clean URL after viewing + const url = new URL(window.location.href); + url.searchParams.delete("hl"); + const cleanUrl = url.pathname + (url.search || "") + url.hash; + window.history.replaceState({}, "", cleanUrl); + } + } + + // First attempt after a short delay to let the DOM settle + setTimeout(tryHighlight, 300); + }, [hl]); + + return null; +} diff --git a/components/text-selection-toolbar.tsx b/components/text-selection-toolbar.tsx index 657eddef..1c9f4906 100644 --- a/components/text-selection-toolbar.tsx +++ b/components/text-selection-toolbar.tsx @@ -1,848 +1,19 @@ "use client"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { Share2, Copy, MoreHorizontal, Check } from "lucide-react"; +import { formatCopyText } from "@/lib/source-reference"; 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, - ); -} + KautilyaIcon, + useMediaQuery, + useCopyFeedback, + type Position, + type ToolbarAction, +} from "./selection-toolbar/shared"; +import { FloatingToolbar } from "./selection-toolbar/floating-toolbar"; +import { ShareModal } from "./selection-toolbar/share-modal"; +import { CollectionsDrawer } from "./selection-toolbar/collections-drawer"; +import { MorePopover } from "./selection-toolbar/more-popover"; // --------------------------------------------------------------------------- // Main Component @@ -883,7 +54,7 @@ export function TextSelectionToolbar() { return; } - // Check that the selection is inside a content area (not in nav, sidebar, inputs, etc.) + // Check that the selection is inside a content area const anchor = sel?.anchorNode; if (!anchor) { clearSelection(); @@ -925,8 +96,16 @@ export function TextSelectionToolbar() { return; } - const rect = range.getBoundingClientRect(); + // iOS Safari fix: getBoundingClientRect() can return a zero rect + let rect = range.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) { + const rects = range.getClientRects(); + if (rects.length > 0) { + rect = rects[0]; + } + } + + if (!rect || (rect.width === 0 && rect.height === 0)) { clearSelection(); return; } @@ -934,43 +113,54 @@ export function TextSelectionToolbar() { setSelectedText(text); setToolbarPos({ x: rect.left + rect.width / 2, - y: - rect.top + window.scrollY - window.scrollY + rect.top >= 60 - ? rect.top - : rect.bottom, + y: rect.top >= 60 ? rect.top : rect.bottom, }); } - function handleMouseUp() { - // Small delay to let the selection finalize - setTimeout(handleSelectionChange, 10); + // Debounce so the toolbar doesn't flicker during active selection + let debounceTimer: ReturnType | null = null; + + function debouncedCheck(delay: number) { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(handleSelectionChange, delay); } - function handleTouchEnd() { - // Longer delay on mobile for selection handles - setTimeout(handleSelectionChange, 300); + function onSelectionChange() { + debouncedCheck(150); } - function handleKeyUp(e: KeyboardEvent) { + function onMouseUp() { + debouncedCheck(50); + } + + function onTouchEnd() { + debouncedCheck(200); + } + + function onTouchStart() { + interactingRef.current = false; + } + + function onKeyUp(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); + document.addEventListener("selectionchange", onSelectionChange); + document.addEventListener("mouseup", onMouseUp); + document.addEventListener("touchend", onTouchEnd); + document.addEventListener("touchstart", onTouchStart); + document.addEventListener("keyup", onKeyUp); return () => { - document.removeEventListener("mouseup", handleMouseUp); - document.removeEventListener("touchend", handleTouchEnd); - document.removeEventListener("keyup", handleKeyUp); + if (debounceTimer) clearTimeout(debounceTimer); + document.removeEventListener("selectionchange", onSelectionChange); + document.removeEventListener("mouseup", onMouseUp); + document.removeEventListener("touchend", onTouchEnd); + document.removeEventListener("touchstart", onTouchStart); + document.removeEventListener("keyup", onKeyUp); }; }, [clearSelection, showMore, showShare, showCollections]); @@ -978,11 +168,11 @@ export function TextSelectionToolbar() { useEffect(() => { function handleCopy(e: ClipboardEvent) { const sel = window.getSelection(); - const text = sel?.toString().trim() ?? ""; + if (!sel || sel.rangeCount === 0) return; + const text = sel.toString().trim(); if (text.length < 2) return; - // Check that selection is inside a content area - const anchor = sel?.anchorNode; + const anchor = sel.anchorNode; if (!anchor) return; const el = @@ -991,19 +181,15 @@ export function TextSelectionToolbar() { : 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; + if (el.closest("input, textarea, select, [role=dialog]")) 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); @@ -1013,14 +199,15 @@ export function TextSelectionToolbar() { return () => document.removeEventListener("copy", handleCopy); }, []); - // Close everything when clicking outside + // Close toolbar on mouse click outside (desktop only) useEffect(() => { - function handleClick(e: MouseEvent) { + function handlePointerDown(e: PointerEvent) { + if (e.pointerType !== "mouse") return; + 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) { @@ -1028,14 +215,11 @@ export function TextSelectionToolbar() { } } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); }, [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 + // Don't render on RTF viewer or if nothing is selected if ( typeof window !== "undefined" && window.location.pathname.startsWith("/rtfs/") @@ -1043,13 +227,17 @@ export function TextSelectionToolbar() { return null; } + if (!toolbarPos || !selectedText) return null; + + // --- Toolbar actions --- + const toolbarActions: ToolbarAction[] = [ { key: "ask", label: "Ask Kautilya", icon: , onClick: () => { - // TODO: Implement Ask Kautilya — open AI companion with the selected text + // TODO: Implement Ask Kautilya clearSelection(); }, className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300", @@ -1093,26 +281,26 @@ export function TextSelectionToolbar() { }, ]; + // --- Render --- + return (
- {/* Floating toolbar */} - {/* More popover */} { setShowMore(false); interactingRef.current = false; }} onBookmark={() => { - // TODO: Implement bookmark setShowMore(false); interactingRef.current = false; }} @@ -1121,13 +309,11 @@ export function TextSelectionToolbar() { setShowCollections(true); }} onMention={() => { - // TODO: Implement mention setShowMore(false); interactingRef.current = false; }} /> - {/* Share modal/drawer */} { @@ -1138,7 +324,6 @@ export function TextSelectionToolbar() { isMobile={isMobile} /> - {/* Collections drawer */} { diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 00000000..d2eb7b47 --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,48 @@ +/** + * Shared constants used across the app — book names, CC līlā names, etc. + */ + +export 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", +}; + +export const CC_LILAS: Record = { + adi: "Ādi-līlā", + madhya: "Madhya-līlā", + antya: "Antya-līlā", +}; diff --git a/lib/og.ts b/lib/og.ts new file mode 100644 index 00000000..bd548278 --- /dev/null +++ b/lib/og.ts @@ -0,0 +1,171 @@ +/** + * OG Image metadata utilities. + * + * Builds the /api/og URL with appropriate query params for dynamic + * Open Graph image generation. Used by page-level metadata exports. + */ + +import { BOOK_NAMES, CC_LILAS } from "@/lib/constants"; + +const SITE_URL = + process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type OgParams = { + title: string; + detail?: string; + quote?: string; +}; + +// --------------------------------------------------------------------------- +// Build the /api/og image URL +// --------------------------------------------------------------------------- + +/** + * Build a fully-qualified URL to the /api/og image endpoint. + * + * The URL is kept as short as possible — only non-empty params are included. + * The quote is truncated to 260 chars to keep URL length reasonable. + */ +export function buildOgImageUrl(params: OgParams): string { + const url = new URL("/api/og", SITE_URL); + + url.searchParams.set("title", params.title); + + if (params.detail) { + url.searchParams.set("detail", params.detail); + } + + if (params.quote) { + const truncated = + params.quote.length > 260 + ? params.quote.slice(0, 257) + "..." + : params.quote; + url.searchParams.set("quote", truncated); + } + + return url.toString(); +} + +/** + * Build OG metadata object for use in Next.js page `metadata` exports. + * + * Returns the `openGraph` and `twitter` fields to spread into `Metadata`. + */ +export function buildOgMeta(params: OgParams & { description?: string }) { + const imageUrl = buildOgImageUrl(params); + const description = + params.description || + params.quote || + `${params.title}${params.detail ? ` — ${params.detail}` : ""}`; + + return { + openGraph: { + title: params.title, + description, + images: [ + { + url: imageUrl, + width: 1200, + height: 630, + alt: params.title, + }, + ], + siteName: "Prabhupāda Says", + }, + twitter: { + card: "summary_large_image" as const, + title: params.title, + description, + images: [imageUrl], + }, + }; +} + +// --------------------------------------------------------------------------- +// Helpers for common page types +// --------------------------------------------------------------------------- + +/** OG metadata for a book page. */ +export function ogForBook(code: string) { + const title = BOOK_NAMES[code] || code.toUpperCase(); + return buildOgMeta({ title }); +} + +/** OG metadata for a verse page. */ +export function ogForVerse( + code: string, + parts: { division?: string; chapter: number; verse: number }, +) { + const title = BOOK_NAMES[code] || code.toUpperCase(); + let detail: string; + + if (code === "sb" && parts.division) { + detail = `Canto ${parts.division}, Chapter ${parts.chapter}, Verse ${parts.verse}`; + } else if (code === "cc" && parts.division) { + const lila = CC_LILAS[parts.division] || parts.division; + detail = `${lila}, Chapter ${parts.chapter}, Verse ${parts.verse}`; + } else { + detail = `Chapter ${parts.chapter}, Verse ${parts.verse}`; + } + + return buildOgMeta({ title, detail }); +} + +/** OG metadata for a prose chapter. */ +export function ogForChapter(code: string, chapterTitle: string) { + const bookTitle = BOOK_NAMES[code] || code.toUpperCase(); + return buildOgMeta({ title: bookTitle, detail: chapterTitle }); +} + +/** OG metadata for an essay. */ +export function ogForEssay(title: string) { + return buildOgMeta({ + title, + detail: "Essay by Śrīla Prabhupāda", + }); +} + +/** OG metadata for a letter. */ +export function ogForLetter(recipient: string, meta?: string) { + return buildOgMeta({ + title: `Letter to ${recipient}`, + detail: meta || undefined, + }); +} + +/** OG metadata for a journal entry. */ +export function ogForJournal(journalTitle: string, entryTitle?: string) { + return buildOgMeta({ + title: journalTitle, + detail: entryTitle || undefined, + }); +} + +/** OG metadata for a magazine issue. */ +export function ogForMagazine(magazineTitle: string, issueTitle?: string) { + return buildOgMeta({ + title: magazineTitle, + detail: issueTitle || undefined, + }); +} + +/** OG metadata for an other document chapter. */ +export function ogForOtherDocument(docTitle: string, chapterTitle?: string) { + return buildOgMeta({ + title: docTitle, + detail: chapterTitle || undefined, + }); +} + +/** Default OG metadata for the site. */ +export function ogDefault() { + return buildOgMeta({ + title: "Prabhupāda Says", + description: + "Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda", + }); +} diff --git a/lib/source-reference.ts b/lib/source-reference.ts new file mode 100644 index 00000000..5eb8d256 --- /dev/null +++ b/lib/source-reference.ts @@ -0,0 +1,199 @@ +// --------------------------------------------------------------------------- +// Source-reference helpers (browser-only) +// +// These functions access `window` and `document` and therefore can only run in +// the browser. They are intentionally NOT marked "use client" because they are +// plain utility functions, not React components or hooks. Any React component +// that calls them must itself be a client component. +// --------------------------------------------------------------------------- + +import { BOOK_NAMES, CC_LILAS } from "@/lib/constants"; +import { buildHighlightUrl } from "@/lib/text-highlight"; + +/** + * Parse the current page URL into a human-readable source reference. + * Returns { title, detail, url } or null if on a non-content page. + */ +export 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. + */ +export function formatCopyText(selectedText: string): string { + const ref = getSourceReference(); + if (!ref) return selectedText; + + const highlightUrl = buildHighlightUrl(selectedText); + + let source = `\n\nSource:\n${ref.title}`; + if (ref.detail) source += `\n${ref.detail}`; + source += `\n${highlightUrl}`; + + return selectedText + source; +} diff --git a/lib/text-highlight.ts b/lib/text-highlight.ts new file mode 100644 index 00000000..8f049b80 --- /dev/null +++ b/lib/text-highlight.ts @@ -0,0 +1,195 @@ +// --------------------------------------------------------------------------- +// Text Fragment Highlight — compact URL encoding for selected text +// --------------------------------------------------------------------------- + +/** + * Encode a text selection into a compact URL-safe fragment. + * + * Format: `?hl=,,` + * - prefix: first 4 words of the selection + * - suffix: last 4 words of the selection (omitted if same as prefix) + * - length: character count of the full selection (for disambiguation) + * + * This keeps URLs short while being specific enough to locate the text. + */ +export function encodeTextFragment(text: string): string { + const words = text.split(/\s+/).filter(Boolean); + if (words.length === 0) return ""; + + const prefixWords = words.slice(0, 4).join(" "); + const suffixWords = words.length > 6 ? words.slice(-4).join(" ") : ""; + const len = text.length; + + const parts = [prefixWords]; + if (suffixWords) parts.push(suffixWords); + parts.push(String(len)); + + return parts.join(","); +} + +/** + * Decode a text fragment from URL params. + * Returns { prefix, suffix, length } or null if invalid. + */ +export function decodeTextFragment( + hl: string, +): { prefix: string; suffix: string; length: number } | null { + const parts = hl.split(","); + if (parts.length < 2) return null; + + const lengthStr = parts[parts.length - 1]; + const length = parseInt(lengthStr, 10); + if (isNaN(length)) return null; + + if (parts.length === 2) { + // prefix + length only (short selections) + return { prefix: parts[0], suffix: "", length }; + } + + // prefix + suffix + length + const prefix = parts.slice(0, -2).join(","); // in case prefix itself had commas + const suffix = parts[parts.length - 2]; + return { prefix, suffix, length }; +} + +/** + * Build the full URL with highlight fragment for the selected text. + */ +export function buildHighlightUrl(selectedText: string): string { + const fragment = encodeTextFragment(selectedText); + if (!fragment) return window.location.href; + + const url = new URL(window.location.href); + // Remove any existing hl param + url.searchParams.delete("hl"); + url.searchParams.set("hl", fragment); + return url.toString(); +} + +/** + * Find and highlight text in the DOM that matches a decoded text fragment. + * Returns true if found and highlighted. + */ +export function findAndHighlightText(fragment: { + prefix: string; + suffix: string; + length: number; +}): boolean { + // Walk all text nodes in main/article/card-content areas + const contentAreas = document.querySelectorAll( + "main, [data-slot=card-content], article, [data-content-area]", + ); + if (contentAreas.length === 0) return false; + + const allTextNodes: Text[] = []; + contentAreas.forEach((area) => { + const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT); + let node: Text | null; + while ((node = walker.nextNode() as Text | null)) { + if (node.textContent && node.textContent.trim().length > 0) { + allTextNodes.push(node); + } + } + }); + + // Build a concatenated string of all text content with node boundaries + const segments: { node: Text; start: number; end: number }[] = []; + let fullText = ""; + for (const node of allTextNodes) { + const text = node.textContent || ""; + segments.push({ + node, + start: fullText.length, + end: fullText.length + text.length, + }); + fullText += text; + } + + // Normalize whitespace for matching + const normalizedFull = fullText.replace(/\s+/g, " "); + + // Search for the prefix in the full text + const normalizedPrefix = fragment.prefix.replace(/\s+/g, " "); + let searchFrom = 0; + let matchStart = -1; + + while (searchFrom < normalizedFull.length) { + const idx = normalizedFull.indexOf(normalizedPrefix, searchFrom); + if (idx === -1) break; + + // If we have a suffix, verify it appears near the expected length + if (fragment.suffix) { + const normalizedSuffix = fragment.suffix.replace(/\s+/g, " "); + const expectedEnd = idx + fragment.length; + const suffixSearchStart = Math.max( + idx, + expectedEnd - normalizedSuffix.length - 20, + ); + const suffixSearchEnd = Math.min(normalizedFull.length, expectedEnd + 20); + const searchWindow = normalizedFull.slice( + suffixSearchStart, + suffixSearchEnd, + ); + if (searchWindow.includes(normalizedSuffix)) { + matchStart = idx; + break; + } + } else { + // No suffix — just match prefix + approximate length + matchStart = idx; + break; + } + searchFrom = idx + 1; + } + + if (matchStart === -1) return false; + + const matchEnd = Math.min( + matchStart + fragment.length, + normalizedFull.length, + ); + + // Map the match range back to DOM text nodes and wrap with + for (const seg of segments) { + const overlapStart = Math.max(matchStart, seg.start); + const overlapEnd = Math.min(matchEnd, seg.end); + + if (overlapStart >= overlapEnd) continue; + + const node = seg.node; + const text = node.textContent || ""; + const localStart = overlapStart - seg.start; + const localEnd = overlapEnd - seg.start; + + // Split the text node and wrap the matching portion in a + const before = text.slice(0, localStart); + const matched = text.slice(localStart, localEnd); + const after = text.slice(localEnd); + + const parent = node.parentNode; + if (!parent) continue; + + const frag = document.createDocumentFragment(); + if (before) frag.appendChild(document.createTextNode(before)); + + const mark = document.createElement("mark"); + mark.className = "bg-amber-400/30 text-foreground rounded-sm px-0.5"; + mark.setAttribute("data-text-highlight", "true"); + mark.textContent = matched; + frag.appendChild(mark); + + if (after) frag.appendChild(document.createTextNode(after)); + + parent.replaceChild(frag, node); + } + + // Scroll to the first highlight + const firstMark = document.querySelector("[data-text-highlight]"); + if (firstMark) { + setTimeout(() => { + firstMark.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + } + + return true; +} diff --git a/public/images/og-left-prabhupada-reading.png b/public/images/og-left-prabhupada-reading.png new file mode 100644 index 00000000..a28331ac Binary files /dev/null and b/public/images/og-left-prabhupada-reading.png differ