Skip to content
ilinxa/pro-ui

Content Composer

alphav0.3.1

Multi-step content authoring shell — each content type is a JSON config composing form, rich text, and media editing steps.

Category: MediaUpdated: 2026-08-11Created: 2026-06-04Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/content-composer

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/content-composer-fixtures

CLI 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

Optional. Leave blank to let the backend derive it.

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.

ComposerConfig · JSON
valid

52 lines · valid

Live preview

Nothing rendered yet

Edit the JSON on the left, then press Submit to render the live result on the right.

Demo source

demo.tsxtsx
"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&apos;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 via mediaSlot) and post (multi-media viamediaCarouselSlot → carousel-composer — drop/browse N photo+video, reorder, per-item edit). Post authoring is fully live; only its publish path (the post-content-item adapter + 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 with useComposerState.
  • Override or extend substrates via the substrates prop; custom json-form fields ship as tagsFieldRenderer + 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.

Tags

content-composercomposercmsauthoringmulti-stepjson-configshell

Dependencies

shadcn primitives: badge, button, command, dialog, input, popover, separator, textarea
npm peer deps: lucide-react@^1.11.0
internal: media-editor, carousel-composer, rich-text-editor, json-form, news-card