Content Composer
alphav0.3.1Multi-step content authoring shell — each content type is a JSON config composing form, rich text, and media editing steps.
Context
A single procomp shell for CMS content authoring. It owns the cross-cutting lifecycle — step navigation, dialog/inline presentation, autosave, dirty tracking, the draft → publish → schedule state machine, and the between-step validation gates — and mounts four substrate slots per step: metadataFields → json-form, bodySlot → rich-text-editor/Plate (or a plaintext fallback), mediaSlot → media-editor (single hero), mediaCarouselSlot → carousel-composer (multi-media post). Each content type is one declarative ComposerConfig; per-type adapters map the collected draft to/from the backend ContentItem (news-card's NewsCardItem). The wrapping CMS pro-page owns routing, data, permissions, and the upload implementation. v0.2 ships news (single hero) + post (multi-media carousel) configs; post authoring is live, its publish/upload deferred to the v0.3 post backend; event/project follow as JSON files.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/content-composerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/content-composer-fixturesPreview
Headline
Live playground
This composer is defined entirely by a JSON ComposerConfig. Edit it on the left — when it's valid, press Submit to render a fully-functional composer from your config on the right (step nav, gates, autosave, and the metadata / body / media / carousel slots all live). Publish/Save assemble a result via a playground adapter.
52 lines · valid
Nothing rendered yet
Edit the JSON on the left, then press Submit to render the live result on the right.
Demo source
Usage
When to use
Reach for ContentComposer when you need a multi-step content-authoring surface in a CMS — one shell that composes structured metadata fields (json-form), a rich body (rich-text-editor / Plate or a plaintext fallback), and a captured/edited hero (media-editor). Each content type is one declarative ComposerConfig; adding a type is a JSON file, not a new component. The shell owns step navigation, the blocking gates, autosave, the draft → publish → schedule lifecycle, and the upload.
Basic example
import {
ContentComposer,
createNewsComposerConfig,
} from "@/components/content-composer"
const newsConfig = createNewsComposerConfig({
// async author loader for the author-picker field (optional)
authorSource: (q) => fetchAuthors(q),
})
export function NewsComposer() {
return (
<ContentComposer
config={newsConfig}
// the SHELL owns upload — pass a fn (or the uploadUrl shorthand)
uploader={async (blob, meta) => {
const url = await uploadToStorage(blob, meta.mimeType)
return { url }
}}
onAutosave={(draft) => persistDraft(draft)} // debounced (~800ms)
onSaveDraft={(item) => saveContentItem(item)} // status: "draft"
onPublish={(item) => saveContentItem(item)} // status: "published"
onSchedule={(item, at) => scheduleItem(item, at)}
/>
)
}Re-editing an item
<ContentComposer
config={newsConfig}
initialItem={existingArticle} // drives the inverse adapter
initialBody={persistedBodyValue} // body is NOT on NewsCardItem
uploader={uploader}
onPublish={(item) => patchContentItem(item)}
/>On re-publish the adapter omits engagement counts (likeCount, views, …) — it never zeroes them — so your PATCH/merge preserves the real numbers.
Notes
- Configs are data. Two ship:
news(single hero viamediaSlot) andpost(multi-media viamediaCarouselSlot→carousel-composer— drop/browse N photo+video, reorder, per-item edit). Post authoring is fully live; only its publish path (thepost-content-itemadapter + multi-blob upload-at-publish) is deferred to the v0.3 post backend. Adding a type is a JSON file, not a new component. - Upload is lazy. The hero blob is captured when you leave the media step and uploaded only at save/publish/schedule — never stored in the draft JSON. Autosave persists the editor state + the uploaded URL, not the blob.
- Gates are blocking.Forward navigation runs each step's gate; backward is free. Publish/schedule re-run every gate. A referenced slot with no registered substrate renders a degraded fallback (non-blocking) instead.
- Draft state is a controlled triplet (
value/defaultValue/onChange) — or stay headless withuseComposerState. - Override or extend substrates via the
substratesprop; customjson-formfields ship astagsFieldRenderer+authorPickerFieldRenderer.
Features
- Single configurable shell — a new content type is one JSON config, not a new component
- Four substrate slots per step: metadataFields (json-form) / bodySlot (rich-text-editor Plate or plaintext) / mediaSlot (media-editor single hero) / mediaCarouselSlot (carousel-composer multi-media)
- Draft → publish → schedule state machine (schedule = publish with a future publishAt)
- Blocking between-step validation gates (forward-gated, backward-free; publish re-runs all)
- Autosave split: per-mutation onDraftChange vs debounced onAutosave; aggregated dirty across three asymmetric slots
- Controlled / uncontrolled draft triplet (value / defaultValue / onChange)
- Per-content-type adapters: collected draft ↔ NewsCardItem (CMS re-edit round-trip)
- Shell owns upload (uploader / uploadUrl); lazy upload-on-publish
- Inline / dialog / auto presentation
- Multi-media post step (v0.2): `mediaCarouselSlot` backed by carousel-composer — drop/browse N mixed photo+video, reorder, per-item edit (news keeps the single mediaSlot)
- v0.3.1 — mediaSlot's MediaEditor mount wires the `@ilinxa/media-editor-capture` extension (news hero step enables camera intake); no public-API change.