feat(admin): show post preview in reports — images, caption, pet, counts
This commit is contained in:
1 parent
e2d14654c8
commit
0f34a2c9c6
2 files changed
+165
-50
No files matched your search
@@ -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">
|
||||
“{report.notes}”
|
||||
</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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user