feat: user blocking and content reporting/flagging
- user_blocks table (composite PK), Block/Unblock button on profiles. Blocking severs any existing follow relationship both ways and prevents the blocked party from following, commenting on the blocker's recipes, or the blocker from seeing their comments. - reports table (recipe/comment/user targets, pending/reviewed/ dismissed status). ReportButton on comments, admin review queue at /admin/reports with dismiss/mark-reviewed actions, audit-logged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Report = {
|
||||
id: string;
|
||||
targetType: "recipe" | "comment" | "user";
|
||||
targetId: string;
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
reporterName: string;
|
||||
reporterEmail: string;
|
||||
};
|
||||
|
||||
export function ReportsQueue({ reports }: { reports: Report[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function resolve(id: string, status: "reviewed" | "dismissed") {
|
||||
const res = await fetch(`/api/v1/admin/reports/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error("Failed to update report");
|
||||
return;
|
||||
}
|
||||
toast.success(status === "reviewed" ? "Marked reviewed" : "Dismissed");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (reports.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">No pending reports.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{reports.map((r) => (
|
||||
<div key={r.id} className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="capitalize">{r.targetType}</Badge>
|
||||
<span className="text-xs text-muted-foreground font-mono">{r.targetId}</span>
|
||||
</div>
|
||||
<p className="text-sm">{r.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reported by {r.reporterName} ({r.reporterEmail}) · {new Date(r.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button size="sm" variant="outline" onClick={() => { void resolve(r.id, "dismissed"); }}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { void resolve(r.id, "reviewed"); }}>
|
||||
Mark reviewed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Ban } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function BlockButton({
|
||||
targetUsername,
|
||||
initialBlocked = false,
|
||||
}: {
|
||||
targetUsername: string;
|
||||
initialBlocked?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [blocked, setBlocked] = useState(initialBlocked);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function toggle() {
|
||||
if (!blocked && !confirm(`Block @${targetUsername}? They won't be able to follow you or comment on your recipes.`)) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${targetUsername}/block`, {
|
||||
method: blocked ? "DELETE" : "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json() as { error?: string };
|
||||
toast.error(err.error ?? "Failed");
|
||||
return;
|
||||
}
|
||||
setBlocked(!blocked);
|
||||
toast.success(blocked ? "Unblocked" : "Blocked");
|
||||
router.refresh();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void toggle(); }}
|
||||
disabled={loading}
|
||||
className={blocked ? "" : "text-destructive hover:text-destructive"}
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
{loading ? "…" : blocked ? "Unblock" : "Block"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CommentReactions } from "@/components/social/comment-reactions";
|
||||
import { ReportButton } from "@/components/social/report-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Comment = {
|
||||
@@ -136,6 +137,9 @@ function CommentItem({
|
||||
<Reply className="h-3 w-3" /> Reply
|
||||
</button>
|
||||
)}
|
||||
{currentUserId && !isOwn && (
|
||||
<ReportButton targetType="comment" targetId={comment.id} />
|
||||
)}
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={deleteComment}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Flag } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function ReportButton({
|
||||
targetType,
|
||||
targetId,
|
||||
variant = "icon",
|
||||
}: {
|
||||
targetType: "recipe" | "comment" | "user";
|
||||
targetId: string;
|
||||
variant?: "icon" | "text";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!reason.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/reports", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetType, targetId, reason: reason.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as { error?: string };
|
||||
toast.error(err.error ?? "Failed to submit report");
|
||||
return;
|
||||
}
|
||||
setSubmitted(true);
|
||||
toast.success("Report submitted — thanks for flagging this");
|
||||
setTimeout(() => setOpen(false), 800);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "icon" ? (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-xs text-muted-foreground hover:text-destructive flex items-center gap-1"
|
||||
>
|
||||
<Flag className="h-3 w-3" /> Report
|
||||
</button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
|
||||
<Flag className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) { setReason(""); setSubmitted(false); } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader><DialogTitle>Report {targetType}</DialogTitle></DialogHeader>
|
||||
{submitted ? (
|
||||
<p className="text-sm text-muted-foreground py-4">Thanks — an admin will review this.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="report-reason">What's wrong with this?</Label>
|
||||
<Textarea
|
||||
id="report-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Spam, harassment, off-topic…"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => { void handleSubmit(); }}
|
||||
disabled={!reason.trim() || submitting}
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit report"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user