feat: social graph features

This commit is contained in:
gosangam
2026-06-04 19:30:50 +05:30
parent 0f433c5708
commit 684ea2ae97
16 changed files with 2504 additions and 119 deletions
+93 -50
View File
@@ -30,9 +30,26 @@ import {
DrawerTitle,
} from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import type { Post } from "@/lib/social";
import {
posts,
relationships,
type Post,
type PostReportCategory,
} from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { ReportDialog, type ReportOption } from "@/components/social/report-dialog";
// Post report categories — mirrors the BFF allow-list (postReportCategories).
const POST_REPORT_OPTIONS: ReportOption[] = [
{ value: "spam", label: "Spam or misleading" },
{ value: "harassment", label: "Harassment or abuse" },
{ value: "misinformation", label: "False information" },
{ value: "inappropriate", label: "Inappropriate content" },
{ value: "other", label: "Something else" },
];
type MenuItem = {
key: string;
@@ -54,8 +71,11 @@ export function PostCardMenu({
onDelete?: (id: string) => void;
}) {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const isMobile = useMediaQuery("(max-width: 767px)");
const [open, setOpen] = useState(false);
const [blockOpen, setBlockOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [popPos, setPopPos] = useState<{ top: number; left: number } | null>(
@@ -147,16 +167,15 @@ export function PostCardMenu({
});
}
} else {
if (isAuthed) {
// Block needs the author's handle (the block API is keyed by handle).
if (isAuthed && authorHandle) {
items.push({
key: "block",
label: `Block ${authorDisplay}`,
icon: <Ban className="size-4" />,
onClick: () => {
setOpen(false);
toast(`Blocking ${authorDisplay} is coming soon`, {
duration: 2500,
});
setBlockOpen(true);
},
});
}
@@ -166,7 +185,11 @@ export function PostCardMenu({
icon: <Flag className="size-4" />,
onClick: () => {
setOpen(false);
toast("Post reporting is coming soon", { duration: 2500 });
// Reporting requires auth (the BFF route is protected). Gate
// anonymous viewers through the login modal, then open the form.
void (async () => {
if (await requireAuth()) setReportOpen(true);
})();
},
});
}
@@ -193,54 +216,74 @@ export function PostCardMenu({
</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>
</>
);
}
// Menu surface: bottom drawer on mobile, floating popover on desktop.
const menu = isMobile ? (
<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>
) : 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;
// 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}
{menu}
{/* Block confirm — only meaningful when we know the author's handle. */}
{authorHandle ? (
<ConfirmModal
open={blockOpen}
onClose={() => setBlockOpen(false)}
title={`Block ${authorDisplay}?`}
message="They won't be able to follow you or see that you follow them, and any follow between you will be removed. You can unblock them later."
confirmLabel="Block"
onConfirm={async () => {
await relationships.block(authorHandle);
toast(`Blocked ${authorDisplay}`, { duration: 2500 });
}}
/>
) : null}
<ReportDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
title="Report post"
options={POST_REPORT_OPTIONS}
onSubmit={async (category, reason) => {
await posts.report(post.id, category as PostReportCategory, reason);
toast("Thanks — your report was submitted", { duration: 3000 });
}}
/>
</>
);
}