/** * TanStack Router configuration for EmDash Admin * * Defines all admin routes and their components. */ import { Loader, Toast } from "@cloudflare/kumo"; import type { QueryClient } from "@tanstack/react-query"; import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { createRouter, createRootRouteWithContext, createRoute, Outlet, Link, useParams, useNavigate, useSearch, } from "@tanstack/react-router"; import * as React from "react"; import { CommentInbox } from "./components/comments/CommentInbox"; import { ContentEditor } from "./components/ContentEditor"; import { ContentList } from "./components/ContentList"; import { ContentTypeEditor } from "./components/ContentTypeEditor"; import { ContentTypeList } from "./components/ContentTypeList"; import { Dashboard } from "./components/Dashboard"; import { DeviceAuthorizePage } from "./components/DeviceAuthorizePage"; import { LoginPage } from "./components/LoginPage"; import { MarketplaceBrowse } from "./components/MarketplaceBrowse"; import { MarketplacePluginDetail } from "./components/MarketplacePluginDetail"; import { MediaLibrary } from "./components/MediaLibrary"; import { MenuEditor } from "./components/MenuEditor"; import { MenuList } from "./components/MenuList"; import { PluginManager } from "./components/PluginManager"; import type { PluginBlockDef } from "./components/PortableTextEditor"; import { Redirects } from "./components/Redirects"; import { SandboxedPluginPage } from "./components/SandboxedPluginPage"; import { SectionEditor } from "./components/SectionEditor"; import { Sections } from "./components/Sections"; import { Settings } from "./components/Settings"; import { AllowedDomainsSettings } from "./components/settings/AllowedDomainsSettings"; import { ApiTokenSettings } from "./components/settings/ApiTokenSettings"; import { EmailSettings } from "./components/settings/EmailSettings"; import { GeneralSettings } from "./components/settings/GeneralSettings"; import { SecuritySettings } from "./components/settings/SecuritySettings"; import { SeoSettings } from "./components/settings/SeoSettings"; import { SocialSettings } from "./components/settings/SocialSettings"; import { SetupWizard } from "./components/SetupWizard"; import { Shell } from "./components/Shell"; import { SignupPage } from "./components/SignupPage"; import { TaxonomyManager } from "./components/TaxonomyManager"; import { ThemeMarketplaceBrowse } from "./components/ThemeMarketplaceBrowse"; import { ThemeMarketplaceDetail } from "./components/ThemeMarketplaceDetail"; import { Widgets } from "./components/Widgets"; import { WordPressImport } from "./components/WordPressImport"; import { apiFetch, parseApiResponse, fetchManifest, fetchContentList, fetchContent, createContent, updateContent, deleteContent, fetchTranslations, fetchMediaList, uploadMedia, deleteMedia, fetchCollections, fetchCollection, createCollection, updateCollection, deleteCollection, createField, updateField, deleteField, reorderFields, fetchOrphanedTables, registerOrphanedTable, fetchUsers, fetchBylines, createByline, updateByline, setSearchEnabled, fetchTrashedContent, restoreContent, permanentDeleteContent, duplicateContent, scheduleContent, unscheduleContent, publishContent, unpublishContent, discardDraft, fetchRevision, type AdminManifest, type CreateCollectionInput, type UpdateCollectionInput, type CreateFieldInput, type BylineCreditInput, type ContentSeoInput, } from "./lib/api"; import { fetchComments, fetchCommentCounts, updateCommentStatus, deleteComment, bulkCommentAction, type CommentStatus, } from "./lib/api/comments"; import { usePluginPage } from "./lib/plugin-context"; import { sanitizeRedirectUrl } from "./lib/url"; import { BylinesPage } from "./routes/bylines"; import { UsersPage } from "./routes/users"; // Router context type interface RouterContext { queryClient: QueryClient; } // Create a base root route without Shell for setup const baseRootRoute = createRootRouteWithContext()({ component: () => , }); // Setup route (standalone, no Shell) const setupRoute = createRoute({ getParentRoute: () => baseRootRoute, path: "/setup", component: SetupWizard, }); // Login route (standalone, no Shell) const loginRoute = createRoute({ getParentRoute: () => baseRootRoute, path: "/login", component: LoginPageWrapper, }); function LoginPageWrapper() { // Extract redirect URL from query params, sanitized to prevent open redirect / XSS const searchParams = new URLSearchParams(window.location.search); const redirect = sanitizeRedirectUrl(searchParams.get("redirect") || "/_emdash/admin"); return ; } // Signup route (standalone, no Shell) const signupRoute = createRoute({ getParentRoute: () => baseRootRoute, path: "/signup", component: SignupPage, }); // Device authorization route (standalone, no Shell) const deviceRoute = createRoute({ getParentRoute: () => baseRootRoute, path: "/device", component: DeviceAuthorizePage, }); // Layout route with Shell wrapper for admin pages (pathless - matches all admin routes) const adminLayoutRoute = createRoute({ getParentRoute: () => baseRootRoute, id: "_admin", component: RootComponent, }); function RootComponent() { const { data: manifest, isLoading, error, } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); if (isLoading) { return ; } if (error || !manifest) { return ; } // Plugin admin components are passed via props and available through PluginAdminContext return ( ); } // Dashboard route - matches the index path "/" const dashboardRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/", component: DashboardPage, }); function DashboardPage() { const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); if (!manifest) return null; return ; } // Content list route const contentListRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/content/$collection", component: ContentListPage, validateSearch: (search: Record) => ({ locale: typeof search.locale === "string" ? search.locale : undefined, }), }); function ContentListPage() { const { collection } = useParams({ from: "/_admin/content/$collection" }); const { locale: localeParam } = useSearch({ from: "/_admin/content/$collection" }); const queryClient = useQueryClient(); const navigate = useNavigate(); const toastManager = Toast.useToastManager(); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); const i18n = manifest?.i18n; // Default to defaultLocale when i18n is enabled and no locale specified const activeLocale = i18n ? (localeParam ?? i18n.defaultLocale) : undefined; const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({ queryKey: ["content", collection, { locale: activeLocale }], queryFn: ({ pageParam }) => fetchContentList(collection, { locale: activeLocale, cursor: pageParam as string | undefined, limit: 100, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, enabled: !!manifest, }); // Fetch trashed items const { data: trashedData, isLoading: isTrashedLoading } = useQuery({ queryKey: ["content", collection, "trash"], queryFn: () => fetchTrashedContent(collection), }); const deleteMutation = useMutation({ mutationFn: (id: string) => deleteContent(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] }); }, onError: (mutationError) => { toastManager.add({ title: "Failed to delete", description: mutationError instanceof Error ? mutationError.message : "An error occurred", type: "error", }); }, }); const restoreMutation = useMutation({ mutationFn: (id: string) => restoreContent(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] }); }, onError: (mutationError) => { toastManager.add({ title: "Failed to restore", description: mutationError instanceof Error ? mutationError.message : "An error occurred", type: "error", }); }, }); const permanentDeleteMutation = useMutation({ mutationFn: (id: string) => permanentDeleteContent(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] }); }, onError: (mutationError) => { toastManager.add({ title: "Failed to delete", description: mutationError instanceof Error ? mutationError.message : "An error occurred", type: "error", }); }, }); const duplicateMutation = useMutation({ mutationFn: (id: string) => duplicateContent(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); }, onError: (mutationError) => { toastManager.add({ title: "Failed to duplicate", description: mutationError instanceof Error ? mutationError.message : "An error occurred", type: "error", }); }, }); if (!manifest) { return ; } const collectionConfig = manifest.collections[collection]; if (!collectionConfig) { return ; } if (error) { return ; } const handleLocaleChange = (locale: string) => { // Update URL search params without full navigation void navigate({ to: "/content/$collection", params: { collection }, search: { locale: locale || undefined }, }); }; const items = React.useMemo(() => { return data?.pages.flatMap((page) => page.items) || []; }, [data]); return ( void fetchNextPage()} trashedCount={trashedData?.items?.length || 0} onDelete={(id) => deleteMutation.mutate(id)} onRestore={(id) => restoreMutation.mutate(id)} onPermanentDelete={(id) => permanentDeleteMutation.mutate(id)} onDuplicate={(id) => duplicateMutation.mutate(id)} i18n={i18n} activeLocale={activeLocale} onLocaleChange={handleLocaleChange} /> ); } /** Extract plugin block definitions from the manifest for Portable Text editor */ function getPluginBlocks(manifest: AdminManifest): PluginBlockDef[] { const blocks: PluginBlockDef[] = []; for (const [pluginId, plugin] of Object.entries(manifest.plugins)) { if (plugin.portableTextBlocks) { for (const block of plugin.portableTextBlocks) { blocks.push({ ...block, pluginId }); } } } return blocks; } // Content new route const contentNewRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/content/$collection/new", component: ContentNewPage, }); function ContentNewPage() { const { collection } = useParams({ from: "/_admin/content/$collection/new" }); const navigate = useNavigate(); const queryClient = useQueryClient(); const [selectedBylines, setSelectedBylines] = React.useState([]); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); const createMutation = useMutation({ mutationFn: (data: { data: Record; slug?: string; bylines?: BylineCreditInput[]; }) => createContent(collection, data), onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void navigate({ to: "/content/$collection/$id", params: { collection, id: result.id }, }); }, }); const pluginBlocks = React.useMemo(() => (manifest ? getPluginBlocks(manifest) : []), [manifest]); const { data: bylinesData } = useQuery({ queryKey: ["bylines"], queryFn: () => fetchBylines({ limit: 100 }), }); const createBylineMutation = useMutation({ mutationFn: (input: { slug: string; displayName: string }) => createByline({ ...input, isGuest: true }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["bylines"] }); }, }); const updateBylineMutation = useMutation({ mutationFn: (input: { id: string; slug: string; displayName: string }) => updateByline(input.id, { slug: input.slug, displayName: input.displayName, }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["bylines"] }); }, }); if (!manifest) { return ; } const collectionConfig = manifest.collections[collection]; if (!collectionConfig) { return ; } const handleSave = (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[]; }) => { createMutation.mutate(payload); }; return ( { const created = await createBylineMutation.mutateAsync(input); return created; }} onQuickEditByline={async (bylineId, input) => { const updated = await updateBylineMutation.mutateAsync({ id: bylineId, ...input }); return updated; }} manifest={manifest ?? null} /> ); } // Content edit route const contentEditRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/content/$collection/$id", component: ContentEditPage, }); // Editor role level from @emdash-cms/auth const ROLE_EDITOR = 40; function ContentEditPage() { const { collection, id } = useParams({ from: "/_admin/content/$collection/$id", }); const queryClient = useQueryClient(); const navigate = useNavigate(); const toastManager = Toast.useToastManager(); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); const i18n = manifest?.i18n; const { data: rawItem, isLoading } = useQuery({ queryKey: ["content", collection, id], queryFn: () => fetchContent(collection, id), }); // Fetch translations when i18n is enabled const { data: translationsData } = useQuery({ queryKey: ["translations", collection, id], queryFn: () => fetchTranslations(collection, id), enabled: !!i18n && !!rawItem, }); // When a draft revision exists, fetch its data for the editor form. // The content table holds published data; the draft revision holds // the editor's working copy. const { data: draftRevision } = useQuery({ queryKey: ["revision", rawItem?.draftRevisionId], queryFn: () => fetchRevision(rawItem!.draftRevisionId!), enabled: !!rawItem?.draftRevisionId, }); // Merge draft revision data into the item for the editor. // The item's metadata (id, status, slug, etc.) comes from the content table; // the data fields come from the draft revision if available. const item = React.useMemo(() => { if (!rawItem) return undefined; if (!draftRevision?.data) return rawItem; // Strip revision metadata keys (prefixed with _) const draftData: Record = {}; for (const [key, value] of Object.entries(draftRevision.data)) { if (!key.startsWith("_")) { draftData[key] = value; } } // Draft slug override const draftSlug = typeof draftRevision.data._slug === "string" ? draftRevision.data._slug : rawItem.slug; return { ...rawItem, slug: draftSlug, data: { ...rawItem.data, ...draftData }, }; }, [rawItem, draftRevision]); // Fetch current user for permission checks const { data: currentUser } = useQuery({ queryKey: ["currentUser"], queryFn: async (): Promise<{ id: string; role: number }> => { const response = await apiFetch("/_emdash/api/auth/me"); return parseApiResponse<{ id: string; role: number }>(response, "Failed to fetch user"); }, staleTime: 5 * 60 * 1000, }); // Fetch users list for author selector (only if user is editor+) const { data: usersData } = useQuery({ queryKey: ["users"], queryFn: () => fetchUsers({ limit: 100 }), enabled: !!currentUser && currentUser.role >= ROLE_EDITOR, staleTime: 5 * 60 * 1000, }); const { data: bylinesData } = useQuery({ queryKey: ["bylines"], queryFn: () => fetchBylines({ limit: 100 }), }); const createBylineMutation = useMutation({ mutationFn: (input: { slug: string; displayName: string }) => createByline({ ...input, isGuest: true }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["bylines"] }); }, }); const updateBylineMutation = useMutation({ mutationFn: (input: { id: string; slug: string; displayName: string }) => updateByline(input.id, { slug: input.slug, displayName: input.displayName, }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["bylines"] }); }, }); const updateMutation = useMutation({ mutationFn: (data: { data?: Record; slug?: string; authorId?: string | null; bylines?: BylineCreditInput[]; skipRevision?: boolean; seo?: ContentSeoInput; }) => updateContent(collection, id, data), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); // Also invalidate revisions since a new one was created void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); }, onError: (error) => { toastManager.add({ title: "Failed to save", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); // Autosave mutation - skips revision creation const [lastAutosaveAt, setLastAutosaveAt] = React.useState(null); const autosaveMutation = useMutation({ mutationFn: (data: { data?: Record; slug?: string; bylines?: BylineCreditInput[]; }) => updateContent(collection, id, { ...data, skipRevision: true }), onSuccess: () => { setLastAutosaveAt(new Date()); // Silently update the cache without full invalidation void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); }, onError: (err) => { toastManager.add({ title: "Autosave failed", description: err instanceof Error ? err.message : "An error occurred", type: "error", }); }, }); const publishMutation = useMutation({ mutationFn: () => publishContent(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: "Published", description: "Content is now live" }); }, onError: (error) => { toastManager.add({ title: "Failed to publish", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); const unpublishMutation = useMutation({ mutationFn: () => unpublishContent(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: "Unpublished", description: "Content removed from public view" }); }, onError: (error) => { toastManager.add({ title: "Failed to unpublish", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); const discardDraftMutation = useMutation({ mutationFn: () => discardDraft(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: "Changes discarded", description: "Reverted to published version", }); }, onError: (error) => { toastManager.add({ title: "Failed to discard changes", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); const scheduleMutation = useMutation({ mutationFn: (scheduledAt: string) => scheduleContent(collection, id, scheduledAt), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); toastManager.add({ title: "Scheduled", description: "Content has been scheduled for publishing", }); }, onError: (error) => { toastManager.add({ title: "Failed to schedule", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); const unscheduleMutation = useMutation({ mutationFn: () => unscheduleContent(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); toastManager.add({ title: "Unscheduled", description: "Content reverted to draft", }); }, onError: (error) => { toastManager.add({ title: "Failed to unschedule", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); // Create translation mutation const translateMutation = useMutation({ mutationFn: (locale: string) => createContent(collection, { data: rawItem?.data ?? {}, slug: rawItem?.slug ?? undefined, locale, translationOf: id, }), onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: ["translations", collection, id] }); void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void navigate({ to: "/content/$collection/$id", params: { collection, id: result.id }, }); toastManager.add({ title: "Translation created", description: `Created ${result.locale?.toUpperCase() ?? "new"} translation`, }); }, onError: (error) => { toastManager.add({ title: "Failed to create translation", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); const deleteMutation = useMutation({ mutationFn: () => deleteContent(collection, id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] }); void navigate({ to: "/content/$collection", params: { collection }, search: { locale: undefined }, }); }, onError: (error) => { toastManager.add({ title: "Failed to delete", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); const pluginBlocks = React.useMemo(() => (manifest ? getPluginBlocks(manifest) : []), [manifest]); if (!manifest) { return ; } const collectionConfig = manifest.collections[collection]; if (!collectionConfig) { return ; } if (isLoading) { return ; } const handleSave = (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[]; }) => { updateMutation.mutate(payload); }; const handleAutosave = (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[]; }) => { autosaveMutation.mutate(payload); }; const handleAuthorChange = (authorId: string | null) => { updateMutation.mutate({ authorId }); }; const handleSeoChange = (seo: ContentSeoInput) => { updateMutation.mutate({ seo }); }; return ( publishMutation.mutate()} onUnpublish={() => unpublishMutation.mutate()} onDiscardDraft={() => discardDraftMutation.mutate()} onSchedule={(scheduledAt) => scheduleMutation.mutate(scheduledAt)} onUnschedule={() => unscheduleMutation.mutate()} isScheduling={scheduleMutation.isPending} onDelete={() => deleteMutation.mutate()} isDeleting={deleteMutation.isPending} supportsDrafts={collectionConfig.supports.includes("drafts")} supportsRevisions={collectionConfig.supports.includes("revisions")} supportsPreview={collectionConfig.supports.includes("preview")} currentUser={currentUser} users={usersData?.items} onAuthorChange={handleAuthorChange} i18n={i18n} translations={translationsData?.translations} onTranslate={(locale) => translateMutation.mutate(locale)} pluginBlocks={pluginBlocks} hasSeo={collectionConfig.hasSeo} onSeoChange={handleSeoChange} availableBylines={bylinesData?.items} onQuickCreateByline={async (input) => { const created = await createBylineMutation.mutateAsync(input); return created; }} onQuickEditByline={async (bylineId, input) => { const updated = await updateBylineMutation.mutateAsync({ id: bylineId, ...input }); return updated; }} manifest={manifest ?? null} /> ); } // Media library route const mediaRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/media", component: MediaPage, }); function MediaPage() { const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ queryKey: ["media"], queryFn: () => fetchMediaList(), }); const uploadMutation = useMutation({ mutationFn: (file: File) => uploadMedia(file), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["media"] }); }, }); const deleteMutation = useMutation({ mutationFn: (id: string) => deleteMedia(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["media"] }); }, }); if (error) { return ; } return ( uploadMutation.mutate(file)} onDelete={(id) => deleteMutation.mutate(id)} /> ); } // Comments moderation inbox route const commentsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/comments", component: CommentsPage, }); // Admin role level from @emdash-cms/auth const ROLE_ADMIN = 50; function CommentsPage() { const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); // Current user for ADMIN check (hard delete) const { data: currentUser } = useQuery({ queryKey: ["currentUser"], queryFn: async (): Promise<{ id: string; role: number }> => { const response = await apiFetch("/_emdash/api/auth/me"); return parseApiResponse<{ id: string; role: number }>(response, "Failed to fetch user"); }, staleTime: 5 * 60 * 1000, }); // Filter state const [activeStatus, setActiveStatus] = React.useState("pending"); const [collectionFilter, setCollectionFilter] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState(""); const [debouncedSearch, setDebouncedSearch] = React.useState(""); // Debounce search React.useEffect(() => { const timer = setTimeout(setDebouncedSearch, 300, searchQuery); return () => clearTimeout(timer); }, [searchQuery]); // Fetch comments const { data: commentsData, isLoading, fetchNextPage, hasNextPage, } = useInfiniteQuery({ queryKey: ["comments", activeStatus, collectionFilter, debouncedSearch], queryFn: ({ pageParam }) => fetchComments({ status: activeStatus, collection: collectionFilter || undefined, search: debouncedSearch || undefined, cursor: pageParam, limit: 50, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, }); // Fetch counts const { data: counts } = useQuery({ queryKey: ["commentCounts"], queryFn: fetchCommentCounts, }); // Status change mutation const statusMutation = useMutation({ mutationFn: ({ id, status }: { id: string; status: CommentStatus }) => updateCommentStatus(id, status), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["comments"] }); void queryClient.invalidateQueries({ queryKey: ["commentCounts"] }); }, onError: (error) => { toastManager.add({ title: "Failed to update status", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); // Delete mutation const deleteMutation = useMutation({ mutationFn: (id: string) => deleteComment(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["comments"] }); void queryClient.invalidateQueries({ queryKey: ["commentCounts"] }); }, onError: (error) => { toastManager.add({ title: "Failed to delete comment", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); // Bulk action mutation const bulkMutation = useMutation({ mutationFn: ({ ids, action, }: { ids: string[]; action: "approve" | "spam" | "trash" | "delete"; }) => bulkCommentAction(ids, action), onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: ["comments"] }); void queryClient.invalidateQueries({ queryKey: ["commentCounts"] }); toastManager.add({ title: `${result.affected} comment${result.affected !== 1 ? "s" : ""} updated`, }); }, onError: (error) => { toastManager.add({ title: "Failed to perform bulk action", description: error instanceof Error ? error.message : "An error occurred", type: "error", }); }, }); const allComments = commentsData?.pages.flatMap((p) => p.items) ?? []; const lastPage = commentsData?.pages[commentsData.pages.length - 1]; // Require EDITOR role for comment moderation if (currentUser && currentUser.role < ROLE_EDITOR) { return (

