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:
1 parent
9ba965ae11
commit
c4fb9aefb7
44 files changed
+2074
-973
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "Gelesen". 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">
|
||||
"{deleteTarget?.title}" 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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 „Inaktiv“ 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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
“{report.notes}”
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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 ? <> — “{pet.bio}”</> : <> — 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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user