/** * TanStack Router configuration for EmDash Admin * * Defines all admin routes and their components. */ import { Button, Loader, Toast } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; 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, EMPTY_DATE_FILTER, type ContentDateFilter, type ContentListSort, type ContentStatusFilter, } from "./components/ContentList"; import { ContentTypeEditor } from "./components/ContentTypeEditor"; import { ContentTypeList } from "./components/ContentTypeList"; import { Dashboard } from "./components/Dashboard"; import { DeviceAuthorizePage } from "./components/DeviceAuthorizePage"; import { InviteAcceptPage } from "./components/InviteAcceptPage"; 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 { Redirects } from "./components/Redirects"; import { RegistryBrowse } from "./components/RegistryBrowse"; import { RegistryPluginDetail } from "./components/RegistryPluginDetail"; 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, fetchContentAuthors, 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 CreateCollectionInput, type UpdateCollectionInput, type CreateFieldInput, type BylineCreditInput, type ContentSeoInput, type ContentItem, type Revision, } from "./lib/api"; import { fetchComments, fetchCommentCounts, updateCommentStatus, deleteComment, bulkCommentAction, type CommentStatus, } from "./lib/api/comments"; import { usePluginPage } from "./lib/plugin-context"; import { getPluginBlocks } from "./lib/pluginBlocks"; import { sanitizeRedirectUrl } from "./lib/url"; import { BylineSchemaPage } from "./routes/byline-schema"; import { BylinesPage } from "./routes/bylines"; import { UsersPage } from "./routes/users"; // Router context type interface RouterContext { queryClient: QueryClient; } function patchAutosaveQueries( queryClient: QueryClient, params: { collection: string; id: string; savedItem: ContentItem; payload: { data?: Record; slug?: string; }; locale?: string; }, ) { const { collection, id, savedItem, payload, locale } = params; const draftRevisionId = savedItem.draftRevisionId; if (draftRevisionId) { queryClient.setQueryData(["revision", draftRevisionId], (existing) => { const nextData: Record = { ...existing?.data, ...payload.data, }; if (payload.slug !== undefined) { nextData._slug = payload.slug; } return { id: draftRevisionId, collection, entryId: id, data: nextData, authorId: existing?.authorId ?? savedItem.authorId, createdAt: existing?.createdAt ?? savedItem.updatedAt, }; }); } queryClient.setQueryData( locale ? ["content", collection, id, { locale }] : ["content", collection, id], savedItem, ); } // 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, }); // Invite accept route (standalone, no Shell) const inviteAcceptRoute = createRoute({ getParentRoute: () => baseRootRoute, path: "/invite/accept", component: InviteAcceptPage, validateSearch: (search: Record) => ({ token: typeof search.token === "string" ? search.token : undefined, }), }); // 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, }); // Isomorphic requestIdleCallback polyfill if (typeof window !== "undefined" && typeof window.requestIdleCallback === "undefined") { window.requestIdleCallback = (cb) => setTimeout(cb, 50); window.cancelIdleCallback = (id) => clearTimeout(id); } function RootComponent() { const { t } = useLingui(); 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 { t } = useLingui(); 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; // Controlled sort state — passed to the list, and included in the query // key so changing direction invalidates the current cursor chain. const [sort, setSort] = React.useState({ field: "updatedAt", direction: "desc", }); // Server-side search term (debounced inside ContentList). Part of the query // key so a new term restarts the cursor chain from a filtered first page. const [searchTerm, setSearchTerm] = React.useState(""); // Filter state (#1288). All are part of the query key so changing any of // them restarts the cursor chain from a filtered first page. const [statusFilter, setStatusFilter] = React.useState("all"); const [authorFilter, setAuthorFilter] = React.useState(""); const [dateFilter, setDateFilter] = React.useState(EMPTY_DATE_FILTER); // The date inputs yield calendar dates; widen them to UTC day boundaries so // the inclusive `dateTo` covers the whole day (timestamps are stored in UTC). const dateApiParams = React.useMemo(() => { const hasRange = !!dateFilter.from || !!dateFilter.to; if (!hasRange) return undefined; return { dateField: dateFilter.field, dateFrom: dateFilter.from ? `${dateFilter.from}T00:00:00.000Z` : undefined, dateTo: dateFilter.to ? `${dateFilter.to}T23:59:59.999Z` : undefined, }; }, [dateFilter]); // Authors are collection-wide (the endpoint doesn't scope by locale), so the // query key omits locale to avoid refetching/cache-fragmenting on locale // switches, and the selection stays valid across locales. const { data: authors } = useQuery({ queryKey: ["content", collection, "authors"], queryFn: () => fetchContentAuthors(collection), enabled: !!manifest, }); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({ queryKey: [ "content", collection, { locale: activeLocale, sort, search: searchTerm, status: statusFilter, author: authorFilter, date: dateApiParams, }, ], queryFn: ({ pageParam }) => fetchContentList(collection, { locale: activeLocale, cursor: pageParam, limit: 100, orderBy: sort.field, order: sort.direction, search: searchTerm || undefined, status: statusFilter === "all" ? undefined : statusFilter, authorId: authorFilter || undefined, ...dateApiParams, }), 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: t`Failed to delete`, description: mutationError instanceof Error ? mutationError.message : t`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: t`Failed to restore`, description: mutationError instanceof Error ? mutationError.message : t`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: t`Failed to delete`, description: mutationError instanceof Error ? mutationError.message : t`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: t`Failed to duplicate`, description: mutationError instanceof Error ? mutationError.message : t`An error occurred`, type: "error", }); }, }); const items = React.useMemo(() => { return data?.pages.flatMap((page) => page.items) || []; }, [data]); // Server returns `total` on every page; the first page is authoritative // because filters don't change within a fetch cycle. Fall back to the // loaded count so old servers (pre-total) still render a denominator. const total = data?.pages[0]?.total ?? items.length; // Keep every hook above the early returns below — a render that takes a // guard (e.g. `error`) must run the same number of hooks as a full render, // or React throws #300 "Rendered fewer hooks than expected" (#1415). const handleLoadMore = React.useCallback(() => void fetchNextPage(), [fetchNextPage]); 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 }, }); }; return ( deleteMutation.mutate(id)} onRestore={(id) => restoreMutation.mutate(id)} onPermanentDelete={(id) => permanentDeleteMutation.mutate(id)} onDuplicate={(id) => duplicateMutation.mutate(id)} i18n={i18n} activeLocale={activeLocale} onLocaleChange={handleLocaleChange} urlPattern={collectionConfig.urlPattern} sort={sort} onSortChange={setSort} total={total} onSearchChange={setSearchTerm} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} authors={authors} authorFilter={authorFilter} onAuthorFilterChange={setAuthorFilter} dateFilter={dateFilter} onDateFilterChange={setDateFilter} /> ); } // Content new route const contentNewRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/content/$collection/new", component: ContentNewPage, validateSearch: (search: Record) => ({ locale: typeof search.locale === "string" ? search.locale : undefined, }), }); function ContentNewPage() { const { collection } = useParams({ from: "/_admin/content/$collection/new" }); const { locale } = useSearch({ from: "/_admin/content/$collection/new" }); const navigate = useNavigate(); const queryClient = useQueryClient(); const [selectedBylines, setSelectedBylines] = React.useState([]); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); // Locale the picker should scope to. URL `?locale=` wins; otherwise // fall back to the configured defaultLocale. Single-locale installs // resolve to `defaultLocale` too — the server treats that as "use the // configured default" so behaviour matches pre-i18n in that case. const pickerLocale = locale ?? manifest?.i18n?.defaultLocale; // Send the resolved picker locale so the new entry's locale matches // the locale the byline picker was scoped to. const createMutation = useMutation({ mutationFn: (data: { data: Record; slug?: string; bylines?: BylineCreditInput[]; }) => createContent(collection, { ...data, locale: pickerLocale }), onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void navigate({ to: "/content/$collection/$id", params: { collection, id: result.id }, search: { locale: result.locale }, }); }, }); const pluginBlocks = React.useMemo(() => (manifest ? getPluginBlocks(manifest) : []), [manifest]); // The picker is locale-pinned to the entry being created so editors // only see bylines that will actually hydrate at this locale (per the // strict per-locale model from migration 040). Locale is part of the // query key so switching locales fetches a fresh slice rather than // reusing a stale cache. const { data: bylinesData, isSuccess: bylinesLoaded } = useQuery({ queryKey: ["bylines", "picker", pickerLocale ?? null], queryFn: () => fetchBylines({ locale: pickerLocale, limit: 100 }), enabled: !!manifest, }); const createBylineMutation = useMutation({ mutationFn: (input: { slug: string; displayName: string }) => createByline({ ...input, isGuest: true, locale: pickerLocale }), 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, validateSearch: (search) => ({ ...(typeof search.field === "string" && { field: search.field }), ...(typeof search.locale === "string" && { locale: search.locale }), }), }); // Editor role level from @emdash-cms/auth const ROLE_EDITOR = 40; function ContentEditPage() { const { t } = useLingui(); const { collection, id } = useParams({ from: "/_admin/content/$collection/$id", }); const searchParams = useSearch({ 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 activeLocale = i18n ? (searchParams.locale ?? i18n.defaultLocale) : undefined; const { data: rawItem, isLoading } = useQuery({ queryKey: ["content", collection, id, { locale: activeLocale }], queryFn: () => fetchContent(collection, id, { locale: activeLocale }), enabled: !i18n || !!activeLocale, }); React.useEffect(() => { if (typeof searchParams.field !== "string" || isLoading) return; const timeoutId = requestIdleCallback(() => { const el = document.getElementById(`field-${searchParams.field}`); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); el.focus(); const { field: _, ...preservedSearch } = searchParams; void navigate({ search: preservedSearch as never, replace: true }); } }); return () => cancelIdleCallback(timeoutId); }, [searchParams, isLoading, navigate]); // 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, t`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, }); // Picker is locale-pinned to the entry being edited. The credit // hydration server-side is strict per locale (migration 040), so the // picker must show only bylines that will actually render at this // locale — otherwise the editor adds a credit that silently vanishes // after autosave. Query disabled until `rawItem.locale` resolves so a // transient `undefined` doesn't populate the cache with default-locale // data. const itemLocale = rawItem?.locale ?? undefined; const { data: bylinesData, isSuccess: bylinesLoaded } = useQuery({ queryKey: ["bylines", "picker", itemLocale ?? null], queryFn: () => fetchBylines({ locale: itemLocale, limit: 100 }), enabled: !!itemLocale, }); const createBylineMutation = useMutation({ mutationFn: (input: { slug: string; displayName: string }) => createByline({ ...input, isGuest: true, locale: itemLocale }), 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, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { // Invalidate by (collection, id) prefix without the locale object: the // editor's read query is keyed `{ locale: activeLocale }` (undefined when // i18n is off) while `rawItem.locale` is the DB default "en", so a // locale-scoped invalidation key would not match and the item would never // refetch — leaving the publish/save buttons stale until a hard refresh. void queryClient.invalidateQueries({ queryKey: ["content", collection, id], }); // Also invalidate revisions since a new one was created void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); // Invalidate the cached draft revision so stale data doesn't overwrite the form if (rawItem?.draftRevisionId) { void queryClient.invalidateQueries({ queryKey: ["revision", rawItem.draftRevisionId], }); } }, onError: (error) => { toastManager.add({ title: t`Failed to save`, description: error instanceof Error ? error.message : t`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 }, { locale: rawItem?.locale ?? activeLocale }, ), onSuccess: (savedItem, variables) => { patchAutosaveQueries(queryClient, { collection, id, savedItem, payload: { data: variables.data, slug: variables.slug, }, locale: rawItem?.locale ?? activeLocale, }); setLastAutosaveAt(new Date()); // Keep the cache fresh without refetching older server state back into the form // while the user is still typing. }, onError: (err) => { toastManager.add({ title: t`Autosave failed`, description: err instanceof Error ? err.message : t`An error occurred`, type: "error", }); }, }); const publishMutation = useMutation({ mutationFn: () => publishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id], }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Published`, description: t`Content is now live` }); }, onError: (error) => { toastManager.add({ title: t`Failed to publish`, description: error instanceof Error ? error.message : t`An error occurred`, type: "error", }); }, }); const unpublishMutation = useMutation({ mutationFn: () => unpublishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id], }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Unpublished`, description: t`Content removed from public view` }); }, onError: (error) => { toastManager.add({ title: t`Failed to unpublish`, description: error instanceof Error ? error.message : t`An error occurred`, type: "error", }); }, }); const discardDraftMutation = useMutation({ mutationFn: () => discardDraft(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id], }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Changes discarded`, description: t`Reverted to published version`, }); }, onError: (error) => { toastManager.add({ title: t`Failed to discard changes`, description: error instanceof Error ? error.message : t`An error occurred`, type: "error", }); }, }); const scheduleMutation = useMutation({ mutationFn: (scheduledAt: string) => scheduleContent(collection, id, scheduledAt, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id], }); toastManager.add({ title: t`Scheduled`, description: t`Content has been scheduled for publishing`, }); }, onError: (error) => { toastManager.add({ title: t`Failed to schedule`, description: error instanceof Error ? error.message : t`An error occurred`, type: "error", }); }, }); const unscheduleMutation = useMutation({ mutationFn: () => unscheduleContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection, id], }); toastManager.add({ title: t`Unscheduled`, description: t`Content reverted to draft`, }); }, onError: (error) => { toastManager.add({ title: t`Failed to unschedule`, description: error instanceof Error ? error.message : t`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 }, search: { locale: result.locale }, }); toastManager.add({ title: t`Translation created`, description: t`Created ${result.locale?.toUpperCase() ?? t`new`} translation`, }); }, onError: (error) => { toastManager.add({ title: t`Failed to create translation`, description: error instanceof Error ? error.message : t`An error occurred`, type: "error", }); }, }); const deleteMutation = useMutation({ mutationFn: () => deleteContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] }); void navigate({ to: "/content/$collection", params: { collection }, search: { locale: activeLocale }, }); }, onError: (error) => { toastManager.add({ title: t`Failed to delete`, description: error instanceof Error ? error.message : t`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} availableBylinesLoaded={bylinesLoaded} 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(); // Filename search + MIME type filter for the local library (server-side). const [search, setSearch] = React.useState(""); const [mimeFilter, setMimeFilter] = React.useState(undefined); const mimeKey = Array.isArray(mimeFilter) ? mimeFilter.join(",") : (mimeFilter ?? ""); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({ queryKey: ["media", { search, mime: mimeKey }], queryFn: ({ pageParam }) => fetchMediaList({ cursor: pageParam, limit: 100, search: search || undefined, mimeType: mimeFilter, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, }); 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"] }); }, }); const items = React.useMemo(() => { return data?.pages.flatMap((page) => page.items) || []; }, [data]); if (error) { return ; } return ( void fetchNextPage()} onUpload={(file) => uploadMutation.mutate(file)} onDelete={(id) => deleteMutation.mutate(id)} onLocalSearchChange={setSearch} onLocalMimeFilterChange={setMimeFilter} /> ); } // 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 { t } = useLingui(); 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, t`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: t`Failed to update status`, description: error instanceof Error ? error.message : t`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: t`Failed to delete comment`, description: error instanceof Error ? error.message : t`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: plural(result.affected, { one: "# comment updated", other: "# comments updated" }), }); }, onError: (error) => { toastManager.add({ title: t`Failed to perform bulk action`, description: error instanceof Error ? error.message : t`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 (

