feat(admin): clickable reported posts and color-coded user actions (Gitea #10)

Two follow-up requests on the admin dashboard:

- Reports page: the post preview is now clickable, opening a detail
  dialog with full-size images, the complete comment list (reusing
  the existing admin.listComments query), and a "liked by" list of
  pets — previously moderators only saw a caption snippet and raw
  counts, with no way to inspect what was actually reported. Backed
  by a new admin.getReportedPostDetail query (images + reactions;
  comments intentionally reuse the existing procedure instead of
  duplicating that fetch).
- Users page: Warning/Export/Message buttons get distinct colors
  (amber/blue/sky) matching the existing Ban (red) / Shadowban
  (violet) pattern, instead of all three sharing the same neutral
  gray outline.
This commit is contained in:
admin committed 2026-08-12 18:49:44 +02:00
1 parent 95df270821
commit a35cb6640f
3 files changed
+197 -5

No files matched your search

+160 -2
View File
@@ -37,9 +37,23 @@ export default function AdminReportsPage() {
const qc = useQueryClient();
const [action, setAction] = useState<ReportAction | null>(null);
const [reason, setReason] = useState("");
const [detailPostId, setDetailPostId] = useState<string | null>(null);
const { data: reports = [], isLoading } = useQuery(trpc.admin.listReports.queryOptions());
const { data: detailPost, isLoading: detailLoading } = useQuery(
trpc.admin.getReportedPostDetail.queryOptions(
{ postId: detailPostId ?? "" },
{ enabled: !!detailPostId }
)
);
const { data: detailComments } = useQuery(
trpc.admin.listComments.queryOptions(
{ postId: detailPostId ?? "" },
{ enabled: !!detailPostId }
)
);
const invalidate = () =>
void qc.invalidateQueries({ queryKey: trpc.admin.listReports.queryKey() });
@@ -161,7 +175,12 @@ export default function AdminReportsPage() {
{/* Post preview */}
{report.post ? (
<div className="border-t border-zinc-800 mx-4 mb-3 pt-3 flex gap-3">
<button
type="button"
onClick={() => setDetailPostId(report.post!.id)}
className="border-t border-zinc-800 mx-4 mb-3 pt-3 flex gap-3 w-[calc(100%-2rem)] text-left rounded-lg hover:bg-zinc-800/40 transition-colors"
title="Vollständigen Post ansehen"
>
{/* Image strip */}
<div className="flex gap-1 shrink-0">
{report.post.images.length > 0 ? (
@@ -226,7 +245,7 @@ export default function AdminReportsPage() {
</span>
</div>
</div>
</div>
</button>
) : report.targetPetId ? (
<div className="border-t border-zinc-800 mx-4 mb-3 pt-2">
<p className="text-xs text-zinc-500">
@@ -286,6 +305,145 @@ export default function AdminReportsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reported post detail — full images, who reacted, all comments */}
<Dialog open={!!detailPostId} onOpenChange={(o) => { if (!o) setDetailPostId(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-3xl max-h-[85dvh] p-0 gap-0 overflow-hidden flex flex-col">
<DialogHeader className="px-5 py-4 border-b border-zinc-800 shrink-0">
<DialogTitle>Gemeldeter Post</DialogTitle>
</DialogHeader>
{detailLoading || !detailPost ? (
<div className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : (
<div className="flex flex-col md:flex-row min-h-0 flex-1 overflow-hidden">
{/* Images */}
<div className="md:w-1/2 shrink-0 overflow-y-auto max-h-[35dvh] md:max-h-none bg-black flex flex-col gap-px">
{detailPost.images.length > 0 ? (
detailPost.images.map((img, i) => (
<BlurImage
key={i}
src={getMediaUrl(img.storageKey) ?? ""}
blurhash={img.blurhash}
alt=""
className="w-full h-auto object-contain"
/>
))
) : (
<div className="flex h-64 items-center justify-center">
<ImageOff className="h-8 w-8 text-zinc-600" />
</div>
)}
</div>
{/* Right panel */}
<div className="flex-1 min-w-0 overflow-y-auto p-5 space-y-5">
<div className="space-y-2">
<div className="flex items-center gap-2">
{detailPost.pet.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(detailPost.pet.avatarKey) ?? ""}
alt=""
className="h-6 w-6 rounded-full object-cover"
/>
) : (
<div className="h-6 w-6 rounded-full bg-zinc-700" />
)}
<span className="text-sm font-medium text-zinc-200">{detailPost.pet.name}</span>
<span className="text-xs text-zinc-600">
{new Date(detailPost.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
{detailPost.caption && (
<p className="text-sm text-zinc-300 leading-snug whitespace-pre-wrap">
{detailPost.caption}
</p>
)}
</div>
{/* Who liked */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 mb-2 flex items-center gap-1.5">
<Heart className="h-3.5 w-3.5" />
Geliked von ({detailPost.reactions.length})
</h3>
{detailPost.reactions.length === 0 ? (
<p className="text-xs text-zinc-600 italic">Noch keine Reaktionen.</p>
) : (
<div className="flex flex-wrap gap-2">
{detailPost.reactions.map((r, i) => (
<div
key={i}
className="flex items-center gap-1.5 rounded-full bg-zinc-800 px-2 py-1"
>
{r.pet.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(r.pet.avatarKey) ?? ""}
alt=""
className="h-4 w-4 rounded-full object-cover"
/>
) : (
<div className="h-4 w-4 rounded-full bg-zinc-700" />
)}
<span className="text-xs text-zinc-300">{r.pet.name}</span>
</div>
))}
</div>
)}
</div>
{/* Comments */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 mb-2 flex items-center gap-1.5">
<MessageCircle className="h-3.5 w-3.5" />
Kommentare ({detailComments?.length ?? 0})
</h3>
{!detailComments || detailComments.length === 0 ? (
<p className="text-xs text-zinc-600 italic">Keine Kommentare.</p>
) : (
<div className="space-y-3">
{detailComments.map((c) => (
<div key={c.id} className="flex gap-2">
{c.pet.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(c.pet.avatarKey) ?? ""}
alt=""
className="h-6 w-6 rounded-full object-cover shrink-0"
/>
) : (
<div className="h-6 w-6 rounded-full bg-zinc-700 shrink-0" />
)}
<div className="min-w-0">
<span className="text-xs font-medium text-zinc-300">{c.pet.name}</span>{" "}
<span className="text-xs text-zinc-600">
{new Date(c.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
})}
</span>
<p className="text-sm text-zinc-300 leading-snug break-words">{c.body}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
+3 -3
View File
@@ -284,7 +284,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
className="border-sky-800 text-sky-300 hover:text-sky-100"
onClick={() => startConversationMutation.mutate({ targetPetId: user.pets[0].id })}
disabled={startConversationMutation.isPending}
title={`Message ${user.pets[0].name} as PawFeed Team`}
@@ -300,7 +300,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
className="border-blue-800 text-blue-300 hover:text-blue-100"
onClick={() => exportDataMutation.mutate({ ownerId: user.id })}
disabled={exportDataMutation.isPending}
title="Datenauskunft (Art. 15 DSGVO) per E-Mail versenden"
@@ -314,7 +314,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
className="border-amber-800 text-amber-300 hover:text-amber-100"
onClick={() => setWarnTarget(user.id)}
title="Warnings"
>
+34
View File
@@ -1041,6 +1041,40 @@ export const adminRouter = router({
);
}),
// Full images + reactor list for the report detail dialog. Comments reuse
// the existing listComments/deleteComment pair (see admin posts page) —
// no need to duplicate that fetch here.
getReportedPostDetail: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const post = await ctx.prisma.post.findUnique({
where: { id: input.postId },
select: {
id: true,
caption: true,
type: true,
createdAt: true,
hiddenAt: true,
pet: { select: { id: true, name: true, avatarKey: true } },
images: {
orderBy: { position: "asc" },
select: { storageKey: true, position: true, blurhash: true },
},
reactions: {
orderBy: { createdAt: "desc" },
take: 200,
select: {
pet: { select: { id: true, name: true, avatarKey: true } },
},
},
},
});
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
dismissReport: protectedProcedure
.input(z.object({ reportId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {