RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
632
admin-ui/src/components/ContentForm.tsx
Normal file
632
admin-ui/src/components/ContentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user