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

View File

@@ -0,0 +1,114 @@
/**
* Theme Marketplace API client
*
* Calls the site-side proxy endpoints (/_emdash/api/admin/themes/marketplace/*)
* which forward to the marketplace Worker. The preview signing endpoint
* is local (/_emdash/api/themes/preview).
*/
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
// ---------------------------------------------------------------------------
// Types — matches the marketplace REST API response shapes
// ---------------------------------------------------------------------------
export interface ThemeAuthor {
name: string;
verified: boolean;
avatarUrl: string | null;
}
export interface ThemeAuthorDetail extends ThemeAuthor {
id: string;
}
/** Summary shown in browse cards */
export interface ThemeSummary {
id: string;
name: string;
description: string | null;
author: ThemeAuthor;
keywords: string[];
previewUrl: string;
demoUrl: string | null;
hasThumbnail: boolean;
thumbnailUrl: string | null;
createdAt: string;
updatedAt: string;
}
/** Full detail returned by GET /themes/:id */
export interface ThemeDetail extends Omit<ThemeSummary, "author"> {
author: ThemeAuthorDetail;
repositoryUrl: string | null;
homepageUrl: string | null;
license: string | null;
screenshotCount: number;
screenshotUrls: string[];
}
export interface ThemeSearchResult {
items: ThemeSummary[];
nextCursor?: string;
}
export interface ThemeSearchOpts {
q?: string;
keyword?: string;
sort?: "name" | "created" | "updated";
cursor?: string;
limit?: number;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
const THEME_MARKETPLACE_BASE = `${API_BASE}/admin/themes/marketplace`;
/**
* Search theme listings.
* Proxied through /_emdash/api/admin/themes/marketplace
*/
export async function searchThemes(opts: ThemeSearchOpts = {}): Promise<ThemeSearchResult> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
if (opts.keyword) params.set("keyword", opts.keyword);
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 = `${THEME_MARKETPLACE_BASE}${qs ? `?${qs}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<ThemeSearchResult>(response, "Theme search failed");
}
/**
* Get full theme detail.
* Proxied through /_emdash/api/admin/themes/marketplace/:id
*/
export async function fetchTheme(id: string): Promise<ThemeDetail> {
const response = await apiFetch(`${THEME_MARKETPLACE_BASE}/${encodeURIComponent(id)}`);
if (response.status === 404) {
throw new Error(`Theme "${id}" not found`);
}
return parseApiResponse<ThemeDetail>(response, "Failed to fetch theme");
}
/**
* Generate a signed preview URL for the "Try with my data" flow.
* POST /_emdash/api/themes/preview (local, not proxied)
*/
export async function generatePreviewUrl(previewUrl: string): Promise<string> {
const response = await apiFetch(`${API_BASE}/themes/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ previewUrl }),
});
const result = await parseApiResponse<{ url: string }>(
response,
"Failed to generate preview URL",
);
return result.url;
}

View File

@@ -0,0 +1,406 @@
/**
* User management, passkeys, and allowed domains APIs
*/
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
// =============================================================================
// User Management API
// =============================================================================
/** User list item with computed fields */
export interface UserListItem {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
role: number;
emailVerified: boolean;
disabled: boolean;
createdAt: string;
updatedAt: string;
lastLogin: string | null;
credentialCount: number;
oauthProviders: string[];
}
/** User detail with credentials and OAuth accounts */
export interface UserDetail extends UserListItem {
credentials: Array<{
id: string;
name: string | null;
deviceType: string;
createdAt: string;
lastUsedAt: string;
}>;
oauthAccounts: Array<{
provider: string;
createdAt: string;
}>;
}
/** User update input */
export interface UpdateUserInput {
name?: string;
email?: string;
role?: number;
}
/**
* Fetch users with search, filter, and pagination
*/
export async function fetchUsers(options?: {
search?: string;
role?: number;
cursor?: string;
limit?: number;
}): Promise<FindManyResult<UserListItem>> {
const params = new URLSearchParams();
if (options?.search) params.set("search", options.search);
if (options?.role !== undefined) params.set("role", String(options.role));
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
const url = `${API_BASE}/admin/users${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<UserListItem>>(response, "Failed to fetch users");
}
/**
* Fetch a single user with details
*/
export async function fetchUser(id: string): Promise<UserDetail> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`User not found: ${id}`);
}
await throwResponseError(response, "Failed to fetch user");
}
const data = await parseApiResponse<{ item: UserDetail }>(response, "Failed to fetch user");
return data.item;
}
/**
* Update a user
*/
export async function updateUser(id: string, input: UpdateUserInput): Promise<UserDetail> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ item: UserDetail }>(response, "Failed to update user");
return data.item;
}
/**
* Disable a user
*/
export async function disableUser(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}/disable`, {
method: "POST",
});
if (!response.ok) await throwResponseError(response, "Failed to disable user");
}
/**
* Send a recovery magic link to a user
*/
export async function sendRecoveryLink(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}/send-recovery`, {
method: "POST",
});
if (!response.ok) await throwResponseError(response, "Failed to send recovery link");
}
/**
* Enable a user
*/
export async function enableUser(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}/enable`, {
method: "POST",
});
if (!response.ok) await throwResponseError(response, "Failed to enable user");
}
/** Invite response -- includes inviteUrl when no email provider is configured */
export interface InviteResult {
success: true;
message: string;
/** Present when no email provider is configured (copy-link fallback) */
inviteUrl?: string;
}
/**
* Invite a new user
*
* Uses the existing /auth/invite endpoint.
* When no email provider is configured, the response includes
* an `inviteUrl` for manual sharing.
*/
export async function inviteUser(email: string, role?: number): Promise<InviteResult> {
const response = await apiFetch(`${API_BASE}/auth/invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, role }),
});
return parseApiResponse<InviteResult>(response, "Failed to invite user");
}
// =============================================================================
// Passkey Management API
// =============================================================================
/**
* Passkey info returned from API
*/
export interface PasskeyInfo {
id: string;
name: string | null;
deviceType: "singleDevice" | "multiDevice";
backedUp: boolean;
createdAt: string;
lastUsedAt: string;
}
/**
* List all passkeys for the current user
*/
export async function fetchPasskeys(): Promise<PasskeyInfo[]> {
const response = await apiFetch(`${API_BASE}/auth/passkey`);
const data = await parseApiResponse<{ items: PasskeyInfo[] }>(
response,
"Failed to fetch passkeys",
);
return data.items;
}
/**
* Rename a passkey
*/
export async function renamePasskey(id: string, name: string): Promise<PasskeyInfo> {
const response = await apiFetch(`${API_BASE}/auth/passkey/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await parseApiResponse<{ passkey: PasskeyInfo }>(
response,
"Failed to rename passkey",
);
return data.passkey;
}
/**
* Delete a passkey
*/
export async function deletePasskey(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/auth/passkey/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete passkey");
}
// =============================================================================
// Allowed Domains API (Self-Signup)
// =============================================================================
/** Allowed domain for self-signup */
export interface AllowedDomain {
domain: string;
defaultRole: number;
roleName: string;
enabled: boolean;
createdAt: string;
}
/** Create allowed domain input */
export interface CreateAllowedDomainInput {
domain: string;
defaultRole: number;
}
/** Update allowed domain input */
export interface UpdateAllowedDomainInput {
enabled?: boolean;
defaultRole?: number;
}
/**
* Fetch all allowed domains
*/
export async function fetchAllowedDomains(): Promise<AllowedDomain[]> {
const response = await apiFetch(`${API_BASE}/admin/allowed-domains`);
const data = await parseApiResponse<{ domains: AllowedDomain[] }>(
response,
"Failed to fetch allowed domains",
);
return data.domains;
}
/**
* Create an allowed domain
*/
export async function createAllowedDomain(input: CreateAllowedDomainInput): Promise<AllowedDomain> {
const response = await apiFetch(`${API_BASE}/admin/allowed-domains`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ domain: AllowedDomain }>(
response,
"Failed to create allowed domain",
);
return data.domain;
}
/**
* Update an allowed domain
*/
export async function updateAllowedDomain(
domain: string,
input: UpdateAllowedDomainInput,
): Promise<AllowedDomain> {
const response = await apiFetch(
`${API_BASE}/admin/allowed-domains/${encodeURIComponent(domain)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
const data = await parseApiResponse<{ domain: AllowedDomain }>(
response,
"Failed to update allowed domain",
);
return data.domain;
}
/**
* Delete an allowed domain
*/
export async function deleteAllowedDomain(domain: string): Promise<void> {
const response = await apiFetch(
`${API_BASE}/admin/allowed-domains/${encodeURIComponent(domain)}`,
{
method: "DELETE",
},
);
if (!response.ok) await throwResponseError(response, "Failed to delete allowed domain");
}
// =============================================================================
// Self-Signup API
// =============================================================================
/** Signup verification result */
export interface SignupVerifyResult {
email: string;
role: number;
roleName: string;
}
/**
* Request signup - send verification email
* Always returns success to prevent enumeration
*/
export async function requestSignup(email: string): Promise<{ success: true; message: string }> {
const response = await apiFetch(`${API_BASE}/auth/signup/request`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
return parseApiResponse<{ success: true; message: string }>(response, "Signup request failed");
}
/**
* Verify signup token
*
* Uses custom error handling to preserve error codes for the UI.
*/
export async function verifySignupToken(token: string): Promise<SignupVerifyResult> {
const response = await apiFetch(
`${API_BASE}/auth/signup/verify?token=${encodeURIComponent(token)}`,
);
if (!response.ok) {
const errorData: unknown = await response.json().catch(() => ({}));
let message = `Token verification failed: ${response.statusText}`;
let code: string | undefined;
if (typeof errorData === "object" && errorData !== null && "error" in errorData) {
const err = errorData.error;
if (typeof err === "object" && err !== null) {
if ("message" in err && typeof err.message === "string") message = err.message;
if ("code" in err && typeof err.code === "string") code = err.code;
}
}
const error: Error & { code?: string } = new Error(message);
error.code = code;
throw error;
}
return parseApiResponse<SignupVerifyResult>(response, "Token verification failed");
}
/**
* Complete signup with passkey registration
*
* Uses custom error handling to preserve error codes for the UI.
*/
export async function completeSignup(
token: string,
credential: unknown,
name?: string,
): Promise<{
success: true;
user: { id: string; email: string; name: string | null; role: number };
}> {
const response = await apiFetch(`${API_BASE}/auth/signup/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, credential, name }),
});
if (!response.ok) {
const errorData: unknown = await response.json().catch(() => ({}));
let message = `Signup completion failed: ${response.statusText}`;
let code: string | undefined;
if (typeof errorData === "object" && errorData !== null && "error" in errorData) {
const err = errorData.error;
if (typeof err === "object" && err !== null) {
if ("message" in err && typeof err.message === "string") message = err.message;
if ("code" in err && typeof err.code === "string") code = err.code;
}
}
const error: Error & { code?: string } = new Error(message);
error.code = code;
throw error;
}
return parseApiResponse<{
success: true;
user: { id: string; email: string; name: string | null; role: number };
}>(response, "Signup completion failed");
}
/**
* Check if any allowed domains exist (for showing signup link)
*/
export async function hasAllowedDomains(): Promise<boolean> {
try {
const domains = await fetchAllowedDomains();
return domains.some((d) => d.enabled);
} catch {
// If we can't fetch (e.g., not logged in), assume no domains
return false;
}
}

View File

@@ -0,0 +1,168 @@
/**
* Widget areas APIs
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
export interface WidgetArea {
id: string;
name: string;
label: string;
description?: string;
widgets?: Widget[];
widgetCount?: number;
}
export interface Widget {
id: string;
type: "content" | "menu" | "component";
title?: string;
content?: unknown[]; // Portable Text
menuName?: string;
componentId?: string;
componentProps?: Record<string, unknown>;
sort_order?: number;
}
export interface WidgetComponent {
id: string;
label: string;
description?: string;
props: Record<
string,
{
type: "string" | "number" | "boolean" | "select";
label: string;
default?: unknown;
options?: Array<{ value: string; label: string }>;
}
>;
}
export interface CreateWidgetAreaInput {
name: string;
label: string;
description?: string;
}
export interface CreateWidgetInput {
type: "content" | "menu" | "component";
title?: string;
content?: unknown[];
menuName?: string;
componentId?: string;
componentProps?: Record<string, unknown>;
}
export interface UpdateWidgetInput {
type?: "content" | "menu" | "component";
title?: string;
content?: unknown[];
menuName?: string;
componentId?: string;
componentProps?: Record<string, unknown>;
}
/**
* Fetch all widget areas
*/
export async function fetchWidgetAreas(): Promise<WidgetArea[]> {
const response = await apiFetch(`${API_BASE}/widget-areas`);
const data = await parseApiResponse<{ items: WidgetArea[] }>(
response,
"Failed to fetch widget areas",
);
return data.items;
}
/**
* Fetch a single widget area by name
*/
export async function fetchWidgetArea(name: string): Promise<WidgetArea> {
const response = await apiFetch(`${API_BASE}/widget-areas/${name}`);
return parseApiResponse<WidgetArea>(response, "Failed to fetch widget area");
}
/**
* Create a widget area
*/
export async function createWidgetArea(input: CreateWidgetAreaInput): Promise<WidgetArea> {
const response = await apiFetch(`${API_BASE}/widget-areas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<WidgetArea>(response, "Failed to create widget area");
}
/**
* Delete a widget area
*/
export async function deleteWidgetArea(name: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/widget-areas/${name}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete widget area");
}
/**
* Add a widget to an area
*/
export async function createWidget(areaName: string, input: CreateWidgetInput): Promise<Widget> {
const response = await apiFetch(`${API_BASE}/widget-areas/${areaName}/widgets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Widget>(response, "Failed to create widget");
}
/**
* Update a widget
*/
export async function updateWidget(
areaName: string,
widgetId: string,
input: UpdateWidgetInput,
): Promise<Widget> {
const response = await apiFetch(`${API_BASE}/widget-areas/${areaName}/widgets/${widgetId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Widget>(response, "Failed to update widget");
}
/**
* Delete a widget
*/
export async function deleteWidget(areaName: string, widgetId: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/widget-areas/${areaName}/widgets/${widgetId}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete widget");
}
/**
* Reorder widgets in an area
*/
export async function reorderWidgets(areaName: string, widgetIds: string[]): Promise<void> {
const response = await apiFetch(`${API_BASE}/widget-areas/${areaName}/reorder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ widgetIds }),
});
if (!response.ok) await throwResponseError(response, "Failed to reorder widgets");
}
/**
* Fetch available widget components
*/
export async function fetchWidgetComponents(): Promise<WidgetComponent[]> {
const response = await apiFetch(`${API_BASE}/widget-components`);
const data = await parseApiResponse<{ items: WidgetComponent[] }>(
response,
"Failed to fetch widget components",
);
return data.items;
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
/**
* Returns a stable function reference that always calls the latest version
* of the provided callback. Useful for event listeners in effects where
* you don't want the listener to be torn down and re-added when the
* callback identity changes.
*/
export function useStableCallback<Args extends unknown[], Return>(
callback: (...args: Args) => Return,
): (...args: Args) => Return {
const ref = React.useRef(callback);
React.useLayoutEffect(() => {
ref.current = callback;
});
return React.useCallback((...args: Args) => ref.current(...args), []);
}
/**
* Returns a debounced version of a value that only updates after the
* specified delay has elapsed since the last change. Useful for search
* inputs that trigger API calls.
*/
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const timer = setTimeout(setDebouncedValue, delay, value);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,37 @@
import type { MediaItem, MediaProviderItem } from "./api/media.js";
export function providerItemToMediaItem(
providerId: string,
item: MediaProviderItem,
): MediaItem & { provider: string; meta?: Record<string, unknown> } {
return {
id: item.id,
filename: item.filename,
mimeType: item.mimeType,
url: item.previewUrl || "",
size: item.size || 0,
width: item.width,
height: item.height,
alt: item.alt,
createdAt: new Date().toISOString(),
provider: providerId,
meta: item.meta,
} as MediaItem & { provider: string; meta?: Record<string, unknown> };
}
export function getFileIcon(mimeType: string): string {
if (mimeType.startsWith("video/")) return "🎬";
if (mimeType.startsWith("audio/")) return "🎵";
if (mimeType.includes("pdf")) return "📄";
if (mimeType.includes("document") || mimeType.includes("word")) return "📝";
if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) return "📊";
return "📁";
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}

View File

@@ -0,0 +1,83 @@
/**
* Plugin Admin Context
*
* Provides plugin admin modules (widgets, pages, fields) to the admin UI
* via React context. This avoids cross-module registry issues by keeping
* everything in React's component tree.
*/
import * as React from "react";
import { createContext, useContext } from "react";
/** Shape of a plugin's admin exports */
export interface PluginAdminModule {
widgets?: Record<string, React.ComponentType>;
pages?: Record<string, React.ComponentType>;
fields?: Record<string, React.ComponentType>;
}
/** All plugin admin modules keyed by plugin ID */
export type PluginAdmins = Record<string, PluginAdminModule>;
const PluginAdminContext = createContext<PluginAdmins>({});
export interface PluginAdminProviderProps {
children: React.ReactNode;
pluginAdmins: PluginAdmins;
}
/**
* Provider that makes plugin admin modules available to all descendants
*/
export function PluginAdminProvider({ children, pluginAdmins }: PluginAdminProviderProps) {
return <PluginAdminContext.Provider value={pluginAdmins}>{children}</PluginAdminContext.Provider>;
}
/**
* Get all plugin admin modules
*/
export function usePluginAdmins(): PluginAdmins {
return useContext(PluginAdminContext);
}
/**
* Get a dashboard widget component by plugin ID and widget ID
*/
export function usePluginWidget(pluginId: string, widgetId: string): React.ComponentType | null {
const admins = useContext(PluginAdminContext);
return admins[pluginId]?.widgets?.[widgetId] ?? null;
}
/**
* Get a plugin page component by plugin ID and path
*/
export function usePluginPage(pluginId: string, path: string): React.ComponentType | null {
const admins = useContext(PluginAdminContext);
return admins[pluginId]?.pages?.[path] ?? null;
}
/**
* Get a field widget component by plugin ID and field type
*/
export function usePluginField(pluginId: string, fieldType: string): React.ComponentType | null {
const admins = useContext(PluginAdminContext);
return admins[pluginId]?.fields?.[fieldType] ?? null;
}
/**
* Check if a plugin has any registered admin pages
*/
export function usePluginHasPages(pluginId: string): boolean {
const admins = useContext(PluginAdminContext);
const pages = admins[pluginId]?.pages;
return pages !== undefined && Object.keys(pages).length > 0;
}
/**
* Check if a plugin has any registered dashboard widgets
*/
export function usePluginHasWidgets(pluginId: string): boolean {
const admins = useContext(PluginAdminContext);
const widgets = admins[pluginId]?.widgets;
return widgets !== undefined && Object.keys(widgets).length > 0;
}

View File

@@ -0,0 +1,44 @@
/**
* Shared URL validation and transformation utilities
*/
const DEFAULT_REDIRECT = "/_emdash/admin";
/**
* Sanitize a redirect URL to prevent open-redirect and javascript: XSS attacks.
*
* Only allows relative paths starting with `/`. Rejects protocol-relative
* URLs (`//evil.com`), backslash tricks (`/\evil.com`), and non-path schemes
* like `javascript:`.
*
* Returns the default admin URL when the input is unsafe.
*/
export function sanitizeRedirectUrl(raw: string): string {
if (raw.startsWith("/") && !raw.startsWith("//") && !raw.includes("\\")) {
return raw;
}
return DEFAULT_REDIRECT;
}
/** Matches http:// or https:// URLs */
export const SAFE_URL_RE = /^https?:\/\//i;
/** Returns true if the URL uses a safe scheme (http/https) */
export function isSafeUrl(url: string): boolean {
return SAFE_URL_RE.test(url);
}
/**
* Build an icon URL with a width query param, or return null for unsafe URLs.
* Validates the URL scheme and appends `?w=<width>` for image resizing.
*/
export function safeIconUrl(url: string, width: number): string | null {
if (!SAFE_URL_RE.test(url)) return null;
try {
const u = new URL(url);
u.searchParams.set("w", String(width));
return u.href;
} catch {
return null;
}
}

View File

@@ -0,0 +1,53 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// Regex patterns for slugify
const DIACRITICS_PATTERN = /[\u0300-\u036f]/g;
const WHITESPACE_UNDERSCORE_PATTERN = /[\s_]+/g;
const NON_ALPHANUMERIC_HYPHEN_PATTERN = /[^a-z0-9-]/g;
const MULTIPLE_HYPHENS_PATTERN = /-+/g;
const LEADING_TRAILING_HYPHEN_PATTERN = /^-|-$/g;
/**
* Merge class names with Tailwind CSS support
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Convert a string to a URL-friendly slug.
*
* Handles unicode by normalizing to NFD and stripping diacritics.
*/
export 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";
if (diffMins < 60) return `${diffMins} min${diffMins === 1 ? "" : "s"} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function slugify(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(DIACRITICS_PATTERN, "")
.replace(WHITESPACE_UNDERSCORE_PATTERN, "-")
.replace(NON_ALPHANUMERIC_HYPHEN_PATTERN, "")
.replace(MULTIPLE_HYPHENS_PATTERN, "-")
.replace(LEADING_TRAILING_HYPHEN_PATTERN, "");
}