RustyCMS: file-based headless CMS — API, Admin UI (content, types, assets), Docker/Caddy, image transform; only demo type and demo content in version control
Made-with: Cursor
This commit is contained in:
@@ -42,6 +42,8 @@ export type FieldDefinition = {
|
||||
enum?: unknown[];
|
||||
default?: unknown;
|
||||
items?: FieldDefinition;
|
||||
/** Optional section key for grouping fields in the admin UI (collapsible blocks). */
|
||||
section?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -99,6 +101,34 @@ export async function createSchema(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateSchema(
|
||||
name: string,
|
||||
schema: SchemaDefinition
|
||||
): Promise<SchemaDefinition> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/schemas/${encodeURIComponent(name)}`, {
|
||||
method: "PUT",
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(schema),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
const msg = (err as { error?: string }).error ?? (err as { errors?: string[] })?.errors?.join(", ") ?? `Update schema: ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSchema(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/schemas/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error ?? `Delete schema: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type SlugCheckResponse = {
|
||||
valid: boolean;
|
||||
normalized: string;
|
||||
@@ -177,8 +207,11 @@ export async function createEntry(
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { message?: string }).message || `Create: ${res.status}`);
|
||||
const err = await res.json().catch(() => ({})) as { error?: string; errors?: string[] };
|
||||
const msg = err.errors?.length ? err.errors.join("\n") : err.error ?? `Create: ${res.status}`;
|
||||
const e = new Error(msg) as Error & { apiErrors?: string[] };
|
||||
e.apiErrors = err.errors;
|
||||
throw e;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -199,12 +232,199 @@ export async function updateEntry(
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { message?: string }).message || `Update: ${res.status}`);
|
||||
const err = await res.json().catch(() => ({})) as { error?: string; errors?: string[] };
|
||||
const msg = err.errors?.length ? err.errors.join("\n") : err.error ?? `Update: ${res.status}`;
|
||||
const e = new Error(msg) as Error & { apiErrors?: string[] };
|
||||
e.apiErrors = err.errors;
|
||||
throw e;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type Asset = {
|
||||
filename: string;
|
||||
folder: string | null;
|
||||
url: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type AssetsResponse = {
|
||||
assets: Asset[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type AssetFolder = {
|
||||
name: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type FoldersResponse = {
|
||||
folders: AssetFolder[];
|
||||
};
|
||||
|
||||
/** folder=undefined → all; folder="" → root only; folder="name" → specific folder */
|
||||
export async function fetchAssets(folder?: string): Promise<AssetsResponse> {
|
||||
const url = new URL(`${getBaseUrl()}/api/assets`);
|
||||
if (folder !== undefined) url.searchParams.set("folder", folder);
|
||||
const res = await fetch(url.toString(), { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Assets: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFolders(): Promise<FoldersResponse> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Folders: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createFolder(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders`, {
|
||||
method: "POST",
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error ?? `Create folder: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFolder(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error ?? `Delete folder: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const getUploadHeaders = (): HeadersInit => {
|
||||
const key =
|
||||
typeof window !== "undefined"
|
||||
? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null
|
||||
: process.env.RUSTYCMS_API_KEY ?? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null;
|
||||
const headers: HeadersInit = {};
|
||||
if (key) headers["X-API-Key"] = key;
|
||||
return headers;
|
||||
};
|
||||
|
||||
export async function uploadAsset(file: File, folder?: string): Promise<Asset> {
|
||||
const url = new URL(`${getBaseUrl()}/api/assets`);
|
||||
if (folder) url.searchParams.set("folder", folder);
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const res = await fetch(url.toString(), { method: "POST", headers: getUploadHeaders(), body });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error ?? `Upload: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** path = "hero.jpg" or "blog/hero.jpg" */
|
||||
export async function deleteAsset(path: string): Promise<void> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/${path}`, {
|
||||
method: "DELETE",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error ?? `Delete asset: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rename asset; path = "hero.jpg" or "blog/hero.jpg", newFilename = "newname.jpg" */
|
||||
export async function renameAsset(path: string, newFilename: string): Promise<Asset> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/${path}`, {
|
||||
method: "PATCH",
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ filename: newFilename }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error ?? `Rename: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Query params for GET /api/transform (w, h, ar, fit, format). */
|
||||
export type TransformParams = {
|
||||
w?: number;
|
||||
h?: number;
|
||||
ar?: string;
|
||||
fit?: "fill" | "contain" | "cover";
|
||||
format?: "jpeg" | "png" | "webp" | "avif";
|
||||
quality?: number;
|
||||
};
|
||||
|
||||
/** Build URL for transformed image (same origin or full URL). */
|
||||
export function getTransformUrl(imageUrl: string, params: TransformParams): string {
|
||||
const base = getBaseUrl();
|
||||
const fullUrl = imageUrl.startsWith("http") ? imageUrl : `${base}${imageUrl.startsWith("/") ? "" : "/"}${imageUrl}`;
|
||||
const u = new URL(`${base}/api/transform`);
|
||||
u.searchParams.set("url", fullUrl);
|
||||
if (params.w != null) u.searchParams.set("w", String(params.w));
|
||||
if (params.h != null) u.searchParams.set("h", String(params.h));
|
||||
if (params.ar != null) u.searchParams.set("ar", params.ar);
|
||||
if (params.fit != null) u.searchParams.set("fit", params.fit);
|
||||
if (params.format != null) u.searchParams.set("format", params.format);
|
||||
if (params.quality != null) u.searchParams.set("quality", String(params.quality));
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/** Build a filename for a transformed copy: base-w800-h600.webp */
|
||||
export function getTransformedFilename(originalFilename: string, params: TransformParams): string {
|
||||
const lastDot = originalFilename.lastIndexOf(".");
|
||||
const base = lastDot >= 0 ? originalFilename.slice(0, lastDot) : originalFilename;
|
||||
const ext = (params.format ?? "jpeg") === "jpeg" ? "jpg" : (params.format ?? "jpeg");
|
||||
const parts: string[] = [];
|
||||
if (params.w != null) parts.push(`w${params.w}`);
|
||||
if (params.h != null) parts.push(`h${params.h}`);
|
||||
if (params.ar != null) parts.push(`ar${params.ar.replace(":", "x")}`);
|
||||
if (params.fit != null) parts.push(params.fit);
|
||||
const suffix = parts.length ? `-${parts.join("-")}` : "";
|
||||
return `${base}${suffix}.${ext}`;
|
||||
}
|
||||
|
||||
/** Fetch transformed image and upload as new asset. Returns the new asset. */
|
||||
export async function copyAssetWithTransformation(
|
||||
asset: Asset,
|
||||
params: TransformParams,
|
||||
folder?: string
|
||||
): Promise<Asset> {
|
||||
const imageUrl = `${getBaseUrl()}${asset.url}`;
|
||||
const transformUrl = getTransformUrl(asset.url, params);
|
||||
const res = await fetch(transformUrl);
|
||||
if (!res.ok) throw new Error(`Transform failed: ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const contentType = res.headers.get("Content-Type") ?? "image/jpeg";
|
||||
const ext = (params.format ?? "jpeg") === "jpeg" ? "jpg" : (params.format ?? "jpeg");
|
||||
const suggestedName = getTransformedFilename(asset.filename, params);
|
||||
const file = new File([blob], suggestedName, { type: contentType });
|
||||
return uploadAsset(file, folder);
|
||||
}
|
||||
|
||||
export type LocalesResponse = {
|
||||
locales: string[];
|
||||
default: string | null;
|
||||
};
|
||||
|
||||
export async function fetchLocales(): Promise<LocalesResponse> {
|
||||
try {
|
||||
const res = await fetch(`${getBaseUrl()}/api/locales`, {
|
||||
headers: getHeaders(),
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
} catch { /* API not reachable */ }
|
||||
const envLocales = (process.env.NEXT_PUBLIC_RUSTYCMS_LOCALES ?? "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return { locales: envLocales, default: envLocales[0] ?? null };
|
||||
}
|
||||
|
||||
export async function deleteEntry(
|
||||
collection: string,
|
||||
slug: string,
|
||||
|
||||
12
admin-ui/src/lib/referenceOptionLabel.ts
Normal file
12
admin-ui/src/lib/referenceOptionLabel.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Build a display label for a reference option (entry from any collection).
|
||||
* Used by ReferenceField and ReferenceArrayField for consistent option labels.
|
||||
*/
|
||||
export function getOptionLabel(item: Record<string, unknown>): string {
|
||||
const slug = String(item._slug ?? "");
|
||||
const name = item.name != null ? String(item.name) : "";
|
||||
const headline = item.headline != null ? String(item.headline) : "";
|
||||
const linkName = item.linkName != null ? String(item.linkName) : "";
|
||||
const full = name || headline || linkName || slug;
|
||||
return full !== slug ? `${slug} — ${full}` : slug;
|
||||
}
|
||||
6
admin-ui/src/lib/utils.ts
Normal file
6
admin-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user