feat(admin): show post preview in reports — images, caption, pet, counts

This commit is contained in:
admin committed 2026-06-21 16:10:01 +02:00
1 parent e2d14654c8
commit 0f34a2c9c6
2 files changed
+165 -50

No files matched your search

+131 -48
View File
@@ -3,7 +3,8 @@
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 { getMediaUrl } from "@/lib/media-url";
import { Flag, Trash2, X, Loader2, CheckCheck, MessageCircle, Heart, ImageOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -79,71 +80,151 @@ export default function AdminReportsPage() {
<p className="text-sm">Keine offenen Reports — alles sauber.</p>
</div>
) : (
<div className="space-y-2">
<div className="space-y-3">
{reports.map((report) => (
<div
key={report.id}
className="flex items-start gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden"
>
<Flag className="h-4 w-4 text-yellow-400 shrink-0 mt-0.5" />
{/* Report header */}
<div className="flex items-start gap-3 px-4 py-3">
<Flag className="h-4 w-4 text-yellow-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="border-yellow-600 text-yellow-400 text-[10px]">
{REASON_LABELS[report.reason] ?? report.reason}
</Badge>
<span className="text-xs text-zinc-500">
von <span className="text-zinc-300">{report.reporter.name}</span>
</span>
<span className="text-xs text-zinc-600">
{new Date(report.createdAt).toLocaleDateString("de-DE")}
</span>
</div>
{report.notes && (
<p className="text-sm text-zinc-300 leading-snug">{report.notes}</p>
)}
<div className="flex gap-3 text-xs text-zinc-600 flex-wrap">
{report.targetPostId && (
<span>Post: <code className="text-zinc-500">{report.targetPostId}</code></span>
)}
{report.targetPetId && (
<span>Pet: <code className="text-zinc-500">{report.targetPetId}</code></span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="border-yellow-600 text-yellow-400 text-[10px]">
{REASON_LABELS[report.reason] ?? report.reason}
</Badge>
<span className="text-xs text-zinc-500">
gemeldet von{" "}
<span className="text-zinc-300 font-medium">{report.reporter.name}</span>
</span>
<span className="text-xs text-zinc-600">
{new Date(report.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
{report.notes && (
<p className="text-sm text-zinc-300 mt-1 leading-snug">
&ldquo;{report.notes}&rdquo;
</p>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
title="Report ignorieren"
className="text-zinc-400 hover:text-zinc-100"
onClick={() => setAction({ kind: "dismiss", reportId: report.id })}
>
<X className="h-4 w-4" />
</Button>
{report.targetPostId && (
{/* Actions */}
<div className="flex gap-1.5 shrink-0">
<Button
size="sm"
variant="ghost"
title="Post löschen"
className="text-red-400 hover:text-red-300"
onClick={() =>
setAction({ kind: "deletePost", postId: report.targetPostId! })
}
title="Report ignorieren"
className="text-zinc-400 hover:text-zinc-100 h-8 w-8 p-0"
onClick={() => setAction({ kind: "dismiss", reportId: report.id })}
>
<Trash2 className="h-4 w-4" />
<X className="h-4 w-4" />
</Button>
)}
{report.targetPostId && (
<Button
size="sm"
variant="ghost"
title="Post löschen"
className="text-red-400 hover:text-red-300 h-8 w-8 p-0"
onClick={() =>
setAction({ kind: "deletePost", postId: report.targetPostId! })
}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Post preview */}
{report.post ? (
<div className="border-t border-zinc-800 mx-4 mb-3 pt-3 flex gap-3">
{/* Image strip */}
<div className="flex gap-1 shrink-0">
{report.post.images.length > 0 ? (
report.post.images.map((img, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={getMediaUrl(img.storageKey) ?? ""}
alt=""
className="h-20 w-20 rounded-lg object-cover"
/>
))
) : (
<div className="h-20 w-20 rounded-lg bg-zinc-800 flex items-center justify-center">
<ImageOff className="h-6 w-6 text-zinc-600" />
</div>
)}
</div>
{/* Post meta */}
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div className="space-y-1">
{/* Pet */}
<div className="flex items-center gap-1.5">
{report.post.pet.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(report.post.pet.avatarKey) ?? ""}
alt=""
className="h-5 w-5 rounded-full object-cover"
/>
) : (
<div className="h-5 w-5 rounded-full bg-zinc-700" />
)}
<span className="text-xs font-medium text-zinc-300">
{report.post.pet.name}
</span>
<span className="text-xs text-zinc-600">
{new Date(report.post.createdAt).toLocaleDateString("de-DE")}
</span>
</div>
{/* Caption */}
{report.post.caption ? (
<p className="text-sm text-zinc-200 leading-snug line-clamp-3">
{report.post.caption}
</p>
) : (
<p className="text-xs text-zinc-600 italic">Kein Caption</p>
)}
</div>
{/* Counts */}
<div className="flex gap-3 text-xs text-zinc-500 mt-1">
<span className="flex items-center gap-1">
<Heart className="h-3 w-3" />
{report.post._count.reactions}
</span>
<span className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
{report.post._count.comments}
</span>
</div>
</div>
</div>
) : report.targetPetId ? (
<div className="border-t border-zinc-800 mx-4 mb-3 pt-2">
<p className="text-xs text-zinc-500">
Profil-Report — Pet-ID:{" "}
<code className="text-zinc-400">{report.targetPetId}</code>
</p>
</div>
) : null}
</div>
))}
</div>
)}
{/* Confirm dialog */}
<Dialog open={!!action} onOpenChange={(o) => { if (!o) closeDialog(); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
@@ -179,7 +260,9 @@ export default function AdminReportsPage() {
variant={action?.kind === "deletePost" ? "destructive" : "default"}
onClick={confirm}
disabled={isPending}
className={action?.kind === "dismiss" ? "bg-zinc-700 hover:bg-zinc-600 text-zinc-100" : ""}
className={
action?.kind === "dismiss" ? "bg-zinc-700 hover:bg-zinc-600 text-zinc-100" : ""
}
>
{isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{action?.kind === "dismiss" ? "Ignorieren" : "Post löschen"}
+34 -2
View File
@@ -210,13 +210,45 @@ export const adminRouter = router({
// ─── Reports ─────────────────────────────────────────────────────────────────
listReports: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.report.findMany({
const reports = await ctx.prisma.report.findMany({
include: {
reporter: { select: { id: true, name: true } },
reporter: { select: { id: true, name: true, avatarKey: true } },
},
orderBy: { createdAt: "desc" },
take: 100,
});
// targetPostId is a raw string — no Prisma relation, so fetch posts separately
const postIds = [...new Set(
reports.map((r) => r.targetPostId).filter((id): id is string => !!id)
)];
const posts = postIds.length > 0
? await ctx.prisma.post.findMany({
where: { id: { in: postIds } },
select: {
id: true,
caption: true,
type: true,
createdAt: true,
pet: { select: { id: true, name: true, avatarKey: true } },
images: {
orderBy: { position: "asc" },
take: 4,
select: { storageKey: true, position: true },
},
_count: { select: { reactions: true, comments: true } },
},
})
: [];
const postMap = new Map(posts.map((p) => [p.id, p]));
return reports.map((r) => ({
...r,
post: r.targetPostId ? (postMap.get(r.targetPostId) ?? null) : null,
}));
}),
dismissReport: protectedProcedure