{
  "id": "tool-workspace",
  "name": "File processing workspace",
  "description": "A focused media workspace with local file selection, output controls, a filterable task queue, downloadable demo previews, and a clear usage summary.",
  "category": "Application",
  "blockIds": [
    "workspace-header-01",
    "upload-workflow-01",
    "task-queue-01",
    "results-gallery-01",
    "usage-panel-01"
  ],
  "style": "clean-saas",
  "registryId": "recipe-tool-workspace",
  "componentName": "ToolWorkspaceRecipe",
  "componentSource": "content/recipe-components/tool-workspace.tsx",
  "installUsage": "import ToolWorkspaceRecipe from \"@/components/uiblocks/recipe-tool-workspace/recipe-tool-workspace\";\n\nexport default function WorkspacePage() {\n  return <ToolWorkspaceRecipe mode=\"demo\" websiteHref=\"/\" upgradeHref=\"/pricing\" />;\n}",
  "propsContract": [
    {
      "name": "mode",
      "description": "Default 'controlled' mode never fabricates processing or results. Explicit mode='demo' enables browser-only timers, illustrative files, SVG downloads, and optional state controls."
    },
    {
      "name": "files, onFilesChange, status, progress, tasks, results",
      "description": "Supply real service state in controlled mode. The default file selector retains metadata only, not File bytes. Keep File objects in the host uploader and use renderUpload for authenticated uploads."
    },
    {
      "name": "onRun, onRetry",
      "description": "Controlled-mode callbacks receive selected file metadata/format or a task ID. The host performs network work and supplies status/results; rejected callbacks show a retryable error, never a simulated success."
    },
    {
      "name": "renderUpload, renderResults, headerSlot, footer",
      "description": "Replace example UI with your real upload control, authenticated result/download links, navigation/account header, and product footer. The bundled blocks retain explicit demo labels; the default gallery only downloads SVG examples."
    },
    {
      "name": "header, upload, queueTitle, resultsTitle, usage, websiteHref, upgradeHref",
      "description": "Adapt workspace copy, product identity, plan/usage data, and normal host navigation links. Do not treat illustrative credits or download previews as real billing or conversion."
    },
    {
      "name": "searchQuery, onSearchChange",
      "description": "Share one search state across the workspace header, task queue, and results, or allow the page to manage local search."
    },
    {
      "name": "theme, className",
      "description": "Inherit the host theme, or enable its class-based dark variant with theme='dark'; customize shared Tailwind 4 theme tokens and the page className."
    }
  ],
  "contentGuidelines": [
    "Keep the workspace heading to one short sentence and file names to their original values, truncating visual labels when needed.",
    "Use shared state for file selection, queue progress, and results; keep the upload controls disabled while a task runs.",
    "Provide empty, processing, completed, and error states. Status must remain understandable without relying on color.",
    "The preview is a local interface simulation. Selected videos are never uploaded or converted; downloads are labeled SVG demo previews.",
    "Connect the upload and retry callbacks to your own authenticated processing service before using this as a production tool.",
    "Keep demo credits and illustrative usage separate from real billing data. Validate the layout with long file names and mobile widths."
  ],
  "prompt": "Build a file-processing workspace using the UIBlocks tool-workspace recipe and clean-saas style. Install the complete recipe-tool-workspace registry item in one command and import ToolWorkspaceRecipe. Inspect its full preview and preserve the neutral surfaces, violet accent, type scale, and spacing. Use explicit mode='demo' only to inspect the browser-only sample. For a real product, use controlled mode with one source of files/tasks/results/search state and onRun/onRetry service callbacks; supply renderUpload for actual File bytes and renderResults for real authenticated download URLs. Adapt the blocks' explicit demo copy. Implement empty, processing, completed, and error states without presenting simulated conversion or credits as real. Check keyboard navigation, long file names, and mobile layout.",
  "preview": "/preview/recipes/tool-workspace",
  "screenshot": "/previews/recipe-tool-workspace.png",
  "screenshotMetadata": {
    "renderer": "Chromium",
    "id": "recipe-tool-workspace",
    "image": "recipe-tool-workspace.png",
    "sourceFiles": [
      "app/globals.css",
      "components/brand.tsx",
      "components/copy-button.tsx",
      "components/footer.tsx",
      "components/header.tsx",
      "components/recipes/recipe-preview.tsx",
      "components/registry-setup.tsx",
      "components/seo-jsonld.tsx",
      "components/theme-toggle.tsx",
      "components/ui/button.tsx",
      "components/ui/dialog.tsx",
      "components/ui/sonner.tsx",
      "components/ui/tooltip.tsx",
      "config.ts",
      "content/blocks-metadata.ts",
      "content/catalog.ts",
      "content/components/marketing/faq-01.tsx",
      "content/components/marketing/features-01.tsx",
      "content/components/marketing/footer-01.tsx",
      "content/components/marketing/hero-01.tsx",
      "content/components/marketing/logo-cloud-01.tsx",
      "content/components/marketing/navigation-01.tsx",
      "content/components/marketing/pricing-01.tsx",
      "content/components/workspace/results-gallery-01.tsx",
      "content/components/workspace/task-queue-01.tsx",
      "content/components/workspace/upload-workflow-01.tsx",
      "content/components/workspace/usage-panel-01.tsx",
      "content/components/workspace/workspace-header-01.tsx",
      "content/declarations.ts",
      "content/recipe-components/tool-landing.tsx",
      "content/recipe-components/tool-workspace.tsx",
      "content/recipes.ts",
      "hooks/use-registry-origin.ts",
      "lib/utils.ts",
      "package-lock.json",
      "package.json",
      "postcss.config.mjs",
      "public/icon.svg",
      "public/opengraph-image.png",
      "scripts/capture-previews.mjs",
      "scripts/preview-provenance.mjs",
      "src/lib/head.ts",
      "src/routes/__root.tsx",
      "src/routes/preview.recipes.$recipeId.tsx",
      "vite.config.ts"
    ],
    "sourceHash": "0d2fbe99d6853624a9c700b479dd6bcaa7d2c81161b2b6aef25267cc72896f91",
    "imageHash": "43a8b7ae4072ea4bde2f10e431abb79a3a03da8e86e32e11e08cbe6abdf7697d",
    "capturedAt": "2026-10-30T04:36:52.986Z",
    "viewport": {
      "width": 1200,
      "height": 800
    },
    "dimensions": {
      "width": 1200,
      "height": 1470
    },
    "state": "default"
  },
  "url": "/recipes/tool-workspace",
  "registry": "/r/recipe-tool-workspace.json",
  "install": {
    "direct": "npx shadcn@latest add https://uiblocks.org/r/recipe-tool-workspace.json",
    "namespaced": "npx shadcn@latest add @uiblocks/recipe-tool-workspace",
    "registryConfig": {
      "registries": {
        "@uiblocks": "https://uiblocks.org/r/{name}.json"
      }
    },
    "entry": "@components/uiblocks/recipe-tool-workspace/recipe-tool-workspace.tsx",
    "exportName": "default",
    "dependencies": [
      "lucide-react@0.577.0"
    ],
    "requirements": {
      "react": ">=19",
      "tailwind": ">=4"
    },
    "usage": "import ToolWorkspaceRecipe from \"@/components/uiblocks/recipe-tool-workspace/recipe-tool-workspace\";\n\nexport default function WorkspacePage() {\n  return <ToolWorkspaceRecipe mode=\"demo\" websiteHref=\"/\" upgradeHref=\"/pricing\" />;\n}"
  },
  "compositionSource": {
    "language": "tsx",
    "componentName": "ToolWorkspaceRecipe",
    "referenceOnly": false,
    "installedEntry": "@components/uiblocks/recipe-tool-workspace/recipe-tool-workspace.tsx",
    "content": "\"use client\";\n\nimport {\n  CheckCircle2,\n  Circle,\n  CircleAlert,\n  FlaskConical,\n  LoaderCircle,\n  RotateCcw,\n} from \"lucide-react\";\nimport {\n  useEffect,\n  useState,\n  type ReactNode,\n  type ComponentProps,\n} from \"react\";\nimport ResultsGallery01, {\n  type DemoResult,\n} from \"./shared/content/components/workspace/results-gallery-01\";\nimport TaskQueue01, {\n  type DemoTask,\n} from \"./shared/content/components/workspace/task-queue-01\";\nimport UploadWorkflow01, {\n  type WorkflowFile,\n} from \"./shared/content/components/workspace/upload-workflow-01\";\nimport UsagePanel01 from \"./shared/content/components/workspace/usage-panel-01\";\nimport WorkspaceHeader01 from \"./shared/content/components/workspace/workspace-header-01\";\n\nexport type WorkspaceStatus =\n  | \"empty\"\n  | \"ready\"\n  | \"processing\"\n  | \"completed\"\n  | \"error\";\nexport type { WorkflowFile, DemoTask, DemoResult };\nexport type WorkspaceUploadContext = {\n  files: WorkflowFile[];\n  busy: boolean;\n  onFilesChange: (files: WorkflowFile[]) => void;\n  onRun: (files: WorkflowFile[], format: string) => Promise<void>;\n};\nexport type ToolWorkspaceRecipeProps = {\n  /** Only explicit demo mode starts local timers or fabricates sample results. */\n  mode?: \"controlled\" | \"demo\";\n  theme?: \"inherit\" | \"dark\";\n  className?: string;\n  websiteHref?: string;\n  upgradeHref?: string;\n  header?: Omit<\n    NonNullable<ComponentProps<typeof WorkspaceHeader01>>,\n    \"onSearch\" | \"searchQuery\" | \"websiteHref\"\n  >;\n  upload?: Pick<\n    NonNullable<ComponentProps<typeof UploadWorkflow01>>,\n    \"title\" | \"description\"\n  >;\n  queueTitle?: string;\n  resultsTitle?: string;\n  usage?: ComponentProps<typeof UsagePanel01>;\n  files?: WorkflowFile[];\n  onFilesChange?: (files: WorkflowFile[]) => void;\n  status?: WorkspaceStatus;\n  progress?: number;\n  tasks?: DemoTask[];\n  results?: DemoResult[];\n  searchQuery?: string;\n  onSearchChange?: (query: string) => void;\n  /** Invoked only in controlled mode; the host owns network work and status updates. */\n  onRun?: (files: WorkflowFile[], format: string) => void | Promise<void>;\n  onRetry?: (id: string) => void | Promise<void>;\n  errorMessage?: string;\n  initialDemoState?: WorkspaceStatus;\n  demoFiles?: WorkflowFile[];\n  showDemoControls?: boolean;\n  /** The default selector returns metadata, not File bytes. Supply a real upload control here. */\n  renderUpload?: (context: WorkspaceUploadContext) => ReactNode;\n  /** The default gallery downloads SVG examples. Supply real result links here. */\n  renderResults?: (context: {\n    results: DemoResult[];\n    searchQuery: string;\n    status: WorkspaceStatus;\n  }) => ReactNode;\n  headerSlot?: ReactNode;\n  footer?: ReactNode;\n};\nconst exampleFiles: WorkflowFile[] = [\n  { id: \"sample-launch\", name: \"Launch-film.mp4\", size: 26_738_000 },\n  { id: \"sample-studio\", name: \"Studio-walkthrough.mov\", size: 48_124_000 },\n];\n\n/**\n * Five-block workspace composition. Controlled mode never simulates a backend.\n * The included UI blocks retain their honest demo labels; adapt these source\n * files, renderUpload/renderResults/headerSlot, and service callbacks for a\n * production product. No upload, transcoding, authentication, or billing API is included.\n */\nexport default function ToolWorkspaceRecipe({\n  mode = \"controlled\",\n  theme = \"inherit\",\n  className = \"\",\n  websiteHref = \"/\",\n  upgradeHref = \"#pricing\",\n  header,\n  upload,\n  queueTitle,\n  resultsTitle,\n  usage,\n  files: suppliedFiles,\n  onFilesChange,\n  status: suppliedStatus,\n  progress: suppliedProgress,\n  tasks: suppliedTasks,\n  results: suppliedResults,\n  searchQuery: suppliedSearch,\n  onSearchChange,\n  onRun,\n  onRetry,\n  errorMessage,\n  initialDemoState = \"completed\",\n  demoFiles = exampleFiles,\n  showDemoControls = true,\n  renderUpload,\n  renderResults,\n  headerSlot,\n  footer,\n}: ToolWorkspaceRecipeProps) {\n  const [localFiles, setLocalFiles] = useState<WorkflowFile[]>(\n    mode === \"demo\" && initialDemoState !== \"empty\" ? demoFiles : []\n  );\n  const files = suppliedFiles ?? localFiles;\n  const [demoState, setDemoState] = useState<WorkspaceStatus>(initialDemoState);\n  const state =\n    mode === \"demo\"\n      ? demoState\n      : (suppliedStatus ?? (files.length ? \"ready\" : \"empty\"));\n  const [format, setFormat] = useState(\"MP4\");\n  const [demoProgress, setDemoProgress] = useState(\n    initialDemoState === \"completed\" ? 100 : 0\n  );\n  const rawProgress = mode === \"demo\" ? demoProgress : (suppliedProgress ?? 0);\n  const progress = Number.isFinite(rawProgress)\n    ? Math.max(0, Math.min(100, rawProgress))\n    : 0;\n  const [submitting, setSubmitting] = useState(false);\n  const [serviceError, setServiceError] = useState(\"\");\n  const [localSearch, setLocalSearch] = useState(\"\");\n  const searchQuery = suppliedSearch ?? localSearch;\n  function setSearchQuery(query: string) {\n    setLocalSearch(query);\n    onSearchChange?.(query);\n  }\n\n  useEffect(() => {\n    if (mode !== \"demo\" || state !== \"processing\") return;\n    const interval = window.setInterval(\n      () => setDemoProgress((value) => Math.min(96, value + 14)),\n      350\n    );\n    const timeout = window.setTimeout(() => {\n      setDemoProgress(100);\n      setDemoState(\"completed\");\n    }, 2400);\n    return () => {\n      window.clearInterval(interval);\n      window.clearTimeout(timeout);\n    };\n  }, [mode, state]);\n\n  const tasks: DemoTask[] =\n    suppliedTasks?.map((task) =>\n      task.status === \"processing\" && task.progress === undefined\n        ? { ...task, progress }\n        : task\n    ) ??\n    (mode === \"demo\"\n      ? files.map((file, index) => ({\n          id: file.id,\n          name: file.name,\n          detail: `${format} · Demo workflow`,\n          status:\n            state === \"completed\"\n              ? \"completed\"\n              : state === \"error\" && index === 0\n                ? \"error\"\n                : state === \"processing\" && index === 0\n                  ? \"processing\"\n                  : \"queued\",\n          progress:\n            state === \"processing\" && index === 0 ? progress : undefined,\n        }))\n      : []);\n  const results: DemoResult[] =\n    suppliedResults ??\n    (mode === \"demo\" && state === \"completed\"\n      ? files.map((file, index) => ({\n          id: file.id,\n          name: file.name.replace(/\\.[^.]+$/, \"\"),\n          format,\n          detail: \"SVG demo preview · No video conversion\",\n          tone: ([\"violet\", \"mint\", \"peach\"] as const)[index % 3],\n        }))\n      : []);\n\n  function changeFiles(nextFiles: WorkflowFile[]) {\n    setLocalFiles(nextFiles);\n    if (mode === \"demo\") {\n      setDemoProgress(0);\n      setDemoState(nextFiles.length ? \"ready\" : \"empty\");\n    } else onFilesChange?.(nextFiles);\n  }\n\n  async function startWorkflow(nextFiles: WorkflowFile[], nextFormat: string) {\n    if (!nextFiles.length || submitting) return;\n    setFormat(nextFormat);\n    setServiceError(\"\");\n    if (mode === \"demo\") {\n      setLocalFiles(nextFiles);\n      setDemoProgress(8);\n      setDemoState(\"processing\");\n      return;\n    }\n    if (!onRun) {\n      setServiceError(\n        \"This workflow is not connected to a processing service yet.\"\n      );\n      return;\n    }\n    setSubmitting(true);\n    try {\n      await onRun(nextFiles, nextFormat);\n    } catch {\n      setServiceError(\"The workflow could not start. Please try again.\");\n    } finally {\n      setSubmitting(false);\n    }\n  }\n\n  async function retryTask(id: string) {\n    if (submitting) return;\n    if (mode === \"demo\") return startWorkflow(files, format);\n    if (!onRetry) {\n      if (onRun && files.length) return startWorkflow(files, format);\n      setServiceError(\"This task is not connected to a retry action yet.\");\n      return;\n    }\n    setServiceError(\"\");\n    setSubmitting(true);\n    try {\n      await onRetry(id);\n    } catch {\n      setServiceError(\"The task could not restart. Please try again.\");\n    } finally {\n      setSubmitting(false);\n    }\n  }\n  const uploadContext: WorkspaceUploadContext = {\n    files,\n    busy: state === \"processing\" || submitting,\n    onFilesChange: changeFiles,\n    onRun: startWorkflow,\n  };\n\n  function changeDemoState(nextState: WorkspaceStatus) {\n    if (nextState === \"empty\") {\n      setLocalFiles([]);\n      setSearchQuery(\"\");\n    } else if (!files.length) {\n      setLocalFiles(demoFiles);\n    }\n    setDemoProgress(\n      nextState === \"processing\" ? 8 : nextState === \"completed\" ? 100 : 0\n    );\n    setDemoState(nextState);\n  }\n\n  const states = [\n    { value: \"empty\", label: \"Empty\", icon: Circle },\n    { value: \"processing\", label: \"Processing\", icon: LoaderCircle },\n    { value: \"completed\", label: \"Complete\", icon: CheckCircle2 },\n    { value: \"error\", label: \"Error\", icon: CircleAlert },\n  ] as const;\n\n  return (\n    <div\n      className={`${theme === \"inherit\" ? \"\" : theme} min-h-screen bg-zinc-50 text-zinc-950 dark:bg-zinc-900/50 dark:text-zinc-50 ${className}`}\n    >\n      {headerSlot ?? (\n        <WorkspaceHeader01\n          {...header}\n          onSearch={setSearchQuery}\n          searchQuery={searchQuery}\n          websiteHref={websiteHref}\n        />\n      )}\n      <main className=\"mx-auto max-w-7xl px-5 pb-12 sm:px-8\">\n        {mode === \"demo\" && showDemoControls && (\n          <div className=\"mb-6 flex flex-col justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 sm:flex-row sm:items-center dark:border-zinc-800 dark:bg-zinc-950\">\n            <div className=\"flex items-center gap-2.5\">\n              <FlaskConical\n                aria-hidden=\"true\"\n                className=\"size-4 shrink-0 text-violet-500\"\n              />\n              <p className=\"text-[11px] leading-5 text-zinc-500 dark:text-zinc-400\">\n                <span className=\"font-medium text-zinc-700 dark:text-zinc-200\">\n                  Try every state.\n                </span>{\" \"}\n                Sample files are illustrative. Nothing is uploaded or converted.\n              </p>\n            </div>\n            <div\n              aria-label=\"Choose a demo state\"\n              className=\"flex flex-wrap gap-1\"\n              role=\"group\"\n            >\n              {states.map(({ value, label, icon: Icon }) => (\n                <button\n                  aria-pressed={state === value}\n                  className={`inline-flex min-h-8 items-center gap-1.5 rounded-md px-2.5 text-[10px] font-medium transition-colors focus-visible:outline-2 focus-visible:outline-violet-500 ${state === value ? \"bg-violet-50 text-violet-600 dark:bg-violet-950/50 dark:text-violet-300\" : \"text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200\"}`}\n                  key={value}\n                  onClick={() => changeDemoState(value)}\n                  type=\"button\"\n                >\n                  <Icon\n                    aria-hidden=\"true\"\n                    className={`size-3 ${state === value && value === \"processing\" ? \"animate-spin\" : \"\"}`}\n                  />\n                  {label}\n                </button>\n              ))}\n            </div>\n          </div>\n        )}\n        {(state === \"error\" || serviceError) && (\n          <div\n            className=\"mb-6 flex flex-wrap items-center gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-red-700 dark:border-red-900 dark:bg-red-950/20 dark:text-red-300\"\n            role=\"alert\"\n          >\n            <CircleAlert aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n            <p className=\"flex-1 text-xs leading-5\">\n              {errorMessage ??\n                (serviceError ||\n                  (mode === \"demo\"\n                    ? \"Demo error: this task needs another try. Your selected file details are still here.\"\n                    : \"This task needs another try. Your selected file details are still here.\"))}\n            </p>\n            {(mode === \"demo\" || onRetry || onRun) && (\n              <button\n                className=\"inline-flex min-h-8 items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 text-[11px] font-medium hover:bg-red-100 dark:border-red-800 dark:bg-red-950/30 dark:hover:bg-red-950\"\n                disabled={submitting}\n                onClick={() =>\n                  void retryTask(\n                    tasks.find((task) => task.status === \"error\")?.id ??\n                      files[0]?.id ??\n                      \"\"\n                  )\n                }\n                type=\"button\"\n              >\n                <RotateCcw aria-hidden=\"true\" className=\"size-3\" />{\" \"}\n                {mode === \"demo\" ? \"Retry demo\" : \"Retry\"}\n              </button>\n            )}\n          </div>\n        )}\n        <div className=\"grid grid-cols-1 items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] xl:grid-cols-[320px_minmax(0,1fr)_250px]\">\n          {renderUpload ? (\n            renderUpload(uploadContext)\n          ) : (\n            <UploadWorkflow01\n              {...upload}\n              busy={uploadContext.busy}\n              files={files}\n              onFilesChange={changeFiles}\n              onRun={(nextFiles, nextFormat) =>\n                void startWorkflow(nextFiles, nextFormat)\n              }\n            />\n          )}\n          <div className=\"min-w-0 space-y-6\">\n            <TaskQueue01\n              onRetry={(id) => void retryTask(id)}\n              title={queueTitle}\n              searchQuery={searchQuery}\n              tasks={tasks}\n            />\n            {renderResults ? (\n              renderResults({ results, searchQuery, status: state })\n            ) : (\n              <ResultsGallery01\n                title={resultsTitle}\n                results={results}\n                searchQuery={searchQuery}\n              />\n            )}\n          </div>\n          <div className=\"md:col-span-2 xl:col-span-1\">\n            <UsagePanel01\n              planName={mode === \"demo\" ? \"Studio demo\" : \"Usage\"}\n              upgradeHref={upgradeHref}\n              used={\n                mode === \"demo\"\n                  ? 38 + (state === \"completed\" ? files.length : 0)\n                  : 0\n              }\n              {...usage}\n            />\n          </div>\n        </div>\n        {footer ??\n          (mode === \"demo\" && (\n            <p className=\"mt-7 text-center text-[10px] leading-5 text-zinc-400\">\n              Frame is a fictional product. This UIBlocks recipe demonstrates\n              local interface behavior, not a hosted processing service.\n            </p>\n          ))}\n      </main>\n    </div>\n  );\n}\n",
    "guidance": "Install this complete recipe with its single shadcn command, then import the component using your configured components alias and meta.entry. Matching blocks, helpers, and MIT notices are bundled. Pass your own copy, navigation, and callbacks through the published props. Workspace simulation requires explicit demo mode; controlled mode delegates processing to your application."
  }
}
