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:
1 parent
95df270821
commit
a35cb6640f
3 files changed
+197
-5
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user