first commit
This commit is contained in:
97
packages/admin/package.json
Normal file
97
packages/admin/package.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "@emdashcms/admin",
|
||||
"version": "0.0.0",
|
||||
"description": "Admin UI for EmDash CMS",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./styles.css": "./dist/styles.css"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown && npx @tailwindcss/cli -i src/styles.css -o dist/styles.css --minify",
|
||||
"dev": "tsdown src/index.ts --format esm --dts --watch",
|
||||
"prepublishOnly": "node --run build",
|
||||
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/kumo": "^1.16.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emdashcms/blocks": "workspace:*",
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"@phosphor-icons/react": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-router": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-character-count": "catalog:",
|
||||
"@tiptap/extension-drag-handle": "catalog:",
|
||||
"@tiptap/extension-drag-handle-react": "catalog:",
|
||||
"@tiptap/extension-dropcursor": "catalog:",
|
||||
"@tiptap/extension-focus": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
"@tiptap/extension-node-range": "catalog:",
|
||||
"@tiptap/extension-placeholder": "catalog:",
|
||||
"@tiptap/extension-text-align": "catalog:",
|
||||
"@tiptap/extension-typography": "catalog:",
|
||||
"@tiptap/extension-underline": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/react": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"marked": "^17.0.3",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hotkeys-hook": "^5.2.4",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arethetypeswrong/cli": "catalog:",
|
||||
"@tailwindcss/cli": "^4.1.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@tiptap/suggestion": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"jsdom": "^26.1.0",
|
||||
"playwright": "^1.58.2",
|
||||
"publint": "catalog:",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "catalog:",
|
||||
"vitest-browser-react": "^2.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/cloudflare/emdash.git",
|
||||
"directory": "packages/admin"
|
||||
},
|
||||
"homepage": "https://github.com/cloudflare/emdash",
|
||||
"keywords": [
|
||||
"astro",
|
||||
"cms",
|
||||
"admin",
|
||||
"react"
|
||||
],
|
||||
"author": "Matt Kane",
|
||||
"license": "MIT"
|
||||
}
|
||||
61
packages/admin/src/App.tsx
Normal file
61
packages/admin/src/App.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* EmDash Admin React Application
|
||||
*
|
||||
* This is the main entry point for the admin SPA.
|
||||
* Uses TanStack Router for client-side routing and TanStack Query for data fetching.
|
||||
*
|
||||
* Plugin admin components are passed via the pluginAdmins prop and made
|
||||
* available throughout the app via PluginAdminContext.
|
||||
*/
|
||||
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
import { PluginAdminProvider, type PluginAdmins } from "./lib/plugin-context";
|
||||
import { createAdminRouter } from "./router";
|
||||
|
||||
// Create a query client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create the router with query client context
|
||||
const router = createAdminRouter(queryClient);
|
||||
|
||||
export interface AdminAppProps {
|
||||
/** Plugin admin modules keyed by plugin ID */
|
||||
pluginAdmins?: PluginAdmins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Admin Application
|
||||
*/
|
||||
const EMPTY_PLUGINS: PluginAdmins = {};
|
||||
|
||||
export function AdminApp({ pluginAdmins = EMPTY_PLUGINS }: AdminAppProps) {
|
||||
React.useEffect(() => {
|
||||
document.getElementById("emdash-boot-loader")?.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Toasty>
|
||||
<PluginAdminProvider pluginAdmins={pluginAdmins}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</PluginAdminProvider>
|
||||
</Toasty>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminApp;
|
||||
472
packages/admin/src/components/AdminCommandPalette.tsx
Normal file
472
packages/admin/src/components/AdminCommandPalette.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Admin Command Palette
|
||||
*
|
||||
* Quick navigation and search across the admin interface.
|
||||
* Opens with Cmd+K (Mac) or Ctrl+K (Windows/Linux).
|
||||
*/
|
||||
|
||||
import { CommandPalette } from "@cloudflare/kumo";
|
||||
import {
|
||||
SquaresFour,
|
||||
FileText,
|
||||
Image,
|
||||
Gear,
|
||||
PuzzlePiece,
|
||||
Upload,
|
||||
Database,
|
||||
List,
|
||||
GridFour,
|
||||
Users,
|
||||
Stack,
|
||||
MagnifyingGlass,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
import { apiFetch } from "../lib/api/client";
|
||||
import { useCurrentUser } from "../lib/api/current-user";
|
||||
|
||||
// Role levels (matching @emdashcms/auth)
|
||||
const ROLE_ADMIN = 50;
|
||||
const ROLE_EDITOR = 40;
|
||||
|
||||
// Regex for replacing route params like $collection with actual values
|
||||
const ROUTE_PARAM_REGEX = /\$(\w+)/g;
|
||||
|
||||
// Debounce delay for content search (ms)
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// Detect macOS for keyboard shortcut display
|
||||
const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
|
||||
/**
|
||||
* Custom hook for debouncing a value
|
||||
*/
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
collection: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
items: SearchResult[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
title: string;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
icon: React.ElementType;
|
||||
minRole?: number;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
interface ResultGroup {
|
||||
label: string;
|
||||
items: ResultItem[];
|
||||
}
|
||||
|
||||
interface ResultItem {
|
||||
id: string;
|
||||
title: string;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
icon?: React.ReactNode;
|
||||
description?: string;
|
||||
collection?: string;
|
||||
}
|
||||
|
||||
interface AdminCommandPaletteProps {
|
||||
manifest: {
|
||||
collections: Record<string, { label: string; labelSingular?: string }>;
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
package?: string;
|
||||
enabled?: boolean;
|
||||
adminPages?: Array<{
|
||||
path: string;
|
||||
label?: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
async function searchContent(query: string): Promise<SearchResponse> {
|
||||
if (!query || query.length < 2) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
const response = await apiFetch(`/_emdash/api/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||
if (!response.ok) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
const body = (await response.json()) as { data: SearchResponse };
|
||||
return body.data;
|
||||
}
|
||||
|
||||
function buildNavItems(
|
||||
manifest: AdminCommandPaletteProps["manifest"],
|
||||
userRole: number,
|
||||
): NavItem[] {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
id: "dashboard",
|
||||
title: "Dashboard",
|
||||
to: "/",
|
||||
icon: SquaresFour,
|
||||
keywords: ["home", "overview"],
|
||||
},
|
||||
];
|
||||
|
||||
// Add collection links
|
||||
for (const [name, config] of Object.entries(manifest.collections)) {
|
||||
items.push({
|
||||
id: `collection-${name}`,
|
||||
title: config.label,
|
||||
to: "/content/$collection",
|
||||
params: { collection: name },
|
||||
icon: FileText,
|
||||
keywords: ["content", name],
|
||||
});
|
||||
}
|
||||
|
||||
// Add core admin links
|
||||
items.push(
|
||||
{
|
||||
id: "media",
|
||||
title: "Media Library",
|
||||
to: "/media",
|
||||
icon: Image,
|
||||
keywords: ["images", "files", "uploads"],
|
||||
},
|
||||
{
|
||||
id: "menus",
|
||||
title: "Menus",
|
||||
to: "/menus",
|
||||
icon: List,
|
||||
minRole: ROLE_EDITOR,
|
||||
keywords: ["navigation"],
|
||||
},
|
||||
{
|
||||
id: "widgets",
|
||||
title: "Widgets",
|
||||
to: "/widgets",
|
||||
icon: GridFour,
|
||||
minRole: ROLE_EDITOR,
|
||||
keywords: ["sidebar", "footer"],
|
||||
},
|
||||
{
|
||||
id: "sections",
|
||||
title: "Sections",
|
||||
to: "/sections",
|
||||
icon: Stack,
|
||||
minRole: ROLE_EDITOR,
|
||||
keywords: ["page builder", "blocks"],
|
||||
},
|
||||
{
|
||||
id: "content-types",
|
||||
title: "Content Types",
|
||||
to: "/content-types",
|
||||
icon: Database,
|
||||
minRole: ROLE_ADMIN,
|
||||
keywords: ["schema", "collections"],
|
||||
},
|
||||
{
|
||||
id: "categories",
|
||||
title: "Categories",
|
||||
to: "/taxonomies/$taxonomy",
|
||||
params: { taxonomy: "category" },
|
||||
icon: FileText,
|
||||
minRole: ROLE_EDITOR,
|
||||
keywords: ["taxonomy"],
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
title: "Tags",
|
||||
to: "/taxonomies/$taxonomy",
|
||||
params: { taxonomy: "tag" },
|
||||
icon: FileText,
|
||||
minRole: ROLE_EDITOR,
|
||||
keywords: ["taxonomy"],
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
title: "Users",
|
||||
to: "/users",
|
||||
icon: Users,
|
||||
minRole: ROLE_ADMIN,
|
||||
keywords: ["accounts", "team"],
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
title: "Plugins",
|
||||
to: "/plugins-manager",
|
||||
icon: PuzzlePiece,
|
||||
minRole: ROLE_ADMIN,
|
||||
keywords: ["extensions", "add-ons"],
|
||||
},
|
||||
{
|
||||
id: "import",
|
||||
title: "Import",
|
||||
to: "/import/wordpress",
|
||||
icon: Upload,
|
||||
minRole: ROLE_ADMIN,
|
||||
keywords: ["wordpress", "migrate"],
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
to: "/settings",
|
||||
icon: Gear,
|
||||
minRole: ROLE_ADMIN,
|
||||
keywords: ["configuration", "preferences"],
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
title: "Security Settings",
|
||||
to: "/settings/security",
|
||||
icon: Gear,
|
||||
minRole: ROLE_ADMIN,
|
||||
keywords: ["passkeys", "authentication"],
|
||||
},
|
||||
);
|
||||
|
||||
// Add plugin pages
|
||||
for (const [pluginId, config] of Object.entries(manifest.plugins)) {
|
||||
if (config.enabled === false) continue;
|
||||
if (config.adminPages && config.adminPages.length > 0) {
|
||||
for (const page of config.adminPages) {
|
||||
const label =
|
||||
page.label ||
|
||||
pluginId
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
|
||||
items.push({
|
||||
id: `plugin-${pluginId}-${page.path}`,
|
||||
title: label,
|
||||
to: `/plugins/${pluginId}${page.path}`,
|
||||
icon: PuzzlePiece,
|
||||
keywords: ["plugin", pluginId],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by role
|
||||
return items.filter((item) => !item.minRole || userRole >= item.minRole);
|
||||
}
|
||||
|
||||
function filterNavItems(items: NavItem[], query: string): NavItem[] {
|
||||
if (!query) return items;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return items.filter((item) => {
|
||||
const titleMatch = item.title.toLowerCase().includes(lowerQuery);
|
||||
const keywordMatch = item.keywords?.some((k) => k.toLowerCase().includes(lowerQuery));
|
||||
return titleMatch || keywordMatch;
|
||||
});
|
||||
}
|
||||
|
||||
export function AdminCommandPalette({ manifest }: AdminCommandPaletteProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Debounce the search query to avoid flickering on every keystroke
|
||||
const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const { data: user } = useCurrentUser();
|
||||
|
||||
const userRole = user?.role ?? 0;
|
||||
|
||||
// Search content when debounced query is long enough
|
||||
const { data: searchResults, isFetching: isSearching } = useQuery({
|
||||
queryKey: ["command-palette-search", debouncedQuery],
|
||||
queryFn: () => searchContent(debouncedQuery),
|
||||
enabled: debouncedQuery.length >= 2,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
// Show loading while waiting for debounce or API response
|
||||
const isWaitingForDebounce = query.length >= 2 && query !== debouncedQuery;
|
||||
const isPendingSearch = isWaitingForDebounce || isSearching;
|
||||
|
||||
// Build navigation items
|
||||
const allNavItems = React.useMemo(() => buildNavItems(manifest, userRole), [manifest, userRole]);
|
||||
|
||||
// Filter nav items based on query
|
||||
const filteredNavItems = React.useMemo(
|
||||
() => filterNavItems(allNavItems, query),
|
||||
[allNavItems, query],
|
||||
);
|
||||
|
||||
// Build result groups
|
||||
const resultGroups = React.useMemo((): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
// Navigation group
|
||||
if (filteredNavItems.length > 0) {
|
||||
groups.push({
|
||||
label: "Navigation",
|
||||
items: filteredNavItems.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
to: item.to,
|
||||
params: item.params,
|
||||
icon: <item.icon className="h-4 w-4" />,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Content search results
|
||||
if (searchResults?.items && searchResults.items.length > 0) {
|
||||
const contentItems = searchResults.items.map((result) => {
|
||||
const collectionConfig = manifest.collections[result.collection];
|
||||
return {
|
||||
id: `content-${result.id}`,
|
||||
title: result.title || result.slug,
|
||||
to: "/content/$collection/$id",
|
||||
params: { collection: result.collection, id: result.id },
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
description: collectionConfig?.label || result.collection,
|
||||
collection: result.collection,
|
||||
};
|
||||
});
|
||||
|
||||
groups.push({
|
||||
label: "Content",
|
||||
items: contentItems,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [filteredNavItems, searchResults, manifest.collections]);
|
||||
|
||||
// Keyboard shortcut to open (Cmd+K / Ctrl+K)
|
||||
useHotkeys("mod+k", (e) => {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
});
|
||||
|
||||
// Reset query when closing
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(item: ResultItem, options: { newTab: boolean }) => {
|
||||
setOpen(false);
|
||||
if (options.newTab) {
|
||||
// Build the full URL for new tab
|
||||
const path = item.params
|
||||
? item.to.replace(ROUTE_PARAM_REGEX, (_, key) => item.params?.[key] ?? "")
|
||||
: item.to;
|
||||
window.open(`/_emdash/admin${path}`, "_blank");
|
||||
} else {
|
||||
// Navigate within the app
|
||||
void navigate({
|
||||
to: item.to as "/",
|
||||
params: item.params,
|
||||
});
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleItemClick = React.useCallback(
|
||||
(item: ResultItem, e: React.MouseEvent) => {
|
||||
handleSelect(item, { newTab: e.metaKey || e.ctrlKey });
|
||||
},
|
||||
[handleSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandPalette.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
items={resultGroups}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
itemToStringValue={(group) => group.label}
|
||||
onSelect={handleSelect}
|
||||
getSelectableItems={(groups) => groups.flatMap((g) => g.items)}
|
||||
>
|
||||
<CommandPalette.Input
|
||||
placeholder="Search pages and content..."
|
||||
leading={<MagnifyingGlass className="h-4 w-4 text-kumo-subtle" weight="bold" />}
|
||||
/>
|
||||
<CommandPalette.List>
|
||||
{isPendingSearch ? (
|
||||
<CommandPalette.Loading />
|
||||
) : (
|
||||
<>
|
||||
<CommandPalette.Results>
|
||||
{(group: ResultGroup) => (
|
||||
<CommandPalette.Group key={group.label} items={group.items}>
|
||||
<CommandPalette.GroupLabel>{group.label}</CommandPalette.GroupLabel>
|
||||
<CommandPalette.Items>
|
||||
{(item: ResultItem) => (
|
||||
<CommandPalette.ResultItem
|
||||
key={item.id}
|
||||
value={item}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
onClick={(e: React.MouseEvent) => handleItemClick(item, e)}
|
||||
/>
|
||||
)}
|
||||
</CommandPalette.Items>
|
||||
</CommandPalette.Group>
|
||||
)}
|
||||
</CommandPalette.Results>
|
||||
<CommandPalette.Empty>No results found</CommandPalette.Empty>
|
||||
</>
|
||||
)}
|
||||
</CommandPalette.List>
|
||||
<CommandPalette.Footer>
|
||||
<div className="flex items-center gap-4 text-kumo-subtle">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-kumo-control px-1.5 py-0.5 text-xs">Enter</kbd>
|
||||
<span>to select</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-kumo-control px-1.5 py-0.5 text-xs">
|
||||
{IS_MAC ? "Cmd" : "Ctrl"}+Enter
|
||||
</kbd>
|
||||
<span>new tab</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-kumo-control px-1.5 py-0.5 text-xs">Esc</kbd>
|
||||
<span>to close</span>
|
||||
</span>
|
||||
</div>
|
||||
</CommandPalette.Footer>
|
||||
</CommandPalette.Root>
|
||||
);
|
||||
}
|
||||
123
packages/admin/src/components/BlockKitFieldWidget.tsx
Normal file
123
packages/admin/src/components/BlockKitFieldWidget.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Input, Switch } from "@cloudflare/kumo";
|
||||
import type { Element } from "@emdashcms/blocks";
|
||||
import * as React from "react";
|
||||
|
||||
interface BlockKitFieldWidgetProps {
|
||||
label: string;
|
||||
elements: Element[];
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders Block Kit elements as a field widget for sandboxed plugins.
|
||||
* Decomposes a JSON value into per-element values keyed by action_id,
|
||||
* and recomposes on change.
|
||||
*/
|
||||
export function BlockKitFieldWidget({
|
||||
label,
|
||||
elements,
|
||||
value,
|
||||
onChange,
|
||||
}: BlockKitFieldWidgetProps) {
|
||||
const obj = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
||||
|
||||
// Use a ref to avoid stale closure -- rapid changes to different elements
|
||||
// would otherwise lose updates because each callback spreads from a stale obj.
|
||||
const objRef = React.useRef(obj);
|
||||
objRef.current = obj;
|
||||
|
||||
const handleElementChange = React.useCallback(
|
||||
(actionId: string, elementValue: unknown) => {
|
||||
onChange({ ...objRef.current, [actionId]: elementValue });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Filter out elements without action_id -- they can't be mapped to values
|
||||
const validElements = elements.filter((el) => el.action_id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-sm font-medium leading-none">{label}</span>
|
||||
<div className="mt-2 space-y-3">
|
||||
{validElements.map((el) => (
|
||||
<BlockKitFieldElement
|
||||
key={el.action_id}
|
||||
element={el}
|
||||
value={obj[el.action_id]}
|
||||
onChange={handleElementChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockKitFieldElement({
|
||||
element,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
element: Element;
|
||||
value: unknown;
|
||||
onChange: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
switch (element.type) {
|
||||
case "text_input":
|
||||
return (
|
||||
<Input
|
||||
label={element.label}
|
||||
placeholder={element.placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(element.action_id, e.target.value)}
|
||||
/>
|
||||
);
|
||||
case "number_input":
|
||||
return (
|
||||
<Input
|
||||
label={element.label}
|
||||
type="number"
|
||||
value={typeof value === "number" ? String(value) : ""}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
onChange(element.action_id, e.target.value && Number.isFinite(n) ? n : undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "toggle":
|
||||
return (
|
||||
<Switch
|
||||
label={element.label}
|
||||
checked={!!value}
|
||||
onCheckedChange={(checked) => onChange(element.action_id, checked)}
|
||||
/>
|
||||
);
|
||||
case "select": {
|
||||
const options = Array.isArray(element.options) ? element.options : [];
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">{element.label}</label>
|
||||
<select
|
||||
className="flex w-full rounded-md border border-kumo-line bg-transparent px-3 py-2 text-sm"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(element.action_id, e.target.value)}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className="text-sm text-kumo-subtle">
|
||||
Unsupported widget element type: {(element as { type: string }).type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
154
packages/admin/src/components/CapabilityConsentDialog.tsx
Normal file
154
packages/admin/src/components/CapabilityConsentDialog.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Capability Consent Dialog
|
||||
*
|
||||
* Shown before installing or updating a marketplace plugin.
|
||||
* Lists each requested capability with a human-readable explanation.
|
||||
* User must explicitly confirm before the action proceeds.
|
||||
*/
|
||||
|
||||
import { Button } from "@cloudflare/kumo";
|
||||
import { ShieldCheck, ShieldWarning, Warning } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { describeCapability } from "../lib/api/marketplace.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { DialogError } from "./DialogError.js";
|
||||
|
||||
export interface CapabilityConsentDialogProps {
|
||||
/** Dialog mode */
|
||||
mode?: "install" | "update";
|
||||
/** Plugin display name */
|
||||
pluginName: string;
|
||||
/** Capabilities the plugin requests */
|
||||
capabilities: string[];
|
||||
/** Allowed network hosts (for network:fetch capability) */
|
||||
allowedHosts?: string[];
|
||||
/** New capabilities added in an update (highlighted differently) */
|
||||
newCapabilities?: string[];
|
||||
/** Audit verdict badge */
|
||||
auditVerdict?: "pass" | "warn" | "fail";
|
||||
/** Whether the action is in progress */
|
||||
isPending?: boolean;
|
||||
/** Error message to display inline */
|
||||
error?: string | null;
|
||||
/** Called when user confirms */
|
||||
onConfirm: () => void;
|
||||
/** Called when user cancels */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CapabilityConsentDialog({
|
||||
mode,
|
||||
pluginName,
|
||||
capabilities,
|
||||
allowedHosts,
|
||||
newCapabilities = [],
|
||||
auditVerdict,
|
||||
isPending = false,
|
||||
error,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: CapabilityConsentDialogProps) {
|
||||
const newSet = new Set(newCapabilities);
|
||||
const isUpdate = mode === "update" || newCapabilities.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Capability consent"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => !isPending && onCancel()} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative w-full max-w-md rounded-lg border bg-kumo-base shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isUpdate ? "Review New Permissions" : "Plugin Permissions"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">
|
||||
{isUpdate
|
||||
? `${pluginName} is requesting additional permissions:`
|
||||
: `${pluginName} requires the following permissions:`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Capabilities list */}
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
{capabilities.map((cap) => {
|
||||
const isNew = newSet.has(cap);
|
||||
return (
|
||||
<div
|
||||
key={cap}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-md p-2 text-sm",
|
||||
isNew ? "bg-warning/10 border border-warning/30" : "bg-kumo-tint/50",
|
||||
)}
|
||||
>
|
||||
<ShieldCheck
|
||||
className={cn(
|
||||
"mt-0.5 h-4 w-4 shrink-0",
|
||||
isNew ? "text-warning" : "text-kumo-subtle",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<span className={cn(isNew && "font-medium")}>
|
||||
{describeCapability(cap, allowedHosts)}
|
||||
</span>
|
||||
{isNew && <span className="ml-2 text-xs text-warning font-medium">NEW</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Audit verdict banner */}
|
||||
{auditVerdict && auditVerdict !== "pass" && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md p-3 text-sm mt-2",
|
||||
auditVerdict === "warn"
|
||||
? "bg-warning/10 text-warning"
|
||||
: "bg-kumo-danger/10 text-kumo-danger",
|
||||
)}
|
||||
>
|
||||
{auditVerdict === "warn" ? (
|
||||
<Warning className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ShieldWarning className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{auditVerdict === "warn"
|
||||
? "Security audit flagged potential concerns with this plugin."
|
||||
: "Security audit flagged this plugin as potentially unsafe."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
<DialogError message={error} className="mx-6" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||
<Button variant="ghost" onClick={onCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={isPending}>
|
||||
{isPending
|
||||
? isUpdate
|
||||
? "Updating..."
|
||||
: "Installing..."
|
||||
: isUpdate
|
||||
? "Accept & Update"
|
||||
: "Accept & Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CapabilityConsentDialog;
|
||||
64
packages/admin/src/components/ConfirmDialog.tsx
Normal file
64
packages/admin/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Reusable confirmation dialog with inline error display.
|
||||
*
|
||||
* Handles the common pattern: title, description, optional error banner,
|
||||
* cancel/confirm buttons with pending state. Dialog stays open on error.
|
||||
*/
|
||||
|
||||
import { Button, Dialog } from "@cloudflare/kumo";
|
||||
import * as React from "react";
|
||||
|
||||
import { DialogError, getMutationError } from "./DialogError.js";
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
/** Static description or dynamic JSX content */
|
||||
description: React.ReactNode;
|
||||
/** Label for the confirm button (e.g. "Delete", "Disable User") */
|
||||
confirmLabel: string;
|
||||
/** Label shown while the action is pending (e.g. "Deleting...") */
|
||||
pendingLabel: string;
|
||||
/** Button variant — defaults to "destructive" */
|
||||
variant?: "destructive" | "primary";
|
||||
isPending: boolean;
|
||||
/** Error from a mutation — pass mutation.error directly */
|
||||
error: unknown;
|
||||
onConfirm: () => void;
|
||||
/** Extra content rendered between description and buttons (e.g. a checkbox) */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
pendingLabel,
|
||||
variant = "destructive",
|
||||
isPending,
|
||||
error,
|
||||
onConfirm,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()} disablePointerDismissal>
|
||||
<Dialog className="p-6" size="sm">
|
||||
<Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
|
||||
<Dialog.Description className="text-kumo-subtle">{description}</Dialog.Description>
|
||||
{children}
|
||||
<DialogError message={getMutationError(error)} className="mt-3" />
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant={variant} disabled={isPending} onClick={onConfirm}>
|
||||
{isPending ? pendingLabel : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
1572
packages/admin/src/components/ContentEditor.tsx
Normal file
1572
packages/admin/src/components/ContentEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
513
packages/admin/src/components/ContentList.tsx
Normal file
513
packages/admin/src/components/ContentList.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
import { Badge, Button, buttonVariants, Dialog, Input, Tabs } from "@cloudflare/kumo";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash,
|
||||
ArrowCounterClockwise,
|
||||
Copy,
|
||||
MagnifyingGlass,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import type { ContentItem, TrashedContentItem } from "../lib/api";
|
||||
import { cn } from "../lib/utils";
|
||||
import { LocaleSwitcher } from "./LocaleSwitcher";
|
||||
|
||||
export interface ContentListProps {
|
||||
collection: string;
|
||||
collectionLabel: string;
|
||||
items: ContentItem[];
|
||||
trashedItems?: TrashedContentItem[];
|
||||
isLoading?: boolean;
|
||||
isTrashedLoading?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
onRestore?: (id: string) => void;
|
||||
onPermanentDelete?: (id: string) => void;
|
||||
onLoadMore?: () => void;
|
||||
onLoadMoreTrashed?: () => void;
|
||||
hasMore?: boolean;
|
||||
hasMoreTrashed?: boolean;
|
||||
trashedCount?: number;
|
||||
/** i18n config — present when multiple locales are configured */
|
||||
i18n?: { defaultLocale: string; locales: string[] };
|
||||
/** Currently active locale filter */
|
||||
activeLocale?: string;
|
||||
/** Callback when locale filter changes */
|
||||
onLocaleChange?: (locale: string) => void;
|
||||
}
|
||||
|
||||
type ViewTab = "all" | "trash";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function getItemTitle(item: { data: Record<string, unknown>; slug: string | null; id: string }) {
|
||||
const rawTitle = item.data.title;
|
||||
const rawName = item.data.name;
|
||||
return (
|
||||
(typeof rawTitle === "string" ? rawTitle : "") ||
|
||||
(typeof rawName === "string" ? rawName : "") ||
|
||||
item.slug ||
|
||||
item.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content list view with table display and trash tab
|
||||
*/
|
||||
export function ContentList({
|
||||
collection,
|
||||
collectionLabel,
|
||||
items,
|
||||
trashedItems = [],
|
||||
isLoading,
|
||||
isTrashedLoading,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onRestore,
|
||||
onPermanentDelete,
|
||||
onLoadMore,
|
||||
onLoadMoreTrashed,
|
||||
hasMore,
|
||||
hasMoreTrashed,
|
||||
trashedCount = 0,
|
||||
i18n,
|
||||
activeLocale,
|
||||
onLocaleChange,
|
||||
}: ContentListProps) {
|
||||
const [activeTab, setActiveTab] = React.useState<ViewTab>("all");
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [page, setPage] = React.useState(0);
|
||||
|
||||
// Reset page when search changes
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (!searchQuery) return items;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return items.filter((item) => getItemTitle(item).toLowerCase().includes(query));
|
||||
}, [items, searchQuery]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredItems.length / PAGE_SIZE));
|
||||
const paginatedItems = filteredItems.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">{collectionLabel}</h1>
|
||||
{i18n && activeLocale && onLocaleChange && (
|
||||
<LocaleSwitcher
|
||||
locales={i18n.locales}
|
||||
defaultLocale={i18n.defaultLocale}
|
||||
value={activeLocale}
|
||||
onChange={onLocaleChange}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/content/$collection/new" params={{ collection }} className={buttonVariants()}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Add New
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
{items.length > 0 && (
|
||||
<div className="relative max-w-sm">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`Search ${collectionLabel.toLowerCase()}...`}
|
||||
aria-label={`Search ${collectionLabel.toLowerCase()}`}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
variant="underline"
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
if (v === "all" || v === "trash") setActiveTab(v);
|
||||
}}
|
||||
tabs={[
|
||||
{ value: "all", label: "All" },
|
||||
{
|
||||
value: "trash",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Trash className="h-4 w-4" aria-hidden="true" />
|
||||
Trash
|
||||
{trashedCount > 0 && <Badge variant="secondary">{trashedCount}</Badge>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Content based on active tab */}
|
||||
{activeTab === "all" ? (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-kumo-tint/50">
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Title
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Status
|
||||
</th>
|
||||
{i18n && (
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Locale
|
||||
</th>
|
||||
)}
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Date
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && !isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
|
||||
No {collectionLabel.toLowerCase()} yet.{" "}
|
||||
<Link
|
||||
to="/content/$collection/new"
|
||||
params={{ collection }}
|
||||
className="text-kumo-brand underline"
|
||||
>
|
||||
Create your first one
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
|
||||
No results for “{searchQuery}”
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedItems.map((item) => (
|
||||
<ContentListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
collection={collection}
|
||||
onDelete={onDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
showLocale={!!i18n}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-kumo-subtle">
|
||||
{filteredItems.length} {filteredItems.length === 1 ? "item" : "items"}
|
||||
{searchQuery && ` matching "${searchQuery}"`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
shape="square"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<CaretLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
shape="square"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<CaretRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={onLoadMore} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Trash Table */}
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-kumo-tint/50">
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Title
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Deleted
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trashedItems.length === 0 && !isTrashedLoading ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-8 text-center text-kumo-subtle">
|
||||
Trash is empty
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
trashedItems.map((item) => (
|
||||
<TrashedListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRestore={onRestore}
|
||||
onPermanentDelete={onPermanentDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Load more trashed */}
|
||||
{hasMoreTrashed && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={onLoadMoreTrashed} disabled={isTrashedLoading}>
|
||||
{isTrashedLoading ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContentListItemProps {
|
||||
item: ContentItem;
|
||||
collection: string;
|
||||
onDelete?: (id: string) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
showLocale?: boolean;
|
||||
}
|
||||
|
||||
function ContentListItem({
|
||||
item,
|
||||
collection,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
showLocale,
|
||||
}: ContentListItemProps) {
|
||||
const title = getItemTitle(item);
|
||||
const date = new Date(item.updatedAt || item.createdAt);
|
||||
|
||||
return (
|
||||
<tr className="border-b hover:bg-kumo-tint/25">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to="/content/$collection/$id"
|
||||
params={{ collection, id: item.id }}
|
||||
className="font-medium hover:text-kumo-brand"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge
|
||||
status={item.status}
|
||||
hasPendingChanges={!!item.draftRevisionId && item.draftRevisionId !== item.liveRevisionId}
|
||||
/>
|
||||
</td>
|
||||
{showLocale && (
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-kumo-tint rounded px-1.5 py-0.5 text-xs font-semibold uppercase">
|
||||
{item.locale}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-sm text-kumo-subtle">{date.toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
<Link
|
||||
to="/content/$collection/$id"
|
||||
params={{ collection, id: item.id }}
|
||||
aria-label={`Edit ${title}`}
|
||||
className={buttonVariants({ variant: "ghost", shape: "square" })}
|
||||
>
|
||||
<Pencil className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label={`Duplicate ${title}`}
|
||||
onClick={() => onDuplicate?.(item.id)}
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Dialog.Root disablePointerDismissal>
|
||||
<Dialog.Trigger
|
||||
render={(p) => (
|
||||
<Button {...p} variant="ghost" shape="square" aria-label={`Move ${title} to trash`}>
|
||||
<Trash className="h-4 w-4 text-kumo-danger" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog className="p-6" size="sm">
|
||||
<Dialog.Title className="text-lg font-semibold">Move to Trash?</Dialog.Title>
|
||||
<Dialog.Description className="text-kumo-subtle">
|
||||
Move "{title}" to trash? You can restore it later.
|
||||
</Dialog.Description>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Dialog.Close
|
||||
render={(p) => (
|
||||
<Button {...p} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog.Close
|
||||
render={(p) => (
|
||||
<Button {...p} variant="destructive" onClick={() => onDelete?.(item.id)}>
|
||||
Move to Trash
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrashedListItemProps {
|
||||
item: TrashedContentItem;
|
||||
onRestore?: (id: string) => void;
|
||||
onPermanentDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
function TrashedListItem({ item, onRestore, onPermanentDelete }: TrashedListItemProps) {
|
||||
const title = getItemTitle(item);
|
||||
const deletedDate = new Date(item.deletedAt);
|
||||
|
||||
return (
|
||||
<tr className="border-b hover:bg-kumo-tint/25">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-kumo-subtle">{title}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-kumo-subtle">{deletedDate.toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label={`Restore ${title}`}
|
||||
onClick={() => onRestore?.(item.id)}
|
||||
>
|
||||
<ArrowCounterClockwise className="h-4 w-4 text-kumo-brand" aria-hidden="true" />
|
||||
</Button>
|
||||
<Dialog.Root disablePointerDismissal>
|
||||
<Dialog.Trigger
|
||||
render={(p) => (
|
||||
<Button
|
||||
{...p}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label={`Permanently delete ${title}`}
|
||||
>
|
||||
<Trash className="h-4 w-4 text-kumo-danger" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog className="p-6" size="sm">
|
||||
<Dialog.Title className="text-lg font-semibold">Delete Permanently?</Dialog.Title>
|
||||
<Dialog.Description className="text-kumo-subtle">
|
||||
Permanently delete "{title}"? This cannot be undone.
|
||||
</Dialog.Description>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Dialog.Close
|
||||
render={(p) => (
|
||||
<Button {...p} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog.Close
|
||||
render={(p) => (
|
||||
<Button
|
||||
{...p}
|
||||
variant="destructive"
|
||||
onClick={() => onPermanentDelete?.(item.id)}
|
||||
>
|
||||
Delete Permanently
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
hasPendingChanges,
|
||||
}: {
|
||||
status: string;
|
||||
hasPendingChanges?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-1 text-xs font-medium",
|
||||
status === "published" &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
status === "draft" &&
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
status === "scheduled" &&
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
status === "archived" && "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
{hasPendingChanges && <Badge variant="secondary">pending</Badge>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
257
packages/admin/src/components/ContentPickerModal.tsx
Normal file
257
packages/admin/src/components/ContentPickerModal.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Content Picker Modal
|
||||
*
|
||||
* A modal for browsing and selecting content items to add to menus.
|
||||
* Uses cursor pagination to allow browsing beyond the initial page.
|
||||
*/
|
||||
|
||||
import { Button, Dialog, Input, Loader } from "@cloudflare/kumo";
|
||||
import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api";
|
||||
import type { ContentItem } from "../lib/api";
|
||||
import { useDebouncedValue } from "../lib/hooks";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ContentPickerModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (item: { collection: string; id: string; title: string }) => void;
|
||||
}
|
||||
|
||||
function getItemTitle(item: { data: Record<string, unknown>; slug: string | null; id: string }) {
|
||||
const rawTitle = item.data.title;
|
||||
const rawName = item.data.name;
|
||||
return (
|
||||
(typeof rawTitle === "string" ? rawTitle : "") ||
|
||||
(typeof rawName === "string" ? rawName : "") ||
|
||||
item.slug ||
|
||||
item.id
|
||||
);
|
||||
}
|
||||
|
||||
export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPickerModalProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
||||
const [selectedCollection, setSelectedCollection] = React.useState<string>("");
|
||||
const [allItems, setAllItems] = React.useState<ContentItem[]>([]);
|
||||
const [nextCursor, setNextCursor] = React.useState<string | undefined>();
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
|
||||
const { data: collections = [] } = useQuery({
|
||||
queryKey: ["collections"],
|
||||
queryFn: fetchCollections,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Default to first collection when collections load
|
||||
React.useEffect(() => {
|
||||
if (collections.length > 0 && !selectedCollection) {
|
||||
setSelectedCollection(collections[0]!.slug);
|
||||
}
|
||||
}, [collections, selectedCollection]);
|
||||
|
||||
const { data: contentResult, isLoading: contentLoading } = useQuery({
|
||||
queryKey: ["content-picker", selectedCollection, { limit: 50 }],
|
||||
queryFn: () => fetchContentList(selectedCollection, { limit: 50 }),
|
||||
enabled: open && !!selectedCollection,
|
||||
});
|
||||
|
||||
// Sync initial page into accumulated items
|
||||
React.useEffect(() => {
|
||||
if (contentResult) {
|
||||
setAllItems(contentResult.items);
|
||||
setNextCursor(contentResult.nextCursor);
|
||||
}
|
||||
}, [contentResult]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!nextCursor || isLoadingMore) return;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const result = await fetchContentList(selectedCollection, {
|
||||
limit: 50,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
setAllItems((prev) => [...prev, ...result.items]);
|
||||
setNextCursor(result.nextCursor);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (!debouncedSearch) return allItems;
|
||||
const query = debouncedSearch.toLowerCase();
|
||||
return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query));
|
||||
}, [allItems, debouncedSearch]);
|
||||
|
||||
// Reset state when modal opens or collection changes
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setSearchQuery("");
|
||||
setSelectedCollection("");
|
||||
setAllItems([]);
|
||||
setNextCursor(undefined);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (item: ContentItem) => {
|
||||
onSelect({
|
||||
collection: selectedCollection,
|
||||
id: item.id,
|
||||
title: getItemTitle(item),
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog className="p-6 w-2xl h-[80vh] flex flex-col" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
Select Content
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search and collection filter */}
|
||||
<div className="flex items-center gap-4 py-4 border-b">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
||||
<Input
|
||||
placeholder="Search content..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedCollection}
|
||||
onChange={(e) => {
|
||||
setSelectedCollection(e.target.value);
|
||||
setAllItems([]);
|
||||
setNextCursor(undefined);
|
||||
}}
|
||||
className="h-10 rounded-md border border-kumo-line bg-kumo-base px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2"
|
||||
>
|
||||
{collections.map((col) => (
|
||||
<option key={col.slug} value={col.slug}>
|
||||
{col.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Content list */}
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{contentLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-kumo-subtle">Loading content...</div>
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<MagnifyingGlass className="h-8 w-8 text-kumo-subtle mb-2" />
|
||||
<p className="text-kumo-subtle">No content found</p>
|
||||
<p className="text-sm text-kumo-subtle">Try adjusting your search</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FolderOpen className="h-8 w-8 text-kumo-subtle mb-2" />
|
||||
<p className="text-kumo-subtle">No content in this collection</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredItems.map((item) => {
|
||||
const status = getDraftStatus(item);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(item)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-3 py-2 transition-colors",
|
||||
"hover:bg-kumo-tint/50",
|
||||
"focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2",
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">{getItemTitle(item)}</div>
|
||||
<div className="text-sm text-kumo-subtle flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
status === "published"
|
||||
? "bg-green-500"
|
||||
: status === "published_with_changes"
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-400",
|
||||
)}
|
||||
/>
|
||||
{status === "published"
|
||||
? "Published"
|
||||
: status === "published_with_changes"
|
||||
? "Modified"
|
||||
: "Draft"}
|
||||
{item.slug && (
|
||||
<>
|
||||
<span className="text-kumo-subtle/50">/</span>
|
||||
<span>{item.slug}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{nextCursor && !searchQuery && (
|
||||
<div className="pt-2 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader size="sm" /> Loading...
|
||||
</>
|
||||
) : (
|
||||
"Load more"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
687
packages/admin/src/components/ContentTypeEditor.tsx
Normal file
687
packages/admin/src/components/ContentTypeEditor.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import { Badge, Button, Input, InputArea, Label, Select, buttonVariants } from "@cloudflare/kumo";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
DotsSixVertical,
|
||||
Pencil,
|
||||
Trash,
|
||||
Database,
|
||||
FileText,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
SchemaCollectionWithFields,
|
||||
SchemaField,
|
||||
CreateFieldInput,
|
||||
CreateCollectionInput,
|
||||
UpdateCollectionInput,
|
||||
} from "../lib/api";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { FieldEditor } from "./FieldEditor";
|
||||
|
||||
// Regex patterns for slug generation
|
||||
const SLUG_INVALID_CHARS_PATTERN = /[^a-z0-9]+/g;
|
||||
const SLUG_LEADING_TRAILING_PATTERN = /^_|_$/g;
|
||||
|
||||
export interface ContentTypeEditorProps {
|
||||
collection?: SchemaCollectionWithFields;
|
||||
isNew?: boolean;
|
||||
isSaving?: boolean;
|
||||
onSave: (input: CreateCollectionInput | UpdateCollectionInput) => void;
|
||||
onAddField?: (input: CreateFieldInput) => void;
|
||||
onUpdateField?: (fieldSlug: string, input: CreateFieldInput) => void;
|
||||
onDeleteField?: (fieldSlug: string) => void;
|
||||
onReorderFields?: (fieldSlugs: string[]) => void;
|
||||
}
|
||||
|
||||
const SUPPORT_OPTIONS = [
|
||||
{
|
||||
value: "drafts",
|
||||
label: "Drafts",
|
||||
description: "Save content as draft before publishing",
|
||||
},
|
||||
{
|
||||
value: "revisions",
|
||||
label: "Revisions",
|
||||
description: "Track content history",
|
||||
},
|
||||
{
|
||||
value: "preview",
|
||||
label: "Preview",
|
||||
description: "Preview content before publishing",
|
||||
},
|
||||
{
|
||||
value: "search",
|
||||
label: "Search",
|
||||
description: "Enable full-text search on this collection",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* System fields that exist on every collection
|
||||
* These are created automatically and cannot be modified
|
||||
*/
|
||||
const SYSTEM_FIELDS = [
|
||||
{
|
||||
slug: "id",
|
||||
label: "ID",
|
||||
type: "text",
|
||||
description: "Unique identifier (ULID)",
|
||||
},
|
||||
{
|
||||
slug: "slug",
|
||||
label: "Slug",
|
||||
type: "text",
|
||||
description: "URL-friendly identifier",
|
||||
},
|
||||
{
|
||||
slug: "status",
|
||||
label: "Status",
|
||||
type: "text",
|
||||
description: "draft, published, or archived",
|
||||
},
|
||||
{
|
||||
slug: "created_at",
|
||||
label: "Created At",
|
||||
type: "datetime",
|
||||
description: "When the entry was created",
|
||||
},
|
||||
{
|
||||
slug: "updated_at",
|
||||
label: "Updated At",
|
||||
type: "datetime",
|
||||
description: "When the entry was last modified",
|
||||
},
|
||||
{
|
||||
slug: "published_at",
|
||||
label: "Published At",
|
||||
type: "datetime",
|
||||
description: "When the entry was published",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Content Type editor for creating/editing collections
|
||||
*/
|
||||
export function ContentTypeEditor({
|
||||
collection,
|
||||
isNew,
|
||||
isSaving,
|
||||
onSave,
|
||||
onAddField,
|
||||
onUpdateField,
|
||||
onDeleteField,
|
||||
onReorderFields: _onReorderFields,
|
||||
}: ContentTypeEditorProps) {
|
||||
const _navigate = useNavigate();
|
||||
|
||||
// Form state
|
||||
const [slug, setSlug] = React.useState(collection?.slug ?? "");
|
||||
const [label, setLabel] = React.useState(collection?.label ?? "");
|
||||
const [labelSingular, setLabelSingular] = React.useState(collection?.labelSingular ?? "");
|
||||
const [description, setDescription] = React.useState(collection?.description ?? "");
|
||||
const [urlPattern, setUrlPattern] = React.useState(collection?.urlPattern ?? "");
|
||||
const [supports, setSupports] = React.useState<string[]>(collection?.supports ?? ["drafts"]);
|
||||
|
||||
// SEO state
|
||||
const [hasSeo, setHasSeo] = React.useState(collection?.hasSeo ?? false);
|
||||
|
||||
// Comment settings state
|
||||
const [commentsEnabled, setCommentsEnabled] = React.useState(
|
||||
collection?.commentsEnabled ?? false,
|
||||
);
|
||||
const [commentsModeration, setCommentsModeration] = React.useState<"all" | "first_time" | "none">(
|
||||
collection?.commentsModeration ?? "first_time",
|
||||
);
|
||||
const [commentsClosedAfterDays, setCommentsClosedAfterDays] = React.useState(
|
||||
collection?.commentsClosedAfterDays ?? 90,
|
||||
);
|
||||
const [commentsAutoApproveUsers, setCommentsAutoApproveUsers] = React.useState(
|
||||
collection?.commentsAutoApproveUsers ?? true,
|
||||
);
|
||||
|
||||
// Field editor state
|
||||
const [fieldEditorOpen, setFieldEditorOpen] = React.useState(false);
|
||||
const [editingField, setEditingField] = React.useState<SchemaField | undefined>();
|
||||
const [fieldSaving, setFieldSaving] = React.useState(false);
|
||||
const [deleteFieldTarget, setDeleteFieldTarget] = React.useState<SchemaField | null>(null);
|
||||
|
||||
const urlPatternValid = !urlPattern || urlPattern.includes("{slug}");
|
||||
|
||||
// Track whether form has unsaved changes
|
||||
const hasChanges = React.useMemo(() => {
|
||||
if (isNew) return slug && label;
|
||||
if (!collection) return false;
|
||||
return (
|
||||
label !== collection.label ||
|
||||
labelSingular !== (collection.labelSingular ?? "") ||
|
||||
description !== (collection.description ?? "") ||
|
||||
urlPattern !== (collection.urlPattern ?? "") ||
|
||||
JSON.stringify([...supports].toSorted()) !==
|
||||
JSON.stringify([...collection.supports].toSorted()) ||
|
||||
hasSeo !== collection.hasSeo ||
|
||||
commentsEnabled !== collection.commentsEnabled ||
|
||||
commentsModeration !== collection.commentsModeration ||
|
||||
commentsClosedAfterDays !== collection.commentsClosedAfterDays ||
|
||||
commentsAutoApproveUsers !== collection.commentsAutoApproveUsers
|
||||
);
|
||||
}, [
|
||||
isNew,
|
||||
collection,
|
||||
slug,
|
||||
label,
|
||||
labelSingular,
|
||||
description,
|
||||
urlPattern,
|
||||
supports,
|
||||
hasSeo,
|
||||
commentsEnabled,
|
||||
commentsModeration,
|
||||
commentsClosedAfterDays,
|
||||
commentsAutoApproveUsers,
|
||||
]);
|
||||
|
||||
// Auto-generate slug from plural label
|
||||
const handleLabelChange = (value: string) => {
|
||||
setLabel(value);
|
||||
if (isNew) {
|
||||
setSlug(
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(SLUG_INVALID_CHARS_PATTERN, "_")
|
||||
.replace(SLUG_LEADING_TRAILING_PATTERN, ""),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-generate plural label (and slug) from singular label
|
||||
const handleSingularLabelChange = (value: string) => {
|
||||
setLabelSingular(value);
|
||||
if (isNew) {
|
||||
const plural = value ? `${value}s` : "";
|
||||
handleLabelChange(plural);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSupportToggle = (value: string) => {
|
||||
setSupports((prev) =>
|
||||
prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isNew) {
|
||||
onSave({
|
||||
slug,
|
||||
label,
|
||||
labelSingular: labelSingular || undefined,
|
||||
description: description || undefined,
|
||||
urlPattern: urlPattern || undefined,
|
||||
supports,
|
||||
hasSeo,
|
||||
});
|
||||
} else {
|
||||
onSave({
|
||||
label,
|
||||
labelSingular: labelSingular || undefined,
|
||||
description: description || undefined,
|
||||
urlPattern: urlPattern || undefined,
|
||||
supports,
|
||||
hasSeo,
|
||||
commentsEnabled,
|
||||
commentsModeration,
|
||||
commentsClosedAfterDays,
|
||||
commentsAutoApproveUsers,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldSave = async (input: CreateFieldInput) => {
|
||||
setFieldSaving(true);
|
||||
try {
|
||||
if (editingField) {
|
||||
onUpdateField?.(editingField.slug, input);
|
||||
} else {
|
||||
onAddField?.(input);
|
||||
}
|
||||
setFieldEditorOpen(false);
|
||||
setEditingField(undefined);
|
||||
} finally {
|
||||
setFieldSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditField = (field: SchemaField) => {
|
||||
setEditingField(field);
|
||||
setFieldEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleAddField = () => {
|
||||
setEditingField(undefined);
|
||||
setFieldEditorOpen(true);
|
||||
};
|
||||
|
||||
const isFromCode = collection?.source === "code";
|
||||
const fields = collection?.fields ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/content-types"
|
||||
aria-label="Back to Content Types"
|
||||
className={buttonVariants({ variant: "ghost", shape: "square" })}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{isNew ? "New Content Type" : collection?.label}</h1>
|
||||
{!isNew && (
|
||||
<p className="text-kumo-subtle text-sm">
|
||||
<code className="bg-kumo-tint px-1.5 py-0.5 rounded">{collection?.slug}</code>
|
||||
{isFromCode && (
|
||||
<span className="ml-2 text-purple-600 dark:text-purple-400">Defined in code</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFromCode && (
|
||||
<div className="rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-950 p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||
This collection is defined in code. Some settings cannot be changed here. Edit your
|
||||
live.config.ts file to modify the schema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Settings form */}
|
||||
<div className="lg:col-span-1">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<h2 className="font-semibold">Settings</h2>
|
||||
|
||||
<Input
|
||||
label="Label (Singular)"
|
||||
value={labelSingular}
|
||||
onChange={(e) => handleSingularLabelChange(e.target.value)}
|
||||
placeholder="Post"
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Label (Plural)"
|
||||
value={label}
|
||||
onChange={(e) => handleLabelChange(e.target.value)}
|
||||
placeholder="Posts"
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
|
||||
{isNew && (
|
||||
<div>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="posts"
|
||||
disabled={!isNew}
|
||||
/>
|
||||
<p className="text-xs text-kumo-subtle mt-2">Used in URLs and API endpoints</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputArea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="A brief description of this content type"
|
||||
rows={3}
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="URL Pattern"
|
||||
value={urlPattern}
|
||||
onChange={(e) => setUrlPattern(e.target.value)}
|
||||
placeholder={`/${slug === "pages" ? "" : `${slug}/`}{slug}`}
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
{urlPattern && !urlPattern.includes("{slug}") && (
|
||||
<p className="text-xs text-kumo-danger mt-2">
|
||||
Pattern must include a {"{slug}"} placeholder
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-kumo-subtle mt-1">
|
||||
Pattern for generating URLs, e.g. /blog/{"{slug}"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Features</Label>
|
||||
{SUPPORT_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-2 rounded-md cursor-pointer hover:bg-kumo-tint/50",
|
||||
isFromCode && "opacity-60 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={supports.includes(option.value)}
|
||||
onChange={() => handleSupportToggle(option.value)}
|
||||
className="mt-1 rounded border-kumo-line"
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{option.label}</span>
|
||||
<p className="text-xs text-kumo-subtle">{option.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SEO toggle */}
|
||||
<div className="pt-2 border-t">
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-2 rounded-md cursor-pointer hover:bg-kumo-tint/50",
|
||||
isFromCode && "opacity-60 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasSeo}
|
||||
onChange={() => setHasSeo(!hasSeo)}
|
||||
className="mt-1 rounded border-kumo-line"
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">SEO</span>
|
||||
<p className="text-xs text-kumo-subtle">
|
||||
Add SEO metadata fields (title, description, image) and include in sitemap
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments settings — only for existing collections */}
|
||||
{!isNew && (
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<h2 className="font-semibold">Comments</h2>
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-2 rounded-md cursor-pointer hover:bg-kumo-tint/50",
|
||||
isFromCode && "opacity-60 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commentsEnabled}
|
||||
onChange={() => setCommentsEnabled(!commentsEnabled)}
|
||||
className="mt-1 rounded border-kumo-line"
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Enable comments</span>
|
||||
<p className="text-xs text-kumo-subtle">
|
||||
Allow visitors to leave comments on this collection's content
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{commentsEnabled && (
|
||||
<>
|
||||
<Select
|
||||
label="Moderation"
|
||||
value={commentsModeration}
|
||||
onValueChange={(v) =>
|
||||
setCommentsModeration((v as "all" | "first_time" | "none") ?? "first_time")
|
||||
}
|
||||
items={{
|
||||
all: "All comments require approval",
|
||||
first_time: "First-time commenters only",
|
||||
none: "No moderation (auto-approve all)",
|
||||
}}
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Close comments after (days)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={String(commentsClosedAfterDays)}
|
||||
onChange={(e) => {
|
||||
const parsed = Number.parseInt(e.target.value, 10);
|
||||
setCommentsClosedAfterDays(Number.isNaN(parsed) ? 0 : Math.max(0, parsed));
|
||||
}}
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
<p className="text-xs text-kumo-subtle -mt-2">
|
||||
Set to 0 to never close comments automatically.
|
||||
</p>
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-2 rounded-md cursor-pointer hover:bg-kumo-tint/50",
|
||||
isFromCode && "opacity-60 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commentsAutoApproveUsers}
|
||||
onChange={() => setCommentsAutoApproveUsers(!commentsAutoApproveUsers)}
|
||||
className="mt-1 rounded border-kumo-line"
|
||||
disabled={isFromCode}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
Auto-approve authenticated users
|
||||
</span>
|
||||
<p className="text-xs text-kumo-subtle">
|
||||
Comments from logged-in CMS users are approved automatically
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFromCode && (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!hasChanges || !urlPatternValid || isSaving}
|
||||
className="w-full"
|
||||
>
|
||||
{isSaving ? "Saving..." : isNew ? "Create Content Type" : "Save Changes"}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Fields section - only show for existing collections */}
|
||||
{!isNew && (
|
||||
<div className="lg:col-span-2">
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h2 className="font-semibold">Fields</h2>
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
{SYSTEM_FIELDS.length} system + {fields.length} custom field
|
||||
{fields.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
{!isFromCode && (
|
||||
<Button icon={<Plus />} onClick={handleAddField}>
|
||||
Add Field
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System fields - always shown */}
|
||||
<div className="border-b bg-kumo-tint/30">
|
||||
<div className="px-4 py-2 text-xs font-medium text-kumo-subtle uppercase tracking-wider">
|
||||
System Fields
|
||||
</div>
|
||||
<div className="divide-y divide-kumo-line/50">
|
||||
{SYSTEM_FIELDS.map((field) => (
|
||||
<SystemFieldRow key={field.slug} field={field} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
{fields.length === 0 ? (
|
||||
<div className="p-8 text-center text-kumo-subtle">
|
||||
<Database className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="font-medium">No custom fields yet</p>
|
||||
<p className="text-sm">Add fields to define the structure of your content</p>
|
||||
{!isFromCode && (
|
||||
<Button className="mt-4" icon={<Plus />} onClick={handleAddField}>
|
||||
Add First Field
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 py-2 text-xs font-medium text-kumo-subtle uppercase tracking-wider border-b">
|
||||
Custom Fields
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{fields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
field={field}
|
||||
isFromCode={isFromCode}
|
||||
onEdit={() => handleEditField(field)}
|
||||
onDelete={() => setDeleteFieldTarget(field)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field editor dialog */}
|
||||
<FieldEditor
|
||||
open={fieldEditorOpen}
|
||||
onOpenChange={setFieldEditorOpen}
|
||||
field={editingField}
|
||||
onSave={handleFieldSave}
|
||||
isSaving={fieldSaving}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteFieldTarget}
|
||||
onClose={() => setDeleteFieldTarget(null)}
|
||||
title="Delete Field?"
|
||||
description={
|
||||
deleteFieldTarget
|
||||
? `Are you sure you want to delete the "${deleteFieldTarget.label}" field?`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={false}
|
||||
error={null}
|
||||
onConfirm={() => {
|
||||
if (deleteFieldTarget) {
|
||||
onDeleteField?.(deleteFieldTarget.slug);
|
||||
setDeleteFieldTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldRowProps {
|
||||
field: SchemaField;
|
||||
isFromCode?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function FieldRow({ field, isFromCode, onEdit, onDelete }: FieldRowProps) {
|
||||
return (
|
||||
<div className="flex items-center px-4 py-3 hover:bg-kumo-tint/25">
|
||||
{!isFromCode && <DotsSixVertical className="h-5 w-5 mr-3 text-kumo-subtle cursor-grab" />}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{field.label}</span>
|
||||
<code className="text-xs bg-kumo-tint px-1.5 py-0.5 rounded text-kumo-subtle">
|
||||
{field.slug}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className="text-xs text-kumo-subtle capitalize">{field.type}</span>
|
||||
{field.required && <Badge variant="secondary">Required</Badge>}
|
||||
{field.unique && <Badge variant="secondary">Unique</Badge>}
|
||||
{field.searchable && <Badge variant="secondary">Searchable</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
{!isFromCode && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
onClick={onEdit}
|
||||
aria-label={`Edit ${field.label} field`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete ${field.label} field`}
|
||||
>
|
||||
<Trash className="h-4 w-4 text-kumo-danger" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SystemFieldInfo {
|
||||
slug: string;
|
||||
label: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function SystemFieldRow({ field }: { field: SystemFieldInfo }) {
|
||||
return (
|
||||
<div className="flex items-center px-4 py-2 opacity-75">
|
||||
<div className="w-8" /> {/* Spacer for alignment with draggable fields */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-sm">{field.label}</span>
|
||||
<code className="text-xs bg-kumo-tint px-1.5 py-0.5 rounded text-kumo-subtle">
|
||||
{field.slug}
|
||||
</code>
|
||||
<Badge variant="secondary">System</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-kumo-subtle mt-0.5">{field.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
packages/admin/src/components/ContentTypeList.tsx
Normal file
243
packages/admin/src/components/ContentTypeList.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Badge, Button, buttonVariants } from "@cloudflare/kumo";
|
||||
import { Plus, Pencil, Trash, Database, FileText, Warning, Check } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import type { SchemaCollection, OrphanedTable } from "../lib/api";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
export interface ContentTypeListProps {
|
||||
collections: SchemaCollection[];
|
||||
orphanedTables?: OrphanedTable[];
|
||||
isLoading?: boolean;
|
||||
onDelete?: (slug: string) => void;
|
||||
onRegisterOrphan?: (slug: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Type list view - shows all collections in the schema registry
|
||||
*/
|
||||
export function ContentTypeList({
|
||||
collections,
|
||||
orphanedTables,
|
||||
isLoading,
|
||||
onDelete,
|
||||
onRegisterOrphan,
|
||||
}: ContentTypeListProps) {
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<SchemaCollection | null>(null);
|
||||
const hasOrphans = orphanedTables && orphanedTables.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Content Types</h1>
|
||||
<p className="text-kumo-subtle text-sm">Define the structure of your content</p>
|
||||
</div>
|
||||
<Link to="/content-types/new" className={buttonVariants()}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
New Content Type
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orphaned Tables Warning */}
|
||||
{hasOrphans && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Warning className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-amber-800 dark:text-amber-200">
|
||||
Unregistered Content Tables Found
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
The following tables contain content but aren't registered as collections. Register
|
||||
them to manage this content in the admin.
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{orphanedTables.map((orphan) => (
|
||||
<div
|
||||
key={orphan.slug}
|
||||
className="flex items-center justify-between bg-white dark:bg-amber-900/50 rounded-md px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<code className="text-sm font-medium">{orphan.slug}</code>
|
||||
<span className="text-xs text-kumo-subtle ml-2">
|
||||
({orphan.rowCount} items)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={<Check />}
|
||||
onClick={() => onRegisterOrphan?.(orphan.slug)}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-kumo-tint/50">
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Slug
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Source
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Features
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle">
|
||||
Loading collections...
|
||||
</td>
|
||||
</tr>
|
||||
) : collections.length === 0 && !hasOrphans ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle">
|
||||
No content types yet.{" "}
|
||||
<Link to="/content-types/new" className="text-kumo-brand underline">
|
||||
Create your first one
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
collections.map((collection) => (
|
||||
<ContentTypeRow
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onRequestDelete={setDeleteTarget}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Delete Content Type?"
|
||||
description={
|
||||
deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.label}"? This will also delete all content in this collection.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={false}
|
||||
error={null}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
onDelete?.(deleteTarget.slug);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContentTypeRowProps {
|
||||
collection: SchemaCollection;
|
||||
onRequestDelete?: (collection: SchemaCollection) => void;
|
||||
}
|
||||
|
||||
function ContentTypeRow({ collection, onRequestDelete }: ContentTypeRowProps) {
|
||||
const isFromCode = collection.source === "code";
|
||||
|
||||
return (
|
||||
<tr className="border-b hover:bg-kumo-tint/25">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg",
|
||||
isFromCode
|
||||
? "bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300"
|
||||
: "bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300",
|
||||
)}
|
||||
>
|
||||
{isFromCode ? <FileText className="h-4 w-4" /> : <Database className="h-4 w-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
to="/content-types/$slug"
|
||||
params={{ slug: collection.slug }}
|
||||
className="font-medium hover:text-kumo-brand"
|
||||
>
|
||||
{collection.label}
|
||||
</Link>
|
||||
{collection.description && (
|
||||
<p className="text-xs text-kumo-subtle">{collection.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm bg-kumo-tint px-1.5 py-0.5 rounded">{collection.slug}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<SourceBadge source={collection.source} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{collection.supports.map((feature) => (
|
||||
<Badge key={feature} variant="secondary">
|
||||
{feature}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
<Link
|
||||
to="/content-types/$slug"
|
||||
params={{ slug: collection.slug }}
|
||||
aria-label={`Edit ${collection.label}`}
|
||||
className={buttonVariants({ variant: "ghost", shape: "square" })}
|
||||
>
|
||||
<Pencil className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{!isFromCode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label={`Delete ${collection.label}`}
|
||||
onClick={() => onRequestDelete?.(collection)}
|
||||
>
|
||||
<Trash className="h-4 w-4 text-kumo-danger" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (source === "code") {
|
||||
return <Badge variant="secondary">Code</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">Dashboard</Badge>;
|
||||
}
|
||||
328
packages/admin/src/components/Dashboard.tsx
Normal file
328
packages/admin/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import {
|
||||
Plus,
|
||||
Upload,
|
||||
ArrowRight,
|
||||
CircleDashed,
|
||||
CheckCircle,
|
||||
PencilSimple,
|
||||
CalendarBlank,
|
||||
Image,
|
||||
Users,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import type { AdminManifest } from "../lib/api";
|
||||
import type { CollectionStats, DashboardStats, RecentItem } from "../lib/api/dashboard";
|
||||
import { fetchDashboardStats } from "../lib/api/dashboard";
|
||||
import { usePluginWidget } from "../lib/plugin-context";
|
||||
import { formatRelativeTime } from "../lib/utils";
|
||||
import { SandboxedPluginWidget } from "./SandboxedPluginWidget";
|
||||
|
||||
export interface DashboardProps {
|
||||
manifest: AdminManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin dashboard — quick actions, status, collections, recent activity.
|
||||
*/
|
||||
export function Dashboard({ manifest }: DashboardProps) {
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ["dashboard-stats"],
|
||||
queryFn: fetchDashboardStats,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<QuickActions manifest={manifest} />
|
||||
</div>
|
||||
|
||||
<StatusBar stats={stats} loading={isLoading} />
|
||||
|
||||
{/* Collections + Recent activity */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<CollectionList
|
||||
collections={stats?.collections ?? []}
|
||||
manifest={manifest}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<RecentActivity items={stats?.recentItems ?? []} loading={isLoading} />
|
||||
</div>
|
||||
|
||||
{/* Plugin widgets */}
|
||||
<PluginWidgets manifest={manifest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Quick actions ---
|
||||
|
||||
function QuickActions({ manifest }: { manifest: AdminManifest }) {
|
||||
const collections = Object.entries(manifest.collections);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{collections.map(([slug, config]) => (
|
||||
<Link
|
||||
key={slug}
|
||||
to="/content/$collection"
|
||||
params={{ collection: slug }}
|
||||
search={{ locale: undefined }}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border bg-kumo-base px-3 py-1.5 text-sm font-medium transition-colors hover:bg-kumo-tint"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{config.labelSingular ?? config.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
to="/media"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border bg-kumo-base px-3 py-1.5 text-sm font-medium transition-colors hover:bg-kumo-tint"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Upload Media
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Status bar ---
|
||||
|
||||
function StatusBar({ stats, loading }: { stats?: DashboardStats; loading: boolean }) {
|
||||
if (loading) {
|
||||
return <div className="flex h-9 animate-pulse rounded-lg border bg-kumo-tint" />;
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const totalDrafts = stats.collections.reduce((sum, c) => sum + c.draft, 0);
|
||||
const totalScheduled = stats.collections.reduce(
|
||||
(sum, c) => sum + (c.total - c.published - c.draft),
|
||||
0,
|
||||
);
|
||||
|
||||
const indicators = [
|
||||
totalDrafts > 0 && {
|
||||
icon: PencilSimple,
|
||||
label: `${totalDrafts} draft${totalDrafts !== 1 ? "s" : ""}`,
|
||||
className: "text-amber-700 dark:text-amber-400",
|
||||
},
|
||||
totalScheduled > 0 && {
|
||||
icon: CalendarBlank,
|
||||
label: `${totalScheduled} scheduled`,
|
||||
className: "text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
label: `${stats.mediaCount} media`,
|
||||
className: "text-kumo-subtle",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
label: `${stats.userCount} user${stats.userCount !== 1 ? "s" : ""}`,
|
||||
className: "text-kumo-subtle",
|
||||
},
|
||||
].filter(Boolean) as Array<{
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
className: string;
|
||||
}>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border bg-kumo-base px-4 py-2 text-sm">
|
||||
{indicators.map((ind) => (
|
||||
<span key={ind.label} className={`inline-flex items-center gap-1.5 ${ind.className}`}>
|
||||
<ind.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{ind.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Collection list with counts ---
|
||||
|
||||
function CollectionList({
|
||||
collections,
|
||||
manifest,
|
||||
loading,
|
||||
}: {
|
||||
collections: CollectionStats[];
|
||||
manifest: AdminManifest;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-kumo-base p-4 sm:p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">Content</h2>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-10 animate-pulse rounded-md bg-kumo-tint" />
|
||||
))}
|
||||
</div>
|
||||
) : collections.length === 0 ? (
|
||||
<p className="text-sm text-kumo-subtle">No collections configured</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{collections.map((col) => {
|
||||
const config = manifest.collections[col.slug];
|
||||
return (
|
||||
<Link
|
||||
key={col.slug}
|
||||
to="/content/$collection"
|
||||
params={{ collection: col.slug }}
|
||||
search={{ locale: undefined }}
|
||||
className="group flex items-center justify-between rounded-md px-3 py-2 hover:bg-kumo-tint"
|
||||
>
|
||||
<span className="font-medium">{config?.label ?? col.label}</span>
|
||||
<span className="flex items-center gap-3 text-xs text-kumo-subtle">
|
||||
<CountBadge icon={CheckCircle} count={col.published} title="Published" />
|
||||
<CountBadge icon={PencilSimple} count={col.draft} title="Drafts" />
|
||||
<ArrowRight
|
||||
className="h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountBadge({
|
||||
icon: Icon,
|
||||
count,
|
||||
title,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
count: number;
|
||||
title: string;
|
||||
}) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1" title={title}>
|
||||
<Icon className="h-3 w-3" aria-hidden="true" />
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Recent activity ---
|
||||
|
||||
function RecentActivity({ items, loading }: { items: RecentItem[]; loading: boolean }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-kumo-base p-4 sm:p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">Recent Activity</h2>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-10 animate-pulse rounded-md bg-kumo-tint" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-kumo-subtle">No recent activity</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={`${item.collection}-${item.id}`}
|
||||
to="/content/$collection/$id"
|
||||
params={{ collection: item.collection, id: item.id }}
|
||||
className="group flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-kumo-tint"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<StatusDot status={item.status} />
|
||||
<span className="truncate font-medium">
|
||||
{item.title || item.slug || "Untitled"}
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs text-kumo-subtle sm:inline">
|
||||
{item.collectionLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-kumo-subtle">
|
||||
{formatRelativeTime(item.updatedAt)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
published: "text-green-500",
|
||||
draft: "text-amber-500",
|
||||
scheduled: "text-blue-500",
|
||||
};
|
||||
const Icon = status === "published" ? CheckCircle : CircleDashed;
|
||||
return (
|
||||
<Icon
|
||||
className={`h-3.5 w-3.5 shrink-0 ${colors[status] ?? "text-kumo-subtle"}`}
|
||||
aria-label={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Plugin widgets ---
|
||||
|
||||
function PluginWidgets({ manifest }: { manifest: AdminManifest }) {
|
||||
const widgets: Array<{
|
||||
id: string;
|
||||
pluginId: string;
|
||||
title?: string;
|
||||
size?: "full" | "half" | "third";
|
||||
}> = [];
|
||||
|
||||
for (const [pluginId, plugin] of Object.entries(manifest.plugins || {})) {
|
||||
if (plugin.enabled === false) continue;
|
||||
|
||||
if ("dashboardWidgets" in plugin && Array.isArray(plugin.dashboardWidgets)) {
|
||||
for (const widget of plugin.dashboardWidgets) {
|
||||
widgets.push({
|
||||
id: widget.id,
|
||||
pluginId,
|
||||
title: widget.title,
|
||||
size: widget.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (widgets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{widgets.map((widget) => (
|
||||
<PluginWidgetCard key={`${widget.pluginId}:${widget.id}`} widget={widget} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginWidgetCard({
|
||||
widget,
|
||||
}: {
|
||||
widget: { id: string; pluginId: string; title?: string; size?: string };
|
||||
}) {
|
||||
const WidgetComponent = usePluginWidget(widget.pluginId, widget.id);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-kumo-base p-4 sm:p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">{widget.title || widget.id}</h2>
|
||||
{WidgetComponent ? (
|
||||
<WidgetComponent />
|
||||
) : (
|
||||
<SandboxedPluginWidget pluginId={widget.pluginId} widgetId={widget.id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
331
packages/admin/src/components/DeviceAuthorizePage.tsx
Normal file
331
packages/admin/src/components/DeviceAuthorizePage.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Device Authorization Page
|
||||
*
|
||||
* Standalone page where users enter the code displayed by `emdash login`
|
||||
* to authorize a CLI or agent to access their account.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User runs `emdash login` → sees a code like ABCD-1234
|
||||
* 2. User opens this page in their browser (already logged in)
|
||||
* 3. User enters the code → clicks Authorize
|
||||
* 4. CLI receives tokens and saves them
|
||||
*/
|
||||
|
||||
import { Button, Input } from "@cloudflare/kumo";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { apiFetch, API_BASE, parseApiResponse } from "../lib/api";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: number;
|
||||
}
|
||||
|
||||
type PageState = "input" | "submitting" | "success" | "denied" | "error";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const ROLE_NAMES: Record<number, string> = {
|
||||
10: "Subscriber",
|
||||
20: "Contributor",
|
||||
30: "Author",
|
||||
40: "Editor",
|
||||
50: "Admin",
|
||||
};
|
||||
|
||||
const DEVICE_CODE_INVALID_CHARS_REGEX = /[^A-Z0-9-]/g;
|
||||
const DEVICE_CODE_HYPHEN_REGEX = /-/g;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function DeviceAuthorizePage() {
|
||||
const [code, setCode] = React.useState("");
|
||||
const [pageState, setPageState] = React.useState<PageState>("input");
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
|
||||
// Check if user is logged in
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
error: authError,
|
||||
} = useQuery<UserInfo>({
|
||||
queryKey: ["auth-me"],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`${API_BASE}/auth/me`);
|
||||
return parseApiResponse<UserInfo>(res, "Not authenticated");
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Pre-populate from URL query param (?code=ABCD-1234)
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlCode = params.get("code");
|
||||
if (urlCode) {
|
||||
setCode(urlCode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Not authenticated — redirect to login
|
||||
React.useEffect(() => {
|
||||
if (!isLoading && (authError || !user)) {
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.href = `/_emdash/admin/login?redirect=${returnUrl}`;
|
||||
}
|
||||
}, [isLoading, authError, user]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setPageState("submitting");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`${API_BASE}/oauth/device/authorize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_code: trimmed, action: "approve" }),
|
||||
});
|
||||
|
||||
const data = await parseApiResponse<{ authorized: boolean }>(res, "Authorization failed");
|
||||
setPageState(data.authorized ? "success" : "denied");
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : "Network error");
|
||||
setPageState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeny(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setPageState("submitting");
|
||||
|
||||
try {
|
||||
await apiFetch(`${API_BASE}/oauth/device/authorize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_code: trimmed, action: "deny" }),
|
||||
});
|
||||
setPageState("denied");
|
||||
} catch {
|
||||
setPageState("denied");
|
||||
}
|
||||
}
|
||||
|
||||
// Format code as user types (insert hyphen after 4 chars)
|
||||
function handleCodeChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
let value = e.target.value.toUpperCase().replace(DEVICE_CODE_INVALID_CHARS_REGEX, "");
|
||||
|
||||
// Auto-insert hyphen after 4 chars if not already present
|
||||
if (value.length === 4 && !value.includes("-")) {
|
||||
value = value + "-";
|
||||
}
|
||||
|
||||
// Limit to 9 chars (XXXX-XXXX)
|
||||
if (value.length > 9) {
|
||||
value = value.slice(0, 9);
|
||||
}
|
||||
|
||||
setCode(value);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<p className="text-kumo-subtle text-sm">Checking authentication...</p>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<p className="text-kumo-subtle text-sm">Redirecting to login...</p>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-kumo-brand/10 mb-4">
|
||||
<TerminalIcon className="w-6 h-6 text-kumo-brand" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Authorize Device</h1>
|
||||
<p className="text-kumo-subtle text-sm mt-1.5">Enter the code from your terminal</p>
|
||||
</div>
|
||||
|
||||
{/* Success state */}
|
||||
{pageState === "success" && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/50 p-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/50 mb-3">
|
||||
<CheckIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h2 className="font-medium text-green-900 dark:text-green-100">Device authorized</h2>
|
||||
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
|
||||
You can close this page and return to your terminal.
|
||||
</p>
|
||||
<p className="text-xs text-kumo-subtle mt-3">Signed in as {user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Denied state */}
|
||||
{pageState === "denied" && (
|
||||
<div className="rounded-lg border border-kumo-line p-6 text-center">
|
||||
<h2 className="font-medium">Authorization denied</h2>
|
||||
<p className="text-sm text-kumo-subtle mt-1">The device will not be granted access.</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPageState("input");
|
||||
setCode("");
|
||||
}}
|
||||
>
|
||||
Try another code
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input / Error state */}
|
||||
{(pageState === "input" || pageState === "submitting" || pageState === "error") && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="rounded-lg border border-kumo-line bg-kumo-base p-6">
|
||||
{/* User badge */}
|
||||
<div className="flex items-center gap-2 mb-5 pb-4 border-b border-kumo-line">
|
||||
<div className="w-8 h-8 rounded-full bg-kumo-tint flex items-center justify-center text-xs font-medium">
|
||||
{(user.name || user.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{user.name || user.email}</p>
|
||||
<p className="text-xs text-kumo-subtle">{ROLE_NAMES[user.role] || "User"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code input */}
|
||||
<label className="block text-sm font-medium mb-2" htmlFor="user-code">
|
||||
Device code
|
||||
</label>
|
||||
<Input
|
||||
id="user-code"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
placeholder="XXXX-XXXX"
|
||||
className="text-center text-lg font-mono tracking-widest"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={pageState === "submitting"}
|
||||
/>
|
||||
|
||||
{/* Error message */}
|
||||
{pageState === "error" && errorMessage && (
|
||||
<p className="text-sm text-kumo-danger mt-2">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={
|
||||
code.replace(DEVICE_CODE_HYPHEN_REGEX, "").length < 8 ||
|
||||
pageState === "submitting"
|
||||
}
|
||||
>
|
||||
{pageState === "submitting" ? "Authorizing..." : "Authorize"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleDeny}
|
||||
disabled={
|
||||
code.replace(DEVICE_CODE_HYPHEN_REGEX, "").length < 8 ||
|
||||
pageState === "submitting"
|
||||
}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-kumo-subtle text-center mt-4">
|
||||
This will grant CLI access with your permissions.
|
||||
<br />
|
||||
Only authorize codes you recognize.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Layout wrapper
|
||||
// ============================================================================
|
||||
|
||||
function PageWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
|
||||
<div className="w-full max-w-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Icons (inline SVG to avoid dependency on icon library for this simple page)
|
||||
// ============================================================================
|
||||
|
||||
function TerminalIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
packages/admin/src/components/DialogError.tsx
Normal file
28
packages/admin/src/components/DialogError.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Shared error display for dialogs and mutation error extraction.
|
||||
*/
|
||||
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
/** Extract a user-facing message from a mutation error value. */
|
||||
export function getMutationError(error: unknown): string | null {
|
||||
if (!error) return null;
|
||||
if (error instanceof Error) return error.message;
|
||||
return "An error occurred";
|
||||
}
|
||||
|
||||
/** Inline error banner for use inside dialogs. */
|
||||
export function DialogError({
|
||||
message,
|
||||
className,
|
||||
}: {
|
||||
message?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div className={cn("rounded-md bg-kumo-danger/10 p-3 text-sm text-kumo-danger", className)}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
480
packages/admin/src/components/FieldEditor.tsx
Normal file
480
packages/admin/src/components/FieldEditor.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import { Button, Dialog, Input, InputArea } from "@cloudflare/kumo";
|
||||
import {
|
||||
TextT,
|
||||
TextAlignLeft,
|
||||
Hash,
|
||||
ToggleLeft,
|
||||
Calendar,
|
||||
List,
|
||||
ListChecks,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
File,
|
||||
LinkSimple,
|
||||
BracketsCurly,
|
||||
Link,
|
||||
} from "@phosphor-icons/react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const SLUG_INVALID_CHARS_REGEX = /[^a-z0-9]+/g;
|
||||
const SLUG_LEADING_TRAILING_REGEX = /^_|_$/g;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface FieldEditorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
field?: SchemaField;
|
||||
onSave: (input: CreateFieldInput) => void;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
const FIELD_TYPES: {
|
||||
type: FieldType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
}[] = [
|
||||
{
|
||||
type: "string",
|
||||
label: "Short Text",
|
||||
description: "Single line text input",
|
||||
icon: TextT,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Long Text",
|
||||
description: "Multi-line plain text",
|
||||
icon: TextAlignLeft,
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
label: "Number",
|
||||
description: "Decimal number",
|
||||
icon: Hash,
|
||||
},
|
||||
{
|
||||
type: "integer",
|
||||
label: "Integer",
|
||||
description: "Whole number",
|
||||
icon: Hash,
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
label: "Boolean",
|
||||
description: "True/false toggle",
|
||||
icon: ToggleLeft,
|
||||
},
|
||||
{
|
||||
type: "datetime",
|
||||
label: "Date & Time",
|
||||
description: "Date and time picker",
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
label: "Select",
|
||||
description: "Single choice from options",
|
||||
icon: List,
|
||||
},
|
||||
{
|
||||
type: "multiSelect",
|
||||
label: "Multi Select",
|
||||
description: "Multiple choices from options",
|
||||
icon: ListChecks,
|
||||
},
|
||||
{
|
||||
type: "portableText",
|
||||
label: "Rich Text",
|
||||
description: "Rich text editor",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
label: "Image",
|
||||
description: "Image from media library",
|
||||
icon: ImageIcon,
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
label: "File",
|
||||
description: "File from media library",
|
||||
icon: File,
|
||||
},
|
||||
{
|
||||
type: "reference",
|
||||
label: "Reference",
|
||||
description: "Link to another content item",
|
||||
icon: LinkSimple,
|
||||
},
|
||||
{
|
||||
type: "json",
|
||||
label: "JSON",
|
||||
description: "Arbitrary JSON data",
|
||||
icon: BracketsCurly,
|
||||
},
|
||||
{
|
||||
type: "slug",
|
||||
label: "Slug",
|
||||
description: "URL-friendly identifier",
|
||||
icon: Link,
|
||||
},
|
||||
];
|
||||
|
||||
interface FieldFormState {
|
||||
step: "type" | "config";
|
||||
selectedType: FieldType | null;
|
||||
slug: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
unique: boolean;
|
||||
searchable: boolean;
|
||||
minLength: string;
|
||||
maxLength: string;
|
||||
min: string;
|
||||
max: string;
|
||||
pattern: string;
|
||||
options: string;
|
||||
}
|
||||
|
||||
function getInitialFormState(field?: SchemaField): FieldFormState {
|
||||
if (field) {
|
||||
return {
|
||||
step: "config",
|
||||
selectedType: field.type,
|
||||
slug: field.slug,
|
||||
label: field.label,
|
||||
required: field.required,
|
||||
unique: field.unique,
|
||||
searchable: field.searchable,
|
||||
minLength: field.validation?.minLength?.toString() ?? "",
|
||||
maxLength: field.validation?.maxLength?.toString() ?? "",
|
||||
min: field.validation?.min?.toString() ?? "",
|
||||
max: field.validation?.max?.toString() ?? "",
|
||||
pattern: field.validation?.pattern ?? "",
|
||||
options: field.validation?.options?.join("\n") ?? "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
step: "type",
|
||||
selectedType: null,
|
||||
slug: "",
|
||||
label: "",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
minLength: "",
|
||||
maxLength: "",
|
||||
min: "",
|
||||
max: "",
|
||||
pattern: "",
|
||||
options: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Field editor dialog for creating/editing fields
|
||||
*/
|
||||
export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: FieldEditorProps) {
|
||||
const [formState, setFormState] = React.useState(() => getInitialFormState(field));
|
||||
|
||||
// Reset state when dialog opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setFormState(getInitialFormState(field));
|
||||
}
|
||||
}, [open, field]);
|
||||
|
||||
const { step, selectedType, slug, label, required, unique, searchable } = formState;
|
||||
const { minLength, maxLength, min, max, pattern, options } = formState;
|
||||
const setField = <K extends keyof FieldFormState>(key: K, value: FieldFormState[K]) =>
|
||||
setFormState((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
// Auto-generate slug from label
|
||||
const handleLabelChange = (value: string) => {
|
||||
setField("label", value);
|
||||
if (!field) {
|
||||
// Only auto-generate for new fields
|
||||
setField(
|
||||
"slug",
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(SLUG_INVALID_CHARS_REGEX, "_")
|
||||
.replace(SLUG_LEADING_TRAILING_REGEX, ""),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeSelect = (type: FieldType) => {
|
||||
setFormState((prev) => ({ ...prev, selectedType: type, step: "config" }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedType || !slug || !label) return;
|
||||
|
||||
const validation: CreateFieldInput["validation"] = {};
|
||||
|
||||
// Build validation based on field type
|
||||
if (selectedType === "string" || selectedType === "text" || selectedType === "slug") {
|
||||
if (minLength) validation.minLength = parseInt(minLength, 10);
|
||||
if (maxLength) validation.maxLength = parseInt(maxLength, 10);
|
||||
if (pattern) validation.pattern = pattern;
|
||||
}
|
||||
|
||||
if (selectedType === "number" || selectedType === "integer") {
|
||||
if (min) validation.min = parseFloat(min);
|
||||
if (max) validation.max = parseFloat(max);
|
||||
}
|
||||
|
||||
if (selectedType === "select" || selectedType === "multiSelect") {
|
||||
const optionList = options
|
||||
.split("\n")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
if (optionList.length > 0) {
|
||||
validation.options = optionList;
|
||||
}
|
||||
}
|
||||
|
||||
// Only include searchable for text-based fields
|
||||
const isSearchableType =
|
||||
selectedType === "string" ||
|
||||
selectedType === "text" ||
|
||||
selectedType === "portableText" ||
|
||||
selectedType === "slug";
|
||||
|
||||
const input: CreateFieldInput = {
|
||||
slug,
|
||||
label,
|
||||
type: selectedType,
|
||||
required,
|
||||
unique,
|
||||
searchable: isSearchableType ? searchable : undefined,
|
||||
validation: Object.keys(validation).length > 0 ? validation : undefined,
|
||||
};
|
||||
|
||||
onSave(input);
|
||||
};
|
||||
|
||||
const typeConfig = FIELD_TYPES.find((t) => t.type === selectedType);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog className="p-6 max-w-2xl" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
{field ? "Edit Field" : step === "type" ? "Add Field" : "Configure Field"}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === "type" ? (
|
||||
<div className="grid grid-cols-2 gap-3 max-h-[60vh] overflow-y-auto">
|
||||
{FIELD_TYPES.map((ft) => {
|
||||
const Icon = ft.icon;
|
||||
return (
|
||||
<button
|
||||
key={ft.type}
|
||||
type="button"
|
||||
onClick={() => handleTypeSelect(ft.type)}
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-4 rounded-lg border text-left transition-colors hover:border-kumo-brand hover:bg-kumo-tint/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-kumo-tint">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{ft.label}</p>
|
||||
<p className="text-sm text-kumo-subtle">{ft.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Type indicator */}
|
||||
{typeConfig && (
|
||||
<div className="flex items-center space-x-3 p-3 bg-kumo-tint/50 rounded-lg">
|
||||
<typeConfig.icon className="h-5 w-5" />
|
||||
<div>
|
||||
<p className="font-medium">{typeConfig.label}</p>
|
||||
<p className="text-sm text-kumo-subtle">{typeConfig.description}</p>
|
||||
</div>
|
||||
{!field && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => setField("step", "type")}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Label"
|
||||
value={label}
|
||||
onChange={(e) => handleLabelChange(e.target.value)}
|
||||
placeholder="Field Label"
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={slug}
|
||||
onChange={(e) => setField("slug", e.target.value)}
|
||||
placeholder="field_slug"
|
||||
disabled={!!field}
|
||||
/>
|
||||
{field && (
|
||||
<p className="text-xs text-kumo-subtle mt-2">
|
||||
Field slugs cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="flex items-center space-x-6">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={required}
|
||||
onChange={(e) => setField("required", e.target.checked)}
|
||||
className="rounded border-kumo-line"
|
||||
/>
|
||||
<span className="text-sm">Required</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unique}
|
||||
onChange={(e) => setField("unique", e.target.checked)}
|
||||
className="rounded border-kumo-line"
|
||||
/>
|
||||
<span className="text-sm">Unique</span>
|
||||
</label>
|
||||
{(selectedType === "string" ||
|
||||
selectedType === "text" ||
|
||||
selectedType === "portableText" ||
|
||||
selectedType === "slug") && (
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchable}
|
||||
onChange={(e) => setField("searchable", e.target.checked)}
|
||||
className="rounded border-kumo-line"
|
||||
/>
|
||||
<span className="text-sm">Searchable</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type-specific validation */}
|
||||
{(selectedType === "string" || selectedType === "text" || selectedType === "slug") && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-sm">Validation</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Min Length"
|
||||
type="number"
|
||||
value={minLength}
|
||||
onChange={(e) => setField("minLength", e.target.value)}
|
||||
placeholder="No minimum"
|
||||
/>
|
||||
<Input
|
||||
label="Max Length"
|
||||
type="number"
|
||||
value={maxLength}
|
||||
onChange={(e) => setField("maxLength", e.target.value)}
|
||||
placeholder="No maximum"
|
||||
/>
|
||||
</div>
|
||||
{selectedType === "string" && (
|
||||
<Input
|
||||
label="Pattern (Regex)"
|
||||
value={pattern}
|
||||
onChange={(e) => setField("pattern", e.target.value)}
|
||||
placeholder="^[a-z]+$"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedType === "number" || selectedType === "integer") && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-sm">Validation</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Min Value"
|
||||
type="number"
|
||||
value={min}
|
||||
onChange={(e) => setField("min", e.target.value)}
|
||||
placeholder="No minimum"
|
||||
/>
|
||||
<Input
|
||||
label="Max Value"
|
||||
type="number"
|
||||
value={max}
|
||||
onChange={(e) => setField("max", e.target.value)}
|
||||
placeholder="No maximum"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedType === "select" || selectedType === "multiSelect") && (
|
||||
<InputArea
|
||||
label="Options (one per line)"
|
||||
value={options}
|
||||
onChange={(e) => setField("options", e.target.value)}
|
||||
placeholder={"Option 1\nOption 2\nOption 3"}
|
||||
rows={5}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "config" && (
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!slug || !label || isSaving}>
|
||||
{isSaving ? "Saving..." : field ? "Update Field" : "Add Field"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
107
packages/admin/src/components/Header.tsx
Normal file
107
packages/admin/src/components/Header.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Button, LinkButton, Popover } from "@cloudflare/kumo";
|
||||
import { SignOut, Shield, Gear, ArrowSquareOut } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { apiFetch } from "../lib/api/client";
|
||||
import { useCurrentUser } from "../lib/api/current-user";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
export type { CurrentUser } from "../lib/api/current-user";
|
||||
|
||||
async function handleLogout() {
|
||||
const res = await apiFetch("/_emdash/api/auth/logout?redirect=/_emdash/admin/login", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.redirected) {
|
||||
window.location.href = res.url;
|
||||
} else {
|
||||
window.location.href = "/_emdash/admin/login";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin header with mobile menu toggle and user actions.
|
||||
* Uses useSidebar() hook from kumo Sidebar.Provider context.
|
||||
*/
|
||||
export function Header() {
|
||||
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
|
||||
|
||||
const { data: user } = useCurrentUser();
|
||||
|
||||
// Get display name and initials
|
||||
const displayName = user?.name || user?.email || "User";
|
||||
const initialsSource = user?.name || user?.email || "U";
|
||||
const initials = (initialsSource[0] ?? "U").toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-kumo-base px-4">
|
||||
{/* Sidebar toggle — collapses to icon mode on desktop, opens drawer on mobile */}
|
||||
<Sidebar.Trigger />
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* View site link */}
|
||||
<LinkButton variant="ghost" size="sm" href="/" external>
|
||||
<ArrowSquareOut className="h-4 w-4 mr-1" />
|
||||
View Site
|
||||
</LinkButton>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User menu */}
|
||||
<Popover open={userMenuOpen} onOpenChange={setUserMenuOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
{user?.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="h-6 w-6 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="h-6 w-6 rounded-full bg-kumo-brand/10 flex items-center justify-center text-xs font-medium">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline max-w-[120px] truncate">{displayName}</span>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content className="w-56 p-2" align="end">
|
||||
{/* User info */}
|
||||
<div className="px-3 py-2 border-b mb-1">
|
||||
<div className="font-medium truncate">{user?.name || "User"}</div>
|
||||
<div className="text-xs text-kumo-subtle truncate">{user?.email}</div>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Link
|
||||
to="/settings/security"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-kumo-tint"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Security Settings
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-kumo-tint"
|
||||
>
|
||||
<Gear className="h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
<hr className="my-1" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-kumo-danger hover:bg-kumo-danger/10 w-full text-left"
|
||||
>
|
||||
<SignOut className="h-4 w-4" />
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
136
packages/admin/src/components/LocaleSwitcher.tsx
Normal file
136
packages/admin/src/components/LocaleSwitcher.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Locale switcher component for i18n-enabled sites.
|
||||
*
|
||||
* Used in both the content list (to filter by locale) and the content editor
|
||||
* (to switch between locale versions of a content item).
|
||||
*
|
||||
* Only renders when i18n is configured (manifest.i18n is present).
|
||||
*/
|
||||
|
||||
import { GlobeSimple } from "@phosphor-icons/react";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
interface LocaleSwitcherProps {
|
||||
locales: string[];
|
||||
defaultLocale: string;
|
||||
value: string;
|
||||
onChange: (locale: string) => void;
|
||||
/** Show "All locales" option (for list filtering) */
|
||||
showAll?: boolean;
|
||||
className?: string;
|
||||
/** Size variant */
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a display label for a locale code.
|
||||
* Uses Intl.DisplayNames when available, falls back to uppercase code.
|
||||
*/
|
||||
function getLocaleLabel(code: string): string {
|
||||
try {
|
||||
const names = new Intl.DisplayNames(["en"], { type: "language" });
|
||||
return names.of(code) ?? code.toUpperCase();
|
||||
} catch {
|
||||
return code.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function LocaleSwitcher({
|
||||
locales,
|
||||
defaultLocale,
|
||||
value,
|
||||
onChange,
|
||||
showAll = false,
|
||||
className,
|
||||
size = "md",
|
||||
}: LocaleSwitcherProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5", className)}>
|
||||
<GlobeSimple
|
||||
className={cn("text-kumo-subtle shrink-0", size === "sm" ? "size-3.5" : "size-4")}
|
||||
weight="bold"
|
||||
/>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label="Locale"
|
||||
className={cn(
|
||||
"rounded-md border bg-transparent font-medium transition-colors",
|
||||
"focus:ring-kumo-ring focus:outline-none focus:ring-2 focus:ring-offset-1",
|
||||
"hover:bg-kumo-tint/50 cursor-pointer",
|
||||
size === "sm" ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-sm",
|
||||
)}
|
||||
>
|
||||
{showAll && <option value="">All locales</option>}
|
||||
{locales.map((locale) => (
|
||||
<option key={locale} value={locale}>
|
||||
{locale.toUpperCase()}
|
||||
{locale === defaultLocale ? " (default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact locale badges showing which translations exist for a content item.
|
||||
* Renders as a row of small locale codes, with existing translations highlighted.
|
||||
*/
|
||||
export function LocaleBadges({
|
||||
locales,
|
||||
existingLocales,
|
||||
onLocaleClick,
|
||||
}: {
|
||||
locales: string[];
|
||||
existingLocales: string[];
|
||||
onLocaleClick?: (locale: string) => void;
|
||||
}) {
|
||||
const existingSet = new Set(existingLocales);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{locales.map((locale) => {
|
||||
const exists = existingSet.has(locale);
|
||||
return (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
onClick={() => onLocaleClick?.(locale)}
|
||||
disabled={!onLocaleClick}
|
||||
title={
|
||||
exists
|
||||
? `${getLocaleLabel(locale)} \u2014 view translation`
|
||||
: `${getLocaleLabel(locale)} \u2014 no translation`
|
||||
}
|
||||
className={cn(
|
||||
"rounded px-1 py-0.5 text-[10px] font-semibold uppercase leading-none transition-colors",
|
||||
exists
|
||||
? "bg-kumo-brand/10 text-kumo-brand hover:bg-kumo-brand/20"
|
||||
: "bg-kumo-tint text-kumo-subtle/50",
|
||||
onLocaleClick && exists && "cursor-pointer",
|
||||
(!onLocaleClick || !exists) && "cursor-default",
|
||||
)}
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get i18n config from the manifest query.
|
||||
* Returns null if i18n is not configured.
|
||||
*/
|
||||
export function useI18nConfig(
|
||||
manifest: { i18n?: { defaultLocale: string; locales: string[] } } | undefined,
|
||||
) {
|
||||
return React.useMemo(() => {
|
||||
if (!manifest?.i18n) return null;
|
||||
return manifest.i18n;
|
||||
}, [manifest?.i18n]);
|
||||
}
|
||||
357
packages/admin/src/components/LoginPage.tsx
Normal file
357
packages/admin/src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Login Page - Standalone login page for the admin
|
||||
*
|
||||
* This component is NOT wrapped in the admin Shell.
|
||||
* It's a standalone page for authentication.
|
||||
*
|
||||
* Supports:
|
||||
* - Passkey authentication (primary)
|
||||
* - OAuth (GitHub, Google) when configured
|
||||
* - Magic link (email) when configured
|
||||
*
|
||||
* When external auth (e.g., Cloudflare Access) is configured, this page
|
||||
* redirects to the admin dashboard since authentication is handled externally.
|
||||
*/
|
||||
|
||||
import { Button, Input, Loader } from "@cloudflare/kumo";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { apiFetch, fetchManifest } from "../lib/api";
|
||||
import { sanitizeRedirectUrl } from "../lib/url";
|
||||
import { PasskeyLogin } from "./auth/PasskeyLogin";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface LoginPageProps {
|
||||
/** URL to redirect to after successful login */
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
type LoginMethod = "passkey" | "magic-link";
|
||||
|
||||
interface OAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Icons
|
||||
// ============================================================================
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Providers
|
||||
// ============================================================================
|
||||
|
||||
const OAUTH_PROVIDERS: OAuthProvider[] = [
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
icon: <GitHubIcon className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google",
|
||||
icon: <GoogleIcon className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Components
|
||||
// ============================================================================
|
||||
|
||||
interface MagicLinkFormProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function MagicLinkForm({ onBack }: MagicLinkFormProps) {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [sent, setSent] = React.useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await apiFetch("/_emdash/api/auth/magic-link/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email.trim().toLowerCase() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body: { error?: { message?: string } } = await response.json().catch(() => ({}));
|
||||
throw new Error(body?.error?.message || "Failed to send magic link");
|
||||
}
|
||||
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send magic link");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-brand/10 mx-auto">
|
||||
<svg
|
||||
className="w-8 h-8 text-kumo-brand"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Check your email</h2>
|
||||
<p className="text-kumo-subtle mt-2">
|
||||
If an account exists for <span className="font-medium text-kumo-default">{email}</span>,
|
||||
we've sent a sign-in link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-kumo-subtle">
|
||||
<p>Click the link in the email to sign in.</p>
|
||||
<p className="mt-2">The link will expire in 15 minutes.</p>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={onBack} className="mt-4 w-full justify-center">
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className={error ? "border-kumo-danger" : ""}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger">{error}</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full justify-center"
|
||||
variant="primary"
|
||||
loading={isLoading}
|
||||
disabled={!email}
|
||||
>
|
||||
{isLoading ? "Sending..." : "Send magic link"}
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="ghost" className="w-full justify-center" onClick={onBack}>
|
||||
Back to login
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
function handleOAuthClick(providerId: string) {
|
||||
// Redirect to OAuth endpoint
|
||||
window.location.href = `/_emdash/api/auth/oauth/${providerId}`;
|
||||
}
|
||||
|
||||
export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
|
||||
// Defense-in-depth: sanitize even if the caller already validated
|
||||
const safeRedirectUrl = sanitizeRedirectUrl(redirectUrl);
|
||||
const [method, setMethod] = React.useState<LoginMethod>("passkey");
|
||||
const [urlError, setUrlError] = React.useState<string | null>(null);
|
||||
|
||||
// Fetch manifest to check auth mode
|
||||
const { data: manifest, isLoading: manifestLoading } = useQuery({
|
||||
queryKey: ["manifest"],
|
||||
queryFn: fetchManifest,
|
||||
});
|
||||
|
||||
// Redirect to admin when using external auth (authentication is handled externally)
|
||||
React.useEffect(() => {
|
||||
if (manifest?.authMode && manifest.authMode !== "passkey") {
|
||||
window.location.href = safeRedirectUrl;
|
||||
}
|
||||
}, [manifest, safeRedirectUrl]);
|
||||
|
||||
// Check for error in URL (from OAuth redirect)
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const error = params.get("error");
|
||||
const message = params.get("message");
|
||||
|
||||
if (error) {
|
||||
setUrlError(message || `Authentication error: ${error}`);
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSuccess = () => {
|
||||
// Redirect after successful login
|
||||
window.location.href = safeRedirectUrl;
|
||||
};
|
||||
|
||||
// Show loading state while checking auth mode
|
||||
if (manifestLoading || (manifest?.authMode && manifest.authMode !== "passkey")) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold mb-4">💫 EmDash</div>
|
||||
<Loader />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-4xl font-bold mb-2">💫 EmDash</div>
|
||||
<h1 className="text-2xl font-semibold text-kumo-default">
|
||||
{method === "passkey" && "Sign in to your site"}
|
||||
{method === "magic-link" && "Sign in with email"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Error from URL (OAuth failure) */}
|
||||
{urlError && (
|
||||
<div className="mb-6 rounded-lg bg-kumo-danger/10 border border-kumo-danger/20 p-4 text-sm text-kumo-danger">
|
||||
{urlError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
|
||||
{method === "passkey" && (
|
||||
<div className="space-y-6">
|
||||
{/* Passkey Login */}
|
||||
<PasskeyLogin
|
||||
optionsEndpoint="/_emdash/api/auth/passkey/options"
|
||||
verifyEndpoint="/_emdash/api/auth/passkey/verify"
|
||||
onSuccess={handleSuccess}
|
||||
buttonText="Sign in with Passkey"
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-kumo-base px-2 text-kumo-subtle">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{OAUTH_PROVIDERS.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => handleOAuthClick(provider.id)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{provider.icon}
|
||||
<span className="ml-2">{provider.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Magic Link Option */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-center"
|
||||
type="button"
|
||||
onClick={() => setMethod("magic-link")}
|
||||
>
|
||||
Sign in with email link
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method === "magic-link" && <MagicLinkForm onBack={() => setMethod("passkey")} />}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<p className="text-center mt-6 text-sm text-kumo-subtle">
|
||||
{method === "passkey"
|
||||
? "Use your registered passkey to sign in securely."
|
||||
: "We'll send you a link to sign in without a password."}
|
||||
</p>
|
||||
|
||||
{/* Signup link — only shown when self-signup is enabled */}
|
||||
{manifest?.signupEnabled && (
|
||||
<p className="text-center mt-4 text-sm text-kumo-subtle">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/signup" className="text-kumo-brand hover:underline font-medium">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
packages/admin/src/components/MarketplaceBrowse.tsx
Normal file
348
packages/admin/src/components/MarketplaceBrowse.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Marketplace Browse
|
||||
*
|
||||
* Grid of plugin cards with search and sorting.
|
||||
* Navigates to plugin detail on card click.
|
||||
*/
|
||||
|
||||
import { Badge, Button } from "@cloudflare/kumo";
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
PuzzlePiece,
|
||||
DownloadSimple,
|
||||
ShieldCheck,
|
||||
ShieldWarning,
|
||||
Warning,
|
||||
ArrowsClockwise,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
CAPABILITY_LABELS,
|
||||
searchMarketplace,
|
||||
type MarketplacePluginSummary,
|
||||
type MarketplaceSearchOpts,
|
||||
} from "../lib/api/marketplace.js";
|
||||
import { safeIconUrl } from "../lib/url.js";
|
||||
|
||||
type SortOption = "installs" | "updated" | "created" | "name";
|
||||
|
||||
const SORT_OPTIONS = new Set<string>(["installs", "updated", "created", "name"]);
|
||||
|
||||
function isSortOption(value: string): value is SortOption {
|
||||
return SORT_OPTIONS.has(value);
|
||||
}
|
||||
|
||||
const SORT_LABELS: Record<SortOption, string> = {
|
||||
installs: "Most Popular",
|
||||
updated: "Recently Updated",
|
||||
created: "Newest",
|
||||
name: "Name",
|
||||
};
|
||||
|
||||
export interface MarketplaceBrowseProps {
|
||||
/** IDs of plugins already installed on this site */
|
||||
installedPluginIds?: Set<string>;
|
||||
}
|
||||
|
||||
export function MarketplaceBrowse({ installedPluginIds = new Set() }: MarketplaceBrowseProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [sort, setSort] = React.useState<SortOption>("installs");
|
||||
const [capability, setCapability] = React.useState<string>("");
|
||||
const [debouncedQuery, setDebouncedQuery] = React.useState("");
|
||||
|
||||
// Debounce search input
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(setDebouncedQuery, 300, searchQuery);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
const searchOpts: MarketplaceSearchOpts = {
|
||||
q: debouncedQuery || undefined,
|
||||
capability: capability || undefined,
|
||||
sort,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
const { data, isLoading, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["marketplace", "search", searchOpts],
|
||||
queryFn: ({ pageParam }) => searchMarketplace({ ...searchOpts, cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const plugins = data?.pages.flatMap((p) => p.items);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Marketplace</h1>
|
||||
<p className="mt-1 text-kumo-subtle">Browse and install plugins to extend your site.</p>
|
||||
</div>
|
||||
|
||||
{/* Search + Sort */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-kumo-subtle" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search plugins..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border bg-kumo-base px-3 py-2 pl-9 text-sm placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-ring"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={capability}
|
||||
onChange={(e) => setCapability(e.target.value)}
|
||||
className="rounded-md border bg-kumo-base px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-kumo-ring"
|
||||
aria-label="Filter by capability"
|
||||
>
|
||||
<option value="">All capabilities</option>
|
||||
{Object.entries(CAPABILITY_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (isSortOption(v)) setSort(v);
|
||||
}}
|
||||
className="rounded-md border bg-kumo-base px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-kumo-ring"
|
||||
aria-label="Sort plugins"
|
||||
>
|
||||
{Object.entries(SORT_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
|
||||
<Warning className="mx-auto h-8 w-8 text-kumo-danger" />
|
||||
<h3 className="mt-3 font-medium text-kumo-danger">Unable to reach marketplace</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">
|
||||
{error instanceof Error ? error.message : "An error occurred"}
|
||||
</p>
|
||||
<Button variant="ghost" className="mt-4" onClick={() => void refetch()}>
|
||||
<ArrowsClockwise className="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-lg border bg-kumo-base p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-kumo-tint" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 rounded bg-kumo-tint" />
|
||||
<div className="h-3 w-16 rounded bg-kumo-tint" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="h-3 w-full rounded bg-kumo-tint" />
|
||||
<div className="h-3 w-2/3 rounded bg-kumo-tint" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results grid */}
|
||||
{plugins && !isLoading && (
|
||||
<>
|
||||
{plugins.length === 0 ? (
|
||||
<div className="rounded-lg border bg-kumo-base p-8 text-center">
|
||||
<PuzzlePiece className="mx-auto h-12 w-12 text-kumo-subtle" />
|
||||
<h3 className="mt-4 text-lg font-medium">No plugins found</h3>
|
||||
<p className="mt-2 text-sm text-kumo-subtle">
|
||||
{debouncedQuery
|
||||
? `No results for "${debouncedQuery}". Try a different search term.`
|
||||
: "The marketplace is empty. Check back later for new plugins."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
isInstalled={installedPluginIds.has(plugin.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: MarketplacePluginSummary;
|
||||
isInstalled: boolean;
|
||||
}
|
||||
|
||||
function PluginCard({ plugin, isInstalled }: PluginCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const auditVerdict = plugin.latestVersion?.audit?.verdict;
|
||||
const imageVerdict = plugin.latestVersion?.imageAudit?.verdict;
|
||||
const isImageFlagged = imageVerdict === "warn" || imageVerdict === "fail";
|
||||
const iconSrc = plugin.iconUrl ? safeIconUrl(plugin.iconUrl, 64) : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/plugins/marketplace/$pluginId"
|
||||
params={{ pluginId: plugin.id }}
|
||||
className="group block rounded-lg border bg-kumo-base p-4 transition-colors hover:border-kumo-brand/50 hover:bg-kumo-tint/30"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
{iconSrc ? (
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
className={`h-10 w-10 rounded-lg object-cover ${isImageFlagged ? "blur-sm" : ""}`}
|
||||
loading="lazy"
|
||||
aria-label={isImageFlagged ? "Icon blurred due to image audit" : undefined}
|
||||
/>
|
||||
) : (
|
||||
<PluginAvatar name={plugin.name} />
|
||||
)}
|
||||
|
||||
{/* Name + meta */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-semibold group-hover:text-kumo-brand">{plugin.name}</h3>
|
||||
{isInstalled && (
|
||||
<span
|
||||
role="link"
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void navigate({ to: "/plugins-manager" });
|
||||
}}
|
||||
>
|
||||
<Badge variant="secondary">Installed</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-kumo-subtle">
|
||||
<span>{plugin.author.name}</span>
|
||||
{plugin.author.verified && <ShieldCheck className="h-3 w-3 text-kumo-brand" />}
|
||||
{plugin.latestVersion?.version && <span>v{plugin.latestVersion.version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{plugin.description && (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-kumo-subtle">{plugin.description}</p>
|
||||
)}
|
||||
|
||||
{/* Footer: install count + audit + capabilities */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-kumo-subtle">
|
||||
<DownloadSimple className="h-3.5 w-3.5" />
|
||||
<span>{formatInstallCount(plugin.installCount)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{auditVerdict && <AuditBadge verdict={auditVerdict} />}
|
||||
{plugin.capabilities.length > 0 && (
|
||||
<span className="text-xs text-kumo-subtle">
|
||||
{plugin.capabilities.length} permission{plugin.capabilities.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared small components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PluginAvatar({ name }: { name: string }) {
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-kumo-brand/10 text-kumo-brand font-bold text-lg">
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditBadge({ verdict }: { verdict: "pass" | "warn" | "fail" }) {
|
||||
if (verdict === "pass") {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-green-500/10 text-green-600"
|
||||
title="Security audit passed"
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Pass
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (verdict === "warn") {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-warning/10 text-warning"
|
||||
title="Security audit flagged concerns"
|
||||
>
|
||||
<Warning className="h-3 w-3" />
|
||||
Warn
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-kumo-danger/10 text-kumo-danger"
|
||||
title="Security audit failed"
|
||||
>
|
||||
<ShieldWarning className="h-3 w-3" />
|
||||
Fail
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatInstallCount(count: number): string {
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k`;
|
||||
}
|
||||
return String(count);
|
||||
}
|
||||
|
||||
export default MarketplaceBrowse;
|
||||
567
packages/admin/src/components/MarketplacePluginDetail.tsx
Normal file
567
packages/admin/src/components/MarketplacePluginDetail.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Marketplace Plugin Detail
|
||||
*
|
||||
* Full detail view for a marketplace plugin:
|
||||
* - README rendered as markdown
|
||||
* - Screenshot gallery
|
||||
* - Capability list
|
||||
* - Audit summary
|
||||
* - Version history
|
||||
* - Install button (with capability consent)
|
||||
*/
|
||||
|
||||
import { Badge, Button } from "@cloudflare/kumo";
|
||||
import {
|
||||
ArrowLeft,
|
||||
DownloadSimple,
|
||||
GithubLogo,
|
||||
Globe,
|
||||
ShieldCheck,
|
||||
Warning,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Marked, Renderer } from "marked";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchMarketplacePlugin,
|
||||
installMarketplacePlugin,
|
||||
uninstallMarketplacePlugin,
|
||||
describeCapability,
|
||||
} from "../lib/api/marketplace.js";
|
||||
import { SAFE_URL_RE, isSafeUrl, safeIconUrl } from "../lib/url.js";
|
||||
import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js";
|
||||
import { getMutationError } from "./DialogError.js";
|
||||
import { AuditBadge } from "./MarketplaceBrowse.js";
|
||||
import { UninstallConfirmDialog } from "./PluginManager.js";
|
||||
|
||||
export interface MarketplacePluginDetailProps {
|
||||
pluginId: string;
|
||||
/** IDs of plugins already installed on this site */
|
||||
installedPluginIds?: Set<string>;
|
||||
}
|
||||
|
||||
export function MarketplacePluginDetail({
|
||||
pluginId,
|
||||
installedPluginIds = new Set(),
|
||||
}: MarketplacePluginDetailProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showConsent, setShowConsent] = React.useState(false);
|
||||
const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
|
||||
|
||||
const {
|
||||
data: plugin,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["marketplace", "plugin", pluginId],
|
||||
queryFn: () => fetchMarketplacePlugin(pluginId),
|
||||
});
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
installMarketplacePlugin(pluginId, {
|
||||
version: plugin?.latestVersion?.version,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setShowConsent(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["marketplace"] });
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: (deleteData: boolean) => uninstallMarketplacePlugin(pluginId, { deleteData }),
|
||||
onSuccess: () => {
|
||||
setShowUninstallConfirm(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["marketplace"] });
|
||||
},
|
||||
});
|
||||
|
||||
const isInstalled = installedPluginIds.has(pluginId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackLink />
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-xl bg-kumo-tint" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-6 w-48 rounded bg-kumo-tint" />
|
||||
<div className="h-4 w-32 rounded bg-kumo-tint" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 w-full rounded bg-kumo-tint" />
|
||||
<div className="h-4 w-3/4 rounded bg-kumo-tint" />
|
||||
<div className="h-64 w-full rounded bg-kumo-tint" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !plugin) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackLink />
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
|
||||
<Warning className="mx-auto h-8 w-8 text-kumo-danger" />
|
||||
<h3 className="mt-3 font-medium text-kumo-danger">Failed to load plugin</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">
|
||||
{error instanceof Error ? error.message : "Plugin not found"}
|
||||
</p>
|
||||
<Link to="/plugins/marketplace" className="mt-4 inline-block text-kumo-brand text-sm">
|
||||
Back to marketplace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latest = plugin.latestVersion;
|
||||
const imageVerdict = latest?.imageAudit?.verdict;
|
||||
const isImageFlagged = imageVerdict === "warn" || imageVerdict === "fail";
|
||||
const isAuditFailed = latest?.audit?.verdict === "fail";
|
||||
const screenshots = (latest?.screenshotUrls ?? []).filter(isSafeUrl);
|
||||
const iconSrc = plugin.iconUrl ? safeIconUrl(plugin.iconUrl, 128) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackLink />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
{iconSrc ? (
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
className={`h-16 w-16 rounded-xl object-cover ${isImageFlagged ? "blur-md" : ""}`}
|
||||
aria-label={isImageFlagged ? "Icon blurred due to image audit" : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-kumo-brand/10 text-kumo-brand text-2xl font-bold">
|
||||
{plugin.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{plugin.name}</h1>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-kumo-subtle">
|
||||
<span>{plugin.author.name}</span>
|
||||
{plugin.author.verified && <ShieldCheck className="h-4 w-4 text-kumo-brand" />}
|
||||
{latest && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>v{latest.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="mt-2 text-sm text-kumo-subtle max-w-lg">{plugin.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{isInstalled ? (
|
||||
<>
|
||||
<Badge variant="secondary" className="text-sm px-3 py-1">
|
||||
Installed
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-kumo-danger hover:text-kumo-danger"
|
||||
onClick={() => setShowUninstallConfirm(true)}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
</>
|
||||
) : isAuditFailed ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button disabled variant="secondary">
|
||||
Install blocked
|
||||
</Button>
|
||||
<span className="text-xs text-kumo-danger">Failed security audit</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setShowConsent(true)}>
|
||||
<DownloadSimple className="mr-2 h-4 w-4" />
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="flex flex-wrap items-center gap-4 rounded-lg border bg-kumo-tint/30 p-3 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<DownloadSimple className="h-4 w-4 text-kumo-subtle" />
|
||||
<span>{plugin.installCount.toLocaleString()} installs</span>
|
||||
</div>
|
||||
{latest?.audit && <AuditBadge verdict={latest.audit.verdict} />}
|
||||
{plugin.license && <span className="text-kumo-subtle">{plugin.license}</span>}
|
||||
{plugin.repositoryUrl && isSafeUrl(plugin.repositoryUrl) && (
|
||||
<a
|
||||
href={plugin.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-kumo-brand hover:underline"
|
||||
>
|
||||
<GithubLogo className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
{plugin.homepageUrl && isSafeUrl(plugin.homepageUrl) && (
|
||||
<a
|
||||
href={plugin.homepageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-kumo-brand hover:underline"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
{screenshots.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Screenshots</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{screenshots.map((url, i) => (
|
||||
<button
|
||||
key={url}
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
className="shrink-0 overflow-hidden rounded-lg border hover:ring-2 hover:ring-kumo-brand transition-shadow"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`Screenshot ${i + 1}`}
|
||||
className={`h-40 w-auto object-cover ${isImageFlagged ? "blur-md" : ""}`}
|
||||
loading="lazy"
|
||||
aria-label={isImageFlagged ? "Screenshot blurred due to image audit" : undefined}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-column layout: README + sidebar */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||
{/* README */}
|
||||
<div>
|
||||
{latest?.readme ? (
|
||||
<div className="prose prose-sm max-w-none rounded-lg border bg-kumo-base p-6">
|
||||
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(latest.readme) }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-kumo-base p-6 text-center text-kumo-subtle">
|
||||
No detailed description available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4">
|
||||
{/* Capabilities */}
|
||||
<div className="rounded-lg border bg-kumo-base p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">Permissions</h3>
|
||||
{plugin.capabilities.length === 0 ? (
|
||||
<p className="text-xs text-kumo-subtle">
|
||||
This plugin requires no special permissions.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{plugin.capabilities.map((cap) => (
|
||||
<li key={cap} className="flex items-start gap-2 text-xs text-kumo-subtle">
|
||||
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-kumo-brand" />
|
||||
<span>{describeCapability(cap)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keywords */}
|
||||
{plugin.keywords && plugin.keywords.length > 0 && (
|
||||
<div className="rounded-lg border bg-kumo-base p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">Keywords</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{plugin.keywords.map((kw) => (
|
||||
<span key={kw} className="rounded-md bg-kumo-tint px-2 py-0.5 text-xs">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit summary */}
|
||||
{latest?.audit && (
|
||||
<div className="rounded-lg border bg-kumo-base p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">Security Audit</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<AuditBadge verdict={latest.audit.verdict} />
|
||||
<span className="text-xs text-kumo-subtle">
|
||||
Risk score: {latest.audit.riskScore}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version info */}
|
||||
{latest && (
|
||||
<div className="rounded-lg border bg-kumo-base p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">Version</h3>
|
||||
<div className="space-y-1 text-xs text-kumo-subtle">
|
||||
<div>v{latest.version}</div>
|
||||
{latest.minEmDashVersion && (
|
||||
<div>Requires EmDash {latest.minEmDashVersion}</div>
|
||||
)}
|
||||
<div>Published {new Date(latest.publishedAt).toLocaleDateString()}</div>
|
||||
{latest.bundleSize > 0 && <div>{formatBytes(latest.bundleSize)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capability consent dialog */}
|
||||
{showConsent && (
|
||||
<CapabilityConsentDialog
|
||||
mode="install"
|
||||
pluginName={plugin.name}
|
||||
capabilities={plugin.capabilities}
|
||||
auditVerdict={latest?.audit?.verdict}
|
||||
isPending={installMutation.isPending}
|
||||
error={getMutationError(installMutation.error)}
|
||||
onConfirm={() => installMutation.mutate()}
|
||||
onCancel={() => {
|
||||
setShowConsent(false);
|
||||
installMutation.reset();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Uninstall confirmation */}
|
||||
{showUninstallConfirm && (
|
||||
<UninstallConfirmDialog
|
||||
pluginName={plugin.name}
|
||||
isPending={uninstallMutation.isPending}
|
||||
error={getMutationError(uninstallMutation.error)}
|
||||
onConfirm={(deleteData) => uninstallMutation.mutate(deleteData)}
|
||||
onCancel={() => {
|
||||
setShowUninstallConfirm(false);
|
||||
uninstallMutation.reset();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Screenshot lightbox */}
|
||||
{lightboxIndex !== null && lightboxIndex < screenshots.length && (
|
||||
<ScreenshotLightbox
|
||||
screenshots={screenshots}
|
||||
index={lightboxIndex}
|
||||
isBlurred={isImageFlagged}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onNavigate={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link
|
||||
to="/plugins/marketplace"
|
||||
className="inline-flex items-center gap-1 text-sm text-kumo-subtle hover:text-kumo-default"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to marketplace
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenshotLightboxProps {
|
||||
screenshots: string[];
|
||||
index: number;
|
||||
isBlurred?: boolean;
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
}
|
||||
|
||||
function ScreenshotLightbox({
|
||||
screenshots,
|
||||
index,
|
||||
isBlurred = false,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: ScreenshotLightboxProps) {
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "ArrowLeft" && index > 0) onNavigate(index - 1);
|
||||
if (e.key === "ArrowRight" && index < screenshots.length - 1) onNavigate(index + 1);
|
||||
},
|
||||
[index, screenshots.length, onClose, onNavigate],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Screenshot viewer"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{index > 0 && (
|
||||
<button
|
||||
onClick={() => onNavigate(index - 1)}
|
||||
className="absolute left-4 rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<CaretLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={screenshots[index]}
|
||||
alt={`Screenshot ${index + 1} of ${screenshots.length}`}
|
||||
className={`max-h-[85vh] max-w-[90vw] rounded-lg object-contain ${
|
||||
isBlurred ? "blur-md" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
{index < screenshots.length - 1 && (
|
||||
<button
|
||||
onClick={() => onNavigate(index + 1)}
|
||||
className="absolute right-4 rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
<CaretRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Counter */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-sm text-white">
|
||||
{index + 1} / {screenshots.length}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown rendering (via marked, raw HTML blocked, sanitized with DOMPurify)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
const HTML_ESCAPE_RE = /[&<>"']/g;
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(HTML_ESCAPE_RE, (ch) => HTML_ESCAPE_MAP[ch]!);
|
||||
}
|
||||
|
||||
const renderer = new Renderer();
|
||||
|
||||
renderer.link = ({ href, text }) => {
|
||||
if (!SAFE_URL_RE.test(href)) return escapeHtml(text);
|
||||
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
|
||||
};
|
||||
|
||||
renderer.image = ({ text }) => escapeHtml(text);
|
||||
|
||||
renderer.html = () => "";
|
||||
|
||||
const md = new Marked({ renderer, async: false });
|
||||
|
||||
/** Allowed tags and attributes for DOMPurify — only standard markdown output. */
|
||||
const SANITIZE_CONFIG = {
|
||||
ALLOWED_TAGS: [
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"p",
|
||||
"a",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"code",
|
||||
"em",
|
||||
"strong",
|
||||
"del",
|
||||
"br",
|
||||
"hr",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"details",
|
||||
"summary",
|
||||
"sup",
|
||||
"sub",
|
||||
],
|
||||
ALLOWED_ATTR: ["href", "target", "rel"],
|
||||
};
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const result = md.parse(markdown);
|
||||
const html = typeof result === "string" ? result : "";
|
||||
return DOMPurify.sanitize(html, SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default MarketplacePluginDetail;
|
||||
274
packages/admin/src/components/MediaDetailPanel.tsx
Normal file
274
packages/admin/src/components/MediaDetailPanel.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Media Detail Panel
|
||||
*
|
||||
* A slide-out panel for viewing and editing media item metadata.
|
||||
* Opens when clicking an item in the MediaLibrary.
|
||||
*/
|
||||
|
||||
import { Button, Input, InputArea } from "@cloudflare/kumo";
|
||||
import { X, Trash, Calendar, HardDrive, Ruler } from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { updateMedia, deleteMedia, type MediaItem } from "../lib/api";
|
||||
import { useStableCallback } from "../lib/hooks";
|
||||
import { getFileIcon, formatFileSize } from "../lib/media-utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
export interface MediaDetailPanelProps {
|
||||
item: MediaItem | null;
|
||||
onClose: () => void;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-out panel for viewing and editing media metadata
|
||||
*/
|
||||
export function MediaDetailPanel({ item, onClose, onDeleted }: MediaDetailPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Form state - controlled inputs
|
||||
const [filename, setFilename] = React.useState(item?.filename ?? "");
|
||||
const [alt, setAlt] = React.useState(item?.alt ?? "");
|
||||
const [caption, setCaption] = React.useState(item?.caption ?? "");
|
||||
|
||||
// Reset form when item changes
|
||||
React.useEffect(() => {
|
||||
if (item) {
|
||||
setFilename(item.filename);
|
||||
setAlt(item.alt ?? "");
|
||||
setCaption(item.caption ?? "");
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
// Track if form has unsaved changes
|
||||
const hasChanges = React.useMemo(() => {
|
||||
if (!item) return false;
|
||||
return (
|
||||
filename !== item.filename || alt !== (item.alt ?? "") || caption !== (item.caption ?? "")
|
||||
);
|
||||
}, [item, filename, alt, caption]);
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { alt?: string; caption?: string }) => {
|
||||
if (!item) throw new Error("No item selected");
|
||||
return updateMedia(item.id, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate to refresh the list
|
||||
void queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!item) throw new Error("No item selected");
|
||||
return deleteMedia(item.id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
onDeleted?.();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (!item || !hasChanges) return;
|
||||
updateMutation.mutate({
|
||||
alt: alt || undefined,
|
||||
caption: caption || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!item) return;
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const stableOnClose = useStableCallback(onClose);
|
||||
const stableHandleSave = useStableCallback(handleSave);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
stableOnClose();
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
stableHandleSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [stableOnClose, stableHandleSave]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const isImage = item.mimeType.startsWith("image/");
|
||||
const isVideo = item.mimeType.startsWith("video/");
|
||||
const isAudio = item.mimeType.startsWith("audio/");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 right-0 w-96 bg-kumo-base border-l shadow-xl z-50",
|
||||
"flex flex-col",
|
||||
"animate-in slide-in-from-right duration-200",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="font-semibold truncate pr-2">Media Details</h2>
|
||||
<Button variant="ghost" shape="square" aria-label="Close" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Preview */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="aspect-video bg-kumo-tint rounded-lg overflow-hidden flex items-center justify-center">
|
||||
{isImage ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.alt || item.filename}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : isVideo ? (
|
||||
<video
|
||||
src={item.url}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="max-h-full max-w-full"
|
||||
/>
|
||||
) : isAudio ? (
|
||||
<audio src={item.url} controls preload="metadata" className="w-full" />
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<span className="text-4xl">{getFileIcon(item.mimeType)}</span>
|
||||
<p className="mt-2 text-sm text-kumo-subtle">{item.mimeType}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Info */}
|
||||
<div className="p-4 border-b space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<HardDrive className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="text-kumo-subtle">Size:</span>
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
</div>
|
||||
{item.width && item.height && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Ruler className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="text-kumo-subtle">Dimensions:</span>
|
||||
<span>
|
||||
{item.width} × {item.height}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="text-kumo-subtle">Uploaded:</span>
|
||||
<span>{formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable Fields */}
|
||||
<div className="p-4 space-y-4">
|
||||
<Input
|
||||
label="Filename"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
disabled // Filename editing needs backend support
|
||||
description="Filename cannot be changed after upload"
|
||||
/>
|
||||
|
||||
{isImage && (
|
||||
<>
|
||||
<Input
|
||||
label="Alt Text"
|
||||
value={alt}
|
||||
onChange={(e) => setAlt(e.target.value)}
|
||||
placeholder="Describe this image for accessibility"
|
||||
description="Used by screen readers and when image fails to load"
|
||||
/>
|
||||
|
||||
<InputArea
|
||||
label="Caption"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
placeholder="Optional caption for display"
|
||||
rows={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
icon={<Trash />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteMutation.reset();
|
||||
}}
|
||||
title="Delete Media?"
|
||||
description={`Delete "${item.filename}"? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={deleteMutation.isPending}
|
||||
error={deleteMutation.error}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
return new Date(isoString).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default MediaDetailPanel;
|
||||
673
packages/admin/src/components/MediaLibrary.tsx
Normal file
673
packages/admin/src/components/MediaLibrary.tsx
Normal file
@@ -0,0 +1,673 @@
|
||||
import { Button, Input, Loader } from "@cloudflare/kumo";
|
||||
import { Upload, Image, SquaresFour, List, MagnifyingGlass, Check, X } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
type MediaItem,
|
||||
type MediaProviderInfo,
|
||||
type MediaProviderItem,
|
||||
fetchMediaProviders,
|
||||
fetchProviderMedia,
|
||||
uploadToProvider,
|
||||
} from "../lib/api";
|
||||
import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MediaDetailPanel } from "./MediaDetailPanel";
|
||||
|
||||
export interface MediaLibraryProps {
|
||||
items?: MediaItem[];
|
||||
isLoading?: boolean;
|
||||
onUpload?: (file: File) => Promise<void> | void;
|
||||
onSelect?: (item: MediaItem) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onItemUpdated?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media library component with upload, provider tabs, and grid view
|
||||
*/
|
||||
export function MediaLibrary({
|
||||
items = [],
|
||||
isLoading,
|
||||
onUpload,
|
||||
onDelete,
|
||||
onItemUpdated,
|
||||
}: MediaLibraryProps) {
|
||||
const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid");
|
||||
const [selectedItem, setSelectedItem] = React.useState<MediaItem | null>(null);
|
||||
const [activeProvider, setActiveProvider] = React.useState<string>("local");
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [uploadState, setUploadState] = React.useState<{
|
||||
status: "idle" | "uploading" | "success" | "error";
|
||||
message?: string;
|
||||
progress?: { current: number; total: number };
|
||||
}>({ status: "idle" });
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
// Track loaded image dimensions for providers that don't return them (e.g., CF Images)
|
||||
const [loadedDimensions, setLoadedDimensions] = React.useState<
|
||||
Record<string, { width: number; height: number }>
|
||||
>({});
|
||||
|
||||
// Fetch available providers
|
||||
const { data: providers } = useQuery({
|
||||
queryKey: ["media-providers"],
|
||||
queryFn: fetchMediaProviders,
|
||||
placeholderData: [],
|
||||
});
|
||||
|
||||
// Fetch provider media when a non-local provider is selected
|
||||
const {
|
||||
data: providerData,
|
||||
isLoading: providerLoading,
|
||||
refetch: refetchProviderMedia,
|
||||
} = useQuery({
|
||||
queryKey: ["provider-media", activeProvider, searchQuery],
|
||||
queryFn: () =>
|
||||
fetchProviderMedia(activeProvider, {
|
||||
limit: 50,
|
||||
query: searchQuery || undefined,
|
||||
}),
|
||||
enabled: activeProvider !== "local",
|
||||
});
|
||||
|
||||
// Get active provider info
|
||||
const activeProviderInfo = React.useMemo(() => {
|
||||
if (activeProvider === "local") {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Library",
|
||||
capabilities: { browse: true, search: false, upload: true, delete: true },
|
||||
} as MediaProviderInfo;
|
||||
}
|
||||
return providers?.find((p) => p.id === activeProvider);
|
||||
}, [activeProvider, providers]);
|
||||
|
||||
// Update selected item when items change (e.g., after metadata update)
|
||||
React.useEffect(() => {
|
||||
if (selectedItem && activeProvider === "local") {
|
||||
const updated = items.find((i) => i.id === selectedItem.id);
|
||||
if (updated) {
|
||||
setSelectedItem(updated);
|
||||
} else {
|
||||
// Item was deleted
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}
|
||||
}, [items, selectedItem?.id, activeProvider]);
|
||||
|
||||
// Clear success/error message after a delay
|
||||
React.useEffect(() => {
|
||||
if (uploadState.status === "success" || uploadState.status === "error") {
|
||||
const timer = setTimeout(() => {
|
||||
setUploadState({ status: "idle" });
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [uploadState.status]);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const fileArray = [...files];
|
||||
const total = fileArray.length;
|
||||
|
||||
if (activeProvider === "local") {
|
||||
setUploadState({ status: "uploading", progress: { current: 0, total } });
|
||||
let uploaded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const file of fileArray) {
|
||||
try {
|
||||
await onUpload?.(file);
|
||||
uploaded++;
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
failed++;
|
||||
}
|
||||
setUploadState({
|
||||
status: "uploading",
|
||||
progress: { current: uploaded + failed, total },
|
||||
});
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
setUploadState({
|
||||
status: "success",
|
||||
message: total === 1 ? "File uploaded" : `${total} files uploaded`,
|
||||
});
|
||||
} else if (uploaded === 0) {
|
||||
setUploadState({
|
||||
status: "error",
|
||||
message: total === 1 ? "Upload failed" : `All ${total} uploads failed`,
|
||||
});
|
||||
} else {
|
||||
setUploadState({
|
||||
status: "error",
|
||||
message: `${uploaded} uploaded, ${failed} failed`,
|
||||
});
|
||||
}
|
||||
} else if (activeProviderInfo?.capabilities.upload) {
|
||||
// Upload to external provider
|
||||
setUploadState({ status: "uploading", progress: { current: 0, total } });
|
||||
let uploaded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const file of fileArray) {
|
||||
try {
|
||||
await uploadToProvider(activeProvider, file);
|
||||
uploaded++;
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
failed++;
|
||||
}
|
||||
setUploadState({
|
||||
status: "uploading",
|
||||
progress: { current: uploaded + failed, total },
|
||||
});
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
setUploadState({
|
||||
status: "success",
|
||||
message: total === 1 ? "File uploaded" : `${total} files uploaded`,
|
||||
});
|
||||
} else if (uploaded === 0) {
|
||||
setUploadState({
|
||||
status: "error",
|
||||
message: total === 1 ? "Upload failed" : `All ${total} uploads failed`,
|
||||
});
|
||||
} else {
|
||||
setUploadState({
|
||||
status: "error",
|
||||
message: `${uploaded} uploaded, ${failed} failed`,
|
||||
});
|
||||
}
|
||||
|
||||
void refetchProviderMedia();
|
||||
}
|
||||
}
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Build provider tabs
|
||||
const providerTabs = React.useMemo(() => {
|
||||
const tabs: Array<{ id: string; name: string; icon?: string }> = [
|
||||
{ id: "local", name: "Library", icon: undefined },
|
||||
];
|
||||
if (providers) {
|
||||
for (const p of providers) {
|
||||
if (p.id !== "local") {
|
||||
tabs.push({ id: p.id, name: p.name, icon: p.icon });
|
||||
}
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}, [providers]);
|
||||
|
||||
// Get current items based on active provider
|
||||
const currentItems = activeProvider === "local" ? items : [];
|
||||
const currentProviderItems = activeProvider !== "local" ? providerData?.items || [] : [];
|
||||
const currentLoading = activeProvider === "local" ? isLoading : providerLoading;
|
||||
|
||||
const canUpload = activeProviderInfo?.capabilities.upload ?? false;
|
||||
const canSearch = activeProviderInfo?.capabilities.search ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Media Library</h1>
|
||||
<div className="flex rounded-md border" role="group" aria-label="View mode">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
shape="square"
|
||||
onClick={() => setViewMode("grid")}
|
||||
aria-label="Grid view"
|
||||
aria-pressed={viewMode === "grid"}
|
||||
>
|
||||
<SquaresFour className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
shape="square"
|
||||
onClick={() => setViewMode("list")}
|
||||
aria-label="List view"
|
||||
aria-pressed={viewMode === "list"}
|
||||
>
|
||||
<List className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Tabs + Upload */}
|
||||
<div className="flex items-center justify-between gap-4 border-b pb-3">
|
||||
{providerTabs.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{providerTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveProvider(tab.id);
|
||||
setSelectedItem(null);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors whitespace-nowrap",
|
||||
activeProvider === tab.id
|
||||
? "bg-kumo-brand text-white"
|
||||
: "bg-kumo-tint hover:bg-kumo-tint/80 text-kumo-subtle",
|
||||
)}
|
||||
>
|
||||
{tab.icon &&
|
||||
(tab.icon.startsWith("data:") ? (
|
||||
<img src={tab.icon} alt="" className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<span aria-hidden="true">{tab.icon}</span>
|
||||
))}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload button + status */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{/* Upload status feedback */}
|
||||
{uploadState.status === "uploading" && (
|
||||
<div className="flex items-center gap-2 text-sm text-kumo-subtle">
|
||||
<Loader size="sm" />
|
||||
<span>
|
||||
Uploading
|
||||
{uploadState.progress &&
|
||||
uploadState.progress.total > 1 &&
|
||||
` ${uploadState.progress.current}/${uploadState.progress.total}`}
|
||||
...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadState.status === "success" && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<Check className="h-4 w-4" />
|
||||
<span>{uploadState.message}</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadState.status === "error" && (
|
||||
<div className="flex items-center gap-2 text-sm text-kumo-danger">
|
||||
<X className="h-4 w-4" />
|
||||
<span>{uploadState.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadState.status === "uploading"}
|
||||
icon={uploadState.status === "uploading" ? <Loader size="sm" /> : <Upload />}
|
||||
>
|
||||
Upload to {activeProviderInfo?.name || "Library"}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
className="sr-only"
|
||||
onChange={handleFileSelect}
|
||||
aria-label="Upload files"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search (for providers that support it) */}
|
||||
{canSearch && (
|
||||
<div className="relative max-w-sm">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{currentLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader />
|
||||
</div>
|
||||
) : activeProvider === "local" && currentItems.length === 0 ? (
|
||||
<div className="rounded-lg border bg-kumo-base p-12 text-center">
|
||||
<Image className="mx-auto h-12 w-12 text-kumo-subtle" aria-hidden="true" />
|
||||
<h2 className="mt-4 text-lg font-medium">No media yet</h2>
|
||||
<p className="mt-2 text-sm text-kumo-subtle">
|
||||
Upload images, videos, and documents to get started.
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => fileInputRef.current?.click()} icon={<Upload />}>
|
||||
Upload Files
|
||||
</Button>
|
||||
</div>
|
||||
) : activeProvider !== "local" && currentProviderItems.length === 0 ? (
|
||||
<div className="rounded-lg border bg-kumo-base p-12 text-center">
|
||||
<Image className="mx-auto h-12 w-12 text-kumo-subtle" aria-hidden="true" />
|
||||
<h2 className="mt-4 text-lg font-medium">No media found</h2>
|
||||
<p className="mt-2 text-sm text-kumo-subtle">
|
||||
{canSearch && searchQuery
|
||||
? "Try a different search term"
|
||||
: canUpload
|
||||
? "Upload media to get started"
|
||||
: "No media available from this provider"}
|
||||
</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
||||
{activeProvider === "local"
|
||||
? currentItems.map((item) => (
|
||||
<MediaGridItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedItem?.id === item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
onDelete={() => onDelete?.(item.id)}
|
||||
/>
|
||||
))
|
||||
: currentProviderItems.map((item) => (
|
||||
<ProviderGridItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedItem?.id === item.id}
|
||||
onClick={() => {
|
||||
// Merge loaded dimensions if provider didn't return them
|
||||
const dims = loadedDimensions[item.id];
|
||||
const itemWithDims = dims
|
||||
? {
|
||||
...item,
|
||||
width: item.width ?? dims.width,
|
||||
height: item.height ?? dims.height,
|
||||
}
|
||||
: item;
|
||||
setSelectedItem(providerItemToMediaItem(activeProvider, itemWithDims));
|
||||
}}
|
||||
onDimensionsLoaded={(width, height) => {
|
||||
setLoadedDimensions((prev) => ({
|
||||
...prev,
|
||||
[item.id]: { width, height },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-kumo-tint/50">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Preview</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Filename</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Type</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Size</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeProvider === "local"
|
||||
? currentItems.map((item) => (
|
||||
<MediaListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedItem?.id === item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
onDelete={() => onDelete?.(item.id)}
|
||||
/>
|
||||
))
|
||||
: currentProviderItems.map((item) => (
|
||||
<ProviderListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedItem?.id === item.id}
|
||||
onClick={() => {
|
||||
const dims = loadedDimensions[item.id];
|
||||
const itemWithDims = dims
|
||||
? {
|
||||
...item,
|
||||
width: item.width ?? dims.width,
|
||||
height: item.height ?? dims.height,
|
||||
}
|
||||
: item;
|
||||
setSelectedItem(providerItemToMediaItem(activeProvider, itemWithDims));
|
||||
}}
|
||||
onDimensionsLoaded={(width, height) => {
|
||||
setLoadedDimensions((prev) => ({
|
||||
...prev,
|
||||
[item.id]: { width, height },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedItem && (
|
||||
<MediaDetailPanel
|
||||
item={selectedItem}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
onDeleted={() => {
|
||||
if (activeProvider === "local") {
|
||||
onDelete?.(selectedItem.id);
|
||||
onItemUpdated?.();
|
||||
} else {
|
||||
void refetchProviderMedia();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MediaGridItemProps {
|
||||
item: MediaItem;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
|
||||
const isImage = item.mimeType.startsWith("image/");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-lg border bg-kumo-base text-left transition-all max-w-[200px]",
|
||||
selected ? "ring-2 ring-kumo-brand border-kumo-brand" : "hover:border-kumo-brand/50",
|
||||
)}
|
||||
>
|
||||
<div className="aspect-square">
|
||||
{isImage ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.alt || item.filename}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
|
||||
<span className="text-4xl">{getFileIcon(item.mimeType)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="w-full p-3">
|
||||
<p className="truncate text-sm font-medium text-white">{item.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderGridItemProps {
|
||||
item: MediaProviderItem;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
/** Callback when image dimensions are loaded (for providers that don't return dimensions) */
|
||||
onDimensionsLoaded?: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
function ProviderGridItem({ item, selected, onClick, onDimensionsLoaded }: ProviderGridItemProps) {
|
||||
const isImage = item.mimeType.startsWith("image/");
|
||||
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
// Only report if we don't already have dimensions
|
||||
if (onDimensionsLoaded && (!item.width || !item.height)) {
|
||||
onDimensionsLoaded(img.naturalWidth, img.naturalHeight);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-lg border bg-kumo-base text-left transition-all max-w-[200px]",
|
||||
selected ? "ring-2 ring-kumo-brand border-kumo-brand" : "hover:border-kumo-brand/50",
|
||||
)}
|
||||
>
|
||||
<div className="aspect-square">
|
||||
{isImage && item.previewUrl ? (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt={item.alt || item.filename}
|
||||
className="h-full w-full object-cover"
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
|
||||
<span className="text-4xl">{getFileIcon(item.mimeType)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="w-full p-3">
|
||||
<p className="truncate text-sm font-medium text-white">{item.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface MediaListItemProps {
|
||||
item: MediaItem;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
|
||||
const isImage = item.mimeType.startsWith("image/");
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b cursor-pointer transition-colors",
|
||||
selected ? "bg-kumo-brand/10" : "hover:bg-kumo-tint/25",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-10 w-10 overflow-hidden rounded">
|
||||
{isImage ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.alt || item.filename}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-kumo-tint text-xl">
|
||||
{getFileIcon(item.mimeType)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{item.filename}</td>
|
||||
<td className="px-4 py-3 text-sm text-kumo-subtle">{item.mimeType}</td>
|
||||
<td className="px-4 py-3 text-sm text-kumo-subtle">{formatFileSize(item.size)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-sm text-kumo-subtle">
|
||||
{item.alt ? "Alt text set" : "No alt text"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderListItemProps {
|
||||
item: MediaProviderItem;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
/** Callback when image dimensions are loaded (for providers that don't return dimensions) */
|
||||
onDimensionsLoaded?: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: ProviderListItemProps) {
|
||||
const isImage = item.mimeType.startsWith("image/");
|
||||
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
if (onDimensionsLoaded && (!item.width || !item.height)) {
|
||||
onDimensionsLoaded(img.naturalWidth, img.naturalHeight);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b cursor-pointer transition-colors",
|
||||
selected ? "bg-kumo-brand/10" : "hover:bg-kumo-tint/25",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-10 w-10 overflow-hidden rounded">
|
||||
{isImage && item.previewUrl ? (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt={item.alt || item.filename}
|
||||
className="h-full w-full object-cover"
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-kumo-tint text-xl">
|
||||
{getFileIcon(item.mimeType)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{item.filename}</td>
|
||||
<td className="px-4 py-3 text-sm text-kumo-subtle">{item.mimeType}</td>
|
||||
<td className="px-4 py-3 text-sm text-kumo-subtle">
|
||||
{item.size ? formatFileSize(item.size) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-sm text-kumo-subtle">
|
||||
{item.alt ? "Alt text set" : "No alt text"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default MediaLibrary;
|
||||
749
packages/admin/src/components/MediaPickerModal.tsx
Normal file
749
packages/admin/src/components/MediaPickerModal.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* Media Picker Modal
|
||||
*
|
||||
* A modal dialog for selecting media from the library or uploading new files.
|
||||
* Supports multiple media providers with tabbed navigation.
|
||||
* Used by the rich text editor and image field components.
|
||||
*/
|
||||
|
||||
import { Button, Dialog, Input, Label, Loader } from "@cloudflare/kumo";
|
||||
import { Upload, Image, Check, Globe, MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchMediaList,
|
||||
fetchMediaProviders,
|
||||
fetchProviderMedia,
|
||||
uploadMedia,
|
||||
uploadToProvider,
|
||||
updateMedia,
|
||||
type MediaItem,
|
||||
type MediaProviderInfo,
|
||||
type MediaProviderItem,
|
||||
} from "../lib/api";
|
||||
import { providerItemToMediaItem, getFileIcon } from "../lib/media-utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { DialogError } from "./DialogError.js";
|
||||
|
||||
/** Selected item can be either a local MediaItem or a provider item with provider context */
|
||||
interface SelectedMedia {
|
||||
providerId: string;
|
||||
item: MediaItem | MediaProviderItem;
|
||||
}
|
||||
|
||||
export interface MediaPickerModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (item: MediaItem) => void;
|
||||
/** Filter by mime type prefix, e.g. "image/" */
|
||||
mimeTypeFilter?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe image URL to get dimensions
|
||||
*/
|
||||
function probeImageDimensions(url: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
};
|
||||
img.onerror = () => {
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function MediaPickerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
mimeTypeFilter = "image/",
|
||||
title = "Select Image",
|
||||
}: MediaPickerModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedItem, setSelectedItem] = React.useState<SelectedMedia | null>(null);
|
||||
const [activeProvider, setActiveProvider] = React.useState<string>("local");
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// URL input state
|
||||
const [imageUrl, setImageUrl] = React.useState("");
|
||||
const [isProbing, setIsProbing] = React.useState(false);
|
||||
const [urlError, setUrlError] = React.useState<string | null>(null);
|
||||
|
||||
// Track loaded image dimensions for providers that don't return them (e.g., CF Images)
|
||||
const [providerDimensions, setProviderDimensions] = React.useState<
|
||||
Record<string, { width: number; height: number }>
|
||||
>({});
|
||||
|
||||
// Reset state when modal opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedItem(null);
|
||||
setActiveProvider("local");
|
||||
setSearchQuery("");
|
||||
setImageUrl("");
|
||||
setUrlError(null);
|
||||
setUploadError(null);
|
||||
setProviderDimensions({});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Fetch available providers
|
||||
const { data: providers } = useQuery({
|
||||
queryKey: ["media-providers"],
|
||||
queryFn: fetchMediaProviders,
|
||||
enabled: open,
|
||||
// Default to just local if fetch fails
|
||||
placeholderData: [],
|
||||
});
|
||||
|
||||
// Get active provider info
|
||||
const activeProviderInfo = React.useMemo(() => {
|
||||
if (activeProvider === "local") {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Library",
|
||||
icon: undefined,
|
||||
capabilities: { browse: true, search: false, upload: true, delete: true },
|
||||
} as MediaProviderInfo;
|
||||
}
|
||||
return providers?.find((p) => p.id === activeProvider);
|
||||
}, [activeProvider, providers]);
|
||||
|
||||
// Fetch local media list
|
||||
const { data: localData, isLoading: localLoading } = useQuery({
|
||||
queryKey: ["media", mimeTypeFilter],
|
||||
queryFn: () =>
|
||||
fetchMediaList({
|
||||
mimeType: mimeTypeFilter,
|
||||
limit: 50,
|
||||
}),
|
||||
enabled: open && activeProvider === "local",
|
||||
});
|
||||
|
||||
// Fetch provider media list
|
||||
const { data: providerData, isLoading: providerLoading } = useQuery({
|
||||
queryKey: ["provider-media", activeProvider, mimeTypeFilter, searchQuery],
|
||||
queryFn: () =>
|
||||
fetchProviderMedia(activeProvider, {
|
||||
mimeType: mimeTypeFilter,
|
||||
limit: 50,
|
||||
query: searchQuery || undefined,
|
||||
}),
|
||||
enabled: open && activeProvider !== "local",
|
||||
});
|
||||
|
||||
const isLoading = activeProvider === "local" ? localLoading : providerLoading;
|
||||
|
||||
const [uploadError, setUploadError] = React.useState<string | null>(null);
|
||||
|
||||
// Upload mutation for local provider
|
||||
const uploadLocalMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadMedia(file),
|
||||
onSuccess: (item) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
setSelectedItem({ providerId: "local", item });
|
||||
setUploadError(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setUploadError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Upload mutation for external providers
|
||||
const uploadProviderMutation = useMutation({
|
||||
mutationFn: ({ providerId, file }: { providerId: string; file: File }) =>
|
||||
uploadToProvider(providerId, file),
|
||||
onSuccess: (item, { providerId }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["provider-media", providerId] });
|
||||
setSelectedItem({ providerId, item });
|
||||
setUploadError(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setUploadError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const isUploading = uploadLocalMutation.isPending || uploadProviderMutation.isPending;
|
||||
|
||||
// Track which items we've already updated dimensions for
|
||||
const updatedDimensionsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
// Mutation for updating media dimensions
|
||||
const dimensionsMutation = useMutation({
|
||||
mutationFn: ({ id, width, height }: { id: string; width: number; height: number }) =>
|
||||
updateMedia(id, { width, height }),
|
||||
onSuccess: (_updated, { id, width, height }) => {
|
||||
queryClient.setQueryData(
|
||||
["media", mimeTypeFilter],
|
||||
(old: { items: MediaItem[]; nextCursor?: string } | undefined) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((item) => (item.id === id ? { ...item, width, height } : item)),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (selectedItem?.providerId === "local" && selectedItem.item.id === id) {
|
||||
setSelectedItem({
|
||||
providerId: "local",
|
||||
item: { ...selectedItem.item, width, height },
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.warn("Failed to update media dimensions:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Handle dimensions detected for local images missing them
|
||||
const handleDimensionsDetected = React.useCallback(
|
||||
(id: string, width: number, height: number) => {
|
||||
if (updatedDimensionsRef.current.has(id)) return;
|
||||
updatedDimensionsRef.current.add(id);
|
||||
dimensionsMutation.mutate({ id, width, height });
|
||||
},
|
||||
[dimensionsMutation],
|
||||
);
|
||||
|
||||
// Get items for current view
|
||||
const items = React.useMemo(() => {
|
||||
if (activeProvider === "local") {
|
||||
const localItems = localData?.items || [];
|
||||
if (!mimeTypeFilter) return localItems;
|
||||
return localItems.filter((item) => item.mimeType.startsWith(mimeTypeFilter));
|
||||
}
|
||||
return providerData?.items || [];
|
||||
}, [activeProvider, localData?.items, providerData?.items, mimeTypeFilter]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
const file = files?.[0];
|
||||
if (file) {
|
||||
if (activeProvider === "local") {
|
||||
uploadLocalMutation.mutate(file);
|
||||
} else if (activeProviderInfo?.capabilities.upload) {
|
||||
uploadProviderMutation.mutate({ providerId: activeProvider, file });
|
||||
}
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedItem) {
|
||||
if (selectedItem.providerId === "local") {
|
||||
// When providerId is "local", item is always MediaItem
|
||||
onSelect(selectedItem.item as MediaItem);
|
||||
} else {
|
||||
// When providerId is not "local", item is always MediaProviderItem
|
||||
const providerItem = selectedItem.item as MediaProviderItem;
|
||||
const dims = providerDimensions[providerItem.id];
|
||||
const itemWithDims = dims
|
||||
? {
|
||||
...providerItem,
|
||||
width: providerItem.width ?? dims.width,
|
||||
height: providerItem.height ?? dims.height,
|
||||
}
|
||||
: providerItem;
|
||||
const mediaItem = providerItemToMediaItem(selectedItem.providerId, itemWithDims);
|
||||
onSelect(mediaItem);
|
||||
}
|
||||
onOpenChange(false);
|
||||
setSelectedItem(null);
|
||||
setImageUrl("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
setSelectedItem(null);
|
||||
setImageUrl("");
|
||||
setUrlError(null);
|
||||
};
|
||||
|
||||
const handleUrlSubmit = async () => {
|
||||
if (!imageUrl.trim()) return;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(imageUrl.trim());
|
||||
} catch {
|
||||
setUrlError("Please enter a valid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProbing(true);
|
||||
setUrlError(null);
|
||||
|
||||
try {
|
||||
const dimensions = await probeImageDimensions(url.href);
|
||||
const externalItem: MediaItem = {
|
||||
id: "",
|
||||
filename: url.pathname.split("/").pop() || "external-image",
|
||||
mimeType: "image/unknown",
|
||||
url: url.href,
|
||||
size: 0,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
onSelect(externalItem);
|
||||
onOpenChange(false);
|
||||
setImageUrl("");
|
||||
} catch {
|
||||
setUrlError("Could not load image from URL");
|
||||
} finally {
|
||||
setIsProbing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleUrlSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const canUpload =
|
||||
activeProvider === "local" || (activeProviderInfo?.capabilities.upload ?? false);
|
||||
const canSearch = activeProviderInfo?.capabilities.search ?? false;
|
||||
|
||||
// Build provider tabs - always show local first, then add external providers
|
||||
// Filter out "local" from API response since we add it manually
|
||||
const providerTabs = React.useMemo(() => {
|
||||
const tabs: Array<{ id: string; name: string; icon?: string }> = [
|
||||
{ id: "local", name: "Library", icon: undefined },
|
||||
];
|
||||
if (providers) {
|
||||
for (const p of providers) {
|
||||
if (p.id !== "local") {
|
||||
tabs.push({ id: p.id, name: p.name, icon: p.icon });
|
||||
}
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}, [providers]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={handleClose}>
|
||||
<Dialog className="p-6 max-w-4xl max-h-[80vh] flex flex-col" size="xl">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL Input */}
|
||||
<div className="border-b pb-4">
|
||||
<Label>Insert from URL</Label>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<div className="flex-1 relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
aria-label="Image URL"
|
||||
value={imageUrl}
|
||||
onChange={(e) => {
|
||||
setImageUrl(e.target.value);
|
||||
setUrlError(null);
|
||||
}}
|
||||
onKeyDown={handleUrlKeyDown}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleUrlSubmit} disabled={!imageUrl.trim() || isProbing}>
|
||||
{isProbing ? <Loader size="sm" /> : "Insert"}
|
||||
</Button>
|
||||
</div>
|
||||
{urlError && <p className="text-sm text-kumo-danger mt-1">{urlError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Divider with "or" */}
|
||||
<div className="relative py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-kumo-base px-2 text-kumo-subtle">or choose from library</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Tabs */}
|
||||
{providerTabs.length > 1 && (
|
||||
<div className="flex gap-2 border-b pb-3 flex-wrap">
|
||||
{providerTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveProvider(tab.id);
|
||||
setSelectedItem(null);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 h-9 text-sm font-medium rounded-md transition-colors whitespace-nowrap",
|
||||
activeProvider === tab.id
|
||||
? "bg-kumo-brand text-white"
|
||||
: "bg-kumo-tint hover:bg-kumo-tint/80 text-kumo-subtle",
|
||||
)}
|
||||
>
|
||||
{tab.icon &&
|
||||
(tab.icon.startsWith("data:") ? (
|
||||
<img src={tab.icon} alt="" className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<span aria-hidden="true">{tab.icon}</span>
|
||||
))}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between pb-3 gap-4">
|
||||
{/* Search (if provider supports it) */}
|
||||
{canSearch ? (
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
aria-label="Search media"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
{items.length} item{items.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Upload button (if provider supports it) */}
|
||||
{canUpload && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<Upload />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={mimeTypeFilter ? `${mimeTypeFilter}*` : undefined}
|
||||
className="sr-only"
|
||||
onChange={handleFileSelect}
|
||||
aria-label="Upload file"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload error */}
|
||||
<DialogError
|
||||
message={uploadError ? `Upload failed: ${uploadError}` : null}
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
{/* Media Grid */}
|
||||
<div className="flex-1 overflow-y-auto min-h-[300px]">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<Image className="h-12 w-12 text-kumo-subtle mb-4" aria-hidden="true" />
|
||||
<h3 className="text-lg font-medium">No media found</h3>
|
||||
<p className="text-sm text-kumo-subtle mt-1">
|
||||
{canSearch && searchQuery
|
||||
? "Try a different search term"
|
||||
: canUpload
|
||||
? "Upload an image to get started"
|
||||
: "No media available from this provider"}
|
||||
</p>
|
||||
{canUpload && !searchQuery && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
icon={<Upload />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Upload Image
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3 p-1"
|
||||
role="listbox"
|
||||
aria-label="Available media"
|
||||
>
|
||||
{activeProvider === "local"
|
||||
? (items as MediaItem[]).map((item) => (
|
||||
<MediaPickerItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={
|
||||
selectedItem?.providerId === "local" && selectedItem.item.id === item.id
|
||||
}
|
||||
onClick={() => setSelectedItem({ providerId: "local", item })}
|
||||
onDoubleClick={() => {
|
||||
onSelect(item);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onDimensionsDetected={handleDimensionsDetected}
|
||||
/>
|
||||
))
|
||||
: (items as MediaProviderItem[]).map((item) => (
|
||||
<ProviderMediaItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={
|
||||
selectedItem?.providerId === activeProvider &&
|
||||
selectedItem.item.id === item.id
|
||||
}
|
||||
onClick={() => setSelectedItem({ providerId: activeProvider, item })}
|
||||
onDoubleClick={() => {
|
||||
// Merge loaded dimensions for double-click select
|
||||
const dims = providerDimensions[item.id];
|
||||
const itemWithDims = dims
|
||||
? {
|
||||
...item,
|
||||
width: item.width ?? dims.width,
|
||||
height: item.height ?? dims.height,
|
||||
}
|
||||
: item;
|
||||
const mediaItem = providerItemToMediaItem(activeProvider, itemWithDims);
|
||||
onSelect(mediaItem);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onDimensionsLoaded={(width, height) => {
|
||||
setProviderDimensions((prev) => ({
|
||||
...prev,
|
||||
[item.id]: { width, height },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 border-t pt-4">
|
||||
<div className="flex-1 text-sm text-kumo-subtle">
|
||||
{selectedItem && (
|
||||
<span>
|
||||
Selected: <strong>{selectedItem.item.filename}</strong>
|
||||
{selectedItem.providerId !== "local" && (
|
||||
<span className="ml-2 text-xs">
|
||||
(from {providers?.find((p) => p.id === selectedItem.providerId)?.name})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedItem}>
|
||||
Insert
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface MediaPickerItemProps {
|
||||
item: MediaItem;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick: () => void;
|
||||
onDimensionsDetected?: (id: string, width: number, height: number) => void;
|
||||
}
|
||||
|
||||
function MediaPickerItem({
|
||||
item,
|
||||
selected,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onDimensionsDetected,
|
||||
}: MediaPickerItemProps) {
|
||||
const isImage = item.mimeType.startsWith("image/");
|
||||
const needsDimensions = isImage && (!item.width || !item.height);
|
||||
|
||||
const handleImageLoad = React.useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (needsDimensions && onDimensionsDetected) {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
onDimensionsDetected(item.id, img.naturalWidth, img.naturalHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
[needsDimensions, onDimensionsDetected, item.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<li role="option" aria-selected={selected}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative aspect-square w-full rounded-lg border-2 overflow-hidden transition-all",
|
||||
"hover:border-kumo-brand/50 focus:outline-none focus:ring-2 focus:ring-kumo-ring",
|
||||
selected ? "border-kumo-brand ring-2 ring-kumo-brand/20" : "border-transparent",
|
||||
)}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
aria-label={`${item.filename}${selected ? " (selected)" : ""}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
|
||||
<span className="text-3xl" aria-hidden="true">
|
||||
{getFileIcon(item.mimeType)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
className="absolute inset-0 bg-kumo-brand/20 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="bg-kumo-brand text-white rounded-full p-1">
|
||||
<Check className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="text-xs text-white truncate">{item.filename}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderMediaItemProps {
|
||||
item: MediaProviderItem;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick: () => void;
|
||||
/** Callback when image dimensions are loaded (for providers that don't return dimensions) */
|
||||
onDimensionsLoaded?: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
function ProviderMediaItem({
|
||||
item,
|
||||
selected,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onDimensionsLoaded,
|
||||
}: ProviderMediaItemProps) {
|
||||
const isImage = item.mimeType.startsWith("image/");
|
||||
const needsDimensions = isImage && (!item.width || !item.height);
|
||||
|
||||
const handleImageLoad = React.useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (needsDimensions && onDimensionsLoaded) {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
onDimensionsLoaded(img.naturalWidth, img.naturalHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
[needsDimensions, onDimensionsLoaded],
|
||||
);
|
||||
|
||||
return (
|
||||
<li role="option" aria-selected={selected}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative aspect-square w-full rounded-lg border-2 overflow-hidden transition-all",
|
||||
"hover:border-kumo-brand/50 focus:outline-none focus:ring-2 focus:ring-kumo-ring",
|
||||
selected ? "border-kumo-brand ring-2 ring-kumo-brand/20" : "border-transparent",
|
||||
)}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
aria-label={`${item.filename}${selected ? " (selected)" : ""}`}
|
||||
>
|
||||
{isImage && item.previewUrl ? (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
|
||||
<span className="text-3xl" aria-hidden="true">
|
||||
{getFileIcon(item.mimeType)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
className="absolute inset-0 bg-kumo-brand/20 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="bg-kumo-brand text-white rounded-full p-1">
|
||||
<Check className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="text-xs text-white truncate">{item.filename}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default MediaPickerModal;
|
||||
445
packages/admin/src/components/MenuEditor.tsx
Normal file
445
packages/admin/src/components/MenuEditor.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Menu Editor component
|
||||
*
|
||||
* Edit menu items with basic reordering (simplified version without drag-and-drop)
|
||||
*/
|
||||
|
||||
import { Button, Dialog, Input, Select, Toast } from "@cloudflare/kumo";
|
||||
import {
|
||||
Plus,
|
||||
Trash,
|
||||
CaretUp,
|
||||
CaretDown,
|
||||
Link as LinkIcon,
|
||||
ArrowLeft,
|
||||
X,
|
||||
File as FileIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useNavigate } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchMenu,
|
||||
createMenuItem,
|
||||
deleteMenuItem,
|
||||
updateMenuItem,
|
||||
reorderMenuItems,
|
||||
type MenuItem,
|
||||
} from "../lib/api";
|
||||
import { ContentPickerModal } from "./ContentPickerModal";
|
||||
import { DialogError, getMutationError } from "./DialogError.js";
|
||||
|
||||
export function MenuEditor() {
|
||||
const { name } = useParams({ from: "/_admin/menus/$name" });
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
const [isAddOpen, setIsAddOpen] = React.useState(false);
|
||||
const [isContentPickerOpen, setIsContentPickerOpen] = React.useState(false);
|
||||
const [editingItem, setEditingItem] = React.useState<MenuItem | null>(null);
|
||||
const [localItems, setLocalItems] = React.useState<MenuItem[]>([]);
|
||||
const [addError, setAddError] = React.useState<string | null>(null);
|
||||
const [editError, setEditError] = React.useState<string | null>(null);
|
||||
|
||||
const { data: menu, isLoading } = useQuery({
|
||||
queryKey: ["menu", name],
|
||||
queryFn: () => fetchMenu(name),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Sync local items with fetched data
|
||||
React.useEffect(() => {
|
||||
if (menu?.items) {
|
||||
setLocalItems(menu.items);
|
||||
}
|
||||
}, [menu]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (input: Parameters<typeof createMenuItem>[1]) => createMenuItem(name, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
|
||||
setIsAddOpen(false);
|
||||
toastManager.add({ title: "Item added", description: "Menu item has been added." });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setAddError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (itemId: string) => deleteMenuItem(name, itemId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
|
||||
toastManager.add({
|
||||
title: "Item deleted",
|
||||
description: "Menu item has been deleted.",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toastManager.add({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
itemId,
|
||||
input,
|
||||
}: {
|
||||
itemId: string;
|
||||
input: Parameters<typeof updateMenuItem>[2];
|
||||
}) => updateMenuItem(name, itemId, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
|
||||
setEditingItem(null);
|
||||
toastManager.add({
|
||||
title: "Item updated",
|
||||
description: "Menu item has been updated.",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setEditError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (input: Parameters<typeof reorderMenuItems>[1]) => reorderMenuItems(name, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
|
||||
toastManager.add({
|
||||
title: "Order saved",
|
||||
description: "Menu order has been updated.",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toastManager.add({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddCustomLink = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setAddError(null);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const labelVal = formData.get("label");
|
||||
const urlVal = formData.get("url");
|
||||
const targetVal = formData.get("target");
|
||||
createMutation.mutate({
|
||||
type: "custom",
|
||||
label: typeof labelVal === "string" ? labelVal : "",
|
||||
customUrl: typeof urlVal === "string" ? urlVal : "",
|
||||
target: (typeof targetVal === "string" ? targetVal : "") || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddContent = (item: { collection: string; id: string; title: string }) => {
|
||||
createMutation.mutate({
|
||||
type: item.collection,
|
||||
label: item.title,
|
||||
referenceCollection: item.collection,
|
||||
referenceId: item.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateItem = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setEditError(null);
|
||||
if (!editingItem) return;
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const uLabelVal = formData.get("label");
|
||||
const uUrlVal = formData.get("url");
|
||||
const uTargetVal = formData.get("target");
|
||||
updateMutation.mutate({
|
||||
itemId: editingItem.id,
|
||||
input: {
|
||||
label: typeof uLabelVal === "string" ? uLabelVal : "",
|
||||
customUrl:
|
||||
editingItem.type === "custom" ? (typeof uUrlVal === "string" ? uUrlVal : "") : undefined,
|
||||
target: (typeof uTargetVal === "string" ? uTargetVal : "") || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const moveItem = (index: number, direction: "up" | "down") => {
|
||||
const newItems = [...localItems];
|
||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= newItems.length) return;
|
||||
|
||||
const currentItem = newItems[index];
|
||||
const targetItem = newItems[targetIndex];
|
||||
if (!currentItem || !targetItem) return;
|
||||
|
||||
newItems[index] = targetItem;
|
||||
newItems[targetIndex] = currentItem;
|
||||
|
||||
// Update sort orders
|
||||
const reorderedItems = newItems.map((item, i) => ({
|
||||
id: item.id,
|
||||
parentId: item.parent_id,
|
||||
sortOrder: i,
|
||||
}));
|
||||
|
||||
setLocalItems(newItems);
|
||||
reorderMutation.mutate({ items: reorderedItems });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-kumo-subtle">Loading menu...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!menu) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-kumo-subtle">Menu not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Back"
|
||||
onClick={() => navigate({ to: "/menus" })}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{menu.label}</h1>
|
||||
<p className="text-kumo-subtle">Edit menu items</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
icon={<FileIcon />}
|
||||
variant="outline"
|
||||
onClick={() => setIsContentPickerOpen(true)}
|
||||
>
|
||||
Add Content
|
||||
</Button>
|
||||
<Dialog.Root
|
||||
open={isAddOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddOpen(open);
|
||||
if (!open) setAddError(null);
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger
|
||||
render={(props) => (
|
||||
<Button {...props} icon={<Plus />}>
|
||||
Add Custom Link
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
Add Custom Link
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleAddCustomLink} className="space-y-4">
|
||||
<Input label="Label" name="label" required placeholder="Home" />
|
||||
<Input
|
||||
label="URL"
|
||||
name="url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<Select
|
||||
label="Target"
|
||||
name="target"
|
||||
defaultValue=""
|
||||
items={{ "": "Same window", _blank: "New window" }}
|
||||
>
|
||||
<Select.Option value="">Same window</Select.Option>
|
||||
<Select.Option value="_blank">New window</Select.Option>
|
||||
</Select>
|
||||
<DialogError message={addError || getMutationError(createMutation.error)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAddOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentPickerModal
|
||||
open={isContentPickerOpen}
|
||||
onOpenChange={setIsContentPickerOpen}
|
||||
onSelect={handleAddContent}
|
||||
/>
|
||||
|
||||
{localItems.length === 0 ? (
|
||||
<div className="border rounded-lg p-12 text-center">
|
||||
<LinkIcon className="mx-auto h-12 w-12 text-kumo-subtle mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No menu items yet</h3>
|
||||
<p className="text-kumo-subtle mb-4">Add links to build your navigation menu</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
icon={<FileIcon />}
|
||||
variant="outline"
|
||||
onClick={() => setIsContentPickerOpen(true)}
|
||||
>
|
||||
Add Content
|
||||
</Button>
|
||||
<Button icon={<Plus />} onClick={() => setIsAddOpen(true)}>
|
||||
Add Custom Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{localItems.map((item, index) => (
|
||||
<div key={item.id} className="border rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{item.label}</div>
|
||||
<div className="text-sm text-kumo-subtle">
|
||||
{item.type === "custom" ? (
|
||||
item.custom_url
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-full bg-kumo-brand/10 px-2 py-0.5 text-xs font-medium text-kumo-brand">
|
||||
{item.reference_collection ?? item.type}
|
||||
</span>
|
||||
)}
|
||||
{item.target === "_blank" && " (opens in new window)"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Move up"
|
||||
onClick={() => moveItem(index, "up")}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<CaretUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Move down"
|
||||
onClick={() => moveItem(index, "down")}
|
||||
disabled={index === localItems.length - 1}
|
||||
>
|
||||
<CaretDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditingItem(item)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Delete"
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog.Root
|
||||
open={editingItem !== null}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
setEditingItem(null);
|
||||
setEditError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
Edit Menu Item
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{editingItem && (
|
||||
<form onSubmit={handleUpdateItem} className="space-y-4">
|
||||
<Input label="Label" name="label" required defaultValue={editingItem.label} />
|
||||
{editingItem.type === "custom" && (
|
||||
<Input
|
||||
label="URL"
|
||||
name="url"
|
||||
type="url"
|
||||
required
|
||||
defaultValue={editingItem.custom_url || ""}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label="Target"
|
||||
name="target"
|
||||
defaultValue={editingItem.target || ""}
|
||||
items={{ "": "Same window", _blank: "New window" }}
|
||||
>
|
||||
<Select.Option value="">Same window</Select.Option>
|
||||
<Select.Option value="_blank">New window</Select.Option>
|
||||
</Select>
|
||||
<DialogError message={editError || getMutationError(updateMutation.error)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setEditingItem(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
packages/admin/src/components/MenuList.tsx
Normal file
215
packages/admin/src/components/MenuList.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Menu List component
|
||||
*
|
||||
* Displays all menus with ability to create, edit, and delete.
|
||||
*/
|
||||
|
||||
import { Button, Dialog, Input, Toast, buttonVariants } from "@cloudflare/kumo";
|
||||
import { Plus, Pencil, Trash, List as ListIcon } from "@phosphor-icons/react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchMenus, createMenu, deleteMenu } from "../lib/api";
|
||||
import { ConfirmDialog } from "./ConfirmDialog.js";
|
||||
import { DialogError, getMutationError } from "./DialogError.js";
|
||||
|
||||
export function MenuList() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const toastManager = Toast.useToastManager();
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||
const [deleteMenuName, setDeleteMenuName] = React.useState<string | null>(null);
|
||||
const [createError, setCreateError] = React.useState<string | null>(null);
|
||||
|
||||
const { data: menus, isLoading } = useQuery({
|
||||
queryKey: ["menus"],
|
||||
queryFn: fetchMenus,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createMenu,
|
||||
onSuccess: (menu) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["menus"] });
|
||||
setIsCreateOpen(false);
|
||||
toastManager.add({
|
||||
title: "Menu created",
|
||||
description: `Menu "${menu.label}" has been created.`,
|
||||
});
|
||||
void navigate({ to: "/menus/$name", params: { name: menu.name } });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setCreateError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteMenu,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["menus"] });
|
||||
setDeleteMenuName(null);
|
||||
toastManager.add({
|
||||
title: "Menu deleted",
|
||||
description: "The menu has been deleted.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const nameVal = formData.get("name");
|
||||
const name = typeof nameVal === "string" ? nameVal : "";
|
||||
const labelVal = formData.get("label");
|
||||
const label = typeof labelVal === "string" ? labelVal : "";
|
||||
createMutation.mutate({ name, label });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-kumo-subtle">Loading menus...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Menus</h1>
|
||||
<p className="text-kumo-subtle">Manage navigation menus for your site</p>
|
||||
</div>
|
||||
<Dialog.Root
|
||||
open={isCreateOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsCreateOpen(open);
|
||||
if (!open) setCreateError(null);
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger
|
||||
render={(props) => (
|
||||
<Button {...props} icon={<Plus />}>
|
||||
Create Menu
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
Create New Menu
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="primary"
|
||||
pattern="[a-z0-9-]+"
|
||||
title="Only lowercase letters, numbers, and hyphens"
|
||||
/>
|
||||
<p className="text-sm text-kumo-subtle mt-1">
|
||||
URL-friendly identifier (e.g., "primary", "footer")
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input label="Label" name="label" required placeholder="Primary Navigation" />
|
||||
<p className="text-sm text-kumo-subtle mt-1">Display name for admin interface</p>
|
||||
</div>
|
||||
<DialogError message={createError || getMutationError(createMutation.error)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
|
||||
{!menus || menus.length === 0 ? (
|
||||
<div className="border rounded-lg p-12 text-center">
|
||||
<ListIcon className="mx-auto h-12 w-12 text-kumo-subtle mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No menus yet</h3>
|
||||
<p className="text-kumo-subtle mb-4">Create your first navigation menu to get started</p>
|
||||
<Button icon={<Plus />} onClick={() => setIsCreateOpen(true)}>
|
||||
Create Menu
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{menus.map((menu) => (
|
||||
<div
|
||||
key={menu.id}
|
||||
className="border rounded-lg p-6 flex items-center justify-between hover:bg-kumo-tint transition-colors"
|
||||
>
|
||||
<Link to="/menus/$name" params={{ name: menu.name }} className="flex-1">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{menu.label}</h3>
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
{menu.name} • {menu.itemCount || 0} items
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to="/menus/$name"
|
||||
params={{ name: menu.name }}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteMenuName(menu.name)}
|
||||
aria-label={`Delete ${menu.name} menu`}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteMenuName !== null}
|
||||
onClose={() => {
|
||||
setDeleteMenuName(null);
|
||||
deleteMutation.reset();
|
||||
}}
|
||||
title="Delete Menu"
|
||||
description="Are you sure you want to delete this menu? This will also delete all menu items. This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={deleteMutation.isPending}
|
||||
error={deleteMutation.error}
|
||||
onConfirm={() => deleteMenuName && deleteMutation.mutate(deleteMenuName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
packages/admin/src/components/PluginFieldErrorBoundary.tsx
Normal file
48
packages/admin/src/components/PluginFieldErrorBoundary.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** The underlying field kind to show in the error message */
|
||||
fieldKind: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary that wraps trusted plugin field widgets.
|
||||
* On render error, shows a warning with a retry button instead of crashing the editor.
|
||||
*/
|
||||
export class PluginFieldErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="rounded-md border border-kumo-danger/50 bg-kumo-danger/5 p-3">
|
||||
<p className="text-sm font-medium text-kumo-danger">Plugin widget error</p>
|
||||
<p className="mt-1 text-xs text-kumo-subtle">
|
||||
{this.state.error?.message || "The plugin field widget failed to render."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-xs font-medium text-kumo-brand underline"
|
||||
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
581
packages/admin/src/components/PluginManager.tsx
Normal file
581
packages/admin/src/components/PluginManager.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Plugin Manager Component
|
||||
*
|
||||
* Displays list of configured plugins with enable/disable controls.
|
||||
* Extended with marketplace features: source badges, update checking,
|
||||
* update/uninstall for marketplace-installed plugins.
|
||||
*/
|
||||
|
||||
import { Badge, Button, Switch, Toast } from "@cloudflare/kumo";
|
||||
import {
|
||||
PuzzlePiece,
|
||||
Gear,
|
||||
FileText,
|
||||
SquaresFour,
|
||||
WebhooksLogo,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
ArrowsClockwise,
|
||||
Storefront,
|
||||
Trash,
|
||||
ShieldCheck,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchPlugins,
|
||||
enablePlugin,
|
||||
disablePlugin,
|
||||
type PluginInfo,
|
||||
type AdminManifest,
|
||||
CAPABILITY_LABELS,
|
||||
} from "../lib/api";
|
||||
import {
|
||||
checkPluginUpdates,
|
||||
updateMarketplacePlugin,
|
||||
uninstallMarketplacePlugin,
|
||||
type PluginUpdateInfo,
|
||||
} from "../lib/api/marketplace.js";
|
||||
import { safeIconUrl } from "../lib/url.js";
|
||||
import { cn } from "../lib/utils";
|
||||
import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js";
|
||||
import { DialogError, getMutationError } from "./DialogError.js";
|
||||
|
||||
export interface PluginManagerProps {
|
||||
/** Admin manifest — used to check if marketplace is configured */
|
||||
manifest?: AdminManifest;
|
||||
}
|
||||
|
||||
export function PluginManager({ manifest }: PluginManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
const hasMarketplace = !!manifest?.marketplace;
|
||||
|
||||
const {
|
||||
data: plugins,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryFn: fetchPlugins,
|
||||
});
|
||||
|
||||
const {
|
||||
data: updates,
|
||||
refetch: refetchUpdates,
|
||||
isFetching: isCheckingUpdates,
|
||||
} = useQuery({
|
||||
queryKey: ["plugin-updates"],
|
||||
queryFn: checkPluginUpdates,
|
||||
enabled: false, // Only fetch on demand
|
||||
});
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: enablePlugin,
|
||||
onSuccess: (plugin) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
||||
toastManager.add({
|
||||
title: "Plugin enabled",
|
||||
description: `${plugin.name} is now active`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toastManager.add({
|
||||
title: "Failed to enable plugin",
|
||||
description: err instanceof Error ? err.message : "An error occurred",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: disablePlugin,
|
||||
onSuccess: (plugin) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
||||
toastManager.add({
|
||||
title: "Plugin disabled",
|
||||
description: `${plugin.name} has been deactivated`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toastManager.add({
|
||||
title: "Failed to disable plugin",
|
||||
description: err instanceof Error ? err.message : "An error occurred",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMap = React.useMemo(() => {
|
||||
if (!updates) return new Map<string, PluginUpdateInfo>();
|
||||
return new Map(updates.map((u) => [u.pluginId, u]));
|
||||
}, [updates]);
|
||||
|
||||
const hasMarketplacePlugins = plugins?.some((p) => p.source === "marketplace");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">Plugins</h1>
|
||||
<div className="text-kumo-subtle">Loading plugins...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">Plugins</h1>
|
||||
<div className="text-kumo-danger">Failed to load plugins: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Plugins</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasMarketplacePlugins && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void refetchUpdates()}
|
||||
disabled={isCheckingUpdates}
|
||||
>
|
||||
<ArrowsClockwise
|
||||
className={cn("mr-2 h-4 w-4", isCheckingUpdates && "animate-spin")}
|
||||
/>
|
||||
Check for updates
|
||||
</Button>
|
||||
)}
|
||||
{hasMarketplace && (
|
||||
<Link to="/plugins/marketplace">
|
||||
<Button variant="ghost">
|
||||
<Storefront className="mr-2 h-4 w-4" />
|
||||
Marketplace
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-sm text-kumo-subtle">{plugins?.length ?? 0} plugins</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-kumo-subtle">
|
||||
Manage installed plugins. Enable or disable plugins to control their functionality.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{plugins?.map((plugin) => (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
updateInfo={updateMap.get(plugin.id)}
|
||||
onEnable={() => enableMutation.mutate(plugin.id)}
|
||||
onDisable={() => disableMutation.mutate(plugin.id)}
|
||||
isToggling={enableMutation.isPending || disableMutation.isPending}
|
||||
hasMarketplace={hasMarketplace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{plugins?.length === 0 && (
|
||||
<div className="rounded-lg border bg-kumo-base p-8 text-center">
|
||||
<PuzzlePiece className="mx-auto h-12 w-12 text-kumo-subtle" />
|
||||
<h3 className="mt-4 text-lg font-medium">No plugins configured</h3>
|
||||
<p className="mt-2 text-sm text-kumo-subtle">
|
||||
{hasMarketplace ? (
|
||||
<>
|
||||
Browse the{" "}
|
||||
<Link to="/plugins/marketplace" className="text-kumo-brand hover:underline">
|
||||
marketplace
|
||||
</Link>{" "}
|
||||
to install plugins, or add them to your astro.config.mjs.
|
||||
</>
|
||||
) : (
|
||||
"Add plugins to your astro.config.mjs to extend EmDash functionality."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: PluginInfo;
|
||||
updateInfo?: PluginUpdateInfo;
|
||||
onEnable: () => void;
|
||||
onDisable: () => void;
|
||||
isToggling: boolean;
|
||||
/** Whether the marketplace is configured (controls "View in Marketplace" link) */
|
||||
hasMarketplace: boolean;
|
||||
}
|
||||
|
||||
function PluginCard({
|
||||
plugin,
|
||||
updateInfo,
|
||||
onEnable,
|
||||
onDisable,
|
||||
isToggling,
|
||||
hasMarketplace,
|
||||
}: PluginCardProps) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [showUpdateConsent, setShowUpdateConsent] = React.useState(false);
|
||||
const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
|
||||
const isMarketplace = plugin.source === "marketplace";
|
||||
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
|
||||
onSuccess: () => {
|
||||
setShowUpdateConsent(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["plugin-updates"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
||||
toastManager.add({
|
||||
title: "Plugin updated",
|
||||
description: `${plugin.name} updated to v${updateInfo?.latest}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: (deleteData: boolean) => uninstallMarketplacePlugin(plugin.id, { deleteData }),
|
||||
onSuccess: () => {
|
||||
setShowUninstallConfirm(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
|
||||
toastManager.add({
|
||||
title: "Plugin uninstalled",
|
||||
description: `${plugin.name} has been removed`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = () => {
|
||||
if (plugin.enabled) {
|
||||
onDisable();
|
||||
} else {
|
||||
onEnable();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-kumo-base transition-colors",
|
||||
!plugin.enabled && "opacity-75",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
{/* Plugin icon */}
|
||||
{plugin.iconUrl ? (
|
||||
<img
|
||||
src={safeIconUrl(plugin.iconUrl, 80) ?? undefined}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||
plugin.enabled ? "bg-kumo-brand/10" : "bg-kumo-tint",
|
||||
)}
|
||||
>
|
||||
<PuzzlePiece
|
||||
className={cn("h-5 w-5", plugin.enabled ? "text-kumo-brand" : "text-kumo-subtle")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{plugin.name}</h3>
|
||||
<span className="text-xs text-kumo-subtle">v{plugin.version}</span>
|
||||
{!plugin.enabled && <Badge variant="secondary">Disabled</Badge>}
|
||||
{isMarketplace && <Badge variant="secondary">Marketplace</Badge>}
|
||||
{hasUpdate && (
|
||||
<Badge variant="outline" className="border-kumo-brand text-kumo-brand">
|
||||
v{updateInfo.latest} available
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{plugin.description && (
|
||||
<p className="mt-0.5 text-sm text-kumo-subtle line-clamp-1">{plugin.description}</p>
|
||||
)}
|
||||
|
||||
{/* Feature indicators + inline capabilities */}
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-kumo-subtle">
|
||||
{plugin.hasAdminPages && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
Pages
|
||||
</span>
|
||||
)}
|
||||
{plugin.hasDashboardWidgets && (
|
||||
<span className="flex items-center gap-1">
|
||||
<SquaresFour className="h-3 w-3" />
|
||||
Widgets
|
||||
</span>
|
||||
)}
|
||||
{plugin.hasHooks && (
|
||||
<span className="flex items-center gap-1">
|
||||
<WebhooksLogo className="h-3 w-3" />
|
||||
Hooks
|
||||
</span>
|
||||
)}
|
||||
{plugin.capabilities.length > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={plugin.capabilities.map((c) => CAPABILITY_LABELS[c] ?? c).join(", ")}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{plugin.capabilities.length} permission
|
||||
{plugin.capabilities.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUpdate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowUpdateConsent(true)}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? "Updating..." : `Update to v${updateInfo.latest}`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMarketplace && hasMarketplace && (
|
||||
<Link to="/plugins/marketplace/$pluginId" params={{ pluginId: plugin.id }}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Storefront className="mr-1.5 h-3.5 w-3.5" />
|
||||
View in Marketplace
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{plugin.hasAdminPages && plugin.enabled && (
|
||||
<Link to="/plugins/$pluginId/$" params={{ pluginId: plugin.id, _splat: "settings" }}>
|
||||
<Button variant="ghost" shape="square" aria-label="Settings">
|
||||
<Gear className="h-4 w-4" />
|
||||
<span className="sr-only">Settings</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
checked={plugin.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isToggling}
|
||||
aria-label={plugin.enabled ? "Disable plugin" : "Enable plugin"}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label={expanded ? "Collapse details" : "Expand details"}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? <CaretDown className="h-4 w-4" /> : <CaretRight className="h-4 w-4" />}
|
||||
<span className="sr-only">{expanded ? "Collapse" : "Expand"} details</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="border-t px-4 py-3 space-y-3">
|
||||
{/* Capabilities */}
|
||||
{plugin.capabilities.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-kumo-subtle uppercase tracking-wider mb-1">
|
||||
Capabilities
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{plugin.capabilities.map((cap) => (
|
||||
<span
|
||||
key={cap}
|
||||
className="inline-flex items-center rounded-md bg-kumo-tint px-2 py-0.5 text-xs"
|
||||
title={CAPABILITY_LABELS[cap]}
|
||||
>
|
||||
{CAPABILITY_LABELS[cap] ?? cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
{isMarketplace && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-kumo-subtle uppercase tracking-wider mb-1">
|
||||
Source
|
||||
</h4>
|
||||
<span className="text-xs text-kumo-subtle">
|
||||
Installed from marketplace (v{plugin.marketplaceVersion || plugin.version})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Package */}
|
||||
{plugin.package && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-kumo-subtle uppercase tracking-wider mb-1">
|
||||
Package
|
||||
</h4>
|
||||
<code className="text-xs bg-kumo-tint px-2 py-0.5 rounded">{plugin.package}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
{plugin.installedAt && (
|
||||
<div>
|
||||
<span className="text-kumo-subtle">Installed:</span>{" "}
|
||||
{new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
{plugin.activatedAt && (
|
||||
<div>
|
||||
<span className="text-kumo-subtle">Last enabled:</span>{" "}
|
||||
{new Date(plugin.activatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
{plugin.deactivatedAt && !plugin.enabled && (
|
||||
<div>
|
||||
<span className="text-kumo-subtle">Disabled:</span>{" "}
|
||||
{new Date(plugin.deactivatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Uninstall button for marketplace plugins */}
|
||||
{isMarketplace && (
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-kumo-danger hover:text-kumo-danger"
|
||||
onClick={() => setShowUninstallConfirm(true)}
|
||||
disabled={uninstallMutation.isPending}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update consent dialog */}
|
||||
{showUpdateConsent && updateInfo && (
|
||||
<CapabilityConsentDialog
|
||||
mode="update"
|
||||
pluginName={plugin.name}
|
||||
capabilities={plugin.capabilities}
|
||||
newCapabilities={[]} // WS3 will populate this from the diff
|
||||
isPending={updateMutation.isPending}
|
||||
error={getMutationError(updateMutation.error)}
|
||||
onConfirm={() => updateMutation.mutate()}
|
||||
onCancel={() => {
|
||||
setShowUpdateConsent(false);
|
||||
updateMutation.reset();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Uninstall confirmation */}
|
||||
{showUninstallConfirm && (
|
||||
<UninstallConfirmDialog
|
||||
pluginName={plugin.name}
|
||||
isPending={uninstallMutation.isPending}
|
||||
error={getMutationError(uninstallMutation.error)}
|
||||
onConfirm={(deleteData) => uninstallMutation.mutate(deleteData)}
|
||||
onCancel={() => {
|
||||
setShowUninstallConfirm(false);
|
||||
uninstallMutation.reset();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uninstall confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UninstallConfirmDialogProps {
|
||||
pluginName: string;
|
||||
isPending: boolean;
|
||||
error?: string | null;
|
||||
onConfirm: (deleteData: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UninstallConfirmDialog({
|
||||
pluginName,
|
||||
isPending,
|
||||
error,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: UninstallConfirmDialogProps) {
|
||||
const [deleteData, setDeleteData] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Uninstall confirmation"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => !isPending && onCancel()} />
|
||||
<div className="relative w-full max-w-sm rounded-lg border bg-kumo-base shadow-lg">
|
||||
<div className="p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">Uninstall {pluginName}?</h2>
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
This will remove the plugin and its bundle from your site.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteData}
|
||||
onChange={(e) => setDeleteData(e.target.checked)}
|
||||
className="rounded border"
|
||||
/>
|
||||
Also delete plugin storage data
|
||||
</label>
|
||||
<DialogError message={error} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||
<Button variant="ghost" onClick={onCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => onConfirm(deleteData)} disabled={isPending}>
|
||||
{isPending ? "Uninstalling..." : "Uninstall"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PluginManager;
|
||||
2383
packages/admin/src/components/PortableTextEditor.tsx
Normal file
2383
packages/admin/src/components/PortableTextEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
521
packages/admin/src/components/Redirects.tsx
Normal file
521
packages/admin/src/components/Redirects.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import { Badge, Button, Dialog, Input, Label, Switch } from "@cloudflare/kumo";
|
||||
import {
|
||||
ArrowRight,
|
||||
MagnifyingGlass,
|
||||
Plus,
|
||||
ArrowsLeftRight,
|
||||
Trash,
|
||||
PencilSimple,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
createRedirect,
|
||||
deleteRedirect,
|
||||
fetch404Summary,
|
||||
fetchRedirects,
|
||||
updateRedirect,
|
||||
} from "../lib/api/redirects.js";
|
||||
import type {
|
||||
CreateRedirectInput,
|
||||
NotFoundSummary,
|
||||
Redirect,
|
||||
UpdateRedirectInput,
|
||||
} from "../lib/api/redirects.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { ConfirmDialog } from "./ConfirmDialog.js";
|
||||
import { DialogError, getMutationError } from "./DialogError.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redirect form dialog (create + edit)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RedirectFormDialog({
|
||||
open,
|
||||
onClose,
|
||||
redirect,
|
||||
defaultSource,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Pass for edit mode */
|
||||
redirect?: Redirect;
|
||||
/** Pre-fill source for create mode (e.g. from 404 list) */
|
||||
defaultSource?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!redirect;
|
||||
|
||||
const [source, setSource] = useState(redirect?.source ?? defaultSource ?? "");
|
||||
const [destination, setDestination] = useState(redirect?.destination ?? "");
|
||||
const [type, setType] = useState(String(redirect?.type ?? 301));
|
||||
const [enabled, setEnabled] = useState(redirect?.enabled ?? true);
|
||||
const [groupName, setGroupName] = useState(redirect?.groupName ?? "");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (input: CreateRedirectInput) => createRedirect(input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (input: UpdateRedirectInput) => updateRedirect(redirect!.id, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = isEdit ? updateMutation : createMutation;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const input = {
|
||||
source: source.trim(),
|
||||
destination: destination.trim(),
|
||||
type: Number(type),
|
||||
enabled,
|
||||
groupName: groupName.trim() || null,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(input);
|
||||
} else {
|
||||
createMutation.mutate(input);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
{isEdit ? "Edit Redirect" : "New Redirect"}
|
||||
</Dialog.Title>
|
||||
<p className="text-sm text-kumo-subtle mt-1">
|
||||
{isEdit
|
||||
? "Update this redirect rule."
|
||||
: "Use [param] or [...rest] in paths for pattern matching."}
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Source path"
|
||||
placeholder="/old-page or /blog/[slug]"
|
||||
value={source}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSource(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Destination path"
|
||||
placeholder="/new-page or /articles/[slug]"
|
||||
value={destination}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDestination(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="redirect-type">Status code</Label>
|
||||
<select
|
||||
id="redirect-type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-kumo-line bg-kumo-base px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="301">301 Permanent</option>
|
||||
<option value="302">302 Temporary</option>
|
||||
<option value="307">307 Temporary (Strict)</option>
|
||||
<option value="308">308 Permanent (Strict)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Group (optional)"
|
||||
placeholder="e.g. import, blog"
|
||||
value={groupName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGroupName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} id="redirect-enabled" />
|
||||
<Label htmlFor="redirect-enabled">Enabled</Label>
|
||||
</div>
|
||||
|
||||
<DialogError message={getMutationError(mutation.error)} />
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending
|
||||
? isEdit
|
||||
? "Saving..."
|
||||
: "Creating..."
|
||||
: isEdit
|
||||
? "Save"
|
||||
: "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 404 Summary panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NotFoundPanel({
|
||||
items,
|
||||
onCreateRedirect,
|
||||
}: {
|
||||
items: NotFoundSummary[];
|
||||
onCreateRedirect: (path: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-kumo-subtle py-4 text-center">No 404 errors recorded yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg">
|
||||
<div className="flex items-center gap-4 py-2 px-4 border-b bg-kumo-tint/50 text-sm font-medium text-kumo-subtle">
|
||||
<div className="flex-1">Path</div>
|
||||
<div className="w-16 text-right">Hits</div>
|
||||
<div className="w-32">Last seen</div>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center gap-4 py-2 px-4 border-b last:border-0 text-sm"
|
||||
>
|
||||
<div className="flex-1 font-mono text-xs truncate">{item.path}</div>
|
||||
<div className="w-16 text-right tabular-nums">{item.count}</div>
|
||||
<div className="w-32 text-kumo-subtle text-xs">
|
||||
{(() => {
|
||||
const d = new Date(item.lastSeen);
|
||||
return Number.isNaN(d.getTime()) ? item.lastSeen : d.toLocaleDateString();
|
||||
})()}
|
||||
</div>
|
||||
<div className="w-8">
|
||||
<button
|
||||
onClick={() => onCreateRedirect(item.path)}
|
||||
className="text-kumo-subtle hover:text-kumo-default"
|
||||
title="Create redirect for this path"
|
||||
aria-label={`Create redirect for ${item.path}`}
|
||||
>
|
||||
<ArrowsLeftRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Redirects page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TabKey = "redirects" | "404s";
|
||||
|
||||
export function Redirects() {
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<TabKey>("redirects");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [filterEnabled, setFilterEnabled] = useState<string>("all");
|
||||
const [filterAuto, setFilterAuto] = useState<string>("all");
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(setDebouncedSearch, 300, search);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Dialog state
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editRedirect, setEditRedirect] = useState<Redirect | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [prefillSource, setPrefillSource] = useState("");
|
||||
|
||||
// Queries
|
||||
const enabledFilter = filterEnabled === "all" ? undefined : filterEnabled === "true";
|
||||
const autoFilter = filterAuto === "all" ? undefined : filterAuto === "true";
|
||||
|
||||
const redirectsQuery = useQuery({
|
||||
queryKey: ["redirects", debouncedSearch, enabledFilter, autoFilter],
|
||||
queryFn: () =>
|
||||
fetchRedirects({
|
||||
search: debouncedSearch || undefined,
|
||||
enabled: enabledFilter,
|
||||
auto: autoFilter,
|
||||
limit: 100,
|
||||
}),
|
||||
});
|
||||
|
||||
const notFoundQuery = useQuery({
|
||||
queryKey: ["redirects", "404-summary"],
|
||||
queryFn: () => fetch404Summary(50),
|
||||
enabled: tab === "404s",
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRedirect(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
|
||||
setDeleteId(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle enabled mutation
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||
updateRedirect(id, { enabled }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
|
||||
},
|
||||
onError: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["redirects"] });
|
||||
},
|
||||
});
|
||||
|
||||
function handleCreateFrom404(path: string) {
|
||||
setPrefillSource(path);
|
||||
setShowCreate(true);
|
||||
setTab("redirects");
|
||||
}
|
||||
|
||||
const redirects = redirectsQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Redirects</h1>
|
||||
<p className="text-kumo-subtle">Manage URL redirects and view 404 errors.</p>
|
||||
</div>
|
||||
<Button icon={<Plus />} onClick={() => setShowCreate(true)}>
|
||||
New Redirect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b">
|
||||
<button
|
||||
onClick={() => setTab("redirects")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
tab === "redirects"
|
||||
? "border-kumo-brand text-kumo-brand"
|
||||
: "border-transparent text-kumo-subtle hover:text-kumo-default",
|
||||
)}
|
||||
>
|
||||
Redirects
|
||||
{redirectsQuery.data && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{redirectsQuery.data.items.length}
|
||||
{redirectsQuery.data.nextCursor ? "+" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("404s")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
tab === "404s"
|
||||
? "border-kumo-brand text-kumo-brand"
|
||||
: "border-transparent text-kumo-subtle hover:text-kumo-default",
|
||||
)}
|
||||
>
|
||||
404 Errors
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "redirects" && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<MagnifyingGlass
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-kumo-subtle"
|
||||
size={16}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search source or destination..."
|
||||
className="pl-10"
|
||||
value={search}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterEnabled}
|
||||
onChange={(e) => setFilterEnabled(e.target.value)}
|
||||
className="h-10 rounded-md border border-kumo-line bg-kumo-base px-3 text-sm"
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterAuto}
|
||||
onChange={(e) => setFilterAuto(e.target.value)}
|
||||
className="h-10 rounded-md border border-kumo-line bg-kumo-base px-3 text-sm"
|
||||
>
|
||||
<option value="all">All types</option>
|
||||
<option value="false">Manual</option>
|
||||
<option value="true">Auto (slug change)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Redirect list */}
|
||||
{redirectsQuery.isLoading ? (
|
||||
<div className="py-12 text-center text-kumo-subtle">Loading redirects...</div>
|
||||
) : redirects.length === 0 ? (
|
||||
<div className="py-12 text-center text-kumo-subtle">
|
||||
<ArrowsLeftRight size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium">No redirects yet</p>
|
||||
<p className="text-sm mt-1">Create redirect rules to manage URL changes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<div className="flex items-center gap-4 py-2 px-4 border-b bg-kumo-tint/50 text-sm font-medium text-kumo-subtle">
|
||||
<div className="flex-1">Source</div>
|
||||
<div className="w-8 text-center" />
|
||||
<div className="flex-1">Destination</div>
|
||||
<div className="w-14 text-center">Code</div>
|
||||
<div className="w-16 text-right">Hits</div>
|
||||
<div className="w-20 text-center">Status</div>
|
||||
<div className="w-20" />
|
||||
</div>
|
||||
{redirects.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
"flex items-center gap-4 py-2 px-4 border-b last:border-0 text-sm",
|
||||
!r.enabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 font-mono text-xs truncate" title={r.source}>
|
||||
{r.source}
|
||||
</div>
|
||||
<div className="w-8 text-center text-kumo-subtle">
|
||||
<ArrowRight size={14} />
|
||||
</div>
|
||||
<div className="flex-1 font-mono text-xs truncate" title={r.destination}>
|
||||
{r.destination}
|
||||
</div>
|
||||
<div className="w-14 text-center">
|
||||
<Badge variant="secondary">{r.type}</Badge>
|
||||
</div>
|
||||
<div className="w-16 text-right tabular-nums text-kumo-subtle">{r.hits}</div>
|
||||
<div className="w-20 text-center">
|
||||
<Switch
|
||||
checked={r.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleMutation.mutate({
|
||||
id: r.id,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
aria-label={r.enabled ? "Disable redirect" : "Enable redirect"}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 flex items-center justify-end gap-1">
|
||||
{r.auto && (
|
||||
<Badge variant="outline" className="mr-1 text-xs">
|
||||
auto
|
||||
</Badge>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditRedirect(r)}
|
||||
className="p-1 text-kumo-subtle hover:text-kumo-default"
|
||||
title="Edit redirect"
|
||||
aria-label={`Edit redirect ${r.source}`}
|
||||
>
|
||||
<PencilSimple size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
className="p-1 text-kumo-subtle hover:text-kumo-danger"
|
||||
title="Delete redirect"
|
||||
aria-label={`Delete redirect ${r.source}`}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "404s" && (
|
||||
<NotFoundPanel items={notFoundQuery.data ?? []} onCreateRedirect={handleCreateFrom404} />
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
{showCreate && (
|
||||
<RedirectFormDialog
|
||||
open
|
||||
onClose={() => {
|
||||
setShowCreate(false);
|
||||
setPrefillSource("");
|
||||
}}
|
||||
defaultSource={prefillSource || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit dialog */}
|
||||
{editRedirect && (
|
||||
<RedirectFormDialog open onClose={() => setEditRedirect(null)} redirect={editRedirect} />
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteId}
|
||||
onClose={() => {
|
||||
setDeleteId(null);
|
||||
deleteMutation.reset();
|
||||
}}
|
||||
title="Delete Redirect?"
|
||||
description="This redirect rule will be permanently removed."
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={deleteMutation.isPending}
|
||||
error={deleteMutation.error}
|
||||
onConfirm={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
423
packages/admin/src/components/RevisionHistory.tsx
Normal file
423
packages/admin/src/components/RevisionHistory.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { Badge, Button, Loader, Toast } from "@cloudflare/kumo";
|
||||
import {
|
||||
ClockCounterClockwise,
|
||||
ArrowCounterClockwise,
|
||||
CaretDown,
|
||||
CaretUp,
|
||||
Plus,
|
||||
Minus,
|
||||
PencilSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchRevisions, restoreRevision, type Revision } from "../lib/api";
|
||||
import { formatRelativeTime } from "../lib/utils";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
// =============================================================================
|
||||
// Diff utilities
|
||||
// =============================================================================
|
||||
|
||||
type DiffKind = "added" | "removed" | "changed" | "unchanged";
|
||||
|
||||
interface FieldDiff {
|
||||
field: string;
|
||||
kind: DiffKind;
|
||||
oldValue?: unknown;
|
||||
newValue?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute field-level diff between two revision data snapshots.
|
||||
* `older` is the revision being viewed, `newer` is the next revision after it.
|
||||
*/
|
||||
function computeFieldDiff(
|
||||
older: Record<string, unknown>,
|
||||
newer: Record<string, unknown>,
|
||||
): FieldDiff[] {
|
||||
const allKeys = new Set([...Object.keys(older), ...Object.keys(newer)]);
|
||||
const diffs: FieldDiff[] = [];
|
||||
|
||||
for (const key of allKeys) {
|
||||
const inOlder = key in older;
|
||||
const inNewer = key in newer;
|
||||
|
||||
if (inOlder && !inNewer) {
|
||||
diffs.push({ field: key, kind: "removed", oldValue: older[key] });
|
||||
} else if (!inOlder && inNewer) {
|
||||
diffs.push({ field: key, kind: "added", newValue: newer[key] });
|
||||
} else {
|
||||
const oldJson = JSON.stringify(older[key]);
|
||||
const newJson = JSON.stringify(newer[key]);
|
||||
if (oldJson !== newJson) {
|
||||
diffs.push({ field: key, kind: "changed", oldValue: older[key], newValue: newer[key] });
|
||||
} else {
|
||||
diffs.push({ field: key, kind: "unchanged", oldValue: older[key], newValue: newer[key] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: changes first, then added, removed, unchanged
|
||||
const kindOrder: Record<DiffKind, number> = { changed: 0, added: 1, removed: 2, unchanged: 3 };
|
||||
diffs.sort((a, b) => kindOrder[a.kind] - kindOrder[b.kind]);
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/** Format a value for display in the diff view */
|
||||
function formatDiffValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
interface RevisionHistoryProps {
|
||||
collection: string;
|
||||
entryId: string;
|
||||
/** Called when a revision is successfully restored */
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as a full timestamp
|
||||
*/
|
||||
function formatFullDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RevisionHistory component - displays revision history for a content item
|
||||
* with ability to restore previous versions.
|
||||
*/
|
||||
export function RevisionHistory({ collection, entryId, onRestored }: RevisionHistoryProps) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [selectedRevision, setSelectedRevision] = React.useState<Revision | null>(null);
|
||||
const [restoreTarget, setRestoreTarget] = React.useState<Revision | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["revisions", collection, entryId],
|
||||
queryFn: () => fetchRevisions(collection, entryId, { limit: 20 }),
|
||||
enabled: isExpanded, // Only fetch when expanded
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (revisionId: string) => restoreRevision(revisionId),
|
||||
onSuccess: () => {
|
||||
// Invalidate content and revisions queries
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["content", collection, entryId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["revisions", collection, entryId],
|
||||
});
|
||||
setSelectedRevision(null);
|
||||
setRestoreTarget(null);
|
||||
onRestored?.();
|
||||
toastManager.add({
|
||||
title: "Revision restored",
|
||||
description: "Content has been updated to the selected revision.",
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toastManager.add({
|
||||
title: "Restore failed",
|
||||
description: err.message,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRestore = (revision: Revision) => {
|
||||
setRestoreTarget(revision);
|
||||
};
|
||||
|
||||
const revisions = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border bg-kumo-base">
|
||||
{/* Header - always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex w-full items-center justify-between p-4 text-left hover:bg-kumo-tint/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockCounterClockwise className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="font-semibold">Revisions</span>
|
||||
{total > 0 && <span className="text-xs text-kumo-subtle">({total})</span>}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<CaretUp className="h-4 w-4 text-kumo-subtle" />
|
||||
) : (
|
||||
<CaretDown className="h-4 w-4 text-kumo-subtle" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content - shown when expanded */}
|
||||
{isExpanded && (
|
||||
<div className="border-t px-4 pb-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-4 text-center text-sm text-kumo-danger">
|
||||
Failed to load revisions
|
||||
</div>
|
||||
) : revisions.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-kumo-subtle">No revisions yet</div>
|
||||
) : (
|
||||
<div className="space-y-1 pt-2">
|
||||
{revisions.map((revision, index) => (
|
||||
<RevisionItem
|
||||
key={revision.id}
|
||||
revision={revision}
|
||||
compareRevision={index > 0 ? revisions[index - 1] : undefined}
|
||||
isLatest={index === 0}
|
||||
isRestoring={
|
||||
restoreMutation.isPending && restoreMutation.variables === revision.id
|
||||
}
|
||||
onRestore={() => handleRestore(revision)}
|
||||
onSelect={() =>
|
||||
setSelectedRevision(selectedRevision?.id === revision.id ? null : revision)
|
||||
}
|
||||
isSelected={selectedRevision?.id === revision.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!restoreTarget}
|
||||
onClose={() => {
|
||||
setRestoreTarget(null);
|
||||
restoreMutation.reset();
|
||||
}}
|
||||
title="Restore Revision?"
|
||||
description={
|
||||
restoreTarget
|
||||
? `Restore this version from ${formatFullDate(restoreTarget.createdAt)}? This will update the current content to this revision's data.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Restore"
|
||||
pendingLabel="Restoring..."
|
||||
variant="primary"
|
||||
isPending={restoreMutation.isPending}
|
||||
error={restoreMutation.error}
|
||||
onConfirm={() => {
|
||||
if (restoreTarget) restoreMutation.mutate(restoreTarget.id);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface RevisionItemProps {
|
||||
revision: Revision;
|
||||
/** The next newer revision to compare against (undefined for the latest) */
|
||||
compareRevision?: Revision;
|
||||
isLatest: boolean;
|
||||
isRestoring: boolean;
|
||||
isSelected: boolean;
|
||||
onRestore: () => void;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function RevisionItem({
|
||||
revision,
|
||||
compareRevision,
|
||||
isLatest,
|
||||
isRestoring,
|
||||
isSelected,
|
||||
onRestore,
|
||||
onSelect,
|
||||
}: RevisionItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border p-3 transition-colors ${
|
||||
isSelected ? "border-kumo-brand bg-kumo-brand/5" : "hover:bg-kumo-tint/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<button type="button" onClick={onSelect} className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{formatRelativeTime(revision.createdAt)}</span>
|
||||
{isLatest && <Badge variant="outline">Current</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-kumo-subtle mt-0.5">
|
||||
{formatFullDate(revision.createdAt)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!isLatest && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore();
|
||||
}}
|
||||
disabled={isRestoring}
|
||||
className="shrink-0"
|
||||
title="Restore this version"
|
||||
aria-label="Restore this version"
|
||||
>
|
||||
{isRestoring ? <Loader size="sm" /> : <ArrowCounterClockwise className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diff view or snapshot - shown when selected */}
|
||||
{isSelected && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
{compareRevision ? (
|
||||
<RevisionDiffView older={revision.data} newer={compareRevision.data} />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs font-medium text-kumo-subtle mb-2">Content snapshot:</div>
|
||||
<pre className="text-xs bg-kumo-tint p-2 rounded overflow-auto max-h-48">
|
||||
{JSON.stringify(revision.data, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Diff view component
|
||||
// =============================================================================
|
||||
|
||||
interface RevisionDiffViewProps {
|
||||
older: Record<string, unknown>;
|
||||
newer: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function RevisionDiffView({ older, newer }: RevisionDiffViewProps) {
|
||||
const [showUnchanged, setShowUnchanged] = React.useState(false);
|
||||
const diffs = React.useMemo(() => computeFieldDiff(older, newer), [older, newer]);
|
||||
|
||||
const changedCount = diffs.filter((d) => d.kind !== "unchanged").length;
|
||||
const unchangedCount = diffs.length - changedCount;
|
||||
|
||||
if (diffs.length === 0) {
|
||||
return <div className="text-xs text-kumo-subtle text-center py-2">No fields to compare</div>;
|
||||
}
|
||||
|
||||
const visibleDiffs = showUnchanged ? diffs : diffs.filter((d) => d.kind !== "unchanged");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-kumo-subtle">
|
||||
{changedCount} change{changedCount === 1 ? "" : "s"} from next revision
|
||||
</div>
|
||||
{unchangedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUnchanged(!showUnchanged)}
|
||||
className="text-xs text-kumo-brand hover:underline"
|
||||
>
|
||||
{showUnchanged ? "Hide" : "Show"} {unchangedCount} unchanged
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{visibleDiffs.map((diff) => (
|
||||
<DiffFieldRow key={diff.field} diff={diff} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DIFF_STYLES: Record<DiffKind, { bg: string; icon: React.ReactNode; label: string }> = {
|
||||
added: {
|
||||
bg: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800",
|
||||
icon: <Plus className="h-3 w-3 text-green-600 dark:text-green-400" aria-hidden="true" />,
|
||||
label: "Added",
|
||||
},
|
||||
removed: {
|
||||
bg: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800",
|
||||
icon: <Minus className="h-3 w-3 text-red-600 dark:text-red-400" aria-hidden="true" />,
|
||||
label: "Removed",
|
||||
},
|
||||
changed: {
|
||||
bg: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800",
|
||||
icon: (
|
||||
<PencilSimple className="h-3 w-3 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
),
|
||||
label: "Changed",
|
||||
},
|
||||
unchanged: {
|
||||
bg: "bg-kumo-tint/50 border-kumo-line",
|
||||
icon: null,
|
||||
label: "Unchanged",
|
||||
},
|
||||
};
|
||||
|
||||
function DiffFieldRow({ diff }: { diff: FieldDiff }) {
|
||||
const style = DIFF_STYLES[diff.kind];
|
||||
|
||||
return (
|
||||
<div className={`rounded border px-3 py-2 text-xs ${style.bg}`}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{style.icon}
|
||||
<span className="font-medium">{diff.field}</span>
|
||||
</div>
|
||||
|
||||
{diff.kind === "changed" && (
|
||||
<div className="space-y-1 mt-1.5">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-red-600 dark:text-red-400 shrink-0">−</span>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono">
|
||||
{formatDiffValue(diff.oldValue)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-green-600 dark:text-green-400 shrink-0">+</span>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono">
|
||||
{formatDiffValue(diff.newValue)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diff.kind === "added" && (
|
||||
<pre className="whitespace-pre-wrap break-all font-mono mt-1">
|
||||
{formatDiffValue(diff.newValue)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{diff.kind === "removed" && (
|
||||
<pre className="whitespace-pre-wrap break-all font-mono mt-1">
|
||||
{formatDiffValue(diff.oldValue)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{diff.kind === "unchanged" && (
|
||||
<pre className="whitespace-pre-wrap break-all font-mono mt-1 text-kumo-subtle">
|
||||
{formatDiffValue(diff.oldValue)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
packages/admin/src/components/SandboxedPluginPage.tsx
Normal file
115
packages/admin/src/components/SandboxedPluginPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* SandboxedPluginPage
|
||||
*
|
||||
* Renders a plugin's admin page using Block Kit. Sends page_load/block_action/form_submit
|
||||
* interactions to the plugin's admin route and renders the returned blocks.
|
||||
*/
|
||||
|
||||
import { CircleNotch, WarningCircle } from "@phosphor-icons/react";
|
||||
import { BlockRenderer } from "@emdashcms/blocks";
|
||||
import type { Block, BlockInteraction, BlockResponse } from "@emdashcms/blocks";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch, API_BASE } from "../lib/api/client.js";
|
||||
|
||||
interface SandboxedPluginPageProps {
|
||||
pluginId: string;
|
||||
page: string;
|
||||
}
|
||||
|
||||
export function SandboxedPluginPage({ pluginId, page }: SandboxedPluginPageProps) {
|
||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<BlockResponse["toast"] | null>(null);
|
||||
|
||||
// Send an interaction to the plugin admin route
|
||||
const sendInteraction = useCallback(
|
||||
async (interaction: BlockInteraction) => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/plugins/${pluginId}/admin`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(interaction),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
setError(`Plugin responded with ${response.status}: ${text}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { data: BlockResponse };
|
||||
const data = body.data;
|
||||
setBlocks(data.blocks);
|
||||
setError(null);
|
||||
|
||||
if (data.toast) {
|
||||
setToast(data.toast);
|
||||
setTimeout(setToast, 4000, null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to communicate with plugin");
|
||||
}
|
||||
},
|
||||
[pluginId],
|
||||
);
|
||||
|
||||
// Initial page load
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void sendInteraction({ type: "page_load", page }).finally(() => setLoading(false));
|
||||
}, [sendInteraction, page]);
|
||||
|
||||
// Handle block actions
|
||||
const handleAction = useCallback(
|
||||
(interaction: BlockInteraction) => {
|
||||
void sendInteraction(interaction);
|
||||
},
|
||||
[sendInteraction],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<CircleNotch className="h-6 w-6 animate-spin text-kumo-subtle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/5 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<WarningCircle className="h-5 w-5 shrink-0 text-kumo-danger" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-kumo-danger">Plugin Error</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed right-4 top-4 z-50 rounded-lg border px-4 py-3 text-sm shadow-lg ${
|
||||
toast.type === "success"
|
||||
? "border-green-200 bg-green-50 text-green-800"
|
||||
: toast.type === "error"
|
||||
? "border-red-200 bg-red-50 text-red-800"
|
||||
: "border-blue-200 bg-blue-50 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BlockRenderer blocks={blocks} onAction={handleAction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
packages/admin/src/components/SandboxedPluginWidget.tsx
Normal file
82
packages/admin/src/components/SandboxedPluginWidget.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* SandboxedPluginWidget
|
||||
*
|
||||
* Renders a plugin's dashboard widget using Block Kit. Sends a page_load
|
||||
* interaction with page="widget:<widgetId>" to the plugin's admin route.
|
||||
*/
|
||||
|
||||
import { CircleNotch } from "@phosphor-icons/react";
|
||||
import { BlockRenderer } from "@emdashcms/blocks";
|
||||
import type { Block, BlockInteraction, BlockResponse } from "@emdashcms/blocks";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch, API_BASE } from "../lib/api/client.js";
|
||||
|
||||
interface SandboxedPluginWidgetProps {
|
||||
pluginId: string;
|
||||
widgetId: string;
|
||||
}
|
||||
|
||||
export function SandboxedPluginWidget({ pluginId, widgetId }: SandboxedPluginWidgetProps) {
|
||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sendInteraction = useCallback(
|
||||
async (interaction: BlockInteraction) => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/plugins/${pluginId}/admin`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(interaction),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setError(`Plugin error (${response.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { data: BlockResponse };
|
||||
const data = body.data;
|
||||
setBlocks(data.blocks);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Failed to load widget");
|
||||
}
|
||||
},
|
||||
[pluginId],
|
||||
);
|
||||
|
||||
// Initial widget load
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void sendInteraction({ type: "page_load", page: `widget:${widgetId}` }).finally(() =>
|
||||
setLoading(false),
|
||||
);
|
||||
}, [sendInteraction, widgetId]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(interaction: BlockInteraction) => {
|
||||
void sendInteraction(interaction);
|
||||
},
|
||||
[sendInteraction],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<CircleNotch className="h-5 w-5 animate-spin text-kumo-subtle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-sm text-kumo-subtle">{error}</p>;
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return <p className="text-sm text-kumo-subtle">No content</p>;
|
||||
}
|
||||
|
||||
return <BlockRenderer blocks={blocks} onAction={handleAction} />;
|
||||
}
|
||||
45
packages/admin/src/components/SaveButton.tsx
Normal file
45
packages/admin/src/components/SaveButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Save Button with inline feedback
|
||||
*
|
||||
* Shows state based on whether there are unsaved changes:
|
||||
* - "Saved" when clean (no unsaved changes)
|
||||
* - "Save" when dirty (has unsaved changes)
|
||||
* - "Saving..." while saving
|
||||
*/
|
||||
|
||||
import { Button, Loader } from "@cloudflare/kumo";
|
||||
import { FloppyDisk, Check } from "@phosphor-icons/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface SaveButtonProps extends Omit<ComponentProps<typeof Button>, "children" | "shape"> {
|
||||
/** Whether there are unsaved changes */
|
||||
isDirty: boolean;
|
||||
/** Whether currently saving */
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button that reflects save state
|
||||
*/
|
||||
export function SaveButton({ isDirty, isSaving, className, disabled, ...props }: SaveButtonProps) {
|
||||
const isSaved = !isDirty && !isSaving;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("min-w-[100px] transition-all", className)}
|
||||
disabled={disabled || isSaving || isSaved}
|
||||
variant={isSaved ? "secondary" : "primary"}
|
||||
icon={isSaving ? <Loader size="sm" /> : isSaved ? <Check /> : <FloppyDisk />}
|
||||
aria-live="polite"
|
||||
aria-busy={isSaving}
|
||||
{...props}
|
||||
>
|
||||
{isSaving ? "Saving..." : isSaved ? "Saved" : "Save"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveButton;
|
||||
248
packages/admin/src/components/SectionEditor.tsx
Normal file
248
packages/admin/src/components/SectionEditor.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Section editor page component
|
||||
*
|
||||
* Edit a section's content and metadata.
|
||||
*/
|
||||
|
||||
import { Button, Input, InputArea, Label, Loader, Toast } from "@cloudflare/kumo";
|
||||
import { ArrowLeft } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams, useNavigate } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchSection, updateSection, type Section, type UpdateSectionInput } from "../lib/api";
|
||||
import { slugify } from "../lib/utils";
|
||||
import { PortableTextEditor } from "./PortableTextEditor";
|
||||
import { SaveButton } from "./SaveButton";
|
||||
|
||||
export function SectionEditor() {
|
||||
const { slug } = useParams({ from: "/_admin/sections/$slug" });
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
|
||||
const {
|
||||
data: section,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["sections", slug],
|
||||
queryFn: () => fetchSection(slug),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (input: UpdateSectionInput) => updateSection(slug, input),
|
||||
onSuccess: (updated) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["sections"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["sections", slug] });
|
||||
toastManager.add({ title: "Section saved" });
|
||||
// If slug changed, navigate to new URL
|
||||
if (updated.slug !== slug) {
|
||||
void navigate({ to: "/sections/$slug", params: { slug: updated.slug } });
|
||||
}
|
||||
},
|
||||
onError: (mutationError: Error) => {
|
||||
toastManager.add({
|
||||
title: "Error saving section",
|
||||
description: mutationError.message,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !section) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/sections">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to sections">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Section Not Found</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<p className="text-kumo-subtle">
|
||||
{error ? error.message : `Section "${slug}" could not be found.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionEditorForm
|
||||
key={section.updatedAt}
|
||||
section={section}
|
||||
isSaving={updateMutation.isPending}
|
||||
onSave={(input) => updateMutation.mutate(input)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionEditorFormProps {
|
||||
section: Section;
|
||||
isSaving: boolean;
|
||||
onSave: (input: UpdateSectionInput) => void;
|
||||
}
|
||||
|
||||
function SectionEditorForm({ section, isSaving, onSave }: SectionEditorFormProps) {
|
||||
const [title, setTitle] = React.useState(section.title);
|
||||
const [sectionSlug, setSectionSlug] = React.useState(section.slug);
|
||||
const [slugTouched, setSlugTouched] = React.useState(true); // Existing sections have touched slugs
|
||||
const [description, setDescription] = React.useState(section.description || "");
|
||||
const [keywords, setKeywords] = React.useState(section.keywords.join(", "));
|
||||
const [content, setContent] = React.useState<unknown[]>(section.content);
|
||||
|
||||
// Track initial state for dirty checking
|
||||
const [lastSavedData] = React.useState(() =>
|
||||
JSON.stringify({
|
||||
title: section.title,
|
||||
slug: section.slug,
|
||||
description: section.description || "",
|
||||
keywords: section.keywords.join(", "),
|
||||
content: section.content,
|
||||
}),
|
||||
);
|
||||
|
||||
// Auto-generate slug from title if editing title and slug hasn't been manually changed
|
||||
React.useEffect(() => {
|
||||
if (!slugTouched && title && title !== section.title) {
|
||||
setSectionSlug(slugify(title));
|
||||
}
|
||||
}, [title, slugTouched, section.title]);
|
||||
|
||||
const currentData = React.useMemo(
|
||||
() => JSON.stringify({ title, slug: sectionSlug, description, keywords, content }),
|
||||
[title, sectionSlug, description, keywords, content],
|
||||
);
|
||||
const isDirty = currentData !== lastSavedData;
|
||||
|
||||
const handleSave = () => {
|
||||
const keywordsArray = keywords
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
onSave({
|
||||
title,
|
||||
slug: sectionSlug,
|
||||
description: description || undefined,
|
||||
keywords: keywordsArray,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/sections">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to sections">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{section.title}</h1>
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
{section.source === "theme" ? "Theme Section" : "Custom Section"} ·{" "}
|
||||
{section.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SaveButton isSaving={isSaving} isDirty={isDirty} onClick={handleSave} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Main content */}
|
||||
<div className="col-span-8 space-y-6">
|
||||
{/* Content editor */}
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<Label className="text-lg font-semibold mb-4 block">Content</Label>
|
||||
<PortableTextEditor
|
||||
value={content as Parameters<typeof PortableTextEditor>[0]["value"]}
|
||||
onChange={(value) => setContent(value as unknown[])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="col-span-4 space-y-6">
|
||||
{/* Metadata */}
|
||||
<div className="rounded-lg border bg-kumo-base p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">Section Details</h2>
|
||||
|
||||
<Input
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Section title"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={sectionSlug}
|
||||
onChange={(e) => {
|
||||
setSectionSlug(e.target.value);
|
||||
setSlugTouched(true);
|
||||
}}
|
||||
placeholder="section-slug"
|
||||
pattern="[a-z0-9-]+"
|
||||
/>
|
||||
<p className="text-xs text-kumo-subtle mt-1">
|
||||
Used to identify this section. Lowercase letters, numbers, and hyphens only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InputArea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this section is for..."
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Keywords"
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
placeholder="hero, banner, cta"
|
||||
/>
|
||||
<p className="text-xs text-kumo-subtle mt-1">Comma-separated keywords for search.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source info */}
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<h2 className="text-lg font-semibold mb-2">Source</h2>
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
{section.source === "theme" && (
|
||||
<>
|
||||
This section is provided by the theme. Editing will create a custom copy that
|
||||
overrides the theme version.
|
||||
</>
|
||||
)}
|
||||
{section.source === "user" && <>This is a custom section.</>}
|
||||
{section.source === "import" && <>This section was imported from another system.</>}
|
||||
</p>
|
||||
{section.themeId && (
|
||||
<p className="text-xs text-kumo-subtle mt-2">Theme ID: {section.themeId}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
packages/admin/src/components/SectionPickerModal.tsx
Normal file
169
packages/admin/src/components/SectionPickerModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Section Picker Modal
|
||||
*
|
||||
* A modal for selecting and inserting sections into content.
|
||||
*/
|
||||
|
||||
import { Button, Dialog, Input } from "@cloudflare/kumo";
|
||||
import { MagnifyingGlass, Stack, FolderOpen } from "@phosphor-icons/react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchSections, type Section } from "../lib/api";
|
||||
import { useDebouncedValue } from "../lib/hooks";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface SectionPickerModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (section: Section) => void;
|
||||
}
|
||||
|
||||
export function SectionPickerModal({ open, onOpenChange, onSelect }: SectionPickerModalProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
||||
|
||||
const { data: sectionsData, isLoading: sectionsLoading } = useQuery({
|
||||
queryKey: ["sections", { search: debouncedSearch }],
|
||||
queryFn: () =>
|
||||
fetchSections({
|
||||
search: debouncedSearch || undefined,
|
||||
}),
|
||||
enabled: open,
|
||||
});
|
||||
const sections = sectionsData?.items ?? [];
|
||||
|
||||
// Reset search when modal opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (section: Section) => {
|
||||
onSelect(section);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog className="p-6 max-w-3xl max-h-[80vh] flex flex-col" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight flex items-center gap-2">
|
||||
<Stack className="h-5 w-5" />
|
||||
Insert Section
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4 py-4 border-b">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
||||
<Input
|
||||
placeholder="Search sections..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section grid */}
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{sectionsLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-kumo-subtle">Loading sections...</div>
|
||||
</div>
|
||||
) : sections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<MagnifyingGlass className="h-8 w-8 text-kumo-subtle mb-2" />
|
||||
<p className="text-kumo-subtle">No sections found</p>
|
||||
<p className="text-sm text-kumo-subtle">Try adjusting your search</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FolderOpen className="h-8 w-8 text-kumo-subtle mb-2" />
|
||||
<p className="text-kumo-subtle">No sections available</p>
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
Create sections in the Sections library to use them here
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sections.map((section) => (
|
||||
<SectionCard
|
||||
key={section.id}
|
||||
section={section}
|
||||
onSelect={() => handleSelect(section)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ section, onSelect }: { section: Section; onSelect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"text-left rounded-lg border bg-kumo-base overflow-hidden transition-colors",
|
||||
"hover:border-kumo-brand hover:bg-kumo-tint/50",
|
||||
"focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2",
|
||||
)}
|
||||
>
|
||||
{/* Preview */}
|
||||
<div className="aspect-video bg-kumo-tint flex items-center justify-center">
|
||||
{section.previewUrl ? (
|
||||
<img
|
||||
src={section.previewUrl}
|
||||
alt={section.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Stack className="h-8 w-8 text-kumo-subtle" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3">
|
||||
<h4 className="font-medium truncate">{section.title}</h4>
|
||||
{section.description && (
|
||||
<p className="text-xs text-kumo-subtle line-clamp-2 mt-1">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
413
packages/admin/src/components/Sections.tsx
Normal file
413
packages/admin/src/components/Sections.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Sections library page component
|
||||
*
|
||||
* Browse, create, and manage reusable content sections (block patterns).
|
||||
*/
|
||||
|
||||
import { Button, Dialog, Input, InputArea, Toast } from "@cloudflare/kumo";
|
||||
import {
|
||||
Plus,
|
||||
MagnifyingGlass,
|
||||
Trash,
|
||||
PencilSimple,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
User,
|
||||
FileArrowDown,
|
||||
} from "@phosphor-icons/react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchSections,
|
||||
createSection,
|
||||
deleteSection,
|
||||
type Section,
|
||||
type SectionSource,
|
||||
} from "../lib/api";
|
||||
import { slugify } from "../lib/utils";
|
||||
import { ConfirmDialog } from "./ConfirmDialog.js";
|
||||
import { DialogError, getMutationError } from "./DialogError.js";
|
||||
|
||||
const sourceIcons: Record<SectionSource, React.ElementType> = {
|
||||
theme: Globe,
|
||||
user: User,
|
||||
import: FileArrowDown,
|
||||
};
|
||||
|
||||
const sourceLabels: Record<SectionSource, string> = {
|
||||
theme: "Theme",
|
||||
user: "Custom",
|
||||
import: "Imported",
|
||||
};
|
||||
|
||||
export function Sections() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||
const [deleteSlug, setDeleteSlug] = React.useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [selectedSource, setSelectedSource] = React.useState<SectionSource | null>(null);
|
||||
|
||||
// Create form state
|
||||
const [createTitle, setCreateTitle] = React.useState("");
|
||||
const [createSlug, setCreateSlug] = React.useState("");
|
||||
const [createDescription, setCreateDescription] = React.useState("");
|
||||
const [slugTouched, setSlugTouched] = React.useState(false);
|
||||
const [createError, setCreateError] = React.useState<string | null>(null);
|
||||
|
||||
// Reset form when dialog closes
|
||||
React.useEffect(() => {
|
||||
if (!isCreateOpen) {
|
||||
setCreateTitle("");
|
||||
setCreateSlug("");
|
||||
setCreateDescription("");
|
||||
setSlugTouched(false);
|
||||
setCreateError(null);
|
||||
}
|
||||
}, [isCreateOpen]);
|
||||
|
||||
const { data: sectionsData, isLoading: sectionsLoading } = useQuery({
|
||||
queryKey: ["sections", { source: selectedSource, search: searchQuery }],
|
||||
queryFn: () =>
|
||||
fetchSections({
|
||||
source: selectedSource || undefined,
|
||||
search: searchQuery || undefined,
|
||||
}),
|
||||
});
|
||||
const sections = sectionsData?.items ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createSection,
|
||||
onSuccess: (section) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["sections"] });
|
||||
setIsCreateOpen(false);
|
||||
toastManager.add({ title: "Section created" });
|
||||
// Navigate to edit the new section
|
||||
void navigate({ to: "/sections/$slug", params: { slug: section.slug } });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setCreateError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteSection,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["sections"] });
|
||||
setDeleteSlug(null);
|
||||
toastManager.add({ title: "Section deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
createMutation.mutate({
|
||||
slug: createSlug,
|
||||
title: createTitle,
|
||||
description: createDescription || undefined,
|
||||
content: [], // Start with empty content
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopySlug = (slug: string) => {
|
||||
void navigator.clipboard.writeText(slug);
|
||||
toastManager.add({ title: "Slug copied to clipboard" });
|
||||
};
|
||||
|
||||
const sectionToDelete = sections.find((s) => s.slug === deleteSlug);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Sections</h1>
|
||||
<p className="text-kumo-subtle">
|
||||
Reusable content blocks you can insert into any content
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Root open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<Dialog.Trigger
|
||||
render={(props) => (
|
||||
<Button {...props} icon={<Plus />}>
|
||||
New Section
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
Create Section
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<Input
|
||||
label="Title"
|
||||
value={createTitle}
|
||||
onChange={(e) => {
|
||||
const title = e.target.value;
|
||||
setCreateTitle(title);
|
||||
if (!slugTouched && title) {
|
||||
setCreateSlug(slugify(title));
|
||||
}
|
||||
}}
|
||||
required
|
||||
placeholder="Hero Banner"
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={createSlug}
|
||||
onChange={(e) => {
|
||||
setCreateSlug(e.target.value);
|
||||
setSlugTouched(true);
|
||||
}}
|
||||
required
|
||||
placeholder="hero-banner"
|
||||
pattern="[a-z0-9-]+"
|
||||
title="Lowercase letters, numbers, and hyphens only"
|
||||
/>
|
||||
<p className="text-xs text-kumo-subtle mt-1">
|
||||
Used to identify this section. Lowercase letters, numbers, and hyphens only.
|
||||
</p>
|
||||
</div>
|
||||
<InputArea
|
||||
label="Description"
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="A full-width hero banner with heading, text, and CTA button"
|
||||
rows={3}
|
||||
/>
|
||||
<DialogError message={createError || getMutationError(createMutation.error)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
||||
<Input
|
||||
placeholder="Search sections..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source filter */}
|
||||
<select
|
||||
value={selectedSource || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setSelectedSource(val === "theme" || val === "user" || val === "import" ? val : null);
|
||||
}}
|
||||
className="h-10 rounded-md border border-kumo-line bg-kumo-base px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="theme">Theme</option>
|
||||
<option value="user">Custom</option>
|
||||
<option value="import">Imported</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Section Grid */}
|
||||
{sectionsLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-kumo-subtle">Loading sections...</div>
|
||||
</div>
|
||||
) : sections.length === 0 ? (
|
||||
<div className="rounded-lg border bg-kumo-base p-12 text-center">
|
||||
{searchQuery || selectedSource ? (
|
||||
<>
|
||||
<MagnifyingGlass className="mx-auto h-12 w-12 text-kumo-subtle" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No sections found</h3>
|
||||
<p className="mt-2 text-kumo-subtle">Try adjusting your search or filters.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FolderOpen className="mx-auto h-12 w-12 text-kumo-subtle" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No sections yet</h3>
|
||||
<p className="mt-2 text-kumo-subtle">
|
||||
Create your first reusable content section to get started.
|
||||
</p>
|
||||
<Button className="mt-4" icon={<Plus />} onClick={() => setIsCreateOpen(true)}>
|
||||
Create Section
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sections.map((section) => (
|
||||
<SectionCard
|
||||
key={section.id}
|
||||
section={section}
|
||||
onEdit={() => navigate({ to: "/sections/$slug", params: { slug: section.slug } })}
|
||||
onDelete={() => setDeleteSlug(section.slug)}
|
||||
onCopySlug={() => handleCopySlug(section.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteSlug}
|
||||
onClose={() => {
|
||||
setDeleteSlug(null);
|
||||
deleteMutation.reset();
|
||||
}}
|
||||
title="Delete Section?"
|
||||
description={
|
||||
sectionToDelete?.source === "theme" ? (
|
||||
<>
|
||||
Theme-provided sections cannot be deleted. Edit the section to create a custom copy,
|
||||
then delete that.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will permanently delete "{sectionToDelete?.title}". This action cannot be undone.
|
||||
</>
|
||||
)
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={deleteMutation.isPending}
|
||||
error={deleteMutation.error}
|
||||
onConfirm={() => deleteSlug && deleteMutation.mutate(deleteSlug)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
section,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCopySlug,
|
||||
}: {
|
||||
section: Section;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onCopySlug: () => void;
|
||||
}) {
|
||||
const SourceIcon = sourceIcons[section.source];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-kumo-base overflow-hidden">
|
||||
{/* Preview area */}
|
||||
<div className="aspect-video bg-kumo-tint flex items-center justify-center">
|
||||
{section.previewUrl ? (
|
||||
<img
|
||||
src={section.previewUrl}
|
||||
alt={section.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-kumo-subtle text-sm">No preview</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{section.title}</h3>
|
||||
<p className="text-sm text-kumo-subtle truncate">{section.slug}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-kumo-subtle"
|
||||
title={sourceLabels[section.source]}
|
||||
>
|
||||
<SourceIcon className="h-3 w-3" />
|
||||
<span>{sourceLabels[section.source]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{section.description && (
|
||||
<p className="mt-2 text-sm text-kumo-subtle line-clamp-2">{section.description}</p>
|
||||
)}
|
||||
|
||||
{section.keywords.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{section.keywords.slice(0, 3).map((keyword) => (
|
||||
<span
|
||||
key={keyword}
|
||||
className="inline-flex items-center rounded bg-kumo-tint px-1.5 py-0.5 text-xs text-kumo-subtle"
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
{section.keywords.length > 3 && (
|
||||
<span className="text-xs text-kumo-subtle">+{section.keywords.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<PencilSimple />}
|
||||
onClick={onEdit}
|
||||
className="flex-1"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCopySlug}
|
||||
title="Copy slug"
|
||||
aria-label={`Copy ${section.slug} to clipboard`}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
title={section.source === "theme" ? "Cannot delete theme sections" : "Delete"}
|
||||
aria-label={`Delete ${section.title}`}
|
||||
disabled={section.source === "theme"}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
packages/admin/src/components/SeoPanel.tsx
Normal file
100
packages/admin/src/components/SeoPanel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* SEO Panel for Content Editor Sidebar
|
||||
*
|
||||
* Shows SEO metadata fields (title, description, OG image, canonical URL,
|
||||
* noIndex) when the collection has `hasSeo` enabled. Changes are sent
|
||||
* alongside content updates via the `seo` field on the update body.
|
||||
*/
|
||||
|
||||
import { Input, InputArea, Label, Switch } from "@cloudflare/kumo";
|
||||
import * as React from "react";
|
||||
|
||||
import type { ContentSeo, ContentSeoInput } from "../lib/api";
|
||||
|
||||
export interface SeoPanelProps {
|
||||
seo?: ContentSeo;
|
||||
onChange: (seo: ContentSeoInput) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact SEO metadata editor for the content sidebar.
|
||||
*/
|
||||
export function SeoPanel({ seo, onChange }: SeoPanelProps) {
|
||||
const [title, setTitle] = React.useState(seo?.title ?? "");
|
||||
const [description, setDescription] = React.useState(seo?.description ?? "");
|
||||
const [canonical, setCanonical] = React.useState(seo?.canonical ?? "");
|
||||
const [noIndex, setNoIndex] = React.useState(seo?.noIndex ?? false);
|
||||
|
||||
// Keep local state in sync when the prop changes (e.g. after save)
|
||||
React.useEffect(() => {
|
||||
setTitle(seo?.title ?? "");
|
||||
setDescription(seo?.description ?? "");
|
||||
setCanonical(seo?.canonical ?? "");
|
||||
setNoIndex(seo?.noIndex ?? false);
|
||||
}, [seo]);
|
||||
|
||||
const emitChange = (patch: Partial<ContentSeoInput>) => {
|
||||
onChange({
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
canonical: canonical || null,
|
||||
noIndex,
|
||||
...patch,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label="SEO Title"
|
||||
description="Overrides the page title in search engine results"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
emitChange({ title: e.target.value || null });
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<InputArea
|
||||
label="Meta Description"
|
||||
description={
|
||||
description
|
||||
? `${description.length}/160 characters`
|
||||
: "Brief summary shown below the title in search results"
|
||||
}
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
emitChange({ description: e.target.value || null });
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Canonical URL"
|
||||
description="Points search engines to the original version of this page, if it's duplicated from another URL"
|
||||
value={canonical}
|
||||
onChange={(e) => {
|
||||
setCanonical(e.target.value);
|
||||
emitChange({ canonical: e.target.value || null });
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div>
|
||||
<Label>Hide from search engines</Label>
|
||||
<p className="text-xs text-kumo-subtle">Add noindex meta tag</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={noIndex}
|
||||
onCheckedChange={(checked) => {
|
||||
setNoIndex(checked);
|
||||
emitChange({ noIndex: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
packages/admin/src/components/Settings.tsx
Normal file
115
packages/admin/src/components/Settings.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Gear,
|
||||
ShareNetwork,
|
||||
MagnifyingGlass,
|
||||
Shield,
|
||||
Globe,
|
||||
Key,
|
||||
Envelope,
|
||||
CaretRight,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { fetchManifest } from "../lib/api";
|
||||
|
||||
interface SettingsLinkProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function SettingsLink({ to, icon, title, description }: SettingsLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-kumo-base hover:bg-kumo-tint transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-kumo-subtle">{icon}</div>
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-kumo-subtle">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<CaretRight className="h-5 w-5 text-kumo-subtle" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings hub page — links to all settings sub-pages.
|
||||
*/
|
||||
export function Settings() {
|
||||
const { data: manifest } = useQuery({
|
||||
queryKey: ["manifest"],
|
||||
queryFn: fetchManifest,
|
||||
});
|
||||
|
||||
const showSecuritySettings = manifest?.authMode === "passkey";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
|
||||
{/* Site settings */}
|
||||
<div className="space-y-2">
|
||||
<SettingsLink
|
||||
to="/settings/general"
|
||||
icon={<Gear className="h-5 w-5" />}
|
||||
title="General"
|
||||
description="Site identity, logo, favicon, and reading preferences"
|
||||
/>
|
||||
<SettingsLink
|
||||
to="/settings/social"
|
||||
icon={<ShareNetwork className="h-5 w-5" />}
|
||||
title="Social Links"
|
||||
description="Social media profile links"
|
||||
/>
|
||||
<SettingsLink
|
||||
to="/settings/seo"
|
||||
icon={<MagnifyingGlass className="h-5 w-5" />}
|
||||
title="SEO"
|
||||
description="Search engine optimization and verification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Security & access — only for passkey auth */}
|
||||
{showSecuritySettings && (
|
||||
<div className="space-y-2">
|
||||
<SettingsLink
|
||||
to="/settings/security"
|
||||
icon={<Shield className="h-5 w-5" />}
|
||||
title="Security"
|
||||
description="Manage your passkeys and authentication"
|
||||
/>
|
||||
<SettingsLink
|
||||
to="/settings/allowed-domains"
|
||||
icon={<Globe className="h-5 w-5" />}
|
||||
title="Self-Signup Domains"
|
||||
description="Allow users from specific domains to sign up"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Always visible for admins */}
|
||||
<div className="space-y-2">
|
||||
<SettingsLink
|
||||
to="/settings/api-tokens"
|
||||
icon={<Key className="h-5 w-5" />}
|
||||
title="API Tokens"
|
||||
description="Create personal access tokens for programmatic API access"
|
||||
/>
|
||||
<SettingsLink
|
||||
to="/settings/email"
|
||||
icon={<Envelope className="h-5 w-5" />}
|
||||
title="Email"
|
||||
description="View email provider status and send test emails"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
534
packages/admin/src/components/SetupWizard.tsx
Normal file
534
packages/admin/src/components/SetupWizard.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Setup Wizard - Multi-step first-run setup page
|
||||
*
|
||||
* This component is NOT wrapped in the admin Shell.
|
||||
* It's a standalone page for initial site configuration.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Site Configuration (title, tagline, sample content)
|
||||
* 2. Admin Account (email, name)
|
||||
* 3. Passkey Registration
|
||||
*/
|
||||
|
||||
import { Button, Checkbox, Input, Loader } from "@cloudflare/kumo";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { apiFetch, parseApiResponse } from "../lib/api/client";
|
||||
import { PasskeyRegistration } from "./auth/PasskeyRegistration";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface SetupStatusResponse {
|
||||
needsSetup: boolean;
|
||||
step?: "start" | "site" | "admin" | "complete";
|
||||
seedInfo?: {
|
||||
name: string;
|
||||
description: string;
|
||||
collections: number;
|
||||
hasContent: boolean;
|
||||
};
|
||||
/** Auth mode - "cloudflare-access" or "passkey" */
|
||||
authMode?: "cloudflare-access" | "passkey";
|
||||
}
|
||||
|
||||
interface SetupSiteRequest {
|
||||
title: string;
|
||||
tagline?: string;
|
||||
includeContent: boolean;
|
||||
}
|
||||
|
||||
interface SetupSiteResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
/** In Access mode, setup is complete after site config */
|
||||
setupComplete?: boolean;
|
||||
result?: {
|
||||
collections: { created: number; skipped: number };
|
||||
fields: { created: number; skipped: number };
|
||||
taxonomies: { created: number; terms: number };
|
||||
menus: { created: number; items: number };
|
||||
widgetAreas: { created: number; widgets: number };
|
||||
settings: { applied: number };
|
||||
content: { created: number; skipped: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface SetupAdminRequest {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface SetupAdminResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
options?: unknown; // WebAuthn registration options
|
||||
}
|
||||
|
||||
type WizardStep = "site" | "admin" | "passkey";
|
||||
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
|
||||
async function fetchSetupStatus(): Promise<SetupStatusResponse> {
|
||||
const response = await apiFetch("/_emdash/api/setup/status");
|
||||
return parseApiResponse<SetupStatusResponse>(response, "Failed to fetch setup status");
|
||||
}
|
||||
|
||||
async function executeSiteSetup(data: SetupSiteRequest): Promise<SetupSiteResponse> {
|
||||
const response = await apiFetch("/_emdash/api/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
return parseApiResponse<SetupSiteResponse>(response, "Setup failed");
|
||||
}
|
||||
|
||||
async function executeAdminSetup(data: SetupAdminRequest): Promise<SetupAdminResponse> {
|
||||
const response = await apiFetch("/_emdash/api/setup/admin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
return parseApiResponse<SetupAdminResponse>(response, "Failed to create admin");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Step Components
|
||||
// ============================================================================
|
||||
|
||||
interface SiteStepProps {
|
||||
seedInfo?: SetupStatusResponse["seedInfo"];
|
||||
onNext: (data: SetupSiteRequest) => void;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function SiteStep({ seedInfo, onNext, isLoading, error }: SiteStepProps) {
|
||||
const [title, setTitle] = React.useState("");
|
||||
const [tagline, setTagline] = React.useState("");
|
||||
const [includeContent, setIncludeContent] = React.useState(true);
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!title.trim()) {
|
||||
newErrors.title = "Site title is required";
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
onNext({ title, tagline, includeContent });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Site Title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="My Awesome Blog"
|
||||
className={errors.title ? "border-kumo-danger" : ""}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.title && <p className="text-sm text-kumo-danger mt-1">{errors.title}</p>}
|
||||
|
||||
<Input
|
||||
label="Tagline"
|
||||
type="text"
|
||||
value={tagline}
|
||||
onChange={(e) => setTagline(e.target.value)}
|
||||
placeholder="Thoughts, tutorials, and more"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{seedInfo?.hasContent && (
|
||||
<Checkbox
|
||||
label="Include sample content (recommended for new sites)"
|
||||
checked={includeContent}
|
||||
onCheckedChange={(checked) => setIncludeContent(checked)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">{error}</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full justify-center" loading={isLoading} variant="primary">
|
||||
{isLoading ? <>Setting up...</> : "Continue →"}
|
||||
</Button>
|
||||
|
||||
{seedInfo && (
|
||||
<p className="text-xs text-kumo-subtle text-center">
|
||||
Template: {seedInfo.name} ({seedInfo.collections} collection
|
||||
{seedInfo.collections !== 1 ? "s" : ""})
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminStepProps {
|
||||
onNext: (data: SetupAdminRequest) => void;
|
||||
onBack: () => void;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function AdminStep({ onNext, onBack, isLoading, error }: AdminStepProps) {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [name, setName] = React.useState("");
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!email.trim()) {
|
||||
newErrors.email = "Email is required";
|
||||
} else if (!email.includes("@")) {
|
||||
newErrors.email = "Please enter a valid email";
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
onNext({ email, name: name || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Your Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className={errors.email ? "border-kumo-danger" : ""}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-kumo-danger mt-1">{errors.email}</p>}
|
||||
|
||||
<Input
|
||||
label="Your Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Jane Doe"
|
||||
disabled={isLoading}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="button" variant="outline" onClick={onBack} disabled={isLoading}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 justify-center"
|
||||
loading={isLoading}
|
||||
variant="primary"
|
||||
>
|
||||
{isLoading ? <>Preparing...</> : "Continue →"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface PasskeyStepProps {
|
||||
adminData: SetupAdminRequest;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function handlePasskeySuccess() {
|
||||
// Redirect to admin dashboard after successful registration
|
||||
window.location.href = "/_emdash/admin";
|
||||
}
|
||||
|
||||
function PasskeyStep({ adminData, onBack }: PasskeyStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-brand/10 mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-kumo-brand"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">Set up your passkey</h3>
|
||||
<p className="text-sm text-kumo-subtle mt-1">
|
||||
Passkeys are more secure than passwords. You'll use your device's biometrics, PIN, or
|
||||
security key to sign in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PasskeyRegistration
|
||||
optionsEndpoint="/_emdash/api/setup/admin"
|
||||
verifyEndpoint="/_emdash/api/setup/admin/verify"
|
||||
onSuccess={handlePasskeySuccess}
|
||||
buttonText="Create Passkey"
|
||||
additionalData={{ ...adminData }}
|
||||
/>
|
||||
|
||||
<Button type="button" variant="ghost" onClick={onBack} className="w-full">
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Progress Indicator
|
||||
// ============================================================================
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: WizardStep;
|
||||
useAccessAuth?: boolean;
|
||||
}
|
||||
|
||||
function StepIndicator({ currentStep, useAccessAuth }: StepIndicatorProps) {
|
||||
// In Access mode, only show the site step
|
||||
const steps = useAccessAuth
|
||||
? ([{ key: "site", label: "Site Settings" }] as const)
|
||||
: ([
|
||||
{ key: "site", label: "Site" },
|
||||
{ key: "admin", label: "Account" },
|
||||
{ key: "passkey", label: "Passkey" },
|
||||
] as const);
|
||||
|
||||
const currentIndex = steps.findIndex((s) => s.key === currentStep);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.key}>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
|
||||
${
|
||||
index < currentIndex
|
||||
? "bg-kumo-brand text-white"
|
||||
: index === currentIndex
|
||||
? "bg-kumo-brand text-white"
|
||||
: "bg-kumo-tint text-kumo-subtle"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{index < currentIndex ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 text-sm ${
|
||||
index <= currentIndex ? "text-kumo-default" : "text-kumo-subtle"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`w-12 h-0.5 mx-2 ${index < currentIndex ? "bg-kumo-brand" : "bg-kumo-tint"}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export function SetupWizard() {
|
||||
const [currentStep, setCurrentStep] = React.useState<WizardStep>("site");
|
||||
const [_siteData, setSiteData] = React.useState<SetupSiteRequest | null>(null);
|
||||
const [adminData, setAdminData] = React.useState<SetupAdminRequest | null>(null);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
// Check setup status
|
||||
const {
|
||||
data: status,
|
||||
isLoading: statusLoading,
|
||||
error: statusError,
|
||||
} = useQuery({
|
||||
queryKey: ["setup", "status"],
|
||||
queryFn: fetchSetupStatus,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Check if using Cloudflare Access auth
|
||||
const useAccessAuth = status?.authMode === "cloudflare-access";
|
||||
|
||||
// Site setup mutation
|
||||
const siteMutation = useMutation({
|
||||
mutationFn: executeSiteSetup,
|
||||
onSuccess: (data) => {
|
||||
setError(undefined);
|
||||
// In Access mode, setup is complete - redirect to admin
|
||||
if (data.setupComplete) {
|
||||
window.location.href = "/_emdash/admin";
|
||||
return;
|
||||
}
|
||||
// Otherwise continue to admin account creation
|
||||
setCurrentStep("admin");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Admin setup mutation
|
||||
const adminMutation = useMutation({
|
||||
mutationFn: executeAdminSetup,
|
||||
onSuccess: () => {
|
||||
setError(undefined);
|
||||
setCurrentStep("passkey");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Handle site step completion
|
||||
const handleSiteNext = (data: SetupSiteRequest) => {
|
||||
setSiteData(data);
|
||||
siteMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Handle admin step completion
|
||||
const handleAdminNext = (data: SetupAdminRequest) => {
|
||||
setAdminData(data);
|
||||
adminMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Redirect if setup already complete
|
||||
if (!statusLoading && status && !status.needsSetup) {
|
||||
window.location.href = "/_emdash/admin";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-kumo-base">
|
||||
<div className="text-center">
|
||||
<Loader />
|
||||
<p className="mt-4 text-kumo-subtle">Loading setup...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (statusError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-kumo-base">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-bold text-kumo-danger">Error</h1>
|
||||
<p className="mt-2 text-kumo-subtle">
|
||||
{statusError instanceof Error ? statusError.message : "Failed to load setup"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
|
||||
<div className="w-full max-w-lg">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-4xl font-bold mb-2">💫 EmDash</div>
|
||||
<h1 className="text-2xl font-semibold text-kumo-default">
|
||||
{currentStep === "site" && "Set up your site"}
|
||||
{currentStep === "admin" && "Create your account"}
|
||||
{currentStep === "passkey" && "Secure your account"}
|
||||
</h1>
|
||||
{useAccessAuth && currentStep === "site" && (
|
||||
<p className="text-sm text-kumo-subtle mt-2">You're signed in via Cloudflare Access</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<StepIndicator currentStep={currentStep} useAccessAuth={useAccessAuth} />
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
|
||||
{currentStep === "site" && (
|
||||
<SiteStep
|
||||
seedInfo={status?.seedInfo}
|
||||
onNext={handleSiteNext}
|
||||
isLoading={siteMutation.isPending}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "admin" && (
|
||||
<AdminStep
|
||||
onNext={handleAdminNext}
|
||||
onBack={() => {
|
||||
setError(undefined);
|
||||
setCurrentStep("site");
|
||||
}}
|
||||
isLoading={adminMutation.isPending}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "passkey" && adminData && (
|
||||
<PasskeyStep
|
||||
adminData={adminData}
|
||||
onBack={() => {
|
||||
setError(undefined);
|
||||
setCurrentStep("admin");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
packages/admin/src/components/Shell.tsx
Normal file
77
packages/admin/src/components/Shell.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useCurrentUser } from "../lib/api/current-user";
|
||||
import { AdminCommandPalette } from "./AdminCommandPalette";
|
||||
import { Header } from "./Header";
|
||||
import { Sidebar, SidebarNav } from "./Sidebar";
|
||||
import { WelcomeModal } from "./WelcomeModal";
|
||||
|
||||
export interface ShellProps {
|
||||
children: React.ReactNode;
|
||||
manifest: {
|
||||
collections: Record<string, { label: string }>;
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
package?: string;
|
||||
adminPages?: Array<{ path: string; label?: string; icon?: string }>;
|
||||
}
|
||||
>;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin shell layout with kumo Sidebar component.
|
||||
*
|
||||
* Sidebar.Provider wraps both the sidebar and main content area,
|
||||
* handling collapse state, mobile detection, and layout transitions.
|
||||
*/
|
||||
export function Shell({ children, manifest }: ShellProps) {
|
||||
const [welcomeModalOpen, setWelcomeModalOpen] = React.useState(false);
|
||||
|
||||
const { data: user } = useCurrentUser();
|
||||
|
||||
// Show welcome modal on first login
|
||||
React.useEffect(() => {
|
||||
if (user?.isFirstLogin) {
|
||||
setWelcomeModalOpen(true);
|
||||
}
|
||||
}, [user?.isFirstLogin]);
|
||||
|
||||
return (
|
||||
<Sidebar.Provider
|
||||
defaultOpen
|
||||
style={
|
||||
{
|
||||
height: "100svh",
|
||||
minHeight: "0",
|
||||
overflow: "hidden",
|
||||
"--sidebar-width-icon": "53px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Sidebar navigation */}
|
||||
<SidebarNav manifest={manifest} />
|
||||
|
||||
{/* Main content area — scrolls independently so sidebar stays full height */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* Welcome modal for first-time users */}
|
||||
{user && (
|
||||
<WelcomeModal
|
||||
open={welcomeModalOpen}
|
||||
onClose={() => setWelcomeModalOpen(false)}
|
||||
userName={user.name}
|
||||
userRole={user.role}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Command palette for quick navigation */}
|
||||
<AdminCommandPalette manifest={manifest} />
|
||||
</Sidebar.Provider>
|
||||
);
|
||||
}
|
||||
427
packages/admin/src/components/Sidebar.tsx
Normal file
427
packages/admin/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { Sidebar as KumoSidebar, Tooltip, useSidebar } from "@cloudflare/kumo";
|
||||
import {
|
||||
SquaresFour,
|
||||
FileText,
|
||||
Image,
|
||||
ChatCircle,
|
||||
Gear,
|
||||
PuzzlePiece,
|
||||
Storefront,
|
||||
Palette,
|
||||
Upload,
|
||||
Database,
|
||||
List,
|
||||
GridFour,
|
||||
Users,
|
||||
Stack,
|
||||
ArrowsLeftRight,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchCommentCounts } from "../lib/api/comments";
|
||||
import { useCurrentUser } from "../lib/api/current-user";
|
||||
import { usePluginAdmins } from "../lib/plugin-context";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
// Re-export for Shell.tsx and Header.tsx
|
||||
export { KumoSidebar as Sidebar, useSidebar };
|
||||
|
||||
// Role levels (matching @emdashcms/auth)
|
||||
const ROLE_ADMIN = 50;
|
||||
const ROLE_EDITOR = 40;
|
||||
|
||||
export interface SidebarNavProps {
|
||||
manifest: {
|
||||
collections: Record<string, { label: string }>;
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
package?: string;
|
||||
enabled?: boolean;
|
||||
adminMode?: "react" | "blocks" | "none";
|
||||
adminPages?: Array<{
|
||||
path: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
dashboardWidgets?: Array<{ id: string; title?: string }>;
|
||||
version?: string;
|
||||
}
|
||||
>;
|
||||
version?: string;
|
||||
marketplace?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
params?: Record<string, string>;
|
||||
/** Minimum role level required to see this item */
|
||||
minRole?: number;
|
||||
/** Optional badge count (e.g., pending comments) */
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation item rendered as a TanStack Router <Link> inside kumo's
|
||||
* Sidebar.MenuItem. Styled to match kumo MenuButton appearance.
|
||||
* This approach guarantees client-side navigation works correctly.
|
||||
*/
|
||||
function NavMenuLink({ item, isActive }: { item: NavItem; isActive: boolean }) {
|
||||
const { state } = useSidebar();
|
||||
const Icon = item.icon;
|
||||
|
||||
const link = (
|
||||
<Link
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- TanStack Router requires literal route types
|
||||
to={item.to as "/"}
|
||||
params={item.params}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-active={isActive || undefined}
|
||||
data-sidebar="menu-button"
|
||||
className={cn(
|
||||
"emdash-nav-link group/menu-button flex w-full min-w-0 items-center gap-2.5 rounded-md no-underline outline-none cursor-pointer",
|
||||
"min-h-[36px] px-3 py-1.5 text-[13px]",
|
||||
"transition-all duration-200 ease-out",
|
||||
isActive ? "bg-kumo-brand text-white" : "text-white/70 hover:text-white hover:bg-white/8",
|
||||
"focus-visible:ring-2 focus-visible:ring-kumo-brand/50",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"emdash-nav-icon size-[18px] shrink-0 transition-colors duration-200",
|
||||
isActive ? "text-white" : "text-white/60 group-hover/menu-button:text-white/90",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="emdash-nav-label flex flex-1 items-center min-w-0 text-left overflow-hidden">
|
||||
{item.label}
|
||||
{item.badge != null && item.badge > 0 && (
|
||||
<KumoSidebar.MenuBadge>{item.badge}</KumoSidebar.MenuBadge>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<KumoSidebar.MenuItem>
|
||||
{state === "collapsed" ? (
|
||||
<Tooltip content={item.label} side="right" asChild>
|
||||
{link}
|
||||
</Tooltip>
|
||||
) : (
|
||||
link
|
||||
)}
|
||||
</KumoSidebar.MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves a nav item's route path by substituting $param placeholders. */
|
||||
function resolveItemPath(item: NavItem): string {
|
||||
let path = item.to;
|
||||
if (item.params) {
|
||||
for (const [key, value] of Object.entries(item.params)) {
|
||||
path = path.replace(`$${key}`, value);
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Checks if a nav item is active based on the current router path. */
|
||||
function isItemActive(itemPath: string, currentPath: string): boolean {
|
||||
return itemPath === "/"
|
||||
? currentPath === "/"
|
||||
: currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin sidebar navigation using kumo's Sidebar compound component.
|
||||
*/
|
||||
export function SidebarNav({ manifest }: SidebarNavProps) {
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const pluginAdmins = usePluginAdmins();
|
||||
|
||||
const { data: user } = useCurrentUser();
|
||||
const userRole = user?.role ?? 0;
|
||||
|
||||
// Fetch pending comment count for badge
|
||||
const { data: commentCounts } = useQuery({
|
||||
queryKey: ["commentCounts"],
|
||||
queryFn: fetchCommentCounts,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
enabled: userRole >= ROLE_EDITOR,
|
||||
});
|
||||
|
||||
// --- Build nav item groups ---
|
||||
|
||||
const contentItems: NavItem[] = [{ to: "/", label: "Dashboard", icon: SquaresFour }];
|
||||
for (const [name, config] of Object.entries(manifest.collections)) {
|
||||
contentItems.push({
|
||||
to: "/content/$collection",
|
||||
label: config.label,
|
||||
icon: FileText,
|
||||
params: { collection: name },
|
||||
});
|
||||
}
|
||||
contentItems.push({ to: "/media", label: "Media", icon: Image });
|
||||
|
||||
const manageItems: NavItem[] = [
|
||||
{
|
||||
to: "/comments",
|
||||
label: "Comments",
|
||||
icon: ChatCircle,
|
||||
minRole: ROLE_EDITOR,
|
||||
badge: commentCounts?.pending,
|
||||
},
|
||||
{ to: "/menus", label: "Menus", icon: List, minRole: ROLE_EDITOR },
|
||||
{ to: "/redirects", label: "Redirects", icon: ArrowsLeftRight, minRole: ROLE_ADMIN },
|
||||
{ to: "/widgets", label: "Widgets", icon: GridFour, minRole: ROLE_EDITOR },
|
||||
{ to: "/sections", label: "Sections", icon: Stack, minRole: ROLE_EDITOR },
|
||||
{
|
||||
to: "/taxonomies/$taxonomy",
|
||||
label: "Categories",
|
||||
icon: FileText,
|
||||
params: { taxonomy: "category" },
|
||||
minRole: ROLE_EDITOR,
|
||||
},
|
||||
{
|
||||
to: "/taxonomies/$taxonomy",
|
||||
label: "Tags",
|
||||
icon: FileText,
|
||||
params: { taxonomy: "tag" },
|
||||
minRole: ROLE_EDITOR,
|
||||
},
|
||||
{ to: "/bylines", label: "Bylines", icon: FileText, minRole: ROLE_EDITOR },
|
||||
];
|
||||
|
||||
const adminItems: NavItem[] = [
|
||||
{ to: "/content-types", label: "Content Types", icon: Database, minRole: ROLE_ADMIN },
|
||||
{ to: "/users", label: "Users", icon: Users, minRole: ROLE_ADMIN },
|
||||
{ to: "/plugins-manager", label: "Plugins", icon: PuzzlePiece, minRole: ROLE_ADMIN },
|
||||
];
|
||||
|
||||
if (manifest.marketplace) {
|
||||
adminItems.push(
|
||||
{ to: "/plugins/marketplace", label: "Marketplace", icon: Storefront, minRole: ROLE_ADMIN },
|
||||
{ to: "/themes/marketplace", label: "Themes", icon: Palette, minRole: ROLE_ADMIN },
|
||||
);
|
||||
}
|
||||
|
||||
adminItems.push(
|
||||
{ to: "/import/wordpress", label: "Import", icon: Upload, minRole: ROLE_ADMIN },
|
||||
{ to: "/settings", label: "Settings", icon: Gear, minRole: ROLE_ADMIN },
|
||||
);
|
||||
|
||||
const pluginItems: NavItem[] = [];
|
||||
for (const [pluginId, config] of Object.entries(manifest.plugins)) {
|
||||
if (config.enabled === false) continue;
|
||||
if (config.adminPages && config.adminPages.length > 0) {
|
||||
const pluginPages = pluginAdmins[pluginId]?.pages;
|
||||
const isBlocksMode = config.adminMode === "blocks";
|
||||
for (const page of config.adminPages) {
|
||||
if (!isBlocksMode && !pluginPages?.[page.path]) continue;
|
||||
const label =
|
||||
page.label ||
|
||||
pluginId
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
pluginItems.push({ to: `/plugins/${pluginId}${page.path}`, label, icon: PuzzlePiece });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filterByRole = (items: NavItem[]) =>
|
||||
items.filter((item) => !item.minRole || userRole >= item.minRole);
|
||||
|
||||
const visibleContent = filterByRole(contentItems);
|
||||
const visibleManage = filterByRole(manageItems);
|
||||
const visibleAdmin = filterByRole(adminItems);
|
||||
const visiblePlugins = filterByRole(pluginItems);
|
||||
|
||||
function renderNavItems(items: NavItem[]) {
|
||||
return items.map((item, index) => {
|
||||
const itemPath = resolveItemPath(item);
|
||||
const active = isItemActive(itemPath, currentPath);
|
||||
return <NavMenuLink key={`${item.to}-${index}`} item={item} isActive={active} />;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Injected styles — Tailwind 4 strips [data-sidebar] attribute selectors from CSS files.
|
||||
All sidebar-specific overrides go here to avoid conflicting with kumo's inline styles. */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
/* Classic dark chrome — override kumo tokens within the sidebar */
|
||||
.emdash-sidebar {
|
||||
--color-kumo-base: #1d2327;
|
||||
--color-kumo-tint: rgba(255,255,255,0.1);
|
||||
--color-kumo-line: rgba(255,255,255,0.08);
|
||||
--color-kumo-brand: #2271b1;
|
||||
--text-color-kumo-default: #fff;
|
||||
--text-color-kumo-subtle: rgba(255,255,255,0.7);
|
||||
--text-color-kumo-strong: #fff;
|
||||
background-color: #1d2327 !important;
|
||||
color: #fff !important;
|
||||
border-color: rgba(255,255,255,0.08) !important;
|
||||
}
|
||||
/* Group labels — uppercase muted style */
|
||||
.emdash-sidebar [data-sidebar="group-label"] {
|
||||
color: rgba(255,255,255,0.45) !important;
|
||||
font-size: 11px !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.emdash-sidebar [data-sidebar="group-label"] svg {
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.emdash-sidebar [data-sidebar="group-label"]:hover svg {
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
/* Separators */
|
||||
.emdash-sidebar [data-sidebar="separator"] {
|
||||
border-color: rgba(255,255,255,0.06) !important;
|
||||
margin: 0.5rem 0.75rem;
|
||||
}
|
||||
/* Header/footer borders */
|
||||
.emdash-sidebar [data-sidebar="header"] {
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.emdash-sidebar [data-sidebar="footer"] {
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
/* Keep all nav icons visible when sidebar collapses to icon mode */
|
||||
.emdash-sidebar[data-state="collapsed"] [data-sidebar="group-content"] {
|
||||
grid-template-rows: 1fr !important;
|
||||
}
|
||||
/* Collapsed separators — thin centered line */
|
||||
.emdash-sidebar[data-state="collapsed"] [data-sidebar="separator"] {
|
||||
margin: 0.375rem 0.625rem;
|
||||
}
|
||||
/* Collapsed: tighten group spacing */
|
||||
.emdash-sidebar[data-state="collapsed"] [data-sidebar="group"] {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.emdash-sidebar[data-state="collapsed"] [data-sidebar="menu"] {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* Collapsed: nav links — center icon, hide text */
|
||||
.emdash-sidebar[data-state="collapsed"] .emdash-nav-link {
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
gap: 0;
|
||||
min-height: 36px;
|
||||
}
|
||||
.emdash-sidebar[data-state="collapsed"] .emdash-nav-label {
|
||||
display: none !important;
|
||||
}
|
||||
/* Collapsed: brand link */
|
||||
.emdash-sidebar[data-state="collapsed"] .emdash-brand-link {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.emdash-sidebar[data-state="collapsed"] .emdash-brand-text {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<KumoSidebar className="emdash-sidebar" aria-label="Admin navigation">
|
||||
<KumoSidebar.Header>
|
||||
<Link
|
||||
to="/"
|
||||
className="emdash-brand-link flex w-full min-w-0 items-center gap-2 px-3 py-1"
|
||||
>
|
||||
<span className="text-base shrink-0" aria-hidden="true">
|
||||
💫
|
||||
</span>
|
||||
<span className="emdash-brand-text font-semibold truncate">EmDash</span>
|
||||
</Link>
|
||||
</KumoSidebar.Header>
|
||||
|
||||
<KumoSidebar.Content>
|
||||
{/* Dashboard — standalone */}
|
||||
<KumoSidebar.Group>
|
||||
<KumoSidebar.Menu>
|
||||
<NavMenuLink
|
||||
item={{ to: "/", label: "Dashboard", icon: SquaresFour }}
|
||||
isActive={isItemActive("/", currentPath)}
|
||||
/>
|
||||
</KumoSidebar.Menu>
|
||||
</KumoSidebar.Group>
|
||||
|
||||
<KumoSidebar.Separator />
|
||||
|
||||
{/* Content — collections + media (collapsible) */}
|
||||
{visibleContent.length > 1 && (
|
||||
<KumoSidebar.Group collapsible defaultOpen>
|
||||
<KumoSidebar.GroupLabel>Content</KumoSidebar.GroupLabel>
|
||||
<KumoSidebar.GroupContent>
|
||||
<KumoSidebar.Menu>
|
||||
{renderNavItems(visibleContent.filter((i) => i.to !== "/"))}
|
||||
</KumoSidebar.Menu>
|
||||
</KumoSidebar.GroupContent>
|
||||
</KumoSidebar.Group>
|
||||
)}
|
||||
|
||||
<KumoSidebar.Separator />
|
||||
|
||||
{/* Manage — comments, menus, taxonomies, etc. (collapsible) */}
|
||||
{visibleManage.length > 0 && (
|
||||
<KumoSidebar.Group collapsible defaultOpen>
|
||||
<KumoSidebar.GroupLabel>Manage</KumoSidebar.GroupLabel>
|
||||
<KumoSidebar.GroupContent>
|
||||
<KumoSidebar.Menu>{renderNavItems(visibleManage)}</KumoSidebar.Menu>
|
||||
</KumoSidebar.GroupContent>
|
||||
</KumoSidebar.Group>
|
||||
)}
|
||||
|
||||
<KumoSidebar.Separator />
|
||||
|
||||
{/* Admin — content types, users, plugins, import (collapsible) */}
|
||||
{visibleAdmin.length > 0 && (
|
||||
<KumoSidebar.Group collapsible defaultOpen>
|
||||
<KumoSidebar.GroupLabel>Admin</KumoSidebar.GroupLabel>
|
||||
<KumoSidebar.GroupContent>
|
||||
<KumoSidebar.Menu>{renderNavItems(visibleAdmin)}</KumoSidebar.Menu>
|
||||
</KumoSidebar.GroupContent>
|
||||
</KumoSidebar.Group>
|
||||
)}
|
||||
|
||||
{/* Plugin pages (collapsible) */}
|
||||
{visiblePlugins.length > 0 && (
|
||||
<>
|
||||
<KumoSidebar.Separator />
|
||||
<KumoSidebar.Group collapsible defaultOpen>
|
||||
<KumoSidebar.GroupLabel>Plugins</KumoSidebar.GroupLabel>
|
||||
<KumoSidebar.GroupContent>
|
||||
<KumoSidebar.Menu>{renderNavItems(visiblePlugins)}</KumoSidebar.Menu>
|
||||
</KumoSidebar.GroupContent>
|
||||
</KumoSidebar.Group>
|
||||
</>
|
||||
)}
|
||||
</KumoSidebar.Content>
|
||||
|
||||
<KumoSidebar.Footer>
|
||||
<p className="emdash-nav-label px-3 py-2 text-[11px] text-white/30">
|
||||
EmDash CMS v{manifest.version || "0.0.0"}
|
||||
</p>
|
||||
</KumoSidebar.Footer>
|
||||
</KumoSidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
442
packages/admin/src/components/SignupPage.tsx
Normal file
442
packages/admin/src/components/SignupPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Signup Page - Self-signup for allowed domains
|
||||
*
|
||||
* This component is NOT wrapped in the admin Shell.
|
||||
* It's a standalone public page for self-signup.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Email input form
|
||||
* 2. "Check your email" confirmation
|
||||
* 3. After clicking email link: Passkey registration
|
||||
*/
|
||||
|
||||
import { Button, Input, Loader } from "@cloudflare/kumo";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { requestSignup, verifySignupToken, type SignupVerifyResult } from "../lib/api";
|
||||
import { PasskeyRegistration } from "./auth/PasskeyRegistration";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type SignupStep = "email" | "check-email" | "verify" | "complete" | "error";
|
||||
|
||||
// ============================================================================
|
||||
// Step Components
|
||||
// ============================================================================
|
||||
|
||||
interface EmailStepProps {
|
||||
onSubmit: (email: string) => void;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function EmailStep({ onSubmit, isLoading, error }: EmailStepProps) {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [validationError, setValidationError] = React.useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
|
||||
if (!email.trim()) {
|
||||
setValidationError("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.includes("@") || !email.includes(".")) {
|
||||
setValidationError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(email.trim().toLowerCase());
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Email address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className={validationError ? "border-kumo-danger" : ""}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
{validationError && <p className="text-sm text-kumo-danger mt-1">{validationError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">{error}</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader size="sm" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-kumo-subtle text-center">
|
||||
Only email addresses from allowed domains can sign up.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckEmailStepProps {
|
||||
email: string;
|
||||
onResend: () => void;
|
||||
isResending: boolean;
|
||||
resendCooldown: number;
|
||||
}
|
||||
|
||||
function CheckEmailStep({ email, onResend, isResending, resendCooldown }: CheckEmailStepProps) {
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-brand/10 mx-auto">
|
||||
<svg
|
||||
className="w-8 h-8 text-kumo-brand"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Check your email</h2>
|
||||
<p className="text-kumo-subtle mt-2">
|
||||
We've sent a verification link to{" "}
|
||||
<span className="font-medium text-kumo-default">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-kumo-subtle">
|
||||
<p>Click the link in the email to continue setting up your account.</p>
|
||||
<p className="mt-2">The link will expire in 15 minutes.</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-sm text-kumo-subtle mb-2">Didn't receive the email?</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onResend}
|
||||
disabled={isResending || resendCooldown > 0}
|
||||
>
|
||||
{isResending
|
||||
? "Sending..."
|
||||
: resendCooldown > 0
|
||||
? `Resend in ${resendCooldown}s`
|
||||
: "Resend email"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VerifyStepProps {
|
||||
verifyResult: SignupVerifyResult;
|
||||
token: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function handleSignupSuccess() {
|
||||
// Redirect to admin dashboard after successful signup
|
||||
window.location.href = "/_emdash/admin";
|
||||
}
|
||||
|
||||
function VerifyStep({ verifyResult, token, onBack: _onBack }: VerifyStepProps) {
|
||||
const [name, setName] = React.useState("");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10 mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">Email verified!</h2>
|
||||
<p className="text-kumo-subtle mt-2">
|
||||
You'll be signing up as{" "}
|
||||
<span className="font-medium text-kumo-default">{verifyResult.roleName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email display (read-only) */}
|
||||
<Input label="Email" value={verifyResult.email} disabled className="bg-kumo-tint" />
|
||||
|
||||
{/* Name input (optional) */}
|
||||
<Input
|
||||
label="Your name (optional)"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Jane Doe"
|
||||
autoComplete="name"
|
||||
/>
|
||||
|
||||
{/* Passkey registration */}
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="text-sm font-medium mb-3">Create your passkey</h3>
|
||||
<p className="text-sm text-kumo-subtle mb-4">
|
||||
Passkeys are a secure, passwordless way to sign in using your device's biometrics, PIN, or
|
||||
security key.
|
||||
</p>
|
||||
|
||||
<PasskeyRegistration
|
||||
optionsEndpoint="/_emdash/api/setup/admin"
|
||||
verifyEndpoint="/_emdash/api/auth/signup/complete"
|
||||
onSuccess={handleSignupSuccess}
|
||||
buttonText="Create Account"
|
||||
additionalData={{ token, name: name || undefined }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorStepProps {
|
||||
message: string;
|
||||
code?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
function ErrorStep({ message, code, onRetry }: ErrorStepProps) {
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-danger/10 mx-auto">
|
||||
<svg
|
||||
className="w-8 h-8 text-kumo-danger"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-kumo-danger">
|
||||
{code === "token_expired"
|
||||
? "Link expired"
|
||||
: code === "invalid_token"
|
||||
? "Invalid link"
|
||||
: code === "user_exists"
|
||||
? "Account exists"
|
||||
: "Something went wrong"}
|
||||
</h2>
|
||||
<p className="text-kumo-subtle mt-2">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{code === "user_exists" ? (
|
||||
<Link to="/login">
|
||||
<Button className="w-full">Sign in instead</Button>
|
||||
</Link>
|
||||
) : (
|
||||
onRetry && (
|
||||
<Button onClick={onRetry} className="w-full">
|
||||
Request a new link
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<Link to="/login">
|
||||
<Button variant="ghost" className="w-full">
|
||||
Back to login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export function SignupPage() {
|
||||
const [step, setStep] = React.useState<SignupStep>("email");
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
const [errorCode, setErrorCode] = React.useState<string | undefined>();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [verifyResult, setVerifyResult] = React.useState<SignupVerifyResult | null>(null);
|
||||
const [token, setToken] = React.useState<string | null>(null);
|
||||
const [resendCooldown, setResendCooldown] = React.useState(0);
|
||||
|
||||
// Check for token in URL on mount
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlToken = params.get("token");
|
||||
|
||||
if (urlToken) {
|
||||
setToken(urlToken);
|
||||
void verifyToken(urlToken);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Resend cooldown timer
|
||||
React.useEffect(() => {
|
||||
if (resendCooldown > 0) {
|
||||
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [resendCooldown]);
|
||||
|
||||
const verifyToken = async (tokenToVerify: string) => {
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
setErrorCode(undefined);
|
||||
|
||||
try {
|
||||
const result = await verifySignupToken(tokenToVerify);
|
||||
setVerifyResult(result);
|
||||
setStep("verify");
|
||||
} catch (err) {
|
||||
const verifyError = err instanceof Error ? err : new Error(String(err));
|
||||
const errorWithCode = verifyError as Error & { code?: string };
|
||||
setError(verifyError.message);
|
||||
setErrorCode(typeof errorWithCode.code === "string" ? errorWithCode.code : undefined);
|
||||
setStep("error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailSubmit = async (submittedEmail: string) => {
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
setEmail(submittedEmail);
|
||||
|
||||
try {
|
||||
await requestSignup(submittedEmail);
|
||||
setStep("check-email");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send verification email");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email || resendCooldown > 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await requestSignup(email);
|
||||
setResendCooldown(60); // 60 second cooldown
|
||||
} catch {
|
||||
// Silently fail - don't reveal if email exists
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setStep("email");
|
||||
setError(undefined);
|
||||
setErrorCode(undefined);
|
||||
setToken(null);
|
||||
// Clear token from URL
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
};
|
||||
|
||||
// Loading state for token verification
|
||||
if (isLoading && token) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-kumo-base">
|
||||
<div className="text-center">
|
||||
<Loader />
|
||||
<p className="mt-4 text-kumo-subtle">Verifying your link...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-4xl font-bold mb-2">💫 EmDash</div>
|
||||
<h1 className="text-2xl font-semibold text-kumo-default">
|
||||
{step === "email" && "Create an account"}
|
||||
{step === "check-email" && "Check your email"}
|
||||
{step === "verify" && "Complete signup"}
|
||||
{step === "error" && "Oops!"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
|
||||
{step === "email" && (
|
||||
<EmailStep onSubmit={handleEmailSubmit} isLoading={isLoading} error={error} />
|
||||
)}
|
||||
|
||||
{step === "check-email" && (
|
||||
<CheckEmailStep
|
||||
email={email}
|
||||
onResend={handleResend}
|
||||
isResending={isLoading}
|
||||
resendCooldown={resendCooldown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "verify" && verifyResult && token && (
|
||||
<VerifyStep verifyResult={verifyResult} token={token} onBack={handleRetry} />
|
||||
)}
|
||||
|
||||
{step === "error" && (
|
||||
<ErrorStep
|
||||
message={error ?? "An unknown error occurred"}
|
||||
code={errorCode}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Login link */}
|
||||
{step === "email" && (
|
||||
<p className="text-center mt-6 text-sm text-kumo-subtle">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-kumo-brand hover:underline font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignupPage;
|
||||
652
packages/admin/src/components/TaxonomyManager.tsx
Normal file
652
packages/admin/src/components/TaxonomyManager.tsx
Normal file
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* Taxonomy Terms Manager
|
||||
*
|
||||
* Provides UI for managing taxonomy terms (categories, tags, custom taxonomies).
|
||||
* Shows hierarchical structure for categories, flat list for tags.
|
||||
*/
|
||||
|
||||
import { Button, Checkbox, Dialog, Input, InputArea, Select, Toast } from "@cloudflare/kumo";
|
||||
import { Plus, Pencil, Trash, X } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchManifest } from "../lib/api/client.js";
|
||||
import type { TaxonomyTerm, TaxonomyDef, CreateTaxonomyInput } from "../lib/api/taxonomies.js";
|
||||
import {
|
||||
fetchTaxonomyDef,
|
||||
fetchTerms,
|
||||
createTaxonomy,
|
||||
createTerm,
|
||||
updateTerm,
|
||||
deleteTerm,
|
||||
} from "../lib/api/taxonomies.js";
|
||||
import { slugify } from "../lib/utils";
|
||||
import { ConfirmDialog } from "./ConfirmDialog.js";
|
||||
import { DialogError, getMutationError } from "./DialogError.js";
|
||||
|
||||
interface TaxonomyManagerProps {
|
||||
taxonomyName: string;
|
||||
}
|
||||
|
||||
// Regex patterns for taxonomy name generation and validation (module-scoped per lint rules)
|
||||
const NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]+/g;
|
||||
const LEADING_TRAILING_UNDERSCORE_PATTERN = /^_|_$/g;
|
||||
const TAXONOMY_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Flatten tree to get all terms
|
||||
*/
|
||||
function flattenTerms(terms: TaxonomyTerm[]): TaxonomyTerm[] {
|
||||
return terms.flatMap((t) => [t, ...flattenTerms(t.children)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Term row component (recursive for hierarchy)
|
||||
*/
|
||||
function TermRow({
|
||||
term,
|
||||
level = 0,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
term: TaxonomyTerm;
|
||||
level?: number;
|
||||
onEdit: (term: TaxonomyTerm) => void;
|
||||
onDelete: (term: TaxonomyTerm) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 py-2 px-4 border-b hover:bg-kumo-tint/50">
|
||||
<div style={{ marginLeft: `${level * 1.5}rem` }} className="flex-1">
|
||||
<span className="font-medium">{term.label}</span>
|
||||
<span className="text-sm text-kumo-subtle ml-2">({term.slug})</span>
|
||||
</div>
|
||||
<div className="text-sm text-kumo-subtle">{term.count || 0}</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Edit ${term.label}`}
|
||||
onClick={() => onEdit(term)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Delete ${term.label}`}
|
||||
onClick={() => onDelete(term)}
|
||||
>
|
||||
<Trash className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{term.children.map((child) => (
|
||||
<TermRow
|
||||
key={child.id}
|
||||
term={child}
|
||||
level={level + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Term form dialog
|
||||
*/
|
||||
function TermFormDialog({
|
||||
open,
|
||||
onClose,
|
||||
taxonomyName,
|
||||
taxonomyDef,
|
||||
term,
|
||||
allTerms,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
taxonomyName: string;
|
||||
taxonomyDef: TaxonomyDef;
|
||||
term?: TaxonomyTerm;
|
||||
allTerms: TaxonomyTerm[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [label, setLabel] = React.useState(term?.label || "");
|
||||
const [slug, setSlug] = React.useState(term?.slug || "");
|
||||
const [parentId, setParentId] = React.useState(term?.parentId || "");
|
||||
const [description, setDescription] = React.useState(term?.description || "");
|
||||
const [autoSlug, setAutoSlug] = React.useState(!term);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Auto-generate slug from label
|
||||
React.useEffect(() => {
|
||||
if (autoSlug && label) {
|
||||
setSlug(slugify(label));
|
||||
}
|
||||
}, [label, autoSlug]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createTerm(taxonomyName, {
|
||||
slug,
|
||||
label,
|
||||
parentId: parentId || undefined,
|
||||
description: description || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["taxonomy-terms", taxonomyName],
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!term) throw new Error("No term to update");
|
||||
return updateTerm(taxonomyName, term.slug, {
|
||||
slug,
|
||||
label,
|
||||
parentId: parentId || undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["taxonomy-terms", taxonomyName],
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (term) {
|
||||
updateMutation.mutate();
|
||||
} else {
|
||||
createMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
// Flatten terms for parent selector (exclude current term and its children)
|
||||
const flatTerms = flattenTerms(allTerms);
|
||||
const availableParents = term
|
||||
? flatTerms.filter((t) => t.id !== term.id && t.parentId !== term.id)
|
||||
: flatTerms;
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
{term ? "Edit" : "Add"} {taxonomyDef.labelSingular || "Term"}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-sm text-kumo-subtle">
|
||||
{term
|
||||
? `Update the ${taxonomyDef.labelSingular?.toLowerCase() || "term"} details`
|
||||
: `Create a new ${taxonomyDef.labelSingular?.toLowerCase() || "term"}`}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="News"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlug(e.target.value);
|
||||
setAutoSlug(false);
|
||||
}}
|
||||
placeholder="news"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-kumo-subtle mt-1">
|
||||
Auto-generated from name (you can edit)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{taxonomyDef.hierarchical && (
|
||||
<Select
|
||||
label="Parent"
|
||||
value={parentId}
|
||||
onValueChange={(v) => setParentId(v ?? "")}
|
||||
items={{
|
||||
"": "None (top level)",
|
||||
...Object.fromEntries(availableParents.map((t) => [t.id, t.label])),
|
||||
}}
|
||||
>
|
||||
<Select.Option value="">None (top level)</Select.Option>
|
||||
{availableParents.map((t) => (
|
||||
<Select.Option key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<InputArea
|
||||
label="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<DialogError
|
||||
message={
|
||||
error ||
|
||||
getMutationError(createMutation.error) ||
|
||||
getMutationError(updateMutation.error)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? "Saving..."
|
||||
: term
|
||||
? "Update"
|
||||
: "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Taxonomy dialog
|
||||
*/
|
||||
function CreateTaxonomyDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = React.useState("");
|
||||
const [label, setLabel] = React.useState("");
|
||||
const [hierarchical, setHierarchical] = React.useState(false);
|
||||
const [selectedCollections, setSelectedCollections] = React.useState<string[]>([]);
|
||||
const [autoName, setAutoName] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const { data: manifest } = useQuery({
|
||||
queryKey: ["manifest"],
|
||||
queryFn: fetchManifest,
|
||||
});
|
||||
|
||||
const collectionEntries = manifest
|
||||
? Object.entries(manifest.collections).map(([slug, config]) => ({
|
||||
slug,
|
||||
label: config.label,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Auto-generate name from label
|
||||
React.useEffect(() => {
|
||||
if (autoName && label) {
|
||||
setName(
|
||||
label
|
||||
.toLowerCase()
|
||||
.replace(NON_ALPHANUMERIC_PATTERN, "_")
|
||||
.replace(LEADING_TRAILING_UNDERSCORE_PATTERN, ""),
|
||||
);
|
||||
}
|
||||
}, [label, autoName]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (input: CreateTaxonomyInput) => createTaxonomy(input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["taxonomy-defs"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["taxonomy-def"] });
|
||||
onCreated();
|
||||
resetForm();
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setName("");
|
||||
setLabel("");
|
||||
setHierarchical(false);
|
||||
setSelectedCollections([]);
|
||||
setAutoName(true);
|
||||
setError(null);
|
||||
createMutation.reset();
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!name || !label) {
|
||||
setError("Name and label are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TAXONOMY_NAME_PATTERN.test(name)) {
|
||||
setError(
|
||||
"Name must start with a letter and contain only lowercase letters, numbers, and underscores",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate({
|
||||
name,
|
||||
label,
|
||||
hierarchical,
|
||||
collections: selectedCollections,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCollection = (slug: string) => {
|
||||
setSelectedCollections((prev) =>
|
||||
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
Create Taxonomy
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-sm text-kumo-subtle">
|
||||
Define a new taxonomy for classifying content
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Input
|
||||
label="Label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Genres"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setAutoName(false);
|
||||
}}
|
||||
placeholder="genre"
|
||||
required
|
||||
pattern="[a-z][a-z0-9_]*"
|
||||
title="Lowercase letters, numbers, and underscores only, starting with a letter"
|
||||
/>
|
||||
<p className="text-xs text-kumo-subtle mt-1">
|
||||
Used as the identifier. Lowercase letters, numbers, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
label="Hierarchical (like categories, with parent/child relationships)"
|
||||
checked={hierarchical}
|
||||
onCheckedChange={(checked) => setHierarchical(checked)}
|
||||
/>
|
||||
|
||||
{collectionEntries.length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">Collections</label>
|
||||
<p className="text-xs text-kumo-subtle mb-2">
|
||||
Which content types can use this taxonomy
|
||||
</p>
|
||||
<div className="border rounded-md p-2 space-y-1">
|
||||
{collectionEntries.map(({ slug, label: collLabel }) => (
|
||||
<label
|
||||
key={slug}
|
||||
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-kumo-tint/50 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCollections.includes(slug)}
|
||||
onChange={() => toggleCollection(slug)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">{collLabel}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogError message={error || getMutationError(createMutation.error)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Creating..." : "Create Taxonomy"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main TaxonomyManager component
|
||||
*/
|
||||
export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
const [formOpen, setFormOpen] = React.useState(false);
|
||||
const [editingTerm, setEditingTerm] = React.useState<TaxonomyTerm | undefined>();
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<TaxonomyTerm | null>(null);
|
||||
const [createTaxonomyOpen, setCreateTaxonomyOpen] = React.useState(false);
|
||||
|
||||
const { data: taxonomyDef, isLoading: defLoading } = useQuery({
|
||||
queryKey: ["taxonomy-def", taxonomyName],
|
||||
queryFn: () => fetchTaxonomyDef(taxonomyName),
|
||||
});
|
||||
|
||||
const { data: terms = [], isLoading: termsLoading } = useQuery({
|
||||
queryKey: ["taxonomy-terms", taxonomyName],
|
||||
queryFn: () => fetchTerms(taxonomyName),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (term: TaxonomyTerm) => deleteTerm(taxonomyName, term.slug),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["taxonomy-terms", taxonomyName],
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
toastManager.add({ title: "Term deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (term: TaxonomyTerm) => {
|
||||
setEditingTerm(term);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (term: TaxonomyTerm) => {
|
||||
setDeleteTarget(term);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditingTerm(undefined);
|
||||
};
|
||||
|
||||
if (defLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!taxonomyDef) {
|
||||
return <div>Taxonomy not found: {taxonomyName}</div>;
|
||||
}
|
||||
|
||||
const flatTerms = flattenTerms(terms);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{taxonomyDef.label}</h1>
|
||||
<p className="text-kumo-subtle mt-1">
|
||||
Manage {taxonomyDef.label.toLowerCase()} for {taxonomyDef.collections.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" icon={<Plus />} onClick={() => setCreateTaxonomyOpen(true)}>
|
||||
New Taxonomy
|
||||
</Button>
|
||||
<Button icon={<Plus />} onClick={() => setFormOpen(true)}>
|
||||
Add {taxonomyDef.labelSingular || "Term"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<div className="flex items-center gap-4 py-2 px-4 border-b bg-kumo-tint/50 font-medium">
|
||||
<div className="flex-1">Name</div>
|
||||
<div className="w-16 text-center">Count</div>
|
||||
<div className="w-24 text-center">Actions</div>
|
||||
</div>
|
||||
|
||||
{termsLoading ? (
|
||||
<div className="p-8 text-center text-kumo-subtle">Loading terms...</div>
|
||||
) : terms.length === 0 ? (
|
||||
<div className="p-8 text-center text-kumo-subtle">
|
||||
No {taxonomyDef.label.toLowerCase()} yet. Create one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{terms.map((term) => (
|
||||
<TermRow key={term.id} term={term} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TermFormDialog
|
||||
open={formOpen}
|
||||
onClose={handleCloseForm}
|
||||
taxonomyName={taxonomyName}
|
||||
taxonomyDef={taxonomyDef}
|
||||
term={editingTerm}
|
||||
allTerms={flatTerms}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onClose={() => {
|
||||
setDeleteTarget(null);
|
||||
deleteMutation.reset();
|
||||
}}
|
||||
title={`Delete ${taxonomyDef.labelSingular || "Term"}?`}
|
||||
description={
|
||||
<>This will permanently delete "{deleteTarget?.label}" and remove it from all content.</>
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={deleteMutation.isPending}
|
||||
error={deleteMutation.error}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
/>
|
||||
|
||||
<CreateTaxonomyDialog
|
||||
open={createTaxonomyOpen}
|
||||
onClose={() => setCreateTaxonomyOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateTaxonomyOpen(false);
|
||||
toastManager.add({ title: "Taxonomy created" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
packages/admin/src/components/TaxonomySidebar.tsx
Normal file
363
packages/admin/src/components/TaxonomySidebar.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Taxonomy Sidebar for Content Editor
|
||||
*
|
||||
* Shows taxonomy selection UI in the content editor sidebar.
|
||||
* - Checkbox tree for hierarchical taxonomies (categories)
|
||||
* - Tag input for flat taxonomies (tags)
|
||||
*/
|
||||
|
||||
import { Input, Label } from "@cloudflare/kumo";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js";
|
||||
|
||||
interface TaxonomyTerm {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
parentId?: string;
|
||||
children: TaxonomyTerm[];
|
||||
}
|
||||
|
||||
interface TaxonomyDef {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
hierarchical: boolean;
|
||||
collections: string[];
|
||||
}
|
||||
|
||||
interface TaxonomySidebarProps {
|
||||
collection: string;
|
||||
entryId?: string;
|
||||
onChange?: (taxonomyName: string, termIds: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch taxonomy definitions
|
||||
*/
|
||||
async function fetchTaxonomyDefs(): Promise<TaxonomyDef[]> {
|
||||
const res = await apiFetch(`/_emdash/api/taxonomies`);
|
||||
const data = await parseApiResponse<{ taxonomies: TaxonomyDef[] }>(
|
||||
res,
|
||||
"Failed to fetch taxonomies",
|
||||
);
|
||||
return data.taxonomies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch terms for a taxonomy
|
||||
*/
|
||||
async function fetchTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
|
||||
const res = await apiFetch(`/_emdash/api/taxonomies/${taxonomyName}/terms`);
|
||||
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(res, "Failed to fetch terms");
|
||||
return data.terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch entry terms
|
||||
*/
|
||||
async function fetchEntryTerms(
|
||||
collection: string,
|
||||
entryId: string,
|
||||
taxonomy: string,
|
||||
): Promise<TaxonomyTerm[]> {
|
||||
const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`);
|
||||
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(
|
||||
res,
|
||||
"Failed to fetch entry terms",
|
||||
);
|
||||
return data.terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set entry terms
|
||||
*/
|
||||
async function setEntryTerms(
|
||||
collection: string,
|
||||
entryId: string,
|
||||
taxonomy: string,
|
||||
termIds: string[],
|
||||
): Promise<void> {
|
||||
const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ termIds }),
|
||||
});
|
||||
if (!res.ok) await throwResponseError(res, "Failed to set entry terms");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox tree for hierarchical taxonomies
|
||||
*/
|
||||
function CategoryCheckboxTree({
|
||||
term,
|
||||
level = 0,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
}: {
|
||||
term: TaxonomyTerm;
|
||||
level?: number;
|
||||
selectedIds: Set<string>;
|
||||
onToggle: (termId: string) => void;
|
||||
}) {
|
||||
const isChecked = selectedIds.has(term.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
className="flex items-center py-1 cursor-pointer hover:bg-kumo-tint/50 rounded px-2"
|
||||
style={{ marginLeft: `${level}rem` }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => onToggle(term.id)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">{term.label}</span>
|
||||
</label>
|
||||
{term.children.map((child) => (
|
||||
<CategoryCheckboxTree
|
||||
key={child.id}
|
||||
term={child}
|
||||
level={level + 1}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag input for flat taxonomies
|
||||
*/
|
||||
function TagInput({
|
||||
terms,
|
||||
selectedIds,
|
||||
onAdd,
|
||||
onRemove,
|
||||
label,
|
||||
}: {
|
||||
terms: TaxonomyTerm[];
|
||||
selectedIds: Set<string>;
|
||||
onAdd: (termId: string) => void;
|
||||
onRemove: (termId: string) => void;
|
||||
label: string;
|
||||
}) {
|
||||
const [input, setInput] = React.useState("");
|
||||
|
||||
const selectedTerms = terms.filter((t) => selectedIds.has(t.id));
|
||||
|
||||
const suggestions = React.useMemo(() => {
|
||||
if (!input) return [];
|
||||
return terms
|
||||
.filter((t) => t.label.toLowerCase().includes(input.toLowerCase()) && !selectedIds.has(t.id))
|
||||
.slice(0, 5);
|
||||
}, [input, terms, selectedIds]);
|
||||
|
||||
const handleSelect = (term: TaxonomyTerm) => {
|
||||
onAdd(term.id);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Selected tags */}
|
||||
{selectedTerms.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTerms.map((term) => (
|
||||
<span
|
||||
key={term.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-sm bg-kumo-tint rounded"
|
||||
>
|
||||
{term.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(term.id)}
|
||||
className="hover:text-kumo-danger"
|
||||
aria-label={`Remove ${term.label}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input with autocomplete */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Add tags..."
|
||||
aria-label={`Add ${label}`}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-kumo-overlay border rounded-md shadow-lg z-10">
|
||||
{suggestions.map((term) => (
|
||||
<button
|
||||
key={term.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(term)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-kumo-tint"
|
||||
>
|
||||
{term.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single taxonomy section
|
||||
*/
|
||||
function TaxonomySection({
|
||||
taxonomy,
|
||||
collection,
|
||||
entryId,
|
||||
onChange,
|
||||
}: {
|
||||
taxonomy: TaxonomyDef;
|
||||
collection: string;
|
||||
entryId?: string;
|
||||
onChange?: (termIds: string[]) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: terms = [] } = useQuery({
|
||||
queryKey: ["taxonomy-terms", taxonomy.name],
|
||||
queryFn: () => fetchTerms(taxonomy.name),
|
||||
});
|
||||
|
||||
const { data: entryTerms = [] } = useQuery({
|
||||
queryKey: ["entry-terms", collection, entryId, taxonomy.name],
|
||||
queryFn: () => {
|
||||
if (!entryId) return [];
|
||||
return fetchEntryTerms(collection, entryId, taxonomy.name);
|
||||
},
|
||||
enabled: !!entryId,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (termIds: string[]) => {
|
||||
if (!entryId) throw new Error("No entry ID");
|
||||
return setEntryTerms(collection, entryId, taxonomy.name, termIds);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["entry-terms", collection, entryId, taxonomy.name],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
|
||||
// Sync selected IDs from entry terms
|
||||
React.useEffect(() => {
|
||||
setSelectedIds(new Set(entryTerms.map((t) => t.id)));
|
||||
}, [entryTerms]);
|
||||
|
||||
const handleToggle = (termId: string) => {
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (newSelected.has(termId)) {
|
||||
newSelected.delete(termId);
|
||||
} else {
|
||||
newSelected.add(termId);
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
|
||||
// Notify parent of change
|
||||
const termIdsArray = [...newSelected];
|
||||
onChange?.(termIdsArray);
|
||||
|
||||
// Auto-save if entry exists
|
||||
if (entryId) {
|
||||
saveMutation.mutate(termIdsArray);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = (termId: string) => {
|
||||
handleToggle(termId);
|
||||
};
|
||||
|
||||
const handleRemove = (termId: string) => {
|
||||
handleToggle(termId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{taxonomy.label}</Label>
|
||||
|
||||
{terms.length === 0 ? (
|
||||
<p className="text-sm text-kumo-subtle">No {taxonomy.label.toLowerCase()} available.</p>
|
||||
) : taxonomy.hierarchical ? (
|
||||
<div className="border rounded-md p-2 max-h-64 overflow-y-auto">
|
||||
{terms.map((term) => (
|
||||
<CategoryCheckboxTree
|
||||
key={term.id}
|
||||
term={term}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TagInput
|
||||
terms={terms}
|
||||
selectedIds={selectedIds}
|
||||
onAdd={handleAdd}
|
||||
onRemove={handleRemove}
|
||||
label={taxonomy.label}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main TaxonomySidebar component
|
||||
*/
|
||||
export function TaxonomySidebar({ collection, entryId, onChange }: TaxonomySidebarProps) {
|
||||
const { data: taxonomies = [] } = useQuery({
|
||||
queryKey: ["taxonomy-defs"],
|
||||
queryFn: fetchTaxonomyDefs,
|
||||
});
|
||||
|
||||
// Filter to taxonomies that apply to this collection
|
||||
const applicableTaxonomies = taxonomies.filter((t) => t.collections.includes(collection));
|
||||
|
||||
if (applicableTaxonomies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Taxonomies</h3>
|
||||
<div className="space-y-4">
|
||||
{applicableTaxonomies.map((taxonomy) => (
|
||||
<TaxonomySection
|
||||
key={taxonomy.name}
|
||||
taxonomy={taxonomy}
|
||||
collection={collection}
|
||||
entryId={entryId}
|
||||
onChange={(termIds) => onChange?.(taxonomy.name, termIds)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
packages/admin/src/components/ThemeMarketplaceBrowse.tsx
Normal file
274
packages/admin/src/components/ThemeMarketplaceBrowse.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Theme Marketplace Browse
|
||||
*
|
||||
* Visual-first grid of theme cards with large thumbnails.
|
||||
* Navigates to theme detail on card click.
|
||||
*/
|
||||
|
||||
import { Button } from "@cloudflare/kumo";
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
Palette,
|
||||
Warning,
|
||||
ArrowsClockwise,
|
||||
ArrowSquareOut,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
searchThemes,
|
||||
generatePreviewUrl,
|
||||
type ThemeSummary,
|
||||
type ThemeSearchOpts,
|
||||
} from "../lib/api/theme-marketplace.js";
|
||||
|
||||
type SortOption = "updated" | "created" | "name";
|
||||
|
||||
const SORT_LABELS: Record<SortOption, string> = {
|
||||
updated: "Recently Updated",
|
||||
created: "Newest",
|
||||
name: "Name",
|
||||
};
|
||||
|
||||
const VALID_SORTS = new Set<string>(["updated", "created", "name"]);
|
||||
|
||||
function isSortOption(value: string): value is SortOption {
|
||||
return VALID_SORTS.has(value);
|
||||
}
|
||||
|
||||
export function ThemeMarketplaceBrowse() {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [sort, setSort] = React.useState<SortOption>("updated");
|
||||
const [debouncedQuery, setDebouncedQuery] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(setDebouncedQuery, 300, searchQuery);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
const searchOpts: ThemeSearchOpts = {
|
||||
q: debouncedQuery || undefined,
|
||||
sort,
|
||||
limit: 12,
|
||||
};
|
||||
|
||||
const { data, isLoading, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["themes", "search", searchOpts],
|
||||
queryFn: ({ pageParam }) => searchThemes({ ...searchOpts, cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const themes = data?.pages.flatMap((p) => p.items);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Themes</h1>
|
||||
<p className="mt-1 text-kumo-subtle">
|
||||
Browse themes and preview them with your own content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search + Sort */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-kumo-subtle" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search themes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border bg-kumo-base px-3 py-2 pl-9 text-sm placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-ring"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (isSortOption(v)) setSort(v);
|
||||
}}
|
||||
className="rounded-md border bg-kumo-base px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-kumo-ring"
|
||||
aria-label="Sort themes"
|
||||
>
|
||||
{Object.entries(SORT_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
|
||||
<Warning className="mx-auto h-8 w-8 text-kumo-danger" />
|
||||
<h3 className="mt-3 font-medium text-kumo-danger">Unable to reach marketplace</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">
|
||||
{error instanceof Error ? error.message : "An error occurred"}
|
||||
</p>
|
||||
<Button variant="ghost" className="mt-4" onClick={() => void refetch()}>
|
||||
<ArrowsClockwise className="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state — skeleton cards with thumbnail aspect ratio */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-lg border bg-kumo-base overflow-hidden">
|
||||
<div className="aspect-video bg-kumo-tint" />
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="h-4 w-32 rounded bg-kumo-tint" />
|
||||
<div className="h-3 w-48 rounded bg-kumo-tint" />
|
||||
<div className="h-3 w-20 rounded bg-kumo-tint" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results grid */}
|
||||
{themes && !isLoading && (
|
||||
<>
|
||||
{themes.length === 0 ? (
|
||||
<div className="rounded-lg border bg-kumo-base p-8 text-center">
|
||||
<Palette className="mx-auto h-12 w-12 text-kumo-subtle" />
|
||||
<h3 className="mt-4 text-lg font-medium">No themes found</h3>
|
||||
<p className="mt-2 text-sm text-kumo-subtle">
|
||||
{debouncedQuery
|
||||
? `No results for "${debouncedQuery}". Try a different search term.`
|
||||
: "The theme marketplace is empty. Check back later."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{themes.map((theme) => (
|
||||
<ThemeCard key={theme.id} theme={theme} />
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ThemeCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ThemeCard({ theme }: { theme: ThemeSummary }) {
|
||||
const thumbnailUrl = theme.thumbnailUrl
|
||||
? `/_emdash/api/admin/themes/marketplace/${encodeURIComponent(theme.id)}/thumbnail`
|
||||
: null;
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => generatePreviewUrl(theme.previewUrl),
|
||||
onSuccess: (url) => {
|
||||
window.open(url, "_blank", "noopener");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="group rounded-lg border bg-kumo-base overflow-hidden transition-colors hover:border-kumo-brand/50">
|
||||
{/* Thumbnail */}
|
||||
<Link
|
||||
to={"/themes/marketplace/$themeId" as "/"}
|
||||
params={{ themeId: theme.id }}
|
||||
className="block"
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={`${theme.name} preview`}
|
||||
className="aspect-video w-full object-cover bg-kumo-tint"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video w-full bg-kumo-tint flex items-center justify-center">
|
||||
<Palette className="h-12 w-12 text-kumo-subtle/40" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<Link
|
||||
to={"/themes/marketplace/$themeId" as "/"}
|
||||
params={{ themeId: theme.id }}
|
||||
className="block"
|
||||
>
|
||||
<h3 className="font-semibold group-hover:text-kumo-brand truncate">{theme.name}</h3>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-kumo-subtle">
|
||||
<span>{theme.author.name}</span>
|
||||
{theme.author.verified && <ShieldCheck className="h-3 w-3 text-kumo-brand" />}
|
||||
</div>
|
||||
|
||||
{theme.description && (
|
||||
<p className="mt-2 text-sm text-kumo-subtle line-clamp-2">{theme.description}</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
previewMutation.mutate();
|
||||
}}
|
||||
disabled={previewMutation.isPending}
|
||||
>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
||||
{previewMutation.isPending ? "Loading..." : "Try with my data"}
|
||||
</Button>
|
||||
|
||||
{theme.demoUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(theme.demoUrl!, "_blank", "noopener")}
|
||||
>
|
||||
<ArrowSquareOut className="mr-1.5 h-3.5 w-3.5" />
|
||||
Demo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewMutation.error && (
|
||||
<p className="mt-2 text-xs text-kumo-danger">
|
||||
{previewMutation.error instanceof Error
|
||||
? previewMutation.error.message
|
||||
: "Failed to generate preview"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeMarketplaceBrowse;
|
||||
333
packages/admin/src/components/ThemeMarketplaceDetail.tsx
Normal file
333
packages/admin/src/components/ThemeMarketplaceDetail.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Theme Marketplace Detail
|
||||
*
|
||||
* Full detail view for a marketplace theme:
|
||||
* - Screenshot gallery
|
||||
* - Description, author, license
|
||||
* - "Try with my data" button
|
||||
* - Demo + repository links
|
||||
*/
|
||||
|
||||
import { Badge, Button } from "@cloudflare/kumo";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowSquareOut,
|
||||
Eye,
|
||||
GithubLogo,
|
||||
Globe,
|
||||
Palette,
|
||||
ShieldCheck,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchTheme, generatePreviewUrl } from "../lib/api/theme-marketplace.js";
|
||||
|
||||
/** Only allow safe URL protocols for external links */
|
||||
function isSafeUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ThemeMarketplaceDetailProps {
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
export function ThemeMarketplaceDetail({ themeId }: ThemeMarketplaceDetailProps) {
|
||||
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
|
||||
|
||||
const {
|
||||
data: theme,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["themes", "detail", themeId],
|
||||
queryFn: () => fetchTheme(themeId),
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => generatePreviewUrl(theme!.previewUrl),
|
||||
onSuccess: (url) => {
|
||||
window.open(url, "_blank", "noopener");
|
||||
},
|
||||
});
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="h-6 w-48 rounded bg-kumo-tint" />
|
||||
<div className="aspect-video max-w-2xl rounded-lg bg-kumo-tint" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-64 rounded bg-kumo-tint" />
|
||||
<div className="h-4 w-96 rounded bg-kumo-tint" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error
|
||||
if (error || !theme) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to={"/themes/marketplace" as "/"}
|
||||
className="inline-flex items-center gap-1 text-sm text-kumo-subtle hover:text-kumo-default"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Themes
|
||||
</Link>
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
|
||||
<h3 className="font-medium text-kumo-danger">Failed to load theme</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">
|
||||
{error instanceof Error ? error.message : "Theme not found"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thumbnailUrl = theme.hasThumbnail
|
||||
? `/_emdash/api/admin/themes/marketplace/${encodeURIComponent(theme.id)}/thumbnail`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to={"/themes/marketplace" as "/"}
|
||||
className="inline-flex items-center gap-1 text-sm text-kumo-subtle hover:text-kumo-default"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Themes
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
{thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" className="h-16 w-16 rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-kumo-brand/10">
|
||||
<Palette className="h-8 w-8 text-kumo-brand" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{theme.name}</h1>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-kumo-subtle">
|
||||
<span>{theme.author.name}</span>
|
||||
{theme.author.verified && <ShieldCheck className="h-4 w-4 text-kumo-brand" />}
|
||||
</div>
|
||||
{theme.description && (
|
||||
<p className="mt-2 text-sm text-kumo-subtle max-w-xl">{theme.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => previewMutation.mutate()}
|
||||
disabled={previewMutation.isPending}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{previewMutation.isPending ? "Loading..." : "Try with my data"}
|
||||
</Button>
|
||||
{theme.demoUrl && isSafeUrl(theme.demoUrl) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open(theme.demoUrl!, "_blank", "noopener")}
|
||||
>
|
||||
<ArrowSquareOut className="mr-2 h-4 w-4" />
|
||||
Demo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewMutation.error && (
|
||||
<div className="rounded-md border border-kumo-danger/50 bg-kumo-danger/10 p-3 text-sm text-kumo-danger">
|
||||
{previewMutation.error instanceof Error
|
||||
? previewMutation.error.message
|
||||
: "Failed to generate preview URL"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot gallery */}
|
||||
{theme.screenshotCount > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Screenshots</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{theme.screenshotUrls.map((url, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="rounded-lg border overflow-hidden hover:border-kumo-brand/50 transition-colors cursor-pointer"
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`Screenshot ${i + 1}`}
|
||||
className="aspect-video w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{/* Keywords */}
|
||||
{theme.keywords.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-kumo-subtle mb-2">Keywords</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{theme.keywords.map((kw) => (
|
||||
<Badge key={kw} variant="secondary">
|
||||
{kw}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License */}
|
||||
{theme.license && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-kumo-subtle mb-2">License</h3>
|
||||
<p className="text-sm">{theme.license}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-kumo-subtle mb-2">Links</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{theme.repositoryUrl && isSafeUrl(theme.repositoryUrl) && (
|
||||
<a
|
||||
href={theme.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-kumo-brand hover:underline"
|
||||
>
|
||||
<GithubLogo className="h-4 w-4" />
|
||||
Repository
|
||||
</a>
|
||||
)}
|
||||
{theme.homepageUrl && isSafeUrl(theme.homepageUrl) && (
|
||||
<a
|
||||
href={theme.homepageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-kumo-brand hover:underline"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Homepage
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
urls={theme.screenshotUrls}
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onPrev={() =>
|
||||
setLightboxIndex((i) => (i !== null && i > 0 ? i - 1 : theme.screenshotUrls.length - 1))
|
||||
}
|
||||
onNext={() =>
|
||||
setLightboxIndex((i) => (i !== null && i < theme.screenshotUrls.length - 1 ? i + 1 : 0))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Lightbox({
|
||||
urls,
|
||||
index,
|
||||
onClose,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
urls: string[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "ArrowLeft") onPrev();
|
||||
if (e.key === "ArrowRight") onNext();
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose, onPrev, onNext]);
|
||||
|
||||
const url = urls[index];
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="relative max-h-[90vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
|
||||
<img src={url} alt={`Screenshot ${index + 1}`} className="max-h-[85vh] rounded-lg" />
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute -top-3 -right-3 rounded-full bg-kumo-base p-1.5 shadow-lg hover:bg-kumo-tint"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-kumo-base/80 p-2 shadow hover:bg-kumo-base"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<CaretLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-kumo-base/80 p-2 shadow hover:bg-kumo-base"
|
||||
aria-label="Next"
|
||||
>
|
||||
<CaretRight className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-kumo-base/80 px-3 py-1 text-xs">
|
||||
{index + 1} / {urls.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeMarketplaceDetail;
|
||||
101
packages/admin/src/components/ThemeProvider.tsx
Normal file
101
packages/admin/src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
/** The resolved theme (always "light" or "dark") */
|
||||
resolvedTheme: "light" | "dark";
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = "emdash-theme";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function getStoredTheme(): Theme {
|
||||
if (typeof window === "undefined") return "system";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
return stored;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Default theme if none stored. Defaults to "system" */
|
||||
defaultTheme?: Theme;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children, defaultTheme = "system" }: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
const stored = getStoredTheme();
|
||||
return stored === "system" ? defaultTheme : stored;
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
||||
if (theme === "system") return getSystemTheme();
|
||||
return theme;
|
||||
});
|
||||
|
||||
// Update DOM and resolved theme when theme changes.
|
||||
// Uses data-mode (not data-theme) for dark mode — kumo's convention.
|
||||
// data-theme is reserved for visual identity overrides.
|
||||
// Update DOM and resolved theme when theme changes.
|
||||
// Uses data-mode (not data-theme) for dark mode — kumo's convention.
|
||||
// data-theme is reserved for visual identity overrides (e.g. "classic").
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply classic visual identity at the root level
|
||||
// so token overrides cascade to all kumo components including portals
|
||||
root.setAttribute("data-theme", "classic");
|
||||
|
||||
if (theme === "system") {
|
||||
root.removeAttribute("data-mode");
|
||||
setResolvedTheme(getSystemTheme());
|
||||
} else {
|
||||
root.setAttribute("data-mode", theme);
|
||||
setResolvedTheme(theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes when in system mode
|
||||
React.useEffect(() => {
|
||||
if (theme !== "system") return;
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setResolvedTheme(e.matches ? "dark" : "light");
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = React.useCallback((newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ theme, setTheme, resolvedTheme }),
|
||||
[theme, setTheme, resolvedTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = React.useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
41
packages/admin/src/components/ThemeToggle.tsx
Normal file
41
packages/admin/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Button } from "@cloudflare/kumo";
|
||||
import { Sun, Moon, Monitor } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
|
||||
/**
|
||||
* Theme toggle button that cycles through: system -> light -> dark
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const cycleTheme = () => {
|
||||
const order: ["system", "light", "dark"] = ["system", "light", "dark"];
|
||||
const currentIndex = order.indexOf(theme);
|
||||
const nextIndex = (currentIndex + 1) % order.length;
|
||||
setTheme(order[nextIndex]!);
|
||||
};
|
||||
|
||||
const label =
|
||||
theme === "system" ? `System (${resolvedTheme})` : theme === "light" ? "Light" : "Dark";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label={`Toggle theme (current: ${label})`}
|
||||
onClick={cycleTheme}
|
||||
title={`Theme: ${label}`}
|
||||
>
|
||||
{theme === "system" ? (
|
||||
<Monitor className="h-5 w-5" />
|
||||
) : theme === "light" ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme (current: {label})</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
132
packages/admin/src/components/WelcomeModal.tsx
Normal file
132
packages/admin/src/components/WelcomeModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Welcome Modal
|
||||
*
|
||||
* Shown to new users on their first login to welcome them to EmDash.
|
||||
*/
|
||||
|
||||
import { Button, Dialog } from "@cloudflare/kumo";
|
||||
import { Sparkle } from "@phosphor-icons/react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { apiFetch, throwResponseError } from "../lib/api/client";
|
||||
|
||||
interface WelcomeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
userName?: string;
|
||||
userRole: number;
|
||||
}
|
||||
|
||||
// Role labels
|
||||
function getRoleLabel(role: number): string {
|
||||
if (role >= 50) return "Administrator";
|
||||
if (role >= 40) return "Editor";
|
||||
if (role >= 30) return "Author";
|
||||
if (role >= 20) return "Contributor";
|
||||
return "Subscriber";
|
||||
}
|
||||
|
||||
async function dismissWelcome(): Promise<void> {
|
||||
const response = await apiFetch("/_emdash/api/auth/me", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "dismissWelcome" }),
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to dismiss welcome");
|
||||
}
|
||||
|
||||
export function WelcomeModal({ open, onClose, userName, userRole }: WelcomeModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: dismissWelcome,
|
||||
onSuccess: () => {
|
||||
// Update the cached user data to reflect that they've seen the welcome
|
||||
queryClient.setQueryData(["currentUser"], (old: unknown) => {
|
||||
if (old && typeof old === "object") {
|
||||
return { ...old, isFirstLogin: false };
|
||||
}
|
||||
return old;
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
// Still close on error - don't block the user
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleGetStarted = () => {
|
||||
dismissMutation.mutate();
|
||||
};
|
||||
|
||||
const roleLabel = getRoleLabel(userRole);
|
||||
const isAdmin = userRole >= 50;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(isOpen: boolean) => !isOpen && handleGetStarted()}>
|
||||
<Dialog className="p-6 sm:max-w-md" size="lg">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1" />
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1.5 text-center sm:text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-kumo-brand/10">
|
||||
<Sparkle className="h-8 w-8 text-kumo-brand" />
|
||||
</div>
|
||||
<Dialog.Title className="text-2xl font-semibold leading-none tracking-tight">
|
||||
Welcome to EmDash{userName ? `, ${userName.split(" ")[0]}` : ""}!
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-base text-kumo-subtle">
|
||||
Your account has been created successfully.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="rounded-lg bg-kumo-tint p-4">
|
||||
<div className="text-sm font-medium">Your Role</div>
|
||||
<div className="text-lg font-semibold text-kumo-brand">{roleLabel}</div>
|
||||
<p className="text-sm text-kumo-subtle mt-1">
|
||||
{isAdmin
|
||||
? "You have full access to manage this site, including users, settings, and all content."
|
||||
: userRole >= 40
|
||||
? "You can manage content, media, menus, and taxonomies."
|
||||
: userRole >= 30
|
||||
? "You can create and edit your own content."
|
||||
: "You can view and contribute to the site."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
As an administrator, you can invite other users from the{" "}
|
||||
<span className="font-medium">Users</span> section.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-center sm:space-x-2">
|
||||
<Button onClick={handleGetStarted} disabled={dismissMutation.isPending} size="lg">
|
||||
{dismissMutation.isPending ? "Loading..." : "Get Started"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
880
packages/admin/src/components/Widgets.tsx
Normal file
880
packages/admin/src/components/Widgets.tsx
Normal file
@@ -0,0 +1,880 @@
|
||||
/**
|
||||
* Widgets page component
|
||||
*
|
||||
* Manage widget areas and widgets with drag-and-drop support.
|
||||
* Available widgets can be dragged from the palette into widget areas.
|
||||
* Widgets within an area can be reordered via drag-and-drop.
|
||||
*/
|
||||
|
||||
import { Button, Dialog, Input, Label, Select, Switch, Toast } from "@cloudflare/kumo";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
KeyboardSensor,
|
||||
closestCenter,
|
||||
rectIntersection,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
PointerSensor,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Plus, DotsSixVertical, Trash, CaretDown, CaretRight } from "@phosphor-icons/react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchWidgetAreas,
|
||||
fetchWidgetComponents,
|
||||
fetchMenus,
|
||||
createWidgetArea,
|
||||
createWidget,
|
||||
updateWidget,
|
||||
deleteWidget,
|
||||
deleteWidgetArea,
|
||||
reorderWidgets,
|
||||
type WidgetArea,
|
||||
type Widget,
|
||||
type WidgetComponent,
|
||||
type CreateWidgetInput,
|
||||
type UpdateWidgetInput,
|
||||
} from "../lib/api";
|
||||
import { ConfirmDialog } from "./ConfirmDialog.js";
|
||||
import { DialogError, getMutationError } from "./DialogError.js";
|
||||
import { PortableTextEditor } from "./PortableTextEditor";
|
||||
|
||||
/** Palette item types that can be dragged into areas */
|
||||
interface PaletteItemData {
|
||||
source: "palette";
|
||||
widgetInput: CreateWidgetInput;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Identifies an existing widget being reordered */
|
||||
interface ExistingWidgetData {
|
||||
source: "area";
|
||||
areaName: string;
|
||||
}
|
||||
|
||||
type DragItemData = PaletteItemData | ExistingWidgetData;
|
||||
|
||||
function isPaletteItem(data: DragItemData): data is PaletteItemData {
|
||||
return data.source === "palette";
|
||||
}
|
||||
|
||||
/** Built-in widget types available in the palette */
|
||||
const BUILTIN_WIDGETS: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
input: CreateWidgetInput;
|
||||
}> = [
|
||||
{
|
||||
id: "palette-content",
|
||||
label: "Content Block",
|
||||
description: "Rich text content",
|
||||
input: { type: "content", title: "Content Block" },
|
||||
},
|
||||
{
|
||||
id: "palette-menu",
|
||||
label: "Menu",
|
||||
description: "Display a navigation menu",
|
||||
input: { type: "menu", title: "Menu" },
|
||||
},
|
||||
];
|
||||
|
||||
export function Widgets() {
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
const [isCreateAreaOpen, setIsCreateAreaOpen] = React.useState(false);
|
||||
const [createAreaError, setCreateAreaError] = React.useState<string | null>(null);
|
||||
const [activeId, setActiveId] = React.useState<string | null>(null);
|
||||
const [activeDragData, setActiveDragData] = React.useState<DragItemData | null>(null);
|
||||
const [expandedWidgets, setExpandedWidgets] = React.useState<Set<string>>(new Set());
|
||||
// Track palette drag source across the full drag lifecycle (including drop animation)
|
||||
const draggingFromPaletteRef = React.useRef(false);
|
||||
|
||||
const { data: areas = [], isLoading } = useQuery({
|
||||
queryKey: ["widget-areas"],
|
||||
queryFn: fetchWidgetAreas,
|
||||
});
|
||||
|
||||
const { data: components = [] } = useQuery({
|
||||
queryKey: ["widget-components"],
|
||||
queryFn: fetchWidgetComponents,
|
||||
});
|
||||
|
||||
const createAreaMutation = useMutation({
|
||||
mutationFn: createWidgetArea,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
|
||||
setIsCreateAreaOpen(false);
|
||||
toastManager.add({ title: "Widget area created" });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setCreateAreaError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const createWidgetMutation = useMutation({
|
||||
mutationFn: ({ areaName, input }: { areaName: string; input: CreateWidgetInput }) =>
|
||||
createWidget(areaName, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
|
||||
toastManager.add({ title: "Widget added" });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toastManager.add({
|
||||
title: "Error adding widget",
|
||||
description: error.message,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateArea = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setCreateAreaError(null);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const nameVal = formData.get("name");
|
||||
const labelVal = formData.get("label");
|
||||
const descVal = formData.get("description");
|
||||
createAreaMutation.mutate({
|
||||
name: typeof nameVal === "string" ? nameVal : "",
|
||||
label: typeof labelVal === "string" ? labelVal : "",
|
||||
description: typeof descVal === "string" ? descVal : "",
|
||||
});
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// Custom collision detection: palette items use rectIntersection (anywhere
|
||||
// over the area counts) and only match area:* droppables. Existing widgets
|
||||
// use closestCenter for precise reorder positioning.
|
||||
const collisionDetection: CollisionDetection = React.useCallback((args) => {
|
||||
const dragData = args.active.data.current as DragItemData | undefined;
|
||||
if (dragData && isPaletteItem(dragData)) {
|
||||
// Only consider area droppables, use generous rect intersection
|
||||
const areaContainers = args.droppableContainers.filter((c) =>
|
||||
String(c.id).startsWith("area:"),
|
||||
);
|
||||
return rectIntersection({ ...args, droppableContainers: areaContainers });
|
||||
}
|
||||
return closestCenter(args);
|
||||
}, []);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const id = String(event.active.id);
|
||||
const data = (event.active.data.current as DragItemData) ?? null;
|
||||
setActiveId(id);
|
||||
setActiveDragData(data);
|
||||
draggingFromPaletteRef.current = data !== null && isPaletteItem(data);
|
||||
};
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: ({ areaName, widgetIds }: { areaName: string; widgetIds: string[] }) =>
|
||||
reorderWidgets(areaName, widgetIds),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toastManager.add({
|
||||
title: "Error reordering widgets",
|
||||
description: error.message,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const dragData = active.data.current as DragItemData | undefined;
|
||||
|
||||
setActiveId(null);
|
||||
setActiveDragData(null);
|
||||
|
||||
if (!over || !dragData) return;
|
||||
|
||||
// Case 1: Dragging from palette into an area
|
||||
if (isPaletteItem(dragData)) {
|
||||
const overId = String(over.id);
|
||||
// The drop target is a widget area (droppable id = "area:{name}")
|
||||
if (overId.startsWith("area:")) {
|
||||
const areaName = overId.slice(5);
|
||||
createWidgetMutation.mutate({
|
||||
areaName,
|
||||
input: dragData.widgetInput,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: Reordering within an area
|
||||
if (active.id === over.id) return;
|
||||
|
||||
const sourceArea = areas.find((area) => area.widgets?.some((w) => w.id === active.id));
|
||||
if (!sourceArea?.widgets) return;
|
||||
|
||||
const oldIndex = sourceArea.widgets.findIndex((w) => w.id === active.id);
|
||||
const newIndex = sourceArea.widgets.findIndex((w) => w.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newWidgets = [...sourceArea.widgets];
|
||||
const [movedWidget] = newWidgets.splice(oldIndex, 1);
|
||||
if (!movedWidget) return;
|
||||
newWidgets.splice(newIndex, 0, movedWidget);
|
||||
|
||||
reorderMutation.mutate({
|
||||
areaName: sourceArea.name,
|
||||
widgetIds: newWidgets.map((w) => w.id),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleWidget = (widgetId: string) => {
|
||||
setExpandedWidgets((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(widgetId)) {
|
||||
next.delete(widgetId);
|
||||
} else {
|
||||
next.add(widgetId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Build the palette label for the drag overlay
|
||||
const activePaletteLabel =
|
||||
activeDragData && isPaletteItem(activeDragData) ? activeDragData.label : null;
|
||||
// Find the existing widget being dragged for overlay
|
||||
const activeWidget =
|
||||
activeId && activeDragData && !isPaletteItem(activeDragData)
|
||||
? areas.flatMap((a) => a.widgets ?? []).find((w) => w.id === activeId)
|
||||
: null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-kumo-subtle">Loading widgets...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Widgets</h1>
|
||||
<p className="text-kumo-subtle">Manage content widgets in your widget areas</p>
|
||||
</div>
|
||||
<Dialog.Root
|
||||
open={isCreateAreaOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsCreateAreaOpen(open);
|
||||
if (!open) setCreateAreaError(null);
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger
|
||||
render={(props) => (
|
||||
<Button {...props} icon={<Plus />}>
|
||||
Add Widget Area
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
Create Widget Area
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleCreateArea} className="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="sidebar"
|
||||
pattern="[a-z0-9-]+"
|
||||
/>
|
||||
<Input label="Label" name="label" required placeholder="Main Sidebar" />
|
||||
<Input
|
||||
label="Description"
|
||||
name="description"
|
||||
placeholder="Appears on posts and pages"
|
||||
/>
|
||||
<DialogError
|
||||
message={createAreaError || getMutationError(createAreaMutation.error)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateAreaOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createAreaMutation.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Available Widgets (draggable palette) */}
|
||||
<div className="col-span-4">
|
||||
<div className="rounded-lg border bg-kumo-base p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold">Available Widgets</h2>
|
||||
<p className="text-sm text-kumo-subtle">Drag widgets into an area to add them</p>
|
||||
<div className="space-y-2">
|
||||
{BUILTIN_WIDGETS.map((item) => (
|
||||
<DraggablePaletteItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
description={item.description}
|
||||
widgetInput={item.input}
|
||||
/>
|
||||
))}
|
||||
{components.map((comp) => (
|
||||
<DraggablePaletteItem
|
||||
key={`palette-comp-${comp.id}`}
|
||||
id={`palette-comp-${comp.id}`}
|
||||
label={comp.label}
|
||||
description={comp.description}
|
||||
widgetInput={{
|
||||
type: "component",
|
||||
title: comp.label,
|
||||
componentId: comp.id,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Widget Areas (droppable + sortable) */}
|
||||
<div className="col-span-8 space-y-4">
|
||||
{areas.length === 0 ? (
|
||||
<div className="rounded-lg border bg-kumo-base p-12 text-center">
|
||||
<p className="text-kumo-subtle">No widget areas yet. Create one to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
areas.map((area) => (
|
||||
<WidgetAreaPanel
|
||||
key={area.id}
|
||||
area={area}
|
||||
expandedWidgets={expandedWidgets}
|
||||
onToggleWidget={toggleWidget}
|
||||
isDraggingPalette={activeDragData !== null && isPaletteItem(activeDragData)}
|
||||
components={components}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag overlay — no drop animation for palette items (source stays in place).
|
||||
Use ref because state is cleared in handleDragEnd before animation runs. */}
|
||||
<DragOverlay dropAnimation={draggingFromPaletteRef.current ? null : undefined}>
|
||||
{activePaletteLabel ? (
|
||||
<div className="rounded border bg-kumo-base p-3 shadow-lg opacity-90">
|
||||
<div className="font-medium">{activePaletteLabel}</div>
|
||||
</div>
|
||||
) : activeWidget ? (
|
||||
<div className="rounded border bg-kumo-base p-3 shadow-lg opacity-90">
|
||||
<div className="flex items-center gap-2">
|
||||
<DotsSixVertical className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="font-medium">{activeWidget.title || "Untitled Widget"}</span>
|
||||
<span className="text-xs text-kumo-subtle">({activeWidget.type})</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
/** A draggable item in the available widgets palette */
|
||||
function DraggablePaletteItem({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
widgetInput,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
widgetInput: CreateWidgetInput;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id,
|
||||
data: {
|
||||
source: "palette",
|
||||
widgetInput,
|
||||
label,
|
||||
} satisfies PaletteItemData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`p-3 rounded border cursor-grab active:cursor-grabbing select-none ${
|
||||
isDragging ? "opacity-50" : "hover:bg-kumo-tint"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{label}</div>
|
||||
{description && <div className="text-sm text-kumo-subtle">{description}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetAreaPanel({
|
||||
area,
|
||||
expandedWidgets,
|
||||
onToggleWidget,
|
||||
isDraggingPalette,
|
||||
components,
|
||||
}: {
|
||||
area: WidgetArea;
|
||||
expandedWidgets: Set<string>;
|
||||
onToggleWidget: (id: string) => void;
|
||||
isDraggingPalette: boolean;
|
||||
components: WidgetComponent[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
const [deleteAreaName, setDeleteAreaName] = React.useState<string | null>(null);
|
||||
|
||||
// Make the area a droppable target for palette items
|
||||
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||||
id: `area:${area.name}`,
|
||||
});
|
||||
|
||||
const deleteAreaMutation = useMutation({
|
||||
mutationFn: deleteWidgetArea,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
|
||||
setDeleteAreaName(null);
|
||||
toastManager.add({ title: "Widget area deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
const hasWidgets = area.widgets && area.widgets.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border bg-kumo-base transition-colors ${isOver ? "ring-2 ring-kumo-brand" : ""}`}
|
||||
>
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{area.label}</h3>
|
||||
{area.description && <p className="text-sm text-kumo-subtle">{area.description}</p>}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteAreaName(area.name)}
|
||||
aria-label={`Delete ${area.label} widget area`}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref={setDropRef} className="p-4 space-y-2 min-h-[80px]">
|
||||
{hasWidgets ? (
|
||||
<SortableContext
|
||||
items={area.widgets!.map((w) => w.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{area.widgets!.map((widget) => (
|
||||
<WidgetItem
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
areaName={area.name}
|
||||
isExpanded={expandedWidgets.has(widget.id)}
|
||||
onToggle={() => onToggleWidget(widget.id)}
|
||||
components={components}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : null}
|
||||
{/* Drop zone hint — shown when dragging a palette item */}
|
||||
{isDraggingPalette && (
|
||||
<div
|
||||
className={`text-center py-4 rounded border-2 border-dashed transition-colors ${
|
||||
isOver
|
||||
? "border-kumo-brand bg-kumo-brand/5 text-kumo-brand"
|
||||
: "border-kumo-subtle/30 text-kumo-subtle"
|
||||
}`}
|
||||
>
|
||||
{isOver ? "Drop to add widget" : "Drag here to add"}
|
||||
</div>
|
||||
)}
|
||||
{!hasWidgets && !isDraggingPalette && (
|
||||
<div className="text-center py-8 text-kumo-subtle">Drag widgets here to add them</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteAreaName === area.name}
|
||||
onClose={() => {
|
||||
setDeleteAreaName(null);
|
||||
deleteAreaMutation.reset();
|
||||
}}
|
||||
title="Delete Widget Area?"
|
||||
description="This will delete the widget area and all its widgets. This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={deleteAreaMutation.isPending}
|
||||
error={deleteAreaMutation.error}
|
||||
onConfirm={() => deleteAreaMutation.mutate(area.name)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetItem({
|
||||
widget,
|
||||
areaName,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
components,
|
||||
}: {
|
||||
widget: Widget;
|
||||
areaName: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
components: WidgetComponent[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const toastManager = Toast.useToastManager();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: widget.id,
|
||||
data: {
|
||||
source: "area",
|
||||
areaName,
|
||||
} satisfies ExistingWidgetData,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteWidget(areaName, widget.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
|
||||
toastManager.add({ title: "Widget deleted" });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toastManager.add({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (input: UpdateWidgetInput) => updateWidget(areaName, widget.id, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["widget-areas"] });
|
||||
toastManager.add({ title: "Widget updated" });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toastManager.add({
|
||||
title: "Error updating widget",
|
||||
description: error.message,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`rounded border bg-kumo-base p-3 ${isDragging ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag to reorder ${widget.title || "widget"}`}
|
||||
>
|
||||
<DotsSixVertical className="h-4 w-4 text-kumo-subtle" />
|
||||
</button>
|
||||
<button onClick={onToggle} className="flex-1 text-left" aria-expanded={isExpanded}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? <CaretDown className="h-4 w-4" /> : <CaretRight className="h-4 w-4" />}
|
||||
<span className="font-medium">{widget.title || "Untitled Widget"}</span>
|
||||
<span className="text-xs text-kumo-subtle">({widget.type})</span>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
aria-label={`Delete ${widget.title || "widget"}`}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<WidgetEditor
|
||||
widget={widget}
|
||||
components={components}
|
||||
onSave={(input) => updateMutation.mutate(input)}
|
||||
isSaving={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline editor form for a widget, rendered when the widget is expanded */
|
||||
function WidgetEditor({
|
||||
widget,
|
||||
components,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: {
|
||||
widget: Widget;
|
||||
components: WidgetComponent[];
|
||||
onSave: (input: UpdateWidgetInput) => void;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
const [title, setTitle] = React.useState(widget.title ?? "");
|
||||
const [content, setContent] = React.useState<unknown[]>(
|
||||
Array.isArray(widget.content) ? widget.content : [],
|
||||
);
|
||||
const [menuName, setMenuName] = React.useState(widget.menuName ?? "");
|
||||
const [componentId, setComponentId] = React.useState(widget.componentId ?? "");
|
||||
const [componentProps, setComponentProps] = React.useState<Record<string, unknown>>(
|
||||
widget.componentProps ?? {},
|
||||
);
|
||||
|
||||
const { data: menus = [] } = useQuery({
|
||||
queryKey: ["menus"],
|
||||
queryFn: fetchMenus,
|
||||
enabled: widget.type === "menu",
|
||||
});
|
||||
|
||||
const selectedComponent = components.find((c) => c.id === componentId);
|
||||
|
||||
const handleSave = () => {
|
||||
const input: UpdateWidgetInput = { title };
|
||||
if (widget.type === "content") {
|
||||
input.content = content;
|
||||
} else if (widget.type === "menu") {
|
||||
input.menuName = menuName;
|
||||
} else if (widget.type === "component") {
|
||||
input.componentId = componentId;
|
||||
input.componentProps = componentProps;
|
||||
}
|
||||
onSave(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 p-3 bg-kumo-tint rounded space-y-4">
|
||||
<Input
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Widget title"
|
||||
/>
|
||||
|
||||
{widget.type === "content" && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Content</Label>
|
||||
<PortableTextEditor
|
||||
value={content as Parameters<typeof PortableTextEditor>[0]["value"]}
|
||||
onChange={(value) => setContent(value as unknown[])}
|
||||
minimal
|
||||
placeholder="Write widget content..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widget.type === "menu" && (
|
||||
<Select
|
||||
label="Menu"
|
||||
value={menuName}
|
||||
onValueChange={(v) => setMenuName(v ?? "")}
|
||||
items={Object.fromEntries(menus.map((m) => [m.name, m.label || m.name]))}
|
||||
>
|
||||
<Select.Option value="">Select a menu...</Select.Option>
|
||||
{menus.map((m) => (
|
||||
<Select.Option key={m.name} value={m.name}>
|
||||
{m.label || m.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{widget.type === "component" && (
|
||||
<>
|
||||
<Select
|
||||
label="Component"
|
||||
value={componentId}
|
||||
onValueChange={(v) => {
|
||||
setComponentId(v ?? "");
|
||||
// Reset props when component changes
|
||||
if (v !== componentId) {
|
||||
const comp = components.find((c) => c.id === v);
|
||||
if (comp) {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const [key, def] of Object.entries(comp.props)) {
|
||||
defaults[key] = def.default ?? "";
|
||||
}
|
||||
setComponentProps(defaults);
|
||||
} else {
|
||||
setComponentProps({});
|
||||
}
|
||||
}
|
||||
}}
|
||||
items={Object.fromEntries(components.map((c) => [c.id, c.label]))}
|
||||
>
|
||||
<Select.Option value="">Select a component...</Select.Option>
|
||||
{components.map((c) => (
|
||||
<Select.Option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{selectedComponent &&
|
||||
Object.entries(selectedComponent.props).map(([key, def]) => (
|
||||
<ComponentPropField
|
||||
key={key}
|
||||
propKey={key}
|
||||
def={def}
|
||||
value={componentProps[key] ?? def.default ?? ""}
|
||||
onChange={(v) => setComponentProps((prev) => ({ ...prev, [key]: v }))}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a single prop field for a component widget based on PropDef type */
|
||||
function ComponentPropField({
|
||||
def,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
propKey: string;
|
||||
def: WidgetComponent["props"][string];
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
switch (def.type) {
|
||||
case "string":
|
||||
return (
|
||||
<Input
|
||||
label={def.label}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
label={def.label}
|
||||
type="number"
|
||||
value={typeof value === "number" ? value : ""}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
);
|
||||
case "boolean":
|
||||
return (
|
||||
<Switch
|
||||
label={def.label}
|
||||
checked={typeof value === "boolean" ? value : false}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
case "select": {
|
||||
const items: Record<string, string> = {};
|
||||
for (const opt of def.options ?? []) {
|
||||
items[opt.value] = opt.label;
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
label={def.label}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(v) => onChange(v ?? "")}
|
||||
items={items}
|
||||
>
|
||||
{def.options?.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
label={def.label}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
2324
packages/admin/src/components/WordPressImport.tsx
Normal file
2324
packages/admin/src/components/WordPressImport.tsx
Normal file
File diff suppressed because it is too large
Load Diff
343
packages/admin/src/components/auth/PasskeyLogin.tsx
Normal file
343
packages/admin/src/components/auth/PasskeyLogin.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* PasskeyLogin - WebAuthn authentication component
|
||||
*
|
||||
* Handles the passkey login flow:
|
||||
* 1. Fetches authentication options from server
|
||||
* 2. Triggers browser's WebAuthn credential assertion
|
||||
* 3. Sends assertion back to server for verification
|
||||
*
|
||||
* Supports:
|
||||
* - Discoverable credentials (passkey autofill)
|
||||
* - Non-discoverable credentials (email-first flow)
|
||||
*/
|
||||
|
||||
import { Button, Input } from "@cloudflare/kumo";
|
||||
import * as React from "react";
|
||||
|
||||
import { apiFetch, parseApiResponse } from "../../lib/api/client";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const BASE64URL_DASH_REGEX = /-/g;
|
||||
const BASE64URL_UNDERSCORE_REGEX = /_/g;
|
||||
const BASE64_PLUS_REGEX = /\+/g;
|
||||
const BASE64_SLASH_REGEX = /\//g;
|
||||
|
||||
// ============================================================================
|
||||
// WebAuthn types
|
||||
// ============================================================================
|
||||
interface PublicKeyCredentialRequestOptionsJSON {
|
||||
challenge: string;
|
||||
rpId: string;
|
||||
timeout?: number;
|
||||
userVerification?: "discouraged" | "preferred" | "required";
|
||||
allowCredentials?: Array<{
|
||||
type: "public-key";
|
||||
id: string;
|
||||
transports?: AuthenticatorTransport[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AuthenticationResponse {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: "public-key";
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
authenticatorData: string;
|
||||
signature: string;
|
||||
userHandle?: string;
|
||||
};
|
||||
authenticatorAttachment?: "platform" | "cross-platform";
|
||||
}
|
||||
|
||||
export interface PasskeyLoginProps {
|
||||
/** Endpoint to get authentication options */
|
||||
optionsEndpoint: string;
|
||||
/** Endpoint to verify authentication */
|
||||
verifyEndpoint: string;
|
||||
/** Called on successful authentication */
|
||||
onSuccess: (response: unknown) => void;
|
||||
/** Called on error */
|
||||
onError?: (error: Error) => void;
|
||||
/** Show email input for non-discoverable flow */
|
||||
showEmailInput?: boolean;
|
||||
/** Button text */
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
type LoginState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading"; message: string }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "success" };
|
||||
|
||||
/**
|
||||
* Check if WebAuthn is supported in the current browser
|
||||
*/
|
||||
function isWebAuthnSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.PublicKeyCredential !== undefined &&
|
||||
typeof window.PublicKeyCredential === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if conditional mediation (autofill) is supported
|
||||
*/
|
||||
async function isConditionalMediationSupported(): Promise<boolean> {
|
||||
if (!isWebAuthnSupported()) return false;
|
||||
try {
|
||||
return (await PublicKeyCredential.isConditionalMediationAvailable?.()) ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64url to ArrayBuffer
|
||||
*/
|
||||
function base64urlToBuffer(base64url: string): ArrayBuffer {
|
||||
const base64 = base64url
|
||||
.replace(BASE64URL_DASH_REGEX, "+")
|
||||
.replace(BASE64URL_UNDERSCORE_REGEX, "/");
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const binary = atob(base64 + padding);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64url (with padding for @oslojs/encoding compatibility)
|
||||
*/
|
||||
function bufferToBase64url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
// Convert to base64url but keep padding (required by @oslojs/encoding)
|
||||
return base64.replace(BASE64_PLUS_REGEX, "-").replace(BASE64_SLASH_REGEX, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* PasskeyLogin Component
|
||||
*/
|
||||
export function PasskeyLogin({
|
||||
optionsEndpoint,
|
||||
verifyEndpoint,
|
||||
onSuccess,
|
||||
onError,
|
||||
showEmailInput = false,
|
||||
buttonText = "Sign in with Passkey",
|
||||
}: PasskeyLoginProps) {
|
||||
const [state, setState] = React.useState<LoginState>({ status: "idle" });
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [supportsConditional, setSupportsConditional] = React.useState(false);
|
||||
|
||||
// Check WebAuthn support on mount
|
||||
const isSupported = React.useMemo(() => isWebAuthnSupported(), []);
|
||||
|
||||
// Check conditional mediation support
|
||||
React.useEffect(() => {
|
||||
void isConditionalMediationSupported().then(setSupportsConditional);
|
||||
}, []);
|
||||
|
||||
const handleLogin = React.useCallback(
|
||||
async (useConditional = false) => {
|
||||
if (!isSupported) {
|
||||
setState({
|
||||
status: "error",
|
||||
message: "WebAuthn is not supported in this browser",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Get authentication options from server
|
||||
setState({ status: "loading", message: "Preparing..." });
|
||||
|
||||
const optionsResponse = await apiFetch(optionsEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email || undefined }),
|
||||
});
|
||||
|
||||
const optionsData = await parseApiResponse<{
|
||||
options: PublicKeyCredentialRequestOptionsJSON;
|
||||
}>(optionsResponse, "Failed to get authentication options");
|
||||
const { options } = optionsData;
|
||||
|
||||
// Step 2: Get assertion from browser
|
||||
setState({ status: "loading", message: "Waiting for passkey..." });
|
||||
|
||||
// Convert options to the format expected by the browser
|
||||
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
rpId: options.rpId,
|
||||
timeout: options.timeout,
|
||||
userVerification: options.userVerification,
|
||||
allowCredentials: options.allowCredentials?.map((cred) => ({
|
||||
type: cred.type,
|
||||
id: base64urlToBuffer(cred.id),
|
||||
transports: cred.transports,
|
||||
})),
|
||||
};
|
||||
|
||||
const credentialOptions: CredentialRequestOptions = {
|
||||
publicKey: publicKeyOptions,
|
||||
// Use conditional mediation if supported and requested
|
||||
...(useConditional && supportsConditional
|
||||
? { mediation: "conditional" as CredentialMediationRequirement }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const rawCredential = await navigator.credentials.get(credentialOptions);
|
||||
|
||||
if (!rawCredential) {
|
||||
throw new Error("No credential returned from authenticator");
|
||||
}
|
||||
|
||||
// Step 3: Send credential to server for verification
|
||||
setState({ status: "loading", message: "Verifying..." });
|
||||
|
||||
// navigator.credentials.get() with publicKey returns PublicKeyCredential
|
||||
const credential = rawCredential as PublicKeyCredential;
|
||||
const assertionResponse = credential.response as AuthenticatorAssertionResponse;
|
||||
|
||||
// authenticatorAttachment exists at runtime on PublicKeyCredential but isn't in the base type definition
|
||||
const rawAttachment =
|
||||
"authenticatorAttachment" in credential ? credential.authenticatorAttachment : undefined;
|
||||
const authenticatorAttachment =
|
||||
rawAttachment === "platform" || rawAttachment === "cross-platform"
|
||||
? rawAttachment
|
||||
: undefined;
|
||||
|
||||
const authenticationResponse: AuthenticationResponse = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(assertionResponse.clientDataJSON),
|
||||
authenticatorData: bufferToBase64url(assertionResponse.authenticatorData),
|
||||
signature: bufferToBase64url(assertionResponse.signature),
|
||||
userHandle: assertionResponse.userHandle
|
||||
? bufferToBase64url(assertionResponse.userHandle)
|
||||
: undefined,
|
||||
},
|
||||
authenticatorAttachment,
|
||||
};
|
||||
|
||||
const verifyResponse = await apiFetch(verifyEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ credential: authenticationResponse }),
|
||||
});
|
||||
|
||||
const result = await parseApiResponse<unknown>(
|
||||
verifyResponse,
|
||||
"Failed to verify authentication",
|
||||
);
|
||||
|
||||
setState({ status: "success" });
|
||||
onSuccess(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Authentication failed";
|
||||
|
||||
// Handle specific WebAuthn errors
|
||||
let userMessage = message;
|
||||
if (error instanceof DOMException) {
|
||||
switch (error.name) {
|
||||
case "NotAllowedError":
|
||||
userMessage = "Authentication was cancelled or timed out. Please try again.";
|
||||
break;
|
||||
case "InvalidStateError":
|
||||
userMessage = "No matching passkey found for this account.";
|
||||
break;
|
||||
case "NotSupportedError":
|
||||
userMessage = "Your device doesn't support the required security features.";
|
||||
break;
|
||||
case "SecurityError":
|
||||
userMessage = "Security error. Make sure you're on a secure connection.";
|
||||
break;
|
||||
case "AbortError":
|
||||
// User cancelled - don't show error
|
||||
setState({ status: "idle" });
|
||||
return;
|
||||
default:
|
||||
userMessage = `Authentication error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
setState({ status: "error", message: userMessage });
|
||||
onError?.(new Error(userMessage));
|
||||
}
|
||||
},
|
||||
[isSupported, optionsEndpoint, verifyEndpoint, email, supportsConditional, onSuccess, onError],
|
||||
);
|
||||
|
||||
// Not supported message
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
|
||||
<h3 className="font-medium text-kumo-danger">Passkeys Not Supported</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">
|
||||
Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari,
|
||||
Firefox, or Edge.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Email input (optional - for non-discoverable credentials) */}
|
||||
{showEmailInput && (
|
||||
<div>
|
||||
<Input
|
||||
label="Email (optional)"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
disabled={state.status === "loading"}
|
||||
autoComplete="username webauthn"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-kumo-subtle">
|
||||
Leave blank to use a discoverable passkey.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{state.status === "error" && (
|
||||
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login button */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleLogin(false)}
|
||||
loading={state.status === "loading"}
|
||||
className="w-full justify-center"
|
||||
variant="primary"
|
||||
>
|
||||
{state.status === "loading" ? <>{state.message}</> : buttonText}
|
||||
</Button>
|
||||
|
||||
{/* Help text */}
|
||||
<p className="text-xs text-kumo-subtle text-center">
|
||||
Use your device's biometric authentication, security key, or PIN to sign in.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
358
packages/admin/src/components/auth/PasskeyRegistration.tsx
Normal file
358
packages/admin/src/components/auth/PasskeyRegistration.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* PasskeyRegistration - WebAuthn credential registration component
|
||||
*
|
||||
* Handles the passkey registration flow:
|
||||
* 1. Fetches registration options from server
|
||||
* 2. Triggers browser's WebAuthn credential creation
|
||||
* 3. Sends attestation back to server for verification
|
||||
*
|
||||
* Used in:
|
||||
* - Setup wizard (first admin creation)
|
||||
* - User settings (adding additional passkeys)
|
||||
*/
|
||||
|
||||
import { Button, Input } from "@cloudflare/kumo";
|
||||
import * as React from "react";
|
||||
|
||||
import { apiFetch, parseApiResponse } from "../../lib/api/client";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const BASE64URL_DASH_REGEX = /-/g;
|
||||
const BASE64URL_UNDERSCORE_REGEX = /_/g;
|
||||
const BASE64_PLUS_REGEX = /\+/g;
|
||||
const BASE64_SLASH_REGEX = /\//g;
|
||||
|
||||
// ============================================================================
|
||||
// WebAuthn types
|
||||
// ============================================================================
|
||||
interface PublicKeyCredentialCreationOptionsJSON {
|
||||
challenge: string;
|
||||
rp: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
pubKeyCredParams: Array<{
|
||||
type: "public-key";
|
||||
alg: number;
|
||||
}>;
|
||||
timeout?: number;
|
||||
attestation?: "none" | "indirect" | "direct";
|
||||
authenticatorSelection?: {
|
||||
authenticatorAttachment?: "platform" | "cross-platform";
|
||||
residentKey?: "discouraged" | "preferred" | "required";
|
||||
requireResidentKey?: boolean;
|
||||
userVerification?: "discouraged" | "preferred" | "required";
|
||||
};
|
||||
excludeCredentials?: Array<{
|
||||
type: "public-key";
|
||||
id: string;
|
||||
transports?: AuthenticatorTransport[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RegistrationResponse {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: "public-key";
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
transports?: AuthenticatorTransport[];
|
||||
};
|
||||
authenticatorAttachment?: "platform" | "cross-platform";
|
||||
}
|
||||
|
||||
export interface PasskeyRegistrationProps {
|
||||
/** Endpoint to get registration options */
|
||||
optionsEndpoint: string;
|
||||
/** Endpoint to verify registration */
|
||||
verifyEndpoint: string;
|
||||
/** Called on successful registration */
|
||||
onSuccess: (response: unknown) => void;
|
||||
/** Called on error */
|
||||
onError?: (error: Error) => void;
|
||||
/** Button text */
|
||||
buttonText?: string;
|
||||
/** Show passkey name input */
|
||||
showNameInput?: boolean;
|
||||
/** Additional data to send with requests */
|
||||
additionalData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const EMPTY_DATA: Record<string, unknown> = {};
|
||||
|
||||
type RegistrationState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading"; message: string }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "success" };
|
||||
|
||||
/**
|
||||
* Check if WebAuthn is supported in the current browser
|
||||
*/
|
||||
function isWebAuthnSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.PublicKeyCredential !== undefined &&
|
||||
typeof window.PublicKeyCredential === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64url to ArrayBuffer
|
||||
*/
|
||||
function base64urlToBuffer(base64url: string): ArrayBuffer {
|
||||
const base64 = base64url
|
||||
.replace(BASE64URL_DASH_REGEX, "+")
|
||||
.replace(BASE64URL_UNDERSCORE_REGEX, "/");
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const binary = atob(base64 + padding);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64url (with padding for @oslojs/encoding compatibility)
|
||||
*/
|
||||
function bufferToBase64url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
// Convert to base64url but keep padding (required by @oslojs/encoding)
|
||||
return base64.replace(BASE64_PLUS_REGEX, "-").replace(BASE64_SLASH_REGEX, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* PasskeyRegistration Component
|
||||
*/
|
||||
export function PasskeyRegistration({
|
||||
optionsEndpoint,
|
||||
verifyEndpoint,
|
||||
onSuccess,
|
||||
onError,
|
||||
buttonText = "Register Passkey",
|
||||
showNameInput = false,
|
||||
additionalData = EMPTY_DATA,
|
||||
}: PasskeyRegistrationProps) {
|
||||
const [state, setState] = React.useState<RegistrationState>({
|
||||
status: "idle",
|
||||
});
|
||||
const [passkeyName, setPasskeyName] = React.useState("");
|
||||
|
||||
// Check WebAuthn support on mount
|
||||
const isSupported = React.useMemo(() => isWebAuthnSupported(), []);
|
||||
|
||||
const handleRegister = React.useCallback(async () => {
|
||||
if (!isSupported) {
|
||||
setState({
|
||||
status: "error",
|
||||
message: "WebAuthn is not supported in this browser",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Get registration options from server
|
||||
setState({ status: "loading", message: "Preparing registration..." });
|
||||
|
||||
const optionsResponse = await apiFetch(optionsEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(additionalData),
|
||||
});
|
||||
|
||||
const optionsData = await parseApiResponse<{
|
||||
options: PublicKeyCredentialCreationOptionsJSON;
|
||||
}>(optionsResponse, "Failed to get registration options");
|
||||
const { options } = optionsData;
|
||||
|
||||
// Step 2: Create credential with browser
|
||||
setState({ status: "loading", message: "Waiting for passkey..." });
|
||||
|
||||
// Convert options to the format expected by the browser
|
||||
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
rp: options.rp,
|
||||
user: {
|
||||
id: base64urlToBuffer(options.user.id),
|
||||
name: options.user.name,
|
||||
displayName: options.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
timeout: options.timeout,
|
||||
attestation: options.attestation,
|
||||
authenticatorSelection: options.authenticatorSelection,
|
||||
excludeCredentials: options.excludeCredentials?.map((cred) => ({
|
||||
type: cred.type,
|
||||
id: base64urlToBuffer(cred.id),
|
||||
transports: cred.transports,
|
||||
})),
|
||||
};
|
||||
|
||||
const rawCredential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions,
|
||||
});
|
||||
|
||||
if (!rawCredential) {
|
||||
throw new Error("No credential returned from authenticator");
|
||||
}
|
||||
|
||||
// Step 3: Send credential to server for verification
|
||||
setState({ status: "loading", message: "Verifying..." });
|
||||
|
||||
// navigator.credentials.create() with publicKey returns PublicKeyCredential
|
||||
const credential = rawCredential as PublicKeyCredential;
|
||||
const attestationResponse = credential.response as AuthenticatorAttestationResponse;
|
||||
|
||||
// authenticatorAttachment exists at runtime on PublicKeyCredential but isn't in the base type definition
|
||||
const rawAttachment =
|
||||
"authenticatorAttachment" in credential ? credential.authenticatorAttachment : undefined;
|
||||
const authenticatorAttachment =
|
||||
rawAttachment === "platform" || rawAttachment === "cross-platform"
|
||||
? rawAttachment
|
||||
: undefined;
|
||||
|
||||
const registrationResponse: RegistrationResponse = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON),
|
||||
attestationObject: bufferToBase64url(attestationResponse.attestationObject),
|
||||
transports: attestationResponse.getTransports?.() as AuthenticatorTransport[] | undefined,
|
||||
},
|
||||
authenticatorAttachment,
|
||||
};
|
||||
|
||||
const verifyResponse = await apiFetch(verifyEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
credential: registrationResponse,
|
||||
name: passkeyName || undefined,
|
||||
...additionalData,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseApiResponse<unknown>(
|
||||
verifyResponse,
|
||||
"Failed to verify registration",
|
||||
);
|
||||
|
||||
setState({ status: "success" });
|
||||
onSuccess(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Registration failed";
|
||||
|
||||
// Handle specific WebAuthn errors
|
||||
let userMessage = message;
|
||||
if (error instanceof DOMException) {
|
||||
switch (error.name) {
|
||||
case "NotAllowedError":
|
||||
userMessage = "Registration was cancelled or timed out. Please try again.";
|
||||
break;
|
||||
case "InvalidStateError":
|
||||
userMessage = "This passkey is already registered on this device.";
|
||||
break;
|
||||
case "NotSupportedError":
|
||||
userMessage = "Your device doesn't support the required security features.";
|
||||
break;
|
||||
case "SecurityError":
|
||||
userMessage = "Security error. Make sure you're on a secure connection.";
|
||||
break;
|
||||
default:
|
||||
userMessage = `Authentication error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
setState({ status: "error", message: userMessage });
|
||||
onError?.(new Error(userMessage));
|
||||
}
|
||||
}, [
|
||||
isSupported,
|
||||
optionsEndpoint,
|
||||
verifyEndpoint,
|
||||
additionalData,
|
||||
passkeyName,
|
||||
onSuccess,
|
||||
onError,
|
||||
]);
|
||||
|
||||
// Not supported message
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
|
||||
<h3 className="font-medium text-kumo-danger">Passkeys Not Supported</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">
|
||||
Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari,
|
||||
Firefox, or Edge.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Passkey name input (optional) */}
|
||||
{showNameInput && (
|
||||
<div>
|
||||
<Input
|
||||
label="Passkey Name (optional)"
|
||||
type="text"
|
||||
value={passkeyName}
|
||||
onChange={(e) => setPasskeyName(e.target.value)}
|
||||
placeholder="e.g., MacBook Pro, iPhone"
|
||||
disabled={state.status === "loading"}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-kumo-subtle">
|
||||
Give this passkey a name to help you identify it later.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{state.status === "error" && (
|
||||
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{state.status === "success" && (
|
||||
<div className="rounded-lg bg-green-500/10 p-4 text-sm text-green-700 dark:text-green-400">
|
||||
Passkey registered successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Register button */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRegister}
|
||||
loading={state.status === "loading"}
|
||||
className="w-full justify-center"
|
||||
variant="primary"
|
||||
>
|
||||
{state.status === "loading" ? <>{state.message}</> : buttonText}
|
||||
</Button>
|
||||
|
||||
{/* Help text */}
|
||||
<p className="text-xs text-kumo-subtle text-center">
|
||||
You'll be prompted to use your device's biometric authentication, security key, or PIN.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
packages/admin/src/components/auth/index.ts
Normal file
9
packages/admin/src/components/auth/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Auth components for EmDash Admin
|
||||
*/
|
||||
|
||||
export { PasskeyRegistration } from "./PasskeyRegistration";
|
||||
export type { PasskeyRegistrationProps } from "./PasskeyRegistration";
|
||||
|
||||
export { PasskeyLogin } from "./PasskeyLogin";
|
||||
export type { PasskeyLoginProps } from "./PasskeyLogin";
|
||||
204
packages/admin/src/components/comments/CommentDetail.tsx
Normal file
204
packages/admin/src/components/comments/CommentDetail.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Comment detail slide-over panel.
|
||||
*
|
||||
* Shows full comment body, author details, moderation metadata,
|
||||
* and status change buttons.
|
||||
*/
|
||||
|
||||
import { Badge, Button } from "@cloudflare/kumo";
|
||||
import { X, Check, Trash, Warning, UserCircle, EnvelopeSimple } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { AdminComment, CommentStatus } from "../../lib/api/comments.js";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
export interface CommentDetailProps {
|
||||
comment: AdminComment;
|
||||
onClose: () => void;
|
||||
onStatusChange: (id: string, status: CommentStatus) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isAdmin: boolean;
|
||||
isStatusPending: boolean;
|
||||
}
|
||||
|
||||
export function CommentDetail({
|
||||
comment,
|
||||
onClose,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
isAdmin,
|
||||
isStatusPending,
|
||||
}: CommentDetailProps) {
|
||||
// Close on Escape
|
||||
React.useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const date = new Date(comment.createdAt);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed inset-y-0 right-0 z-50 w-full max-w-lg overflow-y-auto bg-kumo-base border-l shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 className="text-lg font-semibold">Comment Detail</h2>
|
||||
<Button variant="ghost" shape="square" onClick={onClose} aria-label="Close">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<CommentStatusBadge status={comment.status} />
|
||||
<span className="text-sm text-kumo-subtle">
|
||||
{date.toLocaleDateString()} {date.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Author info */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-kumo-subtle uppercase tracking-wider">
|
||||
Author
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCircle className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="font-medium">{comment.authorName}</span>
|
||||
{comment.authorUserId && <Badge variant="secondary">Registered user</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvelopeSimple className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="text-sm text-kumo-subtle">{comment.authorEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment body */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-kumo-subtle uppercase tracking-wider">
|
||||
Comment
|
||||
</h3>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
</div>
|
||||
|
||||
{/* Content reference */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-kumo-subtle uppercase tracking-wider">
|
||||
Content
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
<span className="text-kumo-subtle">Collection:</span>{" "}
|
||||
<span className="font-medium">{comment.collection}</span>
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="text-kumo-subtle">Content ID:</span>{" "}
|
||||
<code className="bg-kumo-tint px-1.5 py-0.5 rounded text-xs">
|
||||
{comment.contentId}
|
||||
</code>
|
||||
</p>
|
||||
{comment.parentId && (
|
||||
<p className="text-sm">
|
||||
<span className="text-kumo-subtle">Reply to:</span>{" "}
|
||||
<code className="bg-kumo-tint px-1.5 py-0.5 rounded text-xs">
|
||||
{comment.parentId}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Moderation metadata */}
|
||||
{comment.moderationMetadata && Object.keys(comment.moderationMetadata).length > 0 && (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-kumo-subtle uppercase tracking-wider">
|
||||
Moderation Signals
|
||||
</h3>
|
||||
<pre className="text-xs bg-kumo-tint rounded p-3 overflow-x-auto">
|
||||
{JSON.stringify(comment.moderationMetadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="border-t px-6 py-4 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{comment.status !== "approved" && (
|
||||
<Button
|
||||
icon={<Check />}
|
||||
onClick={() => onStatusChange(comment.id, "approved")}
|
||||
disabled={isStatusPending}
|
||||
className="flex-1"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{comment.status !== "spam" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<Warning />}
|
||||
onClick={() => onStatusChange(comment.id, "spam")}
|
||||
disabled={isStatusPending}
|
||||
className="flex-1"
|
||||
>
|
||||
Spam
|
||||
</Button>
|
||||
)}
|
||||
{comment.status !== "trash" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<Trash />}
|
||||
onClick={() => onStatusChange(comment.id, "trash")}
|
||||
disabled={isStatusPending}
|
||||
className="flex-1"
|
||||
>
|
||||
Trash
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
icon={<Trash />}
|
||||
onClick={() => onDelete(comment.id)}
|
||||
disabled={isStatusPending}
|
||||
className="w-full"
|
||||
>
|
||||
Delete Permanently
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentStatusBadge({ status }: { status: CommentStatus }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
status === "approved" &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
status === "pending" &&
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
status === "spam" && "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
status === "trash" && "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
547
packages/admin/src/components/comments/CommentInbox.tsx
Normal file
547
packages/admin/src/components/comments/CommentInbox.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Comment moderation inbox.
|
||||
*
|
||||
* Status tabs (Pending, Approved, Spam, Trash), search, collection filter,
|
||||
* table with row actions, bulk selection, and detail slide-over.
|
||||
*/
|
||||
|
||||
import { Badge, Button, Checkbox, Input, Select, Tabs } from "@cloudflare/kumo";
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
Check,
|
||||
Trash,
|
||||
Warning,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
ChatCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
AdminComment,
|
||||
CommentCounts,
|
||||
CommentStatus,
|
||||
BulkAction,
|
||||
} from "../../lib/api/comments.js";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import { ConfirmDialog } from "../ConfirmDialog.js";
|
||||
import { CommentDetail } from "./CommentDetail.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CommentInboxProps {
|
||||
comments: AdminComment[];
|
||||
counts: CommentCounts;
|
||||
isLoading: boolean;
|
||||
nextCursor?: string;
|
||||
collections: Record<string, { label: string }>;
|
||||
activeStatus: CommentStatus;
|
||||
onStatusChange: (status: CommentStatus) => void;
|
||||
collectionFilter: string;
|
||||
onCollectionFilterChange: (collection: string) => void;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onCommentStatusChange: (id: string, status: CommentStatus) => Promise<unknown>;
|
||||
onCommentDelete: (id: string) => Promise<unknown>;
|
||||
onBulkAction: (ids: string[], action: BulkAction) => Promise<unknown>;
|
||||
onLoadMore: () => void;
|
||||
isAdmin: boolean;
|
||||
isStatusPending: boolean;
|
||||
deleteError: unknown;
|
||||
onDeleteErrorReset: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function CommentInbox({
|
||||
comments,
|
||||
counts,
|
||||
isLoading,
|
||||
nextCursor,
|
||||
collections,
|
||||
activeStatus,
|
||||
onStatusChange,
|
||||
collectionFilter,
|
||||
onCollectionFilterChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onCommentStatusChange,
|
||||
onCommentDelete,
|
||||
onBulkAction,
|
||||
onLoadMore,
|
||||
isAdmin,
|
||||
isStatusPending,
|
||||
deleteError,
|
||||
onDeleteErrorReset,
|
||||
}: CommentInboxProps) {
|
||||
// Selection state
|
||||
const [selected, setSelected] = React.useState<Set<string>>(new Set());
|
||||
const [detailComment, setDetailComment] = React.useState<AdminComment | null>(null);
|
||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||
|
||||
// Pagination (client-side within loaded data)
|
||||
const [page, setPage] = React.useState(0);
|
||||
|
||||
// Reset selection and page when status tab or filters change
|
||||
React.useEffect(() => {
|
||||
setSelected(new Set());
|
||||
setPage(0);
|
||||
}, [activeStatus, collectionFilter, searchQuery]);
|
||||
|
||||
const clearSelection = React.useCallback(() => setSelected(new Set()), []);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(comments.length / PAGE_SIZE));
|
||||
const paginatedComments = comments.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
// Bulk select
|
||||
const allOnPageSelected =
|
||||
paginatedComments.length > 0 && paginatedComments.every((c) => selected.has(c.id));
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allOnPageSelected) {
|
||||
for (const c of paginatedComments) next.delete(c.id);
|
||||
} else {
|
||||
for (const c of paginatedComments) next.add(c.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulk = (action: BulkAction) => {
|
||||
if (selected.size === 0) return;
|
||||
void onBulkAction([...selected], action).then(clearSelection);
|
||||
};
|
||||
|
||||
// Collection filter items
|
||||
const collectionItems: Record<string, string> = { "": "All collections" };
|
||||
for (const [slug, config] of Object.entries(collections)) {
|
||||
collectionItems[slug] = config.label;
|
||||
}
|
||||
|
||||
const total = counts.pending + counts.approved + counts.spam + counts.trash;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChatCircle className="h-6 w-6" />
|
||||
<h1 className="text-2xl font-bold">Comments</h1>
|
||||
{total > 0 && <span className="text-sm text-kumo-subtle">{total} total</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters row */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative max-w-xs flex-1 min-w-[200px]">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search comments..."
|
||||
aria-label="Search comments"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collection filter */}
|
||||
{Object.keys(collections).length > 1 && (
|
||||
<div className="w-48">
|
||||
<Select
|
||||
value={collectionFilter}
|
||||
onValueChange={(v) => onCollectionFilterChange(v ?? "")}
|
||||
items={collectionItems}
|
||||
aria-label="Filter by collection"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
variant="underline"
|
||||
value={activeStatus}
|
||||
onValueChange={(v) => {
|
||||
if (v === "pending" || v === "approved" || v === "spam" || v === "trash") {
|
||||
onStatusChange(v);
|
||||
}
|
||||
}}
|
||||
tabs={[
|
||||
{
|
||||
value: "pending",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Pending
|
||||
{counts.pending > 0 && <Badge variant="secondary">{counts.pending}</Badge>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ value: "approved", label: "Approved" },
|
||||
{
|
||||
value: "spam",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Spam
|
||||
{counts.spam > 0 && <Badge variant="secondary">{counts.spam}</Badge>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "trash",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Trash
|
||||
{counts.trash > 0 && <Badge variant="secondary">{counts.trash}</Badge>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-kumo-tint/50 px-4 py-2">
|
||||
<span className="text-sm font-medium">{selected.size} selected</span>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
{activeStatus !== "approved" && (
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<Check className="h-3.5 w-3.5" />}
|
||||
onClick={() => handleBulk("approve")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{activeStatus !== "spam" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={<Warning className="h-3.5 w-3.5" />}
|
||||
onClick={() => handleBulk("spam")}
|
||||
>
|
||||
Spam
|
||||
</Button>
|
||||
)}
|
||||
{activeStatus !== "trash" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={<Trash className="h-3.5 w-3.5" />}
|
||||
onClick={() => handleBulk("trash")}
|
||||
>
|
||||
Trash
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
icon={<Trash className="h-3.5 w-3.5" />}
|
||||
onClick={() => handleBulk("delete")}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-kumo-tint/50">
|
||||
<th scope="col" className="w-10 px-3 py-3">
|
||||
<Checkbox
|
||||
checked={allOnPageSelected}
|
||||
onChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Author
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Comment
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Content
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Date
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && comments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-kumo-subtle">
|
||||
Loading comments...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedComments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-kumo-subtle">
|
||||
<EmptyState status={activeStatus} hasSearch={!!searchQuery} />
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedComments.map((comment) => (
|
||||
<CommentRow
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
isSelected={selected.has(comment.id)}
|
||||
onToggle={() => toggleOne(comment.id)}
|
||||
onRowClick={() => setDetailComment(comment)}
|
||||
onStatusChange={(id, status) => {
|
||||
void onCommentStatusChange(id, status).then(clearSelection);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
setDeleteId(id);
|
||||
onDeleteErrorReset();
|
||||
}}
|
||||
isAdmin={isAdmin}
|
||||
isStatusPending={isStatusPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{(totalPages > 1 || nextCursor) && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-kumo-subtle">
|
||||
{comments.length} {comments.length === 1 ? "comment" : "comments"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
shape="square"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<CaretLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
shape="square"
|
||||
disabled={page >= totalPages - 1 && !nextCursor}
|
||||
onClick={() => {
|
||||
if (page >= totalPages - 1 && nextCursor) {
|
||||
onLoadMore();
|
||||
setPage(page + 1);
|
||||
} else {
|
||||
setPage(page + 1);
|
||||
}
|
||||
}}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<CaretRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail slide-over */}
|
||||
{detailComment && (
|
||||
<CommentDetail
|
||||
comment={detailComment}
|
||||
onClose={() => setDetailComment(null)}
|
||||
onStatusChange={(id, status) => {
|
||||
void onCommentStatusChange(id, status).then(clearSelection);
|
||||
setDetailComment(null);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
setDeleteId(id);
|
||||
onDeleteErrorReset();
|
||||
setDetailComment(null);
|
||||
}}
|
||||
isAdmin={isAdmin}
|
||||
isStatusPending={isStatusPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteId}
|
||||
onClose={() => {
|
||||
setDeleteId(null);
|
||||
onDeleteErrorReset();
|
||||
}}
|
||||
title="Delete Comment?"
|
||||
description="This will permanently delete this comment. This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={isStatusPending}
|
||||
error={deleteError}
|
||||
onConfirm={() => {
|
||||
if (deleteId) {
|
||||
void onCommentDelete(deleteId).then(() => setDeleteId(null));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentRowProps {
|
||||
comment: AdminComment;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
onRowClick: () => void;
|
||||
onStatusChange: (id: string, status: CommentStatus) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isAdmin: boolean;
|
||||
isStatusPending: boolean;
|
||||
}
|
||||
|
||||
function CommentRow({
|
||||
comment,
|
||||
isSelected,
|
||||
onToggle,
|
||||
onRowClick,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
isAdmin,
|
||||
isStatusPending,
|
||||
}: CommentRowProps) {
|
||||
const date = new Date(comment.createdAt);
|
||||
const excerpt = comment.body.length > 120 ? comment.body.slice(0, 120) + "..." : comment.body;
|
||||
|
||||
return (
|
||||
<tr className={cn("border-b hover:bg-kumo-tint/25", isSelected && "bg-kumo-tint/40")}>
|
||||
<td className="w-10 px-3 py-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onToggle}
|
||||
aria-label={`Select comment by ${comment.authorName}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button type="button" onClick={onRowClick} className="text-left">
|
||||
<div className="font-medium text-sm">{comment.authorName}</div>
|
||||
<div className="text-xs text-kumo-subtle">{comment.authorEmail}</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRowClick}
|
||||
className="text-left text-sm text-kumo-subtle hover:text-kumo-default line-clamp-2"
|
||||
>
|
||||
{excerpt}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs">
|
||||
<span className="font-medium">{comment.collection}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-kumo-subtle whitespace-nowrap">
|
||||
{date.toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{comment.status !== "approved" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
size="sm"
|
||||
aria-label="Approve"
|
||||
onClick={() => onStatusChange(comment.id, "approved")}
|
||||
disabled={isStatusPending}
|
||||
>
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
)}
|
||||
{comment.status !== "spam" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
size="sm"
|
||||
aria-label="Mark as spam"
|
||||
onClick={() => onStatusChange(comment.id, "spam")}
|
||||
disabled={isStatusPending}
|
||||
>
|
||||
<Warning className="h-4 w-4 text-orange-500" />
|
||||
</Button>
|
||||
)}
|
||||
{comment.status !== "trash" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
size="sm"
|
||||
aria-label="Trash"
|
||||
onClick={() => onStatusChange(comment.id, "trash")}
|
||||
disabled={isStatusPending}
|
||||
>
|
||||
<Trash className="h-4 w-4 text-kumo-subtle" />
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
size="sm"
|
||||
aria-label="Delete permanently"
|
||||
onClick={() => onDelete(comment.id)}
|
||||
disabled={isStatusPending}
|
||||
>
|
||||
<Trash className="h-4 w-4 text-kumo-danger" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ status, hasSearch }: { status: CommentStatus; hasSearch: boolean }) {
|
||||
if (hasSearch) {
|
||||
return <p>No comments match your search.</p>;
|
||||
}
|
||||
|
||||
const messages: Record<CommentStatus, string> = {
|
||||
pending: "No comments awaiting moderation.",
|
||||
approved: "No approved comments yet.",
|
||||
spam: "No spam comments.",
|
||||
trash: "Trash is empty.",
|
||||
};
|
||||
|
||||
return <p>{messages[status]}</p>;
|
||||
}
|
||||
338
packages/admin/src/components/editor/BlockMenu.tsx
Normal file
338
packages/admin/src/components/editor/BlockMenu.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Block Menu Component
|
||||
*
|
||||
* Floating menu that appears when a block is selected via drag handle click.
|
||||
* Provides block actions:
|
||||
* - Turn into (transform to different block type)
|
||||
* - Duplicate
|
||||
* - Delete
|
||||
*
|
||||
* Uses Floating UI for positioning relative to the selected block.
|
||||
*/
|
||||
|
||||
import { Button } from "@cloudflare/kumo";
|
||||
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
|
||||
import {
|
||||
DotsSixVertical,
|
||||
Paragraph,
|
||||
TextHOne,
|
||||
TextHTwo,
|
||||
TextHThree,
|
||||
Quotes,
|
||||
Code,
|
||||
List,
|
||||
ListNumbers,
|
||||
Copy,
|
||||
Trash,
|
||||
CaretRight,
|
||||
type Icon as PhosphorIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useStableCallback } from "../../lib/hooks";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
/**
|
||||
* Block transform options
|
||||
*/
|
||||
interface BlockTransform {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: PhosphorIcon;
|
||||
transform: (editor: Editor) => void;
|
||||
}
|
||||
|
||||
const blockTransforms: BlockTransform[] = [
|
||||
{
|
||||
id: "paragraph",
|
||||
label: "Paragraph",
|
||||
icon: Paragraph,
|
||||
transform: (editor) => {
|
||||
editor.chain().focus().setNode("paragraph").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "heading1",
|
||||
label: "Heading 1",
|
||||
icon: TextHOne,
|
||||
transform: (editor) => {
|
||||
editor.chain().focus().setNode("heading", { level: 1 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "heading2",
|
||||
label: "Heading 2",
|
||||
icon: TextHTwo,
|
||||
transform: (editor) => {
|
||||
editor.chain().focus().setNode("heading", { level: 2 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "heading3",
|
||||
label: "Heading 3",
|
||||
icon: TextHThree,
|
||||
transform: (editor) => {
|
||||
editor.chain().focus().setNode("heading", { level: 3 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "blockquote",
|
||||
label: "Quote",
|
||||
icon: Quotes,
|
||||
transform: (editor) => {
|
||||
editor.chain().focus().toggleBlockquote().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codeBlock",
|
||||
label: "Code Block",
|
||||
icon: Code,
|
||||
transform: (editor) => {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bulletList",
|
||||
label: "Bullet List",
|
||||
icon: List,
|
||||
transform: (editor) => {
|
||||
editor.chain().focus().toggleBulletList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "orderedList",
|
||||
label: "Numbered List",
|
||||
icon: ListNumbers,
|
||||
transform: (editor) => {
|
||||
editor.chain().focus().toggleOrderedList().run();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface BlockMenuProps {
|
||||
editor: Editor;
|
||||
/** The DOM element of the selected block (for positioning) */
|
||||
anchorElement: HTMLElement | null;
|
||||
/** Whether the menu is open */
|
||||
isOpen: boolean;
|
||||
/** Callback to close the menu */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Menu - floating menu for block-level actions
|
||||
*/
|
||||
export function BlockMenu({ editor, anchorElement, isOpen, onClose }: BlockMenuProps) {
|
||||
const [showTransforms, setShowTransforms] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const stableOnClose = useStableCallback(onClose);
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
open: isOpen,
|
||||
placement: "left-start",
|
||||
middleware: [offset({ mainAxis: 8, crossAxis: 0 }), flip(), shift({ padding: 8 })],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
// Sync the anchor element
|
||||
React.useEffect(() => {
|
||||
if (anchorElement) {
|
||||
refs.setReference(anchorElement);
|
||||
}
|
||||
}, [anchorElement, refs]);
|
||||
|
||||
// Close on escape
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (showTransforms) {
|
||||
setShowTransforms(false);
|
||||
} else {
|
||||
stableOnClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, stableOnClose, showTransforms]);
|
||||
|
||||
// Close on click outside
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
// Don't close if clicking on the drag handle or menu itself
|
||||
if (target instanceof Node && menuRef.current?.contains(target)) return;
|
||||
if (target instanceof Element && target.closest("[data-block-handle]")) return;
|
||||
|
||||
stableOnClose();
|
||||
};
|
||||
|
||||
// Delay to avoid immediate close from the click that opened it
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen, stableOnClose]);
|
||||
|
||||
// Reset submenu state when menu closes
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setShowTransforms(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDuplicate = () => {
|
||||
const { selection } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
// Get the block node at current position
|
||||
const blockStart = $from.start($from.depth);
|
||||
const blockEnd = $to.end($to.depth);
|
||||
|
||||
// Get the content to duplicate
|
||||
const slice = editor.state.doc.slice(blockStart, blockEnd);
|
||||
|
||||
// Insert after current block
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr }) => {
|
||||
tr.insert(blockEnd + 1, slice.content);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
editor.chain().focus().deleteNode(editor.state.selection.$from.parent.type.name).run();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleTransform = (transform: BlockTransform) => {
|
||||
transform.transform(editor);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={(node) => {
|
||||
menuRef.current = node;
|
||||
refs.setFloating(node);
|
||||
}}
|
||||
style={floatingStyles}
|
||||
className="z-[100] rounded-lg border bg-kumo-overlay shadow-lg min-w-[180px] overflow-hidden"
|
||||
>
|
||||
{showTransforms ? (
|
||||
// Transform submenu
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-kumo-tint text-left"
|
||||
onClick={() => setShowTransforms(false)}
|
||||
>
|
||||
<CaretRight className="h-4 w-4 rotate-180" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div className="h-px bg-kumo-line my-1" />
|
||||
{blockTransforms.map((transform) => (
|
||||
<button
|
||||
key={transform.id}
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-kumo-tint text-left"
|
||||
onClick={() => handleTransform(transform)}
|
||||
>
|
||||
<transform.icon className="h-4 w-4 text-kumo-subtle" />
|
||||
<span>{transform.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Main menu
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full px-3 py-2 text-sm hover:bg-kumo-tint text-left"
|
||||
onClick={() => setShowTransforms(true)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Paragraph className="h-4 w-4 text-kumo-subtle" />
|
||||
<span>Turn into</span>
|
||||
</span>
|
||||
<CaretRight className="h-4 w-4 text-kumo-subtle" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-kumo-tint text-left"
|
||||
onClick={handleDuplicate}
|
||||
>
|
||||
<Copy className="h-4 w-4 text-kumo-subtle" />
|
||||
<span>Duplicate</span>
|
||||
</button>
|
||||
<div className="h-px bg-kumo-line my-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-kumo-tint text-left text-kumo-danger"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Drag Handle Component
|
||||
*
|
||||
* Shown in the left gutter of each block. Clicking opens the block menu,
|
||||
* dragging reorders blocks.
|
||||
*/
|
||||
interface BlockHandleProps {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function BlockHandle({ onClick, onDragStart, selected }: BlockHandleProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className={cn(
|
||||
"h-6 w-6 cursor-grab active:cursor-grabbing",
|
||||
"text-kumo-subtle/50 hover:text-kumo-subtle",
|
||||
selected && "text-kumo-subtle",
|
||||
)}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
draggable
|
||||
data-block-handle
|
||||
aria-label="Drag to reorder block"
|
||||
>
|
||||
<DotsSixVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { blockTransforms };
|
||||
export type { BlockTransform };
|
||||
214
packages/admin/src/components/editor/DocumentOutline.tsx
Normal file
214
packages/admin/src/components/editor/DocumentOutline.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Document Outline
|
||||
*
|
||||
* Displays a tree structure of headings from the TipTap editor.
|
||||
* - Shows H1 at root, H2 indented, H3 further indented
|
||||
* - Click-to-navigate to heading position
|
||||
* - Highlights the current section based on cursor position
|
||||
*/
|
||||
|
||||
import { Button } from "@cloudflare/kumo";
|
||||
import { CaretDown, CaretRight, List } from "@phosphor-icons/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function getIndentClass(level: number) {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return "pl-0";
|
||||
case 2:
|
||||
return "pl-4";
|
||||
case 3:
|
||||
return "pl-8";
|
||||
default:
|
||||
return "pl-0";
|
||||
}
|
||||
}
|
||||
|
||||
function getTextClass(level: number) {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return "font-medium";
|
||||
case 2:
|
||||
return "font-normal";
|
||||
case 3:
|
||||
return "font-normal text-kumo-subtle";
|
||||
default:
|
||||
return "font-normal";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading item extracted from editor document
|
||||
*/
|
||||
export interface HeadingItem {
|
||||
/** Heading level (1-3) */
|
||||
level: number;
|
||||
/** Heading text content */
|
||||
text: string;
|
||||
/** Position in document for navigation */
|
||||
pos: number;
|
||||
/** Unique key for React */
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headings from the TipTap editor document
|
||||
*/
|
||||
export function extractHeadings(editor: Editor | null): HeadingItem[] {
|
||||
if (!editor) return [];
|
||||
|
||||
const headings: HeadingItem[] = [];
|
||||
const doc = editor.state.doc;
|
||||
let key = 0;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === "heading") {
|
||||
const rawLevel = node.attrs.level;
|
||||
const level = typeof rawLevel === "number" ? rawLevel : 1;
|
||||
const text = node.textContent || "";
|
||||
if (text.trim()) {
|
||||
headings.push({
|
||||
level,
|
||||
text,
|
||||
pos,
|
||||
key: `heading-${key++}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the current heading based on cursor position
|
||||
*/
|
||||
export function findCurrentHeading(headings: HeadingItem[], cursorPos: number): HeadingItem | null {
|
||||
if (headings.length === 0) return null;
|
||||
|
||||
// Find the heading that contains or precedes the cursor
|
||||
let current: HeadingItem | null = null;
|
||||
for (const heading of headings) {
|
||||
if (heading.pos <= cursorPos) {
|
||||
current = heading;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export interface DocumentOutlineProps {
|
||||
/** TipTap editor instance */
|
||||
editor: Editor | null;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document outline component showing heading tree structure
|
||||
*/
|
||||
export function DocumentOutline({ editor, className }: DocumentOutlineProps) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(true);
|
||||
const [headings, setHeadings] = React.useState<HeadingItem[]>([]);
|
||||
const [currentPos, setCurrentPos] = React.useState(0);
|
||||
|
||||
// Extract headings when editor content changes
|
||||
React.useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const updateHeadings = () => {
|
||||
setHeadings(extractHeadings(editor));
|
||||
};
|
||||
|
||||
// Initial extraction
|
||||
updateHeadings();
|
||||
|
||||
// Update on content changes
|
||||
editor.on("update", updateHeadings);
|
||||
|
||||
return () => {
|
||||
editor.off("update", updateHeadings);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
// Track cursor position for current section highlight
|
||||
React.useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const { from } = editor.state.selection;
|
||||
setCurrentPos(from);
|
||||
};
|
||||
|
||||
// Initial position
|
||||
updatePosition();
|
||||
|
||||
// Update on selection changes
|
||||
editor.on("selectionUpdate", updatePosition);
|
||||
|
||||
return () => {
|
||||
editor.off("selectionUpdate", updatePosition);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const currentHeading = findCurrentHeading(headings, currentPos);
|
||||
|
||||
const handleHeadingClick = (heading: HeadingItem) => {
|
||||
if (!editor) return;
|
||||
|
||||
// Navigate to heading and scroll into view
|
||||
editor.chain().focus().setTextSelection(heading.pos).scrollIntoView().run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between px-2 h-8"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
<span className="font-semibold">Outline</span>
|
||||
</span>
|
||||
{isExpanded ? <CaretDown className="h-4 w-4" /> : <CaretRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-0.5">
|
||||
{headings.length === 0 ? (
|
||||
<p className="text-sm text-kumo-subtle px-2 py-1">No headings in document</p>
|
||||
) : (
|
||||
headings.map((heading) => {
|
||||
const isCurrent = currentHeading?.key === heading.key;
|
||||
return (
|
||||
<button
|
||||
key={heading.key}
|
||||
type="button"
|
||||
onClick={() => handleHeadingClick(heading)}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 text-sm rounded transition-colors",
|
||||
"hover:bg-kumo-tint/50 cursor-pointer",
|
||||
"truncate",
|
||||
getIndentClass(heading.level),
|
||||
getTextClass(heading.level),
|
||||
isCurrent && "bg-kumo-tint text-kumo-default",
|
||||
)}
|
||||
title={heading.text}
|
||||
>
|
||||
{heading.text}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
packages/admin/src/components/editor/DragHandleWrapper.tsx
Normal file
138
packages/admin/src/components/editor/DragHandleWrapper.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Drag Handle Wrapper Component
|
||||
*
|
||||
* Wraps TipTap's official DragHandle React component with our BlockMenu.
|
||||
* This component provides:
|
||||
* - Drag handles that appear on block hover
|
||||
* - Actual drag-and-drop block reordering (handled by TipTap)
|
||||
* - Block menu integration for transforms, duplicate, delete
|
||||
*/
|
||||
|
||||
import { DotsSixVertical } from "@phosphor-icons/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { DragHandle } from "@tiptap/extension-drag-handle-react";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { BlockMenu } from "./BlockMenu";
|
||||
|
||||
interface DragHandleWrapperProps {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
interface HoveredNode {
|
||||
node: PMNode;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
// Extend Editor commands type to include DragHandle commands
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
dragHandle: {
|
||||
lockDragHandle: () => ReturnType;
|
||||
unlockDragHandle: () => ReturnType;
|
||||
toggleDragHandle: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DragHandleWrapper - Official TipTap drag handle with BlockMenu integration
|
||||
*/
|
||||
export function DragHandleWrapper({ editor }: DragHandleWrapperProps) {
|
||||
const [hoveredNode, setHoveredNode] = React.useState<HoveredNode | null>(null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const handleRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Handle click on drag handle to open menu
|
||||
const handleClick = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!hoveredNode) return;
|
||||
|
||||
// Select the block in the editor
|
||||
editor.chain().setNodeSelection(hoveredNode.pos).run();
|
||||
|
||||
// Open the menu
|
||||
setMenuAnchor(handleRef.current);
|
||||
setMenuOpen(true);
|
||||
|
||||
// Lock the drag handle so it stays visible while menu is open
|
||||
editor.commands.lockDragHandle();
|
||||
},
|
||||
[editor, hoveredNode],
|
||||
);
|
||||
|
||||
// Close the menu
|
||||
const handleCloseMenu = React.useCallback(() => {
|
||||
setMenuOpen(false);
|
||||
setMenuAnchor(null);
|
||||
editor.commands.unlockDragHandle();
|
||||
}, [editor]);
|
||||
|
||||
// Handle node change from drag handle
|
||||
const handleNodeChange = React.useCallback(
|
||||
(data: { node: PMNode | null; editor: Editor; pos: number }) => {
|
||||
if (data.node) {
|
||||
setHoveredNode({ node: data.node, pos: data.pos });
|
||||
} else {
|
||||
// Only clear if menu is not open
|
||||
if (!menuOpen) {
|
||||
setHoveredNode(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[menuOpen],
|
||||
);
|
||||
|
||||
// Stable reference — DragHandle's useEffect depends on this by reference.
|
||||
// An inline object causes plugin unregister/register every render, which
|
||||
// tears down the Suggestion plugin view (calling onExit → setState → loop).
|
||||
const computePositionConfig = React.useMemo(
|
||||
() => ({
|
||||
placement: "left-start" as const,
|
||||
strategy: "absolute" as const,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragHandle
|
||||
editor={editor}
|
||||
onNodeChange={handleNodeChange}
|
||||
computePositionConfig={computePositionConfig}
|
||||
>
|
||||
<button
|
||||
ref={handleRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
"w-6 h-6 rounded select-none",
|
||||
"text-kumo-subtle/50 hover:text-kumo-subtle",
|
||||
"hover:bg-kumo-tint/80 cursor-grab active:cursor-grabbing",
|
||||
"transition-colors duration-100",
|
||||
menuOpen && "text-kumo-subtle bg-kumo-tint",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
data-block-handle
|
||||
aria-label="Block actions - drag to reorder, click for menu"
|
||||
>
|
||||
<DotsSixVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DragHandle>
|
||||
|
||||
{/* Block menu */}
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
anchorElement={menuAnchor}
|
||||
isOpen={menuOpen}
|
||||
onClose={handleCloseMenu}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
527
packages/admin/src/components/editor/ImageDetailPanel.tsx
Normal file
527
packages/admin/src/components/editor/ImageDetailPanel.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* Image Detail Panel for Editor
|
||||
*
|
||||
* A slide-out panel for editing image properties in the rich text editor.
|
||||
* Shows preview and allows editing alt text, caption, and link settings.
|
||||
*/
|
||||
|
||||
import { Button, Input, InputArea, Label, LinkButton } from "@cloudflare/kumo";
|
||||
import {
|
||||
X,
|
||||
ArrowSquareOut,
|
||||
Ruler,
|
||||
SlidersHorizontal,
|
||||
ImageSquare,
|
||||
LinkSimple,
|
||||
LinkBreak,
|
||||
} from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { MediaItem } from "../../lib/api";
|
||||
import { useStableCallback } from "../../lib/hooks";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
import { MediaPickerModal } from "../MediaPickerModal";
|
||||
|
||||
export interface ImageAttributes {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
mediaId?: string;
|
||||
/** Original image width */
|
||||
width?: number;
|
||||
/** Original image height */
|
||||
height?: number;
|
||||
/** Display width for this instance (defaults to original) */
|
||||
displayWidth?: number;
|
||||
/** Display height for this instance (defaults to original) */
|
||||
displayHeight?: number;
|
||||
}
|
||||
|
||||
export interface ImageDetailPanelProps {
|
||||
attributes: ImageAttributes;
|
||||
onUpdate: (attrs: Partial<ImageAttributes>) => void;
|
||||
onReplace: (attrs: ImageAttributes) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
/** When true, renders inline within the sidebar column instead of as a fixed overlay */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel for editing image properties in the editor.
|
||||
* Renders as a fixed slide-out overlay by default, or inline within
|
||||
* the content sidebar when `inline` is true.
|
||||
*/
|
||||
export function ImageDetailPanel({
|
||||
attributes,
|
||||
onUpdate,
|
||||
onReplace,
|
||||
onDelete,
|
||||
onClose,
|
||||
inline = false,
|
||||
}: ImageDetailPanelProps) {
|
||||
// Form state
|
||||
const [alt, setAlt] = React.useState(attributes.alt ?? "");
|
||||
const [caption, setCaption] = React.useState(attributes.caption ?? "");
|
||||
const [title, setTitle] = React.useState(attributes.title ?? "");
|
||||
const [showMediaPicker, setShowMediaPicker] = React.useState(false);
|
||||
|
||||
// Dimension state - default to display dimensions, fall back to original
|
||||
const [displayWidth, setDisplayWidth] = React.useState<number | undefined>(
|
||||
attributes.displayWidth ?? attributes.width,
|
||||
);
|
||||
const [displayHeight, setDisplayHeight] = React.useState<number | undefined>(
|
||||
attributes.displayHeight ?? attributes.height,
|
||||
);
|
||||
const [lockAspectRatio, setLockAspectRatio] = React.useState(true);
|
||||
|
||||
// Calculate aspect ratio from original dimensions
|
||||
const aspectRatio =
|
||||
attributes.width && attributes.height ? attributes.width / attributes.height : undefined;
|
||||
|
||||
const handleWidthChange = (value: string) => {
|
||||
const newWidth = value ? parseInt(value, 10) : undefined;
|
||||
setDisplayWidth(newWidth);
|
||||
if (lockAspectRatio && aspectRatio && newWidth) {
|
||||
setDisplayHeight(Math.round(newWidth / aspectRatio));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeightChange = (value: string) => {
|
||||
const newHeight = value ? parseInt(value, 10) : undefined;
|
||||
setDisplayHeight(newHeight);
|
||||
if (lockAspectRatio && aspectRatio && newHeight) {
|
||||
setDisplayWidth(Math.round(newHeight * aspectRatio));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetDimensions = () => {
|
||||
setDisplayWidth(attributes.width);
|
||||
setDisplayHeight(attributes.height);
|
||||
};
|
||||
|
||||
const handleMediaSelect = (item: MediaItem) => {
|
||||
onReplace({
|
||||
src: item.url,
|
||||
alt: item.alt || item.filename,
|
||||
mediaId: item.id,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
// Clear caption/title since it's a new image
|
||||
caption: undefined,
|
||||
title: undefined,
|
||||
});
|
||||
setShowMediaPicker(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Track if form has unsaved changes
|
||||
const hasChanges = React.useMemo(() => {
|
||||
const originalDisplayWidth = attributes.displayWidth ?? attributes.width;
|
||||
const originalDisplayHeight = attributes.displayHeight ?? attributes.height;
|
||||
return (
|
||||
alt !== (attributes.alt ?? "") ||
|
||||
caption !== (attributes.caption ?? "") ||
|
||||
title !== (attributes.title ?? "") ||
|
||||
displayWidth !== originalDisplayWidth ||
|
||||
displayHeight !== originalDisplayHeight
|
||||
);
|
||||
}, [attributes, alt, caption, title, displayWidth, displayHeight]);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate({
|
||||
alt: alt || undefined,
|
||||
caption: caption || undefined,
|
||||
title: title || undefined,
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const stableOnClose = useStableCallback(onClose);
|
||||
const stableHandleSave = useStableCallback(handleSave);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
stableOnClose();
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
stableHandleSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [stableOnClose, stableHandleSave]);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title="Remove Image?"
|
||||
description="Remove this image from the document?"
|
||||
confirmLabel="Remove"
|
||||
pendingLabel="Removing..."
|
||||
isPending={false}
|
||||
error={null}
|
||||
onConfirm={() => {
|
||||
onDelete();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<MediaPickerModal
|
||||
open={showMediaPicker}
|
||||
onOpenChange={setShowMediaPicker}
|
||||
onSelect={handleMediaSelect}
|
||||
mimeTypeFilter="image/"
|
||||
title="Replace Image"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-kumo-base flex flex-col animate-in fade-in duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4 text-kumo-subtle" />
|
||||
<h3 className="text-sm font-semibold">Image Settings</h3>
|
||||
</div>
|
||||
<Button variant="ghost" shape="square" aria-label="Close" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="aspect-video bg-kumo-tint rounded-lg overflow-hidden flex items-center justify-center relative group">
|
||||
<img
|
||||
src={attributes.src}
|
||||
alt={attributes.alt || ""}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<ImageSquare />}
|
||||
onClick={() => setShowMediaPicker(true)}
|
||||
>
|
||||
Replace Image
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original dimensions */}
|
||||
{(attributes.width || attributes.height) && (
|
||||
<div className="flex items-center gap-2 text-sm mt-3">
|
||||
<Ruler className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="text-kumo-subtle">Original:</span>
|
||||
<span>
|
||||
{attributes.width} × {attributes.height}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display Size */}
|
||||
{attributes.width && attributes.height && (
|
||||
<div className="p-4 border-b space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Display Size</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetDimensions}
|
||||
className="h-auto py-1 px-2 text-xs"
|
||||
>
|
||||
Reset to original
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Width"
|
||||
type="number"
|
||||
value={displayWidth ?? ""}
|
||||
onChange={(e) => handleWidthChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className="mt-5"
|
||||
onClick={() => setLockAspectRatio(!lockAspectRatio)}
|
||||
title={lockAspectRatio ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
aria-label={lockAspectRatio ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
>
|
||||
{lockAspectRatio ? (
|
||||
<LinkSimple className="h-4 w-4" />
|
||||
) : (
|
||||
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Height"
|
||||
type="number"
|
||||
value={displayHeight ?? ""}
|
||||
onChange={(e) => handleHeightChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-kumo-subtle">
|
||||
Set a custom display size for this image instance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editable Fields */}
|
||||
<div className="p-4 space-y-4">
|
||||
<Input
|
||||
label="Alt Text"
|
||||
value={alt}
|
||||
onChange={(e) => setAlt(e.target.value)}
|
||||
placeholder="Describe this image for accessibility"
|
||||
description="Required for accessibility. Describes the image for screen readers."
|
||||
/>
|
||||
|
||||
<InputArea
|
||||
label="Caption"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
placeholder="Optional caption displayed below the image"
|
||||
description="Displayed below the image as a visible caption."
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Title (Tooltip)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Optional tooltip on hover"
|
||||
description="Shown when hovering over the image."
|
||||
/>
|
||||
|
||||
{/* Source URL - only show for external images (no mediaId) */}
|
||||
{!attributes.mediaId && attributes.src && (
|
||||
<div>
|
||||
<Label>Source</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<Input value={attributes.src} readOnly className="text-xs font-mono flex-1" />
|
||||
<LinkButton
|
||||
variant="outline"
|
||||
shape="square"
|
||||
href={attributes.src}
|
||||
external
|
||||
title="Open in new tab"
|
||||
aria-label="Open in new tab"
|
||||
>
|
||||
<ArrowSquareOut className="h-4 w-4" />
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 border-t flex items-center justify-between gap-2">
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
||||
Remove Image
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!hasChanges}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-96 bg-kumo-base border-l shadow-xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4 text-kumo-subtle" />
|
||||
<h2 className="font-semibold">Image Settings</h2>
|
||||
</div>
|
||||
<Button variant="ghost" shape="square" aria-label="Close" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Preview */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="aspect-video bg-kumo-tint rounded-lg overflow-hidden flex items-center justify-center relative group">
|
||||
<img
|
||||
src={attributes.src}
|
||||
alt={attributes.alt || ""}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<ImageSquare />}
|
||||
onClick={() => setShowMediaPicker(true)}
|
||||
>
|
||||
Replace Image
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Info - original dimensions */}
|
||||
{(attributes.width || attributes.height) && (
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Ruler className="h-4 w-4 text-kumo-subtle" />
|
||||
<span className="text-kumo-subtle">Original:</span>
|
||||
<span>
|
||||
{attributes.width} × {attributes.height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Size */}
|
||||
{attributes.width && attributes.height && (
|
||||
<div className="p-4 border-b space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Display Size</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetDimensions}
|
||||
className="h-auto py-1 px-2 text-xs"
|
||||
>
|
||||
Reset to original
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Width"
|
||||
type="number"
|
||||
value={displayWidth ?? ""}
|
||||
onChange={(e) => handleWidthChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className="mt-5"
|
||||
onClick={() => setLockAspectRatio(!lockAspectRatio)}
|
||||
title={lockAspectRatio ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
aria-label={lockAspectRatio ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
>
|
||||
{lockAspectRatio ? (
|
||||
<LinkSimple className="h-4 w-4" />
|
||||
) : (
|
||||
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Height"
|
||||
type="number"
|
||||
value={displayHeight ?? ""}
|
||||
onChange={(e) => handleHeightChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-kumo-subtle">
|
||||
Set a custom display size for this image instance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editable Fields */}
|
||||
<div className="p-4 space-y-4">
|
||||
<Input
|
||||
label="Alt Text"
|
||||
value={alt}
|
||||
onChange={(e) => setAlt(e.target.value)}
|
||||
placeholder="Describe this image for accessibility"
|
||||
description="Required for accessibility. Describes the image for screen readers."
|
||||
/>
|
||||
|
||||
<InputArea
|
||||
label="Caption"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
placeholder="Optional caption displayed below the image"
|
||||
description="Displayed below the image as a visible caption."
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Title (Tooltip)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Optional tooltip on hover"
|
||||
description="Shown when hovering over the image."
|
||||
/>
|
||||
|
||||
{/* Source URL - only show for external images (no mediaId) */}
|
||||
{!attributes.mediaId && attributes.src && (
|
||||
<div>
|
||||
<Label>Source</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<Input value={attributes.src} readOnly className="text-xs font-mono flex-1" />
|
||||
<LinkButton
|
||||
variant="outline"
|
||||
shape="square"
|
||||
href={attributes.src}
|
||||
external
|
||||
title="Open in new tab"
|
||||
aria-label="Open in new tab"
|
||||
>
|
||||
<ArrowSquareOut className="h-4 w-4" />
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t flex items-center justify-between gap-2">
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
||||
Remove Image
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!hasChanges}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageDetailPanel;
|
||||
369
packages/admin/src/components/editor/ImageNode.tsx
Normal file
369
packages/admin/src/components/editor/ImageNode.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Custom Image Node for TipTap
|
||||
*
|
||||
* Provides a selectable, editable image with:
|
||||
* - Click to select
|
||||
* - Visual selection indicator
|
||||
* - Quick inline alt text editing
|
||||
* - Full detail panel for advanced settings
|
||||
* - Delete/replace options
|
||||
*/
|
||||
|
||||
import { Button, Input } from "@cloudflare/kumo";
|
||||
import { Trash, Pencil, X, Check, SlidersHorizontal } from "@phosphor-icons/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { Node, mergeAttributes } from "@tiptap/react";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { ImageAttributes } from "./ImageDetailPanel";
|
||||
|
||||
// Extend the Commands interface to include setImage
|
||||
declare module "@tiptap/react" {
|
||||
interface Commands<ReturnType> {
|
||||
image: {
|
||||
setImage: (options: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
mediaId?: string;
|
||||
/** Provider ID for external media (e.g., "cloudflare-images") */
|
||||
provider?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
displayWidth?: number;
|
||||
displayHeight?: number;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// React component for the image node view
|
||||
function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }: NodeViewProps) {
|
||||
const [isEditingAlt, setIsEditingAlt] = React.useState(false);
|
||||
const [altText, setAltText] = React.useState(node.attrs.alt || "");
|
||||
|
||||
/** Whether this node currently has its sidebar panel open */
|
||||
const sidebarOpenRef = React.useRef(false);
|
||||
|
||||
const handleSaveAlt = () => {
|
||||
updateAttributes({ alt: altText });
|
||||
setIsEditingAlt(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSaveAlt();
|
||||
} else if (e.key === "Escape") {
|
||||
setAltText(node.attrs.alt || "");
|
||||
setIsEditingAlt(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sync local alt text state when node attributes change
|
||||
React.useEffect(() => {
|
||||
setAltText(node.attrs.alt || "");
|
||||
}, [node.attrs.alt]);
|
||||
|
||||
const getImageAttrs = (): ImageAttributes => ({
|
||||
src: node.attrs.src,
|
||||
alt: node.attrs.alt,
|
||||
title: node.attrs.title,
|
||||
caption: node.attrs.caption,
|
||||
mediaId: node.attrs.mediaId,
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
displayWidth: node.attrs.displayWidth,
|
||||
displayHeight: node.attrs.displayHeight,
|
||||
});
|
||||
|
||||
const openSidebar = () => {
|
||||
const storage = (editor.storage as unknown as Record<string, Record<string, unknown>>).image;
|
||||
const onOpen = storage?.onOpenBlockSidebar as
|
||||
| ((panel: {
|
||||
type: "image";
|
||||
attrs: ImageAttributes;
|
||||
onUpdate: (attrs: Partial<ImageAttributes>) => void;
|
||||
onReplace: (attrs: ImageAttributes) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}) => void)
|
||||
| null;
|
||||
if (onOpen) {
|
||||
sidebarOpenRef.current = true;
|
||||
onOpen({
|
||||
type: "image",
|
||||
attrs: getImageAttrs(),
|
||||
onUpdate: (attrs: Partial<ImageAttributes>) => updateAttributes(attrs),
|
||||
onReplace: (attrs: ImageAttributes) => updateAttributes(attrs),
|
||||
onDelete: () => deleteNode(),
|
||||
onClose: () => {
|
||||
sidebarOpenRef.current = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const closeSidebar = () => {
|
||||
if (!sidebarOpenRef.current) return;
|
||||
const storage = (editor.storage as unknown as Record<string, Record<string, unknown>>).image;
|
||||
const onClose = storage?.onCloseBlockSidebar as (() => void) | null;
|
||||
if (onClose) {
|
||||
onClose();
|
||||
sidebarOpenRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
if (sidebarOpenRef.current) {
|
||||
closeSidebar();
|
||||
} else {
|
||||
openSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
// Close sidebar when this node is deselected
|
||||
React.useEffect(() => {
|
||||
if (!selected) {
|
||||
closeSidebar();
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={cn(
|
||||
"relative my-4 group",
|
||||
selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg",
|
||||
)}
|
||||
>
|
||||
<figure className="relative">
|
||||
<img
|
||||
src={node.attrs.src}
|
||||
alt={node.attrs.alt || ""}
|
||||
title={node.attrs.title || ""}
|
||||
className="rounded-lg max-w-full mx-auto"
|
||||
style={{
|
||||
width: node.attrs.displayWidth ? `${node.attrs.displayWidth}px` : undefined,
|
||||
height: node.attrs.displayHeight ? `${node.attrs.displayHeight}px` : undefined,
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Selection overlay with actions */}
|
||||
{selected && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
shape="square"
|
||||
className="h-8 w-8"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setIsEditingAlt(true)}
|
||||
title="Quick edit alt text"
|
||||
aria-label="Quick edit alt text"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
shape="square"
|
||||
className="h-8 w-8"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={toggleSidebar}
|
||||
title="Image settings"
|
||||
aria-label="Image settings"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
shape="square"
|
||||
className="h-8 w-8"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => deleteNode()}
|
||||
title="Delete image"
|
||||
aria-label="Delete image"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick alt text editor (inline) */}
|
||||
{isEditingAlt && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-kumo-base/95 backdrop-blur p-3 rounded-b-lg border-t">
|
||||
<label className="text-xs font-medium text-kumo-subtle mb-1 block">Alt text</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={altText}
|
||||
onChange={(e) => setAltText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Describe the image..."
|
||||
className="flex-1 h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className="h-8 w-8"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setAltText(node.attrs.alt || "");
|
||||
setIsEditingAlt(false);
|
||||
}}
|
||||
title="Cancel"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
shape="square"
|
||||
className="h-8 w-8"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleSaveAlt}
|
||||
title="Save"
|
||||
aria-label="Save alt text"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Caption display (shows caption if set, falls back to alt) */}
|
||||
{!isEditingAlt && (node.attrs.caption || node.attrs.alt) && (
|
||||
<figcaption className="text-center text-sm text-kumo-subtle mt-2">
|
||||
{node.attrs.caption || node.attrs.alt}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom Image extension with React NodeView
|
||||
export const ImageExtension = Node.create({
|
||||
name: "image",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
/** Callback set by PortableTextEditor to open image settings in the content sidebar */
|
||||
onOpenBlockSidebar: null as
|
||||
| ((panel: {
|
||||
type: "image";
|
||||
attrs: import("./ImageDetailPanel").ImageAttributes;
|
||||
onUpdate: (attrs: Partial<import("./ImageDetailPanel").ImageAttributes>) => void;
|
||||
onReplace: (attrs: import("./ImageDetailPanel").ImageAttributes) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}) => void)
|
||||
| null,
|
||||
/** Callback set by PortableTextEditor to close the sidebar */
|
||||
onCloseBlockSidebar: null as (() => void) | null,
|
||||
};
|
||||
},
|
||||
|
||||
inline() {
|
||||
return this.options.inline;
|
||||
},
|
||||
|
||||
group() {
|
||||
return this.options.inline ? "inline" : "block";
|
||||
},
|
||||
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
caption: {
|
||||
default: null,
|
||||
},
|
||||
mediaId: {
|
||||
default: null,
|
||||
},
|
||||
/** Provider ID for external media (e.g., "cloudflare-images") */
|
||||
provider: {
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
displayWidth: {
|
||||
default: null,
|
||||
},
|
||||
displayHeight: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "img[src]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageNodeView);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setImage:
|
||||
(options: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
mediaId?: string;
|
||||
provider?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
displayWidth?: number;
|
||||
displayHeight?: number;
|
||||
}) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
({ commands }: any) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Markdown Link Extension for TipTap
|
||||
*
|
||||
* Converts markdown link syntax into proper link marks:
|
||||
* - Typing `[text](url)` converts on closing paren
|
||||
* - Pasting text containing `[text](url)` converts inline
|
||||
* - Rejects disallowed protocols (e.g. `javascript:`) via Link's allowlist
|
||||
*
|
||||
* Augments the existing Link mark from StarterKit — no new marks added.
|
||||
*/
|
||||
|
||||
import { Extension, InputRule, PasteRule } from "@tiptap/core";
|
||||
import { isAllowedUri } from "@tiptap/extension-link";
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
// Matches [link text](https://url.com) — typed (input rule, end-anchored)
|
||||
// match[1] = link text, match[2] = href
|
||||
const MARKDOWN_LINK_INPUT_REGEX = /\[([^\]]+)\]\(([^)]+)\)$/;
|
||||
|
||||
// Matches [link text](https://url.com) — pasted (paste rule, global)
|
||||
// match[1] = link text, match[2] = href
|
||||
const MARKDOWN_LINK_PASTE_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
|
||||
/** Shared handler context — InputRule and PasteRule use the same shape. */
|
||||
interface RuleMatch {
|
||||
state: EditorState;
|
||||
range: { from: number; to: number };
|
||||
match: RegExpMatchArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a `[text](url)` match with `text` carrying the link mark.
|
||||
* Returns null (no-op) if the URL fails the protocol allowlist.
|
||||
*
|
||||
* Shared by both the input rule and paste rule — the handler signature
|
||||
* for InputRule and PasteRule is identical.
|
||||
*/
|
||||
function handleMarkdownLink({ state, range, match }: RuleMatch): null | void {
|
||||
const linkType = state.schema.marks["link"];
|
||||
const linkText = match[1];
|
||||
const href = match[2]?.trim();
|
||||
|
||||
if (!linkType || !linkText || !href || !isAllowedUri(href)) return null;
|
||||
|
||||
const { tr } = state;
|
||||
const mark = linkType.create({ href });
|
||||
|
||||
tr.replaceWith(range.from, range.to, state.schema.text(linkText, [mark]));
|
||||
tr.removeStoredMark(linkType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds markdown link syntax support to the TipTap editor.
|
||||
*
|
||||
* Typing `[text](url)` and completing the closing `)` converts the syntax
|
||||
* into a proper link mark. Pasting text containing `[text](url)` patterns
|
||||
* also converts them. URLs that fail the protocol allowlist (e.g. `javascript:`)
|
||||
* are silently ignored, leaving the markdown syntax as literal text.
|
||||
*
|
||||
* Uses raw InputRule/PasteRule rather than the markInputRule/markPasteRule
|
||||
* helpers because those helpers unconditionally use the last capture group as
|
||||
* the replacement text — we need group 1 (text) as content and group 2 (href)
|
||||
* as the attribute, so we write the transaction by hand.
|
||||
*
|
||||
* This augments the Link mark already provided by StarterKit — no new
|
||||
* dependencies required.
|
||||
*/
|
||||
export const MarkdownLinkExtension = Extension.create({
|
||||
name: "markdownLink",
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: MARKDOWN_LINK_INPUT_REGEX,
|
||||
handler: handleMarkdownLink,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
new PasteRule({
|
||||
find: MARKDOWN_LINK_PASTE_REGEX,
|
||||
handler: handleMarkdownLink,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
502
packages/admin/src/components/editor/PluginBlockNode.tsx
Normal file
502
packages/admin/src/components/editor/PluginBlockNode.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Plugin Block Node for TipTap
|
||||
*
|
||||
* Renders embed blocks (YouTube, Vimeo, tweets, etc.) with:
|
||||
* - Selection indicator with ring
|
||||
* - Inline URL editing via popover
|
||||
* - Drag handle in left gutter
|
||||
* - Action buttons on hover/selection
|
||||
* - Keyboard support
|
||||
*/
|
||||
|
||||
import { Button, Input } from "@cloudflare/kumo";
|
||||
import {
|
||||
DotsSixVertical,
|
||||
Trash,
|
||||
Pencil,
|
||||
X,
|
||||
Check,
|
||||
ArrowSquareOut,
|
||||
YoutubeLogo,
|
||||
LinkSimple,
|
||||
Code,
|
||||
Copy,
|
||||
Cube,
|
||||
ListBullets,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { Element } from "@emdashcms/blocks";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
/**
|
||||
* Plugin block definition for slash commands
|
||||
*/
|
||||
export interface PluginBlockDef {
|
||||
type: string;
|
||||
pluginId: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
/** Block Kit form fields. If declared, replaces the simple URL input. */
|
||||
fields?: Element[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Block Registry (stored per-editor instance via TipTap extension storage)
|
||||
// =============================================================================
|
||||
|
||||
/** Register plugin block definitions into editor storage so the node view can look up metadata */
|
||||
export function registerPluginBlocks(
|
||||
editor: { storage: Record<string, Record<string, unknown>> },
|
||||
blocks: PluginBlockDef[],
|
||||
): void {
|
||||
const registry = new Map<string, PluginBlockDef>();
|
||||
for (const block of blocks) {
|
||||
registry.set(block.type, block);
|
||||
}
|
||||
const storage = editor.storage.pluginBlock as Record<string, unknown> | undefined;
|
||||
if (storage) {
|
||||
storage.registry = registry;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the registry from editor storage */
|
||||
function getRegistry(editor: {
|
||||
storage: Record<string, Record<string, unknown>>;
|
||||
}): Map<string, PluginBlockDef> {
|
||||
const storage = editor.storage.pluginBlock as Record<string, unknown> | undefined;
|
||||
return (storage?.registry as Map<string, PluginBlockDef>) ?? new Map();
|
||||
}
|
||||
|
||||
/** Named icon map: icon key → React component */
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
video: YoutubeLogo,
|
||||
code: Code,
|
||||
link: LinkSimple,
|
||||
"link-external": ArrowSquareOut,
|
||||
form: ListBullets,
|
||||
};
|
||||
|
||||
/** Resolve an icon key to a React component */
|
||||
function resolveIcon(iconKey?: string): React.ComponentType<{ className?: string }> {
|
||||
if (iconKey && ICON_MAP[iconKey]) {
|
||||
return ICON_MAP[iconKey];
|
||||
}
|
||||
return Cube;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon component and metadata for embed block types.
|
||||
* Reads from the plugin block registry in editor storage.
|
||||
*/
|
||||
function getEmbedMeta(
|
||||
blockType: string,
|
||||
registry: Map<string, PluginBlockDef>,
|
||||
): {
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
color: string;
|
||||
placeholder: string;
|
||||
} {
|
||||
const def = registry.get(blockType);
|
||||
if (def) {
|
||||
return {
|
||||
Icon: resolveIcon(def.icon),
|
||||
label: def.label,
|
||||
color: "text-kumo-subtle",
|
||||
placeholder: def.placeholder || "Enter URL...",
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unregistered block types
|
||||
return {
|
||||
Icon: Cube,
|
||||
label: blockType.charAt(0).toUpperCase() + blockType.slice(1),
|
||||
color: "text-kumo-subtle",
|
||||
placeholder: "Enter URL...",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract display ID from URL for cleaner presentation
|
||||
*/
|
||||
function getDisplayId(id: string, blockType: string): string {
|
||||
try {
|
||||
const url = new URL(id);
|
||||
|
||||
switch (blockType) {
|
||||
case "youtube": {
|
||||
// youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID
|
||||
const videoId = url.searchParams.get("v") || url.pathname.split("/").pop();
|
||||
return videoId || id;
|
||||
}
|
||||
case "vimeo": {
|
||||
// vimeo.com/VIDEO_ID
|
||||
return url.pathname.split("/").find(Boolean) || id;
|
||||
}
|
||||
case "tweet": {
|
||||
// twitter.com/user/status/TWEET_ID
|
||||
const parts = url.pathname.split("/");
|
||||
const statusIndex = parts.indexOf("status");
|
||||
const tweetId = parts[statusIndex + 1];
|
||||
if (statusIndex !== -1 && tweetId) {
|
||||
return `@${parts[1]}/${tweetId.slice(0, 8)}...`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
case "gist": {
|
||||
// gist.github.com/user/GIST_ID
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||
return `${parts[0]}/${parts[1].slice(0, 8)}...`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
default:
|
||||
// Show hostname + truncated path
|
||||
return url.hostname + (url.pathname.length > 20 ? "..." : url.pathname);
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL, show as-is but truncated
|
||||
return id.length > 30 ? id.slice(0, 27) + "..." : id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React component for the plugin block node view
|
||||
*/
|
||||
function PluginBlockNodeView({
|
||||
node,
|
||||
updateAttributes,
|
||||
selected,
|
||||
deleteNode,
|
||||
editor,
|
||||
getPos,
|
||||
}: NodeViewProps) {
|
||||
const blockType = typeof node.attrs.blockType === "string" ? node.attrs.blockType : "";
|
||||
const id = typeof node.attrs.id === "string" ? node.attrs.id : "";
|
||||
const data =
|
||||
typeof node.attrs.data === "object" && node.attrs.data !== null
|
||||
? (node.attrs.data as Record<string, unknown>)
|
||||
: {};
|
||||
const registry = getRegistry(
|
||||
editor as unknown as { storage: Record<string, Record<string, unknown>> },
|
||||
);
|
||||
const { Icon, label, color, placeholder } = getEmbedMeta(blockType, registry);
|
||||
|
||||
// Check if this block type has fields defined in the registry
|
||||
const blockDef = registry.get(blockType);
|
||||
const hasFields = blockDef?.fields && blockDef.fields.length > 0;
|
||||
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [editValue, setEditValue] = React.useState(id || "");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus input when editing starts
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
setEditValue(id || "");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isEditing, id]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (editValue.trim()) {
|
||||
updateAttributes({ id: editValue.trim() });
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(id || "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
void navigator.clipboard.writeText(id);
|
||||
};
|
||||
|
||||
const handleOpenExternal = () => {
|
||||
window.open(id, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const displayId = id
|
||||
? getDisplayId(id, blockType)
|
||||
: Object.values(data)
|
||||
.filter((v) => typeof v === "string" && v.length > 0)
|
||||
.join(", ") || blockType;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={cn(
|
||||
"plugin-block relative my-3",
|
||||
selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg",
|
||||
)}
|
||||
contentEditable={false}
|
||||
data-drag-handle
|
||||
>
|
||||
<div className="relative group">
|
||||
{/* Drag handle - appears in left gutter */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -left-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing",
|
||||
selected && "opacity-100",
|
||||
)}
|
||||
data-drag-handle
|
||||
>
|
||||
<DotsSixVertical className="h-5 w-5 text-kumo-subtle/50" />
|
||||
</div>
|
||||
|
||||
{/* Main block content */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-kumo-base transition-colors",
|
||||
selected ? "border-kumo-brand/50 bg-kumo-tint/30" : "hover:border-kumo-line",
|
||||
)}
|
||||
>
|
||||
{/* Header with icon, label, and actions */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-10 h-10 rounded-lg bg-kumo-tint flex items-center justify-center",
|
||||
color,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Label and ID */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{!isEditing && (
|
||||
<div className="text-xs text-kumo-subtle truncate font-mono">{displayId}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons - visible on hover or when selected */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 transition-opacity",
|
||||
selected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{id && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className="h-8 w-8"
|
||||
onClick={handleCopyUrl}
|
||||
title="Copy URL"
|
||||
aria-label="Copy URL"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className="h-8 w-8"
|
||||
onClick={handleOpenExternal}
|
||||
title="Open in new tab"
|
||||
aria-label="Open in new tab"
|
||||
>
|
||||
<ArrowSquareOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
if (hasFields) {
|
||||
// Open Block Kit modal via editor storage callback
|
||||
const storage = (
|
||||
editor.storage as unknown as Record<string, Record<string, unknown>>
|
||||
).pluginBlock;
|
||||
const onEdit = storage?.onEditBlock as
|
||||
| ((attrs: {
|
||||
blockType: string;
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
pos: number;
|
||||
}) => void)
|
||||
| null;
|
||||
if (onEdit) {
|
||||
const pos = (typeof getPos === "function" ? getPos() : 0) ?? 0;
|
||||
onEdit({ blockType, id, data, pos });
|
||||
}
|
||||
} else {
|
||||
setIsEditing(true);
|
||||
}
|
||||
}}
|
||||
title={hasFields ? "Edit" : "Edit URL"}
|
||||
aria-label={hasFields ? "Edit" : "Edit URL"}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className="h-8 w-8 text-kumo-danger hover:text-kumo-danger hover:bg-kumo-danger/10"
|
||||
onClick={() => deleteNode()}
|
||||
title="Delete"
|
||||
aria-label="Delete embed"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline URL editor - slides down when editing */}
|
||||
{isEditing && (
|
||||
<div className="px-4 pb-3 pt-0">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 h-9 text-sm font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
className="h-9 w-9"
|
||||
onClick={handleCancel}
|
||||
title="Cancel (Esc)"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
shape="square"
|
||||
className="h-9 w-9"
|
||||
onClick={handleSave}
|
||||
title="Save (Enter)"
|
||||
aria-label="Save"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TipTap Node extension for plugin blocks (embeds)
|
||||
*/
|
||||
export const PluginBlockExtension = Node.create({
|
||||
name: "pluginBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
blockType: {
|
||||
default: null,
|
||||
},
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
data: {
|
||||
default: {},
|
||||
parseHTML: (el: HTMLElement) => JSON.parse(el.getAttribute("data-plugin-data") || "{}"),
|
||||
renderHTML: (attrs: Record<string, unknown>) => ({
|
||||
"data-plugin-data": JSON.stringify(attrs.data),
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
/** Per-editor registry of plugin block definitions */
|
||||
registry: new Map<string, PluginBlockDef>(),
|
||||
/** Callback set by PortableTextEditor to open the Block Kit modal for editing */
|
||||
onEditBlock: null as
|
||||
| ((attrs: {
|
||||
blockType: string;
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
pos: number;
|
||||
}) => void)
|
||||
| null,
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "div[data-plugin-block]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", mergeAttributes(HTMLAttributes, { "data-plugin-block": "" })];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(PluginBlockNodeView);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Delete block on backspace when selected (not editing)
|
||||
Backspace: () => {
|
||||
const { selection } = this.editor.state;
|
||||
const node = this.editor.state.doc.nodeAt(selection.from);
|
||||
if (node?.type.name === "pluginBlock") {
|
||||
this.editor.commands.deleteSelection();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
// Also handle Delete key
|
||||
Delete: () => {
|
||||
const { selection } = this.editor.state;
|
||||
const node = this.editor.state.doc.nodeAt(selection.from);
|
||||
if (node?.type.name === "pluginBlock") {
|
||||
this.editor.commands.deleteSelection();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Re-export helpers for use elsewhere
|
||||
export { getEmbedMeta, resolveIcon };
|
||||
23
packages/admin/src/components/index.ts
Normal file
23
packages/admin/src/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Layout components
|
||||
export { Shell, type ShellProps } from "./Shell";
|
||||
export { Sidebar, SidebarNav, type SidebarNavProps } from "./Sidebar";
|
||||
export { Header } from "./Header";
|
||||
|
||||
// Page components
|
||||
export { Dashboard, type DashboardProps } from "./Dashboard";
|
||||
export { ContentList, type ContentListProps } from "./ContentList";
|
||||
export { ContentEditor, type ContentEditorProps, type FieldDescriptor } from "./ContentEditor";
|
||||
export { MediaLibrary, type MediaLibraryProps } from "./MediaLibrary";
|
||||
export { MediaPickerModal, type MediaPickerModalProps } from "./MediaPickerModal";
|
||||
export { Settings } from "./Settings";
|
||||
|
||||
// Rich text editor
|
||||
export { PortableTextEditor, type PortableTextEditorProps } from "./PortableTextEditor";
|
||||
|
||||
// Buttons
|
||||
export { SaveButton, type SaveButtonProps } from "./SaveButton";
|
||||
|
||||
// Auth components
|
||||
export * from "./auth";
|
||||
export { LoginPage } from "./LoginPage";
|
||||
export { SetupWizard } from "./SetupWizard";
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Allowed Domains Settings - Self-signup domain management
|
||||
*
|
||||
* Only available when using passkey auth. When external auth (e.g., Cloudflare Access)
|
||||
* is configured, this page shows an informational message instead.
|
||||
*/
|
||||
|
||||
import { Button, Dialog, Input, Select, Switch } from "@cloudflare/kumo";
|
||||
import {
|
||||
Globe,
|
||||
Plus,
|
||||
CheckCircle,
|
||||
WarningCircle,
|
||||
Trash,
|
||||
Pencil,
|
||||
ArrowLeft,
|
||||
Info,
|
||||
} from "@phosphor-icons/react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchAllowedDomains,
|
||||
createAllowedDomain,
|
||||
updateAllowedDomain,
|
||||
deleteAllowedDomain,
|
||||
fetchManifest,
|
||||
type AllowedDomain,
|
||||
} from "../../lib/api";
|
||||
|
||||
const ROLES = [
|
||||
{ value: 10, label: "Subscriber" },
|
||||
{ value: 20, label: "Contributor" },
|
||||
{ value: 30, label: "Author" },
|
||||
{ value: 40, label: "Editor" },
|
||||
] as const;
|
||||
|
||||
function getRoleName(level: number): string {
|
||||
return ROLES.find((r) => r.value === level)?.label ?? "Unknown";
|
||||
}
|
||||
|
||||
export function AllowedDomainsSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isAddingDomain, setIsAddingDomain] = React.useState(false);
|
||||
const [editingDomain, setEditingDomain] = React.useState<AllowedDomain | null>(null);
|
||||
const [deletingDomain, setDeletingDomain] = React.useState<string | null>(null);
|
||||
const [saveStatus, setSaveStatus] = React.useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// Form state
|
||||
const [newDomain, setNewDomain] = React.useState("");
|
||||
const [newRole, setNewRole] = React.useState<number>(30); // Default to Author
|
||||
|
||||
// Fetch manifest for auth mode
|
||||
const { data: manifest, isLoading: manifestLoading } = useQuery({
|
||||
queryKey: ["manifest"],
|
||||
queryFn: fetchManifest,
|
||||
});
|
||||
|
||||
const isExternalAuth = manifest?.authMode && manifest.authMode !== "passkey";
|
||||
|
||||
// Fetch domains (only when using passkey auth)
|
||||
const {
|
||||
data: domains,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["allowed-domains"],
|
||||
queryFn: fetchAllowedDomains,
|
||||
enabled: !isExternalAuth && !manifestLoading,
|
||||
});
|
||||
|
||||
// Clear status message after 3 seconds
|
||||
React.useEffect(() => {
|
||||
if (saveStatus) {
|
||||
const timer = setTimeout(setSaveStatus, 3000, null);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saveStatus]);
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createAllowedDomain,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["allowed-domains"] });
|
||||
setIsAddingDomain(false);
|
||||
setNewDomain("");
|
||||
setNewRole(30);
|
||||
setSaveStatus({ type: "success", message: "Domain added successfully" });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message: mutationError instanceof Error ? mutationError.message : "Failed to add domain",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
domain,
|
||||
data,
|
||||
}: {
|
||||
domain: string;
|
||||
data: { enabled?: boolean; defaultRole?: number };
|
||||
}) => updateAllowedDomain(domain, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["allowed-domains"] });
|
||||
setEditingDomain(null);
|
||||
setSaveStatus({ type: "success", message: "Domain updated" });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message: mutationError instanceof Error ? mutationError.message : "Failed to update domain",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteAllowedDomain,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["allowed-domains"] });
|
||||
setDeletingDomain(null);
|
||||
setSaveStatus({ type: "success", message: "Domain removed" });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message: mutationError instanceof Error ? mutationError.message : "Failed to remove domain",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddDomain = () => {
|
||||
if (!newDomain.trim()) return;
|
||||
createMutation.mutate({
|
||||
domain: newDomain.trim().toLowerCase(),
|
||||
defaultRole: newRole,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (domain: AllowedDomain) => {
|
||||
updateMutation.mutate({
|
||||
domain: domain.domain,
|
||||
data: { enabled: !domain.enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateRole = (domain: string, role: number) => {
|
||||
updateMutation.mutate({
|
||||
domain,
|
||||
data: { defaultRole: role },
|
||||
});
|
||||
setEditingDomain(null);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deletingDomain) {
|
||||
deleteMutation.mutate(deletingDomain);
|
||||
}
|
||||
};
|
||||
|
||||
if (manifestLoading || isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Self-Signup Domains</h1>
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<p className="text-kumo-subtle">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show message when external auth is configured
|
||||
if (isExternalAuth) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Self-Signup Domains</h1>
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-kumo-subtle mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-kumo-subtle">
|
||||
User access is managed by an external provider ({manifest?.authMode}). Self-signup
|
||||
domain settings are not available when using external authentication.
|
||||
</p>
|
||||
<Link to="/settings">
|
||||
<Button variant="outline" size="sm" icon={<ArrowLeft />}>
|
||||
Back to Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Self-Signup Domains</h1>
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6">
|
||||
<p className="text-kumo-danger">
|
||||
{error instanceof Error ? error.message : "Failed to load allowed domains"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Self-Signup Domains</h1>
|
||||
|
||||
{/* Status message */}
|
||||
{saveStatus && (
|
||||
<div
|
||||
className={`rounded-lg border p-4 flex items-center gap-2 ${
|
||||
saveStatus.type === "success"
|
||||
? "bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400"
|
||||
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{saveStatus.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<WarningCircle className="h-5 w-5" />
|
||||
)}
|
||||
<span>{saveStatus.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domains Section */}
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Globe className="h-5 w-5 text-kumo-subtle" />
|
||||
<h2 className="text-lg font-semibold">Allowed Domains</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-kumo-subtle mb-6">
|
||||
Users with email addresses from these domains can sign up without an invite. They will be
|
||||
assigned the specified role automatically.
|
||||
</p>
|
||||
|
||||
{/* Domain list */}
|
||||
{domains && domains.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{domains.map((domain) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||
domain.enabled ? "bg-kumo-base" : "bg-kumo-tint/50 opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
checked={domain.enabled}
|
||||
onCheckedChange={() => handleToggleEnabled(domain)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{domain.domain}</div>
|
||||
<div className="text-sm text-kumo-subtle">
|
||||
Default role: {getRoleName(domain.defaultRole)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
onClick={() => setEditingDomain(domain)}
|
||||
disabled={updateMutation.isPending}
|
||||
aria-label={`Edit ${domain.domain}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
onClick={() => setDeletingDomain(domain.domain)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label={`Delete ${domain.domain}`}
|
||||
>
|
||||
<Trash className="h-4 w-4 text-kumo-danger" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-kumo-subtle">
|
||||
No domains configured. Users must be invited individually.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add domain section */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
{isAddingDomain ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">Add an allowed domain</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsAddingDomain(false);
|
||||
setNewDomain("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
label="Default Role"
|
||||
value={String(newRole)}
|
||||
onValueChange={(v) => v !== null && setNewRole(Number(v))}
|
||||
items={Object.fromEntries(ROLES.map((r) => [String(r.value), r.label]))}
|
||||
>
|
||||
{ROLES.map((role) => (
|
||||
<Select.Option key={role.value} value={String(role.value)}>
|
||||
{role.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddDomain}
|
||||
disabled={!newDomain.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? "Adding..." : "Add Domain"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setIsAddingDomain(true)} icon={<Plus />}>
|
||||
Add Domain
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Domain Dialog */}
|
||||
<Dialog.Root
|
||||
open={!!editingDomain}
|
||||
onOpenChange={(open: boolean) => !open && setEditingDomain(null)}
|
||||
>
|
||||
<Dialog className="p-6" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
Edit Domain
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-sm text-kumo-subtle">
|
||||
Update settings for {editingDomain?.domain}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
label="Default Role"
|
||||
value={String(editingDomain?.defaultRole ?? 30)}
|
||||
onValueChange={(v) =>
|
||||
v !== null && editingDomain && handleUpdateRole(editingDomain.domain, Number(v))
|
||||
}
|
||||
items={Object.fromEntries(ROLES.map((r) => [String(r.value), r.label]))}
|
||||
>
|
||||
{ROLES.map((role) => (
|
||||
<Select.Option key={role.value} value={String(role.value)}>
|
||||
{role.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog.Root
|
||||
open={!!deletingDomain}
|
||||
onOpenChange={(open) => !open && setDeletingDomain(null)}
|
||||
disablePointerDismissal
|
||||
>
|
||||
<Dialog className="p-6" size="sm">
|
||||
<Dialog.Title className="text-lg font-semibold">Remove Domain?</Dialog.Title>
|
||||
<Dialog.Description className="text-kumo-subtle">
|
||||
Users from <strong>{deletingDomain}</strong> will no longer be able to sign up without
|
||||
an invite. Existing users are not affected.
|
||||
</Dialog.Description>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Dialog.Close
|
||||
render={(p) => (
|
||||
<Button {...p} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Dialog.Close
|
||||
render={(p) => (
|
||||
<Button {...p} variant="destructive" onClick={handleDelete}>
|
||||
Remove Domain
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AllowedDomainsSettings;
|
||||
379
packages/admin/src/components/settings/ApiTokenSettings.tsx
Normal file
379
packages/admin/src/components/settings/ApiTokenSettings.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* API Tokens settings page
|
||||
*
|
||||
* Allows admins to list, create, and revoke Personal Access Tokens.
|
||||
*/
|
||||
|
||||
import { Button, Checkbox, Input, Loader, Select } from "@cloudflare/kumo";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeSlash,
|
||||
Key,
|
||||
Plus,
|
||||
Trash,
|
||||
WarningCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchApiTokens,
|
||||
createApiToken,
|
||||
revokeApiToken,
|
||||
API_TOKEN_SCOPES,
|
||||
type ApiTokenCreateResult,
|
||||
} from "../../lib/api/api-tokens.js";
|
||||
import { getMutationError } from "../DialogError.js";
|
||||
|
||||
// =============================================================================
|
||||
// Expiry options
|
||||
// =============================================================================
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: "none", label: "No expiry" },
|
||||
{ value: "7d", label: "7 days" },
|
||||
{ value: "30d", label: "30 days" },
|
||||
{ value: "90d", label: "90 days" },
|
||||
{ value: "365d", label: "1 year" },
|
||||
] as const;
|
||||
|
||||
function computeExpiryDate(option: string): string | undefined {
|
||||
if (option === "none") return undefined;
|
||||
const days = parseInt(option, 10);
|
||||
if (Number.isNaN(days)) return undefined;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main component
|
||||
// =============================================================================
|
||||
|
||||
export function ApiTokenSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateForm, setShowCreateForm] = React.useState(false);
|
||||
const [newToken, setNewToken] = React.useState<ApiTokenCreateResult | null>(null);
|
||||
const [tokenVisible, setTokenVisible] = React.useState(false);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [revokeConfirmId, setRevokeConfirmId] = React.useState<string | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: tokens, isLoading } = useQuery({
|
||||
queryKey: ["api-tokens"],
|
||||
queryFn: fetchApiTokens,
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createApiToken,
|
||||
onSuccess: (result) => {
|
||||
setNewToken(result);
|
||||
setShowCreateForm(false);
|
||||
setTokenVisible(false);
|
||||
setCopied(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Revoke mutation
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: revokeApiToken,
|
||||
onSuccess: () => {
|
||||
setRevokeConfirmId(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up copy feedback timeout on unmount
|
||||
const copyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (!newToken) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(newToken.token);
|
||||
setCopied(true);
|
||||
copyTimeoutRef.current = setTimeout(setCopied, 2000, false);
|
||||
} catch {
|
||||
// Clipboard API can fail in insecure contexts or when denied
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings" className="text-kumo-subtle hover:text-kumo-default transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">API Tokens</h1>
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
Create personal access tokens for programmatic API access
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New token banner */}
|
||||
{newToken && (
|
||||
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Key className="h-5 w-5 text-green-600 dark:text-green-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-green-800 dark:text-green-200">
|
||||
Token created: {newToken.info.name}
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
|
||||
Copy this token now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-white dark:bg-black/30 px-3 py-2 text-sm font-mono border truncate">
|
||||
{tokenVisible ? newToken.token : "••••••••••••••••••••••••••••"}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
onClick={() => setTokenVisible(!tokenVisible)}
|
||||
aria-label={tokenVisible ? "Hide token" : "Show token"}
|
||||
>
|
||||
{tokenVisible ? <EyeSlash /> : <Eye />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
onClick={handleCopyToken}
|
||||
aria-label="Copy token"
|
||||
>
|
||||
<Copy />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
Copied to clipboard
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setNewToken(null)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showCreateForm ? (
|
||||
<CreateTokenForm
|
||||
isCreating={createMutation.isPending}
|
||||
error={createMutation.error?.message ?? null}
|
||||
onSubmit={(input) =>
|
||||
createMutation.mutate({
|
||||
name: input.name,
|
||||
scopes: input.scopes,
|
||||
expiresAt: input.expiresAt,
|
||||
})
|
||||
}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<Button icon={<Plus />} onClick={() => setShowCreateForm(true)}>
|
||||
Create Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
<div className="rounded-lg border bg-kumo-base">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader />
|
||||
</div>
|
||||
) : !tokens || tokens.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-kumo-subtle">
|
||||
No API tokens yet. Create one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="flex items-center justify-between p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{token.name}</span>
|
||||
<code className="text-xs text-kumo-subtle bg-kumo-tint px-1.5 py-0.5 rounded">
|
||||
{token.prefix}...
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-kumo-subtle">
|
||||
<span>Scopes: {token.scopes.join(", ")}</span>
|
||||
{token.expiresAt && (
|
||||
<span>Expires {new Date(token.expiresAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
{token.lastUsedAt && (
|
||||
<span>Last used {new Date(token.lastUsedAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-kumo-subtle mt-0.5">
|
||||
Created {new Date(token.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{revokeConfirmId === token.id ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{revokeMutation.error && (
|
||||
<span className="text-sm text-kumo-danger">
|
||||
{getMutationError(revokeMutation.error)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-kumo-danger">Revoke?</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate(token.id)}
|
||||
>
|
||||
{revokeMutation.isPending ? "Revoking..." : "Confirm"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRevokeConfirmId(null);
|
||||
revokeMutation.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
onClick={() => setRevokeConfirmId(token.id)}
|
||||
aria-label="Revoke token"
|
||||
>
|
||||
<Trash className="h-4 w-4 text-kumo-subtle hover:text-kumo-danger" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Create token form
|
||||
// =============================================================================
|
||||
|
||||
interface CreateTokenFormProps {
|
||||
isCreating: boolean;
|
||||
error: string | null;
|
||||
onSubmit: (input: { name: string; scopes: string[]; expiresAt?: string }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function CreateTokenForm({ isCreating, error, onSubmit, onCancel }: CreateTokenFormProps) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<Set<string>>(new Set());
|
||||
const [expiry, setExpiry] = React.useState("30d");
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
setSelectedScopes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(scope)) {
|
||||
next.delete(scope);
|
||||
} else {
|
||||
next.add(scope);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
scopes: [...selectedScopes],
|
||||
expiresAt: computeExpiryDate(expiry),
|
||||
});
|
||||
};
|
||||
|
||||
const isValid = name.trim().length > 0 && selectedScopes.size > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Create New Token</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-3 flex items-center gap-2 text-sm text-kumo-danger">
|
||||
<WarningCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Token Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., CI/CD Pipeline"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Scopes</div>
|
||||
<div className="space-y-2">
|
||||
{API_TOKEN_SCOPES.map((scope) => (
|
||||
<label key={scope.value} className="flex items-start gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selectedScopes.has(scope.value)}
|
||||
onCheckedChange={() => toggleScope(scope.value)}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{scope.label}</div>
|
||||
<div className="text-xs text-kumo-subtle">{scope.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Expiry"
|
||||
value={expiry}
|
||||
onValueChange={(v) => v !== null && setExpiry(v)}
|
||||
items={Object.fromEntries(EXPIRY_OPTIONS.map((o) => [o.value, o.label]))}
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" disabled={!isValid || isCreating}>
|
||||
{isCreating ? "Creating..." : "Create Token"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
packages/admin/src/components/settings/EmailSettings.tsx
Normal file
224
packages/admin/src/components/settings/EmailSettings.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Email settings page
|
||||
*
|
||||
* Shows current email pipeline status, provider info, and allows
|
||||
* sending a test email through the full pipeline.
|
||||
*/
|
||||
|
||||
import { Button, Input, Loader } from "@cloudflare/kumo";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Envelope,
|
||||
PaperPlaneTilt,
|
||||
PlugsConnected,
|
||||
WarningCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
fetchEmailSettings,
|
||||
sendTestEmail,
|
||||
type EmailSettings as EmailSettingsData,
|
||||
} from "../../lib/api/email-settings.js";
|
||||
import { getMutationError } from "../DialogError.js";
|
||||
|
||||
export function EmailSettings() {
|
||||
const [testEmail, setTestEmail] = React.useState("");
|
||||
const [status, setStatus] = React.useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// Clear status after 5 seconds
|
||||
React.useEffect(() => {
|
||||
if (!status) return;
|
||||
const timer = setTimeout(setStatus, 5000, null);
|
||||
return () => clearTimeout(timer);
|
||||
}, [status]);
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["email-settings"],
|
||||
queryFn: fetchEmailSettings,
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (to: string) => sendTestEmail(to),
|
||||
onSuccess: (result) => {
|
||||
setStatus({ type: "success", message: result.message });
|
||||
setTestEmail("");
|
||||
},
|
||||
onError: (error) => {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: getMutationError(error) || "Failed to send test email",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleTestSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!testEmail) return;
|
||||
testMutation.mutate(testEmail);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to settings">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Email Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
{status && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
|
||||
status.type === "success"
|
||||
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
|
||||
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{status.type === "success" ? (
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<WarningCircle className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline status */}
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Envelope className="h-5 w-5 text-kumo-subtle" />
|
||||
<h2 className="text-lg font-semibold">Email Pipeline</h2>
|
||||
</div>
|
||||
|
||||
<PipelineStatus settings={settings} />
|
||||
</div>
|
||||
|
||||
{/* Test email */}
|
||||
{settings?.available && (
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<PaperPlaneTilt className="h-5 w-5 text-kumo-subtle" />
|
||||
<h2 className="text-lg font-semibold">Send Test Email</h2>
|
||||
</div>
|
||||
<p className="text-sm text-kumo-subtle mb-4">
|
||||
Send a test email through the full pipeline to verify your email configuration.
|
||||
</p>
|
||||
<form onSubmit={handleTestSubmit} className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Recipient email"
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
placeholder="test@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={testMutation.isPending || !testEmail}>
|
||||
{testMutation.isPending ? "Sending..." : "Send Test"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pipeline status display
|
||||
// =============================================================================
|
||||
|
||||
function PipelineStatus({ settings }: { settings: EmailSettingsData | undefined }) {
|
||||
if (!settings) return null;
|
||||
|
||||
if (!settings.available) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<WarningCircle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
No email provider configured
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
Install and activate an email provider plugin to enable email features like
|
||||
invitations, magic links, and password recovery.
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-2">
|
||||
Without an email provider, invite links must be shared manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Provider */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-md bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Email provider active
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
Provider:{" "}
|
||||
<code className="rounded bg-green-100 dark:bg-green-900/40 px-1.5 py-0.5 text-xs">
|
||||
{settings.selectedProviderId || "default"}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middleware */}
|
||||
{(settings.middleware.beforeSend.length > 0 || settings.middleware.afterSend.length > 0) && (
|
||||
<div className="p-3 rounded-md bg-kumo-tint/50 border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<PlugsConnected className="h-4 w-4 text-kumo-subtle" />
|
||||
<p className="text-sm font-medium">Email Middleware</p>
|
||||
</div>
|
||||
{settings.middleware.beforeSend.length > 0 && (
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
Before send: {settings.middleware.beforeSend.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{settings.middleware.afterSend.length > 0 && (
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
After send: {settings.middleware.afterSend.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available providers (if multiple) */}
|
||||
{settings.providers.length > 1 && (
|
||||
<div className="p-3 rounded-md bg-kumo-tint/50 border">
|
||||
<p className="text-sm font-medium mb-1">Available Providers</p>
|
||||
<p className="text-sm text-kumo-subtle">
|
||||
{settings.providers.map((p) => p.pluginId).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
packages/admin/src/components/settings/GeneralSettings.tsx
Normal file
318
packages/admin/src/components/settings/GeneralSettings.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* General Settings sub-page
|
||||
*
|
||||
* Site Identity (title, tagline, URL, logo, favicon) and Reading settings
|
||||
* (posts per page, date format, timezone).
|
||||
*/
|
||||
|
||||
import { Button, Input, Label } from "@cloudflare/kumo";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FloppyDisk,
|
||||
CheckCircle,
|
||||
WarningCircle,
|
||||
Upload,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchSettings, updateSettings, type SiteSettings, type MediaItem } from "../../lib/api";
|
||||
import { MediaPickerModal } from "../MediaPickerModal";
|
||||
|
||||
export function GeneralSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = React.useState<Partial<SiteSettings>>({});
|
||||
const [saveStatus, setSaveStatus] = React.useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const [logoPickerOpen, setLogoPickerOpen] = React.useState(false);
|
||||
const [faviconPickerOpen, setFaviconPickerOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) setFormData(settings);
|
||||
}, [settings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (saveStatus) {
|
||||
const timer = setTimeout(setSaveStatus, 3000, null);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saveStatus]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: Partial<SiteSettings>) => updateSettings(data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setSaveStatus({ type: "success", message: "Settings saved successfully" });
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "Failed to save settings",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleChange = (key: keyof SiteSettings, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleLogoSelect = (media: MediaItem) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
logo: { mediaId: media.id, alt: media.alt || "", url: media.url },
|
||||
}));
|
||||
setLogoPickerOpen(false);
|
||||
};
|
||||
|
||||
const handleFaviconSelect = (media: MediaItem) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
favicon: { mediaId: media.id, url: media.url },
|
||||
}));
|
||||
setFaviconPickerOpen(false);
|
||||
};
|
||||
|
||||
const handleLogoRemove = () => {
|
||||
setFormData((prev) => ({ ...prev, logo: undefined }));
|
||||
};
|
||||
|
||||
const handleFaviconRemove = () => {
|
||||
setFormData((prev) => ({ ...prev, favicon: undefined }));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to settings">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">General Settings</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<p className="text-kumo-subtle">Loading settings...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to settings">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">General Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
{saveStatus && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
|
||||
saveStatus.type === "success"
|
||||
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
|
||||
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{saveStatus.type === "success" ? (
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<WarningCircle className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{saveStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Site Identity */}
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">Site Identity</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Site Title"
|
||||
value={formData.title || ""}
|
||||
onChange={(e) => handleChange("title", e.target.value)}
|
||||
description="The name of your site, used in the header and metadata"
|
||||
/>
|
||||
<Input
|
||||
label="Tagline"
|
||||
value={formData.tagline || ""}
|
||||
onChange={(e) => handleChange("tagline", e.target.value)}
|
||||
description="A short description of your site"
|
||||
/>
|
||||
<Input
|
||||
label="Site URL"
|
||||
type="url"
|
||||
value={formData.url || ""}
|
||||
onChange={(e) => handleChange("url", e.target.value)}
|
||||
description="The public URL of your site (used for canonical links and sitemaps)"
|
||||
/>
|
||||
|
||||
{/* Logo Picker */}
|
||||
<div>
|
||||
<Label>Logo</Label>
|
||||
{formData.logo?.url ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<img
|
||||
src={formData.logo.url}
|
||||
alt={formData.logo.alt || "Logo"}
|
||||
className="h-16 rounded border bg-kumo-tint object-contain p-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Upload />}
|
||||
onClick={() => setLogoPickerOpen(true)}
|
||||
>
|
||||
Change Logo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<X />}
|
||||
onClick={handleLogoRemove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={<Upload />}
|
||||
onClick={() => setLogoPickerOpen(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
Select Logo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favicon Picker */}
|
||||
<div>
|
||||
<Label>Favicon</Label>
|
||||
{formData.favicon?.url ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<img
|
||||
src={formData.favicon.url}
|
||||
alt="Favicon"
|
||||
className="h-8 w-8 rounded border bg-kumo-tint object-contain p-1"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Upload />}
|
||||
onClick={() => setFaviconPickerOpen(true)}
|
||||
>
|
||||
Change Favicon
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<X />}
|
||||
onClick={handleFaviconRemove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={<Upload />}
|
||||
onClick={() => setFaviconPickerOpen(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
Select Favicon
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Settings */}
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">Reading</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Posts Per Page"
|
||||
type="number"
|
||||
value={formData.postsPerPage || 10}
|
||||
onChange={(e) => handleChange("postsPerPage", parseInt(e.target.value, 10))}
|
||||
min={1}
|
||||
max={100}
|
||||
description="Number of posts to show per page on list views"
|
||||
/>
|
||||
<Input
|
||||
label="Date Format"
|
||||
value={formData.dateFormat || "MMMM d, yyyy"}
|
||||
onChange={(e) => handleChange("dateFormat", e.target.value)}
|
||||
description={`Example: ${formData.dateFormat || "MMMM d, yyyy"} → January 23, 2026`}
|
||||
/>
|
||||
<Input
|
||||
label="Timezone"
|
||||
value={formData.timezone || "UTC"}
|
||||
onChange={(e) => handleChange("timezone", e.target.value)}
|
||||
description="Timezone for displaying dates (e.g., America/New_York)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saveMutation.isPending} icon={<FloppyDisk />}>
|
||||
{saveMutation.isPending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Media Picker Modals */}
|
||||
<MediaPickerModal
|
||||
open={logoPickerOpen}
|
||||
onOpenChange={setLogoPickerOpen}
|
||||
onSelect={handleLogoSelect}
|
||||
mimeTypeFilter="image/"
|
||||
title="Select Logo"
|
||||
/>
|
||||
<MediaPickerModal
|
||||
open={faviconPickerOpen}
|
||||
onOpenChange={setFaviconPickerOpen}
|
||||
onSelect={handleFaviconSelect}
|
||||
mimeTypeFilter="image/"
|
||||
title="Select Favicon"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralSettings;
|
||||
212
packages/admin/src/components/settings/PasskeyItem.tsx
Normal file
212
packages/admin/src/components/settings/PasskeyItem.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* PasskeyItem - Individual passkey display with rename and delete actions
|
||||
*/
|
||||
|
||||
import { Button, Input } from "@cloudflare/kumo";
|
||||
import { Pencil, Trash, Check, X, DeviceMobile, Cloud } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { PasskeyInfo } from "../../lib/api";
|
||||
import { ConfirmDialog } from "../ConfirmDialog.js";
|
||||
|
||||
export interface PasskeyItemProps {
|
||||
passkey: PasskeyInfo;
|
||||
canDelete: boolean;
|
||||
onRename: (id: string, name: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
isDeleting?: boolean;
|
||||
isRenaming?: boolean;
|
||||
}
|
||||
|
||||
function formatDeviceType(type: "singleDevice" | "multiDevice"): string {
|
||||
return type === "multiDevice" ? "Synced passkey" : "Device-bound passkey";
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) {
|
||||
return "just now";
|
||||
} else if (diffMins < 60) {
|
||||
return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function PasskeyItem({
|
||||
passkey,
|
||||
canDelete,
|
||||
onRename,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
isRenaming,
|
||||
}: PasskeyItemProps) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [editName, setEditName] = React.useState(passkey.name || "");
|
||||
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false);
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus input when editing starts
|
||||
React.useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onRename(passkey.id, editName.trim());
|
||||
setIsEditing(false);
|
||||
} catch {
|
||||
// Error handled by parent
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditName(passkey.name || "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
await onDelete(passkey.id);
|
||||
setShowDeleteDialog(false);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : "Failed to remove passkey");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between p-4 border rounded-lg bg-kumo-base">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="mt-0.5 p-2 rounded-md bg-kumo-tint">
|
||||
{passkey.deviceType === "multiDevice" ? (
|
||||
<Cloud className="h-4 w-4 text-kumo-subtle" />
|
||||
) : (
|
||||
<DeviceMobile className="h-4 w-4 text-kumo-subtle" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 w-48"
|
||||
placeholder="Passkey name"
|
||||
disabled={isRenaming}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleSave}
|
||||
disabled={isRenaming}
|
||||
aria-label="Save name"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
disabled={isRenaming}
|
||||
aria-label="Cancel rename"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-medium">{passkey.name || "Unnamed passkey"}</div>
|
||||
)}
|
||||
<div className="text-sm text-kumo-subtle">
|
||||
{formatDeviceType(passkey.deviceType)}
|
||||
{passkey.backedUp && (
|
||||
<span className="text-green-600 dark:text-green-400"> (synced)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-kumo-subtle mt-1">
|
||||
Last used {formatRelativeTime(passkey.lastUsedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditName(passkey.name || "");
|
||||
setIsEditing(true);
|
||||
}}
|
||||
title="Rename"
|
||||
aria-label={`Rename ${passkey.name || "passkey"}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="text-kumo-danger hover:text-kumo-danger"
|
||||
title="Remove"
|
||||
aria-label={`Remove ${passkey.name || "passkey"}`}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onClose={() => {
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
title="Remove passkey?"
|
||||
description={`You won't be able to use "${passkey.name || "this passkey"}" to sign in anymore. This action cannot be undone.`}
|
||||
confirmLabel="Remove"
|
||||
pendingLabel="Removing..."
|
||||
isPending={!!isDeleting}
|
||||
error={deleteError}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
40
packages/admin/src/components/settings/PasskeyList.tsx
Normal file
40
packages/admin/src/components/settings/PasskeyList.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* PasskeyList - Displays a list of passkeys with actions
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { PasskeyInfo } from "../../lib/api";
|
||||
import { PasskeyItem } from "./PasskeyItem";
|
||||
|
||||
export interface PasskeyListProps {
|
||||
passkeys: PasskeyInfo[];
|
||||
onRename: (id: string, name: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
isDeleting?: boolean;
|
||||
isRenaming?: boolean;
|
||||
}
|
||||
|
||||
export function PasskeyList({
|
||||
passkeys,
|
||||
onRename,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
isRenaming,
|
||||
}: PasskeyListProps) {
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{passkeys.map((passkey) => (
|
||||
<PasskeyItem
|
||||
key={passkey.id}
|
||||
passkey={passkey}
|
||||
canDelete={passkeys.length > 1}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
isRenaming={isRenaming}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
232
packages/admin/src/components/settings/SecuritySettings.tsx
Normal file
232
packages/admin/src/components/settings/SecuritySettings.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Security Settings page - Passkey management
|
||||
*
|
||||
* Only available when using passkey auth. When external auth (e.g., Cloudflare Access)
|
||||
* is configured, this page shows an informational message instead.
|
||||
*/
|
||||
|
||||
import { Button } from "@cloudflare/kumo";
|
||||
import { Shield, Plus, CheckCircle, WarningCircle, ArrowLeft, Info } from "@phosphor-icons/react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchPasskeys, renamePasskey, deletePasskey, fetchManifest } from "../../lib/api";
|
||||
import { PasskeyRegistration } from "../auth/PasskeyRegistration";
|
||||
import { PasskeyList } from "./PasskeyList";
|
||||
|
||||
export function SecuritySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isAdding, setIsAdding] = React.useState(false);
|
||||
const [saveStatus, setSaveStatus] = React.useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// Fetch manifest for auth mode
|
||||
const { data: manifest, isLoading: manifestLoading } = useQuery({
|
||||
queryKey: ["manifest"],
|
||||
queryFn: fetchManifest,
|
||||
});
|
||||
|
||||
const isExternalAuth = manifest?.authMode && manifest.authMode !== "passkey";
|
||||
|
||||
// Fetch passkeys (only when using passkey auth)
|
||||
const {
|
||||
data: passkeys,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["passkeys"],
|
||||
queryFn: fetchPasskeys,
|
||||
enabled: !isExternalAuth && !manifestLoading,
|
||||
});
|
||||
|
||||
// Clear status message after 3 seconds
|
||||
React.useEffect(() => {
|
||||
if (saveStatus) {
|
||||
const timer = setTimeout(setSaveStatus, 3000, null);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saveStatus]);
|
||||
|
||||
// Rename mutation
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => renamePasskey(id, name),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
setSaveStatus({ type: "success", message: "Passkey renamed" });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message:
|
||||
mutationError instanceof Error ? mutationError.message : "Failed to rename passkey",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deletePasskey(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
setSaveStatus({ type: "success", message: "Passkey removed" });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message:
|
||||
mutationError instanceof Error ? mutationError.message : "Failed to remove passkey",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRename = async (id: string, name: string) => {
|
||||
await renameMutation.mutateAsync({ id, name });
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const handleAddSuccess = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
setIsAdding(false);
|
||||
setSaveStatus({ type: "success", message: "Passkey added successfully" });
|
||||
};
|
||||
|
||||
if (manifestLoading || isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Security Settings</h1>
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<p className="text-kumo-subtle">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show message when external auth is configured
|
||||
if (isExternalAuth) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Security Settings</h1>
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-kumo-subtle mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-kumo-subtle">
|
||||
Authentication is managed by an external provider ({manifest?.authMode}). Passkey
|
||||
settings are not available when using external authentication.
|
||||
</p>
|
||||
<Link to="/settings">
|
||||
<Button variant="outline" size="sm" icon={<ArrowLeft />}>
|
||||
Back to Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Security Settings</h1>
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6">
|
||||
<p className="text-kumo-danger">
|
||||
{error instanceof Error ? error.message : "Failed to load passkeys"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Security Settings</h1>
|
||||
|
||||
{/* Status message */}
|
||||
{saveStatus && (
|
||||
<div
|
||||
className={`rounded-lg border p-4 flex items-center gap-2 ${
|
||||
saveStatus.type === "success"
|
||||
? "bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400"
|
||||
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{saveStatus.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<WarningCircle className="h-5 w-5" />
|
||||
)}
|
||||
<span>{saveStatus.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passkeys Section */}
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="h-5 w-5 text-kumo-subtle" />
|
||||
<h2 className="text-lg font-semibold">Passkeys</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-kumo-subtle mb-6">
|
||||
Passkeys are a secure, passwordless way to sign in to your account. You can register
|
||||
multiple passkeys for different devices.
|
||||
</p>
|
||||
|
||||
{/* Passkey list */}
|
||||
{passkeys && passkeys.length > 0 ? (
|
||||
<PasskeyList
|
||||
passkeys={passkeys}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
isRenaming={renameMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-kumo-subtle">
|
||||
No passkeys registered yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add passkey section */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
{isAdding ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">Add a new passkey</h3>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<PasskeyRegistration
|
||||
optionsEndpoint="/_emdash/api/auth/passkey/register/options"
|
||||
verifyEndpoint="/_emdash/api/auth/passkey/register/verify"
|
||||
onSuccess={handleAddSuccess}
|
||||
onError={(registrationError) =>
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message: registrationError.message,
|
||||
})
|
||||
}
|
||||
showNameInput
|
||||
buttonText="Register Passkey"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setIsAdding(true)} icon={<Plus />}>
|
||||
Add Passkey
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SecuritySettings;
|
||||
170
packages/admin/src/components/settings/SeoSettings.tsx
Normal file
170
packages/admin/src/components/settings/SeoSettings.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* SEO Settings sub-page
|
||||
*
|
||||
* Title separator, search engine verification codes, and robots.txt.
|
||||
*/
|
||||
|
||||
import { Button, Input, InputArea } from "@cloudflare/kumo";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FloppyDisk,
|
||||
CheckCircle,
|
||||
WarningCircle,
|
||||
MagnifyingGlass,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchSettings, updateSettings, type SiteSettings } from "../../lib/api";
|
||||
|
||||
export function SeoSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = React.useState<Partial<SiteSettings>>({});
|
||||
const [saveStatus, setSaveStatus] = React.useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) setFormData(settings);
|
||||
}, [settings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (saveStatus) {
|
||||
const timer = setTimeout(setSaveStatus, 3000, null);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saveStatus]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: Partial<SiteSettings>) => updateSettings(data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setSaveStatus({ type: "success", message: "SEO settings saved" });
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "Failed to save settings",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleSeoChange = (key: string, value: unknown) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
seo: {
|
||||
...prev.seo,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to settings">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">SEO Settings</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<p className="text-kumo-subtle">Loading settings...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to settings">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">SEO Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
{saveStatus && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
|
||||
saveStatus.type === "success"
|
||||
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
|
||||
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{saveStatus.type === "success" ? (
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<WarningCircle className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{saveStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MagnifyingGlass className="h-5 w-5 text-kumo-subtle" />
|
||||
<h2 className="text-lg font-semibold">Search Engine Optimization</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Title Separator"
|
||||
value={formData.seo?.titleSeparator || "|"}
|
||||
onChange={(e) => handleSeoChange("titleSeparator", e.target.value)}
|
||||
description='Character between page title and site name (e.g., "My Post | My Site")'
|
||||
/>
|
||||
<Input
|
||||
label="Google Verification"
|
||||
value={formData.seo?.googleVerification || ""}
|
||||
onChange={(e) => handleSeoChange("googleVerification", e.target.value)}
|
||||
description="Meta tag content for Google Search Console verification"
|
||||
/>
|
||||
<Input
|
||||
label="Bing Verification"
|
||||
value={formData.seo?.bingVerification || ""}
|
||||
onChange={(e) => handleSeoChange("bingVerification", e.target.value)}
|
||||
description="Meta tag content for Bing Webmaster Tools verification"
|
||||
/>
|
||||
<InputArea
|
||||
label="robots.txt"
|
||||
value={formData.seo?.robotsTxt || ""}
|
||||
onChange={(e) => handleSeoChange("robotsTxt", e.target.value)}
|
||||
rows={5}
|
||||
description="Custom robots.txt content. Leave empty to use the default."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saveMutation.isPending} icon={<FloppyDisk />}>
|
||||
{saveMutation.isPending ? "Saving..." : "Save SEO Settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeoSettings;
|
||||
176
packages/admin/src/components/settings/SocialSettings.tsx
Normal file
176
packages/admin/src/components/settings/SocialSettings.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Social Settings sub-page
|
||||
*
|
||||
* Social media profile links (Twitter, GitHub, Facebook, Instagram, LinkedIn, YouTube).
|
||||
*/
|
||||
|
||||
import { Button, Input } from "@cloudflare/kumo";
|
||||
import { ArrowLeft, FloppyDisk, CheckCircle, WarningCircle } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
|
||||
import { fetchSettings, updateSettings, type SiteSettings } from "../../lib/api";
|
||||
|
||||
export function SocialSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = React.useState<Partial<SiteSettings>>({});
|
||||
const [saveStatus, setSaveStatus] = React.useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) setFormData(settings);
|
||||
}, [settings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (saveStatus) {
|
||||
const timer = setTimeout(setSaveStatus, 3000, null);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saveStatus]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: Partial<SiteSettings>) => updateSettings(data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setSaveStatus({ type: "success", message: "Social links saved" });
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaveStatus({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "Failed to save settings",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleSocialChange = (key: string, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
social: {
|
||||
...prev.social,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to settings">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Social Links</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<p className="text-kumo-subtle">Loading settings...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" shape="square" aria-label="Back to settings">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Social Links</h1>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
{saveStatus && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
|
||||
saveStatus.type === "success"
|
||||
? "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-200"
|
||||
: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{saveStatus.type === "success" ? (
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<WarningCircle className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{saveStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="rounded-lg border bg-kumo-base p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">Social Profiles</h2>
|
||||
<p className="text-sm text-kumo-subtle mb-6">
|
||||
Add your social media profiles. These are available to your site's theme and can be
|
||||
displayed in headers, footers, or author bios.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Twitter"
|
||||
value={formData.social?.twitter || ""}
|
||||
onChange={(e) => handleSocialChange("twitter", e.target.value)}
|
||||
description="Your Twitter/X handle (e.g., @username)"
|
||||
/>
|
||||
<Input
|
||||
label="GitHub"
|
||||
value={formData.social?.github || ""}
|
||||
onChange={(e) => handleSocialChange("github", e.target.value)}
|
||||
description="Your GitHub username"
|
||||
/>
|
||||
<Input
|
||||
label="Facebook"
|
||||
value={formData.social?.facebook || ""}
|
||||
onChange={(e) => handleSocialChange("facebook", e.target.value)}
|
||||
description="Your Facebook page or profile username"
|
||||
/>
|
||||
<Input
|
||||
label="Instagram"
|
||||
value={formData.social?.instagram || ""}
|
||||
onChange={(e) => handleSocialChange("instagram", e.target.value)}
|
||||
description="Your Instagram username"
|
||||
/>
|
||||
<Input
|
||||
label="LinkedIn"
|
||||
value={formData.social?.linkedin || ""}
|
||||
onChange={(e) => handleSocialChange("linkedin", e.target.value)}
|
||||
description="Your LinkedIn profile username"
|
||||
/>
|
||||
<Input
|
||||
label="YouTube"
|
||||
value={formData.social?.youtube || ""}
|
||||
onChange={(e) => handleSocialChange("youtube", e.target.value)}
|
||||
description="Your YouTube channel ID or handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saveMutation.isPending} icon={<FloppyDisk />}>
|
||||
{saveMutation.isPending ? "Saving..." : "Save Social Links"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SocialSettings;
|
||||
208
packages/admin/src/components/users/InviteUserModal.tsx
Normal file
208
packages/admin/src/components/users/InviteUserModal.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Button, Dialog, Input, Select } from "@cloudflare/kumo";
|
||||
import { Check, Copy, X } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { ROLES } from "./RoleBadge";
|
||||
|
||||
export interface InviteUserModalProps {
|
||||
open: boolean;
|
||||
isSending?: boolean;
|
||||
error?: string | null;
|
||||
/** When set, shows a copy-link view instead of the form (no email provider) */
|
||||
inviteUrl?: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onInvite: (email: string, role: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite user modal — sends invite email or shows copy-link fallback
|
||||
*/
|
||||
export function InviteUserModal({
|
||||
open,
|
||||
isSending,
|
||||
error,
|
||||
inviteUrl,
|
||||
onOpenChange,
|
||||
onInvite,
|
||||
}: InviteUserModalProps) {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [role, setRole] = React.useState(30); // Default to Author
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [copyError, setCopyError] = React.useState(false);
|
||||
|
||||
const copyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
// Reset form when modal opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setEmail("");
|
||||
setRole(30);
|
||||
setCopied(false);
|
||||
setCopyError(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onInvite(email, role);
|
||||
};
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
if (!inviteUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
setCopied(true);
|
||||
setCopyError(false);
|
||||
copyTimeoutRef.current = setTimeout(setCopied, 2000, false);
|
||||
} catch {
|
||||
// Clipboard API can fail in insecure contexts
|
||||
setCopyError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog className="p-6 max-w-md" size="lg">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
|
||||
{inviteUrl ? "Invite Link Created" : "Invite User"}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-sm text-kumo-subtle">
|
||||
{inviteUrl
|
||||
? "No email provider configured. Share this link manually."
|
||||
: "Send an invitation email to a new team member."}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inviteUrl ? (
|
||||
/* Copy-link view — shown when no email provider is configured */
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 font-medium">
|
||||
Share this link with the invited user
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
||||
This link expires in 7 days and can only be used once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-kumo-tint px-3 py-2 text-sm font-mono border truncate">
|
||||
{inviteUrl}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
onClick={handleCopyUrl}
|
||||
aria-label="Copy invite link"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">Copied to clipboard</p>
|
||||
)}
|
||||
{copyError && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Could not copy automatically. Please select the URL above and copy manually.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Standard invite form */
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Email */}
|
||||
<Input
|
||||
label="Email address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="colleague@example.com"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* Role */}
|
||||
<div className="grid gap-2">
|
||||
<Select
|
||||
label="Role"
|
||||
value={role.toString()}
|
||||
onValueChange={(v) => v !== null && setRole(parseInt(v, 10))}
|
||||
items={Object.fromEntries(ROLES.map((r) => [r.value.toString(), r.label]))}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<Select.Option key={r.value} value={r.value.toString()}>
|
||||
<div>
|
||||
<div>{r.label}</div>
|
||||
<div className="text-xs text-kumo-subtle">{r.description}</div>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-kumo-subtle">
|
||||
The invited user will have this role once they complete registration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-kumo-danger/10 p-3 text-sm text-kumo-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSending || !email}>
|
||||
{isSending ? "Sending..." : "Send Invite"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
102
packages/admin/src/components/users/RoleBadge.tsx
Normal file
102
packages/admin/src/components/users/RoleBadge.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
/** Role level to name mapping */
|
||||
const ROLE_CONFIG: Record<number, { label: string; color: string; description: string }> = {
|
||||
10: {
|
||||
label: "Subscriber",
|
||||
color: "gray",
|
||||
description: "Can view content",
|
||||
},
|
||||
20: {
|
||||
label: "Contributor",
|
||||
color: "blue",
|
||||
description: "Can create content",
|
||||
},
|
||||
30: {
|
||||
label: "Author",
|
||||
color: "green",
|
||||
description: "Can publish own content",
|
||||
},
|
||||
40: {
|
||||
label: "Editor",
|
||||
color: "purple",
|
||||
description: "Can manage all content",
|
||||
},
|
||||
50: {
|
||||
label: "Admin",
|
||||
color: "red",
|
||||
description: "Full access",
|
||||
},
|
||||
};
|
||||
|
||||
/** Get role config, with fallback for unknown roles */
|
||||
export function getRoleConfig(role: number) {
|
||||
return (
|
||||
ROLE_CONFIG[role] ?? {
|
||||
label: `Role ${role}`,
|
||||
color: "gray",
|
||||
description: "Unknown role",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Get role label from role level */
|
||||
export function getRoleLabel(role: number): string {
|
||||
return getRoleConfig(role).label;
|
||||
}
|
||||
|
||||
export interface RoleBadgeProps {
|
||||
role: number;
|
||||
size?: "sm" | "md";
|
||||
showDescription?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role badge component with semantic colors
|
||||
*/
|
||||
export function RoleBadge({
|
||||
role,
|
||||
size = "sm",
|
||||
showDescription = false,
|
||||
className,
|
||||
}: RoleBadgeProps) {
|
||||
const config = getRoleConfig(role);
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
gray: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
|
||||
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
green: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
purple: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-2 py-0.5 text-xs",
|
||||
md: "px-2.5 py-1 text-sm",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full font-medium",
|
||||
sizeClasses[size],
|
||||
colorClasses[config.color],
|
||||
className,
|
||||
)}
|
||||
title={showDescription ? undefined : config.description}
|
||||
>
|
||||
{config.label}
|
||||
{showDescription && <span className="ml-1 opacity-75">- {config.description}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** List of all roles for dropdowns */
|
||||
export const ROLES = [
|
||||
{ value: 10, label: "Subscriber", description: "Can view content" },
|
||||
{ value: 20, label: "Contributor", description: "Can create content" },
|
||||
{ value: 30, label: "Author", description: "Can publish own content" },
|
||||
{ value: 40, label: "Editor", description: "Can manage all content" },
|
||||
{ value: 50, label: "Admin", description: "Full access" },
|
||||
];
|
||||
379
packages/admin/src/components/users/UserDetail.tsx
Normal file
379
packages/admin/src/components/users/UserDetail.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { Button, Input, Select } from "@cloudflare/kumo";
|
||||
import {
|
||||
X,
|
||||
Key,
|
||||
Prohibit,
|
||||
CheckCircle,
|
||||
ArrowSquareOut,
|
||||
FloppyDisk,
|
||||
Envelope,
|
||||
} from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { UserDetail as UserDetailType, UpdateUserInput } from "../../lib/api";
|
||||
import { useStableCallback } from "../../lib/hooks";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ROLES, getRoleLabel } from "./RoleBadge";
|
||||
|
||||
export interface UserDetailProps {
|
||||
user: UserDetailType | null;
|
||||
isLoading?: boolean;
|
||||
isOpen: boolean;
|
||||
isSaving?: boolean;
|
||||
isSendingRecovery?: boolean;
|
||||
recoverySent?: boolean;
|
||||
recoveryError?: string | null;
|
||||
currentUserId?: string;
|
||||
onClose: () => void;
|
||||
onSave: (data: UpdateUserInput) => void;
|
||||
onDisable: () => void;
|
||||
onEnable: () => void;
|
||||
onSendRecovery?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* User detail slide-over panel with inline editing
|
||||
*/
|
||||
export function UserDetail({
|
||||
user,
|
||||
isLoading,
|
||||
isOpen,
|
||||
isSaving,
|
||||
isSendingRecovery,
|
||||
recoverySent,
|
||||
recoveryError,
|
||||
currentUserId,
|
||||
onClose,
|
||||
onSave,
|
||||
onDisable,
|
||||
onEnable,
|
||||
onSendRecovery,
|
||||
}: UserDetailProps) {
|
||||
const [name, setName] = React.useState(user?.name ?? "");
|
||||
const [email, setEmail] = React.useState(user?.email ?? "");
|
||||
const [role, setRole] = React.useState(user?.role ?? 30);
|
||||
|
||||
// Reset form when viewing a different user
|
||||
const userIdRef = React.useRef(user?.id);
|
||||
if (user?.id !== userIdRef.current) {
|
||||
userIdRef.current = user?.id;
|
||||
if (user) {
|
||||
setName(user.name ?? "");
|
||||
setEmail(user.email ?? "");
|
||||
setRole(user.role);
|
||||
}
|
||||
}
|
||||
|
||||
const stableOnClose = useStableCallback(onClose);
|
||||
|
||||
// Close on Escape key
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
stableOnClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [stableOnClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isSelf = user && currentUserId && user.id === currentUserId;
|
||||
|
||||
const isDirty =
|
||||
user && (name !== (user.name ?? "") || email !== user.email || role !== user.role);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
|
||||
const data: UpdateUserInput = {};
|
||||
|
||||
if (name !== (user.name ?? "")) {
|
||||
data.name = name || undefined;
|
||||
}
|
||||
if (email !== user.email) {
|
||||
data.email = email;
|
||||
}
|
||||
if (role !== user.role && !isSelf) {
|
||||
data.role = role;
|
||||
}
|
||||
|
||||
onSave(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} aria-hidden="true" />
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col bg-kumo-base shadow-xl",
|
||||
"transform transition-transform duration-200",
|
||||
isOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-detail-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 id="user-detail-title" className="text-lg font-semibold">
|
||||
User Details
|
||||
</h2>
|
||||
<Button variant="ghost" shape="square" onClick={onClose} aria-label="Close panel">
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<UserDetailSkeleton />
|
||||
) : user ? (
|
||||
<form id="user-edit-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Avatar + editable fields */}
|
||||
<div className="flex items-start gap-4">
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt=""
|
||||
className="h-16 w-16 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-16 shrink-0 rounded-full bg-kumo-tint flex items-center justify-center text-2xl font-medium">
|
||||
{(name || email)?.[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<Input
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role + status */}
|
||||
<div className="flex items-end gap-3">
|
||||
{isSelf ? (
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Role"
|
||||
value={getRoleLabel(role)}
|
||||
disabled
|
||||
className="cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-kumo-subtle mt-1">You cannot change your own role</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="Role"
|
||||
value={role.toString()}
|
||||
onValueChange={(v) => v !== null && setRole(parseInt(v, 10))}
|
||||
items={Object.fromEntries(ROLES.map((r) => [r.value.toString(), r.label]))}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<Select.Option key={r.value} value={r.value.toString()}>
|
||||
<div>
|
||||
<div>{r.label}</div>
|
||||
<div className="text-xs text-kumo-subtle">{r.description}</div>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="pb-1">
|
||||
{user.disabled ? (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-kumo-danger">
|
||||
<Prohibit className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Disabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info cards */}
|
||||
<div className="grid gap-4">
|
||||
{/* Timestamps */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-sm font-medium text-kumo-subtle mb-3">Account Info</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-kumo-subtle">Created</span>
|
||||
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-kumo-subtle">Last updated</span>
|
||||
<span>{new Date(user.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-kumo-subtle">Last login</span>
|
||||
<span>
|
||||
{user.lastLogin ? new Date(user.lastLogin).toLocaleDateString() : "Never"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-kumo-subtle">Email verified</span>
|
||||
<span>{user.emailVerified ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passkeys */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-sm font-medium text-kumo-subtle mb-3 flex items-center gap-2">
|
||||
<Key className="h-4 w-4" aria-hidden="true" />
|
||||
Passkeys ({user.credentials.length})
|
||||
</h4>
|
||||
{user.credentials.length === 0 ? (
|
||||
<p className="text-sm text-kumo-subtle">No passkeys registered</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{user.credentials.map((cred) => (
|
||||
<div key={cred.id} className="flex justify-between text-sm">
|
||||
<div>
|
||||
<div>{cred.name || "Unnamed passkey"}</div>
|
||||
<div className="text-xs text-kumo-subtle">
|
||||
{cred.deviceType === "multiDevice" ? "Synced" : "Device-bound"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-kumo-subtle">
|
||||
<div>Created {new Date(cred.createdAt).toLocaleDateString()}</div>
|
||||
<div className="text-xs">
|
||||
Last used {new Date(cred.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OAuth accounts */}
|
||||
{user.oauthAccounts.length > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-sm font-medium text-kumo-subtle mb-3 flex items-center gap-2">
|
||||
<ArrowSquareOut className="h-4 w-4" aria-hidden="true" />
|
||||
Linked Accounts ({user.oauthAccounts.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{user.oauthAccounts.map((account, i) => (
|
||||
<div
|
||||
key={`${account.provider}-${i}`}
|
||||
className="flex justify-between text-sm"
|
||||
>
|
||||
<span className="capitalize">{account.provider}</span>
|
||||
<span className="text-kumo-subtle">
|
||||
Connected {new Date(account.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="text-center text-kumo-subtle py-8">User not found</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{user && (
|
||||
<div className="border-t px-6 py-4 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
form="user-edit-form"
|
||||
className="flex-1"
|
||||
disabled={!isDirty || isSaving}
|
||||
icon={<FloppyDisk />}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
{!isSelf && (
|
||||
<Button
|
||||
variant={user.disabled ? "outline" : "destructive"}
|
||||
onClick={user.disabled ? onEnable : onDisable}
|
||||
icon={user.disabled ? <CheckCircle /> : <Prohibit />}
|
||||
>
|
||||
{user.disabled ? "Enable" : "Disable"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!isSelf && onSendRecovery && (
|
||||
<div className="space-y-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onSendRecovery}
|
||||
disabled={isSendingRecovery}
|
||||
icon={<Envelope />}
|
||||
>
|
||||
{isSendingRecovery ? "Sending..." : "Send Recovery Link"}
|
||||
</Button>
|
||||
{recoverySent && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 text-center">
|
||||
Recovery link sent to {user.email}
|
||||
</p>
|
||||
)}
|
||||
{recoveryError && (
|
||||
<p className="text-xs text-kumo-danger text-center">{recoveryError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Loading skeleton for user detail */
|
||||
function UserDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
{/* Profile skeleton */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-kumo-tint" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-6 w-48 bg-kumo-tint rounded" />
|
||||
<div className="h-4 w-36 bg-kumo-tint rounded" />
|
||||
<div className="h-5 w-24 bg-kumo-tint rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards skeleton */}
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<div key={i} className="rounded-lg border p-4 space-y-3">
|
||||
<div className="h-4 w-24 bg-kumo-tint rounded" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-full bg-kumo-tint rounded" />
|
||||
<div className="h-4 w-full bg-kumo-tint rounded" />
|
||||
<div className="h-4 w-3/4 bg-kumo-tint rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
packages/admin/src/components/users/UserList.tsx
Normal file
245
packages/admin/src/components/users/UserList.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Button, Input, Loader, Select } from "@cloudflare/kumo";
|
||||
import { MagnifyingGlass, UserPlus, Prohibit, CheckCircle } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { UserListItem } from "../../lib/api";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { RoleBadge, ROLES } from "./RoleBadge";
|
||||
|
||||
export interface UserListProps {
|
||||
users: UserListItem[];
|
||||
isLoading?: boolean;
|
||||
hasMore?: boolean;
|
||||
searchQuery: string;
|
||||
roleFilter: number | undefined;
|
||||
onSearchChange: (query: string) => void;
|
||||
onRoleFilterChange: (role: number | undefined) => void;
|
||||
onSelectUser: (id: string) => void;
|
||||
onInviteUser: () => void;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* User list component with search, filter, and table display
|
||||
*/
|
||||
export function UserList({
|
||||
users,
|
||||
isLoading,
|
||||
hasMore,
|
||||
searchQuery,
|
||||
roleFilter,
|
||||
onSearchChange,
|
||||
onRoleFilterChange,
|
||||
onSelectUser,
|
||||
onInviteUser,
|
||||
onLoadMore,
|
||||
}: UserListProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Users</h1>
|
||||
<Button onClick={onInviteUser} icon={<UserPlus />}>
|
||||
Invite User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<MagnifyingGlass
|
||||
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-kumo-subtle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name or email..."
|
||||
className="pl-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
aria-label="Search users"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={roleFilter?.toString() ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
onRoleFilterChange(value === "all" || value === null ? undefined : parseInt(value, 10))
|
||||
}
|
||||
items={{
|
||||
all: "All roles",
|
||||
...Object.fromEntries(ROLES.map((r) => [r.value.toString(), r.label])),
|
||||
}}
|
||||
aria-label="Filter by role"
|
||||
>
|
||||
<Select.Option value="all">All roles</Select.Option>
|
||||
{ROLES.map((role) => (
|
||||
<Select.Option key={role.value} value={role.value.toString()}>
|
||||
{role.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-kumo-tint/50">
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
User
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Role
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Last Login
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium">
|
||||
Passkeys
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 && !isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle">
|
||||
{searchQuery || roleFilter !== undefined ? (
|
||||
<>
|
||||
No users found matching your filters.{" "}
|
||||
<button
|
||||
className="text-kumo-brand underline"
|
||||
onClick={() => {
|
||||
onSearchChange("");
|
||||
onRoleFilterChange(undefined);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
No users yet.{" "}
|
||||
<button className="text-kumo-brand underline" onClick={onInviteUser}>
|
||||
Invite your first team member
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<UserListRow key={user.id} user={user} onSelect={() => onSelectUser(user.id)} />
|
||||
))
|
||||
)}
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-2 text-kumo-subtle">
|
||||
<Loader size="sm" />
|
||||
Loading...
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && !isLoading && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={onLoadMore}>
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserListRowProps {
|
||||
user: UserListItem;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function UserListRow({ user, onSelect }: UserListRowProps) {
|
||||
const displayName = user.name || user.email;
|
||||
const lastLogin = user.lastLogin ? new Date(user.lastLogin).toLocaleDateString() : "Never";
|
||||
|
||||
return (
|
||||
<tr className="border-b hover:bg-kumo-tint/25 cursor-pointer" onClick={onSelect}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="h-8 w-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-kumo-tint flex items-center justify-center text-sm font-medium">
|
||||
{(user.name || user.email)?.[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{displayName}</div>
|
||||
{user.name && <div className="text-sm text-kumo-subtle">{user.email}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{user.disabled ? (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-kumo-danger">
|
||||
<Prohibit className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Disabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-kumo-subtle">{lastLogin}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn("text-sm", user.credentialCount === 0 && "text-kumo-subtle")}>
|
||||
{user.credentialCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** Loading skeleton for user list */
|
||||
export function UserListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-24 bg-kumo-tint animate-pulse rounded" />
|
||||
<div className="h-10 w-32 bg-kumo-tint animate-pulse rounded" />
|
||||
</div>
|
||||
|
||||
{/* Filters skeleton */}
|
||||
<div className="flex gap-4">
|
||||
<div className="h-10 w-64 bg-kumo-tint animate-pulse rounded" />
|
||||
<div className="h-10 w-44 bg-kumo-tint animate-pulse rounded" />
|
||||
</div>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b bg-kumo-tint/50 px-4 py-3">
|
||||
<div className="h-4 w-full bg-kumo-tint animate-pulse rounded" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="border-b px-4 py-4">
|
||||
<div className="h-8 w-full bg-kumo-tint animate-pulse rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
packages/admin/src/components/users/index.ts
Normal file
4
packages/admin/src/components/users/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { RoleBadge, ROLES, getRoleConfig, getRoleLabel } from "./RoleBadge";
|
||||
export { UserList, UserListSkeleton } from "./UserList";
|
||||
export { UserDetail } from "./UserDetail";
|
||||
export { InviteUserModal } from "./InviteUserModal";
|
||||
27
packages/admin/src/index.ts
Normal file
27
packages/admin/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Main App
|
||||
export { AdminApp, default as App } from "./App";
|
||||
|
||||
// Router
|
||||
export { createAdminRouter, Link, useNavigate, useParams } from "./router";
|
||||
|
||||
// Components
|
||||
export * from "./components";
|
||||
|
||||
// API client
|
||||
export * from "./lib/api";
|
||||
|
||||
// Utilities
|
||||
export { cn } from "./lib/utils";
|
||||
|
||||
// Plugin admin context (for accessing plugin components)
|
||||
export {
|
||||
PluginAdminProvider,
|
||||
usePluginAdmins,
|
||||
usePluginWidget,
|
||||
usePluginPage,
|
||||
usePluginField,
|
||||
usePluginHasPages,
|
||||
usePluginHasWidgets,
|
||||
type PluginAdminModule,
|
||||
type PluginAdmins,
|
||||
} from "./lib/plugin-context";
|
||||
8
packages/admin/src/lib/api.ts
Normal file
8
packages/admin/src/lib/api.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* API client for EmDash admin
|
||||
*
|
||||
* This file re-exports from the split API modules for backwards compatibility.
|
||||
* New code should import directly from the specific modules in ./api/
|
||||
*/
|
||||
|
||||
export * from "./api/index.js";
|
||||
87
packages/admin/src/lib/api/api-tokens.ts
Normal file
87
packages/admin/src/lib/api/api-tokens.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* API token management client functions
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/** API token info returned from the server */
|
||||
export interface ApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: string[];
|
||||
userId: string;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Result from creating a new token */
|
||||
export interface ApiTokenCreateResult {
|
||||
/** Raw token — shown once, never stored */
|
||||
token: string;
|
||||
/** Token metadata */
|
||||
info: ApiTokenInfo;
|
||||
}
|
||||
|
||||
/** Input for creating a new token */
|
||||
export interface CreateApiTokenInput {
|
||||
name: string;
|
||||
scopes: string[];
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/** Available scopes for API tokens */
|
||||
export const API_TOKEN_SCOPES = [
|
||||
{ value: "content:read", label: "Content Read", description: "Read content entries" },
|
||||
{ value: "content:write", label: "Content Write", description: "Create, update, delete content" },
|
||||
{ value: "media:read", label: "Media Read", description: "Read media files" },
|
||||
{ value: "media:write", label: "Media Write", description: "Upload and delete media" },
|
||||
{ value: "schema:read", label: "Schema Read", description: "Read collection schemas" },
|
||||
{ value: "schema:write", label: "Schema Write", description: "Modify collection schemas" },
|
||||
{ value: "admin", label: "Admin", description: "Full admin access" },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
// API Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all API tokens for the current user
|
||||
*/
|
||||
export async function fetchApiTokens(): Promise<ApiTokenInfo[]> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/api-tokens`);
|
||||
const result = await parseApiResponse<{ items: ApiTokenInfo[] }>(
|
||||
response,
|
||||
"Failed to fetch API tokens",
|
||||
);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API token
|
||||
*/
|
||||
export async function createApiToken(input: CreateApiTokenInput): Promise<ApiTokenCreateResult> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/api-tokens`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return parseApiResponse<ApiTokenCreateResult>(response, "Failed to create API token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) an API token
|
||||
*/
|
||||
export async function revokeApiToken(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/api-tokens/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) await throwResponseError(response, "Failed to revoke API token");
|
||||
}
|
||||
87
packages/admin/src/lib/api/bylines.ts
Normal file
87
packages/admin/src/lib/api/bylines.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
API_BASE,
|
||||
apiFetch,
|
||||
parseApiResponse,
|
||||
throwResponseError,
|
||||
type FindManyResult,
|
||||
} from "./client.js";
|
||||
|
||||
export interface BylineSummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio: string | null;
|
||||
avatarMediaId: string | null;
|
||||
websiteUrl: string | null;
|
||||
userId: string | null;
|
||||
isGuest: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BylineInput {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio?: string | null;
|
||||
avatarMediaId?: string | null;
|
||||
websiteUrl?: string | null;
|
||||
userId?: string | null;
|
||||
isGuest?: boolean;
|
||||
}
|
||||
|
||||
export interface BylineCreditInput {
|
||||
bylineId: string;
|
||||
roleLabel?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchBylines(options?: {
|
||||
search?: string;
|
||||
isGuest?: boolean;
|
||||
userId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}): Promise<FindManyResult<BylineSummary>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.search) params.set("search", options.search);
|
||||
if (options?.isGuest !== undefined) params.set("isGuest", String(options.isGuest));
|
||||
if (options?.userId) params.set("userId", options.userId);
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
|
||||
const url = `${API_BASE}/admin/bylines${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<BylineSummary>>(response, "Failed to fetch bylines");
|
||||
}
|
||||
|
||||
export async function fetchByline(id: string): Promise<BylineSummary> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`);
|
||||
return parseApiResponse<BylineSummary>(response, "Failed to fetch byline");
|
||||
}
|
||||
|
||||
export async function createByline(input: BylineInput): Promise<BylineSummary> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/bylines`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<BylineSummary>(response, "Failed to create byline");
|
||||
}
|
||||
|
||||
export async function updateByline(
|
||||
id: string,
|
||||
input: Partial<BylineInput>,
|
||||
): Promise<BylineSummary> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<BylineSummary>(response, "Failed to update byline");
|
||||
}
|
||||
|
||||
export async function deleteByline(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete byline");
|
||||
}
|
||||
160
packages/admin/src/lib/api/client.ts
Normal file
160
packages/admin/src/lib/api/client.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Base API client configuration and shared types
|
||||
*/
|
||||
|
||||
import type { Element } from "@emdashcms/blocks";
|
||||
|
||||
export const API_BASE = "/_emdash/api";
|
||||
|
||||
/**
|
||||
* Fetch wrapper that adds the X-EmDash-Request CSRF protection header
|
||||
* to all requests. All API calls should use this instead of raw fetch().
|
||||
*/
|
||||
export function apiFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("X-EmDash-Request", "1");
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an error with the message from the API response body if available,
|
||||
* falling back to a generic message. All API error responses use the shape
|
||||
* `{ error: { code, message } }`.
|
||||
*/
|
||||
export async function throwResponseError(res: Response, fallback: string): Promise<never> {
|
||||
const body: unknown = await res.json().catch(() => ({}));
|
||||
let message: string | undefined;
|
||||
if (typeof body === "object" && body !== null && "error" in body) {
|
||||
const { error } = body;
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
const { message: msg } = error;
|
||||
if (typeof msg === "string") message = msg;
|
||||
}
|
||||
}
|
||||
throw new Error(message || `${fallback}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic paginated result
|
||||
*/
|
||||
export interface FindManyResult<T> {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin manifest describing available collections and plugins
|
||||
*/
|
||||
export interface AdminManifest {
|
||||
version: string;
|
||||
hash: string;
|
||||
collections: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
labelSingular: string;
|
||||
supports: string[];
|
||||
hasSeo: boolean;
|
||||
fields: Record<
|
||||
string,
|
||||
{
|
||||
kind: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
widget?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>;
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
name?: string;
|
||||
version?: string;
|
||||
/** Package name for dynamic import (e.g., "@emdashcms/plugin-audit-log") */
|
||||
package?: string;
|
||||
/** Whether the plugin is enabled */
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* How this plugin renders its admin UI:
|
||||
* - "react": Trusted plugin with React components
|
||||
* - "blocks": Declarative Block Kit UI via admin route handler
|
||||
* - "none": No admin UI
|
||||
*/
|
||||
adminMode?: "react" | "blocks" | "none";
|
||||
adminPages?: Array<{
|
||||
path: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
dashboardWidgets?: Array<{
|
||||
id: string;
|
||||
title?: string;
|
||||
size?: "full" | "half" | "third";
|
||||
}>;
|
||||
fieldWidgets?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
fieldTypes: string[];
|
||||
elements?: import("@emdashcms/blocks").Element[];
|
||||
}>;
|
||||
/** Block types for Portable Text editor */
|
||||
portableTextBlocks?: Array<{
|
||||
type: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
fields?: Element[];
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
* Auth mode for the admin UI. When "passkey", the security settings
|
||||
* (passkey management, self-signup domains) are shown. When using
|
||||
* external auth (e.g., "cloudflare-access"), these are hidden since
|
||||
* authentication is handled externally.
|
||||
*/
|
||||
authMode: string;
|
||||
/**
|
||||
* Whether self-signup is enabled (at least one allowed domain is active).
|
||||
* Used by the login page to conditionally show the "Sign up" link.
|
||||
*/
|
||||
signupEnabled?: boolean;
|
||||
/**
|
||||
* i18n configuration. Present when multiple locales are configured.
|
||||
*/
|
||||
i18n?: {
|
||||
defaultLocale: string;
|
||||
locales: string[];
|
||||
};
|
||||
/**
|
||||
* Marketplace registry URL. Present when `marketplace` is configured
|
||||
* in the EmDash integration. Enables marketplace features in the UI.
|
||||
*/
|
||||
marketplace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an API response with the { data: T } envelope.
|
||||
*
|
||||
* Handles error responses via throwResponseError, then unwraps the data envelope.
|
||||
* Replaces both bare `response.json()` and field-unwrap patterns.
|
||||
*/
|
||||
export async function parseApiResponse<T>(
|
||||
response: Response,
|
||||
fallbackMessage = "Request failed",
|
||||
): Promise<T> {
|
||||
if (!response.ok) await throwResponseError(response, fallbackMessage);
|
||||
const body: { data: T } = await response.json();
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch admin manifest
|
||||
*/
|
||||
export async function fetchManifest(): Promise<AdminManifest> {
|
||||
const response = await apiFetch(`${API_BASE}/manifest`);
|
||||
return parseApiResponse<AdminManifest>(response, "Failed to fetch manifest");
|
||||
}
|
||||
124
packages/admin/src/lib/api/comments.ts
Normal file
124
packages/admin/src/lib/api/comments.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Comment moderation API client
|
||||
*/
|
||||
|
||||
import {
|
||||
API_BASE,
|
||||
apiFetch,
|
||||
parseApiResponse,
|
||||
throwResponseError,
|
||||
type FindManyResult,
|
||||
} from "./client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CommentStatus = "pending" | "approved" | "spam" | "trash";
|
||||
|
||||
export interface AdminComment {
|
||||
id: string;
|
||||
collection: string;
|
||||
contentId: string;
|
||||
parentId: string | null;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
authorUserId: string | null;
|
||||
body: string;
|
||||
status: CommentStatus;
|
||||
ipHash: string | null;
|
||||
userAgent: string | null;
|
||||
moderationMetadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CommentCounts = Record<CommentStatus, number>;
|
||||
|
||||
export type BulkAction = "approve" | "spam" | "trash" | "delete";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch comments for the moderation inbox
|
||||
*/
|
||||
export async function fetchComments(options?: {
|
||||
status?: CommentStatus;
|
||||
collection?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<FindManyResult<AdminComment>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.status) params.set("status", options.status);
|
||||
if (options?.collection) params.set("collection", options.collection);
|
||||
if (options?.search) params.set("search", options.search);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
|
||||
const url = `${API_BASE}/admin/comments${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<AdminComment>>(response, "Failed to fetch comments");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch comment status counts for inbox badges
|
||||
*/
|
||||
export async function fetchCommentCounts(): Promise<CommentCounts> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/counts`);
|
||||
return parseApiResponse<CommentCounts>(response, "Failed to fetch comment counts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single comment by ID
|
||||
*/
|
||||
export async function fetchComment(id: string): Promise<AdminComment> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/${id}`);
|
||||
return parseApiResponse<AdminComment>(response, "Failed to fetch comment");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update a comment's status
|
||||
*/
|
||||
export async function updateCommentStatus(
|
||||
id: string,
|
||||
status: CommentStatus,
|
||||
): Promise<AdminComment> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/${id}/status`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
return parseApiResponse<AdminComment>(response, "Failed to update comment status");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete a comment (ADMIN only)
|
||||
*/
|
||||
export async function deleteComment(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete comment");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk status change or delete
|
||||
*/
|
||||
export async function bulkCommentAction(
|
||||
ids: string[],
|
||||
action: BulkAction,
|
||||
): Promise<{ affected: number }> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/bulk`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids, action }),
|
||||
});
|
||||
return parseApiResponse<{ affected: number }>(response, "Failed to perform bulk action");
|
||||
}
|
||||
477
packages/admin/src/lib/api/content.ts
Normal file
477
packages/admin/src/lib/api/content.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Content CRUD and revision APIs
|
||||
*/
|
||||
|
||||
import type { BylineCreditInput, BylineSummary } from "./bylines.js";
|
||||
import {
|
||||
API_BASE,
|
||||
apiFetch,
|
||||
parseApiResponse,
|
||||
throwResponseError,
|
||||
type FindManyResult,
|
||||
} from "./client.js";
|
||||
|
||||
/**
|
||||
* Derive draft status from a content item's revision pointers
|
||||
*/
|
||||
export function getDraftStatus(
|
||||
item: ContentItem,
|
||||
): "unpublished" | "published" | "published_with_changes" {
|
||||
if (!item.liveRevisionId) return "unpublished";
|
||||
if (item.draftRevisionId && item.draftRevisionId !== item.liveRevisionId)
|
||||
return "published_with_changes";
|
||||
return "published";
|
||||
}
|
||||
|
||||
/** SEO metadata for a content item */
|
||||
export interface ContentSeo {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
canonical: string | null;
|
||||
noIndex: boolean;
|
||||
}
|
||||
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
type: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
locale: string;
|
||||
translationGroup: string | null;
|
||||
data: Record<string, unknown>;
|
||||
authorId: string | null;
|
||||
primaryBylineId: string | null;
|
||||
byline?: BylineSummary | null;
|
||||
bylines?: Array<{
|
||||
byline: BylineSummary;
|
||||
sortOrder: number;
|
||||
roleLabel: string | null;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt: string | null;
|
||||
scheduledAt: string | null;
|
||||
liveRevisionId: string | null;
|
||||
draftRevisionId: string | null;
|
||||
seo?: ContentSeo;
|
||||
}
|
||||
|
||||
export interface CreateContentInput {
|
||||
type: string;
|
||||
slug?: string;
|
||||
data: Record<string, unknown>;
|
||||
status?: string;
|
||||
bylines?: BylineCreditInput[];
|
||||
locale?: string;
|
||||
translationOf?: string;
|
||||
}
|
||||
|
||||
export interface TranslationSummary {
|
||||
id: string;
|
||||
locale: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TranslationsResponse {
|
||||
translationGroup: string;
|
||||
translations: TranslationSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch translations for a content item
|
||||
*/
|
||||
export async function fetchTranslations(
|
||||
collection: string,
|
||||
id: string,
|
||||
): Promise<TranslationsResponse> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/translations`);
|
||||
return parseApiResponse<TranslationsResponse>(response, "Failed to fetch translations");
|
||||
}
|
||||
|
||||
/** Input for updating SEO fields on content */
|
||||
export interface ContentSeoInput {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
image?: string | null;
|
||||
canonical?: string | null;
|
||||
noIndex?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateContentInput {
|
||||
data?: Record<string, unknown>;
|
||||
slug?: string;
|
||||
status?: string;
|
||||
authorId?: string | null;
|
||||
bylines?: BylineCreditInput[];
|
||||
/** Skip revision creation (used by autosave) */
|
||||
skipRevision?: boolean;
|
||||
seo?: ContentSeoInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trashed content item with deletion timestamp
|
||||
*/
|
||||
export interface TrashedContentItem extends ContentItem {
|
||||
deletedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview URL response
|
||||
*/
|
||||
export interface PreviewUrlResponse {
|
||||
url: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch content list
|
||||
*/
|
||||
export async function fetchContentList(
|
||||
collection: string,
|
||||
options?: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
locale?: string;
|
||||
},
|
||||
): Promise<FindManyResult<ContentItem>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.status) params.set("status", options.status);
|
||||
if (options?.locale) params.set("locale", options.locale);
|
||||
|
||||
const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<ContentItem>>(response, "Failed to fetch content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single content item
|
||||
*/
|
||||
export async function fetchContent(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`);
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to fetch content");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create content
|
||||
*/
|
||||
export async function createContent(
|
||||
collection: string,
|
||||
input: Omit<CreateContentInput, "type">,
|
||||
): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
data: input.data,
|
||||
slug: input.slug,
|
||||
status: input.status,
|
||||
bylines: input.bylines,
|
||||
locale: input.locale,
|
||||
translationOf: input.translationOf,
|
||||
}),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to create content");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content
|
||||
*/
|
||||
export async function updateContent(
|
||||
collection: string,
|
||||
id: string,
|
||||
input: UpdateContentInput,
|
||||
): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to update content");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete content (moves to trash)
|
||||
*/
|
||||
export async function deleteContent(collection: string, id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch trashed content list
|
||||
*/
|
||||
export async function fetchTrashedContent(
|
||||
collection: string,
|
||||
options?: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<FindManyResult<TrashedContentItem>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
|
||||
const url = `${API_BASE}/content/${collection}/trash${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<TrashedContentItem>>(
|
||||
response,
|
||||
"Failed to fetch trashed content",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore content from trash
|
||||
*/
|
||||
export async function restoreContent(collection: string, id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/restore`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to restore content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete content (cannot be undone)
|
||||
*/
|
||||
export async function permanentDeleteContent(collection: string, id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/permanent`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to permanently delete content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate content (creates a draft copy)
|
||||
*/
|
||||
export async function duplicateContent(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/duplicate`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(
|
||||
response,
|
||||
"Failed to duplicate content",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule content for future publishing
|
||||
*/
|
||||
export async function scheduleContent(
|
||||
collection: string,
|
||||
id: string,
|
||||
scheduledAt: string,
|
||||
): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scheduledAt }),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(
|
||||
response,
|
||||
"Failed to schedule content",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unschedule content (revert to draft)
|
||||
*/
|
||||
export async function unscheduleContent(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(
|
||||
response,
|
||||
"Failed to unschedule content",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preview URL for content
|
||||
*
|
||||
* Returns a signed URL that allows viewing draft content.
|
||||
* Returns null if preview is not configured (missing EMDASH_PREVIEW_SECRET).
|
||||
*/
|
||||
export async function getPreviewUrl(
|
||||
collection: string,
|
||||
id: string,
|
||||
options?: {
|
||||
expiresIn?: string;
|
||||
pathPattern?: string;
|
||||
},
|
||||
): Promise<PreviewUrlResponse | null> {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/preview-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(options || {}),
|
||||
});
|
||||
|
||||
if (response.status === 500) {
|
||||
// Preview not configured — check error code without consuming body for parseApiResponse
|
||||
const body: unknown = await response.json().catch(() => ({}));
|
||||
if (
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"error" in body &&
|
||||
typeof body.error === "object" &&
|
||||
body.error !== null &&
|
||||
"code" in body.error &&
|
||||
body.error.code === "NOT_CONFIGURED"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Some other 500 error
|
||||
throw new Error("Failed to get preview URL");
|
||||
}
|
||||
|
||||
return parseApiResponse<PreviewUrlResponse>(response, "Failed to get preview URL");
|
||||
} catch {
|
||||
// If preview endpoint doesn't exist or fails, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Publishing (Draft Revisions)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Publish content - promotes current draft to live
|
||||
*/
|
||||
export async function publishContent(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to publish content");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish content - removes from public, preserves draft
|
||||
*/
|
||||
export async function unpublishContent(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(
|
||||
response,
|
||||
"Failed to unpublish content",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard draft changes - reverts to live version
|
||||
*/
|
||||
export async function discardDraft(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to discard draft");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare live and draft revisions
|
||||
*/
|
||||
export async function compareRevisions(
|
||||
collection: string,
|
||||
id: string,
|
||||
): Promise<{
|
||||
hasChanges: boolean;
|
||||
live: Record<string, unknown> | null;
|
||||
draft: Record<string, unknown> | null;
|
||||
}> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/compare`);
|
||||
return parseApiResponse<{
|
||||
hasChanges: boolean;
|
||||
live: Record<string, unknown> | null;
|
||||
draft: Record<string, unknown> | null;
|
||||
}>(response, "Failed to compare revisions");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Revision API
|
||||
// =============================================================================
|
||||
|
||||
export interface Revision {
|
||||
id: string;
|
||||
collection: string;
|
||||
entryId: string;
|
||||
data: Record<string, unknown>;
|
||||
authorId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RevisionListResponse {
|
||||
items: Revision[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch revisions for a content item
|
||||
*/
|
||||
export async function fetchRevisions(
|
||||
collection: string,
|
||||
entryId: string,
|
||||
options?: { limit?: number },
|
||||
): Promise<RevisionListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
|
||||
const url = `${API_BASE}/content/${collection}/${entryId}/revisions${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<RevisionListResponse>(response, "Failed to fetch revisions");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific revision
|
||||
*/
|
||||
export async function fetchRevision(revisionId: string): Promise<Revision> {
|
||||
const response = await apiFetch(`${API_BASE}/revisions/${revisionId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Revision not found: ${revisionId}`);
|
||||
}
|
||||
await throwResponseError(response, "Failed to fetch revision");
|
||||
}
|
||||
|
||||
const data = await parseApiResponse<{ item: Revision }>(response, "Failed to fetch revision");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a revision (updates content to this revision's data)
|
||||
*/
|
||||
export async function restoreRevision(revisionId: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/revisions/${revisionId}/restore`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Revision not found: ${revisionId}`);
|
||||
}
|
||||
await throwResponseError(response, "Failed to restore revision");
|
||||
}
|
||||
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(
|
||||
response,
|
||||
"Failed to restore revision",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
30
packages/admin/src/lib/api/current-user.ts
Normal file
30
packages/admin/src/lib/api/current-user.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Current user query — shared across Shell, Header, Sidebar, and CommandPalette.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role: number;
|
||||
avatarUrl?: string;
|
||||
isFirstLogin?: boolean;
|
||||
}
|
||||
|
||||
async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||
const response = await apiFetch("/_emdash/api/auth/me");
|
||||
return parseApiResponse<CurrentUser>(response, "Failed to fetch user");
|
||||
}
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: fetchCurrentUser,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
39
packages/admin/src/lib/api/dashboard.ts
Normal file
39
packages/admin/src/lib/api/dashboard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Dashboard stats API
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
export interface CollectionStats {
|
||||
slug: string;
|
||||
label: string;
|
||||
total: number;
|
||||
published: number;
|
||||
draft: number;
|
||||
}
|
||||
|
||||
export interface RecentItem {
|
||||
id: string;
|
||||
collection: string;
|
||||
collectionLabel: string;
|
||||
title: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
authorId: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
collections: CollectionStats[];
|
||||
mediaCount: number;
|
||||
userCount: number;
|
||||
recentItems: RecentItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch dashboard statistics
|
||||
*/
|
||||
export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
const response = await apiFetch(`${API_BASE}/dashboard`);
|
||||
return parseApiResponse<DashboardStats>(response, "Failed to fetch dashboard stats");
|
||||
}
|
||||
41
packages/admin/src/lib/api/email-settings.ts
Normal file
41
packages/admin/src/lib/api/email-settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Email settings API client functions
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface EmailProvider {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface EmailSettings {
|
||||
available: boolean;
|
||||
providers: EmailProvider[];
|
||||
selectedProviderId: string | null;
|
||||
middleware: {
|
||||
beforeSend: string[];
|
||||
afterSend: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API functions
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchEmailSettings(): Promise<EmailSettings> {
|
||||
const res = await apiFetch(`${API_BASE}/settings/email`);
|
||||
return parseApiResponse<EmailSettings>(res, "Failed to fetch email settings");
|
||||
}
|
||||
|
||||
export async function sendTestEmail(to: string): Promise<{ success: boolean; message: string }> {
|
||||
const res = await apiFetch(`${API_BASE}/settings/email`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ to }),
|
||||
});
|
||||
return parseApiResponse<{ success: boolean; message: string }>(res, "Failed to send test email");
|
||||
}
|
||||
465
packages/admin/src/lib/api/import.ts
Normal file
465
packages/admin/src/lib/api/import.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* WordPress import and source probing APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
// =============================================================================
|
||||
// WordPress Import API
|
||||
// =============================================================================
|
||||
|
||||
/** Field compatibility status */
|
||||
export type FieldCompatibility =
|
||||
| "compatible" // Field exists with compatible type
|
||||
| "type_mismatch" // Field exists but type differs
|
||||
| "missing"; // Field doesn't exist
|
||||
|
||||
/** Single field definition for import */
|
||||
export interface ImportFieldDef {
|
||||
slug: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** Schema status for a collection */
|
||||
export interface CollectionSchemaStatus {
|
||||
exists: boolean;
|
||||
fieldStatus: Record<
|
||||
string,
|
||||
{
|
||||
status: FieldCompatibility;
|
||||
existingType?: string;
|
||||
requiredType: string;
|
||||
}
|
||||
>;
|
||||
canImport: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Post type with full schema info */
|
||||
export interface PostTypeAnalysis {
|
||||
name: string;
|
||||
count: number;
|
||||
suggestedCollection: string;
|
||||
requiredFields: ImportFieldDef[];
|
||||
schemaStatus: CollectionSchemaStatus;
|
||||
}
|
||||
|
||||
/** Individual attachment info for media import */
|
||||
export interface AttachmentInfo {
|
||||
id?: number;
|
||||
title?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/** Navigation menu from WordPress */
|
||||
export interface NavMenu {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Custom taxonomy from WordPress */
|
||||
export interface CustomTaxonomy {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
hierarchical: boolean;
|
||||
}
|
||||
|
||||
/** Author info from WordPress */
|
||||
export interface WpAuthorInfo {
|
||||
id?: number;
|
||||
login?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
export interface WxrAnalysis {
|
||||
site: {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
postTypes: PostTypeAnalysis[];
|
||||
attachments: {
|
||||
count: number;
|
||||
items: AttachmentInfo[];
|
||||
};
|
||||
categories: number;
|
||||
tags: number;
|
||||
authors: WpAuthorInfo[];
|
||||
customFields: Array<{
|
||||
key: string;
|
||||
count: number;
|
||||
samples: string[];
|
||||
suggestedField: string;
|
||||
suggestedType: string;
|
||||
isInternal: boolean;
|
||||
}>;
|
||||
/** Navigation menus found in the export */
|
||||
navMenus?: NavMenu[];
|
||||
/** Custom taxonomies found in the export */
|
||||
customTaxonomies?: CustomTaxonomy[];
|
||||
}
|
||||
|
||||
export interface PrepareRequest {
|
||||
postTypes: Array<{
|
||||
name: string;
|
||||
collection: string;
|
||||
fields: ImportFieldDef[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PrepareResult {
|
||||
success: boolean;
|
||||
collectionsCreated: string[];
|
||||
fieldsCreated: Array<{ collection: string; field: string }>;
|
||||
errors: Array<{ collection: string; error: string }>;
|
||||
}
|
||||
|
||||
/** Author mapping from WP author login to EmDash user ID */
|
||||
export interface AuthorMapping {
|
||||
/** WordPress author login */
|
||||
wpLogin: string;
|
||||
/** WordPress author display name (for UI) */
|
||||
wpDisplayName: string;
|
||||
/** WordPress author email (for matching) */
|
||||
wpEmail?: string;
|
||||
/** EmDash user ID to assign (null = leave unassigned) */
|
||||
emdashUserId: string | null;
|
||||
/** Number of posts by this author */
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
export interface ImportConfig {
|
||||
postTypeMappings: Record<
|
||||
string,
|
||||
{
|
||||
collection: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
>;
|
||||
skipExisting: boolean;
|
||||
/** Author mappings (WP author login -> EmDash user ID) */
|
||||
authorMappings?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: Array<{ title: string; error: string }>;
|
||||
byCollection: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a WordPress WXR file
|
||||
*/
|
||||
export async function analyzeWxr(file: File): Promise<WxrAnalysis> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/analyze`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return parseApiResponse<WxrAnalysis>(response, "Failed to analyze file");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare WordPress import (create collections/fields)
|
||||
*/
|
||||
export async function prepareWxrImport(request: PrepareRequest): Promise<PrepareResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/prepare`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
return parseApiResponse<PrepareResult>(response, "Failed to prepare import");
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute WordPress import
|
||||
*/
|
||||
export async function executeWxrImport(file: File, config: ImportConfig): Promise<ImportResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("config", JSON.stringify(config));
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/execute`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return parseApiResponse<ImportResult>(response, "Failed to import");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Media Import API
|
||||
// =============================================================================
|
||||
|
||||
export interface MediaImportResult {
|
||||
imported: Array<{
|
||||
wpId?: number;
|
||||
originalUrl: string;
|
||||
newUrl: string;
|
||||
mediaId: string;
|
||||
}>;
|
||||
failed: Array<{
|
||||
wpId?: number;
|
||||
originalUrl: string;
|
||||
error: string;
|
||||
}>;
|
||||
urlMap: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Progress update sent during streaming media import */
|
||||
export interface MediaImportProgress {
|
||||
type: "progress";
|
||||
current: number;
|
||||
total: number;
|
||||
filename?: string;
|
||||
status: "downloading" | "uploading" | "done" | "skipped" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RewriteUrlsResult {
|
||||
updated: number;
|
||||
byCollection: Record<string, number>;
|
||||
urlsRewritten: number;
|
||||
errors: Array<{ collection: string; id: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import media from WordPress with streaming progress
|
||||
*
|
||||
* @param attachments - Array of attachments to import
|
||||
* @param onProgress - Callback for progress updates (optional)
|
||||
* @returns Final import result
|
||||
*/
|
||||
export async function importWxrMedia(
|
||||
attachments: AttachmentInfo[],
|
||||
onProgress?: (progress: MediaImportProgress) => void,
|
||||
): Promise<MediaImportResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/media`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ attachments, stream: !!onProgress }),
|
||||
});
|
||||
|
||||
if (!response.ok) await throwResponseError(response, "Failed to import media");
|
||||
|
||||
// If no progress callback, just parse as JSON (non-streaming mode)
|
||||
// Note: streaming NDJSON responses are excluded from the { data } envelope
|
||||
if (!onProgress) {
|
||||
return parseApiResponse<MediaImportResult>(response, "Failed to import media");
|
||||
}
|
||||
|
||||
// Streaming mode: read NDJSON line by line
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result: MediaImportResult | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const parsed: { type?: string; imported?: unknown } = JSON.parse(line);
|
||||
if (parsed.type === "progress") {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "progress"
|
||||
onProgress(parsed as MediaImportProgress);
|
||||
} else if (parsed.type === "result" || parsed.imported) {
|
||||
// Final result (has type: "result" or is the result object)
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "result"
|
||||
result = parsed as MediaImportResult;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors for incomplete JSON
|
||||
console.warn("Failed to parse NDJSON line:", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed: { type?: string; imported?: unknown } = JSON.parse(buffer);
|
||||
if (parsed.type === "result" || parsed.imported) {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "result"
|
||||
result = parsed as MediaImportResult;
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to parse final NDJSON:", buffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error("No result received from media import");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Import Source Probing
|
||||
// =============================================================================
|
||||
|
||||
/** Capabilities of an import source */
|
||||
export interface SourceCapabilities {
|
||||
publicContent: boolean;
|
||||
privateContent: boolean;
|
||||
customPostTypes: boolean;
|
||||
allMeta: boolean;
|
||||
mediaStream: boolean;
|
||||
}
|
||||
|
||||
/** Auth requirements for import */
|
||||
export interface SourceAuth {
|
||||
type: "oauth" | "token" | "password" | "none";
|
||||
provider?: string;
|
||||
oauthUrl?: string;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
/** Suggested action after probing */
|
||||
export type SuggestedAction =
|
||||
| { type: "proceed" }
|
||||
| { type: "oauth"; url: string; provider: string }
|
||||
| { type: "upload"; instructions: string }
|
||||
| { type: "install-plugin"; instructions: string };
|
||||
|
||||
/** Result from probing a single source */
|
||||
export interface SourceProbeResult {
|
||||
sourceId: string;
|
||||
confidence: "definite" | "likely" | "possible";
|
||||
detected: {
|
||||
platform: string;
|
||||
version?: string;
|
||||
siteTitle?: string;
|
||||
siteUrl?: string;
|
||||
};
|
||||
capabilities: SourceCapabilities;
|
||||
auth?: SourceAuth;
|
||||
suggestedAction: SuggestedAction;
|
||||
preview?: {
|
||||
posts?: number;
|
||||
pages?: number;
|
||||
media?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Combined probe result */
|
||||
export interface ProbeResult {
|
||||
url: string;
|
||||
isWordPress: boolean;
|
||||
bestMatch: SourceProbeResult | null;
|
||||
allMatches: SourceProbeResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a URL to detect import source
|
||||
*/
|
||||
export async function probeImportUrl(url: string): Promise<ProbeResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/probe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const data = await parseApiResponse<{ result: ProbeResult }>(response, "Failed to probe URL");
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite URLs in content after media import
|
||||
*/
|
||||
export async function rewriteContentUrls(
|
||||
urlMap: Record<string, string>,
|
||||
collections?: string[],
|
||||
): Promise<RewriteUrlsResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/rewrite-urls`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ urlMap, collections }),
|
||||
});
|
||||
return parseApiResponse<RewriteUrlsResult>(response, "Failed to rewrite URLs");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WordPress Plugin Direct Import API
|
||||
// =============================================================================
|
||||
|
||||
/** WordPress Plugin analysis result */
|
||||
export interface WpPluginAnalysis {
|
||||
sourceId: string;
|
||||
site: {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
postTypes: PostTypeAnalysis[];
|
||||
attachments: {
|
||||
count: number;
|
||||
items: AttachmentInfo[];
|
||||
};
|
||||
categories: number;
|
||||
tags: number;
|
||||
authors: WpAuthorInfo[];
|
||||
/** Navigation menus found via the plugin */
|
||||
navMenus?: NavMenu[];
|
||||
/** Custom taxonomies found via the plugin */
|
||||
customTaxonomies?: CustomTaxonomy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a WordPress site with EmDash Exporter plugin
|
||||
*/
|
||||
export async function analyzeWpPluginSite(url: string, token: string): Promise<WpPluginAnalysis> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress-plugin/analyze`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, token }),
|
||||
});
|
||||
const data = await parseApiResponse<{ analysis: WpPluginAnalysis }>(
|
||||
response,
|
||||
"Failed to analyze WordPress site",
|
||||
);
|
||||
return data.analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute import from WordPress plugin API
|
||||
*/
|
||||
export async function executeWpPluginImport(
|
||||
url: string,
|
||||
token: string,
|
||||
config: ImportConfig,
|
||||
): Promise<ImportResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress-plugin/execute`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, token, config }),
|
||||
});
|
||||
const data = await parseApiResponse<{ result: ImportResult }>(
|
||||
response,
|
||||
"Failed to import from WordPress",
|
||||
);
|
||||
return data.result;
|
||||
}
|
||||
352
packages/admin/src/lib/api/index.ts
Normal file
352
packages/admin/src/lib/api/index.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* API client for EmDash admin
|
||||
*
|
||||
* Re-exports all API modules for backwards compatibility.
|
||||
*/
|
||||
|
||||
// Base client and shared types
|
||||
export {
|
||||
API_BASE,
|
||||
apiFetch,
|
||||
parseApiResponse,
|
||||
throwResponseError,
|
||||
type FindManyResult,
|
||||
type AdminManifest,
|
||||
fetchManifest,
|
||||
} from "./client.js";
|
||||
|
||||
// Content CRUD and revisions
|
||||
export {
|
||||
type ContentSeo,
|
||||
type ContentSeoInput,
|
||||
type ContentItem,
|
||||
type CreateContentInput,
|
||||
type UpdateContentInput,
|
||||
type TrashedContentItem,
|
||||
type PreviewUrlResponse,
|
||||
type Revision,
|
||||
type RevisionListResponse,
|
||||
type TranslationSummary,
|
||||
type TranslationsResponse,
|
||||
getDraftStatus,
|
||||
fetchContentList,
|
||||
fetchContent,
|
||||
fetchTranslations,
|
||||
createContent,
|
||||
updateContent,
|
||||
deleteContent,
|
||||
fetchTrashedContent,
|
||||
restoreContent,
|
||||
permanentDeleteContent,
|
||||
duplicateContent,
|
||||
scheduleContent,
|
||||
unscheduleContent,
|
||||
getPreviewUrl,
|
||||
publishContent,
|
||||
unpublishContent,
|
||||
discardDraft,
|
||||
compareRevisions,
|
||||
fetchRevisions,
|
||||
fetchRevision,
|
||||
restoreRevision,
|
||||
} from "./content.js";
|
||||
|
||||
// Media
|
||||
export {
|
||||
type MediaItem,
|
||||
type MediaProviderCapabilities,
|
||||
type MediaProviderInfo,
|
||||
type MediaProviderItem,
|
||||
fetchMediaList,
|
||||
uploadMedia,
|
||||
deleteMedia,
|
||||
updateMedia,
|
||||
fetchMediaProviders,
|
||||
fetchProviderMedia,
|
||||
uploadToProvider,
|
||||
deleteFromProvider,
|
||||
} from "./media.js";
|
||||
|
||||
// Schema (Content Type Builder)
|
||||
export {
|
||||
type FieldType,
|
||||
type SchemaCollection,
|
||||
type SchemaField,
|
||||
type SchemaCollectionWithFields,
|
||||
type CreateCollectionInput,
|
||||
type UpdateCollectionInput,
|
||||
type CreateFieldInput,
|
||||
type UpdateFieldInput,
|
||||
type OrphanedTable,
|
||||
fetchCollections,
|
||||
fetchCollection,
|
||||
createCollection,
|
||||
updateCollection,
|
||||
deleteCollection,
|
||||
fetchFields,
|
||||
createField,
|
||||
updateField,
|
||||
deleteField,
|
||||
reorderFields,
|
||||
fetchOrphanedTables,
|
||||
registerOrphanedTable,
|
||||
} from "./schema.js";
|
||||
|
||||
// Plugins
|
||||
export {
|
||||
type PluginInfo,
|
||||
fetchPlugins,
|
||||
fetchPlugin,
|
||||
enablePlugin,
|
||||
disablePlugin,
|
||||
} from "./plugins.js";
|
||||
|
||||
// Settings
|
||||
export { type SiteSettings, fetchSettings, updateSettings } from "./settings.js";
|
||||
|
||||
// Users, passkeys, allowed domains
|
||||
export {
|
||||
type UserListItem,
|
||||
type UserDetail,
|
||||
type UpdateUserInput,
|
||||
type PasskeyInfo,
|
||||
type AllowedDomain,
|
||||
type CreateAllowedDomainInput,
|
||||
type UpdateAllowedDomainInput,
|
||||
type SignupVerifyResult,
|
||||
fetchUsers,
|
||||
fetchUser,
|
||||
updateUser,
|
||||
sendRecoveryLink,
|
||||
disableUser,
|
||||
enableUser,
|
||||
inviteUser,
|
||||
fetchPasskeys,
|
||||
renamePasskey,
|
||||
deletePasskey,
|
||||
fetchAllowedDomains,
|
||||
createAllowedDomain,
|
||||
updateAllowedDomain,
|
||||
deleteAllowedDomain,
|
||||
requestSignup,
|
||||
verifySignupToken,
|
||||
completeSignup,
|
||||
hasAllowedDomains,
|
||||
} from "./users.js";
|
||||
|
||||
// Bylines
|
||||
export {
|
||||
type BylineSummary,
|
||||
type BylineInput,
|
||||
type BylineCreditInput,
|
||||
fetchBylines,
|
||||
fetchByline,
|
||||
createByline,
|
||||
updateByline,
|
||||
deleteByline,
|
||||
} from "./bylines.js";
|
||||
|
||||
// Menus
|
||||
export {
|
||||
type Menu,
|
||||
type MenuItem,
|
||||
type MenuWithItems,
|
||||
type CreateMenuInput,
|
||||
type UpdateMenuInput,
|
||||
type CreateMenuItemInput,
|
||||
type UpdateMenuItemInput,
|
||||
type ReorderMenuItemsInput,
|
||||
fetchMenus,
|
||||
fetchMenu,
|
||||
createMenu,
|
||||
updateMenu,
|
||||
deleteMenu,
|
||||
createMenuItem,
|
||||
updateMenuItem,
|
||||
deleteMenuItem,
|
||||
reorderMenuItems,
|
||||
} from "./menus.js";
|
||||
|
||||
// Widget areas
|
||||
export {
|
||||
type WidgetArea,
|
||||
type Widget,
|
||||
type WidgetComponent,
|
||||
type CreateWidgetAreaInput,
|
||||
type CreateWidgetInput,
|
||||
type UpdateWidgetInput,
|
||||
fetchWidgetAreas,
|
||||
fetchWidgetArea,
|
||||
createWidgetArea,
|
||||
deleteWidgetArea,
|
||||
createWidget,
|
||||
updateWidget,
|
||||
deleteWidget,
|
||||
reorderWidgets,
|
||||
fetchWidgetComponents,
|
||||
} from "./widgets.js";
|
||||
|
||||
// Sections
|
||||
export {
|
||||
type SectionSource,
|
||||
type Section,
|
||||
type SectionsResult,
|
||||
type CreateSectionInput,
|
||||
type UpdateSectionInput,
|
||||
type GetSectionsOptions,
|
||||
fetchSections,
|
||||
fetchSection,
|
||||
createSection,
|
||||
updateSection,
|
||||
deleteSection,
|
||||
} from "./sections.js";
|
||||
|
||||
// Taxonomies
|
||||
export {
|
||||
type TaxonomyTerm,
|
||||
type TaxonomyDef,
|
||||
type CreateTaxonomyInput,
|
||||
type CreateTermInput,
|
||||
type UpdateTermInput,
|
||||
fetchTaxonomyDefs,
|
||||
fetchTaxonomyDef,
|
||||
fetchTerms,
|
||||
createTaxonomy,
|
||||
createTerm,
|
||||
updateTerm,
|
||||
deleteTerm,
|
||||
} from "./taxonomies.js";
|
||||
|
||||
// WordPress import
|
||||
export {
|
||||
type FieldCompatibility,
|
||||
type ImportFieldDef,
|
||||
type CollectionSchemaStatus,
|
||||
type PostTypeAnalysis,
|
||||
type AttachmentInfo,
|
||||
type NavMenu,
|
||||
type CustomTaxonomy,
|
||||
type WpAuthorInfo,
|
||||
type WxrAnalysis,
|
||||
type PrepareRequest,
|
||||
type PrepareResult,
|
||||
type AuthorMapping,
|
||||
type ImportConfig,
|
||||
type ImportResult,
|
||||
type MediaImportResult,
|
||||
type MediaImportProgress,
|
||||
type RewriteUrlsResult,
|
||||
type SourceCapabilities,
|
||||
type SourceAuth,
|
||||
type SuggestedAction,
|
||||
type SourceProbeResult,
|
||||
type ProbeResult,
|
||||
type WpPluginAnalysis,
|
||||
analyzeWxr,
|
||||
prepareWxrImport,
|
||||
executeWxrImport,
|
||||
importWxrMedia,
|
||||
probeImportUrl,
|
||||
rewriteContentUrls,
|
||||
analyzeWpPluginSite,
|
||||
executeWpPluginImport,
|
||||
} from "./import.js";
|
||||
|
||||
// API Tokens
|
||||
export {
|
||||
type ApiTokenInfo,
|
||||
type ApiTokenCreateResult,
|
||||
type CreateApiTokenInput,
|
||||
API_TOKEN_SCOPES,
|
||||
fetchApiTokens,
|
||||
createApiToken,
|
||||
revokeApiToken,
|
||||
} from "./api-tokens.js";
|
||||
|
||||
// Comments
|
||||
export {
|
||||
type AdminComment,
|
||||
type CommentStatus,
|
||||
type CommentCounts,
|
||||
type BulkAction,
|
||||
fetchComments,
|
||||
fetchCommentCounts,
|
||||
fetchComment,
|
||||
updateCommentStatus,
|
||||
deleteComment,
|
||||
bulkCommentAction,
|
||||
} from "./comments.js";
|
||||
|
||||
// Dashboard
|
||||
export {
|
||||
type CollectionStats,
|
||||
type RecentItem,
|
||||
type DashboardStats,
|
||||
fetchDashboardStats,
|
||||
} from "./dashboard.js";
|
||||
|
||||
// Search
|
||||
export { type SearchEnableResult, setSearchEnabled } from "./search.js";
|
||||
|
||||
// Marketplace
|
||||
export {
|
||||
type MarketplaceAuthor,
|
||||
type MarketplaceAuditSummary,
|
||||
type MarketplaceImageAuditSummary,
|
||||
type MarketplaceVersion,
|
||||
type MarketplacePluginSummary,
|
||||
type MarketplacePluginDetail,
|
||||
type MarketplaceSearchResult,
|
||||
type MarketplaceSearchOpts,
|
||||
type PluginUpdateInfo,
|
||||
type InstallPluginOpts,
|
||||
type UpdatePluginOpts,
|
||||
type UninstallPluginOpts,
|
||||
searchMarketplace,
|
||||
fetchMarketplacePlugin,
|
||||
installMarketplacePlugin,
|
||||
updateMarketplacePlugin,
|
||||
uninstallMarketplacePlugin,
|
||||
checkPluginUpdates,
|
||||
CAPABILITY_LABELS,
|
||||
describeCapability,
|
||||
} from "./marketplace.js";
|
||||
|
||||
// Email settings
|
||||
export {
|
||||
type EmailProvider,
|
||||
type EmailSettings,
|
||||
fetchEmailSettings,
|
||||
sendTestEmail,
|
||||
} from "./email-settings.js";
|
||||
|
||||
// Theme marketplace
|
||||
export {
|
||||
type ThemeAuthor,
|
||||
type ThemeAuthorDetail,
|
||||
type ThemeSummary,
|
||||
type ThemeDetail,
|
||||
type ThemeSearchResult,
|
||||
type ThemeSearchOpts,
|
||||
searchThemes,
|
||||
fetchTheme,
|
||||
generatePreviewUrl,
|
||||
} from "./theme-marketplace.js";
|
||||
|
||||
// Redirects
|
||||
export {
|
||||
type Redirect,
|
||||
type NotFoundSummary,
|
||||
type CreateRedirectInput,
|
||||
type UpdateRedirectInput,
|
||||
type RedirectListOptions,
|
||||
type RedirectListResult,
|
||||
fetchRedirects,
|
||||
createRedirect,
|
||||
updateRedirect,
|
||||
deleteRedirect,
|
||||
fetch404Summary,
|
||||
} from "./redirects.js";
|
||||
|
||||
// Current user
|
||||
export { type CurrentUser, useCurrentUser } from "./current-user.js";
|
||||
229
packages/admin/src/lib/api/marketplace.ts
Normal file
229
packages/admin/src/lib/api/marketplace.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Marketplace API client
|
||||
*
|
||||
* Calls the site-side proxy endpoints (/_emdash/api/admin/plugins/marketplace/*)
|
||||
* which forward to the marketplace Worker. This avoids CORS issues since the
|
||||
* admin UI doesn't need to know the marketplace URL.
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — matches the marketplace REST API response shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MarketplaceAuthor {
|
||||
name: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface MarketplaceAuditSummary {
|
||||
verdict: "pass" | "warn" | "fail";
|
||||
riskScore: number;
|
||||
}
|
||||
|
||||
export interface MarketplaceImageAuditSummary {
|
||||
verdict: "pass" | "warn" | "fail";
|
||||
}
|
||||
|
||||
export interface MarketplaceVersion {
|
||||
version: string;
|
||||
minEmDashVersion?: string;
|
||||
bundleSize: number;
|
||||
changelog?: string;
|
||||
readme?: string;
|
||||
screenshotUrls?: string[];
|
||||
audit?: MarketplaceAuditSummary;
|
||||
imageAudit?: MarketplaceImageAuditSummary;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
/** Summary shown in browse cards */
|
||||
export interface MarketplacePluginSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
author: MarketplaceAuthor;
|
||||
capabilities: string[];
|
||||
keywords?: string[];
|
||||
installCount: number;
|
||||
iconUrl?: string;
|
||||
latestVersion?: {
|
||||
version: string;
|
||||
audit?: MarketplaceAuditSummary;
|
||||
imageAudit?: MarketplaceImageAuditSummary;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Full detail returned by GET /plugins/:id */
|
||||
export interface MarketplacePluginDetail extends MarketplacePluginSummary {
|
||||
license?: string;
|
||||
repositoryUrl?: string;
|
||||
homepageUrl?: string;
|
||||
latestVersion?: MarketplaceVersion;
|
||||
}
|
||||
|
||||
export interface MarketplaceSearchResult {
|
||||
items: MarketplacePluginSummary[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceSearchOpts {
|
||||
q?: string;
|
||||
capability?: string;
|
||||
sort?: "installs" | "updated" | "created" | "name";
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Update check result per plugin */
|
||||
export interface PluginUpdateInfo {
|
||||
pluginId: string;
|
||||
installed: string;
|
||||
latest: string;
|
||||
hasCapabilityChanges: boolean;
|
||||
}
|
||||
|
||||
/** Install request body */
|
||||
export interface InstallPluginOpts {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/** Update request body */
|
||||
export interface UpdatePluginOpts {
|
||||
/** User has confirmed new capabilities */
|
||||
confirmCapabilities?: boolean;
|
||||
}
|
||||
|
||||
/** Uninstall request body */
|
||||
export interface UninstallPluginOpts {
|
||||
/** Delete plugin storage data */
|
||||
deleteData?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions — proxy through site endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MARKETPLACE_BASE = `${API_BASE}/admin/plugins/marketplace`;
|
||||
|
||||
/**
|
||||
* Search the marketplace catalog.
|
||||
* Proxied through /_emdash/api/admin/plugins/marketplace
|
||||
*/
|
||||
export async function searchMarketplace(
|
||||
opts: MarketplaceSearchOpts = {},
|
||||
): Promise<MarketplaceSearchResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.q) params.set("q", opts.q);
|
||||
if (opts.capability) params.set("capability", opts.capability);
|
||||
if (opts.sort) params.set("sort", opts.sort);
|
||||
if (opts.cursor) params.set("cursor", opts.cursor);
|
||||
if (opts.limit) params.set("limit", String(opts.limit));
|
||||
|
||||
const qs = params.toString();
|
||||
const url = `${MARKETPLACE_BASE}${qs ? `?${qs}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<MarketplaceSearchResult>(response, "Marketplace search failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full plugin detail.
|
||||
* Proxied through /_emdash/api/admin/plugins/marketplace/:id
|
||||
*/
|
||||
export async function fetchMarketplacePlugin(id: string): Promise<MarketplacePluginDetail> {
|
||||
const response = await apiFetch(`${MARKETPLACE_BASE}/${encodeURIComponent(id)}`);
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Plugin "${id}" not found in marketplace`);
|
||||
}
|
||||
return parseApiResponse<MarketplacePluginDetail>(response, "Failed to fetch plugin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a plugin from the marketplace.
|
||||
* POST /_emdash/api/admin/plugins/marketplace/:id/install
|
||||
*/
|
||||
export async function installMarketplacePlugin(
|
||||
id: string,
|
||||
opts: InstallPluginOpts = {},
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(`${MARKETPLACE_BASE}/${encodeURIComponent(id)}/install`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(opts),
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to install plugin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a marketplace plugin to a newer version.
|
||||
* POST /_emdash/api/admin/plugins/:id/update
|
||||
*/
|
||||
export async function updateMarketplacePlugin(
|
||||
id: string,
|
||||
opts: UpdatePluginOpts = {},
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/${encodeURIComponent(id)}/update`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(opts),
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to update plugin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a marketplace plugin.
|
||||
* POST /_emdash/api/admin/plugins/:id/uninstall
|
||||
*/
|
||||
export async function uninstallMarketplacePlugin(
|
||||
id: string,
|
||||
opts: UninstallPluginOpts = {},
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/${encodeURIComponent(id)}/uninstall`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(opts),
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to uninstall plugin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all marketplace plugins for available updates.
|
||||
* GET /_emdash/api/admin/plugins/updates
|
||||
*/
|
||||
export async function checkPluginUpdates(): Promise<PluginUpdateInfo[]> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/updates`);
|
||||
const result = await parseApiResponse<{ items: PluginUpdateInfo[] }>(
|
||||
response,
|
||||
"Failed to check for updates",
|
||||
);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Human-readable labels for plugin capabilities */
|
||||
export const CAPABILITY_LABELS: Record<string, string> = {
|
||||
"read:content": "Read your content",
|
||||
"write:content": "Create, update, and delete content",
|
||||
"read:media": "Access your media library",
|
||||
"write:media": "Upload and manage media",
|
||||
"network:fetch": "Make network requests",
|
||||
"network:fetch:any": "Make network requests to any host (unrestricted)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a human-readable description for a capability.
|
||||
* For network:fetch, appends the allowed hosts if provided.
|
||||
*/
|
||||
export function describeCapability(capability: string, allowedHosts?: string[]): string {
|
||||
const base = CAPABILITY_LABELS[capability] ?? capability;
|
||||
if (capability === "network:fetch" && allowedHosts && allowedHosts.length > 0) {
|
||||
return `${base} to: ${allowedHosts.join(", ")}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
325
packages/admin/src/lib/api/media.ts
Normal file
325
packages/admin/src/lib/api/media.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Media upload, list, delete, and provider APIs
|
||||
*/
|
||||
|
||||
import {
|
||||
API_BASE,
|
||||
apiFetch,
|
||||
parseApiResponse,
|
||||
throwResponseError,
|
||||
type FindManyResult,
|
||||
} from "./client.js";
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
/** Storage key for local media (e.g., "01ABC.jpg"). Not present for external URLs. */
|
||||
storageKey?: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
createdAt: string;
|
||||
/** Provider ID for external media (e.g., "cloudflare-images") */
|
||||
provider?: string;
|
||||
/** Provider-specific metadata */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch media list
|
||||
*/
|
||||
export async function fetchMediaList(options?: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
mimeType?: string;
|
||||
}): Promise<FindManyResult<MediaItem>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.mimeType) params.set("mimeType", options.mimeType);
|
||||
|
||||
const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<MediaItem>>(response, "Failed to fetch media");
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload URL response from the API
|
||||
*/
|
||||
interface UploadUrlResponse {
|
||||
uploadUrl: string;
|
||||
method: "PUT";
|
||||
headers: Record<string, string>;
|
||||
mediaId: string;
|
||||
storageKey: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get a signed upload URL
|
||||
* Returns null if signed URLs are not supported (e.g., local storage)
|
||||
*/
|
||||
async function getUploadUrl(file: File): Promise<UploadUrlResponse | null> {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/media/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 501) {
|
||||
// Not implemented - storage doesn't support signed URLs
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseApiResponse<UploadUrlResponse>(response, "Failed to get upload URL");
|
||||
} catch (error) {
|
||||
// If the endpoint doesn't exist, fall back to direct upload
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm upload after uploading to signed URL
|
||||
*/
|
||||
async function confirmUpload(
|
||||
mediaId: string,
|
||||
metadata?: { width?: number; height?: number; size?: number },
|
||||
): Promise<MediaItem> {
|
||||
const response = await apiFetch(`${API_BASE}/media/${mediaId}/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(metadata || {}),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to confirm upload");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload directly to signed URL
|
||||
*/
|
||||
async function uploadToSignedUrl(file: File, uploadInfo: UploadUrlResponse): Promise<void> {
|
||||
const response = await fetch(uploadInfo.uploadUrl, {
|
||||
method: uploadInfo.method,
|
||||
headers: {
|
||||
...uploadInfo.headers,
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!response.ok) await throwResponseError(response, "Failed to upload file");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image dimensions from a file
|
||||
*/
|
||||
async function getImageDimensions(file: File): Promise<{ width: number; height: number } | null> {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve(null);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media file via direct upload (legacy/local storage)
|
||||
*/
|
||||
async function uploadMediaDirect(file: File): Promise<MediaItem> {
|
||||
// Get image dimensions before upload
|
||||
const dimensions = await getImageDimensions(file);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
// Send dimensions as form fields
|
||||
if (dimensions?.width) formData.append("width", String(dimensions.width));
|
||||
if (dimensions?.height) formData.append("height", String(dimensions.height));
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/media`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to upload media");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media file
|
||||
*
|
||||
* Tries signed URL upload first (for S3/R2 storage), falls back to direct upload
|
||||
* (for local storage) if signed URLs are not supported.
|
||||
*/
|
||||
export async function uploadMedia(file: File): Promise<MediaItem> {
|
||||
// Try to get a signed upload URL
|
||||
const uploadInfo = await getUploadUrl(file);
|
||||
|
||||
if (!uploadInfo) {
|
||||
// Signed URLs not supported, use direct upload
|
||||
return uploadMediaDirect(file);
|
||||
}
|
||||
|
||||
// Upload directly to storage via signed URL
|
||||
await uploadToSignedUrl(file, uploadInfo);
|
||||
|
||||
// Get image dimensions for confirmation
|
||||
const dimensions = await getImageDimensions(file);
|
||||
|
||||
// Confirm the upload
|
||||
return confirmUpload(uploadInfo.mediaId, {
|
||||
size: file.size,
|
||||
width: dimensions?.width,
|
||||
height: dimensions?.height,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media
|
||||
*/
|
||||
export async function deleteMedia(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/media/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete media");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update media metadata (dimensions, alt text, etc.)
|
||||
*/
|
||||
export async function updateMedia(
|
||||
id: string,
|
||||
input: { alt?: string; caption?: string; width?: number; height?: number },
|
||||
): Promise<MediaItem> {
|
||||
const response = await apiFetch(`${API_BASE}/media/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to update media");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Media Providers API
|
||||
// =============================================================================
|
||||
|
||||
/** Media provider capabilities */
|
||||
export interface MediaProviderCapabilities {
|
||||
browse: boolean;
|
||||
search: boolean;
|
||||
upload: boolean;
|
||||
delete: boolean;
|
||||
}
|
||||
|
||||
/** Media provider info from the API */
|
||||
export interface MediaProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
capabilities: MediaProviderCapabilities;
|
||||
}
|
||||
|
||||
/** Media item from a provider */
|
||||
export interface MediaProviderItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
previewUrl?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all configured media providers
|
||||
*/
|
||||
export async function fetchMediaProviders(): Promise<MediaProviderInfo[]> {
|
||||
const response = await apiFetch(`${API_BASE}/media/providers`);
|
||||
const data = await parseApiResponse<{ items: MediaProviderInfo[] }>(
|
||||
response,
|
||||
"Failed to fetch media providers",
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch media items from a specific provider
|
||||
*/
|
||||
export async function fetchProviderMedia(
|
||||
providerId: string,
|
||||
options?: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
query?: string;
|
||||
mimeType?: string;
|
||||
},
|
||||
): Promise<FindManyResult<MediaProviderItem>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.query) params.set("query", options.query);
|
||||
if (options?.mimeType) params.set("mimeType", options.mimeType);
|
||||
|
||||
const url = `${API_BASE}/media/providers/${providerId}${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<MediaProviderItem>>(
|
||||
response,
|
||||
"Failed to fetch provider media",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media to a specific provider
|
||||
*/
|
||||
export async function uploadToProvider(
|
||||
providerId: string,
|
||||
file: File,
|
||||
alt?: string,
|
||||
): Promise<MediaProviderItem> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (alt) formData.append("alt", alt);
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/media/providers/${providerId}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await parseApiResponse<{ item: MediaProviderItem }>(
|
||||
response,
|
||||
"Failed to upload to provider",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media from a specific provider
|
||||
*/
|
||||
export async function deleteFromProvider(providerId: string, itemId: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/media/providers/${providerId}/${itemId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete from provider");
|
||||
}
|
||||
180
packages/admin/src/lib/api/menus.ts
Normal file
180
packages/admin/src/lib/api/menus.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Menu management APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export interface Menu {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
itemCount?: number;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
menu_id: string;
|
||||
parent_id: string | null;
|
||||
sort_order: number;
|
||||
type: string;
|
||||
reference_collection: string | null;
|
||||
reference_id: string | null;
|
||||
custom_url: string | null;
|
||||
label: string;
|
||||
title_attr: string | null;
|
||||
target: string | null;
|
||||
css_classes: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MenuWithItems extends Menu {
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export interface CreateMenuInput {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface UpdateMenuInput {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface CreateMenuItemInput {
|
||||
type: string;
|
||||
label: string;
|
||||
referenceCollection?: string;
|
||||
referenceId?: string;
|
||||
customUrl?: string;
|
||||
target?: string;
|
||||
titleAttr?: string;
|
||||
cssClasses?: string;
|
||||
parentId?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface UpdateMenuItemInput {
|
||||
label?: string;
|
||||
customUrl?: string;
|
||||
target?: string;
|
||||
titleAttr?: string;
|
||||
cssClasses?: string;
|
||||
parentId?: string | null;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface ReorderMenuItemsInput {
|
||||
items: Array<{
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all menus
|
||||
*/
|
||||
export async function fetchMenus(): Promise<Menu[]> {
|
||||
const response = await apiFetch(`${API_BASE}/menus`);
|
||||
return parseApiResponse<Menu[]>(response, "Failed to fetch menus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single menu with items
|
||||
*/
|
||||
export async function fetchMenu(name: string): Promise<MenuWithItems> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${name}`);
|
||||
return parseApiResponse<MenuWithItems>(response, "Failed to fetch menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a menu
|
||||
*/
|
||||
export async function createMenu(input: CreateMenuInput): Promise<Menu> {
|
||||
const response = await apiFetch(`${API_BASE}/menus`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Menu>(response, "Failed to create menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a menu
|
||||
*/
|
||||
export async function updateMenu(name: string, input: UpdateMenuInput): Promise<Menu> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Menu>(response, "Failed to update menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a menu
|
||||
*/
|
||||
export async function deleteMenu(name: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${name}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a menu item
|
||||
*/
|
||||
export async function createMenuItem(
|
||||
menuName: string,
|
||||
input: CreateMenuItemInput,
|
||||
): Promise<MenuItem> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<MenuItem>(response, "Failed to create menu item");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a menu item
|
||||
*/
|
||||
export async function updateMenuItem(
|
||||
menuName: string,
|
||||
itemId: string,
|
||||
input: UpdateMenuItemInput,
|
||||
): Promise<MenuItem> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<MenuItem>(response, "Failed to update menu item");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a menu item
|
||||
*/
|
||||
export async function deleteMenuItem(menuName: string, itemId: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete menu item");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder menu items
|
||||
*/
|
||||
export async function reorderMenuItems(
|
||||
menuName: string,
|
||||
input: ReorderMenuItemsInput,
|
||||
): Promise<MenuItem[]> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${menuName}/reorder`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<MenuItem[]>(response, "Failed to reorder menu items");
|
||||
}
|
||||
78
packages/admin/src/lib/api/plugins.ts
Normal file
78
packages/admin/src/lib/api/plugins.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Plugin management APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
package?: string;
|
||||
enabled: boolean;
|
||||
status: "installed" | "active" | "inactive";
|
||||
capabilities: string[];
|
||||
hasAdminPages: boolean;
|
||||
hasDashboardWidgets: boolean;
|
||||
hasHooks: boolean;
|
||||
installedAt?: string;
|
||||
activatedAt?: string;
|
||||
deactivatedAt?: string;
|
||||
/** Plugin source: 'config' (declared in astro.config) or 'marketplace' */
|
||||
source?: "config" | "marketplace";
|
||||
/** Installed marketplace version (set when source = 'marketplace') */
|
||||
marketplaceVersion?: string;
|
||||
/** Description of what the plugin does */
|
||||
description?: string;
|
||||
/** URL to the plugin icon (marketplace plugins use the icon proxy) */
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all plugins
|
||||
*/
|
||||
export async function fetchPlugins(): Promise<PluginInfo[]> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins`);
|
||||
const result = await parseApiResponse<{ items: PluginInfo[] }>(
|
||||
response,
|
||||
"Failed to fetch plugins",
|
||||
);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single plugin
|
||||
*/
|
||||
export async function fetchPlugin(pluginId: string): Promise<PluginInfo> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Plugin "${pluginId}" not found`);
|
||||
}
|
||||
await throwResponseError(response, "Failed to fetch plugin");
|
||||
}
|
||||
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to fetch plugin");
|
||||
return result.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a plugin
|
||||
*/
|
||||
export async function enablePlugin(pluginId: string): Promise<PluginInfo> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/enable`, {
|
||||
method: "POST",
|
||||
});
|
||||
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to enable plugin");
|
||||
return result.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a plugin
|
||||
*/
|
||||
export async function disablePlugin(pluginId: string): Promise<PluginInfo> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/disable`, {
|
||||
method: "POST",
|
||||
});
|
||||
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to disable plugin");
|
||||
return result.item;
|
||||
}
|
||||
126
packages/admin/src/lib/api/redirects.ts
Normal file
126
packages/admin/src/lib/api/redirects.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Redirects API client
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export interface Redirect {
|
||||
id: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
type: number;
|
||||
isPattern: boolean;
|
||||
enabled: boolean;
|
||||
hits: number;
|
||||
lastHitAt: string | null;
|
||||
groupName: string | null;
|
||||
auto: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NotFoundSummary {
|
||||
path: string;
|
||||
count: number;
|
||||
lastSeen: string;
|
||||
topReferrer: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRedirectInput {
|
||||
source: string;
|
||||
destination: string;
|
||||
type?: number;
|
||||
enabled?: boolean;
|
||||
groupName?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateRedirectInput {
|
||||
source?: string;
|
||||
destination?: string;
|
||||
type?: number;
|
||||
enabled?: boolean;
|
||||
groupName?: string | null;
|
||||
}
|
||||
|
||||
export interface RedirectListOptions {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
group?: string;
|
||||
enabled?: boolean;
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
export interface RedirectListResult {
|
||||
items: Redirect[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List redirects with optional filters
|
||||
*/
|
||||
export async function fetchRedirects(options?: RedirectListOptions): Promise<RedirectListResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit != null) params.set("limit", String(options.limit));
|
||||
if (options?.search) params.set("search", options.search);
|
||||
if (options?.group) params.set("group", options.group);
|
||||
if (options?.enabled !== undefined) params.set("enabled", String(options.enabled));
|
||||
if (options?.auto !== undefined) params.set("auto", String(options.auto));
|
||||
|
||||
const url = params.toString() ? `${API_BASE}/redirects?${params}` : `${API_BASE}/redirects`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<RedirectListResult>(response, "Failed to fetch redirects");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redirect
|
||||
*/
|
||||
export async function createRedirect(input: CreateRedirectInput): Promise<Redirect> {
|
||||
const response = await apiFetch(`${API_BASE}/redirects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Redirect>(response, "Failed to create redirect");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a redirect
|
||||
*/
|
||||
export async function updateRedirect(id: string, input: UpdateRedirectInput): Promise<Redirect> {
|
||||
const response = await apiFetch(`${API_BASE}/redirects/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Redirect>(response, "Failed to update redirect");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a redirect
|
||||
*/
|
||||
export async function deleteRedirect(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/redirects/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete redirect");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch 404 summary (grouped by path, sorted by count)
|
||||
*/
|
||||
export async function fetch404Summary(limit?: number): Promise<NotFoundSummary[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit != null) params.set("limit", String(limit));
|
||||
|
||||
const url = params.toString()
|
||||
? `${API_BASE}/redirects/404s/summary?${params}`
|
||||
: `${API_BASE}/redirects/404s/summary`;
|
||||
const response = await apiFetch(url);
|
||||
const data = await parseApiResponse<{ items: NotFoundSummary[] }>(
|
||||
response,
|
||||
"Failed to fetch 404 summary",
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
331
packages/admin/src/lib/api/schema.ts
Normal file
331
packages/admin/src/lib/api/schema.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Schema/collection/field management APIs (Content Type Builder)
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export type FieldType =
|
||||
| "string"
|
||||
| "text"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "datetime"
|
||||
| "select"
|
||||
| "multiSelect"
|
||||
| "portableText"
|
||||
| "image"
|
||||
| "file"
|
||||
| "reference"
|
||||
| "json"
|
||||
| "slug";
|
||||
|
||||
export interface SchemaCollection {
|
||||
id: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports: string[];
|
||||
source?: string;
|
||||
urlPattern?: string;
|
||||
hasSeo: boolean;
|
||||
commentsEnabled: boolean;
|
||||
commentsModeration: "all" | "first_time" | "none";
|
||||
commentsClosedAfterDays: number;
|
||||
commentsAutoApproveUsers: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SchemaField {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
columnType: string;
|
||||
required: boolean;
|
||||
unique: boolean;
|
||||
searchable: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
options?: string[];
|
||||
};
|
||||
widget?: string;
|
||||
options?: Record<string, unknown>;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SchemaCollectionWithFields extends SchemaCollection {
|
||||
fields: SchemaField[];
|
||||
}
|
||||
|
||||
export interface CreateCollectionInput {
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports?: string[];
|
||||
urlPattern?: string;
|
||||
hasSeo?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCollectionInput {
|
||||
label?: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports?: string[];
|
||||
urlPattern?: string;
|
||||
hasSeo?: boolean;
|
||||
commentsEnabled?: boolean;
|
||||
commentsModeration?: "all" | "first_time" | "none";
|
||||
commentsClosedAfterDays?: number;
|
||||
commentsAutoApproveUsers?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateFieldInput {
|
||||
slug: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required?: boolean;
|
||||
unique?: boolean;
|
||||
searchable?: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
options?: string[];
|
||||
};
|
||||
widget?: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateFieldInput {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
unique?: boolean;
|
||||
searchable?: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
options?: string[];
|
||||
};
|
||||
widget?: string;
|
||||
options?: Record<string, unknown>;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all collections
|
||||
*/
|
||||
export async function fetchCollections(): Promise<SchemaCollection[]> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections`);
|
||||
const data = await parseApiResponse<{ items: SchemaCollection[] }>(
|
||||
response,
|
||||
"Failed to fetch collections",
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single collection with fields
|
||||
*/
|
||||
export async function fetchCollection(
|
||||
slug: string,
|
||||
includeFields = true,
|
||||
): Promise<SchemaCollectionWithFields> {
|
||||
const url = includeFields
|
||||
? `${API_BASE}/schema/collections/${slug}?includeFields=true`
|
||||
: `${API_BASE}/schema/collections/${slug}`;
|
||||
const response = await apiFetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Collection "${slug}" not found`);
|
||||
}
|
||||
await throwResponseError(response, "Failed to fetch collection");
|
||||
}
|
||||
const data = await parseApiResponse<{ item: SchemaCollectionWithFields }>(
|
||||
response,
|
||||
"Failed to fetch collection",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collection
|
||||
*/
|
||||
export async function createCollection(input: CreateCollectionInput): Promise<SchemaCollection> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: SchemaCollection }>(
|
||||
response,
|
||||
"Failed to create collection",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a collection
|
||||
*/
|
||||
export async function updateCollection(
|
||||
slug: string,
|
||||
input: UpdateCollectionInput,
|
||||
): Promise<SchemaCollection> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections/${slug}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: SchemaCollection }>(
|
||||
response,
|
||||
"Failed to update collection",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a collection
|
||||
*/
|
||||
export async function deleteCollection(slug: string, force = false): Promise<void> {
|
||||
const url = force
|
||||
? `${API_BASE}/schema/collections/${slug}?force=true`
|
||||
: `${API_BASE}/schema/collections/${slug}`;
|
||||
const response = await apiFetch(url, { method: "DELETE" });
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete collection");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fields for a collection
|
||||
*/
|
||||
export async function fetchFields(collectionSlug: string): Promise<SchemaField[]> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections/${collectionSlug}/fields`);
|
||||
const data = await parseApiResponse<{ items: SchemaField[] }>(response, "Failed to fetch fields");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a field
|
||||
*/
|
||||
export async function createField(
|
||||
collectionSlug: string,
|
||||
input: CreateFieldInput,
|
||||
): Promise<SchemaField> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections/${collectionSlug}/fields`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: SchemaField }>(response, "Failed to create field");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field
|
||||
*/
|
||||
export async function updateField(
|
||||
collectionSlug: string,
|
||||
fieldSlug: string,
|
||||
input: UpdateFieldInput,
|
||||
): Promise<SchemaField> {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/schema/collections/${collectionSlug}/fields/${fieldSlug}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
const data = await parseApiResponse<{ item: SchemaField }>(response, "Failed to update field");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a field
|
||||
*/
|
||||
export async function deleteField(collectionSlug: string, fieldSlug: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/schema/collections/${collectionSlug}/fields/${fieldSlug}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete field");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder fields
|
||||
*/
|
||||
export async function reorderFields(collectionSlug: string, fieldSlugs: string[]): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/schema/collections/${collectionSlug}/fields/reorder`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fieldSlugs }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) await throwResponseError(response, "Failed to reorder fields");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Orphaned Tables
|
||||
// ============================================
|
||||
|
||||
export interface OrphanedTable {
|
||||
slug: string;
|
||||
tableName: string;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch orphaned content tables
|
||||
*/
|
||||
export async function fetchOrphanedTables(): Promise<OrphanedTable[]> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/orphans`);
|
||||
const data = await parseApiResponse<{ items: OrphanedTable[] }>(
|
||||
response,
|
||||
"Failed to fetch orphaned tables",
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an orphaned table as a collection
|
||||
*/
|
||||
export async function registerOrphanedTable(
|
||||
slug: string,
|
||||
options?: {
|
||||
label?: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<SchemaCollection> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/orphans/${slug}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(options || {}),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: SchemaCollection }>(
|
||||
response,
|
||||
"Failed to register orphaned table",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
31
packages/admin/src/lib/api/search.ts
Normal file
31
packages/admin/src/lib/api/search.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Search enable/disable APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
export interface SearchEnableResult {
|
||||
success: boolean;
|
||||
collection: string;
|
||||
enabled: boolean;
|
||||
indexed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable search for a collection
|
||||
*/
|
||||
export async function setSearchEnabled(
|
||||
collection: string,
|
||||
enabled: boolean,
|
||||
weights?: Record<string, number>,
|
||||
): Promise<SearchEnableResult> {
|
||||
const response = await apiFetch(`${API_BASE}/search/enable`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ collection, enabled, weights }),
|
||||
});
|
||||
return parseApiResponse<SearchEnableResult>(
|
||||
response,
|
||||
`Failed to ${enabled ? "enable" : "disable"} search`,
|
||||
);
|
||||
}
|
||||
108
packages/admin/src/lib/api/sections.ts
Normal file
108
packages/admin/src/lib/api/sections.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Sections API (reusable content blocks)
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export type SectionSource = "theme" | "user" | "import";
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords: string[];
|
||||
content: unknown[]; // Portable Text
|
||||
previewUrl?: string;
|
||||
source: SectionSource;
|
||||
themeId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSectionInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
content: unknown[];
|
||||
previewMediaId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSectionInput {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
content?: unknown[];
|
||||
previewMediaId?: string | null;
|
||||
}
|
||||
|
||||
export interface GetSectionsOptions {
|
||||
source?: SectionSource;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface SectionsResult {
|
||||
items: Section[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all sections
|
||||
*/
|
||||
export async function fetchSections(options?: GetSectionsOptions): Promise<SectionsResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.source) params.set("source", options.source);
|
||||
if (options?.search) params.set("search", options.search);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
|
||||
const url = params.toString() ? `${API_BASE}/sections?${params}` : `${API_BASE}/sections`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<SectionsResult>(response, "Failed to fetch sections");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single section by slug
|
||||
*/
|
||||
export async function fetchSection(slug: string): Promise<Section> {
|
||||
const response = await apiFetch(`${API_BASE}/sections/${slug}`);
|
||||
return parseApiResponse<Section>(response, "Failed to fetch section");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a section
|
||||
*/
|
||||
export async function createSection(input: CreateSectionInput): Promise<Section> {
|
||||
const response = await apiFetch(`${API_BASE}/sections`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Section>(response, "Failed to create section");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a section
|
||||
*/
|
||||
export async function updateSection(slug: string, input: UpdateSectionInput): Promise<Section> {
|
||||
const response = await apiFetch(`${API_BASE}/sections/${slug}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Section>(response, "Failed to update section");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a section
|
||||
*/
|
||||
export async function deleteSection(slug: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/sections/${slug}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete section");
|
||||
}
|
||||
62
packages/admin/src/lib/api/settings.ts
Normal file
62
packages/admin/src/lib/api/settings.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Site settings APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
export interface SiteSettings {
|
||||
// Identity
|
||||
title: string;
|
||||
tagline?: string;
|
||||
logo?: { mediaId: string; alt?: string; url?: string };
|
||||
favicon?: { mediaId: string; url?: string };
|
||||
|
||||
// URLs
|
||||
url?: string;
|
||||
|
||||
// Display
|
||||
postsPerPage: number;
|
||||
dateFormat: string;
|
||||
timezone: string;
|
||||
|
||||
// Social
|
||||
social?: {
|
||||
twitter?: string;
|
||||
github?: string;
|
||||
facebook?: string;
|
||||
instagram?: string;
|
||||
linkedin?: string;
|
||||
youtube?: string;
|
||||
};
|
||||
|
||||
// SEO
|
||||
seo?: {
|
||||
titleSeparator?: string;
|
||||
defaultOgImage?: { mediaId: string; alt?: string; url?: string };
|
||||
robotsTxt?: string;
|
||||
googleVerification?: string;
|
||||
bingVerification?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch site settings
|
||||
*/
|
||||
export async function fetchSettings(): Promise<Partial<SiteSettings>> {
|
||||
const response = await apiFetch(`${API_BASE}/settings`);
|
||||
return parseApiResponse<Partial<SiteSettings>>(response, "Failed to fetch settings");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update site settings
|
||||
*/
|
||||
export async function updateSettings(
|
||||
settings: Partial<SiteSettings>,
|
||||
): Promise<Partial<SiteSettings>> {
|
||||
const response = await apiFetch(`${API_BASE}/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
return parseApiResponse<Partial<SiteSettings>>(response, "Failed to update settings");
|
||||
}
|
||||
134
packages/admin/src/lib/api/taxonomies.ts
Normal file
134
packages/admin/src/lib/api/taxonomies.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Taxonomies API (categories, tags, custom taxonomies)
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export interface TaxonomyTerm {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
children: TaxonomyTerm[];
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface TaxonomyDef {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
hierarchical: boolean;
|
||||
collections: string[];
|
||||
}
|
||||
|
||||
export interface CreateTaxonomyInput {
|
||||
name: string;
|
||||
label: string;
|
||||
hierarchical?: boolean;
|
||||
collections?: string[];
|
||||
}
|
||||
|
||||
export interface CreateTermInput {
|
||||
slug: string;
|
||||
label: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTermInput {
|
||||
slug?: string;
|
||||
label?: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all taxonomy definitions
|
||||
*/
|
||||
export async function fetchTaxonomyDefs(): Promise<TaxonomyDef[]> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies`);
|
||||
const data = await parseApiResponse<{ taxonomies: TaxonomyDef[] }>(
|
||||
response,
|
||||
"Failed to fetch taxonomies",
|
||||
);
|
||||
return data.taxonomies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch taxonomy definition by name
|
||||
*/
|
||||
export async function fetchTaxonomyDef(name: string): Promise<TaxonomyDef | null> {
|
||||
const defs = await fetchTaxonomyDefs();
|
||||
return defs.find((t) => t.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom taxonomy definition
|
||||
*/
|
||||
export async function createTaxonomy(input: CreateTaxonomyInput): Promise<TaxonomyDef> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ taxonomy: TaxonomyDef }>(
|
||||
response,
|
||||
"Failed to create taxonomy",
|
||||
);
|
||||
return data.taxonomy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch terms for a taxonomy
|
||||
*/
|
||||
export async function fetchTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms`);
|
||||
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(response, "Failed to fetch terms");
|
||||
return data.terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a term
|
||||
*/
|
||||
export async function createTerm(
|
||||
taxonomyName: string,
|
||||
input: CreateTermInput,
|
||||
): Promise<TaxonomyTerm> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ term: TaxonomyTerm }>(response, "Failed to create term");
|
||||
return data.term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a term
|
||||
*/
|
||||
export async function updateTerm(
|
||||
taxonomyName: string,
|
||||
slug: string,
|
||||
input: UpdateTermInput,
|
||||
): Promise<TaxonomyTerm> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ term: TaxonomyTerm }>(response, "Failed to update term");
|
||||
return data.term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a term
|
||||
*/
|
||||
export async function deleteTerm(taxonomyName: string, slug: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete term");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user