RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Peter Meier
2026-02-16 09:30:30 +01:00
commit aad93d145f
224 changed files with 19225 additions and 0 deletions

View File

@@ -0,0 +1,632 @@
"use client";
import { useId } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import type { SchemaDefinition, FieldDefinition } from "@/lib/api";
import { checkSlug } from "@/lib/api";
import { ReferenceArrayField } from "./ReferenceArrayField";
import { MarkdownEditor } from "./MarkdownEditor";
type Props = {
collection: string;
schema: SchemaDefinition;
initialValues?: Record<string, unknown>;
slug?: string;
locale?: string;
onSuccess?: () => void;
};
/** _slug field with format and duplicate check via API. */
function SlugField({
collection,
currentSlug,
locale,
register,
setError,
clearErrors,
error,
}: {
collection: string;
currentSlug?: string;
locale?: string;
register: ReturnType<typeof useForm>["register"];
setError: ReturnType<typeof useForm>["setError"];
clearErrors: ReturnType<typeof useForm>["clearErrors"];
error: ReturnType<typeof useForm>["formState"]["errors"]["_slug"];
}) {
const { ref, onChange, onBlur, name } = register("_slug", {
required: "Slug is required.",
});
const handleBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
onBlur(e);
const value = e.target.value?.trim();
if (!value) return;
try {
const res = await checkSlug(collection, value, {
exclude: currentSlug ?? undefined,
_locale: locale,
});
if (!res.valid && res.error) {
setError("_slug", { type: "server", message: res.error });
return;
}
if (!res.available) {
setError("_slug", {
type: "server",
message: "Slug already in use.",
});
return;
}
clearErrors("_slug");
} catch {
// Network error etc. no setError, user will see it on submit
}
};
return (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
_slug <span className="text-red-500">*</span>
</label>
<input
type="text"
ref={ref}
name={name}
onChange={onChange}
onBlur={handleBlur}
placeholder="e.g. my-post"
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 font-mono"
autoComplete="off"
/>
<p className="mt-1 text-xs text-gray-500">
Lowercase letters (a-z), digits (0-9), hyphens. Spaces become hyphens.
</p>
{error ? (
<p className="mt-1 text-sm text-red-600">{String(error.message)}</p>
) : null}
</div>
);
}
/** Builds form initial values from API response: references → slug strings, objects recursively. */
function buildDefaultValues(
schema: SchemaDefinition,
initialValues?: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const fields = schema.fields ?? {};
if (initialValues) {
for (const [key, value] of Object.entries(initialValues)) {
const def = fields[key] as FieldDefinition | undefined;
if (value == null) {
out[key] = value;
continue;
}
if (def?.type === "reference") {
out[key] =
typeof value === "object" && value !== null && "_slug" in value
? String((value as { _slug?: string })._slug ?? "")
: typeof value === "string"
? value
: "";
continue;
}
if (
def?.type === "array" &&
def.items?.type === "reference" &&
Array.isArray(value)
) {
out[key] = value.map((v) =>
typeof v === "object" && v !== null && "_slug" in v
? (v as { _slug: string })._slug
: String(v),
);
continue;
}
if (
def?.type === "object" &&
def.fields &&
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
out[key] = buildDefaultValues(
{ name: key, fields: def.fields as Record<string, FieldDefinition> },
value as Record<string, unknown>,
);
continue;
}
out[key] = value;
}
}
for (const key of Object.keys(fields)) {
if (!(key in out)) {
const def = fields[key] as FieldDefinition | undefined;
if (def?.default !== undefined && def.default !== null) {
if (
def?.type === "object" &&
def.fields &&
typeof def.default === "object" &&
def.default !== null &&
!Array.isArray(def.default)
) {
out[key] = buildDefaultValues(
{
name: key,
fields: def.fields as Record<string, FieldDefinition>,
},
def.default as Record<string, unknown>,
);
} else {
out[key] = def.default;
}
} else if (def?.type === "boolean") out[key] = false;
else if (def?.type === "array")
out[key] =
(def as FieldDefinition).items?.type === "reference" ? [] : "";
else if (def?.type === "object" && def.fields)
out[key] = buildDefaultValues(
{ name: key, fields: def.fields as Record<string, FieldDefinition> },
{},
);
else out[key] = "";
}
}
return out;
}
export function ContentForm({
collection,
schema,
initialValues,
slug,
locale,
onSuccess,
}: Props) {
const router = useRouter();
const isEdit = !!slug;
const defaultValues = buildDefaultValues(schema, initialValues);
const {
register,
handleSubmit,
setError,
clearErrors,
control,
formState: { errors, isSubmitting },
} = useForm({
defaultValues,
});
const onSubmit = async (data: Record<string, unknown>) => {
const payload: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const def = schema.fields?.[key];
if (def?.type === "array" && def.items?.type === "reference") {
payload[key] = Array.isArray(value)
? value
: typeof value === "string"
? value
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
: [];
} else {
payload[key] = value;
}
}
if (isEdit) {
delete payload._slug;
}
try {
const params = locale ? { _locale: locale } : {};
if (isEdit) {
const { updateEntry } = await import("@/lib/api");
await updateEntry(collection, slug!, payload, params);
onSuccess?.();
} else {
const { createEntry } = await import("@/lib/api");
await createEntry(collection, payload, params);
onSuccess?.();
const newSlug = payload._slug as string | undefined;
const q = locale ? `?_locale=${locale}` : "";
if (newSlug) {
router.push(
`/content/${collection}/${encodeURIComponent(newSlug)}${q}`,
);
} else {
router.push(`/content/${collection}${q}`);
}
}
} catch (err) {
setError("root", {
message: err instanceof Error ? err.message : "Error saving",
});
}
};
const fields = schema.fields ?? {};
const slugParam = locale ? `?_locale=${locale}` : "";
const showSlugField = !isEdit && !(fields._slug != null);
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-4">
{errors.root && (
<div className="rounded bg-red-50 p-3 text-sm text-red-700">
{errors.root.message}
</div>
)}
{showSlugField && (
<SlugField
collection={collection}
currentSlug={slug}
locale={locale}
register={register}
setError={setError}
clearErrors={clearErrors}
error={errors._slug}
/>
)}
{Object.entries(fields).map(([name, def]) =>
(def as FieldDefinition).type === "object" &&
(def as FieldDefinition).fields ? (
<ObjectFieldSet
key={name}
name={name}
def={def as FieldDefinition}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
) : (
<Field
key={name}
name={name}
def={def as FieldDefinition}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
),
)}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50"
>
{isSubmitting ? "Saving…" : "Save"}
</button>
{isEdit && (
<a
href={`/content/${collection}${slugParam}`}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Back to list
</a>
)}
</div>
</form>
);
}
/** Get error for path "layout.mobile" from errors.layout.mobile */
function getFieldError(errors: Record<string, unknown>, name: string): unknown {
const parts = name.split(".");
let current: unknown = errors;
for (const p of parts) {
current =
current != null && typeof current === "object" && p in current
? (current as Record<string, unknown>)[p]
: undefined;
}
return current;
}
function ObjectFieldSet({
name,
def,
register,
control,
errors,
isEdit,
locale,
}: {
name: string;
def: FieldDefinition;
register: ReturnType<typeof useForm>["register"];
control: ReturnType<typeof useForm>["control"];
errors: ReturnType<typeof useForm>["formState"]["errors"];
isEdit: boolean;
locale?: string;
}) {
const nestedFields = def.fields ?? {};
const displayName = name.split(".").pop() ?? name;
return (
<fieldset className="rounded border border-gray-200 bg-gray-50/50 p-4">
<legend className="mb-3 text-sm font-medium text-gray-700">
{displayName}
</legend>
<div className="space-y-4">
{Object.entries(nestedFields).map(([subName, subDef]) => (
<Field
key={subName}
name={`${name}.${subName}`}
def={subDef as FieldDefinition}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
))}
</div>
</fieldset>
);
}
function Field({
name,
def,
register,
control,
errors,
isEdit,
locale,
}: {
name: string;
def: FieldDefinition;
register: ReturnType<typeof useForm>["register"];
control: ReturnType<typeof useForm>["control"];
errors: ReturnType<typeof useForm>["formState"]["errors"];
isEdit: boolean;
locale?: string;
}) {
const type = (def.type ?? "string") as string;
const required = !!def.required;
const isSlug = name === "_slug";
const readonly = isEdit && isSlug;
const fieldError = getFieldError(errors as Record<string, unknown>, name);
const fieldId = useId();
const fieldDescription = def.description ? (
<p className="mt-0.5 text-xs text-gray-500">{def.description}</p>
) : null;
const label = (
<>
<label className="mb-1 block text-sm font-bold text-gray-700">
{name.split(".").pop()}
{required && <span className="text-red-500"> *</span>}
</label>
{fieldDescription}
</>
);
if (type === "boolean") {
return (
<div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id={fieldId}
{...register(name)}
className="mt-0.5 h-5 w-5 rounded border-gray-300 focus:ring-gray-400"
/>
<label
htmlFor={fieldId}
className="cursor-pointer text-sm font-medium text-gray-700"
>
{name.split(".").pop()}
{required && <span className="text-red-500"> *</span>}
</label>
</div>
{fieldDescription}
</div>
);
}
if (type === "number") {
return (
<div>
{label}
<input
type="number"
step="any"
{...register(name, { valueAsNumber: true, required })}
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
if (type === "integer") {
return (
<div>
{label}
<input
type="number"
step={1}
{...register(name, { valueAsNumber: true, required })}
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
/* Markdown: editor with live preview */
if (type === "markdown") {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<MarkdownEditor
value={typeof field.value === "string" ? field.value : ""}
onChange={field.onChange}
label={label}
error={fieldError}
required={required}
readOnly={readonly}
rows={12}
/>
)}
/>
);
}
/* Lange Texte: richtext, html → Textarea */
if (type === "richtext" || type === "html") {
return (
<div>
{label}
<textarea
{...register(name, { required })}
rows={10}
readOnly={readonly}
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 font-mono"
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
if (type === "datetime") {
return (
<div>
{label}
<input
type="datetime-local"
{...register(name, { required })}
readOnly={readonly}
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
if (type === "array" && def.items?.type === "reference") {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<ReferenceArrayField
name={name}
def={def}
value={Array.isArray(field.value) ? field.value : []}
onChange={field.onChange}
required={required}
error={fieldError}
locale={locale}
label={label}
/>
)}
/>
);
}
if (type === "reference") {
return (
<div>
{label}
<input
type="text"
{...register(name, { required })}
placeholder="Slug of referenced entry"
readOnly={readonly}
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 font-mono"
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
/* Nested objects (e.g. file.details, file.details.image) recursively as fieldset */
if (type === "object" && def.fields) {
return (
<ObjectFieldSet
name={name}
def={def}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
);
}
// string, default
const enumValues = def.enum as string[] | undefined;
if (Array.isArray(enumValues) && enumValues.length > 0) {
return (
<div>
{label}
<select
{...register(name, { required })}
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
>
<option value=""> Please select </option>
{enumValues.map((v) => (
<option key={String(v)} value={String(v)}>
{String(v)}
</option>
))}
</select>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
return (
<div>
{label}
<input
type="text"
{...register(name, { required })}
readOnly={readonly}
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}