162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* MorePopover — generic secondary-action menu for the selection toolbar.
|
|
*
|
|
* The parent (`TextSelectionToolbar`) owns all side effects and builds
|
|
* the appropriate `items` array based on auth state. Items can be marked
|
|
* `locked` to render a small Lock badge — used for the public flow
|
|
* where every More action sits behind the auth wall.
|
|
*/
|
|
|
|
import { type ReactNode, useEffect, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Lock } from "lucide-react";
|
|
import { type Position } from "./shared";
|
|
import {
|
|
Drawer,
|
|
DrawerClose,
|
|
DrawerContent,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
} from "@/components/ui/drawer";
|
|
|
|
export type MoreItem = {
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
icon: ReactNode;
|
|
onClick: () => void;
|
|
/** Show a lock badge — indicates the action is behind the auth wall. */
|
|
locked?: boolean;
|
|
};
|
|
|
|
export function MorePopover({
|
|
open,
|
|
position,
|
|
onClose,
|
|
items,
|
|
isMobile,
|
|
}: {
|
|
open: boolean;
|
|
position: Position;
|
|
onClose: () => void;
|
|
items: MoreItem[];
|
|
isMobile: boolean;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open || isMobile) return;
|
|
function handleClick(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
}
|
|
// Delay so the click that opened us doesn't immediately fire it.
|
|
const timer = setTimeout(() => {
|
|
document.addEventListener("mousedown", handleClick);
|
|
}, 10);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
document.removeEventListener("mousedown", handleClick);
|
|
};
|
|
}, [open, onClose, isMobile]);
|
|
|
|
if (!open) return null;
|
|
|
|
// 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">
|
|
{items.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
item.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-10 rounded-lg bg-white/10 text-white/60">
|
|
{item.icon}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-white/90 truncate">
|
|
{item.label}
|
|
</p>
|
|
<p className="text-xs text-white/50 truncate">
|
|
{item.description}
|
|
</p>
|
|
</div>
|
|
{item.locked ? (
|
|
<Lock className="size-3.5 text-white/40 shrink-0" />
|
|
) : null}
|
|
</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 anchored to `position`, flipping when it
|
|
// would overflow the viewport.
|
|
const popoverW = 260;
|
|
const popoverH = items.length * 56 + 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;
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={ref}
|
|
style={{ position: "fixed", zIndex: 90, left: popLeft, top: popTop }}
|
|
className="w-64 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"
|
|
>
|
|
{items.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
item.onClick();
|
|
}}
|
|
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-white/10 transition-colors"
|
|
>
|
|
<span className="text-white/60 mt-0.5">{item.icon}</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm text-white/90 truncate">
|
|
{item.label}
|
|
</span>
|
|
<span className="block text-xs text-white/50 truncate">
|
|
{item.description}
|
|
</span>
|
|
</span>
|
|
{item.locked ? (
|
|
<Lock className="size-3.5 text-white/40 shrink-0 mt-1" />
|
|
) : null}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|