first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View 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"
}

View 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;

View 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>
);
}

View 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>
);
}
}

View 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;

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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 &ldquo;{searchQuery}&rdquo;
</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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]);
}

View 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>
);
}

View 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;

View 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">&middot;</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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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;

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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;

View 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"} &middot;{" "}
{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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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;
}

View 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>
);
}

View 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>
);
}

View 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)}
/>
);
}
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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";

View 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>
);
}

View 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>;
}

View 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 };

View 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>
);
}

View 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}
/>
</>
);
}

View 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;

View 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,
});
},
};
},
});

View File

@@ -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,
}),
];
},
});

View 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 };

View 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";

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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>
);
}

View 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" },
];

View 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>
);
}

View 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>
);
}

View 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";

View 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";

View 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";

View 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");
}

View 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");
}

View 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");
}

View 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");
}

View 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;
}

View 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,
});
}

View 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");
}

View 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");
}

View 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;
}

View 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";

View 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;
}

View 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");
}

View 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");
}

View 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;
}

View 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;
}

View 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;
}

View 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`,
);
}

View 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");
}

View 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");
}

View 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