text highlight and og image
This commit is contained in:
@@ -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",
|
||||
}}
|
||||
>
|
||||
“
|
||||
</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,
|
||||
},
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
“{selectedText.slice(0, 120)}
|
||||
{selectedText.length > 120 ? "…" : ""}”
|
||||
</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">
|
||||
“{selectedText.slice(0, 120)}
|
||||
{selectedText.length > 120 ? "…" : ""}”
|
||||
</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,
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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
@@ -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ā",
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user