Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
203
packages/admin/src/lib/api/client.ts
Normal file
203
packages/admin/src/lib/api/client.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Base API client configuration and shared types
|
||||
*/
|
||||
|
||||
import type { Element } from "@emdash-cms/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;
|
||||
urlPattern?: string;
|
||||
fields: Record<
|
||||
string,
|
||||
{
|
||||
kind: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
widget?: string;
|
||||
/**
|
||||
* For `select` / `multiSelect`: the list of enum choices.
|
||||
* For `json` fields driven by a plugin `widget`: arbitrary widget config.
|
||||
*/
|
||||
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
|
||||
validation?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>;
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
name?: string;
|
||||
version?: string;
|
||||
/** Package name for dynamic import (e.g., "@emdash-cms/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("@emdash-cms/blocks").Element[];
|
||||
}>;
|
||||
/** Block types for Portable Text editor */
|
||||
portableTextBlocks?: Array<{
|
||||
type: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
fields?: Element[];
|
||||
category?: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
* 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[];
|
||||
};
|
||||
/**
|
||||
* Taxonomy definitions for the admin sidebar.
|
||||
*/
|
||||
taxonomies: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
hierarchical: boolean;
|
||||
collections: string[];
|
||||
}>;
|
||||
/**
|
||||
* Marketplace registry URL. Present when `marketplace` is configured
|
||||
* in the EmDash integration. Enables marketplace features in the UI.
|
||||
*/
|
||||
marketplace?: string;
|
||||
/**
|
||||
* Admin branding overrides for white-labeling.
|
||||
* Set via the `admin` config in `astro.config.mjs`.
|
||||
*/
|
||||
admin?: {
|
||||
logo?: string;
|
||||
siteName?: string;
|
||||
favicon?: 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch auth mode (public endpoint — works without authentication).
|
||||
* Used by the login page to determine which login UI to render.
|
||||
*/
|
||||
export async function fetchAuthMode(): Promise<{
|
||||
authMode: string;
|
||||
signupEnabled?: boolean;
|
||||
providers?: Array<{ id: string; label: string }>;
|
||||
}> {
|
||||
const response = await apiFetch(`${API_BASE}/auth/mode`);
|
||||
return parseApiResponse<{
|
||||
authMode: string;
|
||||
signupEnabled?: boolean;
|
||||
providers?: Array<{ id: string; label: string }>;
|
||||
}>(response, "Failed to fetch auth mode");
|
||||
}
|
||||
Reference in New Issue
Block a user