From 5f759985129198746db379f666ee7371467f2040 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Wed, 7 Oct 2026 05:50:05 +0200 Subject: [PATCH] feat(admin): nach jeder Aktion automatisch neu laden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminAutoRefresh im Admin-Layout lädt nach jeder erfolgreichen Mutation alle sichtbaren Daten neu (invalidateQueries, nur aktive Queries holen sofort neu). Damit braucht keine Admin-Seite mehr F5, auch die, die bisher nach manchen Aktionen gar nicht neu geladen haben. Die ~35 einzelnen invalidateQueries-Aufrufe in den Seiten entfallen. Gilt nur im Admin-Panel. Co-Authored-By: Claude Opus 5.5 --- src/app/p/[secret]/ads/page.tsx | 11 ++--- src/app/p/[secret]/blacklist/page.tsx | 5 +-- src/app/p/[secret]/broadcasts/page.tsx | 5 +-- src/app/p/[secret]/curator/page.tsx | 4 +- src/app/p/[secret]/layout.tsx | 2 + src/app/p/[secret]/legal/page.tsx | 4 +- src/app/p/[secret]/lost/page.tsx | 4 +- src/app/p/[secret]/messages/page.tsx | 2 - src/app/p/[secret]/moderators/page.tsx | 6 +-- src/app/p/[secret]/music/page.tsx | 10 ++--- src/app/p/[secret]/posts/page.tsx | 33 ++------------- src/app/p/[secret]/reports/page.tsx | 10 ++--- src/app/p/[secret]/users/page.tsx | 4 +- src/app/p/[secret]/verification/page.tsx | 4 +- src/components/admin/AdminAutoRefresh.tsx | 28 +++++++++++++ .../admin/admin-auto-refresh.test.ts | 41 +++++++++++++++++++ src/components/admin/dashboard/SystemCard.tsx | 5 +-- .../admin/referrals/OverviewTab.tsx | 8 ++-- src/components/admin/referrals/RanksTab.tsx | 5 +-- .../admin/referrals/SettingsTab.tsx | 4 +- .../admin/users/UserActionDialogs.tsx | 7 +--- src/trpc/routers/admin.ts | 2 - 22 files changed, 101 insertions(+), 103 deletions(-) create mode 100644 src/components/admin/AdminAutoRefresh.tsx create mode 100644 src/components/admin/admin-auto-refresh.test.ts diff --git a/src/app/p/[secret]/ads/page.tsx b/src/app/p/[secret]/ads/page.tsx index 618ed22..616b2d9 100644 --- a/src/app/p/[secret]/ads/page.tsx +++ b/src/app/p/[secret]/ads/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useRef, useCallback } from "react"; -import { useQuery, useQueries, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueries, useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { AdminPageHeader } from "@/components/admin/AdminSection"; import { StatusBadge } from "@/components/admin/StatusBadge"; @@ -44,7 +44,6 @@ const MAX_SIZE = 10 * 1024 * 1024; export default function AdminAdsPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [form, setForm] = useState(emptyForm); const [editingId, setEditingId] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); @@ -68,18 +67,16 @@ export default function AdminAdsPage() { breedsBySpecies[speciesId] = breedQueries[i]?.data ?? []; }); - const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listAds.queryKey() }); - const getPresignedUrl = useMutation(trpc.admin.getAdImagePresignedUrl.mutationOptions()); const createAd = useMutation( - trpc.admin.createAd.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } }) + trpc.admin.createAd.mutationOptions({ onSuccess: () => { closeDialog(); } }) ); const updateAd = useMutation( - trpc.admin.updateAd.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } }) + trpc.admin.updateAd.mutationOptions({ onSuccess: () => { closeDialog(); } }) ); const deleteAd = useMutation( - trpc.admin.deleteAd.mutationOptions({ onSuccess: () => { invalidate(); setDeleteId(null); } }) + trpc.admin.deleteAd.mutationOptions({ onSuccess: () => { setDeleteId(null); } }) ); function openCreate() { diff --git a/src/app/p/[secret]/blacklist/page.tsx b/src/app/p/[secret]/blacklist/page.tsx index d568f68..455b865 100644 --- a/src/app/p/[secret]/blacklist/page.tsx +++ b/src/app/p/[secret]/blacklist/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { Trash2, Loader2, Plus, Ban } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -31,7 +31,6 @@ const FILTER_LABEL: Record = { all: "Alle Arten", ...TYPE_LA export default function AdminBlacklistPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [typeFilter, setTypeFilter] = useState("all"); const [newType, setNewType] = useState(BlacklistEntryType.WORD); const [newValue, setNewValue] = useState(""); @@ -49,7 +48,6 @@ export default function AdminBlacklistPage() { const deleteMutation = useMutation( trpc.admin.deleteBlacklistEntry.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listBlacklist.queryKey() }); setDeleteTarget(null); }, }) @@ -81,7 +79,6 @@ export default function AdminBlacklistPage() { } setIsBulkAdding(false); - void qc.invalidateQueries({ queryKey: trpc.admin.listBlacklist.queryKey() }); setNewValue(""); if (addedCount > 0) { diff --git a/src/app/p/[secret]/broadcasts/page.tsx b/src/app/p/[secret]/broadcasts/page.tsx index a904791..9793b95 100644 --- a/src/app/p/[secret]/broadcasts/page.tsx +++ b/src/app/p/[secret]/broadcasts/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { Send, Loader2, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -23,7 +23,6 @@ function formatDate(d: string | Date) { export default function AdminBroadcastsPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); @@ -34,7 +33,6 @@ export default function AdminBroadcastsPage() { const createMutation = useMutation( trpc.admin.createBroadcast.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listBroadcasts.queryKey() }); toast.success("Mitteilung gesendet – Halter sehen sie beim nächsten Öffnen der App"); setTitle(""); setBody(""); @@ -46,7 +44,6 @@ export default function AdminBroadcastsPage() { const deleteMutation = useMutation( trpc.admin.deleteBroadcast.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listBroadcasts.queryKey() }); toast.success("Mitteilung gelöscht"); setDeleteTarget(null); setDeleteReason(""); diff --git a/src/app/p/[secret]/curator/page.tsx b/src/app/p/[secret]/curator/page.tsx index c4db439..af2e30a 100644 --- a/src/app/p/[secret]/curator/page.tsx +++ b/src/app/p/[secret]/curator/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Loader2, Sprout } from "lucide-react"; import { toast } from "sonner"; import { useTRPC } from "@/trpc/client"; @@ -55,7 +55,6 @@ export default function AdminCuratorPage() { function CuratorForm({ initial }: { initial: Form }) { const trpc = useTRPC(); - const qc = useQueryClient(); const [form, setForm] = useState
({ welcomeEnabled: initial.welcomeEnabled, autoFollowEnabled: initial.autoFollowEnabled, @@ -68,7 +67,6 @@ function CuratorForm({ initial }: { initial: Form }) { trpc.curatorAdmin.update.mutationOptions({ onSuccess: () => { toast.success("Begrüßung gespeichert"); - void qc.invalidateQueries(trpc.curatorAdmin.get.queryFilter()); }, onError: (err) => toast.error(err.message), }) diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx index 62c8514..69b4236 100644 --- a/src/app/p/[secret]/layout.tsx +++ b/src/app/p/[secret]/layout.tsx @@ -6,6 +6,7 @@ import { createTRPCCaller } from "@/trpc/server"; import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; import { AdminNavList } from "@/components/admin/AdminNavList"; import { AdminTopbar } from "@/components/admin/AdminTopbar"; +import { AdminAutoRefresh } from "@/components/admin/AdminAutoRefresh"; 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"; @@ -56,6 +57,7 @@ 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. + {/* Eigene Admin-Palette (globals.css .admin-theme), hell als Standard, dunkel per Umschalter in der Kopfzeile — unabhängig vom Website-Theme. */}
("NUTZUNGSBEDINGUNGEN"); const [changeSummary, setChangeSummary] = useState(""); @@ -42,7 +41,6 @@ export default function AdminLegalPage() { const publishMutation = useMutation( trpc.legal.publish.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.legal.listVersions.queryKey() }); toast.success("Veröffentlicht – Halter sehen den Hinweis beim nächsten Öffnen der App"); setChangeSummary(""); }, diff --git a/src/app/p/[secret]/lost/page.tsx b/src/app/p/[secret]/lost/page.tsx index 795509e..ab9003e 100644 --- a/src/app/p/[secret]/lost/page.tsx +++ b/src/app/p/[secret]/lost/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { ExternalLink, Loader2, Mail, MapPin, Phone, Search, Users } from "lucide-react"; import { toast } from "sonner"; import { useTRPC } from "@/trpc/client"; @@ -27,7 +27,6 @@ const DATE_FORMAT = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", time */ export default function AdminLostReportsPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [status, setStatus] = useState("ACTIVE"); const [target, setTarget] = useState<{ id: string; name: string; mode: Mode } | null>(null); const [reason, setReason] = useState(""); @@ -39,7 +38,6 @@ export default function AdminLostReportsPage() { toast.success(target?.mode === "DELETE" ? "Meldung gelöscht" : "Meldung geschlossen"); setTarget(null); setReason(""); - void qc.invalidateQueries({ queryKey: trpc.lostReportAdmin.list.queryKey() }); }, onError: (err) => toast.error(err.message), }) diff --git a/src/app/p/[secret]/messages/page.tsx b/src/app/p/[secret]/messages/page.tsx index ea01189..c697eba 100644 --- a/src/app/p/[secret]/messages/page.tsx +++ b/src/app/p/[secret]/messages/page.tsx @@ -53,8 +53,6 @@ export default function AdminMessagesPage() { trpc.admin.sendTeamReply.mutationOptions({ onSuccess: () => { setDraft(""); - void qc.invalidateQueries({ queryKey: trpc.admin.getTeamConversationThread.queryKey({ conversationId: activeId ?? "" }) }); - void qc.invalidateQueries({ queryKey: trpc.admin.listTeamConversations.queryKey() }); }, }) ); diff --git a/src/app/p/[secret]/moderators/page.tsx b/src/app/p/[secret]/moderators/page.tsx index d5f1af3..cb13c69 100644 --- a/src/app/p/[secret]/moderators/page.tsx +++ b/src/app/p/[secret]/moderators/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { AdminRoleType } from "@prisma/client"; import { Check, Loader2, Plus, Search, Trash2, UserRound } from "lucide-react"; import { useTRPC } from "@/trpc/client"; @@ -30,7 +30,6 @@ const SEARCH_DEBOUNCE_MS = 250; */ export default function AdminModeratorsPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [dialogOpen, setDialogOpen] = useState(false); const [query, setQuery] = useState(""); const [picked, setPicked] = useState<{ id: string; label: string } | null>(null); @@ -45,7 +44,6 @@ export default function AdminModeratorsPage() { ); const actionCountByModeratorId = new Map(throughput.map((t) => [t.moderatorId, t.actionCount])); - const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listModerators.queryKey() }); const closeGrant = () => { setDialogOpen(false); setQuery(""); @@ -56,7 +54,6 @@ export default function AdminModeratorsPage() { const grantRole = useMutation( trpc.admin.grantRole.mutationOptions({ onSuccess: () => { - invalidate(); toast.success("Rolle vergeben."); closeGrant(); }, @@ -66,7 +63,6 @@ export default function AdminModeratorsPage() { const revokeRole = useMutation( trpc.admin.revokeRole.mutationOptions({ onSuccess: () => { - invalidate(); toast.success("Rolle entzogen."); setRevokeTarget(null); }, diff --git a/src/app/p/[secret]/music/page.tsx b/src/app/p/[secret]/music/page.tsx index e4be1a1..5bd60d1 100644 --- a/src/app/p/[secret]/music/page.tsx +++ b/src/app/p/[secret]/music/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useRef, useCallback } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { AdminPageHeader } from "@/components/admin/AdminSection"; import { StatusBadge } from "@/components/admin/StatusBadge"; @@ -73,7 +73,6 @@ function readAudioDuration(file: File): Promise { export default function AdminMusicPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [form, setForm] = useState(emptyForm); const [editingId, setEditingId] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); @@ -86,18 +85,17 @@ export default function AdminMusicPage() { const { data: rawTracks, isLoading } = useQuery(trpc.admin.listMusicTracks.queryOptions(undefined)); const tracks = rawTracks ?? []; - const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listMusicTracks.queryKey() }); const getUploadUrl = useMutation(trpc.admin.getMusicTrackUploadUrl.mutationOptions()); const createTrack = useMutation( - trpc.admin.createMusicTrack.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } }) + trpc.admin.createMusicTrack.mutationOptions({ onSuccess: () => { closeDialog(); } }) ); const updateTrack = useMutation( - trpc.admin.updateMusicTrack.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } }) + trpc.admin.updateMusicTrack.mutationOptions({ onSuccess: () => { closeDialog(); } }) ); const deleteTrack = useMutation( - trpc.admin.deleteMusicTrack.mutationOptions({ onSuccess: () => { invalidate(); setDeleteId(null); } }) + trpc.admin.deleteMusicTrack.mutationOptions({ onSuccess: () => { setDeleteId(null); } }) ); function openCreate() { diff --git a/src/app/p/[secret]/posts/page.tsx b/src/app/p/[secret]/posts/page.tsx index 1b90a09..56237e8 100644 --- a/src/app/p/[secret]/posts/page.tsx +++ b/src/app/p/[secret]/posts/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useSyncExternalStore } from "react"; -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { Trash2, MessageSquare, Loader2, ChevronDown, Play, ImageOff, PawPrint, EyeOff, Eye, Sparkles, Pin, PinOff, Hash, MoreHorizontal, Flag, Search, Repeat2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -57,7 +57,6 @@ function useGridSize(): [GridSize, (size: GridSize) => void] { export default function AdminPostsPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [search, setSearch] = useState(""); const [gridSize, handleGridSizeChange] = useGridSize(); const [commentsPostId, setCommentsPostId] = useState(null); @@ -88,7 +87,6 @@ export default function AdminPostsPage() { const deletePost = useMutation( trpc.admin.deletePost.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listPosts.queryKey() }); setDeleteTarget(null); setReason(""); }, @@ -98,9 +96,6 @@ export default function AdminPostsPage() { const deleteComment = useMutation( trpc.admin.deleteComment.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ - queryKey: trpc.admin.listComments.queryKey({ postId: commentsPostId ?? "" }), - }); setDeleteTarget(null); setReason(""); }, @@ -110,43 +105,23 @@ export default function AdminPostsPage() { const hidePost = useMutation( trpc.admin.hidePost.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listPosts.queryKey() }); setHideTargetId(null); setHideReason(""); }, }) ); - const unhidePost = useMutation( - trpc.admin.unhidePost.mutationOptions({ - onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listPosts.queryKey() }); - }, - }) - ); + const unhidePost = useMutation(trpc.admin.unhidePost.mutationOptions()); - const pinPost = useMutation( - trpc.admin.pinPost.mutationOptions({ - onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listPosts.queryKey() }); - }, - }) - ); + const pinPost = useMutation(trpc.admin.pinPost.mutationOptions()); - const unpinPost = useMutation( - trpc.admin.unpinPost.mutationOptions({ - onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listPosts.queryKey() }); - }, - }) - ); + const unpinPost = useMutation(trpc.admin.unpinPost.mutationOptions()); const { data: trending } = useQuery(trpc.explore.getTrending.queryOptions()); const setAiDisclosure = useMutation( trpc.admin.setAiDisclosure.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listPosts.queryKey() }); setAiTargetId(null); setAiReason(""); }, diff --git a/src/app/p/[secret]/reports/page.tsx b/src/app/p/[secret]/reports/page.tsx index 2186165..c8bed69 100644 --- a/src/app/p/[secret]/reports/page.tsx +++ b/src/app/p/[secret]/reports/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { getMediaUrl } from "@/lib/media-url"; import { BlurImage } from "@/components/ui/blur-image"; @@ -35,7 +35,6 @@ type ReportAction = export default function AdminReportsPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [action, setAction] = useState(null); const [reason, setReason] = useState(""); // found.md: Melder bekommt eine Team-Nachricht (Begründung bzw. Dank). @@ -58,18 +57,15 @@ export default function AdminReportsPage() { ) ); - const invalidate = () => - void qc.invalidateQueries({ queryKey: trpc.admin.listReports.queryKey() }); - const dismiss = useMutation( trpc.admin.dismissReport.mutationOptions({ - onSuccess: () => { invalidate(); closeDialog(); }, + onSuccess: () => { closeDialog(); }, }) ); const deletePost = useMutation( trpc.admin.deleteReportedPost.mutationOptions({ - onSuccess: () => { invalidate(); closeDialog(); }, + onSuccess: () => { closeDialog(); }, }) ); diff --git a/src/app/p/[secret]/users/page.tsx b/src/app/p/[secret]/users/page.tsx index 9fea8a5..b5dee0f 100644 --- a/src/app/p/[secret]/users/page.tsx +++ b/src/app/p/[secret]/users/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter, useParams, useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import type { inferRouterOutputs } from "@trpc/server"; import { AdminRoleType } from "@prisma/client"; import { @@ -78,7 +78,6 @@ function UserStatus({ user }: { user: User }) { */ export default function AdminUsersPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const router = useRouter(); const params = useParams<{ secret: string }>(); // ?q= kommt von der globalen Suche in der Kopfzeile; ein neuer Treffer ersetzt die Suche. @@ -120,7 +119,6 @@ export default function AdminUsersPage() { const verify = useMutation( trpc.admin.setPetVerified.mutationOptions({ onSuccess: (pet) => { - void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); toast.success(pet.isVerified ? `${formatPetName(pet)} ist jetzt verifiziert.` : `Verifizierung von ${formatPetName(pet)} entfernt.`); }, onError: (err) => toast.error(`Verifizierung fehlgeschlagen: ${err.message}`), diff --git a/src/app/p/[secret]/verification/page.tsx b/src/app/p/[secret]/verification/page.tsx index 116bd51..5f05f6d 100644 --- a/src/app/p/[secret]/verification/page.tsx +++ b/src/app/p/[secret]/verification/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { Loader2, @@ -78,7 +78,6 @@ const EMPTY_CHECKLIST: ChecklistState = { export default function AdminVerificationPage() { const trpc = useTRPC(); - const qc = useQueryClient(); const [status, setStatus] = useState("PENDING"); const [reviewTarget, setReviewTarget] = useState(null); const [checklist, setChecklist] = useState(EMPTY_CHECKLIST); @@ -96,7 +95,6 @@ export default function AdminVerificationPage() { const reviewMutation = useMutation( trpc.admin.reviewVerificationRequest.mutationOptions({ onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.admin.listVerificationRequests.queryKey() }); setReviewTarget(null); setChecklist(EMPTY_CHECKLIST); setNote(""); diff --git a/src/components/admin/AdminAutoRefresh.tsx b/src/components/admin/AdminAutoRefresh.tsx new file mode 100644 index 0000000..0620433 --- /dev/null +++ b/src/components/admin/AdminAutoRefresh.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; + +/** + * Nach jeder erfolgreichen Aktion alle Daten neu laden. invalidateQueries() + * holt nur die gerade sichtbaren (aktiven) Queries sofort neu, der Rest gilt + * als veraltet und lädt beim nächsten Anzeigen. Gibt die Abmeldung zurück. + */ +export function subscribeAutoRefresh(queryClient: QueryClient): () => void { + return queryClient.getMutationCache().subscribe((event) => { + if (event.type === "updated" && event.action.type === "success") { + void queryClient.invalidateQueries(); + } + }); +} + +/** + * Admin-Panel: keine Seite muss nach einer Aktion selbst neu laden. Hängt nur, + * solange das Admin-Layout gemountet ist — der QueryClient ist app-weit, die + * normale App bleibt davon unberührt. + */ +export function AdminAutoRefresh() { + const queryClient = useQueryClient(); + useEffect(() => subscribeAutoRefresh(queryClient), [queryClient]); + return null; +} diff --git a/src/components/admin/admin-auto-refresh.test.ts b/src/components/admin/admin-auto-refresh.test.ts new file mode 100644 index 0000000..9ce9d58 --- /dev/null +++ b/src/components/admin/admin-auto-refresh.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { subscribeAutoRefresh } from "./AdminAutoRefresh"; + +function runMutation(queryClient: QueryClient, mutationFn: () => Promise) { + return queryClient.getMutationCache().build(queryClient, { mutationFn }).execute(undefined); +} + +describe("subscribeAutoRefresh", () => { + it("lädt nach einer erfolgreichen Aktion alle Daten neu", async () => { + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue(); + subscribeAutoRefresh(queryClient); + + await runMutation(queryClient, () => Promise.resolve("ok")); + + expect(invalidate).toHaveBeenCalledOnce(); + expect(invalidate).toHaveBeenCalledWith(); + }); + + it("lädt nach einer fehlgeschlagenen Aktion nichts neu", async () => { + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue(); + subscribeAutoRefresh(queryClient); + + await runMutation(queryClient, () => Promise.reject(new Error("FORBIDDEN"))).catch(() => {}); + + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("hört nach dem Abmelden (Admin-Layout verlassen) auf", async () => { + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue(); + const unsubscribe = subscribeAutoRefresh(queryClient); + unsubscribe(); + + await runMutation(queryClient, () => Promise.resolve("ok")); + + expect(invalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/admin/dashboard/SystemCard.tsx b/src/components/admin/dashboard/SystemCard.tsx index 9efc847..c2f5c46 100644 --- a/src/components/admin/dashboard/SystemCard.tsx +++ b/src/components/admin/dashboard/SystemCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Sparkles, TriangleAlert } from "lucide-react"; import { useTRPC } from "@/trpc/client"; import { Switch } from "@/components/ui/switch"; @@ -35,17 +35,14 @@ function SwitchRow({ icon: Icon, title, description, checked, isDanger, disabled */ 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() }), }) ); diff --git a/src/components/admin/referrals/OverviewTab.tsx b/src/components/admin/referrals/OverviewTab.tsx index 7b377ba..3aa1920 100644 --- a/src/components/admin/referrals/OverviewTab.tsx +++ b/src/components/admin/referrals/OverviewTab.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { AlertTriangle, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { useTRPC } from "@/trpc/client"; @@ -31,14 +31,12 @@ function statusOf(o: { referralRevokedAt: MaybeDate; referralQualifiedAt: MaybeD export function OverviewTab() { const trpc = useTRPC(); - const qc = useQueryClient(); const { data, isLoading } = useQuery(trpc.referralAdmin.overview.queryOptions()); const { data: featurePool = [] } = useQuery(trpc.referralAdmin.perkHolders.queryOptions({ perk: "FEATURE_POOL" })); const { data: spotlight = [] } = useQuery(trpc.referralAdmin.perkHolders.queryOptions({ perk: "CURATOR_SPOTLIGHT" })); - const refresh = () => void qc.invalidateQueries(trpc.referralAdmin.overview.queryFilter()); - const approve = useMutation(trpc.referralAdmin.approveFlagged.mutationOptions({ onSuccess: () => { toast.success("Freigegeben"); refresh(); }, onError: (e) => toast.error(e.message) })); - const revoke = useMutation(trpc.referralAdmin.revokeReferral.mutationOptions({ onSuccess: () => { toast.success("Aberkannt"); refresh(); }, onError: (e) => toast.error(e.message) })); + const approve = useMutation(trpc.referralAdmin.approveFlagged.mutationOptions({ onSuccess: () => { toast.success("Freigegeben") }, onError: (e) => toast.error(e.message) })); + const revoke = useMutation(trpc.referralAdmin.revokeReferral.mutationOptions({ onSuccess: () => { toast.success("Aberkannt") }, onError: (e) => toast.error(e.message) })); if (isLoading || !data) return ; diff --git a/src/components/admin/referrals/RanksTab.tsx b/src/components/admin/referrals/RanksTab.tsx index 389ff9f..68cdfea 100644 --- a/src/components/admin/referrals/RanksTab.tsx +++ b/src/components/admin/referrals/RanksTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Loader2, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; import { useTRPC } from "@/trpc/client"; @@ -15,15 +15,12 @@ type EditorState = { mode: "create" | "edit"; id: string | null; initial: RankFo export function RanksTab() { const trpc = useTRPC(); - const qc = useQueryClient(); const [editor, setEditor] = useState(null); const { data: ranks = [], isLoading } = useQuery(trpc.referralAdmin.listRanks.queryOptions()); function onSaved() { toast.success("Gespeichert. Ränge aller Nutzer wurden neu berechnet."); setEditor(null); - void qc.invalidateQueries(trpc.referralAdmin.listRanks.queryFilter()); - void qc.invalidateQueries(trpc.referrals.ranks.queryFilter()); } const onError = (err: { message: string }) => toast.error(err.message); diff --git a/src/components/admin/referrals/SettingsTab.tsx b/src/components/admin/referrals/SettingsTab.tsx index eec55cc..750d51c 100644 --- a/src/components/admin/referrals/SettingsTab.tsx +++ b/src/components/admin/referrals/SettingsTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { useTRPC } from "@/trpc/client"; @@ -25,7 +25,6 @@ export function SettingsTab() { function SettingsForm({ initial }: { initial: SettingsValues }) { const trpc = useTRPC(); - const qc = useQueryClient(); const [form, setForm] = useState({ programActive: initial.programActive, redeemWindowDays: initial.redeemWindowDays, @@ -38,7 +37,6 @@ function SettingsForm({ initial }: { initial: SettingsValues }) { trpc.referralAdmin.updateSettings.mutationOptions({ onSuccess: () => { toast.success("Einstellungen gespeichert"); - void qc.invalidateQueries(trpc.referralAdmin.getSettings.queryFilter()); }, onError: (err) => toast.error(err.message), }) diff --git a/src/components/admin/users/UserActionDialogs.tsx b/src/components/admin/users/UserActionDialogs.tsx index 55327a0..1520aca 100644 --- a/src/components/admin/users/UserActionDialogs.tsx +++ b/src/components/admin/users/UserActionDialogs.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { useTRPC } from "@/trpc/client"; import { Button } from "@/components/ui/button"; @@ -32,7 +32,6 @@ interface UserActionDialogsProps { */ export function UserActionDialogs({ target, onClose }: UserActionDialogsProps) { const trpc = useTRPC(); - const qc = useQueryClient(); const [reason, setReason] = useState(""); const [banUntil, setBanUntil] = useState(""); const [confirmation, setConfirmation] = useState(""); @@ -45,10 +44,8 @@ export function UserActionDialogs({ target, onClose }: UserActionDialogsProps) { setConfirmation(""); onClose(); }; - const refresh = () => void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); const failed = (label: string) => (err: { message: string }) => toast.error(`${label} fehlgeschlagen: ${err.message}`); const done = (message: string) => () => { - refresh(); toast.success(message); close(); }; @@ -61,8 +58,6 @@ export function UserActionDialogs({ target, onClose }: UserActionDialogsProps) { const warn = useMutation( trpc.admin.issueWarning.mutationOptions({ onSuccess: () => { - refresh(); - void qc.invalidateQueries({ queryKey: trpc.admin.listWarnings.queryKey({ ownerId }) }); setReason(""); toast.success("Verwarnung gespeichert."); }, diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index 9478c8c..f4643d3 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -434,7 +434,6 @@ export const adminRouter = router({ .input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); - return deletePostAsModerator(ctx, callerRole, input.postId, input.reason, false); }), @@ -1319,7 +1318,6 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); - return deletePostAsModerator(ctx, callerRole, input.postId, input.reason, input.notifyReporters); }),