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
@@ -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