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 initpnpm dlx shadcn@latest add @ilinxa/content-composerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/content-composer-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
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
"use client"; import * as React from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { Button } from "@/components/ui/button";import { ContentComposer } from "./content-composer";import { createNewsComposerConfig } from "./configs/news-composer.config";import { postComposerConfig } from "./configs/post-composer.config";import { SAMPLE_AUTHORS, SAMPLE_NEWS_BODY, SAMPLE_NEWS_ITEM } from "./dummy-data";import type { AuthorSourceConfig } from "./parts/field-author-picker";import type { NewsCardItem } from "./types"; const sampleAuthorSource: AuthorSourceConfig = async (query) => { const q = query.trim().toLowerCase(); return SAMPLE_AUTHORS.filter((a) => a.name.toLowerCase().includes(q));}; // Fake uploader — returns a local object URL so Publish resolves in the demo// (NOT a network endpoint).const demoUploader = async (blob: Blob) => ({ url: URL.createObjectURL(blob) }); const newsConfig = createNewsComposerConfig({ authorSource: sampleAuthorSource }); type LastAction = | { kind: "draft" | "publish" | "schedule"; item: NewsCardItem; at?: Date } | null; function ResultPanel({ last }: { last: LastAction }) { if (!last) return null; const heading = last.kind === "draft" ? "Saved draft" : last.kind === "publish" ? "Published" : `Scheduled for ${last.at?.toLocaleString()}`; return ( <div className="rounded-lg border border-border bg-muted/30 p-3 text-xs"> <p className="font-medium text-foreground"> {heading}: {last.item.title} </p> <pre className="mt-2 max-h-48 overflow-auto wrap-break-word whitespace-pre-wrap font-mono text-[10px] text-muted-foreground"> {JSON.stringify(last.item, null, 2)} </pre> </div> );} function NewsComposerDemo({ reEdit }: { reEdit?: boolean }) { const [last, setLast] = React.useState<LastAction>(null); return ( <div className="flex flex-col gap-4"> <ContentComposer config={newsConfig} uploader={demoUploader} {...(reEdit ? { initialItem: SAMPLE_NEWS_ITEM, initialBody: SAMPLE_NEWS_BODY } : {})} onAutosave={() => {}} onSaveDraft={(item) => setLast({ kind: "draft", item })} onPublish={(item) => setLast({ kind: "publish", item })} onSchedule={(item, at) => setLast({ kind: "schedule", item, at })} /> <ResultPanel last={last} /> </div> );} export default function ContentComposerDemo() { const [tab, setTab] = React.useState("news"); const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogLast, setDialogLast] = React.useState<LastAction>(null); return ( <Tabs value={tab} onValueChange={setTab} className="w-full"> <SwipeTabsList> <TabsTrigger value="news">News</TabsTrigger> <TabsTrigger value="re-edit">Re-edit</TabsTrigger> <TabsTrigger value="post">Post (carousel)</TabsTrigger> <TabsTrigger value="dialog">Dialog</TabsTrigger> <TabsTrigger value="dark">Dark</TabsTrigger> </SwipeTabsList> <TabsContent value="news" className="mt-4"> <NewsComposerDemo /> </TabsContent> <TabsContent value="re-edit" className="mt-4"> <p className="mb-3 text-sm text-muted-foreground"> Seeded from a published article (plus its persisted body). On re-publish the adapter omits engagement counts, so the page preserves the real numbers. </p> <NewsComposerDemo reEdit /> </TabsContent> <TabsContent value="post" className="mt-4"> <p className="mb-3 text-sm text-muted-foreground"> The post config's media step is a <code>mediaCarouselSlot</code> backed by <code> carousel-composer</code> — drop / browse one or more mixed photo+video files, reorder, and edit any photo (news keeps the single{" "} <code>mediaSlot</code>). Publishing is still deferred (no{" "} <code>post-content-item</code> adapter yet). </p> <ContentComposer config={postComposerConfig} uploader={demoUploader} onAutosave={() => {}} onSaveDraft={() => {}} onPublish={() => {}} /> </TabsContent> <TabsContent value="dialog" className="mt-4"> <Button type="button" onClick={() => setDialogOpen(true)}> Open composer dialog </Button> <ContentComposer config={newsConfig} presentation="dialog" isOpen={dialogOpen} onClose={() => setDialogOpen(false)} uploader={demoUploader} onAutosave={() => {}} onSaveDraft={(item) => setDialogLast({ kind: "draft", item })} onPublish={(item) => { setDialogLast({ kind: "publish", item }); setDialogOpen(false); }} /> <div className="mt-4"> <ResultPanel last={dialogLast} /> </div> </TabsContent> <TabsContent value="dark" className="mt-4"> <div className="dark rounded-xl border border-border bg-background p-4 text-foreground"> <NewsComposerDemo /> </div> </TabsContent> </Tabs> );} 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.