{t`Access Denied`}

{t`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: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); 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]); // When `experimental.registry` is configured, the registry browse // replaces the centralized marketplace browse on this route. Existing // sidebar / deep links stay valid; users see the registry without any // path change. if (manifest?.registry) { // Map installed registry plugins to their AT URIs for the // "Installed" badge on browse cards. const installedRegistryUris = new Set( (plugins ?? []) .filter((p) => p.source === "registry" && p.registryPublisherDid && p.registrySlug) .map( (p) => `at://${p.registryPublisherDid}/com.emdashcms.experimental.package.profile/${p.registrySlug}`, ), ); return ( ); } 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: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); 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]); // Discriminate by param shape, not by the manifest flag. A registry // pluginId is always `${handle}/${slug}` and contains exactly one `/`; // a marketplace pluginId is a single segment with no `/`. This keeps // deep links to marketplace-installed plugins working on sites that // later opt into the registry, instead of unconditionally routing // every visit to RegistryPluginDetail. const looksLikeRegistryId = pluginId.includes("/"); if (manifest?.registry && looksLikeRegistryId) { return ; } 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, validateSearch: (search: Record) => { return { locale: typeof search.locale === "string" ? search.locale : undefined, }; }, }); // 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 // // `validateSearch` rejects empty-string locale (`?locale=`) — left as `""` // it would land in component state and silently drop the locale filter // from `fetchBylines`, fetching every locale's rows while the UI thinks // it's scoped to one. export function parseBylinesLocaleSearch(search: Record): { locale: string | undefined; } { if (typeof search.locale === "string" && search.locale.length > 0) { return { locale: search.locale }; } return { locale: undefined }; } const bylinesRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/bylines", component: BylinesPage, validateSearch: parseBylinesLocaleSearch, }); // Byline schema management route (Discussion #1174, Phase 5). // `minRole: ROLE_ADMIN` is enforced both in the sidebar (entry hidden // for non-admins) and inside `BylineSchemaPage` (URL-direct navigation). const bylineSchemaRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/byline-schema", component: BylineSchemaPage, }); // 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 toastManager = Toast.useToastManager(); const { t } = useLingui(); 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"] }); }, onError: (mutationError) => { toastManager.add({ title: t`Failed to save`, description: mutationError instanceof Error ? mutationError.message : t`An error occurred`, type: "error", }); }, }); 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, bylineSchemaRoute, widgetsRoute, settingsRoute, generalSettingsRoute, socialSettingsRoute, seoSettingsRoute, securitySettingsRoute, allowedDomainsSettingsRoute, apiTokenSettingsRoute, emailSettingsRoute, wordpressImportRoute, notFoundRoute, ]); const routeTree = baseRootRoute.addChildren([ setupRoute, loginRoute, signupRoute, inviteAcceptRoute, 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() { const { t } = useLingui(); return (

{t`Loading configuration...`}

); } function ErrorScreen({ error }: { error: string }) { const { t } = useLingui(); return (

{t`Error`}

{error}

); } function NotFoundPage({ message }: { message?: string }) { const { t } = useLingui(); return (

{t`Page Not Found`}

{message ?? t`The page you're looking for doesn't exist.`}

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