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:
Peter Meier
2026-03-12 14:21:49 +01:00
parent aad93d145f
commit 7795a238e1
278 changed files with 15551 additions and 4072 deletions

View File

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

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

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