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:
8
packages/admin/src/lib/api.ts
Normal file
8
packages/admin/src/lib/api.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* API client for EmDash admin
|
||||
*
|
||||
* This file re-exports from the split API modules for backwards compatibility.
|
||||
* New code should import directly from the specific modules in ./api/
|
||||
*/
|
||||
|
||||
export * from "./api/index.js";
|
||||
96
packages/admin/src/lib/api/api-tokens.ts
Normal file
96
packages/admin/src/lib/api/api-tokens.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope strings for personal API tokens (wire + UI iteration order).
|
||||
* Human-readable copy lives in `ApiTokenSettings` (`SCOPE_UI` + Lingui).
|
||||
*/
|
||||
export const API_TOKEN_SCOPES = {
|
||||
ContentRead: "content:read",
|
||||
ContentWrite: "content:write",
|
||||
MediaRead: "media:read",
|
||||
MediaWrite: "media:write",
|
||||
SchemaRead: "schema:read",
|
||||
SchemaWrite: "schema:write",
|
||||
TaxonomiesManage: "taxonomies:manage",
|
||||
MenusManage: "menus:manage",
|
||||
SettingsRead: "settings:read",
|
||||
SettingsManage: "settings:manage",
|
||||
Admin: "admin",
|
||||
} as const;
|
||||
|
||||
export type ApiTokenScopeValue = (typeof API_TOKEN_SCOPES)[keyof typeof API_TOKEN_SCOPES];
|
||||
|
||||
// =============================================================================
|
||||
// API Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all API tokens for the current user
|
||||
*/
|
||||
export async function fetchApiTokens(): Promise<ApiTokenInfo[]> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/api-tokens`);
|
||||
const result = await parseApiResponse<{ items: ApiTokenInfo[] }>(
|
||||
response,
|
||||
"Failed to fetch API tokens",
|
||||
);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API token
|
||||
*/
|
||||
export async function createApiToken(input: CreateApiTokenInput): Promise<ApiTokenCreateResult> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/api-tokens`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return parseApiResponse<ApiTokenCreateResult>(response, "Failed to create API token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) an API token
|
||||
*/
|
||||
export async function revokeApiToken(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/api-tokens/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) await throwResponseError(response, "Failed to revoke API token");
|
||||
}
|
||||
87
packages/admin/src/lib/api/bylines.ts
Normal file
87
packages/admin/src/lib/api/bylines.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
API_BASE,
|
||||
apiFetch,
|
||||
parseApiResponse,
|
||||
throwResponseError,
|
||||
type FindManyResult,
|
||||
} from "./client.js";
|
||||
|
||||
export interface BylineSummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio: string | null;
|
||||
avatarMediaId: string | null;
|
||||
websiteUrl: string | null;
|
||||
userId: string | null;
|
||||
isGuest: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BylineInput {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio?: string | null;
|
||||
avatarMediaId?: string | null;
|
||||
websiteUrl?: string | null;
|
||||
userId?: string | null;
|
||||
isGuest?: boolean;
|
||||
}
|
||||
|
||||
export interface BylineCreditInput {
|
||||
bylineId: string;
|
||||
roleLabel?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchBylines(options?: {
|
||||
search?: string;
|
||||
isGuest?: boolean;
|
||||
userId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}): Promise<FindManyResult<BylineSummary>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.search) params.set("search", options.search);
|
||||
if (options?.isGuest !== undefined) params.set("isGuest", String(options.isGuest));
|
||||
if (options?.userId) params.set("userId", options.userId);
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
|
||||
const url = `${API_BASE}/admin/bylines${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<BylineSummary>>(response, "Failed to fetch bylines");
|
||||
}
|
||||
|
||||
export async function fetchByline(id: string): Promise<BylineSummary> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`);
|
||||
return parseApiResponse<BylineSummary>(response, "Failed to fetch byline");
|
||||
}
|
||||
|
||||
export async function createByline(input: BylineInput): Promise<BylineSummary> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/bylines`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<BylineSummary>(response, "Failed to create byline");
|
||||
}
|
||||
|
||||
export async function updateByline(
|
||||
id: string,
|
||||
input: Partial<BylineInput>,
|
||||
): Promise<BylineSummary> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<BylineSummary>(response, "Failed to update byline");
|
||||
}
|
||||
|
||||
export async function deleteByline(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete byline");
|
||||
}
|
||||
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");
|
||||
}
|
||||
124
packages/admin/src/lib/api/comments.ts
Normal file
124
packages/admin/src/lib/api/comments.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Comment moderation API client
|
||||
*/
|
||||
|
||||
import {
|
||||
API_BASE,
|
||||
apiFetch,
|
||||
parseApiResponse,
|
||||
throwResponseError,
|
||||
type FindManyResult,
|
||||
} from "./client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CommentStatus = "pending" | "approved" | "spam" | "trash";
|
||||
|
||||
export interface AdminComment {
|
||||
id: string;
|
||||
collection: string;
|
||||
contentId: string;
|
||||
parentId: string | null;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
authorUserId: string | null;
|
||||
body: string;
|
||||
status: CommentStatus;
|
||||
ipHash: string | null;
|
||||
userAgent: string | null;
|
||||
moderationMetadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CommentCounts = Record<CommentStatus, number>;
|
||||
|
||||
export type BulkAction = "approve" | "spam" | "trash" | "delete";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch comments for the moderation inbox
|
||||
*/
|
||||
export async function fetchComments(options?: {
|
||||
status?: CommentStatus;
|
||||
collection?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<FindManyResult<AdminComment>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.status) params.set("status", options.status);
|
||||
if (options?.collection) params.set("collection", options.collection);
|
||||
if (options?.search) params.set("search", options.search);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
|
||||
const url = `${API_BASE}/admin/comments${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<AdminComment>>(response, "Failed to fetch comments");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch comment status counts for inbox badges
|
||||
*/
|
||||
export async function fetchCommentCounts(): Promise<CommentCounts> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/counts`);
|
||||
return parseApiResponse<CommentCounts>(response, "Failed to fetch comment counts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single comment by ID
|
||||
*/
|
||||
export async function fetchComment(id: string): Promise<AdminComment> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/${id}`);
|
||||
return parseApiResponse<AdminComment>(response, "Failed to fetch comment");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update a comment's status
|
||||
*/
|
||||
export async function updateCommentStatus(
|
||||
id: string,
|
||||
status: CommentStatus,
|
||||
): Promise<AdminComment> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/${id}/status`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
return parseApiResponse<AdminComment>(response, "Failed to update comment status");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete a comment (ADMIN only)
|
||||
*/
|
||||
export async function deleteComment(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete comment");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk status change or delete
|
||||
*/
|
||||
export async function bulkCommentAction(
|
||||
ids: string[],
|
||||
action: BulkAction,
|
||||
): Promise<{ affected: number }> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/comments/bulk`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids, action }),
|
||||
});
|
||||
return parseApiResponse<{ affected: number }>(response, "Failed to perform bulk action");
|
||||
}
|
||||
485
packages/admin/src/lib/api/content.ts
Normal file
485
packages/admin/src/lib/api/content.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* 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;
|
||||
/** Field name to order by, matching the server's whitelist. */
|
||||
orderBy?: string;
|
||||
/** Sort direction; defaults to "desc" on the server. */
|
||||
order?: "asc" | "desc";
|
||||
},
|
||||
): 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);
|
||||
if (options?.orderBy) params.set("orderBy", options.orderBy);
|
||||
if (options?.order) params.set("order", options.order);
|
||||
|
||||
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 the EmDash runtime isn't initialized on the server
|
||||
* (responds with NOT_CONFIGURED). The preview secret itself no longer
|
||||
* needs to be set explicitly — it auto-generates on first use.
|
||||
*/
|
||||
export async function getPreviewUrl(
|
||||
collection: string,
|
||||
id: string,
|
||||
options?: {
|
||||
expiresIn?: string;
|
||||
pathPattern?: string;
|
||||
},
|
||||
): Promise<PreviewUrlResponse | null> {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/preview-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(options || {}),
|
||||
});
|
||||
|
||||
if (response.status === 500) {
|
||||
// Preview not configured — check error code without consuming body for parseApiResponse
|
||||
const body: unknown = await response.json().catch(() => ({}));
|
||||
if (
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"error" in body &&
|
||||
typeof body.error === "object" &&
|
||||
body.error !== null &&
|
||||
"code" in body.error &&
|
||||
body.error.code === "NOT_CONFIGURED"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Some other 500 error
|
||||
throw new Error("Failed to get preview URL");
|
||||
}
|
||||
|
||||
return parseApiResponse<PreviewUrlResponse>(response, "Failed to get preview URL");
|
||||
} catch {
|
||||
// If preview endpoint doesn't exist or fails, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Publishing (Draft Revisions)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Publish content - promotes current draft to live
|
||||
*/
|
||||
export async function publishContent(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to publish content");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish content - removes from public, preserves draft
|
||||
*/
|
||||
export async function unpublishContent(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(
|
||||
response,
|
||||
"Failed to unpublish content",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard draft changes - reverts to live version
|
||||
*/
|
||||
export async function discardDraft(collection: string, id: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to discard draft");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare live and draft revisions
|
||||
*/
|
||||
export async function compareRevisions(
|
||||
collection: string,
|
||||
id: string,
|
||||
): Promise<{
|
||||
hasChanges: boolean;
|
||||
live: Record<string, unknown> | null;
|
||||
draft: Record<string, unknown> | null;
|
||||
}> {
|
||||
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/compare`);
|
||||
return parseApiResponse<{
|
||||
hasChanges: boolean;
|
||||
live: Record<string, unknown> | null;
|
||||
draft: Record<string, unknown> | null;
|
||||
}>(response, "Failed to compare revisions");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Revision API
|
||||
// =============================================================================
|
||||
|
||||
export interface Revision {
|
||||
id: string;
|
||||
collection: string;
|
||||
entryId: string;
|
||||
data: Record<string, unknown>;
|
||||
authorId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RevisionListResponse {
|
||||
items: Revision[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch revisions for a content item
|
||||
*/
|
||||
export async function fetchRevisions(
|
||||
collection: string,
|
||||
entryId: string,
|
||||
options?: { limit?: number },
|
||||
): Promise<RevisionListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
|
||||
const url = `${API_BASE}/content/${collection}/${entryId}/revisions${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<RevisionListResponse>(response, "Failed to fetch revisions");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific revision
|
||||
*/
|
||||
export async function fetchRevision(revisionId: string): Promise<Revision> {
|
||||
const response = await apiFetch(`${API_BASE}/revisions/${revisionId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Revision not found: ${revisionId}`);
|
||||
}
|
||||
await throwResponseError(response, "Failed to fetch revision");
|
||||
}
|
||||
|
||||
const data = await parseApiResponse<{ item: Revision }>(response, "Failed to fetch revision");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a revision (updates content to this revision's data)
|
||||
*/
|
||||
export async function restoreRevision(revisionId: string): Promise<ContentItem> {
|
||||
const response = await apiFetch(`${API_BASE}/revisions/${revisionId}/restore`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Revision not found: ${revisionId}`);
|
||||
}
|
||||
await throwResponseError(response, "Failed to restore revision");
|
||||
}
|
||||
|
||||
const data = await parseApiResponse<{ item: ContentItem }>(
|
||||
response,
|
||||
"Failed to restore revision",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
30
packages/admin/src/lib/api/current-user.ts
Normal file
30
packages/admin/src/lib/api/current-user.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Current user query — shared across Shell, Header, Sidebar, and CommandPalette.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role: number;
|
||||
avatarUrl?: string;
|
||||
isFirstLogin?: boolean;
|
||||
}
|
||||
|
||||
async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||
const response = await apiFetch("/_emdash/api/auth/me");
|
||||
return parseApiResponse<CurrentUser>(response, "Failed to fetch user");
|
||||
}
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: fetchCurrentUser,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
39
packages/admin/src/lib/api/dashboard.ts
Normal file
39
packages/admin/src/lib/api/dashboard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Dashboard stats API
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
export interface CollectionStats {
|
||||
slug: string;
|
||||
label: string;
|
||||
total: number;
|
||||
published: number;
|
||||
draft: number;
|
||||
}
|
||||
|
||||
export interface RecentItem {
|
||||
id: string;
|
||||
collection: string;
|
||||
collectionLabel: string;
|
||||
title: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
authorId: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
collections: CollectionStats[];
|
||||
mediaCount: number;
|
||||
userCount: number;
|
||||
recentItems: RecentItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch dashboard statistics
|
||||
*/
|
||||
export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
const response = await apiFetch(`${API_BASE}/dashboard`);
|
||||
return parseApiResponse<DashboardStats>(response, "Failed to fetch dashboard stats");
|
||||
}
|
||||
41
packages/admin/src/lib/api/email-settings.ts
Normal file
41
packages/admin/src/lib/api/email-settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Email settings API client functions
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface EmailProvider {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface EmailSettings {
|
||||
available: boolean;
|
||||
providers: EmailProvider[];
|
||||
selectedProviderId: string | null;
|
||||
middleware: {
|
||||
beforeSend: string[];
|
||||
afterSend: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API functions
|
||||
// =============================================================================
|
||||
|
||||
export async function fetchEmailSettings(): Promise<EmailSettings> {
|
||||
const res = await apiFetch(`${API_BASE}/settings/email`);
|
||||
return parseApiResponse<EmailSettings>(res, "Failed to fetch email settings");
|
||||
}
|
||||
|
||||
export async function sendTestEmail(to: string): Promise<{ success: boolean; message: string }> {
|
||||
const res = await apiFetch(`${API_BASE}/settings/email`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ to }),
|
||||
});
|
||||
return parseApiResponse<{ success: boolean; message: string }>(res, "Failed to send test email");
|
||||
}
|
||||
465
packages/admin/src/lib/api/import.ts
Normal file
465
packages/admin/src/lib/api/import.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* WordPress import and source probing APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
// =============================================================================
|
||||
// WordPress Import API
|
||||
// =============================================================================
|
||||
|
||||
/** Field compatibility status */
|
||||
export type FieldCompatibility =
|
||||
| "compatible" // Field exists with compatible type
|
||||
| "type_mismatch" // Field exists but type differs
|
||||
| "missing"; // Field doesn't exist
|
||||
|
||||
/** Single field definition for import */
|
||||
export interface ImportFieldDef {
|
||||
slug: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** Schema status for a collection */
|
||||
export interface CollectionSchemaStatus {
|
||||
exists: boolean;
|
||||
fieldStatus: Record<
|
||||
string,
|
||||
{
|
||||
status: FieldCompatibility;
|
||||
existingType?: string;
|
||||
requiredType: string;
|
||||
}
|
||||
>;
|
||||
canImport: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Post type with full schema info */
|
||||
export interface PostTypeAnalysis {
|
||||
name: string;
|
||||
count: number;
|
||||
suggestedCollection: string;
|
||||
requiredFields: ImportFieldDef[];
|
||||
schemaStatus: CollectionSchemaStatus;
|
||||
}
|
||||
|
||||
/** Individual attachment info for media import */
|
||||
export interface AttachmentInfo {
|
||||
id?: number;
|
||||
title?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/** Navigation menu from WordPress */
|
||||
export interface NavMenu {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Custom taxonomy from WordPress */
|
||||
export interface CustomTaxonomy {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
hierarchical: boolean;
|
||||
}
|
||||
|
||||
/** Author info from WordPress */
|
||||
export interface WpAuthorInfo {
|
||||
id?: number;
|
||||
login?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
export interface WxrAnalysis {
|
||||
site: {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
postTypes: PostTypeAnalysis[];
|
||||
attachments: {
|
||||
count: number;
|
||||
items: AttachmentInfo[];
|
||||
};
|
||||
categories: number;
|
||||
tags: number;
|
||||
authors: WpAuthorInfo[];
|
||||
customFields: Array<{
|
||||
key: string;
|
||||
count: number;
|
||||
samples: string[];
|
||||
suggestedField: string;
|
||||
suggestedType: string;
|
||||
isInternal: boolean;
|
||||
}>;
|
||||
/** Navigation menus found in the export */
|
||||
navMenus?: NavMenu[];
|
||||
/** Custom taxonomies found in the export */
|
||||
customTaxonomies?: CustomTaxonomy[];
|
||||
}
|
||||
|
||||
export interface PrepareRequest {
|
||||
postTypes: Array<{
|
||||
name: string;
|
||||
collection: string;
|
||||
fields: ImportFieldDef[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PrepareResult {
|
||||
success: boolean;
|
||||
collectionsCreated: string[];
|
||||
fieldsCreated: Array<{ collection: string; field: string }>;
|
||||
errors: Array<{ collection: string; error: string }>;
|
||||
}
|
||||
|
||||
/** Author mapping from WP author login to EmDash user ID */
|
||||
export interface AuthorMapping {
|
||||
/** WordPress author login */
|
||||
wpLogin: string;
|
||||
/** WordPress author display name (for UI) */
|
||||
wpDisplayName: string;
|
||||
/** WordPress author email (for matching) */
|
||||
wpEmail?: string;
|
||||
/** EmDash user ID to assign (null = leave unassigned) */
|
||||
emdashUserId: string | null;
|
||||
/** Number of posts by this author */
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
export interface ImportConfig {
|
||||
postTypeMappings: Record<
|
||||
string,
|
||||
{
|
||||
collection: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
>;
|
||||
skipExisting: boolean;
|
||||
/** Author mappings (WP author login -> EmDash user ID) */
|
||||
authorMappings?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: Array<{ title: string; error: string }>;
|
||||
byCollection: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a WordPress WXR file
|
||||
*/
|
||||
export async function analyzeWxr(file: File): Promise<WxrAnalysis> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/analyze`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return parseApiResponse<WxrAnalysis>(response, "Failed to analyze file");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare WordPress import (create collections/fields)
|
||||
*/
|
||||
export async function prepareWxrImport(request: PrepareRequest): Promise<PrepareResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/prepare`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
return parseApiResponse<PrepareResult>(response, "Failed to prepare import");
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute WordPress import
|
||||
*/
|
||||
export async function executeWxrImport(file: File, config: ImportConfig): Promise<ImportResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("config", JSON.stringify(config));
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/execute`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return parseApiResponse<ImportResult>(response, "Failed to import");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Media Import API
|
||||
// =============================================================================
|
||||
|
||||
export interface MediaImportResult {
|
||||
imported: Array<{
|
||||
wpId?: number;
|
||||
originalUrl: string;
|
||||
newUrl: string;
|
||||
mediaId: string;
|
||||
}>;
|
||||
failed: Array<{
|
||||
wpId?: number;
|
||||
originalUrl: string;
|
||||
error: string;
|
||||
}>;
|
||||
urlMap: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Progress update sent during streaming media import */
|
||||
export interface MediaImportProgress {
|
||||
type: "progress";
|
||||
current: number;
|
||||
total: number;
|
||||
filename?: string;
|
||||
status: "downloading" | "uploading" | "done" | "skipped" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RewriteUrlsResult {
|
||||
updated: number;
|
||||
byCollection: Record<string, number>;
|
||||
urlsRewritten: number;
|
||||
errors: Array<{ collection: string; id: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import media from WordPress with streaming progress
|
||||
*
|
||||
* @param attachments - Array of attachments to import
|
||||
* @param onProgress - Callback for progress updates (optional)
|
||||
* @returns Final import result
|
||||
*/
|
||||
export async function importWxrMedia(
|
||||
attachments: AttachmentInfo[],
|
||||
onProgress?: (progress: MediaImportProgress) => void,
|
||||
): Promise<MediaImportResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/media`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ attachments, stream: !!onProgress }),
|
||||
});
|
||||
|
||||
if (!response.ok) await throwResponseError(response, "Failed to import media");
|
||||
|
||||
// If no progress callback, just parse as JSON (non-streaming mode)
|
||||
// Note: streaming NDJSON responses are excluded from the { data } envelope
|
||||
if (!onProgress) {
|
||||
return parseApiResponse<MediaImportResult>(response, "Failed to import media");
|
||||
}
|
||||
|
||||
// Streaming mode: read NDJSON line by line
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result: MediaImportResult | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const parsed: { type?: string; imported?: unknown } = JSON.parse(line);
|
||||
if (parsed.type === "progress") {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "progress"
|
||||
onProgress(parsed as MediaImportProgress);
|
||||
} else if (parsed.type === "result" || parsed.imported) {
|
||||
// Final result (has type: "result" or is the result object)
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "result"
|
||||
result = parsed as MediaImportResult;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors for incomplete JSON
|
||||
console.warn("Failed to parse NDJSON line:", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed: { type?: string; imported?: unknown } = JSON.parse(buffer);
|
||||
if (parsed.type === "result" || parsed.imported) {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "result"
|
||||
result = parsed as MediaImportResult;
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to parse final NDJSON:", buffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error("No result received from media import");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Import Source Probing
|
||||
// =============================================================================
|
||||
|
||||
/** Capabilities of an import source */
|
||||
export interface SourceCapabilities {
|
||||
publicContent: boolean;
|
||||
privateContent: boolean;
|
||||
customPostTypes: boolean;
|
||||
allMeta: boolean;
|
||||
mediaStream: boolean;
|
||||
}
|
||||
|
||||
/** Auth requirements for import */
|
||||
export interface SourceAuth {
|
||||
type: "oauth" | "token" | "password" | "none";
|
||||
provider?: string;
|
||||
oauthUrl?: string;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
/** Suggested action after probing */
|
||||
export type SuggestedAction =
|
||||
| { type: "proceed" }
|
||||
| { type: "oauth"; url: string; provider: string }
|
||||
| { type: "upload"; instructions: string }
|
||||
| { type: "install-plugin"; instructions: string };
|
||||
|
||||
/** Result from probing a single source */
|
||||
export interface SourceProbeResult {
|
||||
sourceId: string;
|
||||
confidence: "definite" | "likely" | "possible";
|
||||
detected: {
|
||||
platform: string;
|
||||
version?: string;
|
||||
siteTitle?: string;
|
||||
siteUrl?: string;
|
||||
};
|
||||
capabilities: SourceCapabilities;
|
||||
auth?: SourceAuth;
|
||||
suggestedAction: SuggestedAction;
|
||||
preview?: {
|
||||
posts?: number;
|
||||
pages?: number;
|
||||
media?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Combined probe result */
|
||||
export interface ProbeResult {
|
||||
url: string;
|
||||
isWordPress: boolean;
|
||||
bestMatch: SourceProbeResult | null;
|
||||
allMatches: SourceProbeResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a URL to detect import source
|
||||
*/
|
||||
export async function probeImportUrl(url: string): Promise<ProbeResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/probe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const data = await parseApiResponse<{ result: ProbeResult }>(response, "Failed to probe URL");
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite URLs in content after media import
|
||||
*/
|
||||
export async function rewriteContentUrls(
|
||||
urlMap: Record<string, string>,
|
||||
collections?: string[],
|
||||
): Promise<RewriteUrlsResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress/rewrite-urls`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ urlMap, collections }),
|
||||
});
|
||||
return parseApiResponse<RewriteUrlsResult>(response, "Failed to rewrite URLs");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WordPress Plugin Direct Import API
|
||||
// =============================================================================
|
||||
|
||||
/** WordPress Plugin analysis result */
|
||||
export interface WpPluginAnalysis {
|
||||
sourceId: string;
|
||||
site: {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
postTypes: PostTypeAnalysis[];
|
||||
attachments: {
|
||||
count: number;
|
||||
items: AttachmentInfo[];
|
||||
};
|
||||
categories: number;
|
||||
tags: number;
|
||||
authors: WpAuthorInfo[];
|
||||
/** Navigation menus found via the plugin */
|
||||
navMenus?: NavMenu[];
|
||||
/** Custom taxonomies found via the plugin */
|
||||
customTaxonomies?: CustomTaxonomy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a WordPress site with EmDash Exporter plugin
|
||||
*/
|
||||
export async function analyzeWpPluginSite(url: string, token: string): Promise<WpPluginAnalysis> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress-plugin/analyze`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, token }),
|
||||
});
|
||||
const data = await parseApiResponse<{ analysis: WpPluginAnalysis }>(
|
||||
response,
|
||||
"Failed to analyze WordPress site",
|
||||
);
|
||||
return data.analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute import from WordPress plugin API
|
||||
*/
|
||||
export async function executeWpPluginImport(
|
||||
url: string,
|
||||
token: string,
|
||||
config: ImportConfig,
|
||||
): Promise<ImportResult> {
|
||||
const response = await apiFetch(`${API_BASE}/import/wordpress-plugin/execute`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, token, config }),
|
||||
});
|
||||
const data = await parseApiResponse<{ result: ImportResult }>(
|
||||
response,
|
||||
"Failed to import from WordPress",
|
||||
);
|
||||
return data.result;
|
||||
}
|
||||
356
packages/admin/src/lib/api/index.ts
Normal file
356
packages/admin/src/lib/api/index.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 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,
|
||||
fetchAuthMode,
|
||||
} 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,
|
||||
type InviteVerifyResult,
|
||||
fetchUsers,
|
||||
fetchUser,
|
||||
updateUser,
|
||||
sendRecoveryLink,
|
||||
disableUser,
|
||||
enableUser,
|
||||
inviteUser,
|
||||
validateInviteToken,
|
||||
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,
|
||||
type ApiTokenScopeValue,
|
||||
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";
|
||||
247
packages/admin/src/lib/api/marketplace.ts
Normal file
247
packages/admin/src/lib/api/marketplace.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Canonical names are the keys; legacy names alias to the same labels so
|
||||
* old manifests still render meaningful copy until they're republished.
|
||||
*/
|
||||
export const CAPABILITY_LABELS: Record<string, string> = {
|
||||
// Canonical
|
||||
"content:read": "Read your content",
|
||||
"content:write": "Create, update, and delete content",
|
||||
"media:read": "Access your media library",
|
||||
"media:write": "Upload and manage media",
|
||||
"users:read": "Read user accounts",
|
||||
"network:request": "Make network requests",
|
||||
"network:request:unrestricted": "Make network requests to any host (unrestricted)",
|
||||
// Legacy aliases (still emitted by older installed manifests)
|
||||
"read:content": "Read your content",
|
||||
"write:content": "Create, update, and delete content",
|
||||
"read:media": "Access your media library",
|
||||
"write:media": "Upload and manage media",
|
||||
"read:users": "Read user accounts",
|
||||
"network:fetch": "Make network requests",
|
||||
"network:fetch:any": "Make network requests to any host (unrestricted)",
|
||||
};
|
||||
|
||||
/** Capability names that grant scoped network access (legacy + canonical). */
|
||||
const NETWORK_REQUEST_CAPABILITIES = new Set(["network:request", "network:fetch"]);
|
||||
|
||||
/**
|
||||
* Get a human-readable description for a capability.
|
||||
* For scoped network capabilities, appends the allowed hosts if provided.
|
||||
*/
|
||||
export function describeCapability(capability: string, allowedHosts?: string[]): string {
|
||||
const base = CAPABILITY_LABELS[capability] ?? capability;
|
||||
if (NETWORK_REQUEST_CAPABILITIES.has(capability) && allowedHosts && allowedHosts.length > 0) {
|
||||
return `${base} to: ${allowedHosts.join(", ")}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
325
packages/admin/src/lib/api/media.ts
Normal file
325
packages/admin/src/lib/api/media.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Media upload, list, delete, and provider APIs
|
||||
*/
|
||||
|
||||
import {
|
||||
API_BASE,
|
||||
apiFetch,
|
||||
parseApiResponse,
|
||||
throwResponseError,
|
||||
type FindManyResult,
|
||||
} from "./client.js";
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
/** Storage key for local media (e.g., "01ABC.jpg"). Not present for external URLs. */
|
||||
storageKey?: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
createdAt: string;
|
||||
/** Provider ID for external media (e.g., "cloudflare-images") */
|
||||
provider?: string;
|
||||
/** Provider-specific metadata */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch media list
|
||||
*/
|
||||
export async function fetchMediaList(options?: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
mimeType?: string;
|
||||
}): Promise<FindManyResult<MediaItem>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.mimeType) params.set("mimeType", options.mimeType);
|
||||
|
||||
const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<MediaItem>>(response, "Failed to fetch media");
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload URL response from the API
|
||||
*/
|
||||
interface UploadUrlResponse {
|
||||
uploadUrl: string;
|
||||
method: "PUT";
|
||||
headers: Record<string, string>;
|
||||
mediaId: string;
|
||||
storageKey: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get a signed upload URL
|
||||
* Returns null if signed URLs are not supported (e.g., local storage)
|
||||
*/
|
||||
async function getUploadUrl(file: File): Promise<UploadUrlResponse | null> {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/media/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 501) {
|
||||
// Not implemented - storage doesn't support signed URLs
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseApiResponse<UploadUrlResponse>(response, "Failed to get upload URL");
|
||||
} catch (error) {
|
||||
// If the endpoint doesn't exist, fall back to direct upload
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm upload after uploading to signed URL
|
||||
*/
|
||||
async function confirmUpload(
|
||||
mediaId: string,
|
||||
metadata?: { width?: number; height?: number; size?: number },
|
||||
): Promise<MediaItem> {
|
||||
const response = await apiFetch(`${API_BASE}/media/${mediaId}/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(metadata || {}),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to confirm upload");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload directly to signed URL
|
||||
*/
|
||||
async function uploadToSignedUrl(file: File, uploadInfo: UploadUrlResponse): Promise<void> {
|
||||
const response = await fetch(uploadInfo.uploadUrl, {
|
||||
method: uploadInfo.method,
|
||||
headers: {
|
||||
...uploadInfo.headers,
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!response.ok) await throwResponseError(response, "Failed to upload file");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image dimensions from a file
|
||||
*/
|
||||
async function getImageDimensions(file: File): Promise<{ width: number; height: number } | null> {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve(null);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media file via direct upload (legacy/local storage)
|
||||
*/
|
||||
async function uploadMediaDirect(file: File): Promise<MediaItem> {
|
||||
// Get image dimensions before upload
|
||||
const dimensions = await getImageDimensions(file);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
// Send dimensions as form fields
|
||||
if (dimensions?.width) formData.append("width", String(dimensions.width));
|
||||
if (dimensions?.height) formData.append("height", String(dimensions.height));
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/media`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to upload media");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media file
|
||||
*
|
||||
* Tries signed URL upload first (for S3/R2 storage), falls back to direct upload
|
||||
* (for local storage) if signed URLs are not supported.
|
||||
*/
|
||||
export async function uploadMedia(file: File): Promise<MediaItem> {
|
||||
// Try to get a signed upload URL
|
||||
const uploadInfo = await getUploadUrl(file);
|
||||
|
||||
if (!uploadInfo) {
|
||||
// Signed URLs not supported, use direct upload
|
||||
return uploadMediaDirect(file);
|
||||
}
|
||||
|
||||
// Upload directly to storage via signed URL
|
||||
await uploadToSignedUrl(file, uploadInfo);
|
||||
|
||||
// Get image dimensions for confirmation
|
||||
const dimensions = await getImageDimensions(file);
|
||||
|
||||
// Confirm the upload
|
||||
return confirmUpload(uploadInfo.mediaId, {
|
||||
size: file.size,
|
||||
width: dimensions?.width,
|
||||
height: dimensions?.height,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media
|
||||
*/
|
||||
export async function deleteMedia(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/media/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete media");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update media metadata (dimensions, alt text, etc.)
|
||||
*/
|
||||
export async function updateMedia(
|
||||
id: string,
|
||||
input: { alt?: string; caption?: string; width?: number; height?: number },
|
||||
): Promise<MediaItem> {
|
||||
const response = await apiFetch(`${API_BASE}/media/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to update media");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Media Providers API
|
||||
// =============================================================================
|
||||
|
||||
/** Media provider capabilities */
|
||||
export interface MediaProviderCapabilities {
|
||||
browse: boolean;
|
||||
search: boolean;
|
||||
upload: boolean;
|
||||
delete: boolean;
|
||||
}
|
||||
|
||||
/** Media provider info from the API */
|
||||
export interface MediaProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
capabilities: MediaProviderCapabilities;
|
||||
}
|
||||
|
||||
/** Media item from a provider */
|
||||
export interface MediaProviderItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
previewUrl?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all configured media providers
|
||||
*/
|
||||
export async function fetchMediaProviders(): Promise<MediaProviderInfo[]> {
|
||||
const response = await apiFetch(`${API_BASE}/media/providers`);
|
||||
const data = await parseApiResponse<{ items: MediaProviderInfo[] }>(
|
||||
response,
|
||||
"Failed to fetch media providers",
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch media items from a specific provider
|
||||
*/
|
||||
export async function fetchProviderMedia(
|
||||
providerId: string,
|
||||
options?: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
query?: string;
|
||||
mimeType?: string;
|
||||
},
|
||||
): Promise<FindManyResult<MediaProviderItem>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.query) params.set("query", options.query);
|
||||
if (options?.mimeType) params.set("mimeType", options.mimeType);
|
||||
|
||||
const url = `${API_BASE}/media/providers/${providerId}${params.toString() ? `?${params}` : ""}`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<FindManyResult<MediaProviderItem>>(
|
||||
response,
|
||||
"Failed to fetch provider media",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media to a specific provider
|
||||
*/
|
||||
export async function uploadToProvider(
|
||||
providerId: string,
|
||||
file: File,
|
||||
alt?: string,
|
||||
): Promise<MediaProviderItem> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (alt) formData.append("alt", alt);
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/media/providers/${providerId}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await parseApiResponse<{ item: MediaProviderItem }>(
|
||||
response,
|
||||
"Failed to upload to provider",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media from a specific provider
|
||||
*/
|
||||
export async function deleteFromProvider(providerId: string, itemId: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/media/providers/${providerId}/${itemId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete from provider");
|
||||
}
|
||||
180
packages/admin/src/lib/api/menus.ts
Normal file
180
packages/admin/src/lib/api/menus.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Menu management APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export interface Menu {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
itemCount?: number;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
menu_id: string;
|
||||
parent_id: string | null;
|
||||
sort_order: number;
|
||||
type: string;
|
||||
reference_collection: string | null;
|
||||
reference_id: string | null;
|
||||
custom_url: string | null;
|
||||
label: string;
|
||||
title_attr: string | null;
|
||||
target: string | null;
|
||||
css_classes: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MenuWithItems extends Menu {
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export interface CreateMenuInput {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface UpdateMenuInput {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface CreateMenuItemInput {
|
||||
type: string;
|
||||
label: string;
|
||||
referenceCollection?: string;
|
||||
referenceId?: string;
|
||||
customUrl?: string;
|
||||
target?: string;
|
||||
titleAttr?: string;
|
||||
cssClasses?: string;
|
||||
parentId?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface UpdateMenuItemInput {
|
||||
label?: string;
|
||||
customUrl?: string;
|
||||
target?: string;
|
||||
titleAttr?: string;
|
||||
cssClasses?: string;
|
||||
parentId?: string | null;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface ReorderMenuItemsInput {
|
||||
items: Array<{
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all menus
|
||||
*/
|
||||
export async function fetchMenus(): Promise<Menu[]> {
|
||||
const response = await apiFetch(`${API_BASE}/menus`);
|
||||
return parseApiResponse<Menu[]>(response, "Failed to fetch menus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single menu with items
|
||||
*/
|
||||
export async function fetchMenu(name: string): Promise<MenuWithItems> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${name}`);
|
||||
return parseApiResponse<MenuWithItems>(response, "Failed to fetch menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a menu
|
||||
*/
|
||||
export async function createMenu(input: CreateMenuInput): Promise<Menu> {
|
||||
const response = await apiFetch(`${API_BASE}/menus`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Menu>(response, "Failed to create menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a menu
|
||||
*/
|
||||
export async function updateMenu(name: string, input: UpdateMenuInput): Promise<Menu> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Menu>(response, "Failed to update menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a menu
|
||||
*/
|
||||
export async function deleteMenu(name: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${name}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a menu item
|
||||
*/
|
||||
export async function createMenuItem(
|
||||
menuName: string,
|
||||
input: CreateMenuItemInput,
|
||||
): Promise<MenuItem> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<MenuItem>(response, "Failed to create menu item");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a menu item
|
||||
*/
|
||||
export async function updateMenuItem(
|
||||
menuName: string,
|
||||
itemId: string,
|
||||
input: UpdateMenuItemInput,
|
||||
): Promise<MenuItem> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<MenuItem>(response, "Failed to update menu item");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a menu item
|
||||
*/
|
||||
export async function deleteMenuItem(menuName: string, itemId: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete menu item");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder menu items
|
||||
*/
|
||||
export async function reorderMenuItems(
|
||||
menuName: string,
|
||||
input: ReorderMenuItemsInput,
|
||||
): Promise<MenuItem[]> {
|
||||
const response = await apiFetch(`${API_BASE}/menus/${menuName}/reorder`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<MenuItem[]>(response, "Failed to reorder menu items");
|
||||
}
|
||||
78
packages/admin/src/lib/api/plugins.ts
Normal file
78
packages/admin/src/lib/api/plugins.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Plugin management APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
package?: string;
|
||||
enabled: boolean;
|
||||
status: "installed" | "active" | "inactive";
|
||||
capabilities: string[];
|
||||
hasAdminPages: boolean;
|
||||
hasDashboardWidgets: boolean;
|
||||
hasHooks: boolean;
|
||||
installedAt?: string;
|
||||
activatedAt?: string;
|
||||
deactivatedAt?: string;
|
||||
/** Plugin source: 'config' (declared in astro.config) or 'marketplace' */
|
||||
source?: "config" | "marketplace";
|
||||
/** Installed marketplace version (set when source = 'marketplace') */
|
||||
marketplaceVersion?: string;
|
||||
/** Description of what the plugin does */
|
||||
description?: string;
|
||||
/** URL to the plugin icon (marketplace plugins use the icon proxy) */
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all plugins
|
||||
*/
|
||||
export async function fetchPlugins(): Promise<PluginInfo[]> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins`);
|
||||
const result = await parseApiResponse<{ items: PluginInfo[] }>(
|
||||
response,
|
||||
"Failed to fetch plugins",
|
||||
);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single plugin
|
||||
*/
|
||||
export async function fetchPlugin(pluginId: string): Promise<PluginInfo> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Plugin "${pluginId}" not found`);
|
||||
}
|
||||
await throwResponseError(response, "Failed to fetch plugin");
|
||||
}
|
||||
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to fetch plugin");
|
||||
return result.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a plugin
|
||||
*/
|
||||
export async function enablePlugin(pluginId: string): Promise<PluginInfo> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/enable`, {
|
||||
method: "POST",
|
||||
});
|
||||
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to enable plugin");
|
||||
return result.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a plugin
|
||||
*/
|
||||
export async function disablePlugin(pluginId: string): Promise<PluginInfo> {
|
||||
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/disable`, {
|
||||
method: "POST",
|
||||
});
|
||||
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to disable plugin");
|
||||
return result.item;
|
||||
}
|
||||
127
packages/admin/src/lib/api/redirects.ts
Normal file
127
packages/admin/src/lib/api/redirects.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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;
|
||||
loopRedirectIds?: 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;
|
||||
}
|
||||
333
packages/admin/src/lib/api/schema.ts
Normal file
333
packages/admin/src/lib/api/schema.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Schema/collection/field management APIs (Content Type Builder)
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export type FieldType =
|
||||
| "string"
|
||||
| "text"
|
||||
| "url"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "datetime"
|
||||
| "select"
|
||||
| "multiSelect"
|
||||
| "portableText"
|
||||
| "image"
|
||||
| "file"
|
||||
| "reference"
|
||||
| "json"
|
||||
| "slug"
|
||||
| "repeater";
|
||||
|
||||
export interface SchemaCollection {
|
||||
id: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports: string[];
|
||||
source?: string;
|
||||
urlPattern?: string;
|
||||
hasSeo: boolean;
|
||||
commentsEnabled: boolean;
|
||||
commentsModeration: "all" | "first_time" | "none";
|
||||
commentsClosedAfterDays: number;
|
||||
commentsAutoApproveUsers: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SchemaField {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
columnType: string;
|
||||
required: boolean;
|
||||
unique: boolean;
|
||||
searchable: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
options?: string[];
|
||||
};
|
||||
widget?: string;
|
||||
options?: Record<string, unknown>;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SchemaCollectionWithFields extends SchemaCollection {
|
||||
fields: SchemaField[];
|
||||
}
|
||||
|
||||
export interface CreateCollectionInput {
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports?: string[];
|
||||
urlPattern?: string;
|
||||
hasSeo?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCollectionInput {
|
||||
label?: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports?: string[];
|
||||
urlPattern?: string;
|
||||
hasSeo?: boolean;
|
||||
commentsEnabled?: boolean;
|
||||
commentsModeration?: "all" | "first_time" | "none";
|
||||
commentsClosedAfterDays?: number;
|
||||
commentsAutoApproveUsers?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateFieldInput {
|
||||
slug: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required?: boolean;
|
||||
unique?: boolean;
|
||||
searchable?: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
options?: string[];
|
||||
};
|
||||
widget?: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateFieldInput {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
unique?: boolean;
|
||||
searchable?: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
options?: string[];
|
||||
};
|
||||
widget?: string;
|
||||
options?: Record<string, unknown>;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all collections
|
||||
*/
|
||||
export async function fetchCollections(): Promise<SchemaCollection[]> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections`);
|
||||
const data = await parseApiResponse<{ items: SchemaCollection[] }>(
|
||||
response,
|
||||
"Failed to fetch collections",
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single collection with fields
|
||||
*/
|
||||
export async function fetchCollection(
|
||||
slug: string,
|
||||
includeFields = true,
|
||||
): Promise<SchemaCollectionWithFields> {
|
||||
const url = includeFields
|
||||
? `${API_BASE}/schema/collections/${slug}?includeFields=true`
|
||||
: `${API_BASE}/schema/collections/${slug}`;
|
||||
const response = await apiFetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Collection "${slug}" not found`);
|
||||
}
|
||||
await throwResponseError(response, "Failed to fetch collection");
|
||||
}
|
||||
const data = await parseApiResponse<{ item: SchemaCollectionWithFields }>(
|
||||
response,
|
||||
"Failed to fetch collection",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collection
|
||||
*/
|
||||
export async function createCollection(input: CreateCollectionInput): Promise<SchemaCollection> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: SchemaCollection }>(
|
||||
response,
|
||||
"Failed to create collection",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a collection
|
||||
*/
|
||||
export async function updateCollection(
|
||||
slug: string,
|
||||
input: UpdateCollectionInput,
|
||||
): Promise<SchemaCollection> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections/${slug}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: SchemaCollection }>(
|
||||
response,
|
||||
"Failed to update collection",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a collection
|
||||
*/
|
||||
export async function deleteCollection(slug: string, force = false): Promise<void> {
|
||||
const url = force
|
||||
? `${API_BASE}/schema/collections/${slug}?force=true`
|
||||
: `${API_BASE}/schema/collections/${slug}`;
|
||||
const response = await apiFetch(url, { method: "DELETE" });
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete collection");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fields for a collection
|
||||
*/
|
||||
export async function fetchFields(collectionSlug: string): Promise<SchemaField[]> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections/${collectionSlug}/fields`);
|
||||
const data = await parseApiResponse<{ items: SchemaField[] }>(response, "Failed to fetch fields");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a field
|
||||
*/
|
||||
export async function createField(
|
||||
collectionSlug: string,
|
||||
input: CreateFieldInput,
|
||||
): Promise<SchemaField> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/collections/${collectionSlug}/fields`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: SchemaField }>(response, "Failed to create field");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field
|
||||
*/
|
||||
export async function updateField(
|
||||
collectionSlug: string,
|
||||
fieldSlug: string,
|
||||
input: UpdateFieldInput,
|
||||
): Promise<SchemaField> {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/schema/collections/${collectionSlug}/fields/${fieldSlug}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
const data = await parseApiResponse<{ item: SchemaField }>(response, "Failed to update field");
|
||||
return data.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a field
|
||||
*/
|
||||
export async function deleteField(collectionSlug: string, fieldSlug: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/schema/collections/${collectionSlug}/fields/${fieldSlug}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete field");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder fields
|
||||
*/
|
||||
export async function reorderFields(collectionSlug: string, fieldSlugs: string[]): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/schema/collections/${collectionSlug}/fields/reorder`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fieldSlugs }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) await throwResponseError(response, "Failed to reorder fields");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Orphaned Tables
|
||||
// ============================================
|
||||
|
||||
export interface OrphanedTable {
|
||||
slug: string;
|
||||
tableName: string;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch orphaned content tables
|
||||
*/
|
||||
export async function fetchOrphanedTables(): Promise<OrphanedTable[]> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/orphans`);
|
||||
const data = await parseApiResponse<{ items: OrphanedTable[] }>(
|
||||
response,
|
||||
"Failed to fetch orphaned tables",
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an orphaned table as a collection
|
||||
*/
|
||||
export async function registerOrphanedTable(
|
||||
slug: string,
|
||||
options?: {
|
||||
label?: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<SchemaCollection> {
|
||||
const response = await apiFetch(`${API_BASE}/schema/orphans/${slug}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(options || {}),
|
||||
});
|
||||
const data = await parseApiResponse<{ item: SchemaCollection }>(
|
||||
response,
|
||||
"Failed to register orphaned table",
|
||||
);
|
||||
return data.item;
|
||||
}
|
||||
31
packages/admin/src/lib/api/search.ts
Normal file
31
packages/admin/src/lib/api/search.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Search enable/disable APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
export interface SearchEnableResult {
|
||||
success: boolean;
|
||||
collection: string;
|
||||
enabled: boolean;
|
||||
indexed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable search for a collection
|
||||
*/
|
||||
export async function setSearchEnabled(
|
||||
collection: string,
|
||||
enabled: boolean,
|
||||
weights?: Record<string, number>,
|
||||
): Promise<SearchEnableResult> {
|
||||
const response = await apiFetch(`${API_BASE}/search/enable`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ collection, enabled, weights }),
|
||||
});
|
||||
return parseApiResponse<SearchEnableResult>(
|
||||
response,
|
||||
`Failed to ${enabled ? "enable" : "disable"} search`,
|
||||
);
|
||||
}
|
||||
108
packages/admin/src/lib/api/sections.ts
Normal file
108
packages/admin/src/lib/api/sections.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Sections API (reusable content blocks)
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export type SectionSource = "theme" | "user" | "import";
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords: string[];
|
||||
content: unknown[]; // Portable Text
|
||||
previewUrl?: string;
|
||||
source: SectionSource;
|
||||
themeId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSectionInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
content: unknown[];
|
||||
previewMediaId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSectionInput {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
content?: unknown[];
|
||||
previewMediaId?: string | null;
|
||||
}
|
||||
|
||||
export interface GetSectionsOptions {
|
||||
source?: SectionSource;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface SectionsResult {
|
||||
items: Section[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all sections
|
||||
*/
|
||||
export async function fetchSections(options?: GetSectionsOptions): Promise<SectionsResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.source) params.set("source", options.source);
|
||||
if (options?.search) params.set("search", options.search);
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
|
||||
const url = params.toString() ? `${API_BASE}/sections?${params}` : `${API_BASE}/sections`;
|
||||
const response = await apiFetch(url);
|
||||
return parseApiResponse<SectionsResult>(response, "Failed to fetch sections");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single section by slug
|
||||
*/
|
||||
export async function fetchSection(slug: string): Promise<Section> {
|
||||
const response = await apiFetch(`${API_BASE}/sections/${slug}`);
|
||||
return parseApiResponse<Section>(response, "Failed to fetch section");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a section
|
||||
*/
|
||||
export async function createSection(input: CreateSectionInput): Promise<Section> {
|
||||
const response = await apiFetch(`${API_BASE}/sections`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Section>(response, "Failed to create section");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a section
|
||||
*/
|
||||
export async function updateSection(slug: string, input: UpdateSectionInput): Promise<Section> {
|
||||
const response = await apiFetch(`${API_BASE}/sections/${slug}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parseApiResponse<Section>(response, "Failed to update section");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a section
|
||||
*/
|
||||
export async function deleteSection(slug: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/sections/${slug}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete section");
|
||||
}
|
||||
62
packages/admin/src/lib/api/settings.ts
Normal file
62
packages/admin/src/lib/api/settings.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Site settings APIs
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
|
||||
|
||||
export interface SiteSettings {
|
||||
// Identity
|
||||
title: string;
|
||||
tagline?: string;
|
||||
logo?: { mediaId: string; alt?: string; url?: string };
|
||||
favicon?: { mediaId: string; url?: string };
|
||||
|
||||
// URLs
|
||||
url?: string;
|
||||
|
||||
// Display
|
||||
postsPerPage: number;
|
||||
dateFormat: string;
|
||||
timezone: string;
|
||||
|
||||
// Social
|
||||
social?: {
|
||||
twitter?: string;
|
||||
github?: string;
|
||||
facebook?: string;
|
||||
instagram?: string;
|
||||
linkedin?: string;
|
||||
youtube?: string;
|
||||
};
|
||||
|
||||
// SEO
|
||||
seo?: {
|
||||
titleSeparator?: string;
|
||||
defaultOgImage?: { mediaId: string; alt?: string; url?: string };
|
||||
robotsTxt?: string;
|
||||
googleVerification?: string;
|
||||
bingVerification?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch site settings
|
||||
*/
|
||||
export async function fetchSettings(): Promise<Partial<SiteSettings>> {
|
||||
const response = await apiFetch(`${API_BASE}/settings`);
|
||||
return parseApiResponse<Partial<SiteSettings>>(response, "Failed to fetch settings");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update site settings
|
||||
*/
|
||||
export async function updateSettings(
|
||||
settings: Partial<SiteSettings>,
|
||||
): Promise<Partial<SiteSettings>> {
|
||||
const response = await apiFetch(`${API_BASE}/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
return parseApiResponse<Partial<SiteSettings>>(response, "Failed to update settings");
|
||||
}
|
||||
134
packages/admin/src/lib/api/taxonomies.ts
Normal file
134
packages/admin/src/lib/api/taxonomies.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Taxonomies API (categories, tags, custom taxonomies)
|
||||
*/
|
||||
|
||||
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
|
||||
|
||||
export interface TaxonomyTerm {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
children: TaxonomyTerm[];
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface TaxonomyDef {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
hierarchical: boolean;
|
||||
collections: string[];
|
||||
}
|
||||
|
||||
export interface CreateTaxonomyInput {
|
||||
name: string;
|
||||
label: string;
|
||||
hierarchical?: boolean;
|
||||
collections?: string[];
|
||||
}
|
||||
|
||||
export interface CreateTermInput {
|
||||
slug: string;
|
||||
label: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTermInput {
|
||||
slug?: string;
|
||||
label?: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all taxonomy definitions
|
||||
*/
|
||||
export async function fetchTaxonomyDefs(): Promise<TaxonomyDef[]> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies`);
|
||||
const data = await parseApiResponse<{ taxonomies: TaxonomyDef[] }>(
|
||||
response,
|
||||
"Failed to fetch taxonomies",
|
||||
);
|
||||
return data.taxonomies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch taxonomy definition by name
|
||||
*/
|
||||
export async function fetchTaxonomyDef(name: string): Promise<TaxonomyDef | null> {
|
||||
const defs = await fetchTaxonomyDefs();
|
||||
return defs.find((t) => t.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom taxonomy definition
|
||||
*/
|
||||
export async function createTaxonomy(input: CreateTaxonomyInput): Promise<TaxonomyDef> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ taxonomy: TaxonomyDef }>(
|
||||
response,
|
||||
"Failed to create taxonomy",
|
||||
);
|
||||
return data.taxonomy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch terms for a taxonomy
|
||||
*/
|
||||
export async function fetchTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms`);
|
||||
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(response, "Failed to fetch terms");
|
||||
return data.terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a term
|
||||
*/
|
||||
export async function createTerm(
|
||||
taxonomyName: string,
|
||||
input: CreateTermInput,
|
||||
): Promise<TaxonomyTerm> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ term: TaxonomyTerm }>(response, "Failed to create term");
|
||||
return data.term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a term
|
||||
*/
|
||||
export async function updateTerm(
|
||||
taxonomyName: string,
|
||||
slug: string,
|
||||
input: UpdateTermInput,
|
||||
): Promise<TaxonomyTerm> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await parseApiResponse<{ term: TaxonomyTerm }>(response, "Failed to update term");
|
||||
return data.term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a term
|
||||
*/
|
||||
export async function deleteTerm(taxonomyName: string, slug: string): Promise<void> {
|
||||
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) await throwResponseError(response, "Failed to delete term");
|
||||
}
|
||||
114
packages/admin/src/lib/api/theme-marketplace.ts
Normal file
114
packages/admin/src/lib/api/theme-marketplace.ts
Normal 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;
|
||||
}
|
||||
446
packages/admin/src/lib/api/users.ts
Normal file
446
packages/admin/src/lib/api/users.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Invite Accept API (for invited users completing registration)
|
||||
// =============================================================================
|
||||
|
||||
/** Invite token verification result */
|
||||
export interface InviteVerifyResult {
|
||||
email: string;
|
||||
role: number;
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an invite token and return the invite data.
|
||||
*
|
||||
* Uses custom error handling to preserve error codes for the UI.
|
||||
*/
|
||||
export async function validateInviteToken(token: string): Promise<InviteVerifyResult> {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/auth/invite/accept?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: unknown = await response.json().catch(() => ({}));
|
||||
let message = `Invite validation 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<InviteVerifyResult>(response, "Invite validation failed");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
168
packages/admin/src/lib/api/widgets.ts
Normal file
168
packages/admin/src/lib/api/widgets.ts
Normal 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;
|
||||
}
|
||||
62
packages/admin/src/lib/auth-provider-context.tsx
Normal file
62
packages/admin/src/lib/auth-provider-context.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Auth Provider Context
|
||||
*
|
||||
* Provides pluggable auth provider UI components (LoginButton, LoginForm, SetupStep)
|
||||
* to the admin UI via React context. Auth providers are registered in astro.config.ts
|
||||
* and their admin components are bundled via the virtual:emdash/auth-providers module.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/** Shape of a single auth provider's admin exports */
|
||||
export interface AuthProviderModule {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Compact button for the login page (icon + label) */
|
||||
LoginButton?: React.ComponentType;
|
||||
/** Full form if the provider needs custom input (e.g., handle field) */
|
||||
LoginForm?: React.ComponentType;
|
||||
/** Component for the setup wizard admin creation step */
|
||||
SetupStep?: React.ComponentType<{ onComplete: () => void }>;
|
||||
}
|
||||
|
||||
/** All auth provider modules keyed by provider ID */
|
||||
export type AuthProviders = Record<string, AuthProviderModule>;
|
||||
|
||||
const AuthProviderContext = createContext<AuthProviders>({});
|
||||
|
||||
export interface AuthProviderContextProps {
|
||||
children: React.ReactNode;
|
||||
authProviders: AuthProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that makes auth provider components available to all descendants
|
||||
*/
|
||||
export function AuthProviderProvider({ children, authProviders }: AuthProviderContextProps) {
|
||||
return (
|
||||
<AuthProviderContext.Provider value={authProviders}>{children}</AuthProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all auth provider modules
|
||||
*/
|
||||
export function useAuthProviders(): AuthProviders {
|
||||
return useContext(AuthProviderContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth providers as an ordered array (buttons first, then forms)
|
||||
*/
|
||||
export function useAuthProviderList(): AuthProviderModule[] {
|
||||
const providers = useContext(AuthProviderContext);
|
||||
const list = Object.values(providers);
|
||||
// Sort: providers with only LoginButton first (compact), then those with LoginForm
|
||||
return list.toSorted((a, b) => {
|
||||
const aHasForm = a.LoginForm ? 1 : 0;
|
||||
const bHasForm = b.LoginForm ? 1 : 0;
|
||||
return aHasForm - bHasForm;
|
||||
});
|
||||
}
|
||||
28
packages/admin/src/lib/datetime-local.ts
Normal file
28
packages/admin/src/lib/datetime-local.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Helpers for round-tripping `datetime` field values through
|
||||
* `<input type="datetime-local">`.
|
||||
*
|
||||
* Stored field values are full ISO 8601 (`YYYY-MM-DDTHH:mm:ss.sssZ`) or
|
||||
* date-only (`YYYY-MM-DD`) per the per-field zod schema in
|
||||
* `packages/core/src/schema/zod-generator.ts`. The browser input only
|
||||
* accepts `YYYY-MM-DDTHH:mm`, so widgets must convert in both directions.
|
||||
*
|
||||
* The widget treats the value as UTC for a stable round-trip. Using
|
||||
* `new Date(...).toISOString()` (the convention in the publish-schedule
|
||||
* UI in `ContentEditor.tsx`) would shift values by the local-UTC offset on
|
||||
* every save, mutating the persisted time on each edit cycle.
|
||||
*/
|
||||
|
||||
/** Format a stored datetime field value for the input. */
|
||||
export function toDatetimeLocalInputValue(value: unknown): string {
|
||||
if (typeof value !== "string" || value === "") return "";
|
||||
// `YYYY-MM-DD` (date-only branch of the schema): pad to UTC midnight.
|
||||
if (value.length === 10) return `${value}T00:00`;
|
||||
// Full ISO 8601: take the date + `HH:mm` prefix.
|
||||
return value.slice(0, 16);
|
||||
}
|
||||
|
||||
/** Convert an input value (`YYYY-MM-DDTHH:mm`) back to the stored ISO shape. */
|
||||
export function fromDatetimeLocalInputValue(value: string): string {
|
||||
return value === "" ? "" : `${value}:00.000Z`;
|
||||
}
|
||||
31
packages/admin/src/lib/hooks.ts
Normal file
31
packages/admin/src/lib/hooks.ts
Normal 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;
|
||||
}
|
||||
37
packages/admin/src/lib/media-utils.ts
Normal file
37
packages/admin/src/lib/media-utils.ts
Normal 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];
|
||||
}
|
||||
83
packages/admin/src/lib/plugin-context.tsx
Normal file
83
packages/admin/src/lib/plugin-context.tsx
Normal 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;
|
||||
}
|
||||
15
packages/admin/src/lib/pluginBlocks.ts
Normal file
15
packages/admin/src/lib/pluginBlocks.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { PluginBlockDef } from "../components/PortableTextEditor";
|
||||
import type { AdminManifest } from "./api";
|
||||
|
||||
/** Extract plugin block definitions from the manifest for the Portable Text editor. */
|
||||
export function getPluginBlocks(manifest: AdminManifest): PluginBlockDef[] {
|
||||
const blocks: PluginBlockDef[] = [];
|
||||
for (const [pluginId, plugin] of Object.entries(manifest.plugins)) {
|
||||
if (plugin.portableTextBlocks) {
|
||||
for (const block of plugin.portableTextBlocks) {
|
||||
blocks.push({ ...block, pluginId });
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
68
packages/admin/src/lib/taxonomy-match.ts
Normal file
68
packages/admin/src/lib/taxonomy-match.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Taxonomy term matching for the admin picker.
|
||||
*
|
||||
* The picker filters an editor's typed input against existing terms. A naive
|
||||
* `label.toLowerCase().includes(input.toLowerCase())` fails the accent case:
|
||||
* typing `"Mexico"` does not substring-match a term labeled `"México"`
|
||||
* because `"méxico".toLowerCase()` is still `"méxico"` and
|
||||
* `"méxico".includes("mexico")` is `false`. The editor then sees zero
|
||||
* suggestions and creates a duplicate `"Mexico"` term alongside the
|
||||
* canonical `"México"`, splitting the taxonomy.
|
||||
*
|
||||
* This module folds diacritics via NFD decomposition before substring
|
||||
* matching. No regexes are compiled from user input, so there is no ReDoS
|
||||
* surface.
|
||||
*/
|
||||
|
||||
const DIACRITIC_RANGE = /[\u0300-\u036f]/g;
|
||||
|
||||
/**
|
||||
* Case-fold + diacritic-fold normalization for substring matching.
|
||||
*
|
||||
* `"México"`, `"mexico"`, `"MÉXICO"` all collapse to `"mexico"`.
|
||||
*
|
||||
* NFD decomposes accented characters into a base + combining-diacritic
|
||||
* sequence; the regex drops the combiners. Greek tonos, Vietnamese
|
||||
* stacked diacritics, and other Latin-adjacent scripts are covered.
|
||||
* Combining marks used meaningfully in non-Latin scripts (Arabic harakat
|
||||
* U+064B–U+0652, Japanese dakuten U+3099) fall outside the U+0300–036F
|
||||
* block and are left untouched — stripping them would change meaning.
|
||||
*/
|
||||
export function foldForMatch(value: string): string {
|
||||
return value.normalize("NFD").replace(DIACRITIC_RANGE, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal shape a term must have to participate in matching.
|
||||
* Kept structural so picker components and tests can use plain objects.
|
||||
*/
|
||||
export interface MatchableTerm {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `input` is a substring of the term's label, ignoring case and
|
||||
* diacritics.
|
||||
*
|
||||
* Empty or whitespace-only input returns `false` — the caller decides
|
||||
* whether to show all terms or none in that state. The whitespace guard
|
||||
* matters: without it, a needle of `" "` would `.includes()`-match
|
||||
* every term whose label contains a space.
|
||||
*/
|
||||
export function termMatches(term: MatchableTerm, input: string): boolean {
|
||||
const needle = foldForMatch(input).trim();
|
||||
if (!needle) return false;
|
||||
return foldForMatch(term.label).includes(needle);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `input` is an exact (fold-equal) match for the term's label.
|
||||
* Used to decide whether to show the "Create new term" button — if an
|
||||
* editor types `"Mexico"` and a term labeled `"México"` already exists,
|
||||
* Create must not appear or they'll produce a duplicate.
|
||||
*/
|
||||
export function termExactMatches(term: MatchableTerm, input: string): boolean {
|
||||
const needle = foldForMatch(input).trim();
|
||||
if (!needle) return false;
|
||||
return foldForMatch(term.label).trim() === needle;
|
||||
}
|
||||
57
packages/admin/src/lib/url.ts
Normal file
57
packages/admin/src/lib/url.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Shared URL validation and transformation utilities
|
||||
*/
|
||||
|
||||
const DEFAULT_REDIRECT = "/_emdash/admin";
|
||||
const LEADING_SLASHES = /^\/+/;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a public content URL from collection metadata and slug.
|
||||
*
|
||||
* Uses the collection's `urlPattern` when available (e.g. `/blog/{slug}`),
|
||||
* otherwise falls back to `/{collection}/{slug}`. Leading slashes are
|
||||
* stripped from the slug to prevent protocol-relative URLs.
|
||||
*/
|
||||
export function contentUrl(collection: string, slug: string, urlPattern?: string): string {
|
||||
const safe = slug.replace(LEADING_SLASHES, "");
|
||||
return urlPattern ? urlPattern.replace("{slug}", safe) : `/${collection}/${safe}`;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
53
packages/admin/src/lib/utils.ts
Normal file
53
packages/admin/src/lib/utils.ts
Normal 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, "");
|
||||
}
|
||||
25
packages/admin/src/lib/webauthn-environment.ts
Normal file
25
packages/admin/src/lib/webauthn-environment.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* WebAuthn is only available in a browser "secure context": HTTPS, or special-cased
|
||||
* loopback hosts such as `http://localhost` / `http://127.0.0.1`.
|
||||
*
|
||||
* An origin like `http://emdash.local:8081` resolves to 127.0.0.1 but is still
|
||||
* **not** a secure context, so `PublicKeyCredential` is hidden — the same symptom
|
||||
* as an unsupported browser.
|
||||
*/
|
||||
|
||||
export function isWebAuthnSecureContext(): boolean {
|
||||
return typeof window !== "undefined" && window.isSecureContext;
|
||||
}
|
||||
|
||||
export function isPublicKeyCredentialConstructorAvailable(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.PublicKeyCredential !== undefined &&
|
||||
typeof window.PublicKeyCredential === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the page can use `navigator.credentials` for passkeys. */
|
||||
export function isPasskeyEnvironmentUsable(): boolean {
|
||||
return isWebAuthnSecureContext() && isPublicKeyCredentialConstructorAvailable();
|
||||
}
|
||||
Reference in New Issue
Block a user