281 lines
8.4 KiB
TypeScript
281 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* PostCardMenu — three-dot overflow menu shown in the post header.
|
|
*
|
|
* Items rendered are decided per viewer:
|
|
*
|
|
* • Author (you wrote the post) — only `Delete post`.
|
|
* • Authenticated non-author — `Block @author` and `Report post`.
|
|
* • Anonymous non-author — `Report post` only.
|
|
*
|
|
* The author never sees Block / Report on their own post (you can't
|
|
* block yourself, and self-report doesn't make sense). Block + Report
|
|
* are placeholder coming-soon toasts today; the slot is wired up so
|
|
* shipping the actual flows is a one-line swap each.
|
|
*
|
|
* Trigger renders a dropdown on desktop and a bottom drawer on mobile,
|
|
* matching the SharePostSheet / CollectionsDrawer convention used
|
|
* elsewhere in the post action UI.
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Ban, Flag, MoreVertical, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Drawer,
|
|
DrawerContent,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
} from "@/components/ui/drawer";
|
|
import { cn } from "@/lib/utils";
|
|
import type { Post } from "@/lib/social";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
|
|
|
type MenuItem = {
|
|
key: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
onClick: () => void;
|
|
variant?: "default" | "destructive";
|
|
};
|
|
|
|
export function PostCardMenu({
|
|
post,
|
|
onDelete,
|
|
}: {
|
|
post: Post;
|
|
/** Owner-only: when provided AND the viewer is the author, the menu
|
|
* exposes a Delete option. Parent receives the post id and is
|
|
* expected to confirm + invoke the delete itself (matches the
|
|
* existing /u/me Delete-with-modal pattern). */
|
|
onDelete?: (id: string) => void;
|
|
}) {
|
|
const { user, loading } = useAuth();
|
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
|
const [open, setOpen] = useState(false);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
const [popPos, setPopPos] = useState<{ top: number; left: number } | null>(
|
|
null,
|
|
);
|
|
|
|
const isOwner = !loading && !!user && user.id === post.user_id;
|
|
const isAuthed = !loading && !!user;
|
|
const authorHandle = post.author_handle ?? null;
|
|
const authorDisplay = authorHandle
|
|
? `@${authorHandle}`
|
|
: post.author_name || "this user";
|
|
|
|
// Desktop popover positioning: anchor below the trigger, right-aligned.
|
|
// Recompute every time we open + on resize so a viewport change between
|
|
// renders doesn't strand the popover at a stale coordinate.
|
|
useEffect(() => {
|
|
if (!open || isMobile) {
|
|
setPopPos(null);
|
|
return;
|
|
}
|
|
function recompute() {
|
|
const btn = triggerRef.current;
|
|
if (!btn) return;
|
|
const rect = btn.getBoundingClientRect();
|
|
const popoverW = 240;
|
|
const pad = 8;
|
|
// Right-align the popover to the trigger's right edge — feels
|
|
// natural for an overflow menu sitting in the top-right corner.
|
|
let left = rect.right - popoverW;
|
|
if (left < pad) left = pad;
|
|
if (left + popoverW > window.innerWidth - pad) {
|
|
left = window.innerWidth - popoverW - pad;
|
|
}
|
|
const top = rect.bottom + 4;
|
|
setPopPos({ top, left });
|
|
}
|
|
recompute();
|
|
window.addEventListener("resize", recompute);
|
|
window.addEventListener("scroll", recompute, true);
|
|
return () => {
|
|
window.removeEventListener("resize", recompute);
|
|
window.removeEventListener("scroll", recompute, true);
|
|
};
|
|
}, [open, isMobile]);
|
|
|
|
// Click-outside + Escape to close (desktop only; the drawer's own
|
|
// overlay handles dismissal on mobile).
|
|
useEffect(() => {
|
|
if (!open || isMobile) return;
|
|
function onPointer(e: PointerEvent) {
|
|
const popover = popoverRef.current;
|
|
const trigger = triggerRef.current;
|
|
if (popover && popover.contains(e.target as Node)) return;
|
|
if (trigger && trigger.contains(e.target as Node)) return;
|
|
setOpen(false);
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") setOpen(false);
|
|
}
|
|
// Delay the listener install one tick so the click that opened us
|
|
// doesn't immediately close us in the same task.
|
|
const t = setTimeout(() => {
|
|
document.addEventListener("pointerdown", onPointer);
|
|
document.addEventListener("keydown", onKey);
|
|
}, 0);
|
|
return () => {
|
|
clearTimeout(t);
|
|
document.removeEventListener("pointerdown", onPointer);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [open, isMobile]);
|
|
|
|
// Build the items list AFTER all hooks have run so a conditional
|
|
// early-return doesn't break the rules-of-hooks contract on
|
|
// re-renders where viewer state changes.
|
|
const items: MenuItem[] = [];
|
|
if (isOwner) {
|
|
if (onDelete) {
|
|
items.push({
|
|
key: "delete",
|
|
label: "Delete post",
|
|
icon: <Trash2 className="size-4" />,
|
|
onClick: () => {
|
|
setOpen(false);
|
|
onDelete(post.id);
|
|
},
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} else {
|
|
if (isAuthed) {
|
|
items.push({
|
|
key: "block",
|
|
label: `Block ${authorDisplay}`,
|
|
icon: <Ban className="size-4" />,
|
|
onClick: () => {
|
|
setOpen(false);
|
|
toast(`Blocking ${authorDisplay} is coming soon`, {
|
|
duration: 2500,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
items.push({
|
|
key: "report",
|
|
label: "Report post",
|
|
icon: <Flag className="size-4" />,
|
|
onClick: () => {
|
|
setOpen(false);
|
|
toast("Post reporting is coming soon", { duration: 2500 });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Don't render the trigger when there's literally nothing to click —
|
|
// e.g. an owner-viewer on a surface that doesn't grant delete.
|
|
if (items.length === 0) return null;
|
|
|
|
const trigger = (
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-label="Post options"
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
className={cn(
|
|
"size-8 inline-flex items-center justify-center rounded-full shrink-0",
|
|
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
"transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
)}
|
|
>
|
|
<MoreVertical className="size-4" />
|
|
</button>
|
|
);
|
|
|
|
// Mobile: bottom drawer
|
|
if (isMobile) {
|
|
return (
|
|
<>
|
|
{trigger}
|
|
<Drawer open={open} onOpenChange={(o) => setOpen(o)}>
|
|
<DrawerContent className="bg-card">
|
|
<DrawerHeader className="text-left border-b border-border pb-3">
|
|
<DrawerTitle>Post options</DrawerTitle>
|
|
</DrawerHeader>
|
|
<div className="p-3 space-y-1">
|
|
{items.map((item) => (
|
|
<MenuItemButton key={item.key} item={item} mobile />
|
|
))}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Desktop: floating popover anchored to the trigger.
|
|
return (
|
|
<>
|
|
{trigger}
|
|
{open && popPos
|
|
? createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
role="menu"
|
|
style={{
|
|
position: "fixed",
|
|
top: popPos.top,
|
|
left: popPos.left,
|
|
zIndex: 60,
|
|
}}
|
|
className={cn(
|
|
"w-60 rounded-lg border border-border bg-card shadow-lg py-1",
|
|
"animate-in fade-in-0 zoom-in-95",
|
|
)}
|
|
>
|
|
{items.map((item) => (
|
|
<MenuItemButton key={item.key} item={item} />
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MenuItemButton({
|
|
item,
|
|
mobile = false,
|
|
}: {
|
|
item: MenuItem;
|
|
mobile?: boolean;
|
|
}) {
|
|
const destructive = item.variant === "destructive";
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={item.onClick}
|
|
className={cn(
|
|
"w-full flex items-center gap-3 text-left transition-colors rounded-md",
|
|
mobile ? "px-3 py-3" : "px-3 py-2",
|
|
destructive
|
|
? "text-destructive hover:bg-destructive/10"
|
|
: "text-foreground hover:bg-muted",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"shrink-0",
|
|
destructive ? "text-destructive" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{item.icon}
|
|
</span>
|
|
<span className="text-sm font-medium truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
}
|