text highlight and og image

This commit is contained in:
gosangam
2026-05-06 00:40:24 +05:30
parent 237840148f
commit 0a82a12580
16 changed files with 1611 additions and 890 deletions
+218
View File
@@ -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(
<div
style={{
width: "1200",
height: "630",
display: "flex",
flexDirection: "row",
backgroundColor: "#faf5f0",
fontFamily: "sans-serif",
overflow: "hidden",
position: "relative",
}}
>
{/* Left side — image + branding */}
<div
style={{
width: "460",
height: "630",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-end",
position: "relative",
flexShrink: 0,
}}
>
{/* Decorative quote mark */}
<div
style={{
position: "absolute",
top: "30",
left: "30",
fontSize: "180",
fontWeight: "900",
color: "#e8b4b8",
lineHeight: "1",
display: "flex",
}}
>
&#x201C;
</div>
{/* Prabhupāda image */}
<img
src={imgSrc}
width="420"
height="500"
style={{
objectFit: "contain",
objectPosition: "bottom",
marginBottom: "0",
}}
/>
{/* Branding at the bottom */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingBottom: "24",
marginTop: "-10",
}}
>
<span
style={{
fontSize: "36",
color: "#d4929a",
fontStyle: "italic",
fontWeight: "600",
}}
>
Prabhupāda
</span>
<span
style={{
fontSize: "20",
color: "#d4929a",
fontWeight: "700",
letterSpacing: "2",
marginTop: "-2",
}}
>
says
</span>
</div>
</div>
{/* Right side — content */}
<div
style={{
flex: "1",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "50 60 50 30",
gap: "16",
}}
>
{/* Title */}
<div
style={{
fontSize: "44",
fontWeight: "800",
color: "#2d2418",
lineHeight: "1.15",
display: "flex",
overflow: "hidden",
maxHeight: "102",
}}
>
{displayTitle}
</div>
{/* Detail (chapter, verse, etc.) */}
{displayDetail && (
<div
style={{
fontSize: "22",
fontWeight: "600",
color: "#5a4a3a",
lineHeight: "1.3",
display: "flex",
overflow: "hidden",
maxHeight: "30",
}}
>
{displayDetail}
</div>
)}
{/* Quote text */}
{displayQuote && (
<div
style={{
display: "flex",
flexDirection: "column",
marginTop: "16",
overflow: "hidden",
maxHeight: "180",
}}
>
<div
style={{
fontSize: "24",
color: "#3d3228",
lineHeight: "1.5",
display: "flex",
overflow: "hidden",
}}
>
{displayQuote}
</div>
</div>
)}
{/* Read more */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "6",
marginTop: "16",
}}
>
<span
style={{
fontSize: "18",
fontWeight: "700",
color: "#5a4a3a",
}}
>
Read more at
</span>
<span
style={{
fontSize: "18",
fontWeight: "700",
color: "#c47a5a",
}}
>
prabhupadasays.org
</span>
</div>
</div>
</div>,
{
width: 1200,
height: 630,
},
);
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+7
View File
@@ -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({
<TooltipProvider>
{children}
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<SiteFooter />
</TooltipProvider>
</ThemeProvider>
+15
View File
@@ -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<Metadata> {
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,
}: {
@@ -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 (
<Drawer
open={open}
onOpenChange={(o) => !o && onClose()}
direction={direction}
>
<DrawerContent
direction={direction}
className="bg-black dark:bg-[#1a1a1a] border-white/10"
>
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">Save to Collection</DrawerTitle>
</DrawerHeader>
<div className="overflow-y-auto flex-1 p-4 space-y-1">
{PSEUDO_COLLECTIONS.map((col) => (
<button
key={col.id}
onClick={() => {
// TODO: Implement save to collection
onClose();
}}
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10">
<FolderPlus className="size-4 text-white/50" />
</div>
<span className="text-sm font-medium text-white/90">
{col.name}
</span>
</div>
<span className="text-xs text-white/50 tabular-nums">
{col.count}
</span>
</button>
))}
{/* Create new collection */}
<button
onClick={() => {
// TODO: Implement create collection
onClose();
}}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
<FolderPlus className="size-4 text-amber-400" />
</div>
<span className="text-sm font-medium text-amber-400">
New Collection
</span>
</button>
</div>
</DrawerContent>
</Drawer>
);
}
@@ -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(
<div
className="fixed z-80 bottom-0 inset-x-0 border-t border-white/10 bg-black shadow-2xl px-3 py-2.5 pb-[max(0.75rem,env(safe-area-inset-bottom))] animate-in fade-in-0 slide-in-from-bottom-4 dark:bg-[#1a1a1a] dark:border-white/15"
onMouseDown={(e) => e.preventDefault()}
>
<div className="flex items-center justify-around gap-1">
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.onClick();
}}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"flex flex-col items-center gap-1.5 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors min-w-16 [&_svg]:size-5",
action.className ??
"text-white/70 active:bg-white/10 active:text-white",
)}
>
{action.icon}
<span>{action.label}</span>
</button>
))}
</div>
</div>,
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(
<div
className="fixed z-80 flex items-center gap-1 rounded-xl border border-white/10 bg-black shadow-2xl px-2 py-1.5 animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 dark:bg-[#1a1a1a] dark:border-white/15"
style={{ left, top }}
onMouseDown={(e) => e.preventDefault()}
>
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.onClick();
}}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
action.className ??
"text-white/70 hover:bg-white/10 hover:text-white",
)}
title={action.label}
>
{action.icon}
<span>{action.label}</span>
</button>
))}
</div>,
document.body,
);
}
@@ -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<HTMLDivElement>(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: <Bookmark className="size-5" />,
onClick: onBookmark,
},
{
key: "collection",
label: "Save to Collection",
description: "Organize into a collection",
icon: <FolderPlus className="size-5" />,
onClick: onSaveToCollection,
},
{
key: "copy-link",
label: copied ? "Link Copied!" : "Copy Link",
description: "Copy a link to this page",
icon: copied ? (
<Check className="size-5" />
) : (
<Link2 className="size-5" />
),
onClick: () => copy(buildHighlightUrl(selectedText)),
},
{
key: "mention",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
onClick: onMention,
},
];
// Mobile: bottom drawer
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-black dark:bg-[#1a1a1a] border-white/10">
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">More Options</DrawerTitle>
</DrawerHeader>
<div className="p-4 space-y-1">
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.stopPropagation();
action.onClick();
if (action.key !== "copy-link") {
onClose();
}
}}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-white/10 text-white/60">
{action.icon}
</div>
<div>
<p className="text-sm font-medium text-white/90">
{action.label}
</p>
<p className="text-xs text-white/50">{action.description}</p>
</div>
</button>
))}
</div>
<div className="p-4 pt-0">
<DrawerClose asChild>
<button className="w-full py-2.5 rounded-lg border border-white/15 text-sm font-medium text-white/60 hover:bg-white/10 transition-colors">
Cancel
</button>
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
);
}
// 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(
<div
ref={ref}
style={style}
className="w-55 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
>
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.stopPropagation();
action.onClick();
if (action.key !== "copy-link") {
onClose();
}
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-white/90 hover:bg-white/10 transition-colors text-left"
>
<span className="text-white/50">{action.icon}</span>
{action.label}
</button>
))}
</div>,
document.body,
);
}
@@ -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: <MessageSquare className="size-4" />,
description: "Post this quote to your feed",
onClick: () => {
// TODO: Implement share to feed
onClose();
},
},
{
key: "link",
label: "Share Link",
icon: <ExternalLink className="size-4" />,
description: "Copy a shareable link",
onClick: () => {
copy(buildHighlightUrl(selectedText));
},
},
];
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-black dark:bg-[#1a1a1a] border-white/10">
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">Share</DrawerTitle>
{selectedText && (
<p className="text-sm text-white/50 mt-1 line-clamp-2">
&ldquo;{selectedText.slice(0, 120)}
{selectedText.length > 120 ? "…" : ""}&rdquo;
</p>
)}
</DrawerHeader>
<div className="p-4 space-y-1">
{shareOptions.map((opt) => (
<button
key={opt.key}
onClick={opt.onClick}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15 text-amber-400">
{opt.icon}
</div>
<div>
<p className="text-sm font-medium text-white/90">
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
</p>
<p className="text-xs text-white/50">{opt.description}</p>
</div>
</button>
))}
</div>
<div className="p-4 pt-0">
<DrawerClose asChild>
<button className="w-full py-2.5 rounded-lg border border-white/15 text-sm font-medium text-white/60 hover:bg-white/10 transition-colors">
Cancel
</button>
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
);
}
// Desktop modal
if (!open) return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-black/60 animate-in fade-in-0"
onClick={onClose}
/>
<div className="fixed left-1/2 top-1/2 z-101 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl border border-white/10 bg-black shadow-2xl animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15">
<div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-white">Share</h2>
<button
onClick={onClose}
className="flex items-center justify-center size-8 rounded-lg text-white/50 hover:bg-white/10 hover:text-white transition-colors"
>
<X className="size-4" />
</button>
</div>
{selectedText && (
<div className="px-5 py-3 border-b border-white/10">
<p className="text-sm text-white/50 line-clamp-2">
&ldquo;{selectedText.slice(0, 120)}
{selectedText.length > 120 ? "…" : ""}&rdquo;
</p>
</div>
)}
<div className="p-3 space-y-1">
{shareOptions.map((opt) => (
<button
key={opt.key}
onClick={opt.onClick}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15 text-amber-400">
{opt.icon}
</div>
<div>
<p className="text-sm font-medium text-white/90">
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
</p>
<p className="text-xs text-white/50">{opt.description}</p>
</div>
</button>
))}
</div>
</div>
</>,
document.body,
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";
// ---------------------------------------------------------------------------
// Kautilya Icon
// ---------------------------------------------------------------------------
export function KautilyaIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
className={className}
>
<path d="M205.867,364.795c-5.754,0.608-15.501,1.389-27.658,1.389c-31.076,0-77.727-5.134-112.693-31.359 C34.93,350.329,10.713,375.897,0,388.396c15.983,18.648,62.023,66.389,114.434,66.388c52.577,0,98.505-47.727,114.441-66.377 C223.894,382.593,215.985,373.945,205.867,364.795z" />
<path d="M446.245,334.708c-34.981,26.324-81.723,31.476-112.845,31.476c-11.916,0-21.519-0.75-27.316-1.353 c-10.091,9.129-17.982,17.757-22.959,23.564c15.982,18.648,62.018,66.389,114.43,66.388 c52.578,0.001,98.508-47.726,114.445-66.375C501.269,375.884,476.952,350.206,446.245,334.708z" />
<path d="M198.416,340.733c0.055-0.718,0.109-1.48,0.164-2.27c-27.465-32.128-55.905-77.85-55.905-129.438 c0-4.031,0.189-8.023,0.523-11.979C105.908,176.27,56.889,177.339,36.586,178.9c-1.886,24.489-3.088,90.803,33.973,127.861 C107.615,343.819,173.925,342.617,198.416,340.733z" />
<path d="M313.088,339.308c0.034,0.48,0.068,0.974,0.102,1.426c24.474,1.885,90.793,3.092,127.855-33.972 c37.179-37.176,35.908-103.403,33.99-127.859c-20.149-1.554-68.666-2.629-105.873,17.723c0.357,4.093,0.556,8.224,0.556,12.398 C369.718,261.067,340.775,307.139,313.088,339.308z" />
<path d="M256.194,57.217c-22.488,18.886-88.917,80.701-88.917,151.809c0,71.11,66.429,132.927,88.917,151.811 c22.479-18.876,88.922-80.696,88.922-151.811C345.116,137.918,278.683,76.101,256.194,57.217z" />
</svg>
);
}
// ---------------------------------------------------------------------------
// 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<ReturnType<typeof setTimeout> | 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 },
];
+60
View File
@@ -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 <mark> 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;
}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
/**
* Shared constants used across the app — book names, CC līlā names, etc.
*/
export const BOOK_NAMES: Record<string, string> = {
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<string, string> = {
adi: "Ādi-līlā",
madhya: "Madhya-līlā",
antya: "Antya-līlā",
};
+171
View File
@@ -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",
});
}
+199
View File
@@ -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 <p> 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;
}
+195
View File
@@ -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>,<suffix>,<length>`
* - 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 <mark>
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 <mark>
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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB