feat: image compress before upload
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user