feat(admin): Moderations-Seiten im neuen Design (Etappe 3, Paket 1)
- Meldungen: Seitenkopf, Statusabzeichen für Schwere/Grund, beschriftete Knöpfe „Abweisen“/„Beitrag löschen“, gemeldete Profile mit Tiername statt ID (listReports liefert targetPet), Pfoten statt Likes - Verifizierung: komplett deutsch, Status-Filter mit Beschriftung, Tierart/Rasse deutsch aus den Übersetzungstabellen (taxonomy-name) - Vermisst: Seitenkopf, Status-Tabs, Statusabzeichen - Nachrichten: passende Höhe im neuen Rahmen, am Handy Liste ODER Verlauf mit Zurück-Knopf, Menü-Zähler sofort aktualisiert - Sperrliste: deutsch, Filter beschriftet, kompakte Liste Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed0b4666bc
commit
cc9db2ea05
9 files changed
+388
-347
No files matched your search
@@ -863,3 +863,21 @@ describe("admin music-track catalog", () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin.listReports", () => {
|
||||
it("includes the reported pet for profile reports so the panel can show its name", async () => {
|
||||
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
||||
const { caller, mockPrisma } = await createAdminCaller();
|
||||
const createdAt = new Date("2026-10-05T08:00:00Z");
|
||||
mockPrisma.report.findMany.mockResolvedValue([
|
||||
{ id: "r1", reason: "SPAM", targetPostId: null, targetPetId: "pet-9", createdAt, reporter: { id: "pet-1", name: "Mia" } },
|
||||
]);
|
||||
mockPrisma.pet.findMany.mockResolvedValue([{ id: "pet-9", name: "Bello", nickname: null, avatarKey: null }]);
|
||||
|
||||
const [report] = await caller.admin.listReports();
|
||||
|
||||
expect(report!.targetPet).toEqual(expect.objectContaining({ id: "pet-9", name: "Bello" }));
|
||||
expect(report!.post).toBeNull();
|
||||
expect(mockPrisma.post.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,14 @@ import { useTRPC } from "@/trpc/client";
|
||||
import { Trash2, Loader2, Plus, Ban } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminPageHeader } from "@/components/admin/AdminSection";
|
||||
import { formatEventTime } from "@/lib/admin/format";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { BlacklistEntryType } from "@prisma/client";
|
||||
|
||||
const TYPE_LABEL: Record<BlacklistEntryType, string> = {
|
||||
WORD: "Word",
|
||||
WORD: "Wort",
|
||||
LINK: "Link",
|
||||
HASHTAG: "Hashtag",
|
||||
};
|
||||
@@ -26,6 +27,8 @@ const TYPE_BADGE_CLASS: Record<BlacklistEntryType, string> = {
|
||||
|
||||
type TypeFilter = "all" | BlacklistEntryType;
|
||||
|
||||
const FILTER_LABEL: Record<TypeFilter, string> = { all: "Alle Arten", ...TYPE_LABEL };
|
||||
|
||||
export default function AdminBlacklistPage() {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
@@ -84,34 +87,30 @@ export default function AdminBlacklistPage() {
|
||||
if (addedCount > 0) {
|
||||
const suffix =
|
||||
hiddenTotal > 0
|
||||
? ` — ${hiddenTotal} existing post${hiddenTotal !== 1 ? "s" : ""} hidden retroactively.`
|
||||
? ` ${hiddenTotal} bestehende${hiddenTotal !== 1 ? "" : "r"} Beitr${hiddenTotal !== 1 ? "äge" : "ag"} nachträglich ausgeblendet.`
|
||||
: "";
|
||||
toast.success(`${addedCount} ${addedCount !== 1 ? "entries" : "entry"} added.${suffix}`);
|
||||
toast.success(`${addedCount} ${addedCount !== 1 ? "Einträge" : "Eintrag"} hinzugefügt.${suffix}`);
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
toast.error(`${failedCount} ${failedCount !== 1 ? "entries" : "entry"} failed to add.`);
|
||||
toast.error(`${failedCount} ${failedCount !== 1 ? "Einträge konnten" : "Eintrag konnte"} nicht hinzugefügt werden.`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Sperrliste</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Words, links, and hashtags that auto-hide matching posts — new posts are checked on
|
||||
creation, and adding an entry here retroactively scans + hides existing matches.
|
||||
Separate multiple values with a comma to add them all at once.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<AdminPageHeader
|
||||
title="Sperrliste"
|
||||
description="Wörter, Links und Hashtags, die Beiträge automatisch ausblenden. Neue Beiträge werden beim Posten geprüft; ein neuer Eintrag blendet auch passende alte Beiträge aus. Mehrere Werte mit Komma trennen."
|
||||
/>
|
||||
|
||||
{/* Add entry */}
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-card p-3">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-border bg-card p-3">
|
||||
<Select value={newType} onValueChange={(v) => setNewType(v as BlacklistEntryType)}>
|
||||
<SelectTrigger className="w-32 bg-muted border-border text-foreground">
|
||||
<SelectValue />
|
||||
<SelectTrigger className="h-9 w-32" aria-label="Art des neuen Eintrags">
|
||||
<SelectValue>{(v: BlacklistEntryType) => TYPE_LABEL[v]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-muted border-border">
|
||||
<SelectItem value={BlacklistEntryType.WORD}>Word</SelectItem>
|
||||
<SelectContent>
|
||||
<SelectItem value={BlacklistEntryType.WORD}>Wort</SelectItem>
|
||||
<SelectItem value={BlacklistEntryType.LINK}>Link</SelectItem>
|
||||
<SelectItem value={BlacklistEntryType.HASHTAG}>Hashtag</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -119,42 +118,43 @@ export default function AdminBlacklistPage() {
|
||||
<Input
|
||||
placeholder={
|
||||
newType === "WORD"
|
||||
? "word1, word2, …"
|
||||
? "wort1, wort2, …"
|
||||
: newType === "LINK"
|
||||
? "domain1.example, domain2.example, …"
|
||||
: "tag1, tag2 (without #)"
|
||||
? "beispiel.de, andere-seite.com, …"
|
||||
: "tag1, tag2 (ohne #)"
|
||||
}
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreate();
|
||||
}}
|
||||
className="flex-1 min-w-[200px] bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||
aria-label="Neue Einträge"
|
||||
className="h-9 min-w-[200px] flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={isBulkAdding || !newValue.trim()}
|
||||
className="bg-secondary hover:bg-accent"
|
||||
>
|
||||
{isBulkAdding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
Add
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as TypeFilter)}>
|
||||
<SelectTrigger className="w-40 bg-card border-border text-foreground">
|
||||
<SelectValue />
|
||||
<SelectTrigger className="h-9 w-40 bg-card" aria-label="Nach Art filtern">
|
||||
<SelectValue>{(v: TypeFilter) => FILTER_LABEL[v]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-muted border-border">
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value={BlacklistEntryType.WORD}>Word</SelectItem>
|
||||
<SelectItem value={BlacklistEntryType.LINK}>Link</SelectItem>
|
||||
<SelectItem value={BlacklistEntryType.HASHTAG}>Hashtag</SelectItem>
|
||||
<SelectContent>
|
||||
{(Object.keys(FILTER_LABEL) as TypeFilter[]).map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{FILTER_LABEL[v]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -164,25 +164,22 @@ export default function AdminBlacklistPage() {
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-border py-12 text-muted-foreground">
|
||||
<Ban className="h-8 w-8" aria-hidden="true" />
|
||||
<p className="text-sm">No blacklist entries yet.</p>
|
||||
<p className="text-sm">Noch keine Einträge.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3"
|
||||
>
|
||||
<Badge variant="secondary" className={`text-[10px] shrink-0 ${TYPE_BADGE_CLASS[entry.type]}`}>
|
||||
<li key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold ${TYPE_BADGE_CLASS[entry.type]}`}>
|
||||
{TYPE_LABEL[entry.type]}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-sm text-foreground font-mono">
|
||||
{entry.type === "HASHTAG" ? `#${entry.value}` : entry.value}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
{formatEventTime(new Date(entry.createdAt))}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -190,6 +187,7 @@ export default function AdminBlacklistPage() {
|
||||
className="text-destructive hover:opacity-80 shrink-0"
|
||||
onClick={() => setDeleteTarget(entry.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label={`Eintrag „${entry.value}“ entfernen`}
|
||||
>
|
||||
{deleteMutation.isPending && deleteMutation.variables?.id === entry.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -197,22 +195,22 @@ export default function AdminBlacklistPage() {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}>
|
||||
<DialogContent className="bg-card border-border text-foreground max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove blacklist entry</DialogTitle>
|
||||
<DialogTitle>Eintrag entfernen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already-hidden posts stay hidden — this only stops future matches.
|
||||
Bereits ausgeblendete Beiträge bleiben ausgeblendet – es werden nur künftige Treffer nicht mehr erkannt.
|
||||
</p>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -220,7 +218,7 @@ export default function AdminBlacklistPage() {
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Remove
|
||||
Entfernen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, Loader2, Mail, MapPin, Phone, Search, Siren, Users } from "lucide-react";
|
||||
import { ExternalLink, Loader2, Mail, MapPin, Phone, Search, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminPageHeader } from "@/components/admin/AdminSection";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -45,30 +46,27 @@ export default function AdminLostReportsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-semibold">
|
||||
<Siren className="h-5 w-5 text-red-600" />
|
||||
Vermisst
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data ? `${data.activeCount} aktive Meldung${data.activeCount === 1 ? "" : "en"}` : "Vermisst-Meldungen der Nutzer"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 rounded-lg border p-1">
|
||||
{(Object.keys(STATUS_LABEL) as Status[]).map((s) => (
|
||||
<Button key={s} size="sm" variant={status === s ? "default" : "ghost"} onClick={() => setStatus(s)}>
|
||||
{STATUS_LABEL[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<AdminPageHeader
|
||||
title="Vermisst"
|
||||
description={data ? `${data.activeCount} aktive Meldung${data.activeCount === 1 ? "" : "en"} · vergessene schließen, Missbrauch löschen` : "Vermisst-Meldungen der Community"}
|
||||
actions={
|
||||
<div role="tablist" aria-label="Nach Status filtern" className="flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||
{(Object.keys(STATUS_LABEL) as Status[]).map((s) => (
|
||||
<Button key={s} role="tab" aria-selected={status === s} size="sm" variant={status === s ? "default" : "ghost"} onClick={() => setStatus(s)}>
|
||||
{STATUS_LABEL[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-label="Wird geladen" />
|
||||
</div>
|
||||
) : !data?.reports.length ? (
|
||||
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
|
||||
<div className="rounded-2xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
<Search className="mx-auto mb-2 h-5 w-5" />
|
||||
Keine Meldungen in dieser Ansicht.
|
||||
</div>
|
||||
@@ -79,7 +77,7 @@ export default function AdminLostReportsPage() {
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className={`space-y-3 rounded-xl border p-4 ${isActive ? "border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-destructive/10" : ""}`}
|
||||
className={`space-y-3 rounded-2xl border bg-card p-5 ${isActive ? "border-destructive/40" : "border-border"}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
@@ -93,9 +91,9 @@ export default function AdminLostReportsPage() {
|
||||
<p className="text-xs text-muted-foreground">gemeldet {DATE_FORMAT.format(new Date(r.createdAt))}</p>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<Badge className="bg-red-600 text-white">Aktiv</Badge>
|
||||
<StatusBadge tone="danger">Aktiv</StatusBadge>
|
||||
) : (
|
||||
<Badge variant="secondary">Wieder da · {DATE_FORMAT.format(new Date(r.foundAt!))}</Badge>
|
||||
<StatusBadge tone="success">Wieder da · {DATE_FORMAT.format(new Date(r.foundAt!))}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -108,31 +106,31 @@ export default function AdminLostReportsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 text-xs">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<StatusBadge tone="neutral">
|
||||
<Users className="mr-1 h-3 w-3" aria-hidden />
|
||||
{r.notifiedCount} benachrichtigt
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
{r.contactPhone && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Phone className="h-3 w-3" />
|
||||
Telefon
|
||||
</Badge>
|
||||
<StatusBadge tone="neutral">
|
||||
<Phone className="mr-1 h-3 w-3" aria-hidden />
|
||||
Telefon angegeben
|
||||
</StatusBadge>
|
||||
)}
|
||||
{r.contactEmail && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
E-Mail
|
||||
</Badge>
|
||||
<StatusBadge tone="neutral">
|
||||
<Mail className="mr-1 h-3 w-3" aria-hidden />
|
||||
E-Mail angegeben
|
||||
</StatusBadge>
|
||||
)}
|
||||
<Badge variant="outline">{r.allowIndexing ? "Suchmaschinen erlaubt" : "Nur per Link"}</Badge>
|
||||
<StatusBadge tone={r.allowIndexing ? "info" : "neutral"}>{r.allowIndexing ? "In Suchmaschinen" : "Nur per Link"}</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t pt-3">
|
||||
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
|
||||
<a
|
||||
href={`/vermisst/${r.code}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium hover:underline"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-primary hover:underline"
|
||||
>
|
||||
Öffentliche Seite <ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
@@ -170,7 +168,7 @@ export default function AdminLostReportsPage() {
|
||||
: "Für vergessene Meldungen. Das Abzeichen verschwindet, die öffentliche Seite zeigt „wieder zu Hause“. Es geht keine Entwarnung raus."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea placeholder="Grund (optional, fürs Audit-Log)" maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
<Textarea placeholder="Grund (optional, fürs Protokoll)" maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTarget(null)}>
|
||||
Abbrechen
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams, useRouter, useParams } from "next/navigation";
|
||||
import { ArrowLeft, Loader2, MessageCircle, Send } from "lucide-react";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { Send, Loader2, MessageCircle } from "lucide-react";
|
||||
import { formatEventTime } from "@/lib/admin/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function formatDate(d: string | Date) {
|
||||
return new Date(d).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
function PetAvatar({ avatarKey, size }: { avatarKey: string | null; size: string }) {
|
||||
return avatarKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={getMediaUrl(avatarKey) ?? ""} alt="" className={`${size} shrink-0 rounded-full object-cover`} />
|
||||
) : (
|
||||
<div className={`${size} shrink-0 rounded-full bg-secondary`} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "PawFeed Team" inbox (Gitea #10) — every SUPPORT+ admin sees and can
|
||||
* reply to the same conversations, unlike the old Users-page "Message"
|
||||
* button which routed through the normal /messages UI and only worked for
|
||||
* the literal ADMIN_OWNER_ID account (assertPetOwnership scoping).
|
||||
* Gemeinsames Postfach des „PawFeed Team“-Tiers (Gitea #10): alle aus dem Team
|
||||
* sehen denselben Verlauf und antworten als PawFeed Team. Am Handy wird
|
||||
* zwischen Liste und Verlauf gewechselt statt beides nebeneinander zu quetschen.
|
||||
*/
|
||||
export default function AdminMessagesPage() {
|
||||
const trpc = useTRPC();
|
||||
@@ -35,26 +34,26 @@ export default function AdminMessagesPage() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(searchParams.get("conversation"));
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const { data: conversations = [], isLoading } = useQuery(
|
||||
trpc.admin.listTeamConversations.queryOptions()
|
||||
);
|
||||
|
||||
const { data: conversations = [], isLoading } = useQuery(trpc.admin.listTeamConversations.queryOptions());
|
||||
const activeId = selectedId ?? conversations[0]?.id ?? null;
|
||||
const activeConversation = conversations.find((c) => c.id === activeId);
|
||||
|
||||
const { data: thread, isLoading: threadLoading } = useQuery(
|
||||
trpc.admin.getTeamConversationThread.queryOptions(
|
||||
{ conversationId: activeId ?? "" },
|
||||
{ enabled: !!activeId }
|
||||
)
|
||||
trpc.admin.getTeamConversationThread.queryOptions({ conversationId: activeId ?? "" }, { enabled: !!activeId })
|
||||
);
|
||||
|
||||
// Das Öffnen eines Verlaufs markiert ihn serverseitig als gelesen → Zähler in Liste und Menü nachziehen.
|
||||
useEffect(() => {
|
||||
if (!thread) return;
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listTeamConversations.queryKey() });
|
||||
void qc.invalidateQueries({ queryKey: trpc.adminOverview.getQueues.queryKey() });
|
||||
}, [thread, qc, trpc]);
|
||||
|
||||
const sendReply = useMutation(
|
||||
trpc.admin.sendTeamReply.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setDraft("");
|
||||
void qc.invalidateQueries({
|
||||
queryKey: trpc.admin.getTeamConversationThread.queryKey({ conversationId: activeId ?? "" }),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.getTeamConversationThread.queryKey({ conversationId: activeId ?? "" }) });
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listTeamConversations.queryKey() });
|
||||
},
|
||||
})
|
||||
@@ -65,110 +64,113 @@ export default function AdminMessagesPage() {
|
||||
sendReply.mutate({ conversationId: activeId, body: draft.trim() });
|
||||
}
|
||||
|
||||
function selectConversation(id: string) {
|
||||
function selectConversation(id: string | null) {
|
||||
setSelectedId(id);
|
||||
router.replace(`/p/${params.secret}/messages?conversation=${id}`);
|
||||
router.replace(id ? `/p/${params.secret}/messages?conversation=${id}` : `/p/${params.secret}/messages`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] gap-4">
|
||||
{/* Conversation list */}
|
||||
<div className="w-80 shrink-0 rounded-xl border border-border bg-card overflow-y-auto">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h1 className="text-lg font-bold text-foreground">Nachrichten ans Team</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Gemeinsamer Posteingang — jeder Admin sieht denselben Verlauf
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground px-4 py-6">Noch keine Konversationen.</p>
|
||||
) : (
|
||||
conversations.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => selectConversation(c.id)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-border hover:bg-muted/60 transition-colors ${
|
||||
activeId === c.id ? "bg-muted" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{c.otherPet.avatarKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getMediaUrl(c.otherPet.avatarKey) ?? ""}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-secondary shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{c.otherPet.name}
|
||||
</span>
|
||||
{c.unreadCount > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-orange-500 text-white text-[10px] font-semibold px-1.5 py-0.5">
|
||||
{c.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{c.lastMessage && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{c.lastMessage.body}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
// Am Handy: ohne ausdrückliche Auswahl zuerst die Liste zeigen.
|
||||
const showThreadOnMobile = selectedId !== null;
|
||||
|
||||
{/* Thread */}
|
||||
<div className="flex-1 flex flex-col rounded-xl border border-border bg-card overflow-hidden">
|
||||
return (
|
||||
<div className="mx-auto flex h-[calc(100dvh-3.5rem-2rem)] max-w-6xl gap-4 md:h-[calc(100dvh-3.5rem-4rem)]">
|
||||
<section
|
||||
aria-label="Unterhaltungen"
|
||||
className={`w-full shrink-0 flex-col overflow-hidden rounded-2xl border border-border bg-card md:flex md:w-80 ${showThreadOnMobile ? "hidden" : "flex"}`}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-3.5">
|
||||
<h1 className="text-base font-bold text-foreground">Nachrichten ans Team</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">Gemeinsames Postfach – alle aus dem Team sehen denselben Verlauf.</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-label="Wird geladen" />
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground">Noch keine Unterhaltungen.</p>
|
||||
) : (
|
||||
conversations.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => selectConversation(c.id)}
|
||||
aria-current={activeId === c.id ? "true" : undefined}
|
||||
className={`w-full cursor-pointer border-b border-border px-4 py-3 text-left transition-colors hover:bg-muted/60 ${activeId === c.id ? "md:bg-accent" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<PetAvatar avatarKey={c.otherPet.avatarKey} size="h-9 w-9" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`truncate text-sm text-foreground ${c.unreadCount > 0 ? "font-bold" : "font-medium"}`}>{c.otherPet.name}</span>
|
||||
{c.unreadCount > 0 ? (
|
||||
<span className="shrink-0 rounded-full bg-primary px-1.5 py-0.5 text-[11px] font-bold text-primary-foreground">
|
||||
{c.unreadCount}
|
||||
<span className="sr-only"> ungelesen</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{formatEventTime(new Date(c.updatedAt))}</span>
|
||||
)}
|
||||
</div>
|
||||
{c.lastMessage && <p className="mt-0.5 truncate text-xs text-muted-foreground">{c.lastMessage.body}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Verlauf"
|
||||
className={`flex-1 flex-col overflow-hidden rounded-2xl border border-border bg-card md:flex ${showThreadOnMobile ? "flex" : "hidden"}`}
|
||||
>
|
||||
{activeConversation && (
|
||||
<div className="flex items-center gap-2.5 border-b border-border px-3 py-2.5 md:px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectConversation(null)}
|
||||
aria-label="Zurück zur Liste"
|
||||
className="grid h-9 w-9 cursor-pointer place-items-center rounded-lg text-muted-foreground hover:bg-muted md:hidden"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
<PetAvatar avatarKey={activeConversation.otherPet.avatarKey} size="h-8 w-8" />
|
||||
<p className="truncate text-sm font-semibold text-foreground">{activeConversation.otherPet.name}</p>
|
||||
</div>
|
||||
)}
|
||||
{!activeId ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<MessageCircle className="h-8 w-8" />
|
||||
<p className="text-sm">Wähle eine Konversation aus.</p>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<MessageCircle className="h-8 w-8" aria-hidden />
|
||||
<p className="text-sm">Wähle links eine Unterhaltung aus.</p>
|
||||
</div>
|
||||
) : threadLoading || !thread ? (
|
||||
<div className="flex-1 flex justify-center items-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-label="Wird geladen" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-3">
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-4 md:p-5">
|
||||
{thread.messages.map((m) => {
|
||||
const isTeam = m.senderPetId === thread.teamPetId;
|
||||
return (
|
||||
<div key={m.id} className={`flex ${isTeam ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-2xl px-3.5 py-2 ${
|
||||
isTeam ? "bg-orange-600 text-white" : "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{!isTeam && (
|
||||
<p className="text-xs font-semibold opacity-70 mb-0.5">{m.senderPet.name}</p>
|
||||
)}
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{m.body}</p>
|
||||
<p className={`text-[10px] mt-1 ${isTeam ? "text-white/60" : "text-muted-foreground"}`}>
|
||||
{formatDate(m.createdAt)}
|
||||
</p>
|
||||
<div className={`max-w-[80%] rounded-2xl px-3.5 py-2 md:max-w-[70%] ${isTeam ? "bg-primary text-primary-foreground" : "bg-muted text-foreground"}`}>
|
||||
{!isTeam && <p className="mb-0.5 text-xs font-semibold opacity-70">{m.senderPet.name}</p>}
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{m.body}</p>
|
||||
<p className={`mt-1 text-[10px] ${isTeam ? "opacity-70" : "text-muted-foreground"}`}>{formatEventTime(new Date(m.createdAt))}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-border p-3 flex gap-2">
|
||||
<div className="flex gap-2 border-t border-border p-3">
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Als PawFeed Team antworten…"
|
||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
|
||||
placeholder="Als PawFeed Team antworten … (Enter sendet, Umschalt+Enter = neue Zeile)"
|
||||
aria-label="Antwort als PawFeed Team"
|
||||
className="resize-none"
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -177,21 +179,13 @@ export default function AdminMessagesPage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!draft.trim() || sendReply.isPending}
|
||||
className="self-end"
|
||||
>
|
||||
{sendReply.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
<Button onClick={handleSend} disabled={!draft.trim() || sendReply.isPending} className="self-end" aria-label="Senden">
|
||||
{sendReply.isPending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : <Send className="h-4 w-4" aria-hidden />}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { BlurImage } from "@/components/ui/blur-image";
|
||||
import { Flag, Trash2, X, Loader2, CheckCheck, MessageCircle, Heart, ImageOff } from "lucide-react";
|
||||
import { Trash2, X, Loader2, CheckCheck, MessageCircle, PawPrint, ImageOff, UserRound } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminPageHeader } from "@/components/admin/AdminSection";
|
||||
import { StatusBadge, type StatusTone } from "@/components/admin/StatusBadge";
|
||||
import { formatEventTime } from "@/lib/admin/format";
|
||||
import { formatPetName } from "@/lib/pet-display-name";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
@@ -24,11 +27,7 @@ const REASON_LABELS: Record<string, string> = {
|
||||
// Severity is derived server-side from `reason` (Gitea #10) — list is already
|
||||
// sorted severity-first, this just renders the label/color.
|
||||
const SEVERITY_LABELS: Record<string, string> = { HIGH: "Hoch", MEDIUM: "Mittel", LOW: "Niedrig" };
|
||||
const SEVERITY_CLASS: Record<string, string> = {
|
||||
HIGH: "border-red-600 text-destructive",
|
||||
MEDIUM: "border-yellow-600 text-warning",
|
||||
LOW: "border-border text-muted-foreground",
|
||||
};
|
||||
const SEVERITY_TONE: Record<string, StatusTone> = { HIGH: "danger", MEDIUM: "warning", LOW: "neutral" };
|
||||
|
||||
type ReportAction =
|
||||
| { kind: "dismiss"; reportId: string }
|
||||
@@ -97,58 +96,44 @@ export default function AdminReportsPage() {
|
||||
const isPending = dismiss.isPending || deletePost.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">Meldungen</h1>
|
||||
{reports.length > 0 && (
|
||||
<Badge className="bg-yellow-600 text-yellow-100">{reports.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<AdminPageHeader
|
||||
title="Meldungen"
|
||||
description="Von der Community gemeldete Beiträge und Profile – die schwersten zuerst."
|
||||
actions={reports.length > 0 ? <StatusBadge tone="warning">{reports.length} offen</StatusBadge> : undefined}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
|
||||
<CheckCheck className="h-8 w-8" />
|
||||
<p className="text-sm">Keine offenen Reports — alles sauber.</p>
|
||||
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-border py-16 text-center">
|
||||
<CheckCheck className="h-8 w-8 text-success" aria-hidden />
|
||||
<p className="text-sm font-semibold text-foreground">Keine offenen Meldungen</p>
|
||||
<p className="text-sm text-muted-foreground">Alles erledigt – neue Meldungen erscheinen hier und als Zähler im Menü.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reports.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className="rounded-xl border border-border bg-card overflow-hidden"
|
||||
className="overflow-hidden rounded-2xl border border-border bg-card"
|
||||
>
|
||||
{/* Report header */}
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<Flag className="h-4 w-4 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex flex-wrap items-start gap-3 px-5 py-4">
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] ${SEVERITY_CLASS[report.severity] ?? "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{SEVERITY_LABELS[report.severity] ?? report.severity}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-yellow-600 text-warning text-[10px]">
|
||||
{REASON_LABELS[report.reason] ?? report.reason}
|
||||
</Badge>
|
||||
<StatusBadge tone={SEVERITY_TONE[report.severity] ?? "neutral"}>
|
||||
Schwere: {SEVERITY_LABELS[report.severity] ?? report.severity}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="neutral">{REASON_LABELS[report.reason] ?? report.reason}</StatusBadge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
gemeldet von{" "}
|
||||
<span className="text-foreground font-medium">{report.reporter.name}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{new Date(report.createdAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">· {formatEventTime(new Date(report.createdAt))}</span>
|
||||
</div>
|
||||
{report.notes && (
|
||||
<p className="text-sm text-foreground mt-1 leading-snug">
|
||||
@@ -158,27 +143,15 @@ export default function AdminReportsPage() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title="Report ignorieren"
|
||||
className="text-muted-foreground hover:text-foreground h-8 w-8 p-0"
|
||||
onClick={() => setAction({ kind: "dismiss", reportId: report.id })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setAction({ kind: "dismiss", reportId: report.id })}>
|
||||
<X className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Abweisen
|
||||
</Button>
|
||||
{report.targetPostId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title="Post löschen"
|
||||
className="text-destructive hover:opacity-80 h-8 w-8 p-0"
|
||||
onClick={() =>
|
||||
setAction({ kind: "deletePost", postId: report.targetPostId! })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Button size="sm" variant="destructive" onClick={() => setAction({ kind: "deletePost", postId: report.targetPostId! })}>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Beitrag löschen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -189,8 +162,8 @@ export default function AdminReportsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailPostId(report.post!.id)}
|
||||
className="border-t border-border mx-4 mb-3 pt-3 flex gap-3 w-[calc(100%-2rem)] text-left rounded-lg hover:bg-muted/60 transition-colors"
|
||||
title="Vollständigen Post ansehen"
|
||||
className="mx-5 mb-4 flex w-[calc(100%-2.5rem)] cursor-pointer gap-3 rounded-xl border border-border p-3 text-left transition-colors hover:bg-muted/60"
|
||||
title="Beitrag vollständig ansehen"
|
||||
>
|
||||
{/* Image strip */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
@@ -240,14 +213,14 @@ export default function AdminReportsPage() {
|
||||
{report.post.caption}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/70 italic">Kein Caption</p>
|
||||
<p className="text-xs italic text-muted-foreground">Ohne Bildtext</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Counts */}
|
||||
<div className="flex gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-3 w-3" />
|
||||
<span className="flex items-center gap-1" title="Pfoten">
|
||||
<PawPrint className="h-3 w-3" aria-hidden />
|
||||
{report.post.reactionCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -258,11 +231,21 @@ export default function AdminReportsPage() {
|
||||
</div>
|
||||
</button>
|
||||
) : report.targetPetId ? (
|
||||
<div className="border-t border-border mx-4 mb-3 pt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Profil-Report — Pet-ID:{" "}
|
||||
<code className="text-muted-foreground">{report.targetPetId}</code>
|
||||
</p>
|
||||
<div className="mx-5 mb-4 flex items-center gap-3 rounded-xl border border-border p-3">
|
||||
{report.targetPet?.avatarKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={getMediaUrl(report.targetPet.avatarKey) ?? ""} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full bg-muted text-muted-foreground">
|
||||
<UserRound className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{report.targetPet ? formatPetName(report.targetPet) : "Gelöschtes Tier"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Profil gemeldet</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -275,14 +258,14 @@ export default function AdminReportsPage() {
|
||||
<DialogContent className="bg-card border-border text-foreground max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{action?.kind === "dismiss" ? "Report ignorieren" : "Post löschen"}
|
||||
{action?.kind === "dismiss" ? "Meldung abweisen" : "Beitrag löschen"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{action?.kind === "dismiss" ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Den Report als unbegründet schließen? Der Inhalt bleibt bestehen.
|
||||
Die Meldung als unbegründet schließen? Der Inhalt bleibt bestehen.
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="Begründung für den Melder (optional), z. B. warum kein Verstoß vorliegt"
|
||||
@@ -297,11 +280,11 @@ export default function AdminReportsPage() {
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Den gemeldeten Post dauerhaft löschen? Alle zugehörigen Reports werden damit
|
||||
ebenfalls geschlossen.
|
||||
Den gemeldeten Beitrag dauerhaft löschen? Alle Meldungen zu diesem Beitrag werden
|
||||
damit ebenfalls geschlossen.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Interne Begründung (optional, nur im Moderations-Log)"
|
||||
placeholder="Interne Begründung (optional, nur im Protokoll)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||
@@ -336,7 +319,7 @@ export default function AdminReportsPage() {
|
||||
}
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
{action?.kind === "dismiss" ? "Ignorieren" : "Post löschen"}
|
||||
{action?.kind === "dismiss" ? "Abweisen" : "Beitrag löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -346,7 +329,7 @@ export default function AdminReportsPage() {
|
||||
<Dialog open={!!detailPostId} onOpenChange={(o) => { if (!o) setDetailPostId(null); }}>
|
||||
<DialogContent className="bg-card border-border text-foreground max-w-3xl max-h-[85dvh] p-0 gap-0 overflow-hidden flex flex-col">
|
||||
<DialogHeader className="px-5 py-4 border-b border-border shrink-0">
|
||||
<DialogTitle>Gemeldeter Post</DialogTitle>
|
||||
<DialogTitle>Gemeldeter Beitrag</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{detailLoading || !detailPost ? (
|
||||
@@ -409,11 +392,11 @@ export default function AdminReportsPage() {
|
||||
{/* Who liked */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
Geliked von ({detailPost.reactions.length})
|
||||
<PawPrint className="h-3.5 w-3.5" aria-hidden />
|
||||
Pfoten von ({detailPost.reactions.length})
|
||||
</h3>
|
||||
{detailPost.reactions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground/70 italic">Noch keine Reaktionen.</p>
|
||||
<p className="text-xs text-muted-foreground/70 italic">Noch keine Pfoten.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detailPost.reactions.map((r, i) => (
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminPageHeader } from "@/components/admin/AdminSection";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { formatEventTime } from "@/lib/admin/format";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -25,6 +27,14 @@ import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
|
||||
type RequestStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
const STATUS_LABEL: Record<RequestStatus, string> = { PENDING: "Offen", APPROVED: "Genehmigt", REJECTED: "Abgelehnt" };
|
||||
const EMPTY_LABEL: Record<RequestStatus, string> = {
|
||||
PENDING: "Keine offenen Anfragen – alles erledigt.",
|
||||
APPROVED: "Noch keine genehmigten Anfragen.",
|
||||
REJECTED: "Noch keine abgelehnten Anfragen.",
|
||||
};
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
type ChecklistKey =
|
||||
| "meetsAuthenticity"
|
||||
| "meetsCompleteProfile"
|
||||
@@ -116,62 +126,66 @@ export default function AdminVerificationPage() {
|
||||
<>
|
||||
<span className="font-medium text-foreground">{pet.name}</span> ·{" "}
|
||||
{pet.breed?.name ?? pet.species.name}
|
||||
{pet.bio ? <> — “{pet.bio}”</> : <> — no bio set</>}
|
||||
{pet.bio ? <> — “{pet.bio}”</> : <> — keine Bio</>}
|
||||
</>
|
||||
);
|
||||
case "meetsCompleteProfile":
|
||||
return (
|
||||
<>
|
||||
Avatar {pet.avatarKey ? "✓ set" : "✗ missing"} · Bio {pet.bio ? "✓ set" : "✗ missing"}
|
||||
Profilbild {pet.avatarKey ? "✓ vorhanden" : "✗ fehlt"} · Bio {pet.bio ? "✓ vorhanden" : "✗ fehlt"}
|
||||
</>
|
||||
);
|
||||
case "meetsGoodStanding":
|
||||
return pet.owner.ban ? (
|
||||
<span className="text-destructive">
|
||||
Banned{pet.owner.ban.reason ? `: ${pet.owner.ban.reason}` : ""}
|
||||
Gesperrt{pet.owner.ban.reason ? `: ${pet.owner.ban.reason}` : ""}
|
||||
</span>
|
||||
) : pet.owner._count.warnings > 0 ? (
|
||||
<span className="text-warning">
|
||||
No ban, but {pet.owner._count.warnings} warning
|
||||
{pet.owner._count.warnings !== 1 ? "s" : ""}
|
||||
Nicht gesperrt, aber {plural(pet.owner._count.warnings, "Verwarnung", "Verwarnungen")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-info">No ban, no warnings</span>
|
||||
<span className="text-success">Nicht gesperrt, keine Verwarnungen</span>
|
||||
);
|
||||
case "meetsEstablishedPresence":
|
||||
return (
|
||||
<>
|
||||
Joined {new Date(pet.createdAt).toLocaleDateString()} · {pet._count.posts} posts ·{" "}
|
||||
{pet._count.followers} followers
|
||||
Dabei seit {formatEventTime(new Date(pet.createdAt))} · {plural(pet._count.posts, "Beitrag", "Beiträge")} ·{" "}
|
||||
{pet._count.followers} Follower
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">Verifizierung</h1>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as RequestStatus)}>
|
||||
<SelectTrigger className="w-40 bg-card border-border text-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-muted border-border">
|
||||
<SelectItem value="PENDING">Pending</SelectItem>
|
||||
<SelectItem value="APPROVED">Approved</SelectItem>
|
||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<AdminPageHeader
|
||||
title="Verifizierung"
|
||||
description="Anfragen für das blaue Häkchen – entschieden wird anhand der Checkliste."
|
||||
actions={
|
||||
<Select value={status} onValueChange={(v) => setStatus((v ?? "PENDING") as RequestStatus)}>
|
||||
<SelectTrigger className="h-9 w-40 bg-card" aria-label="Nach Status filtern">
|
||||
<SelectValue>{(v: RequestStatus) => STATUS_LABEL[v]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(STATUS_LABEL) as RequestStatus[]).map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{STATUS_LABEL[v]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
|
||||
<CheckCheck className="h-8 w-8" />
|
||||
<p className="text-sm">No {status.toLowerCase()} requests.</p>
|
||||
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-border py-16 text-center text-muted-foreground">
|
||||
<CheckCheck className="h-8 w-8 text-success" aria-hidden />
|
||||
<p className="text-sm">{EMPTY_LABEL[status]}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -183,7 +197,7 @@ export default function AdminVerificationPage() {
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className="flex items-start gap-3 rounded-xl border border-border bg-card px-4 py-3"
|
||||
className="flex flex-wrap items-start gap-3 rounded-2xl border border-border bg-card px-5 py-4 sm:flex-nowrap"
|
||||
>
|
||||
<Avatar className="h-11 w-11 shrink-0">
|
||||
{req.pet.avatarKey && (
|
||||
@@ -203,50 +217,47 @@ export default function AdminVerificationPage() {
|
||||
>
|
||||
{req.pet.name}
|
||||
</Link>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{req.pet.breed?.name ?? req.pet.species.name}
|
||||
</Badge>
|
||||
<StatusBadge tone="neutral">{req.pet.breed?.name ?? req.pet.species.name}</StatusBadge>
|
||||
{req.pet.isVerified && (
|
||||
<Badge className="bg-blue-700 text-blue-100 text-[10px]">
|
||||
<BadgeCheck className="h-3 w-3 mr-1" />
|
||||
Already verified
|
||||
</Badge>
|
||||
<StatusBadge tone="info">
|
||||
<BadgeCheck className="mr-1 h-3 w-3" aria-hidden />
|
||||
Schon verifiziert
|
||||
</StatusBadge>
|
||||
)}
|
||||
{req.pet.owner.referralPerks.includes("VERIFICATION_PRIORITY") && (
|
||||
<Badge className="bg-orange-700 text-orange-100 text-[10px]">Bevorzugt (Empfehlungsrang)</Badge>
|
||||
<StatusBadge tone="warning">Bevorzugt (Empfehlungsrang)</StatusBadge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground/70 font-mono">{req.pet.ownerId}</span>
|
||||
</div>
|
||||
|
||||
{/* Profile-at-a-glance — pulled in so no separate lookup is needed */}
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
{req.pet._count.posts} posts
|
||||
{plural(req.pet._count.posts, "Beitrag", "Beiträge")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{req.pet._count.followers} followers
|
||||
{req.pet._count.followers} Follower
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
joined {new Date(req.pet.createdAt).toLocaleDateString()}
|
||||
dabei seit {formatEventTime(new Date(req.pet.createdAt))}
|
||||
</span>
|
||||
{isBanned ? (
|
||||
<span className="flex items-center gap-1 text-destructive">
|
||||
<Ban className="h-3 w-3" />
|
||||
banned{banReason ? `: ${banReason}` : ""}
|
||||
gesperrt{banReason ? `: ${banReason}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Ban className="h-3 w-3" />
|
||||
no ban
|
||||
nicht gesperrt
|
||||
</span>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-warning">
|
||||
<TriangleAlert className="h-3 w-3" />
|
||||
{warningCount} warning{warningCount !== 1 ? "s" : ""}
|
||||
{plural(warningCount, "Verwarnung", "Verwarnungen")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -256,15 +267,15 @@ export default function AdminVerificationPage() {
|
||||
)}
|
||||
|
||||
<p className="text-sm text-foreground mt-2 leading-snug">
|
||||
<span className="text-muted-foreground">Requester says:</span> {req.message}
|
||||
<span className="text-muted-foreground">Begründung:</span> {req.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5">
|
||||
requested {new Date(req.createdAt).toLocaleString()}
|
||||
angefragt {formatEventTime(new Date(req.createdAt))}
|
||||
</p>
|
||||
{req.status !== "PENDING" && req.reviewNote && (
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
req.status === "APPROVED" ? "text-info" : "text-destructive"
|
||||
req.status === "APPROVED" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{req.status === "APPROVED" ? "Genehmigt" : "Abgelehnt"}: {req.reviewNote}
|
||||
@@ -272,13 +283,8 @@ export default function AdminVerificationPage() {
|
||||
)}
|
||||
</div>
|
||||
{req.status === "PENDING" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-card border-border text-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
onClick={() => openReview(req.id)}
|
||||
>
|
||||
Review
|
||||
<Button size="sm" className="shrink-0" onClick={() => openReview(req.id)}>
|
||||
Prüfen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -292,14 +298,13 @@ export default function AdminVerificationPage() {
|
||||
size="sm"
|
||||
onClick={() => void fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="bg-card border-border text-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Load more
|
||||
Weitere laden
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -310,7 +315,7 @@ export default function AdminVerificationPage() {
|
||||
<Dialog open={!!reviewTarget} onOpenChange={(o) => { if (!o) setReviewTarget(null); }}>
|
||||
<DialogContent className="bg-card border-border text-foreground max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review request</DialogTitle>
|
||||
<DialogTitle>Anfrage prüfen</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -324,7 +329,7 @@ export default function AdminVerificationPage() {
|
||||
onChange={(e) =>
|
||||
setChecklist((prev) => ({ ...prev, [c.key]: e.target.checked }))
|
||||
}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-[var(--primary)]"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm text-foreground">{c.label}</span>
|
||||
@@ -338,7 +343,7 @@ export default function AdminVerificationPage() {
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-2 text-xs ${
|
||||
allChecked
|
||||
? "border-info/40 bg-info/10 text-info"
|
||||
? "border-success/40 bg-success/10 text-success"
|
||||
: "border-destructive/40 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
@@ -356,7 +361,7 @@ export default function AdminVerificationPage() {
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-muted-foreground" onClick={() => setReviewTarget(null)}>
|
||||
Cancel
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
@@ -367,10 +372,10 @@ export default function AdminVerificationPage() {
|
||||
})
|
||||
}
|
||||
disabled={reviewMutation.isPending}
|
||||
className={allChecked ? "bg-blue-700 hover:bg-blue-600" : "bg-red-700 hover:bg-red-600"}
|
||||
variant={allChecked ? "default" : "destructive"}
|
||||
>
|
||||
{reviewMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
{allChecked ? "Approve" : "Reject"}
|
||||
{allChecked ? "Genehmigen" : "Ablehnen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { germanName, withGermanName } from "@/lib/admin/taxonomy-name";
|
||||
|
||||
describe("taxonomy-name", () => {
|
||||
it("prefers the German translation and falls back to the key", () => {
|
||||
expect(germanName({ name: "Cat", translations: [{ name: "Katze" }] })).toBe("Katze");
|
||||
expect(germanName({ name: "Axolotl", translations: [] })).toBe("Axolotl");
|
||||
});
|
||||
|
||||
it("replaces name and drops the translation list", () => {
|
||||
expect(withGermanName({ name: "Domestic Shorthair", translations: [{ name: "Europäisch Kurzhaar" }] })).toEqual({ name: "Europäisch Kurzhaar" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Deutsche Namen für Tierart/Rasse im Admin-Panel. Species.name / Breed.name sind
|
||||
* englische Schlüssel („Cat“, „Domestic Shorthair“); die Anzeige kommt aus den
|
||||
* Übersetzungstabellen (SpeciesTranslation / BreedTranslation, locale "de").
|
||||
*/
|
||||
export const germanNameSelect = {
|
||||
name: true,
|
||||
translations: { where: { locale: "de" }, select: { name: true } },
|
||||
} as const;
|
||||
|
||||
/** Deutscher Name, sonst der Schlüssel als Rückfall. */
|
||||
export function germanName(entry: { name: string; translations: { name: string }[] }): string {
|
||||
return entry.translations[0]?.name ?? entry.name;
|
||||
}
|
||||
|
||||
/** Ersetzt `name` durch den deutschen Namen und lässt die Übersetzungsliste weg. */
|
||||
export function withGermanName<T extends { name: string; translations: { name: string }[] }>(entry: T): Omit<T, "translations"> {
|
||||
const { translations, ...rest } = entry;
|
||||
return { ...rest, name: translations[0]?.name ?? entry.name };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { matchesWordBlacklist, matchesLinkBlacklist } from "@/lib/blacklist";
|
||||
import { getDailyVisitCount, getUniqueVisitorCount, lastNDays, daysInRange } from "@/lib/visit-tracking";
|
||||
import { isMaintenanceModeEnabled, setMaintenanceMode } from "@/lib/maintenance";
|
||||
import { isStoryEditorEnabled, setStoryEditorEnabled } from "@/lib/feature-flags";
|
||||
import { germanNameSelect, withGermanName } from "@/lib/admin/taxonomy-name";
|
||||
|
||||
const DATA_EXPORT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
@@ -873,8 +874,8 @@ export const adminRouter = router({
|
||||
ownerId: true,
|
||||
bio: true,
|
||||
createdAt: true,
|
||||
species: { select: { name: true } },
|
||||
breed: { select: { name: true } },
|
||||
species: { select: germanNameSelect },
|
||||
breed: { select: germanNameSelect },
|
||||
_count: { select: { posts: true, followers: true } },
|
||||
owner: {
|
||||
select: {
|
||||
@@ -895,7 +896,10 @@ export const adminRouter = router({
|
||||
// Empfehlungsprogramm-Vorteil VERIFICATION_PRIORITY: innerhalb der geladenen Seite nach oben sortieren.
|
||||
// Der Cursor bleibt die letzte ID der ursprünglichen Reihenfolge, damit die Paginierung stimmt.
|
||||
const hasPriority = (r: (typeof items)[number]) => r.pet.owner.referralPerks.includes("VERIFICATION_PRIORITY");
|
||||
const sorted = [...items.filter(hasPriority), ...items.filter((r) => !hasPriority(r))];
|
||||
const sorted = [...items.filter(hasPriority), ...items.filter((r) => !hasPriority(r))].map((r) => ({
|
||||
...r,
|
||||
pet: { ...r.pet, species: withGermanName(r.pet.species), breed: r.pet.breed ? withGermanName(r.pet.breed) : null },
|
||||
}));
|
||||
return { requests: sorted, nextCursor: hasNext ? items[items.length - 1]!.id : null };
|
||||
}),
|
||||
|
||||
@@ -1241,6 +1245,13 @@ export const adminRouter = router({
|
||||
|
||||
const postMap = new Map(posts.map((p) => [p.id, p]));
|
||||
|
||||
// Profil-Meldungen: Tier zum Anzeigen (Name statt roher ID auf der Admin-Seite)
|
||||
const targetPetIds = [...new Set(reports.map((r) => r.targetPetId).filter((id): id is string => !!id))];
|
||||
const targetPets = targetPetIds.length > 0
|
||||
? ((await ctx.prisma.pet.findMany({ where: { id: { in: targetPetIds } }, select: petIdentitySelect })) ?? [])
|
||||
: [];
|
||||
const targetPetMap = new Map(targetPets.map((p) => [p.id, p]));
|
||||
|
||||
// Severity-first, newest-within-severity — reports don't carry an
|
||||
// explicit severity column, it's derived from `reason` (Gitea #10).
|
||||
return reports
|
||||
@@ -1248,6 +1259,7 @@ export const adminRouter = router({
|
||||
...r,
|
||||
severity: REPORT_SEVERITY[r.reason],
|
||||
post: r.targetPostId ? (postMap.get(r.targetPostId) ?? null) : null,
|
||||
targetPet: r.targetPetId ? (targetPetMap.get(r.targetPetId) ?? null) : null,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
|
||||
Reference in new issue
Block a user