Access Denied

You need Editor permissions to moderate comments.

); } return ( statusMutation.mutateAsync({ id, status }).catch(() => {}) } onCommentDelete={(id) => deleteMutation.mutateAsync(id).catch(() => {})} onBulkAction={(ids, action) => bulkMutation.mutateAsync({ ids, action }).catch(() => {})} onLoadMore={() => { if (hasNextPage) void fetchNextPage(); }} isAdmin={(currentUser?.role ?? 0) >= ROLE_ADMIN} isStatusPending={ statusMutation.isPending || deleteMutation.isPending || bulkMutation.isPending } deleteError={deleteMutation.error} onDeleteErrorReset={() => deleteMutation.reset()} /> ); } // Settings route const settingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/settings", component: Settings, }); // Security settings route const securitySettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/settings/security", component: SecuritySettings, }); // Allowed domains settings route const allowedDomainsSettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/settings/allowed-domains", component: AllowedDomainsSettings, }); // API tokens settings route const apiTokenSettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/settings/api-tokens", component: ApiTokenSettings, }); // Email settings route const emailSettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/settings/email", component: EmailSettings, }); // General settings route const generalSettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/settings/general", component: GeneralSettings, }); // Social settings route const socialSettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/settings/social", component: SocialSettings, }); // SEO settings route const seoSettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/settings/seo", component: SeoSettings, }); // Plugin manager route const pluginManagerRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/plugins-manager", component: PluginManagerPage, }); function PluginManagerPage() { const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); return ; } // Marketplace browse route const marketplaceBrowseRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/plugins/marketplace", component: MarketplaceBrowsePage, }); function MarketplaceBrowsePage() { const { data: plugins } = useQuery({ queryKey: ["plugins"], queryFn: async () => { const { fetchPlugins } = await import("./lib/api/plugins.js"); return fetchPlugins(); }, }); const installedIds = React.useMemo(() => { if (!plugins) return new Set(); return new Set(plugins.map((p) => p.id)); }, [plugins]); return ; } // Marketplace plugin detail route const marketplaceDetailRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/plugins/marketplace/$pluginId", component: MarketplaceDetailPage, }); function MarketplaceDetailPage() { const { pluginId } = useParams({ from: "/_admin/plugins/marketplace/$pluginId" }); const { data: plugins } = useQuery({ queryKey: ["plugins"], queryFn: async () => { const { fetchPlugins } = await import("./lib/api/plugins.js"); return fetchPlugins(); }, }); const installedIds = React.useMemo(() => { if (!plugins) return new Set(); return new Set(plugins.map((p) => p.id)); }, [plugins]); return ; } // Theme marketplace browse route const themeMarketplaceBrowseRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/themes/marketplace", component: ThemeMarketplaceBrowse, }); // Theme marketplace detail route const themeMarketplaceDetailRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/themes/marketplace/$themeId", component: ThemeDetailPage, }); function ThemeDetailPage() { const { themeId } = useParams({ from: "/_admin/themes/marketplace/$themeId" }); return ; } // WordPress import route const wordpressImportRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/import/wordpress", component: WordPressImport, }); // Menu routes const menuListRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/menus", component: MenuList, }); const menuEditorRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/menus/$name", component: MenuEditor, }); // Taxonomy manager route const taxonomyRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/taxonomies/$taxonomy", component: TaxonomyPage, }); function TaxonomyPage() { const { taxonomy } = useParams({ from: "/_admin/taxonomies/$taxonomy" }); return ; } // Widgets route const widgetsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/widgets", component: Widgets, }); // Sections routes const redirectsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/redirects", component: Redirects, }); const sectionsListRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/sections", component: Sections, }); const sectionEditRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/sections/$slug", component: SectionEditor, }); // Users route const usersRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/users", component: UsersPage, }); // Bylines route const bylinesRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/bylines", component: BylinesPage, }); // Content Types routes const contentTypesListRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/content-types", component: ContentTypesListPage, }); function ContentTypesListPage() { const queryClient = useQueryClient(); const { data: collections, isLoading: collectionsLoading, error: collectionsError, } = useQuery({ queryKey: ["schema", "collections"], queryFn: fetchCollections, }); const { data: orphanedTables, isLoading: orphansLoading, error: orphansError, } = useQuery({ queryKey: ["schema", "orphans"], queryFn: fetchOrphanedTables, }); const deleteMutation = useMutation({ mutationFn: (slug: string) => deleteCollection(slug, true), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); }, }); const registerOrphanMutation = useMutation({ mutationFn: (slug: string) => registerOrphanedTable(slug), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); void queryClient.invalidateQueries({ queryKey: ["schema", "orphans"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); }, }); const error = collectionsError || orphansError; if (error) { return ; } return ( deleteMutation.mutate(slug)} onRegisterOrphan={(slug) => registerOrphanMutation.mutate(slug)} /> ); } const contentTypesNewRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/content-types/new", component: ContentTypesNewPage, }); function ContentTypesNewPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const createMutation = useMutation({ mutationFn: (input: CreateCollectionInput) => createCollection(input), onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); void navigate({ to: "/content-types/$slug", params: { slug: result.slug }, }); }, }); return ( { createMutation.mutate(input as CreateCollectionInput); }} /> ); } const contentTypesEditRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/content-types/$slug", component: ContentTypesEditPage, }); function ContentTypesEditPage() { const { slug } = useParams({ from: "/_admin/content-types/$slug" }); const queryClient = useQueryClient(); const { data: collection, isLoading, error, } = useQuery({ queryKey: ["schema", "collections", slug], queryFn: () => fetchCollection(slug), }); const updateMutation = useMutation({ mutationFn: async (input: UpdateCollectionInput) => { // Check if search support is being toggled const oldSupports = collection?.supports ?? []; const newSupports = input.supports ?? oldSupports; const hadSearch = oldSupports.includes("search"); const hasSearch = newSupports.includes("search"); // Update the collection first const result = await updateCollection(slug, input); // If search support changed, enable/disable search if (hadSearch !== hasSearch) { try { await setSearchEnabled(slug, hasSearch); } catch (err) { // Log but don't fail the mutation - search can be enabled manually console.error("Failed to toggle search:", err); } } return result; }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections", slug], }); void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); }, }); const addFieldMutation = useMutation({ mutationFn: (input: CreateFieldInput) => createField(slug, input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections", slug], }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); }, }); const updateFieldMutation = useMutation({ mutationFn: ({ fieldSlug, input }: { fieldSlug: string; input: CreateFieldInput }) => updateField(slug, fieldSlug, input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections", slug], }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); }, }); const deleteFieldMutation = useMutation({ mutationFn: (fieldSlug: string) => deleteField(slug, fieldSlug), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections", slug], }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); }, }); const reorderFieldsMutation = useMutation({ mutationFn: (fieldSlugs: string[]) => reorderFields(slug, fieldSlugs), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections", slug], }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); }, }); if (error) { return ; } if (isLoading) { return ; } return ( updateMutation.mutate(input as UpdateCollectionInput)} onAddField={(input) => addFieldMutation.mutateAsync(input)} onUpdateField={(fieldSlug, input) => updateFieldMutation.mutateAsync({ fieldSlug, input })} onDeleteField={(fieldSlug) => deleteFieldMutation.mutate(fieldSlug)} onReorderFields={(fieldSlugs) => reorderFieldsMutation.mutate(fieldSlugs)} /> ); } // Plugin page route const pluginRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/plugins/$pluginId/$", component: PluginPage, }); function PluginPage() { const { pluginId } = useParams({ from: "/_admin/plugins/$pluginId/$" }); const { _splat } = useParams({ from: "/_admin/plugins/$pluginId/$" }); const pagePath = "/" + (_splat || ""); // Get plugin page component from context (trusted plugins with React) const PluginComponent = usePluginPage(pluginId, pagePath); if (PluginComponent) { return ; } // No React component — fall back to Block Kit rendering return ; } // Catch-all 404 route const notFoundRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "*", component: () => , }); // Create route tree with admin routes under layout and setup route separate const adminRoutes = adminLayoutRoute.addChildren([ dashboardRoute, contentListRoute, contentNewRoute, contentEditRoute, contentTypesListRoute, contentTypesNewRoute, contentTypesEditRoute, mediaRoute, commentsRoute, menuListRoute, menuEditorRoute, pluginManagerRoute, marketplaceDetailRoute, marketplaceBrowseRoute, themeMarketplaceBrowseRoute, themeMarketplaceDetailRoute, pluginRoute, redirectsRoute, sectionsListRoute, sectionEditRoute, taxonomyRoute, usersRoute, bylinesRoute, widgetsRoute, settingsRoute, generalSettingsRoute, socialSettingsRoute, seoSettingsRoute, securitySettingsRoute, allowedDomainsSettingsRoute, apiTokenSettingsRoute, emailSettingsRoute, wordpressImportRoute, notFoundRoute, ]); const routeTree = baseRootRoute.addChildren([ setupRoute, loginRoute, signupRoute, deviceRoute, adminRoutes, ]); // Create router export function createAdminRouter(queryClient: QueryClient) { return createRouter({ routeTree, context: { queryClient }, basepath: "/_emdash/admin", defaultPreload: "intent", }); } // Declare router type declare module "@tanstack/react-router" { interface Register { router: ReturnType; } } // Shared components function LoadingScreen() { return (

Loading configuration...

); } function ErrorScreen({ error }: { error: string }) { return (

Error

{error}

); } function NotFoundPage({ message }: { message?: string }) { return (

Page Not Found

{message || "The page you're looking for doesn't exist."}

Go to Dashboard
); } export { Link, useNavigate, useParams };