{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carousel-composer",
  "title": "Carousel Composer",
  "author": "ilinxa",
  "description": "Multi-item media post composer — drag in photos and videos, reorder them on a rail, and edit each through a shared editor panel.",
  "dependencies": [
    "@dnd-kit/core",
    "@dnd-kit/sortable",
    "@dnd-kit/utilities",
    "lucide-react"
  ],
  "registryDependencies": [
    "@ilinxa/media-editor",
    "button",
    "scroll-area"
  ],
  "files": [
    {
      "path": "src/registry/components/media/carousel-composer/carousel-composer.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  DndContext,\n  KeyboardSensor,\n  PointerSensor,\n  closestCenter,\n  useSensor,\n  useSensors,\n  type Announcements,\n  type DragEndEvent,\n} from \"@dnd-kit/core\";\nimport { arrayMove, sortableKeyboardCoordinates } from \"@dnd-kit/sortable\";\nimport { AlertCircle, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  DEFAULT_CAROUSEL_LABELS,\n  type CarouselComposerHandle,\n  type CarouselComposerProps,\n} from \"./types\";\nimport { useCarouselState } from \"./hooks/use-carousel-state\";\nimport { filesToItems } from \"./lib/file-intake\";\nimport { clampSources } from \"./lib/clamp-sources\";\nimport { aspectToCss, resolveAspect } from \"./lib/aspect\";\nimport { MediaDropzone } from \"./parts/media-dropzone\";\nimport { PreviewRail } from \"./parts/preview-rail\";\nimport { MainPreview } from \"./parts/main-preview\";\nimport { EditPanel } from \"./parts/edit-panel\";\n\n/**\n * carousel-composer — a multi-item media composer (Instagram-feed-post\n * semantics): drag-drop / browse one-or-more mixed photo+video files into an\n * ordered, reorderable rail with a main preview, and edit any item through a\n * single shared `media-editor` instance (loaded serially, never N at once).\n * Composes media-editor without modifying it.\n */\nfunction CarouselComposerImpl(\n  props: CarouselComposerProps,\n  ref: React.Ref<CarouselComposerHandle>,\n) {\n  const {\n    value,\n    defaultValue,\n    onChange,\n    maxItems = 10,\n    maxFileSizeMb = 50,\n    accept,\n    sources,\n    aspect = \"auto\",\n    editorProps,\n    labels: labelOverrides,\n    className,\n    revokeOnUnmount,\n    onItemAdd,\n    onItemRemove,\n    onReorder,\n    onSelect,\n    onEditOpen,\n    onEditApply,\n    onEditCancel,\n    onValidationError,\n    onMaxItemsExceeded,\n  } = props;\n\n  const labels = useMemo(\n    () => ({ ...DEFAULT_CAROUSEL_LABELS, ...labelOverrides }),\n    [labelOverrides],\n  );\n  const acceptKinds = useMemo(\n    () => accept ?? [\"image\" as const, \"video\" as const],\n    [accept],\n  );\n  // v0.1: \"library\" is clamped out — intake is upload-only (drop / browse).\n  const uploadEnabled = useMemo(\n    () => clampSources(sources).includes(\"upload\"),\n    [sources],\n  );\n\n  const [ingesting, setIngesting] = useState(false);\n  const [errors, setErrors] = useState<string[]>([]);\n  const [announce, setAnnounce] = useState(\"\");\n\n  const fillTemplate = (tpl: string) => tpl.replace(\"{max}\", String(maxItems));\n\n  const state = useCarouselState({\n    value,\n    defaultValue,\n    onChange,\n    maxItems,\n    revokeOnUnmount,\n    onItemAdd,\n    onItemRemove: (id) => {\n      onItemRemove?.(id);\n      setAnnounce(labels.remove);\n    },\n    onReorder: (items) => {\n      onReorder?.(items);\n      setAnnounce(labels.reorderHint);\n    },\n    onSelect,\n    onEditOpen,\n    onEditApply,\n    onEditCancel,\n    onMaxItemsExceeded: (attempted, max) => {\n      onMaxItemsExceeded?.(attempted, max);\n      const msg = fillTemplate(labels.maxReached);\n      setErrors((prev) => (prev.includes(msg) ? prev : [...prev, msg]));\n      setAnnounce(msg);\n    },\n  });\n\n  // Latest items for stable async reads (file intake + imperative handle).\n  const itemsRef = useRef(state.items);\n  useEffect(() => {\n    itemsRef.current = state.items;\n  });\n\n  const resolvedAspect = useMemo(\n    () => resolveAspect(state.items, aspect),\n    [state.items, aspect],\n  );\n\n  const addFiles = async (files: File[] | FileList) => {\n    setErrors([]);\n    setIngesting(true);\n    try {\n      const res = await filesToItems(files, {\n        accept: acceptKinds,\n        maxFileSizeMb,\n      });\n      res.errors.forEach((err) => onValidationError?.(err));\n      if (res.errors.length > 0) {\n        setErrors((prev) => [...prev, ...res.errors.map((e) => e.message)]);\n      }\n      // addItems caps to maxItems synchronously + fires onMaxItemsExceeded.\n      if (res.items.length > 0) {\n        state.addItems(res.items);\n        setAnnounce(\n          `${res.items.length} item${res.items.length === 1 ? \"\" : \"s\"} added`,\n        );\n      }\n    } finally {\n      setIngesting(false);\n    }\n  };\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  );\n\n  const dndAnnouncements: Announcements = useMemo(() => {\n    const pos = (id: string | number) =>\n      itemsRef.current.findIndex((it) => it.id === id) + 1;\n    const total = () => itemsRef.current.length;\n    return {\n      onDragStart: ({ active }) => `Picked up item ${pos(active.id)}.`,\n      onDragOver: ({ active, over }) =>\n        over ? `Item ${pos(active.id)} over position ${pos(over.id)}.` : \"\",\n      onDragEnd: ({ active, over }) =>\n        over\n          ? `Item dropped at position ${pos(over.id)} of ${total()}.`\n          : `Item ${pos(active.id)} dropped.`,\n      onDragCancel: ({ active }) =>\n        `Reorder cancelled; item ${pos(active.id)} returned.`,\n    };\n  }, []);\n\n  const onDragEnd = (event: DragEndEvent) => {\n    const { active, over } = event;\n    if (!over || active.id === over.id) return;\n    const items = itemsRef.current;\n    const oldIndex = items.findIndex((it) => it.id === active.id);\n    const newIndex = items.findIndex((it) => it.id === over.id);\n    if (oldIndex < 0 || newIndex < 0) return;\n    state.reorder(arrayMove(items, oldIndex, newIndex));\n  };\n\n  useImperativeHandle(\n    ref,\n    (): CarouselComposerHandle => ({\n      getItems: () => itemsRef.current.map((it) => ({ ...it })),\n      export: async () => itemsRef.current.map((it) => ({ ...it })),\n      addFiles,\n      removeItem: state.removeItem,\n      select: state.select,\n      openEditor: state.openEditor,\n      reset: state.reset,\n    }),\n    // addFiles is recreated each render (intentional); the rest are stable.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [state.removeItem, state.select, state.openEditor, state.reset],\n  );\n\n  const isEmpty = state.items.length === 0;\n  const isEditing = state.editingId !== null && state.editingItem !== null;\n  const canAddMore = uploadEnabled && state.items.length < maxItems;\n\n  return (\n    <div className={cn(\"flex flex-col gap-3\", className)}>\n      {isEmpty ? (\n        uploadEnabled ? (\n          <MediaDropzone\n            variant=\"empty\"\n            accept={acceptKinds}\n            maxItems={maxItems}\n            busy={ingesting}\n            labels={labels}\n            onFiles={addFiles}\n          />\n        ) : null\n      ) : isEditing ? (\n        <EditPanel\n          key={state.editingItem!.id}\n          item={state.editingItem!}\n          aspect={resolvedAspect}\n          editorProps={editorProps}\n          labels={labels}\n          onApply={state.applyEdit}\n          onCancel={state.cancelEdit}\n        />\n      ) : (\n        <MainPreview\n          item={state.selectedItem}\n          aspectCss={aspectToCss(resolvedAspect)}\n          canEdit={state.selectedItem?.kind === \"image\"}\n          labels={labels}\n          onEdit={() =>\n            state.selectedItem && state.openEditor(state.selectedItem.id)\n          }\n        />\n      )}\n\n      {errors.length > 0 ? (\n        <div\n          role=\"alert\"\n          className=\"flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-foreground\"\n        >\n          <AlertCircle\n            className=\"mt-0.5 size-4 shrink-0 text-destructive\"\n            aria-hidden\n          />\n          <ul className=\"flex-1 space-y-0.5\">\n            {errors.map((msg, i) => (\n              <li key={i}>{msg}</li>\n            ))}\n          </ul>\n          <button\n            type=\"button\"\n            aria-label=\"Dismiss\"\n            onClick={() => setErrors([])}\n            className=\"shrink-0 rounded p-0.5 text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <X className=\"size-4\" aria-hidden />\n          </button>\n        </div>\n      ) : null}\n\n      {!isEmpty ? (\n        <DndContext\n          sensors={sensors}\n          collisionDetection={closestCenter}\n          accessibility={{ announcements: dndAnnouncements }}\n          onDragEnd={onDragEnd}\n        >\n          <PreviewRail\n            items={state.items}\n            selectedId={state.selectedId}\n            disabled={isEditing}\n            canAddMore={canAddMore}\n            busy={ingesting}\n            accept={acceptKinds}\n            maxItems={maxItems}\n            labels={labels}\n            onSelect={state.select}\n            onRemove={state.removeItem}\n            onFiles={addFiles}\n          />\n        </DndContext>\n      ) : null}\n\n      <output aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </output>\n    </div>\n  );\n}\n\nexport const CarouselComposer = forwardRef(CarouselComposerImpl);\nCarouselComposer.displayName = \"CarouselComposer\";\n\n// ─── Tail type re-exports (cross-procomp consumers) ──────────────────────────\n// Procomps that compose carousel-composer must import these types from\n// THIS component-file path, not `./types`: the shadcn path rewriter resolves a\n// barrel/directory import to this `.tsx` file but mangles `/types` subpaths\n// (F-01). Mirrors media-editor's tail band.\nexport type {\n  MediaCarouselItem,\n  MediaKind,\n  MediaCarouselSource,\n  MediaCarouselError,\n  CarouselComposerProps,\n  CarouselComposerHandle,\n  CarouselComposerLabels,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/carousel-composer/carousel-composer.tsx"
    },
    {
      "path": "src/registry/components/media/carousel-composer/index.ts",
      "content": "export { CarouselComposer } from \"./carousel-composer\";\n\nexport type {\n  MediaCarouselItem,\n  MediaKind,\n  MediaCarouselSource,\n  MediaCarouselError,\n  CarouselComposerProps,\n  CarouselComposerHandle,\n  CarouselComposerLabels,\n} from \"./types\";\nexport { DEFAULT_CAROUSEL_LABELS } from \"./types\";\n\n// Headless state primitive — for consumers that recompose the surface from the\n// parts below and need the model (items / selection / editing / URL lifecycle).\nexport { useCarouselState } from \"./hooks/use-carousel-state\";\nexport type {\n  UseCarouselStateOptions,\n  UseCarouselStateResult,\n  CarouselStateCallbacks,\n  ApplyEditPatch,\n} from \"./hooks/use-carousel-state\";\n\n// Public parts — for sealed-folder consumers that want to recompose the surface.\nexport { MediaDropzone } from \"./parts/media-dropzone\";\nexport type { MediaDropzoneProps } from \"./parts/media-dropzone\";\nexport { PreviewRail } from \"./parts/preview-rail\";\nexport type { PreviewRailProps } from \"./parts/preview-rail\";\nexport { RailThumb } from \"./parts/rail-thumb\";\nexport type { RailThumbProps } from \"./parts/rail-thumb\";\nexport { MainPreview } from \"./parts/main-preview\";\nexport type { MainPreviewProps } from \"./parts/main-preview\";\nexport { EditPanel } from \"./parts/edit-panel\";\nexport type { EditPanelProps } from \"./parts/edit-panel\";\n",
      "type": "registry:component",
      "target": "components/carousel-composer/index.ts"
    },
    {
      "path": "src/registry/components/media/carousel-composer/types.ts",
      "content": "import type {\n  AspectRatio,\n  ExportMetadata,\n  InitialSource,\n  MediaEditorHandle,\n  MediaEditorProps,\n  MediaEditorState,\n} from \"@/registry/components/media/media-editor/media-editor\";\n// ↑ The SOLE cross-procomp module surface (F-01). Types come from the .tsx\n//   entry's tail re-export band — NOT a `/types` subpath (the rewriter mangles\n//   that). `ValidationError` is NOT in that band, so the carousel defines its\n//   own `MediaCarouselError` below and media-editor stays untouched.\n\n// Re-export the media-editor types this procomp surfaces, so sibling files\n// pull them from the local types barrel and the external module path stays in\n// as few files as possible.\nexport type {\n  AspectRatio,\n  ExportMetadata,\n  InitialSource,\n  MediaEditorHandle,\n  MediaEditorState,\n};\n\nexport type MediaKind = \"image\" | \"video\";\nexport type MediaCarouselSource = \"upload\" | \"library\"; // \"library\" clamped in v0.1\n\n/**\n * Own error type — media-editor's `ValidationError` is not re-exported from\n * its `.tsx` entry, and we keep media-editor untouched. Structurally aligned.\n */\nexport interface MediaCarouselError {\n  // The cap has its own `onMaxItemsExceeded` channel — this type is per-file\n  // validation only.\n  kind: \"unsupported-type\" | \"file-too-large\";\n  message: string;\n  file?: File;\n}\n\n/**\n * One item in the carousel. `url` is the CURRENT displayable source — an object\n * URL for local/edited media, or a remote URL for re-edit seeds. After an\n * edit-apply, `url`/`blob` reflect the flattened export and `editorState`\n * retains the editable layers so a re-open can `loadState` them (photo path).\n */\nexport interface MediaCarouselItem {\n  id: string;\n  kind: MediaKind;\n  url: string;\n  /** Present for local/edited media; absent for a remote-only re-edit seed. */\n  blob?: Blob;\n  /** Present once edited (photo path; `videoBlob` is always nulled). */\n  editorState?: MediaEditorState;\n  /**\n   * Internal — the blob backing `editorState.imageSrc`, persisted at\n   * edit-apply. `editorState.imageSrc` is an object URL that dies with the\n   * edit panel's editor instance; on re-edit the panel passes this blob to\n   * `loadState` so a fresh URL is minted (otherwise: black canvas). Not\n   * serializable; lives only in in-memory item arrays.\n   */\n  sourceBlob?: Blob;\n  /** From the last `export()` of this item. */\n  exportMeta?: ExportMetadata;\n  width?: number;\n  height?: number;\n  fileName?: string;\n}\n\nexport interface CarouselComposerLabels {\n  dropzoneTitle?: string;\n  dropzoneBrowse?: string;\n  dropzoneHint?: string;\n  addMore?: string;\n  edit?: string;\n  editDone?: string;\n  editCancel?: string;\n  editSaving?: string;\n  editError?: string;\n  remove?: string;\n  reorderHint?: string;\n  maxReached?: string;\n  videoNotEditable?: string;\n  finishEditingHint?: string;\n  /** Template with `{n}` / `{total}` / `{kind}`. */\n  itemAria?: string;\n}\n\nexport const DEFAULT_CAROUSEL_LABELS: Required<CarouselComposerLabels> = {\n  dropzoneTitle: \"Drag photos & videos here\",\n  dropzoneBrowse: \"Browse\",\n  dropzoneHint: \"or drop up to {max} files\",\n  addMore: \"Add more\",\n  edit: \"Edit\",\n  editDone: \"Done\",\n  editCancel: \"Cancel\",\n  editSaving: \"Saving…\",\n  editError: \"Couldn't save your edit. Try again.\",\n  remove: \"Remove\",\n  reorderHint: \"Drag to reorder\",\n  maxReached: \"Maximum {max} items reached\",\n  videoNotEditable: \"Video editing arrives in v0.2\",\n  finishEditingHint: \"Finish editing to reorder or add media\",\n  itemAria: \"Media item {n} of {total}, {kind}\",\n};\n\nexport interface CarouselComposerProps {\n  // value\n  value?: MediaCarouselItem[];\n  defaultValue?: MediaCarouselItem[];\n  onChange?: (items: MediaCarouselItem[]) => void;\n\n  // capability dials\n  maxItems?: number; // default 10\n  maxFileSizeMb?: number; // default 50 (mirrors media-editor)\n  accept?: MediaKind[]; // default [\"image\",\"video\"]\n  sources?: MediaCarouselSource[]; // default [\"upload\"]; \"library\" clamped/no-op\n  aspect?: AspectRatio | \"auto\"; // default \"auto\" → derive from item 1 dims\n\n  // forwarded subset of media-editor dials (edit panel). Crop aspect is OWNED\n  // by `aspect` here (shared-aspect guarantee) — deliberately not in this Pick.\n  editorProps?: Pick<\n    MediaEditorProps,\n    \"enabledTools\" | \"stickers\" | \"fonts\" | \"colorPresets\" | \"filterPresets\" | \"labels\"\n  >;\n\n  labels?: Partial<CarouselComposerLabels>;\n  className?: string;\n  /**\n   * Revoke object URLs this component created when it unmounts. Default true.\n   * Set false ONLY when a host preserves the live items across remounts and\n   * takes over final cleanup (e.g. content-composer's carousel cache).\n   */\n  revokeOnUnmount?: boolean;\n\n  // events\n  onItemAdd?: (item: MediaCarouselItem) => void;\n  onItemRemove?: (id: string) => void;\n  onReorder?: (items: MediaCarouselItem[]) => void;\n  onSelect?: (id: string | null) => void;\n  onEditOpen?: (id: string) => void;\n  onEditApply?: (item: MediaCarouselItem) => void;\n  onEditCancel?: (id: string) => void;\n  onValidationError?: (error: MediaCarouselError) => void;\n  onMaxItemsExceeded?: (attempted: number, max: number) => void;\n}\n\nexport interface CarouselComposerHandle {\n  getItems: () => MediaCarouselItem[];\n  /**\n   * Pull-only: resolves a defensive copy of the COMMITTED ordered items (already\n   * flattened on edit-apply). An open-but-unapplied edit is NOT included — the\n   * host should gate publish while editing.\n   */\n  export: () => Promise<MediaCarouselItem[]>;\n  addFiles: (files: File[] | FileList) => void;\n  removeItem: (id: string) => void;\n  select: (id: string | null) => void;\n  openEditor: (id: string) => void;\n  /** Revoke all owned object URLs; clears items. Also auto-runs on unmount. */\n  reset: () => void;\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/types.ts"
    },
    {
      "path": "src/registry/components/media/carousel-composer/hooks/use-carousel-state.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ExportMetadata, MediaCarouselItem, MediaEditorState } from \"../types\";\nimport { useControllableState } from \"./use-controllable-state\";\n\nexport interface CarouselStateCallbacks {\n  onItemAdd?: (item: MediaCarouselItem) => void;\n  onItemRemove?: (id: string) => void;\n  onReorder?: (items: MediaCarouselItem[]) => void;\n  onSelect?: (id: string | null) => void;\n  onEditOpen?: (id: string) => void;\n  onEditApply?: (item: MediaCarouselItem) => void;\n  onEditCancel?: (id: string) => void;\n  onMaxItemsExceeded?: (attempted: number, max: number) => void;\n}\n\nexport interface UseCarouselStateOptions extends CarouselStateCallbacks {\n  value?: MediaCarouselItem[];\n  defaultValue?: MediaCarouselItem[];\n  onChange?: (items: MediaCarouselItem[]) => void;\n  maxItems: number;\n  /**\n   * Revoke owned object URLs when the component unmounts. Default true. A host\n   * that persists the live items across remounts (e.g. content-composer's\n   * carousel cache) sets this false and owns the final cleanup itself.\n   */\n  revokeOnUnmount?: boolean;\n}\n\nexport interface ApplyEditPatch {\n  url: string;\n  blob?: Blob;\n  editorState?: MediaEditorState;\n  /** Blob backing `editorState.imageSrc` — see MediaCarouselItem.sourceBlob. */\n  sourceBlob?: Blob;\n  exportMeta?: ExportMetadata;\n  width?: number;\n  height?: number;\n}\n\nexport interface UseCarouselStateResult {\n  items: MediaCarouselItem[];\n  selectedId: string | null;\n  editingId: string | null;\n  selectedItem: MediaCarouselItem | null;\n  editingItem: MediaCarouselItem | null;\n  addItems: (next: MediaCarouselItem[]) => void;\n  removeItem: (id: string) => void;\n  reorder: (next: MediaCarouselItem[]) => void;\n  select: (id: string | null) => void;\n  openEditor: (id: string) => void;\n  cancelEdit: () => void;\n  applyEdit: (id: string, patch: ApplyEditPatch) => void;\n  reset: () => void;\n}\n\n/** Only object URLs we created are ours to revoke; remote/consumer URLs aren't. */\nfunction isOwnable(url: string): boolean {\n  return url.startsWith(\"blob:\");\n}\n\n/**\n * Owns the carousel model: a controllable `items` array (value/defaultValue/\n * onChange) plus internal `selectedId` / `editingId`, and the object-URL\n * lifecycle. `itemsRef` is updated SYNCHRONOUSLY inside every mutator (not via an\n * effect) so async/batched intakes can't read a stale base. An orphan-revoke\n * effect catches URLs dropped by any path — including a wholesale controlled\n * `value` swap (e.g. the host's `loadValue`) that bypasses removeItem.\n */\nexport function useCarouselState(\n  opts: UseCarouselStateOptions,\n): UseCarouselStateResult {\n  const { maxItems, revokeOnUnmount = true } = opts;\n\n  const [items, setItemsRaw] = useControllableState<MediaCarouselItem[]>({\n    value: opts.value,\n    defaultValue: opts.defaultValue ?? [],\n    onChange: opts.onChange,\n    componentName: \"CarouselComposer\",\n    valuePropName: \"value\",\n  });\n  const [rawSelectedId, setSelectedId] = useState<string | null>(\n    opts.value?.[0]?.id ?? opts.defaultValue?.[0]?.id ?? null,\n  );\n  const [editingId, setEditingId] = useState<string | null>(null);\n\n  const selectedId =\n    rawSelectedId != null && items.some((it) => it.id === rawSelectedId)\n      ? rawSelectedId\n      : (items[0]?.id ?? null);\n\n  const cbRef = useRef<CarouselStateCallbacks>(opts);\n  useEffect(() => {\n    cbRef.current = opts;\n  });\n\n  // itemsRef tracks committed render state; mutators below ALSO write it\n  // synchronously so a second mutation in the same tick reads the latest base.\n  const itemsRef = useRef(items);\n  const selectedIdRef = useRef(selectedId);\n  const editingIdRef = useRef(editingId);\n  useEffect(() => {\n    itemsRef.current = items;\n    selectedIdRef.current = selectedId;\n    editingIdRef.current = editingId;\n  });\n\n  /** Synchronous-ref write + state set, so chained mutators compose correctly. */\n  const setItems = useCallback(\n    (next: MediaCarouselItem[]) => {\n      itemsRef.current = next;\n      setItemsRaw(next);\n    },\n    [setItemsRaw],\n  );\n\n  // Object-URL ownership.\n  const ownedUrls = useRef<Set<string>>(new Set());\n  const revoke = useCallback((url: string) => {\n    if (ownedUrls.current.has(url)) {\n      URL.revokeObjectURL(url);\n      ownedUrls.current.delete(url);\n    }\n  }, []);\n\n  // Catch-all: revoke any owned URL no longer present in items — including\n  // drops via a controlled `value` swap that never went through removeItem.\n  useEffect(() => {\n    const present = new Set(items.map((it) => it.url));\n    ownedUrls.current.forEach((u) => {\n      if (!present.has(u)) {\n        URL.revokeObjectURL(u);\n        ownedUrls.current.delete(u);\n      }\n    });\n  }, [items]);\n\n  useEffect(() => {\n    const owned = ownedUrls.current;\n    return () => {\n      if (!revokeOnUnmount) return;\n      owned.forEach((u) => URL.revokeObjectURL(u));\n      owned.clear();\n    };\n  }, [revokeOnUnmount]);\n\n  const addItems = useCallback(\n    (incoming: MediaCarouselItem[]) => {\n      if (incoming.length === 0) return;\n      // Cap synchronously against the latest items (race-safe).\n      const room = Math.max(0, maxItems - itemsRef.current.length);\n      const toAdd = incoming.slice(0, room);\n      const dropped = incoming.slice(room);\n      // Revoke object URLs of items we won't keep (intake created them).\n      dropped.forEach((it) => {\n        if (isOwnable(it.url)) URL.revokeObjectURL(it.url);\n      });\n      if (toAdd.length > 0) {\n        toAdd.forEach((it) => {\n          if (isOwnable(it.url)) ownedUrls.current.add(it.url);\n        });\n        setItems([...itemsRef.current, ...toAdd]);\n        toAdd.forEach((it) => cbRef.current.onItemAdd?.(it));\n      }\n      if (dropped.length > 0) {\n        cbRef.current.onMaxItemsExceeded?.(\n          itemsRef.current.length + dropped.length,\n          maxItems,\n        );\n      }\n    },\n    [maxItems, setItems],\n  );\n\n  const removeItem = useCallback(\n    (id: string) => {\n      const cur = itemsRef.current;\n      const idx = cur.findIndex((it) => it.id === id);\n      if (idx < 0) return;\n      revoke(cur[idx].url);\n      const next = cur.filter((it) => it.id !== id);\n      setItems(next);\n      cbRef.current.onItemRemove?.(id);\n      if (selectedIdRef.current === id) {\n        const neighbor = next[Math.min(idx, next.length - 1)] ?? null;\n        const nid = neighbor?.id ?? null;\n        setSelectedId(nid);\n        cbRef.current.onSelect?.(nid);\n      }\n      if (editingIdRef.current === id) {\n        setEditingId(null);\n        cbRef.current.onEditCancel?.(id);\n      }\n    },\n    [revoke, setItems],\n  );\n\n  const reorder = useCallback(\n    (next: MediaCarouselItem[]) => {\n      setItems(next);\n      cbRef.current.onReorder?.(next);\n    },\n    [setItems],\n  );\n\n  const select = useCallback((id: string | null) => {\n    setSelectedId(id);\n    cbRef.current.onSelect?.(id);\n  }, []);\n\n  const openEditor = useCallback((id: string) => {\n    setSelectedId(id);\n    setEditingId(id);\n    cbRef.current.onEditOpen?.(id);\n  }, []);\n\n  const cancelEdit = useCallback(() => {\n    const id = editingIdRef.current;\n    if (id == null) return;\n    setEditingId(null);\n    cbRef.current.onEditCancel?.(id);\n  }, []);\n\n  const applyEdit = useCallback(\n    (id: string, patch: ApplyEditPatch) => {\n      const cur = itemsRef.current;\n      const idx = cur.findIndex((it) => it.id === id);\n      if (idx < 0) return;\n      const prev = cur[idx];\n      if (prev.url !== patch.url) revoke(prev.url);\n      if (isOwnable(patch.url)) ownedUrls.current.add(patch.url);\n      const updated: MediaCarouselItem = {\n        ...prev,\n        url: patch.url,\n        blob: patch.blob ?? prev.blob,\n        editorState: patch.editorState ?? prev.editorState,\n        sourceBlob: patch.sourceBlob ?? prev.sourceBlob,\n        exportMeta: patch.exportMeta ?? prev.exportMeta,\n        width: patch.width ?? prev.width,\n        height: patch.height ?? prev.height,\n      };\n      setItems(cur.map((it, i) => (i === idx ? updated : it)));\n      setEditingId(null);\n      cbRef.current.onEditApply?.(updated);\n    },\n    [revoke, setItems],\n  );\n\n  const reset = useCallback(() => {\n    ownedUrls.current.forEach((u) => URL.revokeObjectURL(u));\n    ownedUrls.current.clear();\n    setItems([]);\n    setSelectedId(null);\n    setEditingId(null);\n  }, [setItems]);\n\n  const selectedItem = items.find((it) => it.id === selectedId) ?? null;\n  const editingItem = items.find((it) => it.id === editingId) ?? null;\n\n  return {\n    items,\n    selectedId,\n    editingId,\n    selectedItem,\n    editingItem,\n    addItems,\n    removeItem,\n    reorder,\n    select,\n    openEditor,\n    cancelEdit,\n    applyEdit,\n    reset,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/hooks/use-carousel-state.ts"
    },
    {
      "path": "src/registry/components/media/carousel-composer/hooks/use-controllable-state.ts",
      "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\ninterface UseControllableStateOpts<T> {\n  /** Controlled value. When provided, state is fully controlled by parent. */\n  value?: T;\n  /** Initial value for uncontrolled mode. */\n  defaultValue: T;\n  /** Fires on every state change (both modes). */\n  onChange?: (next: T) => void;\n  /** Component name used in dev warnings. */\n  componentName: string;\n  /** Prop name used in dev warnings (e.g., `\"value\"`). */\n  valuePropName: string;\n}\n\n/**\n * Controlled+uncontrolled state-machine helper. Sealed local copy of the\n * library's standard generic (navigation/account-switcher + code-block) —\n * registry components can't share an app-level util beyond `@/lib/utils`, so\n * each procomp seals its own copy.\n *\n *   - Locks the controlled/uncontrolled mode based on the FIRST render's\n *     `value` and dev-warns when consumers flip modes mid-life.\n *   - Dev-warns when controlled mode is used without an `onChange` handler.\n *\n * Both warns tree-shake out of prod bundles.\n *\n * Internal helper — not exported from the procomp's public API.\n */\nexport function useControllableState<T>({\n  value,\n  defaultValue,\n  onChange,\n  componentName,\n  valuePropName,\n}: UseControllableStateOpts<T>): readonly [T, (next: T) => void] {\n  const [internal, setInternal] = useState<T>(defaultValue);\n  const isControlled = value !== undefined;\n  const onChangeRef = useRef(onChange);\n  useEffect(() => {\n    onChangeRef.current = onChange;\n  });\n\n  const wasControlledRef = useRef(isControlled);\n  useEffect(() => {\n    if (process.env.NODE_ENV === \"production\") return;\n    if (wasControlledRef.current !== isControlled) {\n      console.warn(\n        `[${componentName}] \\`${valuePropName}\\` switched from ${\n          wasControlledRef.current ? \"controlled\" : \"uncontrolled\"\n        } to ${\n          isControlled ? \"controlled\" : \"uncontrolled\"\n        } mode. Components should not switch modes mid-life; pick one at mount.`,\n      );\n      wasControlledRef.current = isControlled;\n    }\n  }, [isControlled, componentName, valuePropName]);\n\n  useEffect(() => {\n    if (process.env.NODE_ENV === \"production\") return;\n    if (isControlled && !onChangeRef.current) {\n      const capitalized =\n        valuePropName.charAt(0).toUpperCase() + valuePropName.slice(1);\n      console.warn(\n        `[${componentName}] \\`${valuePropName}\\` is controlled but no onChange handler was provided. ` +\n          `State will appear frozen. Pass \\`on${capitalized}Change\\` ` +\n          `(or use the uncontrolled variant by passing \\`default${capitalized}\\` instead).`,\n      );\n    }\n  }, [isControlled, componentName, valuePropName]);\n\n  const current = isControlled ? (value as T) : internal;\n\n  const set = useCallback(\n    (next: T) => {\n      if (!isControlled) setInternal(next);\n      onChangeRef.current?.(next);\n    },\n    [isControlled],\n  );\n\n  return [current, set] as const;\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/hooks/use-controllable-state.ts"
    },
    {
      "path": "src/registry/components/media/carousel-composer/lib/aspect.ts",
      "content": "import type { AspectRatio, MediaCarouselItem } from \"../types\";\n\nconst RATIOS: { aspect: Exclude<AspectRatio, \"free\">; value: number }[] = [\n  { aspect: \"9:16\", value: 9 / 16 },\n  { aspect: \"4:5\", value: 4 / 5 },\n  { aspect: \"1:1\", value: 1 },\n  { aspect: \"16:9\", value: 16 / 9 },\n];\n\n/**\n * Resolve the carousel's shared aspect (Instagram behaviour): an explicit prop\n * wins; `\"auto\"` derives from item 1's natural ratio (nearest of the four\n * standard ratios); falls back to `\"1:1\"` until item 1's dimensions are known.\n */\nexport function resolveAspect(\n  items: MediaCarouselItem[],\n  prop: AspectRatio | \"auto\",\n): AspectRatio {\n  if (prop !== \"auto\") return prop;\n  const first = items[0];\n  if (!first?.width || !first?.height) return \"1:1\";\n  const ratio = first.width / first.height;\n  let best: Exclude<AspectRatio, \"free\"> = \"1:1\";\n  let bestDiff = Infinity;\n  for (const cand of RATIOS) {\n    const diff = Math.abs(cand.value - ratio);\n    if (diff < bestDiff) {\n      bestDiff = diff;\n      best = cand.aspect;\n    }\n  }\n  return best;\n}\n\n/**\n * CSS `aspect-ratio` value for a frame. `\"free\"` falls back to a 1:1 frame so the\n * rail + main preview keep a consistent footprint.\n */\nexport function aspectToCss(aspect: AspectRatio): string {\n  switch (aspect) {\n    case \"9:16\":\n      return \"9 / 16\";\n    case \"4:5\":\n      return \"4 / 5\";\n    case \"16:9\":\n      return \"16 / 9\";\n    case \"1:1\":\n    case \"free\":\n    default:\n      return \"1 / 1\";\n  }\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/lib/aspect.ts"
    },
    {
      "path": "src/registry/components/media/carousel-composer/lib/clamp-sources.ts",
      "content": "import type { MediaCarouselSource } from \"../types\";\n\n/**\n * `\"library\"` is declared for forward-compat (pick from existing backend media)\n * but has no implementation in v0.1 — clamp it out so a config that lists it\n * stays valid with no crash and no cosmetic leak (mirrors content-composer's\n * media-source clamp). Always resolves to a non-empty source list.\n */\nexport function clampSources(\n  sources: MediaCarouselSource[] | undefined,\n): MediaCarouselSource[] {\n  const base = sources && sources.length > 0 ? sources : [\"upload\"];\n  const clamped = base.filter((s) => s === \"upload\");\n  return clamped.length > 0 ? clamped : [\"upload\"];\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/lib/clamp-sources.ts"
    },
    {
      "path": "src/registry/components/media/carousel-composer/lib/file-intake.ts",
      "content": "import type { MediaCarouselError, MediaCarouselItem, MediaKind } from \"../types\";\nimport { validateMediaFile } from \"./validate-media-file\";\n\n// Per-module-load random prefix so the counter fallback can't collide with ids\n// reconstructed from a prior draft after a navigation re-evaluates the module.\nconst ID_PREFIX = `mci-${Math.floor(Math.random() * 1e9).toString(36)}`;\nlet idCounter = 0;\nfunction nextId(): string {\n  if (typeof crypto !== \"undefined\" && \"randomUUID\" in crypto) {\n    return crypto.randomUUID();\n  }\n  return `${ID_PREFIX}-${(idCounter++).toString(36)}`;\n}\n\nfunction readImageDims(url: string): Promise<{ width: number; height: number }> {\n  return new Promise((resolve) => {\n    const img = new Image();\n    img.onload = () =>\n      resolve({ width: img.naturalWidth, height: img.naturalHeight });\n    img.onerror = () => resolve({ width: 0, height: 0 });\n    img.src = url;\n  });\n}\n\nfunction readVideoDims(url: string): Promise<{ width: number; height: number }> {\n  return new Promise((resolve) => {\n    const video = document.createElement(\"video\");\n    video.preload = \"metadata\";\n    video.onloadedmetadata = () =>\n      resolve({ width: video.videoWidth, height: video.videoHeight });\n    video.onerror = () => resolve({ width: 0, height: 0 });\n    video.src = url;\n  });\n}\n\nexport interface FilesToItemsOptions {\n  accept: MediaKind[];\n  maxFileSizeMb: number;\n}\n\nexport interface FilesToItemsResult {\n  items: MediaCarouselItem[];\n  errors: MediaCarouselError[];\n}\n\n/**\n * Validate + ingest dropped/picked files into `MediaCarouselItem`s. Creates\n * object URLs and reads natural dimensions (async) so the resolved aspect is\n * correct on first paint. The `maxItems` cap is enforced downstream in the\n * state hook (`addItems`), synchronously against the latest items — so two rapid\n * drops can't each compute room against a stale count.\n */\nexport async function filesToItems(\n  fileList: File[] | FileList,\n  opts: FilesToItemsOptions,\n): Promise<FilesToItemsResult> {\n  const files = Array.from(fileList);\n  const errors: MediaCarouselError[] = [];\n  const accepted: File[] = [];\n\n  for (const file of files) {\n    const err = validateMediaFile(file, opts.maxFileSizeMb, opts.accept);\n    if (err) {\n      errors.push(err);\n      continue;\n    }\n    accepted.push(file);\n  }\n\n  const items = await Promise.all(\n    accepted.map(async (file): Promise<MediaCarouselItem> => {\n      const kind: MediaKind = file.type.startsWith(\"video/\")\n        ? \"video\"\n        : \"image\";\n      const url = URL.createObjectURL(file);\n      const dims =\n        kind === \"image\" ? await readImageDims(url) : await readVideoDims(url);\n      return {\n        id: nextId(),\n        kind,\n        url,\n        blob: file,\n        fileName: file.name,\n        width: dims.width || undefined,\n        height: dims.height || undefined,\n      };\n    }),\n  );\n\n  return { items, errors };\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/lib/file-intake.ts"
    },
    {
      "path": "src/registry/components/media/carousel-composer/lib/validate-media-file.ts",
      "content": "import type { MediaCarouselError, MediaKind } from \"../types\";\n\n/**\n * Local reimplementation of media-editor's `validateGalleryFile` — type +\n * size check, plus an `accept` gate. Kept local (NOT imported) to hold the\n * cross-procomp surface to one module (F-01) and keep media-editor untouched.\n */\nexport function validateMediaFile(\n  file: File,\n  maxFileSizeMb: number,\n  accept: MediaKind[],\n): MediaCarouselError | null {\n  const isImage = file.type.startsWith(\"image/\");\n  const isVideo = file.type.startsWith(\"video/\");\n  const kind: MediaKind | null = isImage ? \"image\" : isVideo ? \"video\" : null;\n\n  if (!kind) {\n    return {\n      kind: \"unsupported-type\",\n      message: `Unsupported file type: ${file.type || \"unknown\"}`,\n      file,\n    };\n  }\n  if (!accept.includes(kind)) {\n    return {\n      kind: \"unsupported-type\",\n      message: `${kind === \"image\" ? \"Image\" : \"Video\"} files are not accepted here.`,\n      file,\n    };\n  }\n  const maxBytes = maxFileSizeMb * 1024 * 1024;\n  if (file.size > maxBytes) {\n    return {\n      kind: \"file-too-large\",\n      message: `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — maximum is ${maxFileSizeMb} MB.`,\n      file,\n    };\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/lib/validate-media-file.ts"
    },
    {
      "path": "src/registry/components/media/carousel-composer/parts/media-dropzone.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { ImagePlus, Loader2, Plus } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport type { MediaKind } from \"../types\";\n\nexport interface MediaDropzoneProps {\n  variant: \"empty\" | \"add-more\";\n  accept: MediaKind[];\n  maxItems: number;\n  disabled?: boolean;\n  /** Ingestion in progress — show a spinner + block input. */\n  busy?: boolean;\n  labels: {\n    dropzoneTitle: string;\n    dropzoneBrowse: string;\n    dropzoneHint: string;\n    addMore: string;\n  };\n  onFiles: (files: File[] | FileList) => void;\n}\n\nfunction acceptAttr(accept: MediaKind[]): string {\n  return accept.map((k) => (k === \"image\" ? \"image/*\" : \"video/*\")).join(\",\");\n}\n\n/**\n * File intake surface. `variant=\"empty\"` is the full-bleed first-run dropzone;\n * `variant=\"add-more\"` is a compact rail-sized tile. Both wrap a hidden\n * `<input type=\"file\" multiple>`, both accept drag-and-drop with hover feedback,\n * and both surface a `busy` spinner during ingestion. Drag-and-drop is an\n * enhancement on top of the always-present Browse button (keyboard path).\n */\nexport function MediaDropzone({\n  variant,\n  accept,\n  maxItems,\n  disabled,\n  busy,\n  labels,\n  onFiles,\n}: MediaDropzoneProps) {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [dragging, setDragging] = useState(false);\n  const blocked = disabled || busy;\n\n  const open = () => inputRef.current?.click();\n\n  const onDragOver = (e: React.DragEvent) => {\n    e.preventDefault();\n    if (!blocked) setDragging(true);\n  };\n  const onDrop = (e: React.DragEvent) => {\n    e.preventDefault();\n    setDragging(false);\n    if (blocked) return;\n    if (e.dataTransfer.files?.length) onFiles(e.dataTransfer.files);\n  };\n\n  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    // Snapshot into an array BEFORE resetting `value`: clearing a file input's\n    // value empties its live `FileList` in place, so the captured reference\n    // would otherwise reach `onFiles` already empty (silent no-add on browse).\n    const files = e.target.files ? Array.from(e.target.files) : [];\n    e.target.value = \"\"; // allow re-picking the same file\n    if (files.length) onFiles(files);\n  };\n\n  const input = (\n    <input\n      ref={inputRef}\n      type=\"file\"\n      multiple\n      accept={acceptAttr(accept)}\n      onChange={onChange}\n      className=\"sr-only\"\n      tabIndex={-1}\n      aria-hidden\n    />\n  );\n\n  if (variant === \"add-more\") {\n    return (\n      <button\n        type=\"button\"\n        onClick={open}\n        onDragOver={onDragOver}\n        onDragLeave={() => setDragging(false)}\n        onDrop={onDrop}\n        disabled={blocked}\n        aria-label={labels.addMore}\n        aria-busy={busy || undefined}\n        className={cn(\n          \"grid size-16 shrink-0 place-items-center rounded-md border border-dashed bg-muted/40 text-muted-foreground transition hover:border-ring hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          dragging ? \"border-ring bg-accent/40 text-foreground\" : \"border-border\",\n        )}\n      >\n        {busy ? (\n          <Loader2 className=\"size-5 animate-spin\" aria-hidden />\n        ) : (\n          <Plus className=\"size-5\" aria-hidden />\n        )}\n        {input}\n      </button>\n    );\n  }\n\n  return (\n    <div\n      onDragOver={onDragOver}\n      onDragLeave={() => setDragging(false)}\n      onDrop={onDrop}\n      aria-label={labels.dropzoneTitle}\n      aria-busy={busy || undefined}\n      className={cn(\n        \"flex w-full flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 text-center transition\",\n        dragging ? \"border-ring bg-accent/40\" : \"border-border bg-muted/30\",\n      )}\n    >\n      {busy ? (\n        <Loader2 className=\"size-8 animate-spin text-muted-foreground\" aria-hidden />\n      ) : (\n        <ImagePlus className=\"size-8 text-muted-foreground\" aria-hidden />\n      )}\n      <div className=\"flex flex-col gap-1\">\n        <p className=\"text-sm font-medium text-foreground\">\n          {busy ? \"Adding media…\" : labels.dropzoneTitle}\n        </p>\n        <p className=\"text-xs text-muted-foreground\">\n          {labels.dropzoneHint.replace(\"{max}\", String(maxItems))}\n        </p>\n      </div>\n      <Button type=\"button\" size=\"sm\" onClick={open} disabled={blocked}>\n        {labels.dropzoneBrowse}\n      </Button>\n      {input}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/parts/media-dropzone.tsx"
    },
    {
      "path": "src/registry/components/media/carousel-composer/parts/preview-rail.tsx",
      "content": "\"use client\";\n\nimport {\n  SortableContext,\n  horizontalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\nimport { cn } from \"@/lib/utils\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\nimport type { MediaCarouselItem, MediaKind } from \"../types\";\nimport { MediaDropzone } from \"./media-dropzone\";\nimport { RailThumb } from \"./rail-thumb\";\n\nexport interface PreviewRailProps {\n  items: MediaCarouselItem[];\n  selectedId: string | null;\n  /** Editing in progress → the whole rail is read-only. */\n  disabled: boolean;\n  canAddMore: boolean;\n  /** Ingestion in progress — spinner on the add-more tile. */\n  busy?: boolean;\n  accept: MediaKind[];\n  maxItems: number;\n  labels: {\n    remove: string;\n    reorderHint: string;\n    itemAria: string;\n    dropzoneTitle: string;\n    dropzoneBrowse: string;\n    dropzoneHint: string;\n    addMore: string;\n    finishEditingHint: string;\n  };\n  onSelect: (id: string) => void;\n  onRemove: (id: string) => void;\n  onFiles: (files: File[] | FileList) => void;\n}\n\n/**\n * Horizontal thumbnail strip. `SortableContext` (horizontal strategy) drives\n * reorder; the wrapping `DndContext` lives in the root component. A trailing\n * compact dropzone tile adds more items when under `maxItems` and not editing.\n */\nexport function PreviewRail({\n  items,\n  selectedId,\n  disabled,\n  canAddMore,\n  busy,\n  accept,\n  maxItems,\n  labels,\n  onSelect,\n  onRemove,\n  onFiles,\n}: PreviewRailProps) {\n  return (\n    <ScrollArea className=\"w-full\">\n      {disabled ? (\n        <p className=\"px-1 pb-1 text-xs text-muted-foreground\">\n          {labels.finishEditingHint}\n        </p>\n      ) : null}\n      <div\n        className={cn(\n          \"flex items-center gap-2 p-1 transition-opacity\",\n          disabled && \"pointer-events-none opacity-60\",\n        )}\n      >\n        <SortableContext\n          items={items.map((it) => it.id)}\n          strategy={horizontalListSortingStrategy}\n        >\n          {items.map((item, i) => (\n            <RailThumb\n              key={item.id}\n              item={item}\n              index={i}\n              total={items.length}\n              selected={item.id === selectedId}\n              disabled={disabled}\n              labels={labels}\n              onSelect={onSelect}\n              onRemove={onRemove}\n            />\n          ))}\n        </SortableContext>\n        {canAddMore && !disabled ? (\n          <MediaDropzone\n            variant=\"add-more\"\n            accept={accept}\n            maxItems={maxItems}\n            busy={busy}\n            labels={labels}\n            onFiles={onFiles}\n          />\n        ) : null}\n      </div>\n      <ScrollBar orientation=\"horizontal\" />\n    </ScrollArea>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/parts/preview-rail.tsx"
    },
    {
      "path": "src/registry/components/media/carousel-composer/parts/rail-thumb.tsx",
      "content": "\"use client\";\n\nimport { useSortable } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { Film, GripVertical, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { MediaCarouselItem } from \"../types\";\n\nexport interface RailThumbProps {\n  item: MediaCarouselItem;\n  index: number;\n  total: number;\n  selected: boolean;\n  /** Editing in progress → rail is read-only (no drag, remove, or re-select). */\n  disabled: boolean;\n  labels: { remove: string; reorderHint: string; itemAria: string };\n  onSelect: (id: string) => void;\n  onRemove: (id: string) => void;\n}\n\n/**\n * A single rail thumbnail. The body is a native `<button>` for select (clean\n * Enter/Space keyboard activation). The drag `listeners` live on a SEPARATE\n * handle button so the `@dnd-kit` keyboard sensor never fights the select\n * button's native activation. Remove is a third button with `stopPropagation`.\n */\nexport function RailThumb({\n  item,\n  index,\n  total,\n  selected,\n  disabled,\n  labels,\n  onSelect,\n  onRemove,\n}: RailThumbProps) {\n  const { attributes, listeners, setNodeRef, transform, transition, isDragging } =\n    useSortable({ id: item.id, disabled });\n\n  const style: React.CSSProperties = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n  };\n\n  const aria = labels.itemAria\n    .replace(\"{n}\", String(index + 1))\n    .replace(\"{total}\", String(total))\n    .replace(\"{kind}\", item.kind);\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      className={cn(\"group relative shrink-0\", isDragging && \"z-10 opacity-60\")}\n    >\n      <button\n        type=\"button\"\n        aria-label={aria}\n        aria-current={selected ? \"true\" : undefined}\n        onClick={() => !disabled && onSelect(item.id)}\n        disabled={disabled}\n        className={cn(\n          \"relative block size-16 overflow-hidden rounded-md border bg-muted outline-none transition focus-visible:ring-2 focus-visible:ring-ring\",\n          selected ? \"border-ring ring-2 ring-ring\" : \"border-border\",\n        )}\n      >\n        {item.kind === \"video\" ? (\n          <>\n            <video\n              src={item.url}\n              muted\n              playsInline\n              preload=\"metadata\"\n              onLoadedMetadata={(e) => {\n                // Nudge to a frame so the thumb isn't a black box (Safari/Chrome\n                // won't paint frame 0 of an unplayed inline video otherwise).\n                try {\n                  e.currentTarget.currentTime = 0.1;\n                } catch {\n                  /* seeking unsupported — leave as-is */\n                }\n              }}\n              className=\"size-full object-cover\"\n            />\n            <span className=\"absolute bottom-0.5 right-0.5 grid place-items-center rounded-sm bg-black/60 p-0.5 text-white\">\n              <Film className=\"size-3\" aria-hidden />\n            </span>\n          </>\n        ) : (\n          <img\n            src={item.url}\n            alt={item.fileName ?? \"\"}\n            className=\"size-full object-cover\"\n          />\n        )}\n      </button>\n\n      {!disabled ? (\n        <>\n          <button\n            type=\"button\"\n            aria-label={labels.reorderHint}\n            {...attributes}\n            {...listeners}\n            className=\"absolute bottom-0.5 left-0.5 grid size-6 cursor-grab touch-none place-items-center rounded bg-black/55 text-white opacity-90 transition hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing\"\n          >\n            <GripVertical className=\"size-4\" aria-hidden />\n          </button>\n          <button\n            type=\"button\"\n            aria-label={labels.remove}\n            onClick={(e) => {\n              e.stopPropagation();\n              onRemove(item.id);\n            }}\n            className=\"absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full border border-border bg-background text-foreground shadow-sm transition hover:bg-destructive hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <X className=\"size-3.5\" aria-hidden />\n          </button>\n        </>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/parts/rail-thumb.tsx"
    },
    {
      "path": "src/registry/components/media/carousel-composer/parts/main-preview.tsx",
      "content": "\"use client\";\n\nimport { ImageIcon, Pencil } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport type { MediaCarouselItem } from \"../types\";\n\nexport interface MainPreviewProps {\n  item: MediaCarouselItem | null;\n  /** CSS `aspect-ratio` value for the frame (the shared carousel aspect). */\n  aspectCss: string;\n  /** False for video items in v0.1 (edit deferred). */\n  canEdit: boolean;\n  labels: { edit: string; videoNotEditable: string };\n  onEdit: () => void;\n}\n\n/**\n * The large preview of the currently-selected item, with an Edit affordance.\n * Images fill the shared-aspect frame (`object-cover`); videos keep their own\n * aspect on a black mat (`object-contain`). Video isn't editable in v0.1 — instead\n * of a disabled (unfocusable, unreadable) button we show a static caption that\n * IS in the reading order, so keyboard + screen-reader users get the reason.\n */\nexport function MainPreview({\n  item,\n  aspectCss,\n  canEdit,\n  labels,\n  onEdit,\n}: MainPreviewProps) {\n  if (!item) {\n    return (\n      <div\n        className=\"grid w-full place-items-center rounded-lg border border-dashed border-border bg-muted/30 text-muted-foreground\"\n        style={{ aspectRatio: aspectCss }}\n      >\n        <ImageIcon className=\"size-8\" aria-hidden />\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className=\"relative w-full overflow-hidden rounded-lg border border-border bg-muted\"\n      style={{ aspectRatio: aspectCss }}\n    >\n      {item.kind === \"video\" ? (\n        <video\n          src={item.url}\n          controls\n          playsInline\n          preload=\"metadata\"\n          onLoadedMetadata={(e) => {\n            try {\n              e.currentTarget.currentTime = 0.1;\n            } catch {\n              /* seeking unsupported — leave as-is */\n            }\n          }}\n          className=\"size-full bg-black object-contain\"\n        />\n      ) : (\n        <img\n          src={item.url}\n          alt={item.fileName ?? \"\"}\n          className=\"size-full object-cover\"\n        />\n      )}\n\n      <div className=\"absolute right-3 top-3\">\n        {canEdit ? (\n          <Button type=\"button\" size=\"sm\" onClick={onEdit}>\n            <Pencil className=\"size-4\" aria-hidden />\n            {labels.edit}\n          </Button>\n        ) : (\n          <span className=\"rounded-full bg-black/70 px-2.5 py-1 text-xs font-medium text-white\">\n            {labels.videoNotEditable}\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/parts/main-preview.tsx"
    },
    {
      "path": "src/registry/components/media/carousel-composer/parts/edit-panel.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Loader2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { MediaEditor } from \"@/registry/components/media/media-editor/media-editor\";\nimport type {\n  AspectRatio,\n  InitialSource,\n  CarouselComposerProps,\n  MediaCarouselItem,\n  MediaEditorHandle,\n} from \"../types\";\nimport type { ApplyEditPatch } from \"../hooks/use-carousel-state\";\nimport { aspectToCss } from \"../lib/aspect\";\n\nexport interface EditPanelProps {\n  item: MediaCarouselItem;\n  aspect: AspectRatio;\n  editorProps?: CarouselComposerProps[\"editorProps\"];\n  labels: {\n    editDone: string;\n    editCancel: string;\n    editSaving: string;\n    editError: string;\n  };\n  onApply: (id: string, patch: ApplyEditPatch) => void;\n  onCancel: () => void;\n}\n\nfunction initialSourceFor(item: MediaCarouselItem): InitialSource {\n  const mode = item.kind === \"video\" ? \"video\" : \"photo\";\n  return item.blob\n    ? { kind: \"blob\", blob: item.blob, mode }\n    : { kind: \"url\", url: item.url, mode };\n}\n\n/**\n * The single, shared edit panel. Mounts ONE `media-editor` in edit-only mode\n * (`enabledModes={[]}` → no capture chrome / no photo-video tabs) for the\n * selected item, keyed by `item.id` so switching items remounts with a fresh\n * source.\n *\n * Re-edit contract (mirrors content-composer's media-substrate): when the item\n * already has `editorState`, we DON'T pass `initialSource` — `loadState` supplies\n * both the original image (`editorState.imageSrc`) AND the editable overlays.\n * Passing `initialSource` *as well* would composite the flattened export under\n * the re-applied overlays (double overlays). `initialSource` is only for the\n * first edit, before any `editorState` exists.\n */\nexport function EditPanel({\n  item,\n  aspect,\n  editorProps,\n  labels,\n  onApply,\n  onCancel,\n}: EditPanelProps) {\n  const editorRef = useRef<MediaEditorHandle | null>(null);\n  const [busy, setBusy] = useState(false);\n  const [failed, setFailed] = useState(false);\n\n  const seedSource = item.editorState ? undefined : initialSourceFor(item);\n\n  // Restore prior editable layers for a re-edit (mount-only — `item` is fixed\n  // for this instance because the parent keys it by `item.id`). The snapshot's\n  // imageSrc is a DEAD object URL (revoked when the previous edit's editor\n  // unmounted) — pass the persisted source blob so loadState re-mints a live\n  // one instead of opening on a black canvas (review 1.3).\n  useEffect(() => {\n    if (item.editorState && editorRef.current) {\n      editorRef.current.loadState(item.editorState, {\n        sourceBlob: item.sourceBlob ?? null,\n      });\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const handleDone = async () => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    setBusy(true);\n    setFailed(false);\n    try {\n      const { blob, metadata } = await editor.export();\n      const url = URL.createObjectURL(blob);\n      onApply(item.id, {\n        url,\n        blob,\n        editorState: { ...editor.getState(), videoBlob: null },\n        // The blob backing editorState.imageSrc — persisted so the NEXT\n        // re-edit can re-materialize after this editor's URLs are revoked.\n        sourceBlob: editor.getSourceBlob() ?? item.sourceBlob,\n        exportMeta: metadata,\n        width: metadata.width,\n        height: metadata.height,\n      });\n    } catch {\n      // Keep the panel open so the user can retry; nothing is committed.\n      setFailed(true);\n    } finally {\n      setBusy(false);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div\n        className=\"overflow-hidden rounded-lg border border-border\"\n        style={{ aspectRatio: aspectToCss(aspect) }}\n      >\n        <MediaEditor\n          ref={editorRef}\n          enabledModes={[]}\n          presentation=\"inline\"\n          initialSource={seedSource}\n          aspect={aspect}\n          cropAspects={[aspect]}\n          {...editorProps}\n        />\n      </div>\n      {failed ? (\n        <p role=\"alert\" className=\"text-sm text-destructive\">\n          {labels.editError}\n        </p>\n      ) : null}\n      <div className=\"flex items-center justify-end gap-2\">\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          onClick={onCancel}\n          disabled={busy}\n        >\n          {labels.editCancel}\n        </Button>\n        <Button\n          type=\"button\"\n          onClick={handleDone}\n          disabled={busy}\n          aria-busy={busy || undefined}\n        >\n          {busy ? (\n            <>\n              <Loader2 className=\"size-4 animate-spin\" aria-hidden />\n              {labels.editSaving}\n            </>\n          ) : (\n            labels.editDone\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/carousel-composer/parts/edit-panel.tsx"
    }
  ],
  "categories": [
    "media"
  ],
  "type": "registry:block"
}