= {
- 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