* Hide preview button unless collection supports preview and content is published * Remove isPublished check --------- Co-authored-by: Matt Kane <mkane@cloudflare.com>
1632 lines
46 KiB
TypeScript
1632 lines
46 KiB
TypeScript
/**
|
|
* 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<RouterContext>()({
|
|
component: () => <Outlet />,
|
|
});
|
|
|
|
// 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 <LoginPage redirectUrl={redirect} />;
|
|
}
|
|
|
|
// 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 <LoadingScreen />;
|
|
}
|
|
|
|
if (error || !manifest) {
|
|
return <ErrorScreen error={error?.message || "Failed to load admin"} />;
|
|
}
|
|
|
|
// Plugin admin components are passed via props and available through PluginAdminContext
|
|
return (
|
|
<Shell manifest={manifest}>
|
|
<Outlet />
|
|
</Shell>
|
|
);
|
|
}
|
|
|
|
// 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 <Dashboard manifest={manifest} />;
|
|
}
|
|
|
|
// Content list route
|
|
const contentListRoute = createRoute({
|
|
getParentRoute: () => adminLayoutRoute,
|
|
path: "/content/$collection",
|
|
component: ContentListPage,
|
|
validateSearch: (search: Record<string, unknown>) => ({
|
|
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 <LoadingScreen />;
|
|
}
|
|
|
|
const collectionConfig = manifest.collections[collection];
|
|
|
|
if (!collectionConfig) {
|
|
return <NotFoundPage message={`Collection "${collection}" not found`} />;
|
|
}
|
|
|
|
if (error) {
|
|
return <ErrorScreen error={error.message} />;
|
|
}
|
|
|
|
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 (
|
|
<ContentList
|
|
collection={collection}
|
|
collectionLabel={collectionConfig.label}
|
|
items={items}
|
|
trashedItems={trashedData?.items || []}
|
|
isLoading={isLoading || isFetchingNextPage}
|
|
isTrashedLoading={isTrashedLoading}
|
|
hasMore={!!hasNextPage}
|
|
onLoadMore={() => 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<BylineCreditInput[]>([]);
|
|
|
|
const { data: manifest } = useQuery({
|
|
queryKey: ["manifest"],
|
|
queryFn: fetchManifest,
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: {
|
|
data: Record<string, unknown>;
|
|
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 <LoadingScreen />;
|
|
}
|
|
|
|
const collectionConfig = manifest.collections[collection];
|
|
|
|
if (!collectionConfig) {
|
|
return <NotFoundPage message={`Collection "${collection}" not found`} />;
|
|
}
|
|
|
|
const handleSave = (payload: {
|
|
data: Record<string, unknown>;
|
|
slug?: string;
|
|
bylines?: BylineCreditInput[];
|
|
}) => {
|
|
createMutation.mutate(payload);
|
|
};
|
|
|
|
return (
|
|
<ContentEditor
|
|
collection={collection}
|
|
collectionLabel={collectionConfig.labelSingular || collectionConfig.label}
|
|
fields={collectionConfig.fields}
|
|
isNew
|
|
isSaving={createMutation.isPending}
|
|
onSave={handleSave}
|
|
pluginBlocks={pluginBlocks}
|
|
availableBylines={bylinesData?.items}
|
|
selectedBylines={selectedBylines}
|
|
onBylinesChange={setSelectedBylines}
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 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<string, unknown> = {};
|
|
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<string, unknown>;
|
|
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<Date | null>(null);
|
|
const autosaveMutation = useMutation({
|
|
mutationFn: (data: {
|
|
data?: Record<string, unknown>;
|
|
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 <LoadingScreen />;
|
|
}
|
|
|
|
const collectionConfig = manifest.collections[collection];
|
|
|
|
if (!collectionConfig) {
|
|
return <NotFoundPage message={`Collection "${collection}" not found`} />;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
const handleSave = (payload: {
|
|
data: Record<string, unknown>;
|
|
slug?: string;
|
|
bylines?: BylineCreditInput[];
|
|
}) => {
|
|
updateMutation.mutate(payload);
|
|
};
|
|
|
|
const handleAutosave = (payload: {
|
|
data: Record<string, unknown>;
|
|
slug?: string;
|
|
bylines?: BylineCreditInput[];
|
|
}) => {
|
|
autosaveMutation.mutate(payload);
|
|
};
|
|
|
|
const handleAuthorChange = (authorId: string | null) => {
|
|
updateMutation.mutate({ authorId });
|
|
};
|
|
|
|
const handleSeoChange = (seo: ContentSeoInput) => {
|
|
updateMutation.mutate({ seo });
|
|
};
|
|
|
|
return (
|
|
<ContentEditor
|
|
collection={collection}
|
|
collectionLabel={collectionConfig.labelSingular || collectionConfig.label}
|
|
item={item}
|
|
fields={collectionConfig.fields}
|
|
isSaving={updateMutation.isPending}
|
|
onSave={handleSave}
|
|
onAutosave={handleAutosave}
|
|
isAutosaving={autosaveMutation.isPending}
|
|
lastAutosaveAt={lastAutosaveAt}
|
|
onPublish={() => 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 <ErrorScreen error={error.message} />;
|
|
}
|
|
|
|
return (
|
|
<MediaLibrary
|
|
items={data?.items || []}
|
|
isLoading={isLoading}
|
|
onUpload={(file) => 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<CommentStatus>("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 (
|
|
<div className="flex items-center justify-center min-h-[50vh]">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold">Access Denied</h1>
|
|
<p className="mt-2 text-kumo-subtle">You need Editor permissions to moderate comments.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<CommentInbox
|
|
comments={allComments}
|
|
counts={counts ?? { pending: 0, approved: 0, spam: 0, trash: 0 }}
|
|
isLoading={isLoading}
|
|
nextCursor={lastPage?.nextCursor}
|
|
collections={manifest?.collections ?? {}}
|
|
activeStatus={activeStatus}
|
|
onStatusChange={setActiveStatus}
|
|
collectionFilter={collectionFilter}
|
|
onCollectionFilterChange={setCollectionFilter}
|
|
searchQuery={searchQuery}
|
|
onSearchChange={setSearchQuery}
|
|
onCommentStatusChange={(id, status) =>
|
|
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 <PluginManager manifest={manifest} />;
|
|
}
|
|
|
|
// 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<string>();
|
|
return new Set(plugins.map((p) => p.id));
|
|
}, [plugins]);
|
|
|
|
return <MarketplaceBrowse installedPluginIds={installedIds} />;
|
|
}
|
|
|
|
// 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<string>();
|
|
return new Set(plugins.map((p) => p.id));
|
|
}, [plugins]);
|
|
|
|
return <MarketplacePluginDetail pluginId={pluginId} installedPluginIds={installedIds} />;
|
|
}
|
|
|
|
// 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 <ThemeMarketplaceDetail themeId={themeId} />;
|
|
}
|
|
|
|
// 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 <TaxonomyManager taxonomyName={taxonomy} />;
|
|
}
|
|
|
|
// 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 <ErrorScreen error={error.message} />;
|
|
}
|
|
|
|
return (
|
|
<ContentTypeList
|
|
collections={collections ?? []}
|
|
orphanedTables={orphanedTables}
|
|
isLoading={collectionsLoading || orphansLoading}
|
|
onDelete={(slug) => 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 (
|
|
<ContentTypeEditor
|
|
isNew
|
|
isSaving={createMutation.isPending}
|
|
onSave={(input) => {
|
|
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 <ErrorScreen error={error.message} />;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <LoadingScreen />;
|
|
}
|
|
|
|
return (
|
|
<ContentTypeEditor
|
|
collection={collection}
|
|
isSaving={updateMutation.isPending}
|
|
onSave={(input) => 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 <PluginComponent />;
|
|
}
|
|
|
|
// No React component — fall back to Block Kit rendering
|
|
return <SandboxedPluginPage pluginId={pluginId} page={pagePath} />;
|
|
}
|
|
|
|
// Catch-all 404 route
|
|
const notFoundRoute = createRoute({
|
|
getParentRoute: () => adminLayoutRoute,
|
|
path: "*",
|
|
component: () => <NotFoundPage />,
|
|
});
|
|
|
|
// 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<typeof createAdminRouter>;
|
|
}
|
|
}
|
|
|
|
// Shared components
|
|
|
|
function LoadingScreen() {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="text-center">
|
|
<Loader />
|
|
<p className="mt-4 text-kumo-subtle">Loading configuration...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorScreen({ error }: { error: string }) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="text-center">
|
|
<h1 className="text-xl font-bold text-kumo-danger">Error</h1>
|
|
<p className="mt-2 text-kumo-subtle">{error}</p>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="mt-4 px-4 py-2 bg-kumo-brand text-white rounded-md"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NotFoundPage({ message }: { message?: string }) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[50vh]">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold">Page Not Found</h1>
|
|
<p className="mt-2 text-kumo-subtle">
|
|
{message || "The page you're looking for doesn't exist."}
|
|
</p>
|
|
<Link to="/" className="mt-4 inline-block text-kumo-brand">
|
|
Go to Dashboard
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export { Link, useNavigate, useParams };
|