From e2d14654c849634c95d69462ebd85e0c09b096bd Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 21 Jun 2026 16:00:33 +0200 Subject: [PATCH] feat(admin): reports page with dismiss and delete-post actions --- src/app/p/[secret]/layout.tsx | 3 +- src/app/p/[secret]/reports/page.tsx | 192 ++++++++++++++++++++++++++++ src/trpc/routers/admin.ts | 39 +++++- 3 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/app/p/[secret]/reports/page.tsx diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx index be28583..92a8165 100644 --- a/src/app/p/[secret]/layout.tsx +++ b/src/app/p/[secret]/layout.tsx @@ -2,7 +2,7 @@ import { notFound, redirect } from "next/navigation"; import { auth } from "@clerk/nextjs/server"; import Link from "next/link"; import { createTRPCCaller } from "@/trpc/server"; -import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText } from "lucide-react"; +import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText, Flag } from "lucide-react"; interface AdminLayoutProps { children: React.ReactNode; @@ -39,6 +39,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps const nav = [ { href: base, label: "Dashboard", Icon: LayoutDashboard }, { href: `${base}/posts`, label: "Posts", Icon: FileText }, + { href: `${base}/reports`, label: "Reports", Icon: Flag }, { href: `${base}/users`, label: "Users", Icon: Users }, { href: `${base}/ads`, label: "Ads", Icon: Megaphone }, { href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck }, diff --git a/src/app/p/[secret]/reports/page.tsx b/src/app/p/[secret]/reports/page.tsx new file mode 100644 index 0000000..ce3889a --- /dev/null +++ b/src/app/p/[secret]/reports/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { Flag, Trash2, X, Loader2, CheckCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; + +const REASON_LABELS: Record = { + SPAM: "Spam", + HARASSMENT: "Belästigung", + INAPPROPRIATE: "Unangemessen", + MISINFORMATION: "Falschinformation", + OTHER: "Sonstiges", +}; + +type ReportAction = + | { kind: "dismiss"; reportId: string } + | { kind: "deletePost"; postId: string }; + +export default function AdminReportsPage() { + const trpc = useTRPC(); + const qc = useQueryClient(); + const [action, setAction] = useState(null); + const [reason, setReason] = useState(""); + + const { data: reports = [], isLoading } = useQuery(trpc.admin.listReports.queryOptions()); + + const invalidate = () => + void qc.invalidateQueries({ queryKey: trpc.admin.listReports.queryKey() }); + + const dismiss = useMutation( + trpc.admin.dismissReport.mutationOptions({ + onSuccess: () => { invalidate(); closeDialog(); }, + }) + ); + + const deletePost = useMutation( + trpc.admin.deleteReportedPost.mutationOptions({ + onSuccess: () => { invalidate(); closeDialog(); }, + }) + ); + + function closeDialog() { + setAction(null); + setReason(""); + } + + function confirm() { + if (!action) return; + if (action.kind === "dismiss") { + dismiss.mutate({ reportId: action.reportId }); + } else { + deletePost.mutate({ postId: action.postId, reason: reason || undefined }); + } + } + + const isPending = dismiss.isPending || deletePost.isPending; + + return ( +
+
+

Reports

+ {reports.length > 0 && ( + {reports.length} + )} +
+ + {isLoading ? ( +
+ +
+ ) : reports.length === 0 ? ( +
+ +

Keine offenen Reports — alles sauber.

+
+ ) : ( +
+ {reports.map((report) => ( +
+ + +
+
+ + {REASON_LABELS[report.reason] ?? report.reason} + + + von {report.reporter.name} + + + {new Date(report.createdAt).toLocaleDateString("de-DE")} + +
+ + {report.notes && ( +

{report.notes}

+ )} + +
+ {report.targetPostId && ( + Post: {report.targetPostId} + )} + {report.targetPetId && ( + Pet: {report.targetPetId} + )} +
+
+ +
+ + + {report.targetPostId && ( + + )} +
+
+ ))} +
+ )} + + { if (!o) closeDialog(); }}> + + + + {action?.kind === "dismiss" ? "Report ignorieren" : "Post löschen"} + + + + {action?.kind === "dismiss" ? ( +

+ Den Report als unbegründet schließen? Der Inhalt bleibt bestehen. +

+ ) : ( + <> +

+ Den gemeldeten Post dauerhaft löschen? Alle zugehörigen Reports werden damit + ebenfalls geschlossen. +

+ setReason(e.target.value)} + className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" + /> + + )} + + + + + +
+
+
+ ); +} diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index 40e225e..2bd16bd 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -213,13 +213,48 @@ export const adminRouter = router({ return ctx.prisma.report.findMany({ include: { reporter: { select: { id: true, name: true } }, - post: { select: { id: true, caption: true, petId: true } }, }, orderBy: { createdAt: "desc" }, - take: 50, + take: 100, }); }), + dismissReport: protectedProcedure + .input(z.object({ reportId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + await assertAdmin(ctx.prisma, ctx.userId); + const report = await ctx.prisma.report.findUnique({ where: { id: input.reportId } }); + if (!report) throw new TRPCError({ code: "NOT_FOUND" }); + await ctx.prisma.report.delete({ where: { id: input.reportId } }); + await ctx.prisma.moderationLog.create({ + data: { + action: "DISMISS_REPORT", + targetType: "REPORT", + targetId: input.reportId, + moderatorId: ctx.userId, + }, + }); + return { success: true }; + }), + + deleteReportedPost: protectedProcedure + .input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() })) + .mutation(async ({ ctx, input }) => { + await assertAdmin(ctx.prisma, ctx.userId); + // Delete the post (cascades to reports, comments, reactions via Prisma schema) + await ctx.prisma.post.delete({ where: { id: input.postId } }); + await ctx.prisma.moderationLog.create({ + data: { + action: "DELETE_POST", + targetType: "POST", + targetId: input.postId, + reason: input.reason ?? null, + moderatorId: ctx.userId, + }, + }); + return { success: true }; + }), + // ─── Advertisements ────────────────────────────────────────────────────────── listAds: protectedProcedure.query(async ({ ctx }) => { await assertAdmin(ctx.prisma, ctx.userId);