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:
adminandClaude Opus 5.5 committed 2026-10-05 08:39:23 +02:00
1 parent ed0b4666bc
commit cc9db2ea05
9 files changed
+388 -347

No files matched your search

+18
View File
@@ -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();
});
});
+46 -48
View File
@@ -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>
+39 -41
View File
@@ -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
+112 -118
View File
@@ -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>
);
}
+60 -77
View File
@@ -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) => (
+65 -60
View File
@@ -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 ? <> — &ldquo;{pet.bio}&rdquo;</> : <> — no bio set</>}
{pet.bio ? <> — &ldquo;{pet.bio}&rdquo;</> : <> — 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>
+13
View File
@@ -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" });
});
});
+20
View File
@@ -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 };
}
+15 -3
View File
@@ -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) =>