text highlight and og image
This commit is contained in:
@@ -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
Reference in New Issue
Block a user