feat(admin): neuer Rahmen und Startseite für das Admin-Panel

- Sidebar nach Aufgaben gruppiert (Moderation, Inhalte, Community,
  Verwaltung), komplett deutsch, mit Zählern für offene Meldungen,
  Verifizierungen, Vermisst-Meldungen und ungelesene Team-Nachrichten
- Kopfzeile mit Breadcrumb, globaler Suche (Tiere + Halter), Hell/Dunkel
  und eigenem Namen + Rolle; hell als Standard, Wahl im Cookie
- Startseite: Kennzahlen mit Vorwochen-Vergleich, Moderationsstatus mit
  Wartezeit, letzte Eingänge, Schnellzugriff, Notschalter als Schalter,
  ein Aktivitäts-Diagramm mit Umschalter statt fünf Karten
- Eigene Admin-Farbtokens (Orange in beiden Modi, Statusfarben); alle
  Admin-Seiten von festen Dunkel-Klassen auf Tokens umgestellt
- Neuer Router adminOverview (getQueues, getKpis, getInbox, search)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 07:30:16 +02:00
1 parent 9ba965ae11
commit c4fb9aefb7
44 files changed
+2074 -973

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
/**
* Admin-Startseite + Rahmen (admin-overview.ts): Warteschlangen für die
* Sidebar-Zähler, Kennzahlen, Eingangs-Feed und globale Suche.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockGetUserList = vi.fn();
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "admin-1" }),
clerkClient: vi.fn().mockResolvedValue({ users: { getUserList: (...a: unknown[]) => mockGetUserList(...a) } }),
}));
const mockAssertAdmin = vi.fn();
vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...a: unknown[]) => mockAssertAdmin(...a) }));
vi.mock("@/lib/team-pet", () => ({ ensureTeamPet: vi.fn().mockResolvedValue({ id: "team-pet" }) }));
vi.mock("@/lib/visit-tracking", () => ({
getDailyVisitCount: vi.fn().mockResolvedValue(3),
getUniqueVisitorCount: vi.fn().mockResolvedValue(12),
lastNDays: vi.fn().mockReturnValue([]),
}));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { adminOverviewRouter } from "@/trpc/routers/admin-overview";
type MockPrisma = ReturnType<typeof createMockPrisma>;
const ctx = (prisma: MockPrisma) => ({ userId: "admin-1", prisma: prisma as never, locale: "de", ip: "127.0.0.1" });
const at = (iso: string) => new Date(iso);
describe("adminOverview", () => {
let prisma: MockPrisma;
beforeEach(() => {
vi.clearAllMocks();
mockAssertAdmin.mockResolvedValue("MODERATOR");
prisma = createMockPrisma();
prisma.report.count.mockResolvedValue(0);
prisma.report.findMany.mockResolvedValue([]);
});
describe("getQueues", () => {
it("counts every queue and reports how long the oldest item has been waiting", async () => {
prisma.report.count.mockResolvedValueOnce(4).mockResolvedValueOnce(1);
prisma.report.findFirst.mockResolvedValue({ createdAt: at("2026-10-01T10:00:00Z") });
prisma.message.count.mockResolvedValue(2);
prisma.verificationRequest.count.mockResolvedValue(3);
prisma.lostReport.count.mockResolvedValue(1);
const result = await adminOverviewRouter.createCaller(ctx(prisma)).getQueues();
expect(result.reports).toEqual({ count: 4, oldestAt: at("2026-10-01T10:00:00Z"), new24h: 1 });
expect(result.messages.count).toBe(2);
expect(result.verifications?.count).toBe(3);
expect(result.lost?.count).toBe(1);
expect(prisma.lostReport.count).toHaveBeenCalledWith({ where: { foundAt: null } });
expect(prisma.message.count).toHaveBeenCalledWith({
where: expect.objectContaining({ readAt: null, senderPetId: { not: "team-pet" } }),
});
});
it("hides moderator-only queues from SUPPORT", async () => {
mockAssertAdmin.mockResolvedValue("SUPPORT");
const result = await adminOverviewRouter.createCaller(ctx(prisma)).getQueues();
expect(result.verifications).toBeNull();
expect(result.lost).toBeNull();
expect(prisma.verificationRequest.count).not.toHaveBeenCalled();
});
it("rejects non-admins", async () => {
mockAssertAdmin.mockRejectedValue(new TRPCError({ code: "FORBIDDEN" }));
await expect(adminOverviewRouter.createCaller(ctx(prisma)).getQueues()).rejects.toThrow(TRPCError);
});
});
it("getKpis compares this week with last week", async () => {
prisma.owner.count.mockResolvedValueOnce(28).mockResolvedValueOnce(5);
prisma.pet.count.mockResolvedValueOnce(32).mockResolvedValueOnce(6);
prisma.post.count.mockResolvedValueOnce(9).mockResolvedValueOnce(4);
const result = await adminOverviewRouter.createCaller(ctx(prisma)).getKpis();
expect(result).toEqual({ owners: 28, ownersThisWeek: 5, pets: 32, petsThisWeek: 6, postsThisWeek: 9, postsLastWeek: 4, dau: 3, mau: 12 });
});
describe("getInbox", () => {
it("merges all sources newest first with a readable status", async () => {
prisma.report.findMany.mockResolvedValue([
{ id: "r1", createdAt: at("2026-10-05T09:00:00Z"), reason: "SPAM", targetPostId: "post-1", reporter: { name: "Mia" } },
]);
prisma.verificationRequest.findMany.mockResolvedValue([
{ id: "v1", createdAt: at("2026-10-05T08:00:00Z"), status: "APPROVED", pet: { name: "Nala" } },
]);
prisma.lostReport.findMany.mockResolvedValue([
{ id: "l1", createdAt: at("2026-10-04T18:00:00Z"), foundAt: null, pet: { name: "Milo" } },
]);
prisma.message.findMany.mockResolvedValue([
{ id: "m1", createdAt: at("2026-10-05T10:00:00Z"), readAt: null, body: "x".repeat(100), senderPet: { name: "Lea" } },
]);
const items = await adminOverviewRouter.createCaller(ctx(prisma)).getInbox();
expect(items.map((i) => [i.kind, i.status])).toEqual([
["MESSAGE", "OPEN"],
["REPORT", "OPEN"],
["VERIFICATION", "DONE"],
["LOST", "ACTIVE"],
]);
expect(items[0]!.detail).toHaveLength(81);
expect(items[1]!.target).toBe("POST");
});
it("leaves out verification and lost reports for SUPPORT", async () => {
mockAssertAdmin.mockResolvedValue("SUPPORT");
await adminOverviewRouter.createCaller(ctx(prisma)).getInbox();
expect(prisma.verificationRequest.findMany).not.toHaveBeenCalled();
expect(prisma.lostReport.findMany).not.toHaveBeenCalled();
});
});
describe("search", () => {
it("finds pets by name or nickname and owners via Clerk", async () => {
prisma.pet.findMany.mockResolvedValue([{ id: "p1", name: "Berli", nickname: null, avatarKey: null }]);
mockGetUserList.mockResolvedValue({
data: [{ id: "user_1", firstName: "Anna", lastName: null, primaryEmailAddress: { emailAddress: "anna@example.com" }, emailAddresses: [] }],
});
const result = await adminOverviewRouter.createCaller(ctx(prisma)).search({ query: " berl " });
expect(prisma.pet.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ name: { contains: "berl", mode: "insensitive" } }, { nickname: { contains: "berl", mode: "insensitive" } }] } })
);
expect(mockGetUserList).toHaveBeenCalledWith({ query: "berl", limit: 6 });
expect(result).toEqual({
pets: [{ id: "p1", name: "Berli", nickname: null, avatarUrl: null }],
owners: [{ id: "user_1", name: "Anna", email: "anna@example.com" }],
});
});
it("needs at least two characters", async () => {
await expect(adminOverviewRouter.createCaller(ctx(prisma)).search({ query: "b" })).rejects.toThrow();
});
});
});
+14
View File
@@ -140,6 +140,7 @@ interface MockBlockClient {
interface MockReportClient {
create: MockFn;
findFirst: MockFn;
findMany: MockFn;
findUnique: MockFn;
delete: MockFn;
@@ -376,6 +377,8 @@ interface MockPrismaClient {
story: MockStoryClient;
storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn };
lostReport: { findFirst: MockFn; findUnique: MockFn; findMany: MockFn; count: MockFn; create: MockFn; update: MockFn; delete: MockFn };
verificationRequest: { findFirst: MockFn; findMany: MockFn; count: MockFn };
message: { findFirst: MockFn; findMany: MockFn; count: MockFn };
storyView: MockStoryViewClient;
follow: MockFollowClient;
block: MockBlockClient;
@@ -508,6 +511,16 @@ export function createMockPrisma(): MockPrismaClient {
deleteMany: vi.fn(),
aggregate: vi.fn(),
},
verificationRequest: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
message: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
lostReport: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn(),
@@ -552,6 +565,7 @@ export function createMockPrisma(): MockPrismaClient {
},
report: {
create: vi.fn(),
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
+71
View File
@@ -35,6 +35,9 @@
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@@ -125,6 +128,74 @@
--sidebar-ring: oklch(0.556 0 0);
}
/* Admin-Panel (/p/[secret]): eigene, warme Palette mit Orange als Primärfarbe in
beiden Modi (die Website-.dark-Tokens sind neutral grau, Primär = hellgrau).
Hell ist Standard; `dark` kommt zusätzlich zu `admin-theme` dazu (Cookie
pf_admin_theme, siehe AdminThemeToggle). Dazu Statusfarben für Abzeichen. */
.admin-theme {
--background: oklch(0.982 0.004 70);
--foreground: oklch(0.2 0.012 50);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.012 50);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.012 50);
--primary: oklch(0.62 0.19 42);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.955 0.006 70);
--secondary-foreground: oklch(0.25 0.012 50);
--muted: oklch(0.955 0.006 70);
--muted-foreground: oklch(0.48 0.014 55);
--accent: oklch(0.95 0.02 60);
--accent-foreground: oklch(0.25 0.012 50);
--destructive: oklch(0.55 0.21 27);
--border: oklch(0.91 0.008 70);
--input: oklch(0.89 0.01 70);
--ring: oklch(0.62 0.19 42);
--success: oklch(0.52 0.12 155);
--warning: oklch(0.58 0.14 70);
--info: oklch(0.52 0.13 245);
--chart-1: oklch(0.62 0.19 42);
--chart-2: oklch(0.52 0.12 155);
--chart-3: oklch(0.52 0.13 245);
--sidebar: oklch(1 0 0);
--sidebar-foreground: oklch(0.3 0.012 50);
--sidebar-accent: oklch(0.96 0.03 60);
--sidebar-accent-foreground: oklch(0.45 0.16 42);
--sidebar-border: oklch(0.91 0.008 70);
}
.admin-theme.dark {
--background: oklch(0.17 0.008 50);
--foreground: oklch(0.96 0.006 70);
--card: oklch(0.215 0.009 50);
--card-foreground: oklch(0.96 0.006 70);
--popover: oklch(0.24 0.01 50);
--popover-foreground: oklch(0.96 0.006 70);
--primary: oklch(0.72 0.17 45);
--primary-foreground: oklch(0.18 0.02 45);
--secondary: oklch(0.27 0.01 50);
--secondary-foreground: oklch(0.94 0.006 70);
--muted: oklch(0.26 0.01 50);
--muted-foreground: oklch(0.72 0.012 60);
--accent: oklch(0.29 0.025 50);
--accent-foreground: oklch(0.96 0.006 70);
--destructive: oklch(0.7 0.17 25);
--border: oklch(1 0 0 / 9%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.72 0.17 45);
--success: oklch(0.74 0.13 155);
--warning: oklch(0.8 0.13 80);
--info: oklch(0.74 0.11 245);
--chart-1: oklch(0.72 0.17 45);
--chart-2: oklch(0.74 0.13 155);
--chart-3: oklch(0.74 0.11 245);
--sidebar: oklch(0.195 0.009 50);
--sidebar-foreground: oklch(0.85 0.008 70);
--sidebar-accent: oklch(0.3 0.04 45);
--sidebar-accent-foreground: oklch(0.85 0.12 50);
--sidebar-border: oklch(1 0 0 / 8%);
}
@layer base {
* {
@apply border-border outline-ring/50;
+33 -33
View File
@@ -203,7 +203,7 @@ export default function AdminAdsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-zinc-100">Advertisements</h1>
<h1 className="text-2xl font-bold text-foreground">Werbung</h1>
<Button size="sm" onClick={openCreate} className="bg-orange-500 hover:bg-orange-600 text-white">
<Plus className="h-4 w-4 mr-1.5" />
New ad
@@ -212,16 +212,16 @@ export default function AdminAdsPage() {
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : ads.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">Noch keine Ads — oben erstellen</p>
<p className="text-sm text-muted-foreground text-center py-12">Noch keine Ads — oben erstellen</p>
) : (
<div className="space-y-2">
{ads.map((ad) => (
<div
key={ad.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
className="flex items-center gap-4 rounded-xl border border-border bg-card px-4 py-3"
>
{/* Thumbnail */}
{ad.imageKey ? (
@@ -232,21 +232,21 @@ export default function AdminAdsPage() {
className="h-12 w-12 rounded-lg object-cover shrink-0"
/>
) : (
<div className="h-12 w-12 rounded-lg bg-zinc-800 flex items-center justify-center shrink-0">
<ImageOff className="h-5 w-5 text-zinc-600" />
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center shrink-0">
<ImageOff className="h-5 w-5 text-muted-foreground/70" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-zinc-100 truncate">{ad.title}</p>
<p className="text-sm font-medium text-foreground truncate">{ad.title}</p>
{ad.isActive ? (
<Badge className="bg-green-700 text-green-100 text-[10px]">Aktiv</Badge>
) : (
<Badge variant="secondary" className="text-[10px]">Inaktiv</Badge>
)}
{ad.species.length > 0 && (
<Badge variant="outline" className="text-[10px] text-zinc-400 border-zinc-700">
<Badge variant="outline" className="text-[10px] text-muted-foreground border-border">
{ad.species
.map((id) => speciesList?.find((s) => s.id === id)?.name ?? "?")
.join(", ")}
@@ -255,15 +255,15 @@ export default function AdminAdsPage() {
)}
</div>
{ad.caption && (
<p className="text-xs text-zinc-500 truncate mt-0.5">{ad.caption}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">{ad.caption}</p>
)}
{ad.targetUrl && (
<p className="text-xs text-zinc-600 truncate">{ad.targetUrl}</p>
<p className="text-xs text-muted-foreground/70 truncate">{ad.targetUrl}</p>
)}
</div>
{/* Results — what advertisers actually want to see */}
<div className="flex items-center gap-3 shrink-0 text-xs text-zinc-500">
<div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
<span className="flex items-center gap-1" title="Views">
<Eye className="h-3.5 w-3.5" />
{ad.adViewCount.toLocaleString()}
@@ -283,10 +283,10 @@ export default function AdminAdsPage() {
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="ghost" className="text-zinc-400 hover:text-zinc-100" onClick={() => openEdit(ad)}>
<Button size="sm" variant="ghost" className="text-muted-foreground hover:text-foreground" onClick={() => openEdit(ad)}>
<Pencil className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="text-red-400 hover:text-red-300" onClick={() => setDeleteId(ad.id)}>
<Button size="sm" variant="ghost" className="text-destructive hover:opacity-80" onClick={() => setDeleteId(ad.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -297,7 +297,7 @@ export default function AdminAdsPage() {
{/* Create / Edit dialog */}
<Dialog open={dialogOpen} onOpenChange={(o) => { if (!o) closeDialog(); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
<DialogContent className="bg-card border-border text-foreground max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "Ad bearbeiten" : "Ad erstellen"}</DialogTitle>
</DialogHeader>
@@ -348,7 +348,7 @@ export default function AdminAdsPage() {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={imageUploading}
className="w-full h-28 rounded-xl border-2 border-dashed border-zinc-700 hover:border-zinc-500 flex flex-col items-center justify-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full h-28 rounded-xl border-2 border-dashed border-border hover:border-ring/50 flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{imageUploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
@@ -366,40 +366,40 @@ export default function AdminAdsPage() {
placeholder="Titel *"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<Textarea
placeholder="Caption"
value={form.caption}
onChange={(e) => setForm({ ...form, caption: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
rows={3}
/>
<Input
placeholder="Ziel-URL (https://…)"
value={form.targetUrl}
onChange={(e) => setForm({ ...form, targetUrl: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
{/* Targeting (Gitea #9) — species checkboxes, each expandable into
its own breed checklist. Empty selection = shown to everyone. */}
<div className="space-y-2">
<label className="text-xs text-zinc-500">
<label className="text-xs text-muted-foreground">
Zielgruppe (leer = alle Tierarten &amp; Rassen)
</label>
<div className="space-y-2 rounded-lg border border-zinc-800 p-3">
<div className="space-y-2 rounded-lg border border-border p-3">
{(speciesList ?? []).map((s) => {
const checked = form.species.includes(s.id);
const breeds = breedsBySpecies[s.id] ?? [];
return (
<div key={s.id} className="space-y-1.5">
<label className="flex items-center gap-2 text-sm text-zinc-200">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={checked}
onChange={(e) => toggleSpecies(s.id, e.target.checked)}
className="h-4 w-4 rounded border-zinc-600 bg-zinc-800 accent-orange-500"
className="h-4 w-4 rounded border-border bg-muted accent-orange-500"
/>
{s.name}
</label>
@@ -408,13 +408,13 @@ export default function AdminAdsPage() {
{breeds.map((b) => (
<label
key={b.id}
className="flex items-center gap-1.5 text-xs text-zinc-400"
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<input
type="checkbox"
checked={form.breeds.includes(b.id)}
onChange={(e) => toggleBreed(b.id, e.target.checked)}
className="h-3.5 w-3.5 rounded border-zinc-600 bg-zinc-800 accent-orange-500"
className="h-3.5 w-3.5 rounded border-border bg-muted accent-orange-500"
/>
{b.name}
</label>
@@ -429,21 +429,21 @@ export default function AdminAdsPage() {
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-zinc-500">Startdatum</label>
<label className="text-xs text-muted-foreground">Startdatum</label>
<Input
type="datetime-local"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100"
className="bg-muted border-border text-foreground"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-500">Enddatum</label>
<label className="text-xs text-muted-foreground">Enddatum</label>
<Input
type="datetime-local"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100"
className="bg-muted border-border text-foreground"
/>
</div>
</div>
@@ -452,12 +452,12 @@ export default function AdminAdsPage() {
checked={form.isActive}
onCheckedChange={(v: boolean) => setForm({ ...form, isActive: v })}
/>
<span className="text-sm text-zinc-300">Aktiv</span>
<span className="text-sm text-foreground">Aktiv</span>
</div>
</div>
<DialogFooter className="gap-2 mt-2">
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
<Button variant="ghost" className="text-muted-foreground" onClick={closeDialog}>
<X className="h-4 w-4 mr-1.5" />
Abbrechen
</Button>
@@ -479,13 +479,13 @@ export default function AdminAdsPage() {
{/* Delete confirm */}
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Ad löschen</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">Diese Anzeige dauerhaft löschen?</p>
<p className="text-sm text-muted-foreground">Diese Anzeige dauerhaft löschen?</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteId(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDeleteId(null)}>
Abbrechen
</Button>
<Button
+21 -21
View File
@@ -19,9 +19,9 @@ const TYPE_LABEL: Record<BlacklistEntryType, string> = {
};
const TYPE_BADGE_CLASS: Record<BlacklistEntryType, string> = {
WORD: "bg-amber-950/60 text-amber-300",
LINK: "bg-sky-950/60 text-sky-300",
HASHTAG: "bg-fuchsia-950/60 text-fuchsia-300",
WORD: "bg-warning/10 text-warning",
LINK: "bg-info/10 text-info",
HASHTAG: "bg-fuchsia-500/12 text-fuchsia-700 dark:text-fuchsia-300",
};
type TypeFilter = "all" | BlacklistEntryType;
@@ -96,8 +96,8 @@ export default function AdminBlacklistPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Blacklist</h1>
<p className="text-sm text-zinc-500 mt-1">
<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.
@@ -105,12 +105,12 @@ export default function AdminBlacklistPage() {
</div>
{/* Add entry */}
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900 p-3">
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-card p-3">
<Select value={newType} onValueChange={(v) => setNewType(v as BlacklistEntryType)}>
<SelectTrigger className="w-32 bg-zinc-800 border-zinc-700 text-zinc-100">
<SelectTrigger className="w-32 bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectContent className="bg-muted border-border">
<SelectItem value={BlacklistEntryType.WORD}>Word</SelectItem>
<SelectItem value={BlacklistEntryType.LINK}>Link</SelectItem>
<SelectItem value={BlacklistEntryType.HASHTAG}>Hashtag</SelectItem>
@@ -129,12 +129,12 @@ export default function AdminBlacklistPage() {
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
className="flex-1 min-w-[200px] bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="flex-1 min-w-[200px] bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<Button
onClick={() => void handleCreate()}
disabled={isBulkAdding || !newValue.trim()}
className="bg-zinc-700 hover:bg-zinc-600"
className="bg-secondary hover:bg-accent"
>
{isBulkAdding ? (
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
@@ -147,10 +147,10 @@ export default function AdminBlacklistPage() {
{/* Filter */}
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as TypeFilter)}>
<SelectTrigger className="w-40 bg-zinc-900 border-zinc-700 text-zinc-100">
<SelectTrigger className="w-40 bg-card border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectContent className="bg-muted border-border">
<SelectItem value="all">All types</SelectItem>
<SelectItem value={BlacklistEntryType.WORD}>Word</SelectItem>
<SelectItem value={BlacklistEntryType.LINK}>Link</SelectItem>
@@ -161,10 +161,10 @@ export default function AdminBlacklistPage() {
{/* List */}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<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-zinc-500">
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
<Ban className="h-8 w-8" aria-hidden="true" />
<p className="text-sm">No blacklist entries yet.</p>
</div>
@@ -173,21 +173,21 @@ export default function AdminBlacklistPage() {
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-3 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
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]}`}>
{TYPE_LABEL[entry.type]}
</Badge>
<span className="flex-1 min-w-0 truncate text-sm text-zinc-100 font-mono">
<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-zinc-500">
<span className="shrink-0 text-xs text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()}
</span>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 shrink-0"
className="text-destructive hover:opacity-80 shrink-0"
onClick={() => setDeleteTarget(entry.id)}
disabled={deleteMutation.isPending}
>
@@ -203,15 +203,15 @@ export default function AdminBlacklistPage() {
)}
<Dialog open={!!deleteTarget} onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Remove blacklist entry</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Already-hidden posts stay hidden — this only stops future matches.
</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
+19 -19
View File
@@ -67,8 +67,8 @@ export default function AdminBroadcastsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Mitteilungen</h1>
<p className="text-sm text-zinc-500 mt-1">
<h1 className="text-2xl font-bold text-foreground">Mitteilungen</h1>
<p className="text-sm text-muted-foreground mt-1">
System-weite Ankündigungen an alle Owner. Senden zeigt den Banner beim nächsten
App-Start; Owner bestätigen mit &quot;Gelesen&quot;. Verlauf zeigt, wie viele Owner die
Mitteilung schon erhalten bzw. gelesen haben.
@@ -76,13 +76,13 @@ export default function AdminBroadcastsPage() {
</div>
{/* Send form */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4 space-y-3">
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
<Input
placeholder="Titel"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<Textarea
placeholder="Nachricht (wird 1:1 im Owner-Banner angezeigt)"
@@ -90,10 +90,10 @@ export default function AdminBroadcastsPage() {
onChange={(e) => setBody(e.target.value)}
maxLength={2000}
rows={4}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-600">{body.length}/2000</span>
<span className="text-xs text-muted-foreground/70">{body.length}/2000</span>
<Button
onClick={handleSend}
disabled={createMutation.isPending || !title.trim() || !body.trim()}
@@ -111,33 +111,33 @@ export default function AdminBroadcastsPage() {
{/* History */}
<div>
<h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-2">Verlauf</h2>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Verlauf</h2>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : broadcasts.length === 0 ? (
<p className="text-sm text-zinc-500 py-4">Noch keine Mitteilungen gesendet.</p>
<p className="text-sm text-muted-foreground py-4">Noch keine Mitteilungen gesendet.</p>
) : (
<div className="space-y-2">
{broadcasts.map((b) => (
<div key={b.id} className="rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3">
<div key={b.id} className="rounded-xl border border-border bg-card px-4 py-3">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-sm font-medium text-zinc-100">{b.title}</span>
<span className="text-sm font-medium text-foreground">{b.title}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-zinc-600">{formatDate(b.createdAt)}</span>
<span className="text-xs text-muted-foreground/70">{formatDate(b.createdAt)}</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
className="h-6 w-6 p-0 text-destructive hover:opacity-80"
onClick={() => setDeleteTarget({ id: b.id, title: b.title })}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="text-sm text-zinc-400 whitespace-pre-wrap">{b.body}</p>
<p className="text-xs text-zinc-600 mt-2">
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{b.body}</p>
<p className="text-xs text-muted-foreground/70 mt-2">
{b.deliveredCount}/{b.totalOwners} erhalten · {b.readCount}/{b.totalOwners} gelesen
</p>
</div>
@@ -151,11 +151,11 @@ export default function AdminBroadcastsPage() {
open={!!deleteTarget}
onOpenChange={(o) => { if (!o) { setDeleteTarget(null); setDeleteReason(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Mitteilung löschen</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
&quot;{deleteTarget?.title}&quot; wird für alle Owner entfernt. Diese Aktion kann nicht
rückgängig gemacht werden.
</p>
@@ -164,10 +164,10 @@ export default function AdminBroadcastsPage() {
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
maxLength={500}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDeleteTarget(null)}>
Abbrechen
</Button>
<Button
+1 -1
View File
@@ -39,7 +39,7 @@ export default function AdminCuratorPage() {
</div>
<section className="flex items-center gap-4 rounded-xl border p-4">
<Sprout className="h-6 w-6 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
<Sprout className="h-6 w-6 shrink-0 text-emerald-600 dark:text-success" aria-hidden="true" />
<div className="flex-1">
<p className="font-medium">Gründungsmitglieder</p>
<p className="text-sm text-muted-foreground">Die ersten {data.foundingLimit} Tiere bekommen das Abzeichen automatisch und dauerhaft.</p>
+30 -72
View File
@@ -1,31 +1,15 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@clerk/nextjs/server";
import { cookies } from "next/headers";
import Link from "next/link";
import { createTRPCCaller } from "@/trpc/server";
import { timingSafeStringEqual } from "@/lib/timing-safe-compare";
import { AdminMobileNav } from "@/components/admin/AdminMobileNav";
import { AdminSidebarNav } from "@/components/admin/AdminSidebarNav";
import { AdminNavList } from "@/components/admin/AdminNavList";
import { AdminTopbar } from "@/components/admin/AdminTopbar";
import { ADMIN_ROOT_ID, ADMIN_THEME_COOKIE, type AdminTheme } from "@/components/admin/admin-theme";
import { ADMIN_ROLE_LABEL, buildAdminNav } from "@/components/admin/admin-nav";
import { ClerkThemeProvider } from "@/components/providers/ClerkThemeProvider";
import {
LayoutDashboard,
Users,
Megaphone,
ShieldCheck,
FileText,
Flag,
Scale,
ScrollText,
BadgeCheck,
Ban,
MessageCircle,
ArrowLeft,
Radio,
Music,
Gift,
Sprout,
PawPrint,
Siren,
} from "lucide-react";
import { ArrowLeft, PawPrint } from "lucide-react";
interface AdminLayoutProps {
children: React.ReactNode;
@@ -56,39 +40,9 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
notFound();
}
const isModeratorOrAbove = myRole === "BOOTSTRAP" || myRole !== "SUPPORT";
const base = `/p/${secret}`;
const nav = [
{ href: base, label: "Dashboard", Icon: LayoutDashboard },
{ href: `${base}/posts`, label: "Posts", Icon: FileText },
{ href: `${base}/reports`, label: "Reports", Icon: Flag },
{ href: `${base}/users`, label: "Users", Icon: Users },
{ href: `${base}/pets`, label: "Tiere", Icon: PawPrint },
{ href: `${base}/messages`, label: "Messages", Icon: MessageCircle },
{ href: `${base}/ads`, label: "Ads", Icon: Megaphone },
{ href: `${base}/music`, label: "Musik-Katalog", Icon: Music },
{ href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck },
{ href: `${base}/legal`, label: "Legal", Icon: Scale },
...(isModeratorOrAbove
? [
{ href: `${base}/broadcasts`, label: "Mitteilungen", Icon: Radio },
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
{ href: `${base}/lost`, label: "Vermisst", Icon: Siren },
{ href: `${base}/referrals`, label: "Empfehlungen", Icon: Gift },
{ href: `${base}/curator`, label: "Begrüßung", Icon: Sprout },
{ href: `${base}/blacklist`, label: "Blacklist", Icon: Ban },
]
: []),
];
const navForMobile = nav.map(({ href, label, Icon }) => ({
href,
label,
icon: <Icon className="h-4 w-4 shrink-0" />,
}));
const groups = buildAdminNav(base, myRole);
const theme: AdminTheme = (await cookies()).get(ADMIN_THEME_COOKIE)?.value === "dark" ? "dark" : "light";
return (
// ClerkThemeProvider mounts Clerk's client SDK, which keeps the short-lived
@@ -102,31 +56,35 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
// SpeedUp-Plan P0 deliberately kept off non-admin routes — acceptable here
// since the admin panel is an internal ops tool, not perf-critical.
<ClerkThemeProvider>
{/* Admin stays permanently dark regardless of the visitor site's theme
toggle — a moderator's ops tool, not a marketing surface — but now
pinned to PawFeed's own dark palette (the `dark` class resolves the
real design tokens) instead of a generic, disconnected zinc scaffold. */}
<div className="dark flex min-h-screen flex-col bg-background text-foreground md:flex-row">
{/* Mobile topbar + drawer — hidden md: and up */}
<AdminMobileNav nav={navForMobile} base={base} />
{/* Sidebar — hidden below md:, permanent from md: up */}
<aside className="hidden md:flex w-56 shrink-0 border-r border-border flex-col py-6 px-4 gap-1 h-screen sticky top-0 overflow-y-auto">
<AdminSidebarNav base={base} nav={navForMobile} />
<div className="mt-auto pt-3 border-t border-border">
{/* Eigene Admin-Palette (globals.css .admin-theme), hell als Standard,
dunkel per Umschalter in der Kopfzeile — unabhängig vom Website-Theme. */}
<div
id={ADMIN_ROOT_ID}
className={`admin-theme ${theme === "dark" ? "dark" : ""} flex min-h-screen bg-background text-foreground`}
>
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col gap-5 overflow-y-auto border-r border-sidebar-border bg-sidebar px-3 py-5 md:flex">
<Link href={base} className="flex items-center gap-2.5 px-3">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-primary text-primary-foreground">
<PawPrint className="h-4 w-4" aria-hidden />
</span>
<span className="text-[15px] font-bold tracking-tight text-foreground">PawFeed Admin</span>
</Link>
<AdminNavList base={base} groups={groups} />
<div className="mt-auto border-t border-sidebar-border pt-3">
<Link
href="/feed"
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="h-4 w-4 shrink-0" />
Zurück
<ArrowLeft className="h-4 w-4 shrink-0" aria-hidden />
Zurück zur App
</Link>
</div>
</aside>
{/* Content */}
<main className="min-w-0 flex-1 overflow-auto p-4 md:p-8">{children}</main>
<div className="flex min-w-0 flex-1 flex-col">
<AdminTopbar base={base} groups={groups} roleLabel={ADMIN_ROLE_LABEL[myRole]} theme={theme} />
<main className="min-w-0 flex-1 p-4 md:p-8">{children}</main>
</div>
</div>
</ClerkThemeProvider>
);
+13 -13
View File
@@ -68,8 +68,8 @@ export default function AdminLegalPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Legal-Updates</h1>
<p className="text-sm text-zinc-500 mt-1">
<h1 className="text-2xl font-bold text-foreground">Rechtstexte</h1>
<p className="text-sm text-muted-foreground mt-1">
Veröffentlichen ist gleichzeitig das Senden — jeder Owner sieht die Zusammenfassung
als Banner, bis er sie bestätigt. Ein erneutes Veröffentlichen (auch mit gleichem Text)
zeigt das Banner allen Owner erneut.
@@ -77,9 +77,9 @@ export default function AdminLegalPage() {
</div>
{/* Publish form */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4 space-y-3">
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
<Select value={docType} onValueChange={(v) => setDocType(v as typeof docType)}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-zinc-100 w-64">
<SelectTrigger className="bg-muted border-border text-foreground w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -93,10 +93,10 @@ export default function AdminLegalPage() {
onChange={(e) => setChangeSummary(e.target.value)}
maxLength={1000}
rows={3}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-600">{changeSummary.length}/1000</span>
<span className="text-xs text-muted-foreground/70">{changeSummary.length}/1000</span>
<Button
onClick={handlePublish}
disabled={publishMutation.isPending || !changeSummary.trim()}
@@ -114,24 +114,24 @@ export default function AdminLegalPage() {
{/* History */}
<div>
<h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-2">Verlauf</h2>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Verlauf</h2>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : versions.length === 0 ? (
<p className="text-sm text-zinc-500 py-4">Noch keine Legal-Updates veröffentlicht.</p>
<p className="text-sm text-muted-foreground py-4">Noch keine Legal-Updates veröffentlicht.</p>
) : (
<div className="space-y-2">
{versions.map((v) => (
<div key={v.id} className="rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3">
<div key={v.id} className="rounded-xl border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-orange-400">
<span className="text-xs font-medium text-primary">
{DOC_TYPE_LABELS[v.docType] ?? v.docType}
</span>
<span className="text-xs text-zinc-600">{formatDate(v.publishedAt)}</span>
<span className="text-xs text-muted-foreground/70">{formatDate(v.publishedAt)}</span>
</div>
<p className="text-sm text-zinc-200">{v.changeSummary}</p>
<p className="text-sm text-foreground">{v.changeSummary}</p>
</div>
))}
</div>
+23 -23
View File
@@ -35,7 +35,7 @@ const TARGET_TYPES = ["POST", "COMMENT", "USER", "REPORT", "AD", "SYSTEM"] as co
const ROLE_BADGE_CLASS: Record<string, string> = {
BOOTSTRAP: "bg-orange-700 text-orange-100",
SUPER_ADMIN: "bg-orange-700 text-orange-100",
MODERATOR: "bg-zinc-700 text-zinc-200",
MODERATOR: "bg-secondary text-foreground",
SUPPORT: "bg-blue-700 text-blue-100",
};
@@ -64,8 +64,8 @@ export default function AdminAuditLogPage() {
const isForbidden = error?.data?.code === "FORBIDDEN";
return (
<div className="flex flex-col items-center justify-center gap-2 py-20 text-center">
<ShieldAlert className="h-8 w-8 text-zinc-600" />
<p className="text-sm text-zinc-400">
<ShieldAlert className="h-8 w-8 text-muted-foreground/70" />
<p className="text-sm text-muted-foreground">
{isForbidden
? "Keine Berechtigung — das Audit Log ist ab Moderator-Rolle sichtbar."
: "Log konnte nicht geladen werden."}
@@ -77,19 +77,19 @@ export default function AdminAuditLogPage() {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold text-zinc-100">Audit Log</h1>
<h1 className="text-2xl font-bold text-foreground">Protokoll</h1>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Moderator user_ID…"
value={moderatorId}
onChange={(e) => setModeratorId(e.target.value)}
className="w-56 bg-zinc-900 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 font-mono text-sm"
className="w-56 bg-card border-border text-foreground placeholder:text-muted-foreground font-mono text-sm"
/>
<Select value={action} onValueChange={(v) => setAction(v ?? "all")}>
<SelectTrigger className="w-44 bg-zinc-900 border-zinc-700 text-zinc-100">
<SelectTrigger className="w-44 bg-card border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectContent className="bg-muted border-border">
<SelectItem value="all">All actions</SelectItem>
{ACTIONS.map((a) => (
<SelectItem key={a} value={a}>
@@ -99,10 +99,10 @@ export default function AdminAuditLogPage() {
</SelectContent>
</Select>
<Select value={targetType} onValueChange={(v) => setTargetType(v ?? "all")}>
<SelectTrigger className="w-36 bg-zinc-900 border-zinc-700 text-zinc-100">
<SelectTrigger className="w-36 bg-card border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectContent className="bg-muted border-border">
<SelectItem value="all">All targets</SelectItem>
{TARGET_TYPES.map((t) => (
<SelectItem key={t} value={t}>
@@ -116,19 +116,19 @@ export default function AdminAuditLogPage() {
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : entries.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">No entries match these filters</p>
<p className="text-sm text-muted-foreground text-center py-12">No entries match these filters</p>
) : (
<div className="space-y-2">
{/* Fixed-width grid columns instead of flex-wrap — long IDs/IPs used
to force rows to overflow the viewport instead of wrapping. The
outer scroll wrapper lets the whole table scroll as one unit on
narrow screens rather than clipping individual cells. */}
<div className="overflow-x-auto rounded-xl border border-zinc-800">
<div className="overflow-x-auto rounded-xl border border-border">
<div className="min-w-[1000px]">
<div className="grid grid-cols-[150px_190px_1fr_130px_170px] gap-3 border-b border-zinc-800 bg-zinc-900/80 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<div className="grid grid-cols-[150px_190px_1fr_130px_170px] gap-3 border-b border-border bg-card/90 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>Action</span>
<span>Target</span>
<span>Moderator</span>
@@ -136,23 +136,23 @@ export default function AdminAuditLogPage() {
<span>Time</span>
</div>
{entries.map((entry) => (
<div key={entry.id} className="border-b border-zinc-800 bg-zinc-900 last:border-b-0">
<div key={entry.id} className="border-b border-border bg-card last:border-b-0">
<div className="grid grid-cols-[150px_190px_1fr_130px_170px] items-center gap-3 px-4 py-3">
<Badge variant="secondary" className="w-fit text-[10px]">
{entry.action}
</Badge>
<p className="min-w-0 truncate text-xs text-zinc-500" title={entry.targetId}>
<p className="min-w-0 truncate text-xs text-muted-foreground" title={entry.targetId}>
{entry.targetType} · <span className="font-mono">{entry.targetId}</span>
</p>
<div className="min-w-0">
<p className="flex items-center gap-2 truncate text-sm text-zinc-200">
<p className="flex items-center gap-2 truncate text-sm text-foreground">
<span className="truncate">
{entry.moderatorName ?? <span className="text-zinc-500">unnamed</span>}
{entry.moderatorName ?? <span className="text-muted-foreground">unnamed</span>}
</span>
{entry.moderatorRole && (
<Badge
className={`shrink-0 text-[10px] ${
ROLE_BADGE_CLASS[entry.moderatorRole] ?? "bg-zinc-700 text-zinc-200"
ROLE_BADGE_CLASS[entry.moderatorRole] ?? "bg-secondary text-foreground"
}`}
>
{entry.moderatorRole}
@@ -160,21 +160,21 @@ export default function AdminAuditLogPage() {
)}
</p>
<p
className="truncate font-mono text-xs text-zinc-600"
className="truncate font-mono text-xs text-muted-foreground/70"
title={entry.moderatorId}
>
{entry.moderatorId}
</p>
</div>
<p className="truncate font-mono text-xs text-zinc-500">
<p className="truncate font-mono text-xs text-muted-foreground">
{entry.ipAddress ?? "—"}
</p>
<p className="truncate text-xs text-zinc-600">
<p className="truncate text-xs text-muted-foreground/70">
{new Date(entry.createdAt).toLocaleString()}
</p>
</div>
{entry.reason && (
<p className="truncate px-4 pb-3 text-xs text-zinc-400">
<p className="truncate px-4 pb-3 text-xs text-muted-foreground">
Reason: {entry.reason}
</p>
)}
@@ -190,7 +190,7 @@ export default function AdminAuditLogPage() {
size="sm"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
className="bg-card border-border text-foreground hover:bg-muted hover:text-foreground"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
+1 -1
View File
@@ -79,7 +79,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-red-950/20" : ""}`}
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" : ""}`}
>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
+18 -18
View File
@@ -73,27 +73,27 @@ export default function AdminMessagesPage() {
return (
<div className="flex h-[calc(100vh-4rem)] gap-4">
{/* Conversation list */}
<div className="w-80 shrink-0 rounded-xl border border-zinc-800 bg-zinc-900 overflow-y-auto">
<div className="px-4 py-3 border-b border-zinc-800">
<h1 className="text-lg font-bold text-zinc-100">Team-Postfach</h1>
<p className="text-xs text-zinc-500 mt-0.5">
<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-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : conversations.length === 0 ? (
<p className="text-sm text-zinc-500 px-4 py-6">Noch keine Konversationen.</p>
<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-zinc-800/60 hover:bg-zinc-800/50 transition-colors ${
activeId === c.id ? "bg-zinc-800" : ""
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">
@@ -105,11 +105,11 @@ export default function AdminMessagesPage() {
className="h-8 w-8 rounded-full object-cover shrink-0"
/>
) : (
<div className="h-8 w-8 rounded-full bg-zinc-700 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-zinc-200 truncate">
<span className="text-sm font-medium text-foreground truncate">
{c.otherPet.name}
</span>
{c.unreadCount > 0 && (
@@ -119,7 +119,7 @@ export default function AdminMessagesPage() {
)}
</div>
{c.lastMessage && (
<p className="text-xs text-zinc-500 truncate mt-0.5">{c.lastMessage.body}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">{c.lastMessage.body}</p>
)}
</div>
</div>
@@ -129,15 +129,15 @@ export default function AdminMessagesPage() {
</div>
{/* Thread */}
<div className="flex-1 flex flex-col rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<div className="flex-1 flex flex-col rounded-xl border border-border bg-card overflow-hidden">
{!activeId ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-zinc-500">
<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>
) : threadLoading || !thread ? (
<div className="flex-1 flex justify-center items-center">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
@@ -148,14 +148,14 @@ export default function AdminMessagesPage() {
<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-zinc-800 text-zinc-100"
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-zinc-500"}`}>
<p className={`text-[10px] mt-1 ${isTeam ? "text-white/60" : "text-muted-foreground"}`}>
{formatDate(m.createdAt)}
</p>
</div>
@@ -163,12 +163,12 @@ export default function AdminMessagesPage() {
);
})}
</div>
<div className="border-t border-zinc-800 p-3 flex gap-2">
<div className="border-t border-border p-3 flex gap-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Als PawFeed Team antworten…"
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
+23 -23
View File
@@ -52,10 +52,10 @@ export default function AdminModeratorsPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Moderators</h1>
<p className="text-xs text-zinc-500 mt-0.5">Only super-admins can manage roles</p>
<h1 className="text-2xl font-bold text-foreground">Team &amp; Rollen</h1>
<p className="text-xs text-muted-foreground mt-0.5">Rollen vergeben nur Super-Admins.</p>
</div>
<Button size="sm" onClick={() => setDialogOpen(true)} className="bg-zinc-700 hover:bg-zinc-600">
<Button size="sm" onClick={() => setDialogOpen(true)} className="bg-secondary hover:bg-accent">
<Plus className="h-4 w-4 mr-1.5" />
Grant role
</Button>
@@ -63,10 +63,10 @@ export default function AdminModeratorsPage() {
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : mods.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">No moderators yet</p>
<p className="text-sm text-muted-foreground text-center py-12">No moderators yet</p>
) : (
<div className="space-y-2">
{mods.map((mod) => {
@@ -74,29 +74,29 @@ export default function AdminModeratorsPage() {
return (
<div
key={mod.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
className="flex items-center gap-4 rounded-xl border border-border bg-card px-4 py-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm text-zinc-100 font-mono truncate">{mod.ownerId}</p>
<p className="text-sm text-foreground font-mono truncate">{mod.ownerId}</p>
<Badge
className={
mod.role === AdminRoleType.SUPER_ADMIN
? "bg-orange-700 text-orange-100 text-[10px]"
: mod.role === AdminRoleType.SUPPORT
? "bg-blue-700 text-blue-100 text-[10px]"
: "bg-zinc-700 text-zinc-200 text-[10px]"
: "bg-secondary text-foreground text-[10px]"
}
>
{mod.role}
</Badge>
</div>
{pet && <p className="text-xs text-zinc-500 mt-0.5">Pet: {pet.name}</p>}
<p className="text-xs text-zinc-500 mt-0.5">
{pet && <p className="text-xs text-muted-foreground mt-0.5">Pet: {pet.name}</p>}
<p className="text-xs text-muted-foreground mt-0.5">
{actionCountByModeratorId.get(mod.ownerId) ?? 0} action
{actionCountByModeratorId.get(mod.ownerId) === 1 ? "" : "s"} (last 30 days)
</p>
<p className="text-xs text-zinc-600 mt-0.5">
<p className="text-xs text-muted-foreground/70 mt-0.5">
Granted {new Date(mod.grantedAt).toLocaleDateString()}
{mod.grantedBy && ` by ${mod.grantedBy.slice(0, 12)}…`}
</p>
@@ -104,7 +104,7 @@ export default function AdminModeratorsPage() {
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 shrink-0"
className="text-destructive hover:opacity-80 shrink-0"
onClick={() => setRevokeTarget(mod.ownerId)}
>
<Trash2 className="h-4 w-4" />
@@ -120,38 +120,38 @@ export default function AdminModeratorsPage() {
open={dialogOpen}
onOpenChange={(o) => { if (!o) { setDialogOpen(false); setNewOwnerId(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Grant role</DialogTitle>
</DialogHeader>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted-foreground">
User must already have an account. Paste their Clerk user ID (starts with{" "}
<code className="text-orange-400">user_</code>).
<code className="text-primary">user_</code>).
</p>
<Input
placeholder="user_xxxxxxxxxxxxxxxxxxxx"
value={newOwnerId}
onChange={(e) => setNewOwnerId(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 font-mono text-sm"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground font-mono text-sm"
/>
<Select value={newRole} onValueChange={(v) => setNewRole(v as AdminRoleType)}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-zinc-100">
<SelectTrigger className="bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectContent className="bg-muted border-border">
<SelectItem value={AdminRoleType.SUPPORT}>Support</SelectItem>
<SelectItem value={AdminRoleType.MODERATOR}>Moderator</SelectItem>
<SelectItem value={AdminRoleType.SUPER_ADMIN}>Super Admin</SelectItem>
</SelectContent>
</Select>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDialogOpen(false)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => grantRole.mutate({ ownerId: newOwnerId.trim(), role: newRole })}
disabled={grantRole.isPending || !newOwnerId.trim()}
className="bg-zinc-700 hover:bg-zinc-600"
className="bg-secondary hover:bg-accent"
>
{grantRole.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Grant
@@ -162,13 +162,13 @@ export default function AdminModeratorsPage() {
{/* Revoke confirm */}
<Dialog open={!!revokeTarget} onOpenChange={(o) => { if (!o) setRevokeTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Revoke role</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">Remove admin/moderator access for this user?</p>
<p className="text-sm text-muted-foreground">Remove admin/moderator access for this user?</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setRevokeTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setRevokeTarget(null)}>
Cancel
</Button>
<Button
+23 -23
View File
@@ -202,7 +202,7 @@ export default function AdminMusicPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-zinc-100">Musik-Katalog</h1>
<h1 className="text-2xl font-bold text-foreground">Musik-Katalog</h1>
<Button size="sm" onClick={openCreate} className="bg-orange-500 hover:bg-orange-600 text-white">
<Plus className="h-4 w-4 mr-1.5" />
Neuer Track
@@ -211,21 +211,21 @@ export default function AdminMusicPage() {
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : tracks.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">Noch keine Tracks — oben hochladen</p>
<p className="text-sm text-muted-foreground text-center py-12">Noch keine Tracks — oben hochladen</p>
) : (
<div className="space-y-2">
{tracks.map((track) => (
<div
key={track.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
className="flex items-center gap-4 rounded-xl border border-border bg-card px-4 py-3"
>
<Button
size="sm"
variant="ghost"
className="h-10 w-10 rounded-full bg-zinc-800 text-zinc-300 shrink-0 p-0"
className="h-10 w-10 rounded-full bg-muted text-foreground shrink-0 p-0"
onClick={() => togglePlay(track)}
>
{playingId === track.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
@@ -233,24 +233,24 @@ export default function AdminMusicPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-zinc-100 truncate">{track.title}</p>
<p className="text-sm font-medium text-foreground truncate">{track.title}</p>
{track.active ? (
<Badge className="bg-green-700 text-green-100 text-[10px]">Aktiv</Badge>
) : (
<Badge variant="secondary" className="text-[10px]">Inaktiv</Badge>
)}
</div>
<p className="text-xs text-zinc-500 truncate mt-0.5">
<p className="text-xs text-muted-foreground truncate mt-0.5">
{track.artist} · {Math.round(track.durationSecs)}s
</p>
<p className="text-xs text-zinc-600 truncate">{track.licenseNote}</p>
<p className="text-xs text-muted-foreground/70 truncate">{track.licenseNote}</p>
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="ghost" className="text-zinc-400 hover:text-zinc-100" onClick={() => openEdit(track)}>
<Button size="sm" variant="ghost" className="text-muted-foreground hover:text-foreground" onClick={() => openEdit(track)}>
<Pencil className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="text-red-400 hover:text-red-300" onClick={() => setDeleteId(track.id)}>
<Button size="sm" variant="ghost" className="text-destructive hover:opacity-80" onClick={() => setDeleteId(track.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -261,7 +261,7 @@ export default function AdminMusicPage() {
{/* Create / Edit dialog */}
<Dialog open={dialogOpen} onOpenChange={(o) => { if (!o) closeDialog(); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
<DialogContent className="bg-card border-border text-foreground max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "Track bearbeiten" : "Track hochladen"}</DialogTitle>
</DialogHeader>
@@ -277,13 +277,13 @@ export default function AdminMusicPage() {
onChange={handleFileChange}
/>
{form.storageKey ? (
<div className="flex items-center gap-2 rounded-xl border border-zinc-700 px-3 py-2 text-sm text-zinc-300">
<Music className="h-4 w-4 text-orange-500" />
<div className="flex items-center gap-2 rounded-xl border border-border px-3 py-2 text-sm text-foreground">
<Music className="h-4 w-4 text-primary" />
{Math.round(form.durationSecs)}s Audiodatei bereit
<button
type="button"
onClick={() => setForm((f) => ({ ...f, storageKey: "", durationSecs: 0 }))}
className="ml-auto h-5 w-5 rounded-full bg-zinc-800 flex items-center justify-center hover:bg-zinc-700"
className="ml-auto h-5 w-5 rounded-full bg-muted flex items-center justify-center hover:bg-secondary"
>
<X className="h-3 w-3" />
</button>
@@ -293,7 +293,7 @@ export default function AdminMusicPage() {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="w-full h-20 rounded-xl border-2 border-dashed border-zinc-700 hover:border-zinc-500 flex flex-col items-center justify-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full h-20 rounded-xl border-2 border-dashed border-border hover:border-ring/50 flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
@@ -312,19 +312,19 @@ export default function AdminMusicPage() {
placeholder="Titel *"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<Input
placeholder="Künstler *"
value={form.artist}
onChange={(e) => setForm({ ...form, artist: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<Textarea
placeholder="Lizenz/Quelle * (z.B. Name der Musikbibliothek, Lizenztyp, Link)"
value={form.licenseNote}
onChange={(e) => setForm({ ...form, licenseNote: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
rows={3}
/>
@@ -334,13 +334,13 @@ export default function AdminMusicPage() {
checked={tracks.find((t) => t.id === editingId)?.active ?? true}
onCheckedChange={(v: boolean) => updateTrack.mutate({ id: editingId, active: v })}
/>
<span className="text-sm text-zinc-300">Aktiv (im Story-Picker sichtbar)</span>
<span className="text-sm text-foreground">Aktiv (im Story-Picker sichtbar)</span>
</div>
)}
</div>
<DialogFooter className="gap-2 mt-2">
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
<Button variant="ghost" className="text-muted-foreground" onClick={closeDialog}>
<X className="h-4 w-4 mr-1.5" />
Abbrechen
</Button>
@@ -369,16 +369,16 @@ export default function AdminMusicPage() {
{/* Delete confirm */}
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Track löschen</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Diesen Track dauerhaft löschen? Stories, die ihn bereits verwenden, behalten ihn (Referenz wird nur
entfernt) — falls unsicher, lieber oben auf &bdquo;Inaktiv&ldquo; schalten statt löschen.
</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteId(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDeleteId(null)}>
Abbrechen
</Button>
<Button
+40 -364
View File
@@ -1,385 +1,61 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useUser } from "@clerk/nextjs";
import { useTRPC } from "@/trpc/client";
import { Flag, Clock, TriangleAlert, TrendingUp, ShieldAlert, Info, ChevronRight, Sparkles } from "lucide-react";
import { TimeSeriesChart } from "@/components/admin/TimeSeriesChart";
import { AdminPageHeader } from "@/components/admin/AdminSection";
import { KpiCards } from "@/components/admin/dashboard/KpiCards";
import { ActivityCard } from "@/components/admin/dashboard/ActivityCard";
import { ModerationStatus } from "@/components/admin/dashboard/ModerationStatus";
import { InboxCard } from "@/components/admin/dashboard/InboxCard";
import { QuickActions } from "@/components/admin/dashboard/QuickActions";
import { SystemCard } from "@/components/admin/dashboard/SystemCard";
import { SpeciesDistributionCard } from "@/components/admin/SpeciesDistributionCard";
import { GranularityToggle, type Granularity } from "@/components/admin/GranularityToggle";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent } from "@/components/ui/card";
import { greetingFor } from "@/lib/admin/format";
/**
* Startseite des Admin-Panels: oben was zählt (Kennzahlen, offene Arbeit),
* darunter Eingänge, Schnellzugriff und Notschalter, ganz unten die Tierarten.
*/
export default function AdminDashboard() {
const pathname = usePathname();
const base = usePathname();
const trpc = useTRPC();
const qc = useQueryClient();
const { data: stats, isLoading } = useQuery(trpc.admin.getStats.queryOptions());
const { user } = useUser();
const { data: myRole } = useQuery(trpc.admin.getMyRole.queryOptions());
const canModerate = myRole !== undefined && myRole !== null && myRole !== "SUPPORT";
const canToggleMaintenance = myRole === "BOOTSTRAP" || myRole === "SUPER_ADMIN";
const { data: maintenance } = useQuery(
trpc.admin.getMaintenanceMode.queryOptions(undefined, { enabled: canToggleMaintenance })
);
const setMaintenance = useMutation(
trpc.admin.setMaintenanceMode.mutationOptions({
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.admin.getMaintenanceMode.queryKey() }),
})
);
const { data: storyEditor } = useQuery(trpc.admin.getStoryEditorEnabled.queryOptions());
const setStoryEditor = useMutation(
trpc.admin.setStoryEditorEnabled.mutationOptions({
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.admin.getStoryEditorEnabled.queryKey() }),
})
);
const [postsGranularity, setPostsGranularity] = useState<Granularity>("day");
const [ownerGranularity, setOwnerGranularity] = useState<Granularity>("day");
const [visitsGranularity, setVisitsGranularity] = useState<Granularity>("day");
const [commentsGranularity, setCommentsGranularity] = useState<Granularity>("day");
const [reactionsGranularity, setReactionsGranularity] = useState<Granularity>("day");
const { data: postsOverTime } = useQuery(
trpc.admin.getPostsOverTime.queryOptions({ granularity: postsGranularity })
);
const { data: ownerGrowth } = useQuery(
trpc.admin.getOwnerGrowth.queryOptions({ granularity: ownerGranularity })
);
const { data: visitsOverTime } = useQuery(
trpc.admin.getVisitsOverTime.queryOptions({ granularity: visitsGranularity })
);
const { data: commentsOverTime } = useQuery(
trpc.admin.getCommentsOverTime.queryOptions({ granularity: commentsGranularity })
);
const { data: reactionsOverTime } = useQuery(
trpc.admin.getReactionsOverTime.queryOptions({ granularity: reactionsGranularity })
);
const today = new Date().toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long", timeZone: "Europe/Berlin" });
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Platform overview</p>
<div className="mx-auto max-w-[1400px] space-y-6">
<AdminPageHeader
eyebrow="Plattformübersicht"
title={`${greetingFor()}${user?.firstName ? `, ${user.firstName}` : ""}`}
description="Hier ist der aktuelle Überblick über PawFeed."
actions={<p className="text-sm text-muted-foreground">{today}</p>}
/>
<KpiCards base={base} />
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div className="xl:col-span-2">
<ActivityCard />
</div>
<ModerationStatus base={base} />
</div>
{canToggleMaintenance && (
<div
className={`flex items-center justify-between gap-4 rounded-xl ring-1 px-5 py-4 ${
maintenance?.enabled
? "ring-destructive/40 bg-destructive/10"
: "ring-foreground/10 bg-card"
}`}
>
<div className="flex items-center gap-3">
<TriangleAlert
className={`h-5 w-5 shrink-0 ${maintenance?.enabled ? "text-destructive" : "text-muted-foreground"}`}
/>
<div>
<p className="text-sm font-semibold text-foreground">Maintenance mode</p>
<p className="text-xs text-muted-foreground mt-0.5">
{maintenance?.enabled
? "Live — all visitors see the maintenance page except the admin panel."
: "Off — the site is publicly reachable."}
</p>
</div>
</div>
<Button
size="sm"
variant={maintenance?.enabled ? "destructive" : "outline"}
disabled={setMaintenance.isPending}
onClick={() => setMaintenance.mutate({ enabled: !maintenance?.enabled })}
>
{maintenance?.enabled ? "Turn off" : "Turn on"}
</Button>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div className="xl:col-span-2">
<InboxCard base={base} />
</div>
)}
{storyEditor && (
<div
className={`flex items-center justify-between gap-4 rounded-xl ring-1 px-5 py-4 ${
storyEditor.enabled ? "ring-foreground/10 bg-card" : "ring-destructive/40 bg-destructive/10"
}`}
>
<div className="flex items-center gap-3">
<Sparkles
className={`h-5 w-5 shrink-0 ${storyEditor.enabled ? "text-muted-foreground" : "text-destructive"}`}
/>
<div>
<p className="text-sm font-semibold text-foreground">Story-Editor (Notschalter)</p>
<p className="text-xs text-muted-foreground mt-0.5">
{storyEditor.enabled
? "An — Text, Markierungen, GIFs und Sticker in Storys sind aktiv."
: "Aus — App und Web blenden Editor und Overlays aus (ohne App-Update)."}
</p>
</div>
</div>
<Button
size="sm"
variant={storyEditor.enabled ? "outline" : "destructive"}
disabled={setStoryEditor.isPending}
onClick={() => setStoryEditor.mutate({ enabled: !storyEditor.enabled })}
>
{storyEditor.enabled ? "Abschalten" : "Wieder einschalten"}
</Button>
<div className="flex flex-col gap-6">
<QuickActions base={base} canModerate={canModerate} />
<SystemCard canToggleMaintenance={canToggleMaintenance} />
</div>
)}
{isLoading ? (
<div className="text-muted-foreground text-sm">Loading…</div>
) : (
<>
{/* Needs attention — the only stats that earn individual weight and
real color; color means "act on this", not "posts are orange". */}
<div>
<h2 className="text-sm font-semibold text-foreground mb-3">Needs attention</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AttentionStat
icon={Flag}
label="Open reports"
value={stats?.reports ?? 0}
href={`${pathname}/reports`}
/>
<AttentionStat
icon={Clock}
label="Verifications pending"
value={stats?.verificationPending ?? 0}
href={`${pathname}/verification`}
/>
</div>
</div>
{/* Everything else, grouped by what kind of number it is — activity
(growth/usage), violations (moderation risk), info (context only).
Category color is a legend, not a wash: only violation values
that are actually > 0 pick up the color, same "act on this"
logic as AttentionStat above. */}
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<StatGroup
icon={TrendingUp}
label="Activity"
color="emerald"
items={[
{ label: "Owners", value: stats?.owners ?? 0 },
{ label: "Pets", value: stats?.pets ?? 0 },
{ label: "Posts", value: stats?.posts ?? 0 },
{ label: "DAU (today)", value: stats?.dau ?? 0 },
{ label: "MAU (30d)", value: stats?.mau ?? 0 },
]}
/>
<StatGroup
icon={ShieldAlert}
label="Violations"
color="rose"
emphasizeNonZero
items={[
{ label: "Banned users", value: stats?.bans ?? 0 },
{ label: "Shadowbanned", value: stats?.shadowbans ?? 0 },
{ label: "Blacklist entries", value: stats?.blacklistEntries ?? 0 },
]}
/>
<StatGroup
icon={Info}
label="Info"
color="sky"
items={[
{ label: "Active ads", value: stats?.activeAds ?? 0 },
{ label: "Verifications closed", value: stats?.verificationClosed ?? 0 },
]}
/>
</div>
</CardContent>
</Card>
</>
)}
</div>
<SpeciesDistributionCard />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Posts over time</CardTitle>
<CardDescription>New posts per {postsGranularity}</CardDescription>
<CardAction>
<GranularityToggle value={postsGranularity} onChange={setPostsGranularity} />
</CardAction>
</CardHeader>
<CardContent>
<TimeSeriesChart
data={postsOverTime ?? []}
granularity={postsGranularity}
variant="bar"
valueLabel="posts"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Owner growth</CardTitle>
<CardDescription>Total registered owners</CardDescription>
<CardAction>
<GranularityToggle value={ownerGranularity} onChange={setOwnerGranularity} />
</CardAction>
</CardHeader>
<CardContent>
<TimeSeriesChart
data={ownerGrowth ?? []}
granularity={ownerGranularity}
variant="line"
valueLabel="owners"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Visits over time</CardTitle>
<CardDescription>Unique active owners per {visitsGranularity}</CardDescription>
<CardAction>
<GranularityToggle value={visitsGranularity} onChange={setVisitsGranularity} />
</CardAction>
</CardHeader>
<CardContent>
<TimeSeriesChart
data={visitsOverTime ?? []}
granularity={visitsGranularity}
variant="bar"
valueLabel="visits"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Comments over time</CardTitle>
<CardDescription>New comments per {commentsGranularity}</CardDescription>
<CardAction>
<GranularityToggle value={commentsGranularity} onChange={setCommentsGranularity} />
</CardAction>
</CardHeader>
<CardContent>
<TimeSeriesChart
data={commentsOverTime ?? []}
granularity={commentsGranularity}
variant="bar"
valueLabel="comments"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Likes over time</CardTitle>
<CardDescription>New reactions per {reactionsGranularity}</CardDescription>
<CardAction>
<GranularityToggle value={reactionsGranularity} onChange={setReactionsGranularity} />
</CardAction>
</CardHeader>
<CardContent>
<TimeSeriesChart
data={reactionsOverTime ?? []}
granularity={reactionsGranularity}
variant="bar"
valueLabel="likes"
/>
</CardContent>
</Card>
</div>
</div>
);
}
/**
* The only stat tiles with individual weight and color — color means
* "this needs action" (orange when > 0), not a decorative per-item hue.
* Each one links straight to the queue it summarizes.
*/
function AttentionStat({
icon: Icon,
label,
value,
href,
}: {
icon: React.ElementType;
label: string;
value: number;
href: string;
}) {
const needsAttention = value > 0;
return (
<Link
href={href}
className={`group rounded-xl ring-1 px-5 py-4 flex items-center gap-4 transition-colors focus-visible:outline-none focus-visible:ring-2 ${
needsAttention
? "ring-orange-500/30 bg-orange-500/5 hover:bg-orange-500/10 focus-visible:ring-orange-500/50"
: "ring-foreground/10 bg-card hover:bg-muted focus-visible:ring-foreground/30"
}`}
>
<Icon className={`h-5 w-5 shrink-0 ${needsAttention ? "text-orange-400" : "text-muted-foreground"}`} />
<div className="flex-1">
<p className={`text-2xl font-bold ${needsAttention ? "text-orange-400" : "text-foreground"}`}>
{value.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
);
}
const STAT_GROUP_COLORS = {
emerald: { text: "text-emerald-400", bg: "bg-emerald-500/5", ring: "ring-emerald-500/15" },
rose: { text: "text-rose-400", bg: "bg-rose-500/5", ring: "ring-rose-500/15" },
sky: { text: "text-sky-400", bg: "bg-sky-500/5", ring: "ring-sky-500/15" },
} as const;
/**
* One labeled, color-identified slice of the Overview card — Activity,
* Violations, or Info. The color lives on the group's icon/label (a legend)
* and, when `emphasizeNonZero` is set, on individual values that are actually
* non-zero — never as a flat wash across numbers that don't mean anything yet.
*/
function StatGroup({
icon: Icon,
label,
color,
items,
emphasizeNonZero = false,
}: {
icon: React.ElementType;
label: string;
color: keyof typeof STAT_GROUP_COLORS;
items: { label: string; value: number }[];
emphasizeNonZero?: boolean;
}) {
const c = STAT_GROUP_COLORS[color];
return (
<div className={`rounded-lg ${c.bg} ring-1 ${c.ring} p-4`}>
<div className={`flex items-center gap-1.5 text-xs font-semibold ${c.text} mb-3`}>
<Icon className="h-3.5 w-3.5" />
{label}
</div>
<dl className="space-y-2.5">
{items.map((item) => {
const flagged = emphasizeNonZero && item.value > 0;
return (
<div key={item.label} className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground">{item.label}</dt>
<dd className={`text-sm font-semibold tabular-nums ${flagged ? c.text : "text-foreground"}`}>
{item.value.toLocaleString()}
</dd>
</div>
);
})}
</dl>
</div>
);
}
+9 -1
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { BadgeCheck, Loader2, Search, Sprout } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useDebounce } from "@/hooks/use-debounce";
@@ -41,7 +42,14 @@ export default function AdminPetsPage() {
const [speciesId, setSpeciesId] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [sort, setSort] = useState<Sort>("newest");
const [openPetId, setOpenPetId] = useState<string | null>(null);
// ?pet= kommt von der globalen Suche in der Kopfzeile und öffnet das Tier direkt.
const urlPetId = useSearchParams().get("pet");
const [openPetId, setOpenPetId] = useState<string | null>(urlPetId);
const [appliedUrlPetId, setAppliedUrlPetId] = useState(urlPetId);
if (urlPetId !== appliedUrlPetId) {
setAppliedUrlPetId(urlPetId);
setOpenPetId(urlPetId);
}
const debouncedSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS);
const { data: species = [] } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: "de" }));
+48 -48
View File
@@ -34,7 +34,7 @@ const POST_ACTION_LEGEND: { icon: React.ElementType; label: string; tone?: strin
{ icon: Pin, label: "Pin post" },
{ icon: PinOff, label: "Unpin post" },
{ icon: Sparkles, label: "Correct AI-content disclosure" },
{ icon: Trash2, label: "Delete post", tone: "text-red-400" },
{ icon: Trash2, label: "Delete post", tone: "text-destructive" },
];
export default function AdminPostsPage() {
@@ -172,11 +172,11 @@ export default function AdminPostsPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-zinc-100">Posts</h1>
<h1 className="text-2xl font-bold text-foreground">Beiträge</h1>
<Popover>
<PopoverTrigger
aria-label="What do the buttons do?"
className="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
className="flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</PopoverTrigger>
@@ -194,7 +194,7 @@ export default function AdminPostsPage() {
</Popover>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-0.5 rounded-lg border border-zinc-700 p-0.5">
<div className="flex items-center gap-0.5 rounded-lg border border-border p-0.5">
{GRID_SIZES.map((size) => (
<button
key={size}
@@ -204,8 +204,8 @@ export default function AdminPostsPage() {
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
gridSize === size
? "bg-zinc-700 text-zinc-100"
: "text-zinc-400 hover:text-zinc-200"
? "bg-secondary text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{size}
@@ -216,21 +216,21 @@ export default function AdminPostsPage() {
placeholder="Search caption or pet name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64 bg-zinc-900 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="w-64 bg-card border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
{trending && trending.hashtags.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3">
<span className="flex items-center gap-1.5 text-xs font-medium text-zinc-500">
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-card px-4 py-3">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Hash className="h-3.5 w-3.5" aria-hidden="true" />
Trending (7d):
</span>
{trending.hashtags.map((h) => (
<span
key={h.id}
className="rounded-full bg-zinc-800 px-2.5 py-1 text-xs text-zinc-300"
className="rounded-full bg-muted px-2.5 py-1 text-xs text-foreground"
>
#{h.tag} · {h._count.posts}
</span>
@@ -240,7 +240,7 @@ export default function AdminPostsPage() {
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
@@ -267,9 +267,9 @@ export default function AdminPostsPage() {
return (
<div
key={post.id}
className="flex flex-col rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden"
className="flex flex-col rounded-xl border border-border bg-card overflow-hidden"
>
<div className="relative aspect-square overflow-hidden bg-zinc-800 shrink-0">
<div className="relative aspect-square overflow-hidden bg-muted shrink-0">
{mediaUrl ? (
<BlurImage
src={mediaUrl}
@@ -279,12 +279,12 @@ export default function AdminPostsPage() {
/>
) : milestoneMeta && MilestoneIcon ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10">
<MilestoneIcon className="h-8 w-8 text-orange-500" aria-hidden="true" />
<span className="text-xs font-medium text-orange-400">{milestoneMeta.label}</span>
<MilestoneIcon className="h-8 w-8 text-primary" aria-hidden="true" />
<span className="text-xs font-medium text-primary">{milestoneMeta.label}</span>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageOff className="h-8 w-8 text-zinc-600" aria-hidden="true" />
<ImageOff className="h-8 w-8 text-muted-foreground/70" aria-hidden="true" />
</div>
)}
{isVideo && (
@@ -294,7 +294,7 @@ export default function AdminPostsPage() {
)}
{post.hiddenAt && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
<span className="flex items-center gap-1.5 rounded-full bg-zinc-900/90 px-2.5 py-1 text-xs font-medium text-zinc-200">
<span className="flex items-center gap-1.5 rounded-full bg-card/90 px-2.5 py-1 text-xs font-medium text-foreground">
<EyeOff className="h-3.5 w-3.5" aria-hidden="true" />
Hidden
</span>
@@ -310,18 +310,18 @@ export default function AdminPostsPage() {
<div className="flex flex-1 flex-col gap-2 p-3">
{post.type === "REPOST" && originalPost && (
<p className="flex items-center gap-1.5 text-xs text-orange-400">
<p className="flex items-center gap-1.5 text-xs text-primary">
<PawPrint className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
Repost of {originalPost.pet.name}
</p>
)}
<p className="text-sm text-zinc-100 line-clamp-2 min-h-[2.5rem]">
{displayCaption ?? <em className="text-zinc-500">No caption</em>}
<p className="text-sm text-foreground line-clamp-2 min-h-[2.5rem]">
{displayCaption ?? <em className="text-muted-foreground">No caption</em>}
</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted-foreground">
{post.pet.name} · {post.reactionCount} 🐾 · {post.commentCount} 💬
{post.reportCount > 0 && (
<span className="ml-2 text-yellow-400">
<span className="ml-2 text-warning">
⚑ {post.reportCount} report{post.reportCount !== 1 ? "s" : ""}
</span>
)}
@@ -330,7 +330,7 @@ export default function AdminPostsPage() {
<Button
size="sm"
variant="ghost"
className="text-zinc-400 hover:text-zinc-100"
className="text-muted-foreground hover:text-foreground"
onClick={() => setCommentsPostId(post.id)}
>
<MessageSquare className="h-4 w-4" />
@@ -339,7 +339,7 @@ export default function AdminPostsPage() {
<Button
size="sm"
variant="ghost"
className="text-zinc-400 hover:text-zinc-100"
className="text-muted-foreground hover:text-foreground"
disabled={unhidePost.isPending}
onClick={() => unhidePost.mutate({ postId: post.id })}
>
@@ -349,7 +349,7 @@ export default function AdminPostsPage() {
<Button
size="sm"
variant="ghost"
className="text-zinc-400 hover:text-zinc-100"
className="text-muted-foreground hover:text-foreground"
onClick={() => setHideTargetId(post.id)}
>
<EyeOff className="h-4 w-4" />
@@ -359,7 +359,7 @@ export default function AdminPostsPage() {
<Button
size="sm"
variant="ghost"
className="text-orange-400 hover:text-orange-300"
className="text-primary hover:opacity-80"
disabled={unpinPost.isPending}
onClick={() => unpinPost.mutate({ postId: post.id })}
>
@@ -369,7 +369,7 @@ export default function AdminPostsPage() {
<Button
size="sm"
variant="ghost"
className="text-zinc-400 hover:text-zinc-100"
className="text-muted-foreground hover:text-foreground"
disabled={pinPost.isPending}
onClick={() => pinPost.mutate({ postId: post.id })}
>
@@ -379,7 +379,7 @@ export default function AdminPostsPage() {
<Button
size="sm"
variant="ghost"
className="text-zinc-400 hover:text-zinc-100"
className="text-muted-foreground hover:text-foreground"
onClick={() => {
setAiTargetId(post.id);
setAiValue(post.aiDisclosure ?? "NONE");
@@ -391,7 +391,7 @@ export default function AdminPostsPage() {
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300"
className="text-destructive hover:opacity-80"
onClick={() => setDeleteTarget({ id: post.id, type: "post" })}
>
<Trash2 className="h-4 w-4" />
@@ -410,7 +410,7 @@ export default function AdminPostsPage() {
size="sm"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
className="bg-card border-border text-foreground hover:bg-muted hover:text-foreground"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
@@ -429,27 +429,27 @@ export default function AdminPostsPage() {
open={!!commentsPostId}
onOpenChange={(o) => { if (!o) setCommentsPostId(null); }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-lg">
<DialogContent className="bg-card border-border text-foreground max-w-lg">
<DialogHeader>
<DialogTitle>Comments</DialogTitle>
</DialogHeader>
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
{(comments ?? []).length === 0 && (
<p className="text-sm text-zinc-500 text-center py-4">No comments</p>
<p className="text-sm text-muted-foreground text-center py-4">No comments</p>
)}
{(comments ?? []).map((c) => (
<div
key={c.id}
className="flex items-start gap-3 rounded-lg border border-zinc-800 px-3 py-2"
className="flex items-start gap-3 rounded-lg border border-border px-3 py-2"
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-zinc-300">{c.pet.name}</p>
<p className="text-sm text-zinc-100 mt-0.5">{c.body}</p>
<p className="text-xs font-medium text-foreground">{c.pet.name}</p>
<p className="text-sm text-foreground mt-0.5">{c.body}</p>
</div>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 shrink-0"
className="text-destructive hover:opacity-80 shrink-0"
onClick={() => setDeleteTarget({ id: c.id, type: "comment" })}
>
<Trash2 className="h-4 w-4" />
@@ -465,21 +465,21 @@ export default function AdminPostsPage() {
open={!!deleteTarget}
onOpenChange={(o) => { if (!o) { setDeleteTarget(null); setReason(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Delete this {deleteTarget?.type}? This action cannot be undone.
</p>
<Input
placeholder="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
@@ -501,11 +501,11 @@ export default function AdminPostsPage() {
open={!!hideTargetId}
onOpenChange={(o) => { if (!o) { setHideTargetId(null); setHideReason(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Hide post</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Hides the post everywhere (feed, explore, profile, search, hashtags) without
deleting it — reversible via the eye icon.
</p>
@@ -513,10 +513,10 @@ export default function AdminPostsPage() {
placeholder="Reason (required)"
value={hideReason}
onChange={(e) => setHideReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setHideTargetId(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setHideTargetId(null)}>
Cancel
</Button>
<Button
@@ -535,11 +535,11 @@ export default function AdminPostsPage() {
open={!!aiTargetId}
onOpenChange={(o) => { if (!o) { setAiTargetId(null); setAiReason(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Correct AI-content disclosure</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Owners cannot change this after posting — use this only to correct a forgotten
or incorrect disclosure. Requires a reason for the audit log.
</p>
@@ -548,10 +548,10 @@ export default function AdminPostsPage() {
placeholder="Reason (required)"
value={aiReason}
onChange={(e) => setAiReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setAiTargetId(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setAiTargetId(null)}>
Cancel
</Button>
<Button
+57 -57
View File
@@ -25,9 +25,9 @@ const REASON_LABELS: Record<string, string> = {
// 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-red-400",
MEDIUM: "border-yellow-600 text-yellow-400",
LOW: "border-zinc-600 text-zinc-400",
HIGH: "border-red-600 text-destructive",
MEDIUM: "border-yellow-600 text-warning",
LOW: "border-border text-muted-foreground",
};
type ReportAction =
@@ -99,7 +99,7 @@ export default function AdminReportsPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-zinc-100">Reports</h1>
<h1 className="text-2xl font-bold text-foreground">Meldungen</h1>
{reports.length > 0 && (
<Badge className="bg-yellow-600 text-yellow-100">{reports.length}</Badge>
)}
@@ -107,10 +107,10 @@ export default function AdminReportsPage() {
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<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-zinc-500">
<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>
@@ -119,28 +119,28 @@ export default function AdminReportsPage() {
{reports.map((report) => (
<div
key={report.id}
className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden"
className="rounded-xl border border-border bg-card overflow-hidden"
>
{/* Report header */}
<div className="flex items-start gap-3 px-4 py-3">
<Flag className="h-4 w-4 text-yellow-400 shrink-0 mt-0.5" />
<Flag className="h-4 w-4 text-warning shrink-0 mt-0.5" />
<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-zinc-600 text-zinc-400"}`}
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-yellow-400 text-[10px]">
<Badge variant="outline" className="border-yellow-600 text-warning text-[10px]">
{REASON_LABELS[report.reason] ?? report.reason}
</Badge>
<span className="text-xs text-zinc-500">
<span className="text-xs text-muted-foreground">
gemeldet von{" "}
<span className="text-zinc-300 font-medium">{report.reporter.name}</span>
<span className="text-foreground font-medium">{report.reporter.name}</span>
</span>
<span className="text-xs text-zinc-600">
<span className="text-xs text-muted-foreground/70">
{new Date(report.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
@@ -151,7 +151,7 @@ export default function AdminReportsPage() {
</span>
</div>
{report.notes && (
<p className="text-sm text-zinc-300 mt-1 leading-snug">
<p className="text-sm text-foreground mt-1 leading-snug">
&ldquo;{report.notes}&rdquo;
</p>
)}
@@ -163,7 +163,7 @@ export default function AdminReportsPage() {
size="sm"
variant="ghost"
title="Report ignorieren"
className="text-zinc-400 hover:text-zinc-100 h-8 w-8 p-0"
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" />
@@ -173,7 +173,7 @@ export default function AdminReportsPage() {
size="sm"
variant="ghost"
title="Post löschen"
className="text-red-400 hover:text-red-300 h-8 w-8 p-0"
className="text-destructive hover:opacity-80 h-8 w-8 p-0"
onClick={() =>
setAction({ kind: "deletePost", postId: report.targetPostId! })
}
@@ -189,7 +189,7 @@ export default function AdminReportsPage() {
<button
type="button"
onClick={() => setDetailPostId(report.post!.id)}
className="border-t border-zinc-800 mx-4 mb-3 pt-3 flex gap-3 w-[calc(100%-2rem)] text-left rounded-lg hover:bg-zinc-800/40 transition-colors"
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"
>
{/* Image strip */}
@@ -205,8 +205,8 @@ export default function AdminReportsPage() {
/>
))
) : (
<div className="h-20 w-20 rounded-lg bg-zinc-800 flex items-center justify-center">
<ImageOff className="h-6 w-6 text-zinc-600" />
<div className="h-20 w-20 rounded-lg bg-muted flex items-center justify-center">
<ImageOff className="h-6 w-6 text-muted-foreground/70" />
</div>
)}
</div>
@@ -224,28 +224,28 @@ export default function AdminReportsPage() {
className="h-5 w-5 rounded-full object-cover"
/>
) : (
<div className="h-5 w-5 rounded-full bg-zinc-700" />
<div className="h-5 w-5 rounded-full bg-secondary" />
)}
<span className="text-xs font-medium text-zinc-300">
<span className="text-xs font-medium text-foreground">
{report.post.pet.name}
</span>
<span className="text-xs text-zinc-600">
<span className="text-xs text-muted-foreground/70">
{new Date(report.post.createdAt).toLocaleDateString("de-DE")}
</span>
</div>
{/* Caption */}
{report.post.caption ? (
<p className="text-sm text-zinc-200 leading-snug line-clamp-3">
<p className="text-sm text-foreground leading-snug line-clamp-3">
{report.post.caption}
</p>
) : (
<p className="text-xs text-zinc-600 italic">Kein Caption</p>
<p className="text-xs text-muted-foreground/70 italic">Kein Caption</p>
)}
</div>
{/* Counts */}
<div className="flex gap-3 text-xs text-zinc-500 mt-1">
<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" />
{report.post.reactionCount}
@@ -258,10 +258,10 @@ export default function AdminReportsPage() {
</div>
</button>
) : report.targetPetId ? (
<div className="border-t border-zinc-800 mx-4 mb-3 pt-2">
<p className="text-xs text-zinc-500">
<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-zinc-400">{report.targetPetId}</code>
<code className="text-muted-foreground">{report.targetPetId}</code>
</p>
</div>
) : null}
@@ -272,7 +272,7 @@ export default function AdminReportsPage() {
{/* Confirm dialog */}
<Dialog open={!!action} onOpenChange={(o) => { if (!o) closeDialog(); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>
{action?.kind === "dismiss" ? "Report ignorieren" : "Post löschen"}
@@ -281,7 +281,7 @@ export default function AdminReportsPage() {
{action?.kind === "dismiss" ? (
<>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Den Report als unbegründet schließen? Der Inhalt bleibt bestehen.
</p>
<Textarea
@@ -291,12 +291,12 @@ export default function AdminReportsPage() {
maxLength={500}
rows={3}
disabled={!notifyReporter}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</>
) : (
<>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Den gemeldeten Post dauerhaft löschen? Alle zugehörigen Reports werden damit
ebenfalls geschlossen.
</p>
@@ -304,18 +304,18 @@ export default function AdminReportsPage() {
placeholder="Interne Begründung (optional, nur im Moderations-Log)"
value={reason}
onChange={(e) => setReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</>
)}
<label className="flex items-start gap-3 rounded-lg border border-zinc-800 px-3 py-2.5 text-sm">
<label className="flex items-start gap-3 rounded-lg border border-border px-3 py-2.5 text-sm">
<Switch checked={notifyReporter} onCheckedChange={setNotifyReporter} className="mt-0.5" />
<span>
<span className="text-zinc-100">
<span className="text-foreground">
{action?.kind === "dismiss" ? "Melder benachrichtigen" : "Allen Meldern danken"}
</span>
<span className="block text-xs text-zinc-500">
<span className="block text-xs text-muted-foreground">
{action?.kind === "dismiss"
? "Team-Nachricht: Meldung geprüft, kein Verstoß — plus deine Begründung."
: "Team-Nachricht: Meldung geprüft, Inhalt entfernt, danke fürs Melden."}
@@ -324,7 +324,7 @@ export default function AdminReportsPage() {
</label>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
<Button variant="ghost" className="text-muted-foreground" onClick={closeDialog}>
Abbrechen
</Button>
<Button
@@ -332,7 +332,7 @@ export default function AdminReportsPage() {
onClick={confirm}
disabled={isPending}
className={
action?.kind === "dismiss" ? "bg-zinc-700 hover:bg-zinc-600 text-zinc-100" : ""
action?.kind === "dismiss" ? "bg-secondary hover:bg-accent text-foreground" : ""
}
>
{isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
@@ -344,14 +344,14 @@ export default function AdminReportsPage() {
{/* Reported post detail — full images, who reacted, all comments */}
<Dialog open={!!detailPostId} onOpenChange={(o) => { if (!o) setDetailPostId(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-3xl max-h-[85dvh] p-0 gap-0 overflow-hidden flex flex-col">
<DialogHeader className="px-5 py-4 border-b border-zinc-800 shrink-0">
<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>
</DialogHeader>
{detailLoading || !detailPost ? (
<div className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex flex-col md:flex-row min-h-0 flex-1 overflow-hidden">
@@ -369,7 +369,7 @@ export default function AdminReportsPage() {
))
) : (
<div className="flex h-64 items-center justify-center">
<ImageOff className="h-8 w-8 text-zinc-600" />
<ImageOff className="h-8 w-8 text-muted-foreground/70" />
</div>
)}
</div>
@@ -386,10 +386,10 @@ export default function AdminReportsPage() {
className="h-6 w-6 rounded-full object-cover"
/>
) : (
<div className="h-6 w-6 rounded-full bg-zinc-700" />
<div className="h-6 w-6 rounded-full bg-secondary" />
)}
<span className="text-sm font-medium text-zinc-200">{detailPost.pet.name}</span>
<span className="text-xs text-zinc-600">
<span className="text-sm font-medium text-foreground">{detailPost.pet.name}</span>
<span className="text-xs text-muted-foreground/70">
{new Date(detailPost.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
@@ -400,7 +400,7 @@ export default function AdminReportsPage() {
</span>
</div>
{detailPost.caption && (
<p className="text-sm text-zinc-300 leading-snug whitespace-pre-wrap">
<p className="text-sm text-foreground leading-snug whitespace-pre-wrap">
{detailPost.caption}
</p>
)}
@@ -408,18 +408,18 @@ export default function AdminReportsPage() {
{/* Who liked */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 mb-2 flex items-center gap-1.5">
<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})
</h3>
{detailPost.reactions.length === 0 ? (
<p className="text-xs text-zinc-600 italic">Noch keine Reaktionen.</p>
<p className="text-xs text-muted-foreground/70 italic">Noch keine Reaktionen.</p>
) : (
<div className="flex flex-wrap gap-2">
{detailPost.reactions.map((r, i) => (
<div
key={i}
className="flex items-center gap-1.5 rounded-full bg-zinc-800 px-2 py-1"
className="flex items-center gap-1.5 rounded-full bg-muted px-2 py-1"
>
{r.pet.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -429,9 +429,9 @@ export default function AdminReportsPage() {
className="h-4 w-4 rounded-full object-cover"
/>
) : (
<div className="h-4 w-4 rounded-full bg-zinc-700" />
<div className="h-4 w-4 rounded-full bg-secondary" />
)}
<span className="text-xs text-zinc-300">{r.pet.name}</span>
<span className="text-xs text-foreground">{r.pet.name}</span>
</div>
))}
</div>
@@ -440,12 +440,12 @@ export default function AdminReportsPage() {
{/* Comments */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
<MessageCircle className="h-3.5 w-3.5" />
Kommentare ({detailComments?.length ?? 0})
</h3>
{!detailComments || detailComments.length === 0 ? (
<p className="text-xs text-zinc-600 italic">Keine Kommentare.</p>
<p className="text-xs text-muted-foreground/70 italic">Keine Kommentare.</p>
) : (
<div className="space-y-3">
{detailComments.map((c) => (
@@ -458,17 +458,17 @@ export default function AdminReportsPage() {
className="h-6 w-6 rounded-full object-cover shrink-0"
/>
) : (
<div className="h-6 w-6 rounded-full bg-zinc-700 shrink-0" />
<div className="h-6 w-6 rounded-full bg-secondary shrink-0" />
)}
<div className="min-w-0">
<span className="text-xs font-medium text-zinc-300">{c.pet.name}</span>{" "}
<span className="text-xs text-zinc-600">
<span className="text-xs font-medium text-foreground">{c.pet.name}</span>{" "}
<span className="text-xs text-muted-foreground/70">
{new Date(c.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
})}
</span>
<p className="text-sm text-zinc-300 leading-snug break-words">{c.body}</p>
<p className="text-sm text-foreground leading-snug break-words">{c.body}</p>
</div>
</div>
))}
+72 -64
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { formatPetName } from "@/lib/pet-display-name";
import { useRouter, useParams } from "next/navigation";
import { useRouter, useParams, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
@@ -41,7 +41,15 @@ export default function AdminUsersPage() {
const [shadowbanTarget, setShadowbanTarget] = useState<string | null>(null);
const [unshadowbanTarget, setUnshadowbanTarget] = useState<string | null>(null);
const [shadowbanReason, setShadowbanReason] = useState("");
const [search, setSearch] = useState("");
// ?q= kommt von der globalen Suche in der Kopfzeile; ein neuer Treffer ersetzt die Suche.
const searchParams = useSearchParams();
const urlQuery = searchParams.get("q");
const [search, setSearch] = useState(urlQuery ?? "");
const [appliedUrlQuery, setAppliedUrlQuery] = useState(urlQuery);
if (urlQuery !== appliedUrlQuery) {
setAppliedUrlQuery(urlQuery);
setSearch(urlQuery ?? "");
}
const [banStatus, setBanStatus] = useState<BanStatusFilter>("all");
const [role, setRole] = useState<RoleFilter>("all");
const [warnTarget, setWarnTarget] = useState<string | null>(null);
@@ -170,30 +178,30 @@ export default function AdminUsersPage() {
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold text-zinc-100">Users</h1>
<h1 className="text-2xl font-bold text-foreground">Halter</h1>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<Input
placeholder="Search name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-zinc-900 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 sm:w-64"
className="w-full bg-card border-border text-foreground placeholder:text-muted-foreground sm:w-64"
/>
<div className="flex gap-2">
<Select value={banStatus} onValueChange={(v) => setBanStatus(v as BanStatusFilter)}>
<SelectTrigger className="flex-1 bg-zinc-900 border-zinc-700 text-zinc-100 sm:w-36 sm:flex-none">
<SelectTrigger className="flex-1 bg-card border-border text-foreground sm:w-36 sm:flex-none">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectContent className="bg-muted border-border">
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="banned">Banned</SelectItem>
<SelectItem value="active">Active</SelectItem>
</SelectContent>
</Select>
<Select value={role} onValueChange={(v) => setRole(v as RoleFilter)}>
<SelectTrigger className="flex-1 bg-zinc-900 border-zinc-700 text-zinc-100 sm:w-36 sm:flex-none">
<SelectTrigger className="flex-1 bg-card border-border text-foreground sm:w-36 sm:flex-none">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectContent className="bg-muted border-border">
<SelectItem value="all">All roles</SelectItem>
<SelectItem value="none">No role</SelectItem>
<SelectItem value={AdminRoleType.SUPPORT}>Support</SelectItem>
@@ -207,25 +215,25 @@ export default function AdminUsersPage() {
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-2">
{users.map((user) => (
<div
key={user.id}
className="flex flex-col gap-3 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
className="flex flex-col gap-3 rounded-xl border border-border bg-card px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm text-zinc-100 font-mono truncate">{user.id}</p>
<p className="text-sm text-foreground font-mono truncate">{user.id}</p>
{isActiveSponsor({
sponsorSince: user.sponsorSince ? new Date(user.sponsorSince) : null,
sponsorPeriodEnd: user.sponsorPeriodEnd ? new Date(user.sponsorPeriodEnd) : null,
}) && (
<Badge
variant="secondary"
className="text-[10px] shrink-0 bg-amber-950/60 text-amber-300"
className="text-[10px] shrink-0 bg-warning/10 text-warning"
>
SPONSOR
</Badge>
@@ -243,19 +251,19 @@ export default function AdminUsersPage() {
{user.shadowban && (
<Badge
variant="secondary"
className="text-[10px] shrink-0 bg-violet-950/60 text-violet-300"
className="text-[10px] shrink-0 bg-violet-500/15 text-violet-700 dark:text-violet-300"
>
SHADOWBANNED
</Badge>
)}
</div>
{user.clerk && (
<p className="text-xs text-zinc-400 mt-0.5 truncate">
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{user.clerk.name ? `${user.clerk.name} · ` : ""}
{user.clerk.email ?? "no email on file"}
</p>
)}
<p className="text-xs text-zinc-500 mt-0.5">
<p className="text-xs text-muted-foreground mt-0.5">
{user._count.pets} pet{user._count.pets !== 1 ? "s" : ""}
{user._count.warnings > 0 &&
` · ${user._count.warnings} warning${user._count.warnings !== 1 ? "s" : ""}`}
@@ -273,23 +281,23 @@ export default function AdminUsersPage() {
title={pet.isVerified ? "Verified — click to remove" : "Click to verify"}
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
pet.isVerified
? "border-blue-700 bg-blue-950/40 text-blue-300"
: "border-zinc-700 text-zinc-400 hover:border-zinc-600"
? "border-info/40 bg-info/10 text-info"
: "border-border text-muted-foreground hover:border-border"
}`}
>
{formatPetName(pet)}
<BadgeCheck
className={`h-3 w-3 ${pet.isVerified ? "fill-blue-400 text-zinc-950" : ""}`}
className={`h-3 w-3 ${pet.isVerified ? "fill-blue-400 text-background" : ""}`}
/>
</button>
))}
</div>
)}
{user.ban?.reason && (
<p className="text-xs text-red-400 mt-0.5">Reason: {user.ban.reason}</p>
<p className="text-xs text-destructive mt-0.5">Reason: {user.ban.reason}</p>
)}
{user.shadowban?.reason && (
<p className="text-xs text-violet-400 mt-0.5">Shadowban: {user.shadowban.reason}</p>
<p className="text-xs text-violet-600 dark:text-violet-400 mt-0.5">Shadowban: {user.shadowban.reason}</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2 sm:max-w-md sm:shrink-0 sm:justify-end">
@@ -297,7 +305,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="outline"
className="bg-zinc-900 border-sky-800 text-sky-300 hover:bg-zinc-800 hover:text-sky-100"
className="bg-card border-sky-800 text-info hover:bg-muted hover:opacity-80"
onClick={() => startConversationMutation.mutate({ targetPetId: user.pets[0].id })}
disabled={startConversationMutation.isPending}
title={`Message ${user.pets[0].name} as PawFeed Team`}
@@ -314,7 +322,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="outline"
className="bg-zinc-900 border-blue-800 text-blue-300 hover:bg-zinc-800 hover:text-blue-100"
className="bg-card border-blue-800 text-info hover:bg-muted hover:opacity-80"
onClick={() => exportDataMutation.mutate({ ownerId: user.id })}
disabled={exportDataMutation.isPending}
title="Datenauskunft (Art. 15 DSGVO) per E-Mail versenden"
@@ -329,7 +337,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="outline"
className="bg-zinc-900 border-amber-800 text-amber-300 hover:bg-zinc-800 hover:text-amber-100"
className="bg-card border-amber-800 text-warning hover:bg-muted hover:opacity-80"
onClick={() => setWarnTarget(user.id)}
title="Warnings"
>
@@ -340,7 +348,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:text-red-400"
className="text-red-500 hover:text-destructive"
onClick={() => setDeleteTarget(user.id)}
title="Delete account permanently (GDPR erasure)"
>
@@ -351,7 +359,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="outline"
className="bg-zinc-900 border-violet-800 text-violet-300 hover:bg-zinc-800 hover:text-violet-100"
className="bg-card border-violet-800 text-violet-600 dark:text-violet-300 hover:bg-muted hover:opacity-80"
onClick={() => setUnshadowbanTarget(user.id)}
title="Remove shadowban"
>
@@ -362,7 +370,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="ghost"
className="text-violet-400 hover:text-violet-300"
className="text-violet-600 dark:text-violet-400 hover:opacity-80"
onClick={() => setShadowbanTarget(user.id)}
title="Shadowban — content quietly stops reaching others, user isn't told"
>
@@ -374,7 +382,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="outline"
className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
className="bg-card border-border text-foreground hover:bg-muted hover:text-foreground"
onClick={() => setUnbanTarget(user.id)}
>
<ShieldCheck className="h-4 w-4 mr-1.5" />
@@ -384,7 +392,7 @@ export default function AdminUsersPage() {
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300"
className="text-destructive hover:opacity-80"
onClick={() => setBanTarget(user.id)}
>
<ShieldOff className="h-4 w-4 mr-1.5" />
@@ -402,7 +410,7 @@ export default function AdminUsersPage() {
size="sm"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="border-zinc-700 text-zinc-300"
className="border-border text-foreground"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
@@ -421,30 +429,30 @@ export default function AdminUsersPage() {
open={!!banTarget}
onOpenChange={(o) => { if (!o) { setBanTarget(null); setBanReason(""); setBanExpiry(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Ban user</DialogTitle>
</DialogHeader>
<p className="text-xs text-zinc-500 font-mono break-all">{banTarget}</p>
<p className="text-xs text-muted-foreground font-mono break-all">{banTarget}</p>
<Input
placeholder="Reason (optional)"
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<div className="space-y-1">
<label className="text-xs text-zinc-500">
<label className="text-xs text-muted-foreground">
Expires (optional — leave empty for permanent)
</label>
<Input
type="datetime-local"
value={banExpiry}
onChange={(e) => setBanExpiry(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100"
className="bg-muted border-border text-foreground"
/>
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setBanTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setBanTarget(null)}>
Cancel
</Button>
<Button
@@ -467,19 +475,19 @@ export default function AdminUsersPage() {
{/* Unban confirm */}
<Dialog open={!!unbanTarget} onOpenChange={(o) => { if (!o) setUnbanTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Unban user</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">Remove the ban for this user?</p>
<p className="text-sm text-muted-foreground">Remove the ban for this user?</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setUnbanTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setUnbanTarget(null)}>
Cancel
</Button>
<Button
onClick={() => unbanMutation.mutate({ ownerId: unbanTarget! })}
disabled={unbanMutation.isPending}
className="bg-zinc-700 hover:bg-zinc-600"
className="bg-secondary hover:bg-accent"
>
{unbanMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Unban
@@ -493,12 +501,12 @@ export default function AdminUsersPage() {
open={!!shadowbanTarget}
onOpenChange={(o) => { if (!o) { setShadowbanTarget(null); setShadowbanReason(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Shadowban user</DialogTitle>
</DialogHeader>
<p className="text-xs text-zinc-500 font-mono break-all">{shadowbanTarget}</p>
<p className="text-sm text-zinc-400">
<p className="text-xs text-muted-foreground font-mono break-all">{shadowbanTarget}</p>
<p className="text-sm text-muted-foreground">
Their posts quietly stop reaching anyone else — feed, explore, search, hashtags,
profile view. They keep posting normally and are never told.
</p>
@@ -506,10 +514,10 @@ export default function AdminUsersPage() {
placeholder="Reason (required)"
value={shadowbanReason}
onChange={(e) => setShadowbanReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setShadowbanTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setShadowbanTarget(null)}>
Cancel
</Button>
<Button
@@ -531,21 +539,21 @@ export default function AdminUsersPage() {
{/* Un-shadowban confirm */}
<Dialog open={!!unshadowbanTarget} onOpenChange={(o) => { if (!o) setUnshadowbanTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>Remove shadowban</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Restore normal visibility for this user&apos;s posts?
</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setUnshadowbanTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setUnshadowbanTarget(null)}>
Cancel
</Button>
<Button
onClick={() => removeShadowbanMutation.mutate({ ownerId: unshadowbanTarget! })}
disabled={removeShadowbanMutation.isPending}
className="bg-zinc-700 hover:bg-zinc-600"
className="bg-secondary hover:bg-accent"
>
{removeShadowbanMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Remove shadowban
@@ -559,19 +567,19 @@ export default function AdminUsersPage() {
open={!!warnTarget}
onOpenChange={(o) => { if (!o) { setWarnTarget(null); setWarnReason(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
<DialogContent className="bg-card border-border text-foreground max-w-md">
<DialogHeader>
<DialogTitle>Warnings</DialogTitle>
</DialogHeader>
<p className="text-xs text-zinc-500 font-mono break-all">{warnTarget}</p>
<p className="text-xs text-muted-foreground font-mono break-all">{warnTarget}</p>
<div className="max-h-48 space-y-2 overflow-y-auto">
{warningHistory.length === 0 ? (
<p className="text-sm text-zinc-500">No warnings yet.</p>
<p className="text-sm text-muted-foreground">No warnings yet.</p>
) : (
warningHistory.map((w) => (
<div key={w.id} className="rounded-lg border border-zinc-800 px-3 py-2">
<p className="text-sm text-zinc-200">{w.reason}</p>
<p className="text-xs text-zinc-600 mt-1">
<div key={w.id} className="rounded-lg border border-border px-3 py-2">
<p className="text-sm text-foreground">{w.reason}</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{new Date(w.createdAt).toLocaleString()}
</p>
</div>
@@ -582,10 +590,10 @@ export default function AdminUsersPage() {
placeholder="New warning reason…"
value={warnReason}
onChange={(e) => setWarnReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setWarnTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setWarnTarget(null)}>
Close
</Button>
<Button
@@ -593,7 +601,7 @@ export default function AdminUsersPage() {
issueWarningMutation.mutate({ ownerId: warnTarget!, reason: warnReason.trim() })
}
disabled={issueWarningMutation.isPending || !warnReason.trim()}
className="bg-zinc-700 hover:bg-zinc-600"
className="bg-secondary hover:bg-accent"
>
{issueWarningMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Issue warning
@@ -607,26 +615,26 @@ export default function AdminUsersPage() {
open={!!deleteTarget}
onOpenChange={(o) => { if (!o) { setDeleteTarget(null); setDeleteConfirmation(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle className="text-red-400">Delete account permanently</DialogTitle>
<DialogTitle className="text-destructive">Delete account permanently</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
This permanently deletes the owner, all their pets, posts, and storage files, and
revokes their login. This cannot be undone.
</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted-foreground">
Type the user ID to confirm:{" "}
<span className="font-mono text-zinc-300">{deleteTarget}</span>
<span className="font-mono text-foreground">{deleteTarget}</span>
</p>
<Input
placeholder="user_…"
value={deleteConfirmation}
onChange={(e) => setDeleteConfirmation(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 font-mono text-sm"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground font-mono text-sm"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
+33 -33
View File
@@ -114,7 +114,7 @@ export default function AdminVerificationPage() {
case "meetsAuthenticity":
return (
<>
<span className="font-medium text-zinc-300">{pet.name}</span> ·{" "}
<span className="font-medium text-foreground">{pet.name}</span> ·{" "}
{pet.breed?.name ?? pet.species.name}
{pet.bio ? <> — &ldquo;{pet.bio}&rdquo;</> : <> — no bio set</>}
</>
@@ -127,16 +127,16 @@ export default function AdminVerificationPage() {
);
case "meetsGoodStanding":
return pet.owner.ban ? (
<span className="text-red-400">
<span className="text-destructive">
Banned{pet.owner.ban.reason ? `: ${pet.owner.ban.reason}` : ""}
</span>
) : pet.owner._count.warnings > 0 ? (
<span className="text-yellow-400">
<span className="text-warning">
No ban, but {pet.owner._count.warnings} warning
{pet.owner._count.warnings !== 1 ? "s" : ""}
</span>
) : (
<span className="text-blue-400">No ban, no warnings</span>
<span className="text-info">No ban, no warnings</span>
);
case "meetsEstablishedPresence":
return (
@@ -151,12 +151,12 @@ export default function AdminVerificationPage() {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold text-zinc-100">Verification Requests</h1>
<h1 className="text-2xl font-bold text-foreground">Verifizierung</h1>
<Select value={status} onValueChange={(v) => setStatus(v as RequestStatus)}>
<SelectTrigger className="w-40 bg-zinc-900 border-zinc-700 text-zinc-100">
<SelectTrigger className="w-40 bg-card border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectContent className="bg-muted border-border">
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="APPROVED">Approved</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
@@ -166,10 +166,10 @@ export default function AdminVerificationPage() {
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
<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-zinc-500">
<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>
@@ -183,13 +183,13 @@ export default function AdminVerificationPage() {
return (
<div
key={req.id}
className="flex items-start gap-3 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
className="flex items-start gap-3 rounded-xl border border-border bg-card px-4 py-3"
>
<Avatar className="h-11 w-11 shrink-0">
{req.pet.avatarKey && (
<AvatarImage src={getAvatarUrl(req.pet.avatarKey) ?? undefined} alt={req.pet.name} />
)}
<AvatarFallback className="bg-zinc-800 text-zinc-400">
<AvatarFallback className="bg-muted text-muted-foreground">
{req.pet.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
@@ -199,7 +199,7 @@ export default function AdminVerificationPage() {
<Link
href={`/pets/${req.pet.id}`}
target="_blank"
className="text-sm font-medium text-zinc-100 hover:underline"
className="text-sm font-medium text-foreground hover:underline"
>
{req.pet.name}
</Link>
@@ -215,11 +215,11 @@ export default function AdminVerificationPage() {
{req.pet.owner.referralPerks.includes("VERIFICATION_PRIORITY") && (
<Badge className="bg-orange-700 text-orange-100 text-[10px]">Bevorzugt (Empfehlungsrang)</Badge>
)}
<span className="text-xs text-zinc-600 font-mono">{req.pet.ownerId}</span>
<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-zinc-500">
<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
@@ -233,18 +233,18 @@ export default function AdminVerificationPage() {
joined {new Date(req.pet.createdAt).toLocaleDateString()}
</span>
{isBanned ? (
<span className="flex items-center gap-1 text-red-400">
<span className="flex items-center gap-1 text-destructive">
<Ban className="h-3 w-3" />
banned{banReason ? `: ${banReason}` : ""}
</span>
) : (
<span className="flex items-center gap-1 text-zinc-500">
<span className="flex items-center gap-1 text-muted-foreground">
<Ban className="h-3 w-3" />
no ban
</span>
)}
{warningCount > 0 && (
<span className="flex items-center gap-1 text-yellow-400">
<span className="flex items-center gap-1 text-warning">
<TriangleAlert className="h-3 w-3" />
{warningCount} warning{warningCount !== 1 ? "s" : ""}
</span>
@@ -252,19 +252,19 @@ export default function AdminVerificationPage() {
</div>
{req.pet.bio && (
<p className="text-xs text-zinc-400 mt-1 italic line-clamp-2">{req.pet.bio}</p>
<p className="text-xs text-muted-foreground mt-1 italic line-clamp-2">{req.pet.bio}</p>
)}
<p className="text-sm text-zinc-300 mt-2 leading-snug">
<span className="text-zinc-500">Requester says:</span> {req.message}
<p className="text-sm text-foreground mt-2 leading-snug">
<span className="text-muted-foreground">Requester says:</span> {req.message}
</p>
<p className="text-xs text-zinc-600 mt-0.5">
<p className="text-xs text-muted-foreground/70 mt-0.5">
requested {new Date(req.createdAt).toLocaleString()}
</p>
{req.status !== "PENDING" && req.reviewNote && (
<p
className={`text-xs mt-1 ${
req.status === "APPROVED" ? "text-blue-400" : "text-red-400"
req.status === "APPROVED" ? "text-info" : "text-destructive"
}`}
>
{req.status === "APPROVED" ? "Genehmigt" : "Abgelehnt"}: {req.reviewNote}
@@ -275,7 +275,7 @@ export default function AdminVerificationPage() {
<Button
size="sm"
variant="outline"
className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 shrink-0"
className="bg-card border-border text-foreground hover:bg-muted hover:text-foreground shrink-0"
onClick={() => openReview(req.id)}
>
Review
@@ -292,7 +292,7 @@ export default function AdminVerificationPage() {
size="sm"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
className="bg-card border-border text-foreground hover:bg-muted hover:text-foreground"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
@@ -308,15 +308,15 @@ export default function AdminVerificationPage() {
{/* Review dialog — the checklist itself decides the outcome */}
<Dialog open={!!reviewTarget} onOpenChange={(o) => { if (!o) setReviewTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
<DialogContent className="bg-card border-border text-foreground max-w-md">
<DialogHeader>
<DialogTitle>Review request</DialogTitle>
</DialogHeader>
<div className="space-y-2">
{CHECKLIST.map((c) => (
<div key={c.key} className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2.5">
<p className="text-xs text-zinc-400 mb-1.5">{renderEvidence(c.key)}</p>
<div key={c.key} className="rounded-lg border border-border bg-muted/40 p-2.5">
<p className="text-xs text-muted-foreground mb-1.5">{renderEvidence(c.key)}</p>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
@@ -327,8 +327,8 @@ export default function AdminVerificationPage() {
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span>
<span className="block text-sm text-zinc-100">{c.label}</span>
<span className="block text-xs text-zinc-500">{c.hint}</span>
<span className="block text-sm text-foreground">{c.label}</span>
<span className="block text-xs text-muted-foreground">{c.hint}</span>
</span>
</label>
</div>
@@ -338,8 +338,8 @@ export default function AdminVerificationPage() {
<div
className={`rounded-lg border px-3 py-2 text-xs ${
allChecked
? "border-blue-700 bg-blue-950/30 text-blue-300"
: "border-red-800 bg-red-950/20 text-red-300"
? "border-info/40 bg-info/10 text-info"
: "border-destructive/40 bg-destructive/10 text-destructive"
}`}
>
{allChecked
@@ -351,11 +351,11 @@ export default function AdminVerificationPage() {
placeholder="Zusätzliche Notiz (optional)…"
value={note}
onChange={(e) => setNote(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setReviewTarget(null)}>
<Button variant="ghost" className="text-muted-foreground" onClick={() => setReviewTarget(null)}>
Cancel
</Button>
<Button
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, ArrowLeft } from "lucide-react";
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
interface AdminMobileNavProps {
base: string;
nav: { href: string; label: string; icon: React.ReactNode }[];
}
/**
* Mobile topbar + slide-in nav drawer for the admin panel — the desktop
* <aside> sidebar in layout.tsx is hidden below md: and replaced by this.
* Topbar shows the current page's own name (there's no permanent sidebar
* to read that from on mobile) instead of a static "PawFeed Admin" label.
*/
export function AdminMobileNav({ base, nav }: AdminMobileNavProps) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const isActive = (href: string) => (href === base ? pathname === base : pathname.startsWith(href));
const currentLabel = nav.find((item) => isActive(item.href))?.label ?? "Admin";
return (
<div className="md:hidden sticky top-0 z-40 flex items-center justify-between gap-2 border-b border-border bg-background px-4 py-3">
<p className="text-sm font-semibold text-foreground">{currentLabel}</p>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={<Button variant="ghost" size="icon-sm" />}>
<Menu className="h-5 w-5" />
<span className="sr-only">Menü öffnen</span>
</SheetTrigger>
<SheetContent
side="left"
className="flex flex-col gap-1 border-border bg-background py-6 px-4 text-foreground"
>
<SheetHeader className="p-0 mb-3">
<SheetTitle className="sr-only">Admin-Navigation</SheetTitle>
</SheetHeader>
{nav.map(({ href, label, icon }) => (
<SheetClose
key={href}
render={
<Link
href={href}
aria-current={isActive(href) ? "page" : undefined}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive(href)
? "bg-orange-500/10 text-orange-400"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
/>
}
>
{icon}
{label}
</SheetClose>
))}
<div className="mt-auto pt-3 border-t border-border">
<SheetClose
render={
<Link
href="/feed"
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
/>
}
>
<ArrowLeft className="h-4 w-4 shrink-0" />
Zurück
</SheetClose>
</div>
</SheetContent>
</Sheet>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { isAdminNavActive, type AdminNavBadge, type AdminNavGroup } from "@/components/admin/admin-nav";
const QUEUE_REFRESH_MS = 60_000;
const BADGE_SR_TEXT: Record<AdminNavBadge, (n: number) => string> = {
reports: (n) => `${n} offene Meldungen`,
verifications: (n) => `${n} offene Verifizierungen`,
lost: (n) => `${n} aktive Vermisst-Meldungen`,
messages: (n) => `${n} ungelesene Nachrichten`,
};
/** Offene Arbeit je Warteschlange — dieselbe Abfrage nutzen Sidebar und Startseite. */
export function useAdminQueues() {
const trpc = useTRPC();
return useQuery(trpc.adminOverview.getQueues.queryOptions(undefined, { refetchInterval: QUEUE_REFRESH_MS, staleTime: 30_000 }));
}
interface AdminNavListProps {
base: string;
groups: AdminNavGroup[];
onNavigate?: () => void;
}
/**
* Gruppiertes Admin-Menü mit Zählern für offene Arbeit (Meldungen, Verifizierungen,
* Vermisst, Nachrichten). Ein Zähler erscheint nur, wenn etwas offen ist.
*/
export function AdminNavList({ base, groups, onNavigate }: AdminNavListProps) {
const pathname = usePathname();
const { data: queues } = useAdminQueues();
const countFor = (badge?: AdminNavBadge) => (badge && queues ? (queues[badge]?.count ?? 0) : 0);
return (
<nav aria-label="Admin-Navigation" className="flex flex-col gap-5">
{groups.map((group) => (
<div key={group.label}>
<p className="px-3 pb-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{group.label}</p>
<ul className="flex flex-col gap-0.5">
{group.items.map(({ href, label, icon, badge }) => {
const isActive = isAdminNavActive(href, base, pathname);
const count = countFor(badge);
return (
<li key={href}>
<Link
href={href}
onClick={onNavigate}
aria-current={isActive ? "page" : undefined}
className={`group flex min-h-9 items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isActive
? "bg-sidebar-accent font-semibold text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-muted hover:text-foreground"
}`}
>
{icon}
<span className="flex-1 truncate">{label}</span>
{count > 0 && badge && (
<span className="min-w-6 rounded-full bg-primary px-1.5 py-0.5 text-center text-[11px] font-bold tabular-nums text-primary-foreground">
<span aria-hidden>{count > 99 ? "99+" : count}</span>
<span className="sr-only">{BADGE_SR_TEXT[badge](count)}</span>
</span>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Loader2, PawPrint, Search, UserRound } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useDebounce } from "@/hooks/use-debounce";
const SEARCH_DEBOUNCE_MS = 250;
const MIN_QUERY_LENGTH = 2;
interface Result {
key: string;
href: string;
title: string;
subtitle: string | null;
kind: "PET" | "OWNER";
}
/**
* Globale Suche in der Kopfzeile: Tiere (Name/Rufname) und Halter (Name/E-Mail).
* Ein Treffer öffnet die passende Admin-Seite mit vorausgefüllter Suche.
* Tastatur: „/“ fokussiert, Pfeiltasten wählen, Enter öffnet, Esc schließt.
*/
export function AdminSearch({ base }: { base: string }) {
const trpc = useTRPC();
const router = useRouter();
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const debounced = useDebounce(query.trim(), SEARCH_DEBOUNCE_MS);
const isSearchable = debounced.length >= MIN_QUERY_LENGTH;
const { data, isFetching } = useQuery(
trpc.adminOverview.search.queryOptions({ query: debounced }, { enabled: isSearchable, staleTime: 30_000 })
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isTyping = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable;
if (e.key === "/" && !isTyping) {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const results: Result[] = isSearchable && data
? [
...data.pets.map((p) => ({
key: `pet:${p.id}`,
href: `${base}/pets?pet=${encodeURIComponent(p.id)}`,
title: p.nickname ? `${p.name} (${p.nickname})` : p.name,
subtitle: null,
kind: "PET" as const,
})),
...data.owners.map((o) => ({
key: `owner:${o.id}`,
href: `${base}/users?q=${encodeURIComponent(o.email ?? o.id)}`,
title: o.name ?? o.email ?? "Ohne Namen",
subtitle: o.name ? o.email : null,
kind: "OWNER" as const,
})),
]
: [];
const open = (result: Result | undefined) => {
if (!result) return;
setIsOpen(false);
setQuery("");
router.push(result.href);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
open(results[activeIndex]);
} else if (e.key === "Escape") {
setIsOpen(false);
inputRef.current?.blur();
}
};
const showPanel = isOpen && query.trim().length >= MIN_QUERY_LENGTH;
const pets = results.filter((r) => r.kind === "PET");
const owners = results.filter((r) => r.kind === "OWNER");
const renderGroup = (label: string, items: Result[]) =>
items.length > 0 && (
<div role="group" aria-label={label}>
<p className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{label}</p>
{items.map((r) => {
const index = results.indexOf(r);
const Icon = r.kind === "PET" ? PawPrint : UserRound;
return (
<button
key={r.key}
id={`${listId}-${index}`}
type="button"
role="option"
aria-selected={index === activeIndex}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => open(r)}
className={`flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm ${
index === activeIndex ? "bg-accent text-accent-foreground" : "text-foreground"
}`}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{r.title}</span>
{r.subtitle && <span className="block truncate text-xs text-muted-foreground">{r.subtitle}</span>}
</span>
</button>
);
})}
</div>
);
return (
<div className="relative w-full max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setActiveIndex(0);
}}
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={onKeyDown}
placeholder="Tiere oder Halter suchen …"
aria-label="Tiere oder Halter suchen"
role="combobox"
aria-expanded={showPanel}
aria-controls={listId}
aria-activedescendant={showPanel && results.length > 0 ? `${listId}-${activeIndex}` : undefined}
className="h-9 w-full rounded-lg border border-input bg-card pl-9 pr-10 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
/>
<kbd className="pointer-events-none absolute right-2.5 top-1/2 hidden -translate-y-1/2 rounded border border-border px-1.5 text-[11px] text-muted-foreground sm:block">
/
</kbd>
{showPanel && (
<div
id={listId}
role="listbox"
aria-label="Suchergebnisse"
className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-96 overflow-y-auto rounded-xl border border-border bg-popover p-1.5 shadow-lg"
>
{isFetching && results.length === 0 ? (
<p className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> Suche läuft …
</p>
) : results.length === 0 ? (
<p className="px-3 py-3 text-sm text-muted-foreground">Keine Treffer für „{query.trim()}“.</p>
) : (
<>
{renderGroup("Tiere", pets)}
{renderGroup("Halter", owners)}
</>
)}
</div>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
interface AdminSectionProps {
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
children: React.ReactNode;
}
/** Einheitlicher Kartenrahmen fürs Admin-Panel: Titel, optionale Beschreibung und Aktion rechts. */
export function AdminSection({ title, description, action, className = "", children }: AdminSectionProps) {
return (
<section className={`rounded-2xl border border-border bg-card p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] md:p-6 ${className}`}>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-card-foreground">{title}</h2>
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
</div>
{action}
</div>
{children}
</section>
);
}
/** Seitenkopf: kleine Überzeile, Titel, Beschreibung und optional Aktionen rechts. */
export function AdminPageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow?: string;
title: string;
description?: string;
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
{eyebrow && <p className="text-xs font-semibold uppercase tracking-[0.12em] text-primary">{eyebrow}</p>}
<h1 className="mt-1 text-2xl font-bold tracking-tight text-foreground md:text-[28px]">{title}</h1>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
</div>
{actions}
</div>
);
}
-42
View File
@@ -1,42 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface AdminSidebarNavProps {
base: string;
nav: { href: string; label: string; icon: React.ReactNode }[];
}
/**
* Desktop admin sidebar nav — highlights the current page so a moderator
* always knows where they are. Dashboard (href === base) only lights up on
* an exact match; every other item lights up for itself and its sub-routes
* (e.g. a future /users/[id] detail page still highlights "Users").
*/
export function AdminSidebarNav({ base, nav }: AdminSidebarNavProps) {
const pathname = usePathname();
return (
<>
{nav.map(({ href, label, icon }) => {
const isActive = href === base ? pathname === base : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
aria-current={isActive ? "page" : undefined}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? "bg-orange-500/10 text-orange-400"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{icon}
{label}
</Link>
);
})}
</>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { ADMIN_ROOT_ID, ADMIN_THEME_COOKIE, type AdminTheme } from "@/components/admin/admin-theme";
const COOKIE_MAX_AGE_S = 60 * 60 * 24 * 365;
/**
* Hell/Dunkel nur fürs Admin-Panel (unabhängig vom Website-Theme). Die Wahl liegt
* im Cookie, damit das Layout schon serverseitig richtig rendert. Solange das
* Panel offen ist, bekommt auch <html> die Admin-Klassen — sonst würden Dialoge,
* Menüs und Selects (Portale direkt im <body>) im Website-Theme erscheinen.
*/
export function AdminThemeToggle({ initialTheme }: { initialTheme: AdminTheme }) {
const [theme, setTheme] = useState<AdminTheme>(initialTheme);
useEffect(() => {
const html = document.documentElement;
const previous = html.className;
html.classList.add("admin-theme");
return () => {
html.className = previous;
};
}, []);
useEffect(() => {
const isDark = theme === "dark";
document.documentElement.classList.toggle("dark", isDark);
document.getElementById(ADMIN_ROOT_ID)?.classList.toggle("dark", isDark);
}, [theme]);
const toggle = () => {
const next: AdminTheme = theme === "dark" ? "light" : "dark";
document.cookie = `${ADMIN_THEME_COOKIE}=${next}; path=/; max-age=${COOKIE_MAX_AGE_S}; samesite=lax`;
setTheme(next);
};
const label = theme === "dark" ? "Helles Design" : "Dunkles Design";
return (
<button
type="button"
onClick={toggle}
aria-label={label}
title={label}
className="grid h-9 w-9 shrink-0 cursor-pointer place-items-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{theme === "dark" ? <Sun className="h-4 w-4" aria-hidden /> : <Moon className="h-4 w-4" aria-hidden />}
</button>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUser } from "@clerk/nextjs";
import { ArrowLeft, ChevronRight, Menu } from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { AdminNavList } from "@/components/admin/AdminNavList";
import { AdminSearch } from "@/components/admin/AdminSearch";
import { AdminThemeToggle } from "@/components/admin/AdminThemeToggle";
import type { AdminTheme } from "@/components/admin/admin-theme";
import { isAdminNavActive, type AdminNavGroup } from "@/components/admin/admin-nav";
interface AdminTopbarProps {
base: string;
groups: AdminNavGroup[];
roleLabel: string;
theme: AdminTheme;
}
function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]!.toUpperCase())
.join("");
}
/** Kopfzeile: Menü (mobil), Breadcrumb, globale Suche, Hell/Dunkel, eigener Name + Rolle. */
export function AdminTopbar({ base, groups, roleLabel, theme }: AdminTopbarProps) {
const pathname = usePathname();
const { user } = useUser();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const current = groups.flatMap((g) => g.items).find((item) => isAdminNavActive(item.href, base, pathname));
const name = user?.firstName || user?.fullName || "Admin";
return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-background/90 px-4 backdrop-blur md:px-8">
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<SheetTrigger
render={
<button
type="button"
className="grid h-9 w-9 shrink-0 cursor-pointer place-items-center rounded-lg text-foreground hover:bg-muted md:hidden"
/>
}
>
<Menu className="h-5 w-5" aria-hidden />
<span className="sr-only">Menü öffnen</span>
</SheetTrigger>
<SheetContent side="left" className="flex flex-col gap-4 overflow-y-auto border-border bg-sidebar px-3 py-5 text-foreground">
<SheetHeader className="p-0 px-3">
<SheetTitle className="text-base font-bold">PawFeed Admin</SheetTitle>
</SheetHeader>
<AdminNavList base={base} groups={groups} onNavigate={() => setIsMenuOpen(false)} />
<Link href="/feed" className="mt-auto flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground">
<ArrowLeft className="h-4 w-4" aria-hidden /> Zurück zur App
</Link>
</SheetContent>
</Sheet>
<nav aria-label="Brotkrumen" className="hidden min-w-0 items-center gap-1.5 text-sm sm:flex">
<Link href={base} className="text-muted-foreground hover:text-foreground">
Admin
</Link>
{current && current.href !== base && (
<>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span aria-current="page" className="truncate font-semibold text-foreground">
{current.label}
</span>
</>
)}
</nav>
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-2 sm:flex-none md:w-[28rem]">
<AdminSearch base={base} />
<AdminThemeToggle initialTheme={theme} />
<div className="hidden items-center gap-2.5 border-l border-border pl-3 lg:flex">
<span className="grid h-8 w-8 place-items-center rounded-full bg-accent text-xs font-bold text-accent-foreground" aria-hidden>
{initials(name)}
</span>
<span className="leading-tight">
<span className="block text-sm font-semibold text-foreground">{name}</span>
<span className="block text-xs text-muted-foreground">{roleLabel}</span>
</span>
</div>
</div>
</header>
);
}
+7 -6
View File
@@ -3,9 +3,9 @@
export type Granularity = "day" | "week" | "month";
const OPTIONS: { value: Granularity; label: string }[] = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "day", label: "Tag" },
{ value: "week", label: "Woche" },
{ value: "month", label: "Monat" },
];
export function GranularityToggle({
@@ -16,15 +16,16 @@ export function GranularityToggle({
onChange: (g: Granularity) => void;
}) {
return (
<div className="flex items-center gap-1 bg-background border border-border p-0.5 rounded-lg">
<div className="flex items-center gap-1 bg-muted border border-border p-0.5 rounded-lg">
{OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
aria-pressed={value === opt.value}
className={`cursor-pointer px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
value === opt.value
? "bg-muted text-foreground"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
+18
View File
@@ -0,0 +1,18 @@
export type StatusTone = "warning" | "success" | "info" | "danger" | "neutral";
const TONE_CLASS: Record<StatusTone, string> = {
warning: "bg-warning/12 text-warning ring-warning/25",
success: "bg-success/12 text-success ring-success/25",
info: "bg-info/12 text-info ring-info/25",
danger: "bg-destructive/12 text-destructive ring-destructive/25",
neutral: "bg-muted text-muted-foreground ring-border",
};
/** Kleines Statusabzeichen (Offen, Erledigt, Aktiv …) — Farbe immer zusammen mit Text, nie allein. */
export function StatusBadge({ tone, children }: { tone: StatusTone; children: React.ReactNode }) {
return (
<span className={`inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset ${TONE_CLASS[tone]}`}>
{children}
</span>
);
}
+20 -23
View File
@@ -11,7 +11,7 @@ interface TimeSeriesChartProps {
data: TimeSeriesPoint[];
granularity: "day" | "week" | "month";
variant: "bar" | "line";
/** Single-hex accent — admin surface is fixed dark, so no light/dark variant needed. */
/** Linienfarbe, Standard = Admin-Primärfarbe (folgt Hell/Dunkel über die CSS-Variable). */
color?: string;
valueLabel: string;
}
@@ -26,29 +26,28 @@ const B = 32;
function formatBucketLabel(iso: string, granularity: TimeSeriesChartProps["granularity"]): string {
const d = new Date(iso);
if (granularity === "month") {
return d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: "UTC" });
return d.toLocaleDateString("de-DE", { month: "short", year: "2-digit", timeZone: "UTC" });
}
return d.toLocaleDateString("en-US", { day: "2-digit", month: "short", timeZone: "UTC" });
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short", timeZone: "UTC" });
}
/**
* Single-series time-series chart (bar for per-bucket counts, line+area for a
* cumulative growth curve). Admin dashboard only — always rendered on the
* fixed zinc-950 dark surface, so colors are hardcoded rather than
* light/dark-aware. One series → no legend needed (dataviz skill: a single
* cumulative growth curve). Admin dashboard only — Achsen, Raster und Tooltip
* nutzen die Admin-Tokens, damit das Diagramm hell wie dunkel lesbar ist. One series → no legend needed (dataviz skill: a single
* series needs no legend box, the chart title names it).
*/
export function TimeSeriesChart({
data,
granularity,
variant,
color = "#f97316", // orange-500 — the app's existing single-hue brand accent
color = "var(--primary)",
valueLabel,
}: TimeSeriesChartProps) {
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
if (data.length === 0) {
return <div className="h-[220px] flex items-center justify-center text-sm text-muted-foreground">No data yet</div>;
return <div className="h-[220px] flex items-center justify-center text-sm text-muted-foreground">Noch keine Daten</div>;
}
const maxV = Math.max(...data.map((d) => d.value), 1);
@@ -93,13 +92,13 @@ export function TimeSeriesChart({
className="w-full"
style={{ maxHeight: H }}
role="img"
aria-label={`${valueLabel} over time`}
aria-label={`${valueLabel} im Zeitverlauf`}
onMouseLeave={() => setHoverIdx(null)}
>
<defs>
<linearGradient id="pf-admin-area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
<stop offset="0%" style={{ stopColor: color }} stopOpacity="0.25" />
<stop offset="100%" style={{ stopColor: color }} stopOpacity="0" />
</linearGradient>
</defs>
@@ -108,8 +107,8 @@ export function TimeSeriesChart({
const y = yOf(v);
return (
<g key={i}>
<line x1={L} y1={y} x2={W - R} y2={y} stroke="#3f3f46" strokeWidth="1" strokeDasharray={i === 0 ? "0" : "3 3"} opacity={i === 0 ? 0.5 : 0.3} />
<text x={L - 6} y={y} textAnchor="end" dominantBaseline="middle" fontSize="9" fill="#71717a">
<line x1={L} y1={y} x2={W - R} y2={y} className="stroke-border" strokeWidth="1" strokeDasharray={i === 0 ? "0" : "3 3"} opacity={i === 0 ? 0.5 : 0.3} />
<text x={L - 6} y={y} textAnchor="end" dominantBaseline="middle" fontSize="9" className="fill-muted-foreground">
{Math.round(v)}
</text>
</g>
@@ -131,10 +130,9 @@ export function TimeSeriesChart({
width={barWidth}
height={Math.max(h, 0)}
rx={2}
fill={color}
opacity={isHovered ? 1 : 0.75}
onMouseEnter={() => setHoverIdx(i)}
style={{ cursor: "pointer" }}
style={{ cursor: "pointer", fill: color }}
/>
);
})}
@@ -143,7 +141,7 @@ export function TimeSeriesChart({
{variant === "line" && (
<>
<path d={areaPath} fill="url(#pf-admin-area)" />
<path d={linePath} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d={linePath} fill="none" style={{ stroke: color }} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
{data.map((d, i) => {
const isHovered = hoverIdx === i;
return (
@@ -161,8 +159,7 @@ export function TimeSeriesChart({
cx={xOf(i)}
cy={yOf(d.value)}
r={isHovered ? 4 : 2.5}
fill={isHovered ? color : "#18181b"}
stroke={color}
style={{ fill: isHovered ? color : "var(--card)", stroke: color }}
strokeWidth={isHovered ? 0 : 1.5}
/>
</g>
@@ -178,7 +175,7 @@ export function TimeSeriesChart({
y1={T}
x2={xOf(hoverIdx!)}
y2={T + cH}
stroke={color}
style={{ stroke: color }}
strokeOpacity="0.3"
strokeWidth="1"
strokeDasharray="2 2"
@@ -190,7 +187,7 @@ export function TimeSeriesChart({
{data.map((d, i) => {
if (!xLabelIndices.has(i)) return null;
return (
<text key={i} x={xOf(i)} y={H - 8} textAnchor="middle" fontSize="9" fill="#71717a">
<text key={i} x={xOf(i)} y={H - 8} textAnchor="middle" fontSize="9" className="fill-muted-foreground">
{formatBucketLabel(d.date, granularity)}
</text>
);
@@ -200,15 +197,15 @@ export function TimeSeriesChart({
{/* Tooltip */}
{hovered && hoverIdx !== null && (
<div
className="absolute pointer-events-none rounded-lg bg-zinc-800 border border-zinc-700 px-2.5 py-1.5 text-xs shadow-lg"
className="absolute pointer-events-none rounded-lg bg-popover border border-border px-2.5 py-1.5 text-xs shadow-lg"
style={{
left: `${(xOf(hoverIdx) / W) * 100}%`,
top: 4,
transform: "translateX(-50%)",
}}
>
<p className="text-zinc-400">{formatBucketLabel(hovered.date, granularity)}</p>
<p className="font-semibold text-zinc-100">
<p className="text-muted-foreground">{formatBucketLabel(hovered.date, granularity)}</p>
<p className="font-semibold text-popover-foreground">
{hovered.value.toLocaleString()} {valueLabel}
</p>
</div>
+101
View File
@@ -0,0 +1,101 @@
import {
LayoutDashboard,
Users,
Megaphone,
ShieldCheck,
FileText,
Flag,
Scale,
ScrollText,
BadgeCheck,
Ban,
MessageCircle,
Radio,
Music,
Gift,
Sprout,
PawPrint,
Siren,
} from "lucide-react";
/** Welche Warteschlange (adminOverview.getQueues) als Zähler neben dem Menüpunkt steht. */
export type AdminNavBadge = "reports" | "verifications" | "lost" | "messages";
export interface AdminNavItem {
href: string;
label: string;
icon: React.ReactNode;
badge?: AdminNavBadge;
}
export interface AdminNavGroup {
label: string;
items: AdminNavItem[];
}
export type AdminRoleName = "BOOTSTRAP" | "SUPER_ADMIN" | "MODERATOR" | "SUPPORT";
export const ADMIN_ROLE_LABEL: Record<AdminRoleName, string> = {
BOOTSTRAP: "Inhaber",
SUPER_ADMIN: "Super-Admin",
MODERATOR: "Moderator",
SUPPORT: "Support",
};
const icon = (Icon: React.ComponentType<{ className?: string }>) => <Icon className="h-4 w-4 shrink-0" aria-hidden />;
/**
* Menü des Admin-Panels, gruppiert nach Aufgaben statt nach Entstehung.
* SUPPORT sieht nur, was die Server-Prozeduren auch für SUPPORT freigeben —
* die Rollenprüfung selbst passiert weiterhin in den tRPC-Routern.
*/
export function buildAdminNav(base: string, role: AdminRoleName): AdminNavGroup[] {
const moderator = role !== "SUPPORT";
const only = (allowed: boolean, item: AdminNavItem) => (allowed ? [item] : []);
const groups: AdminNavGroup[] = [
{ label: "Start", items: [{ href: base, label: "Übersicht", icon: icon(LayoutDashboard) }] },
{
label: "Moderation",
items: [
{ href: `${base}/reports`, label: "Meldungen", icon: icon(Flag), badge: "reports" },
...only(moderator, { href: `${base}/verification`, label: "Verifizierung", icon: icon(BadgeCheck), badge: "verifications" }),
...only(moderator, { href: `${base}/lost`, label: "Vermisst", icon: icon(Siren), badge: "lost" }),
{ href: `${base}/messages`, label: "Nachrichten", icon: icon(MessageCircle), badge: "messages" },
...only(moderator, { href: `${base}/blacklist`, label: "Sperrliste", icon: icon(Ban) }),
],
},
{
label: "Inhalte",
items: [
{ href: `${base}/posts`, label: "Beiträge", icon: icon(FileText) },
{ href: `${base}/pets`, label: "Tiere", icon: icon(PawPrint) },
{ href: `${base}/ads`, label: "Werbung", icon: icon(Megaphone) },
{ href: `${base}/music`, label: "Musik-Katalog", icon: icon(Music) },
],
},
{
label: "Community",
items: [
{ href: `${base}/users`, label: "Halter", icon: icon(Users) },
...only(moderator, { href: `${base}/referrals`, label: "Empfehlungen", icon: icon(Gift) }),
...only(moderator, { href: `${base}/curator`, label: "Begrüßung", icon: icon(Sprout) }),
...only(moderator, { href: `${base}/broadcasts`, label: "Mitteilungen", icon: icon(Radio) }),
],
},
{
label: "Verwaltung",
items: [
{ href: `${base}/moderators`, label: "Team & Rollen", icon: icon(ShieldCheck) },
{ href: `${base}/legal`, label: "Rechtstexte", icon: icon(Scale) },
...only(moderator, { href: `${base}/log`, label: "Protokoll", icon: icon(ScrollText) }),
],
},
];
return groups.filter((g) => g.items.length > 0);
}
/** Aktiver Menüpunkt: Übersicht nur exakt, alle anderen auch für Unterseiten. */
export function isAdminNavActive(href: string, base: string, pathname: string): boolean {
return href === base ? pathname === base : pathname === href || pathname.startsWith(`${href}/`);
}
+4
View File
@@ -0,0 +1,4 @@
/** Hell/Dunkel des Admin-Panels — geteilt zwischen Server-Layout und AdminThemeToggle. */
export type AdminTheme = "light" | "dark";
export const ADMIN_THEME_COOKIE = "pf_admin_theme";
export const ADMIN_ROOT_ID = "admin-root";
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { AdminSection } from "@/components/admin/AdminSection";
import { TimeSeriesChart } from "@/components/admin/TimeSeriesChart";
import { GranularityToggle, type Granularity } from "@/components/admin/GranularityToggle";
type Metric = "visits" | "posts" | "comments" | "reactions" | "owners";
const METRICS: { value: Metric; label: string; description: string; valueLabel: string; variant: "bar" | "line" }[] = [
{ value: "visits", label: "Besucher", description: "Aktive Halter je Zeitraum", valueLabel: "Besucher", variant: "bar" },
{ value: "posts", label: "Beiträge", description: "Neue Beiträge je Zeitraum", valueLabel: "Beiträge", variant: "bar" },
{ value: "comments", label: "Kommentare", description: "Neue Kommentare je Zeitraum", valueLabel: "Kommentare", variant: "bar" },
{ value: "reactions", label: "Pfoten", description: "Vergebene Pfoten je Zeitraum", valueLabel: "Pfoten", variant: "bar" },
{ value: "owners", label: "Halter", description: "Registrierte Halter insgesamt", valueLabel: "Halter", variant: "line" },
];
/** Ein Diagramm mit Umschalter statt fünf einzelner Karten — weniger Fläche, gleiche Information. */
export function ActivityCard() {
const trpc = useTRPC();
const [metric, setMetric] = useState<Metric>("visits");
const [granularity, setGranularity] = useState<Granularity>("day");
const config = METRICS.find((m) => m.value === metric)!;
const input = { granularity };
const { data } = useQuery(
metric === "visits"
? trpc.admin.getVisitsOverTime.queryOptions(input)
: metric === "posts"
? trpc.admin.getPostsOverTime.queryOptions(input)
: metric === "comments"
? trpc.admin.getCommentsOverTime.queryOptions(input)
: metric === "reactions"
? trpc.admin.getReactionsOverTime.queryOptions(input)
: trpc.admin.getOwnerGrowth.queryOptions(input)
);
return (
<AdminSection
title="Aktivität der Plattform"
description={config.description}
action={<GranularityToggle value={granularity} onChange={setGranularity} />}
>
<div role="tablist" aria-label="Kennzahl wählen" className="mb-4 flex flex-wrap gap-1.5">
{METRICS.map((m) => (
<button
key={m.value}
type="button"
role="tab"
aria-selected={metric === m.value}
onClick={() => setMetric(m.value)}
className={`cursor-pointer rounded-full px-3 py-1.5 text-xs font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
metric === m.value ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{m.label}
</button>
))}
</div>
<TimeSeriesChart data={data ?? []} granularity={granularity} variant={config.variant} valueLabel={config.valueLabel} />
</AdminSection>
);
}
@@ -0,0 +1,113 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@/trpc/routers/_app";
import { AdminSection } from "@/components/admin/AdminSection";
import { StatusBadge, type StatusTone } from "@/components/admin/StatusBadge";
import { formatEventTime } from "@/lib/admin/format";
type InboxItem = inferRouterOutputs<AppRouter>["adminOverview"]["getInbox"][number];
const KIND_LABEL: Record<InboxItem["kind"], string> = {
REPORT: "Meldung",
VERIFICATION: "Verifizierung",
LOST: "Vermisst",
MESSAGE: "Nachricht",
};
const KIND_PATH: Record<InboxItem["kind"], string> = {
REPORT: "reports",
VERIFICATION: "verification",
LOST: "lost",
MESSAGE: "messages",
};
const REASON_LABEL: Record<string, string> = {
SPAM: "Spam",
HARASSMENT: "Belästigung",
INAPPROPRIATE: "Unangemessen",
MISINFORMATION: "Falschinformation",
OTHER: "Sonstiges",
};
function describe(item: InboxItem): string {
switch (item.kind) {
case "REPORT":
return `${item.target === "POST" ? "Beitrag" : "Tierprofil"} gemeldet · ${REASON_LABEL[item.detail ?? ""] ?? "Sonstiges"}`;
case "VERIFICATION":
return "Verifizierung beantragt";
case "LOST":
return "Als vermisst gemeldet";
case "MESSAGE":
return `„${item.detail ?? ""}“`;
}
}
function status(item: InboxItem): { tone: StatusTone; label: string } {
if (item.status === "OPEN") return { tone: "warning", label: item.kind === "MESSAGE" ? "Ungelesen" : "Offen" };
if (item.status === "ACTIVE") return { tone: "info", label: "Aktiv" };
if (item.status === "REJECTED") return { tone: "neutral", label: "Abgelehnt" };
const doneLabel: Record<InboxItem["kind"], string> = { REPORT: "Erledigt", VERIFICATION: "Bestätigt", LOST: "Wieder da", MESSAGE: "Gelesen" };
return { tone: item.kind === "MESSAGE" ? "neutral" : "success", label: doneLabel[item.kind] };
}
/** Neueste Eingänge aus allen Warteschlangen (Meldungen, Verifizierung, Vermisst, Nachrichten). */
export function InboxCard({ base }: { base: string }) {
const trpc = useTRPC();
const { data: items, isLoading } = useQuery(trpc.adminOverview.getInbox.queryOptions(undefined, { refetchInterval: 60_000 }));
return (
<AdminSection title="Letzte Eingänge" description="Neues aus allen Warteschlangen">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="h-11 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : !items || items.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">Noch keine Eingänge.</p>
) : (
<div className="-mx-5 overflow-x-auto md:-mx-6">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
<th scope="col" className="py-2 pl-5 pr-3 font-semibold md:pl-6">Zeit</th>
<th scope="col" className="px-3 py-2 font-semibold">Art</th>
<th scope="col" className="px-3 py-2 font-semibold">Tier</th>
<th scope="col" className="px-3 py-2 font-semibold">Beschreibung</th>
<th scope="col" className="px-3 py-2 font-semibold">Status</th>
<th scope="col" className="py-2 pl-3 pr-5 text-right font-semibold md:pr-6">
<span className="sr-only">Aktion</span>
</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const s = status(item);
return (
<tr key={item.id} className="border-b border-border/70 transition-colors last:border-0 hover:bg-muted/60">
<td className="whitespace-nowrap py-3 pl-5 pr-3 text-muted-foreground md:pl-6">{formatEventTime(new Date(item.at))}</td>
<td className="whitespace-nowrap px-3 py-3 font-semibold text-card-foreground">{KIND_LABEL[item.kind]}</td>
<td className="max-w-36 truncate px-3 py-3 text-card-foreground">{item.petName}</td>
<td className="max-w-72 truncate px-3 py-3 text-muted-foreground">{describe(item)}</td>
<td className="px-3 py-3">
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</td>
<td className="py-3 pl-3 pr-5 text-right md:pr-6">
<Link href={`${base}/${KIND_PATH[item.kind]}`} className="font-semibold text-primary hover:underline">
Ansehen
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AdminSection>
);
}
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ArrowDownRight, ArrowUpRight, FileText, Flag, Minus, PawPrint, Users } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useAdminQueues } from "@/components/admin/AdminNavList";
import { formatTrend } from "@/lib/admin/format";
interface KpiCardProps {
label: string;
value: number | undefined;
icon: React.ComponentType<{ className?: string }>;
footer: React.ReactNode;
href?: string;
isAlert?: boolean;
}
function KpiCard({ label, value, icon: Icon, footer, href, isAlert = false }: KpiCardProps) {
const body = (
<>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<span className={`grid h-9 w-9 place-items-center rounded-xl ${isAlert ? "bg-warning/15 text-warning" : "bg-accent text-primary"}`}>
<Icon className="h-[18px] w-[18px]" aria-hidden />
</span>
</div>
<p className="mt-2 text-[28px] font-bold leading-none tracking-tight tabular-nums text-card-foreground">
{value === undefined ? <span className="inline-block h-7 w-16 animate-pulse rounded bg-muted align-middle" /> : value.toLocaleString("de-DE")}
</p>
<div className="mt-3 text-xs text-muted-foreground">{footer}</div>
</>
);
const className = `block rounded-2xl border bg-card p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] ${
isAlert ? "border-warning/40" : "border-border"
}`;
return href ? (
<Link href={href} className={`${className} transition-colors duration-150 hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}>
{body}
</Link>
) : (
<div className={className}>{body}</div>
);
}
function Trend({ current, previous, suffix }: { current: number; previous: number; suffix: string }) {
const { label, direction } = formatTrend(current, previous);
const Icon = direction === "up" ? ArrowUpRight : direction === "down" ? ArrowDownRight : Minus;
const tone = direction === "up" ? "text-success" : direction === "down" ? "text-destructive" : "text-muted-foreground";
return (
<span className="inline-flex items-center gap-1">
<span className={`inline-flex items-center gap-0.5 font-semibold ${tone}`}>
<Icon className="h-3.5 w-3.5" aria-hidden />
{label}
</span>
{suffix}
</span>
);
}
/** Vier Kennzahlen oben auf der Startseite — echte Werte mit Vergleich zur Vorwoche. */
export function KpiCards({ base }: { base: string }) {
const trpc = useTRPC();
const { data: kpis } = useQuery(trpc.adminOverview.getKpis.queryOptions());
const { data: queues } = useAdminQueues();
const openReports = queues?.reports.count;
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard label="Aktive Halter (30 Tage)" value={kpis?.mau} icon={Users} footer={kpis ? `Heute aktiv: ${kpis.dau.toLocaleString("de-DE")}` : " "} />
<KpiCard
label="Neue Beiträge (7 Tage)"
value={kpis?.postsThisWeek}
icon={FileText}
footer={kpis ? <Trend current={kpis.postsThisWeek} previous={kpis.postsLastWeek} suffix="zur Vorwoche" /> : " "}
/>
<KpiCard
label="Offene Meldungen"
value={openReports}
icon={Flag}
href={`${base}/reports`}
isAlert={(openReports ?? 0) > 0}
footer={queues ? (queues.reports.new24h > 0 ? `${queues.reports.new24h} neu seit gestern` : "Keine neuen seit gestern") : " "}
/>
<KpiCard
label="Tiere auf PawFeed"
value={kpis?.pets}
icon={PawPrint}
footer={kpis ? `+${kpis.petsThisWeek} diese Woche · ${kpis.owners.toLocaleString("de-DE")} Halter` : " "}
/>
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import { BadgeCheck, ChevronRight, Flag, MessageCircle, Siren } from "lucide-react";
import { useAdminQueues } from "@/components/admin/AdminNavList";
import { AdminSection } from "@/components/admin/AdminSection";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { formatWaitingSince } from "@/lib/admin/format";
interface QueueRowProps {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
queue: { count: number; oldestAt: string | Date | null } | null | undefined;
openLabel: (n: number) => string;
waitingLabel: string;
isOngoing?: boolean;
}
function QueueRow({ href, label, icon: Icon, queue, openLabel, waitingLabel, isOngoing = false }: QueueRowProps) {
const count = queue?.count ?? 0;
return (
<li>
<Link
href={href}
className="group flex items-center gap-3 rounded-xl px-3 py-3 transition-colors duration-150 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-muted text-muted-foreground">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-card-foreground">{label}</span>
<span className="block truncate text-xs text-muted-foreground">
{queue === undefined ? "Wird geladen …" : count > 0 && queue?.oldestAt ? `${waitingLabel} ${formatWaitingSince(new Date(queue.oldestAt))}` : "Nichts offen"}
</span>
</span>
{queue !== undefined &&
(count > 0 ? (
<StatusBadge tone={isOngoing ? "info" : "warning"}>{openLabel(count)}</StatusBadge>
) : (
<StatusBadge tone="success">Erledigt</StatusBadge>
))}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 group-hover:translate-x-0.5" aria-hidden />
</Link>
</li>
);
}
/** Was gerade auf Bearbeitung wartet — je Warteschlange Anzahl und wie lange der älteste Eintrag wartet. */
export function ModerationStatus({ base }: { base: string }) {
const { data: queues, isLoading } = useAdminQueues();
const q = isLoading ? undefined : queues;
return (
<AdminSection title="Moderationsstatus" description="Was gerade auf euch wartet">
<ul className="-mx-3 flex flex-col">
<QueueRow href={`${base}/reports`} label="Meldungen" icon={Flag} queue={q ? q.reports : undefined} openLabel={(n) => `${n} offen`} waitingLabel="Älteste wartet" />
{(q === undefined || q.verifications) && (
<QueueRow
href={`${base}/verification`}
label="Verifizierung"
icon={BadgeCheck}
queue={q ? q.verifications : undefined}
openLabel={(n) => `${n} ausstehend`}
waitingLabel="Älteste Anfrage"
/>
)}
{(q === undefined || q.lost) && (
<QueueRow
href={`${base}/lost`}
label="Vermisst-Meldungen"
icon={Siren}
queue={q ? q.lost : undefined}
openLabel={(n) => `${n} aktiv`}
waitingLabel="Längste vermisst"
isOngoing
/>
)}
<QueueRow
href={`${base}/messages`}
label="Nachrichten ans Team"
icon={MessageCircle}
queue={q ? q.messages : undefined}
openLabel={(n) => `${n} ungelesen`}
waitingLabel="Älteste ungelesen"
/>
</ul>
</AdminSection>
);
}
@@ -0,0 +1,34 @@
"use client";
import Link from "next/link";
import { ArrowUpRight, BadgeCheck, Flag, Radio, Siren } from "lucide-react";
import { AdminSection } from "@/components/admin/AdminSection";
/** Häufige Wege mit einem Klick — Moderatoren-Ziele nur, wenn die Rolle sie öffnen darf. */
export function QuickActions({ base, canModerate }: { base: string; canModerate: boolean }) {
const actions = [
{ href: `${base}/reports`, label: "Meldungen prüfen", icon: Flag, show: true },
{ href: `${base}/verification`, label: "Tier verifizieren", icon: BadgeCheck, show: canModerate },
{ href: `${base}/broadcasts`, label: "Mitteilung senden", icon: Radio, show: canModerate },
{ href: `${base}/lost`, label: "Vermisst-Meldungen", icon: Siren, show: canModerate },
].filter((a) => a.show);
return (
<AdminSection title="Schnellzugriff">
<ul className="flex flex-col gap-2">
{actions.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link
href={href}
className="group flex min-h-11 items-center gap-3 rounded-xl border border-border px-3.5 py-2.5 text-sm font-semibold text-card-foreground transition-colors duration-150 hover:border-primary/40 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Icon className="h-4 w-4 shrink-0 text-primary" aria-hidden />
<span className="flex-1">{label}</span>
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" aria-hidden />
</Link>
</li>
))}
</ul>
</AdminSection>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles, TriangleAlert } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { Switch } from "@/components/ui/switch";
import { AdminSection } from "@/components/admin/AdminSection";
interface SwitchRowProps {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
checked: boolean;
isDanger: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}
function SwitchRow({ icon: Icon, title, description, checked, isDanger, disabled, onChange }: SwitchRowProps) {
return (
<div className={`flex items-start gap-3 rounded-xl px-3 py-3 ${isDanger ? "bg-destructive/10 ring-1 ring-inset ring-destructive/30" : ""}`}>
<Icon className={`mt-0.5 h-4 w-4 shrink-0 ${isDanger ? "text-destructive" : "text-muted-foreground"}`} aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-card-foreground">{title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
</div>
<Switch checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={title} className="mt-0.5" />
</div>
);
}
/**
* Notschalter: Wartungsmodus (nur Inhaber/Super-Admin) und Story-Editor. Rot nur,
* wenn ein Schalter gerade vom Normalzustand abweicht — sonst ruhig im Hintergrund.
*/
export function SystemCard({ canToggleMaintenance }: { canToggleMaintenance: boolean }) {
const trpc = useTRPC();
const qc = useQueryClient();
const { data: maintenance } = useQuery(trpc.admin.getMaintenanceMode.queryOptions(undefined, { enabled: canToggleMaintenance }));
const { data: storyEditor } = useQuery(trpc.admin.getStoryEditorEnabled.queryOptions());
const setMaintenance = useMutation(
trpc.admin.setMaintenanceMode.mutationOptions({
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.admin.getMaintenanceMode.queryKey() }),
})
);
const setStoryEditor = useMutation(
trpc.admin.setStoryEditorEnabled.mutationOptions({
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.admin.getStoryEditorEnabled.queryKey() }),
})
);
return (
<AdminSection title="System" description="Notschalter für die ganze Plattform">
<div className="-mx-3 flex flex-col gap-1">
{canToggleMaintenance && maintenance && (
<SwitchRow
icon={TriangleAlert}
title="Wartungsmodus"
description={maintenance.enabled ? "Aktiv – Besucher sehen die Wartungsseite (Admin bleibt erreichbar)." : "Aus – die Seite ist normal erreichbar."}
checked={maintenance.enabled}
isDanger={maintenance.enabled}
disabled={setMaintenance.isPending}
onChange={(enabled) => setMaintenance.mutate({ enabled })}
/>
)}
{storyEditor && (
<SwitchRow
icon={Sparkles}
title="Story-Editor"
description={storyEditor.enabled ? "An – Text, Markierungen, GIFs und Sticker in Storys." : "Aus – App und Web blenden Editor und Overlays aus."}
checked={storyEditor.enabled}
isDanger={!storyEditor.enabled}
disabled={setStoryEditor.isPending}
onChange={(enabled) => setStoryEditor.mutate({ enabled })}
/>
)}
</div>
</AdminSection>
);
}
+1 -1
View File
@@ -53,7 +53,7 @@ export function PetRankSection({ ownerId, ownerPetCount, qualifiedCount, ranks,
<section className="space-y-3">
<div>
<h3 className="font-semibold">Empfehlungsrang</h3>
<p className="text-xs text-amber-700 dark:text-amber-400">
<p className="text-xs text-amber-700 dark:text-warning">
Gilt für den Owner, also für {ownerPetCount === 1 ? "sein einziges Tier" : `alle ${ownerPetCount} Tiere`}. {qualifiedCount} gezählte
Einladungen.
</p>
@@ -108,7 +108,7 @@ export function RankEditorDialog({ open, mode, initial, isSaving, onClose, onSav
))}
</div>
{lowContrast && (
<p className="text-xs text-amber-700 dark:text-amber-400">
<p className="text-xs text-amber-700 dark:text-warning">
Die Farbe hat auf hellem oder dunklem Hintergrund wenig Kontrast. Abzeichen und Ring sind dort schlecht zu sehen.
</p>
)}
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { formatEventTime, formatTrend, formatWaitingSince, greetingFor } from "@/lib/admin/format";
// 05.10.2026, 09:30 in Berlin (CEST = UTC+2)
const NOW = new Date("2026-10-05T07:30:00Z");
describe("formatEventTime", () => {
it("names today and yesterday, otherwise shows the date", () => {
expect(formatEventTime(new Date("2026-10-05T07:12:00Z"), NOW)).toBe("Heute, 09:12");
expect(formatEventTime(new Date("2026-10-04T16:30:00Z"), NOW)).toBe("Gestern, 18:30");
expect(formatEventTime(new Date("2026-10-03T09:20:00Z"), NOW)).toBe("03.10., 11:20");
expect(formatEventTime(new Date("2025-12-24T17:00:00Z"), NOW)).toBe("24.12.2025, 18:00");
});
it("uses the Berlin calendar day, not UTC", () => {
// 04.10. 23:30 UTC = 05.10. 01:30 Berlin → heute
expect(formatEventTime(new Date("2026-10-04T23:30:00Z"), NOW)).toBe("Heute, 01:30");
});
});
describe("formatWaitingSince", () => {
it("rounds down to coarse steps", () => {
expect(formatWaitingSince(new Date(NOW.getTime() - 20_000), NOW)).toBe("gerade eben");
expect(formatWaitingSince(new Date(NOW.getTime() - 12 * 60_000), NOW)).toBe("seit 12 Min.");
expect(formatWaitingSince(new Date(NOW.getTime() - 3.5 * 3_600_000), NOW)).toBe("seit 3 Std.");
expect(formatWaitingSince(new Date(NOW.getTime() - 30 * 3_600_000), NOW)).toBe("seit 1 Tag");
expect(formatWaitingSince(new Date(NOW.getTime() - 5 * 86_400_000), NOW)).toBe("seit 5 Tagen");
});
});
describe("greetingFor", () => {
it("follows the time of day in Berlin", () => {
expect(greetingFor(NOW)).toBe("Guten Morgen");
expect(greetingFor(new Date("2026-10-05T12:00:00Z"))).toBe("Guten Tag");
expect(greetingFor(new Date("2026-10-05T18:00:00Z"))).toBe("Guten Abend");
});
});
describe("formatTrend", () => {
it("shows percent change with a direction", () => {
expect(formatTrend(15, 10)).toEqual({ label: "+50 %", direction: "up" });
expect(formatTrend(9, 10)).toEqual({ label: "−10 %", direction: "down" });
expect(formatTrend(10, 10)).toEqual({ label: "±0 %", direction: "flat" });
expect(formatTrend(3, 0)).toEqual({ label: "neu", direction: "up" });
expect(formatTrend(0, 0)).toEqual({ label: "±0 %", direction: "flat" });
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Zeitangaben fürs Admin-Panel, menschenlesbar auf Deutsch (Europe/Berlin).
* `now` ist injizierbar, damit die Ausgabe in Tests stabil ist.
*/
const TIME_ZONE = "Europe/Berlin";
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const dayKey = (d: Date) => d.toLocaleDateString("de-DE", { timeZone: TIME_ZONE });
/** „Heute, 09:42“ · „Gestern, 18:30“ · „03.10., 11:20“ (älteres Jahr: „03.10.2025, 11:20“). */
export function formatEventTime(date: Date, now: Date = new Date()): string {
const time = date.toLocaleTimeString("de-DE", { timeZone: TIME_ZONE, hour: "2-digit", minute: "2-digit" });
if (dayKey(date) === dayKey(now)) return `Heute, ${time}`;
if (dayKey(date) === dayKey(new Date(now.getTime() - DAY_MS))) return `Gestern, ${time}`;
const year = (d: Date) => d.toLocaleDateString("de-DE", { timeZone: TIME_ZONE, year: "numeric" });
const day = date.toLocaleDateString("de-DE", {
timeZone: TIME_ZONE,
day: "2-digit",
month: "2-digit",
...(year(date) === year(now) ? {} : { year: "numeric" }),
});
return `${day}, ${time}`;
}
/** Wartedauer in groben Stufen: „gerade eben“, „seit 12 Min.“, „seit 3 Std.“, „seit 2 Tagen“. */
export function formatWaitingSince(date: Date, now: Date = new Date()): string {
const diff = Math.max(0, now.getTime() - date.getTime());
if (diff < MINUTE_MS) return "gerade eben";
if (diff < HOUR_MS) return `seit ${Math.floor(diff / MINUTE_MS)} Min.`;
if (diff < DAY_MS) return `seit ${Math.floor(diff / HOUR_MS)} Std.`;
const days = Math.floor(diff / DAY_MS);
return days === 1 ? "seit 1 Tag" : `seit ${days} Tagen`;
}
/** Tageszeit-Gruß für den Seitenkopf der Startseite. */
export function greetingFor(now: Date = new Date()): string {
const hourPart = new Intl.DateTimeFormat("de-DE", { timeZone: TIME_ZONE, hour: "numeric", hourCycle: "h23" })
.formatToParts(now)
.find((p) => p.type === "hour");
const hour = Number(hourPart?.value ?? 12);
if (hour < 11) return "Guten Morgen";
if (hour < 18) return "Guten Tag";
return "Guten Abend";
}
/** Veränderung gegenüber dem Vergleichszeitraum, z. B. „+25 %“, „−10 %“, „neu“ (Vorher 0). */
export function formatTrend(current: number, previous: number): { label: string; direction: "up" | "down" | "flat" } {
if (previous === 0) return current === 0 ? { label: "±0 %", direction: "flat" } : { label: "neu", direction: "up" };
const pct = Math.round(((current - previous) / previous) * 100);
if (pct === 0) return { label: "±0 %", direction: "flat" };
return { label: `${pct > 0 ? "+" : "−"}${Math.abs(pct)} %`, direction: pct > 0 ? "up" : "down" };
}
+2
View File
@@ -30,6 +30,7 @@ import { referralAdminRouter } from "@/trpc/routers/referral-admin";
import { curatorAdminRouter } from "@/trpc/routers/curator-admin";
import { lostReportsRouter } from "@/trpc/routers/lost-reports";
import { lostReportAdminRouter } from "@/trpc/routers/lost-report-admin";
import { adminOverviewRouter } from "@/trpc/routers/admin-overview";
import { petAdminRouter } from "@/trpc/routers/pet-admin";
export const appRouter = router({
@@ -65,6 +66,7 @@ export const appRouter = router({
curatorAdmin: curatorAdminRouter,
lostReports: lostReportsRouter,
lostReportAdmin: lostReportAdminRouter,
adminOverview: adminOverviewRouter,
petAdmin: petAdminRouter,
});
+200
View File
@@ -0,0 +1,200 @@
import "server-only";
import { z } from "zod";
import { clerkClient } from "@clerk/nextjs/server";
import type { PrismaClient } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import { assertAdmin } from "@/trpc/routers/admin";
import { ensureTeamPet } from "@/lib/team-pet";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getDailyVisitCount, getUniqueVisitorCount, lastNDays } from "@/lib/visit-tracking";
const DAY_MS = 24 * 60 * 60 * 1000;
const INBOX_PER_SOURCE = 5;
const INBOX_SIZE = 8;
const SEARCH_LIMIT = 6;
const MESSAGE_PREVIEW_CHARS = 80;
type AdminRole = Awaited<ReturnType<typeof assertAdmin>>;
const canModerate = (role: AdminRole) => role !== "SUPPORT";
/** Alle Nachrichten an das Team-Tier, die noch niemand im Team gelesen hat. */
function unreadTeamMessagesWhere(teamPetId: string) {
return {
readAt: null,
senderPetId: { not: teamPetId },
conversation: { OR: [{ petAId: teamPetId }, { petBId: teamPetId }] },
};
}
async function oldestCreatedAt(query: Promise<{ createdAt: Date } | null>): Promise<Date | null> {
return (await query)?.createdAt ?? null;
}
async function loadInbox(prisma: PrismaClient, role: AdminRole) {
const teamPet = await ensureTeamPet(prisma);
const moderator = canModerate(role);
const [reports, verifications, lostReports, messages] = await Promise.all([
prisma.report.findMany({
orderBy: { createdAt: "desc" },
take: INBOX_PER_SOURCE,
select: { id: true, createdAt: true, reason: true, targetPostId: true, reporter: { select: { name: true } } },
}),
moderator
? prisma.verificationRequest.findMany({
orderBy: { createdAt: "desc" },
take: INBOX_PER_SOURCE,
select: { id: true, createdAt: true, status: true, pet: { select: { name: true } } },
})
: [],
moderator
? prisma.lostReport.findMany({
orderBy: { createdAt: "desc" },
take: INBOX_PER_SOURCE,
select: { id: true, createdAt: true, foundAt: true, pet: { select: { name: true } } },
})
: [],
prisma.message.findMany({
where: { senderPetId: { not: teamPet.id }, conversation: { OR: [{ petAId: teamPet.id }, { petBId: teamPet.id }] } },
orderBy: { createdAt: "desc" },
take: INBOX_PER_SOURCE,
select: { id: true, createdAt: true, readAt: true, body: true, senderPet: { select: { name: true } } },
}),
]);
const items = [
...reports.map((r) => ({
id: `report:${r.id}`,
kind: "REPORT" as const,
at: r.createdAt,
petName: r.reporter.name,
detail: r.reason,
target: r.targetPostId ? ("POST" as const) : ("PET" as const),
status: "OPEN" as const,
})),
...verifications.map((v) => ({
id: `verification:${v.id}`,
kind: "VERIFICATION" as const,
at: v.createdAt,
petName: v.pet.name,
detail: null,
target: null,
status: v.status === "PENDING" ? ("OPEN" as const) : v.status === "APPROVED" ? ("DONE" as const) : ("REJECTED" as const),
})),
...lostReports.map((l) => ({
id: `lost:${l.id}`,
kind: "LOST" as const,
at: l.createdAt,
petName: l.pet.name,
detail: null,
target: null,
status: l.foundAt ? ("DONE" as const) : ("ACTIVE" as const),
})),
...messages.map((m) => ({
id: `message:${m.id}`,
kind: "MESSAGE" as const,
at: m.createdAt,
petName: m.senderPet.name,
detail: m.body.length > MESSAGE_PREVIEW_CHARS ? `${m.body.slice(0, MESSAGE_PREVIEW_CHARS)}…` : m.body,
target: null,
status: m.readAt ? ("DONE" as const) : ("OPEN" as const),
})),
];
return items.sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, INBOX_SIZE);
}
/**
* Startseite + Rahmen des Admin-Panels (/p/[secret]): Zähler für die Sidebar,
* Kennzahlen, Eingangs-Feed und globale Suche. Für alle Admin-Rollen; was nur
* Moderatoren sehen dürfen (Verifizierung, Vermisst), kommt für SUPPORT als null.
*/
export const adminOverviewRouter = router({
/** Offene Arbeit je Warteschlange: Anzahl + seit wann der älteste Eintrag wartet. */
getQueues: protectedProcedure.query(async ({ ctx }) => {
const role = await assertAdmin(ctx.prisma, ctx.userId);
const moderator = canModerate(role);
const teamPet = await ensureTeamPet(ctx.prisma);
const oldestFirst = { orderBy: { createdAt: "asc" as const }, select: { createdAt: true } };
const [openReports, oldestReportAt, newReports24h, unreadMessages, oldestMessageAt] = await Promise.all([
ctx.prisma.report.count(),
oldestCreatedAt(ctx.prisma.report.findFirst(oldestFirst)),
ctx.prisma.report.count({ where: { createdAt: { gte: new Date(Date.now() - DAY_MS) } } }),
ctx.prisma.message.count({ where: unreadTeamMessagesWhere(teamPet.id) }),
oldestCreatedAt(ctx.prisma.message.findFirst({ where: unreadTeamMessagesWhere(teamPet.id), ...oldestFirst })),
]);
const [pendingVerifications, oldestVerificationAt, activeLost, oldestLostAt] = moderator
? await Promise.all([
ctx.prisma.verificationRequest.count({ where: { status: "PENDING" } }),
oldestCreatedAt(ctx.prisma.verificationRequest.findFirst({ where: { status: "PENDING" }, ...oldestFirst })),
ctx.prisma.lostReport.count({ where: { foundAt: null } }),
oldestCreatedAt(ctx.prisma.lostReport.findFirst({ where: { foundAt: null }, ...oldestFirst })),
])
: [null, null, null, null];
return {
reports: { count: openReports, oldestAt: oldestReportAt, new24h: newReports24h },
messages: { count: unreadMessages, oldestAt: oldestMessageAt },
verifications: pendingVerifications === null ? null : { count: pendingVerifications, oldestAt: oldestVerificationAt },
lost: activeLost === null ? null : { count: activeLost, oldestAt: oldestLostAt },
};
}),
/** Kennzahlen der Startseite mit Vergleich zur Vorwoche. */
getKpis: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const now = Date.now();
const weekAgo = new Date(now - 7 * DAY_MS);
const twoWeeksAgo = new Date(now - 14 * DAY_MS);
const thisWeek = { createdAt: { gte: weekAgo } };
const lastWeek = { createdAt: { gte: twoWeeksAgo, lt: weekAgo } };
const [owners, ownersThisWeek, pets, petsThisWeek, postsThisWeek, postsLastWeek, dau, mau] = await Promise.all([
ctx.prisma.owner.count(),
ctx.prisma.owner.count({ where: thisWeek }),
ctx.prisma.pet.count(),
ctx.prisma.pet.count({ where: thisWeek }),
ctx.prisma.post.count({ where: thisWeek }),
ctx.prisma.post.count({ where: lastWeek }),
getDailyVisitCount(new Date()),
getUniqueVisitorCount(lastNDays(30)),
]);
return { owners, ownersThisWeek, pets, petsThisWeek, postsThisWeek, postsLastWeek, dau, mau };
}),
/** Neueste Eingänge aus allen Warteschlangen, gemischt und nach Zeit sortiert. */
getInbox: protectedProcedure.query(async ({ ctx }) => {
const role = await assertAdmin(ctx.prisma, ctx.userId);
return loadInbox(ctx.prisma, role);
}),
/** Globale Suche in der Kopfzeile: Tiere (DB) und Halter (Clerk: Name/E-Mail). */
search: protectedProcedure
.input(z.object({ query: z.string().trim().min(2).max(60) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const clerk = await clerkClient();
const [pets, users] = await Promise.all([
ctx.prisma.pet.findMany({
where: {
OR: [
{ name: { contains: input.query, mode: "insensitive" } },
{ nickname: { contains: input.query, mode: "insensitive" } },
],
},
orderBy: { createdAt: "desc" },
take: SEARCH_LIMIT,
select: { id: true, name: true, nickname: true, avatarKey: true },
}),
clerk.users.getUserList({ query: input.query, limit: SEARCH_LIMIT }),
]);
return {
pets: pets.map((p) => ({ id: p.id, name: p.name, nickname: p.nickname, avatarUrl: p.avatarKey ? getAvatarUrl(p.avatarKey) : null })),
owners: users.data.map((u) => ({
id: u.id,
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || null,
email: u.primaryEmailAddress?.emailAddress ?? u.emailAddresses[0]?.emailAddress ?? null,
})),
};
}),
});