{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cms-template",
  "title": "CMS template",
  "author": "andongmin94",
  "description": "A compact CMS for finding, editing, and publishing posts.",
  "dependencies": [
    "lucide-react@^1.24.0"
  ],
  "registryDependencies": [
    "https://neobrutal-ui.andongmin.com/r/neobrutal-ui.json",
    "https://neobrutal-ui.andongmin.com/r/badge.json",
    "https://neobrutal-ui.andongmin.com/r/button.json",
    "https://neobrutal-ui.andongmin.com/r/input.json",
    "https://neobrutal-ui.andongmin.com/r/switch.json",
    "https://neobrutal-ui.andongmin.com/r/tabs.json",
    "https://neobrutal-ui.andongmin.com/r/textarea.json"
  ],
  "files": [
    {
      "path": "src/blocks/templates/cms-template.tsx",
      "content": "\"use client\";\n\nimport { Plus, Save, Search } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nconst TEMPLATE_THEME =\n  \"[color-scheme:light] [--background:#e7e8ff] [--secondary-background:#fff] [--foreground:#000] [--main-foreground:#000] [--main:#7983ff] [--border:#000] [--ring:#000] [--box-shadow-x:4px] [--box-shadow-y:4px] [--reverse-box-shadow-x:-4px] [--reverse-box-shadow-y:-4px] [--shadow:4px_4px_0px_0px_var(--border)] [--radius:5px] [--base-font-weight:600] [--heading-font-weight:700] dark:[color-scheme:dark] dark:[--background:#2c304c] dark:[--secondary-background:#222] dark:[--foreground:#ececec] dark:[--main:#7983ff] dark:[--ring:#fff]\";\n\ntype PostStatus = \"draft\" | \"published\";\ntype StatusFilter = \"all\" | PostStatus;\n\ntype Post = {\n  dirty: boolean;\n  id: string;\n  status: PostStatus;\n  summary: string;\n  title: string;\n  updatedLabel: string;\n};\n\ntype PostChanges = Partial<Pick<Post, \"status\" | \"summary\" | \"title\">>;\n\nconst INITIAL_POSTS: Post[] = [\n  {\n    dirty: false,\n    id: \"post-105\",\n    status: \"published\",\n    summary: \"Highlights from the latest workspace release.\",\n    title: \"July product update\",\n    updatedLabel: \"12 min\",\n  },\n  {\n    dirty: false,\n    id: \"post-104\",\n    status: \"draft\",\n    summary: \"A practical structure for growing teams.\",\n    title: \"Organize your first team space\",\n    updatedLabel: \"38 min\",\n  },\n  {\n    dirty: false,\n    id: \"post-103\",\n    status: \"published\",\n    summary: \"How Verdant Studio simplified editorial review.\",\n    title: \"A faster review process\",\n    updatedLabel: \"2 hr\",\n  },\n  {\n    dirty: false,\n    id: \"post-102\",\n    status: \"draft\",\n    summary: \"Final ownership, access, and link checks.\",\n    title: \"Public page checklist\",\n    updatedLabel: \"Yesterday\",\n  },\n  {\n    dirty: false,\n    id: \"post-101\",\n    status: \"published\",\n    summary: \"Set routing rules and keep requests visible.\",\n    title: \"Shared inbox routing\",\n    updatedLabel: \"Jul 11\",\n  },\n];\n\nconst STATUS_TABS: Array<{ label: string; value: StatusFilter }> = [\n  { label: \"All\", value: \"all\" },\n  { label: \"Draft\", value: \"draft\" },\n  { label: \"Published\", value: \"published\" },\n];\n\nfunction getDisplayPostTitle(post: Post) {\n  return post.title.trim() || \"Untitled post\";\n}\n\nfunction resolveSelectedPostId(posts: Post[], selectedId: string) {\n  return posts.some((post) => post.id === selectedId) ? selectedId : (posts[0]?.id ?? \"\");\n}\n\nfunction PostStatusBadge({ status }: { status: PostStatus }) {\n  return (\n    <Badge variant={status === \"published\" ? \"default\" : \"neutral\"} className=\"justify-self-start\">\n      {status === \"published\" ? \"Published\" : \"Draft\"}\n    </Badge>\n  );\n}\n\nfunction isStatusFilter(value: string): value is StatusFilter {\n  return STATUS_TABS.some((tab) => tab.value === value);\n}\n\nfunction PostListPane({\n  onSelect,\n  posts,\n  selectedId,\n}: {\n  onSelect: (postId: string) => void;\n  posts: Post[];\n  selectedId: string;\n}) {\n  return (\n    <section aria-label=\"Posts\" className=\"min-w-0\">\n      {posts.length === 0 ? (\n        <p className=\"px-4 py-12 text-center text-sm text-foreground/60\">No posts found.</p>\n      ) : (\n        <ul>\n          {posts.map((post) => {\n            const isSelected = post.id === selectedId;\n\n            return (\n              <li key={post.id} className=\"border-b-2 border-border last:border-b-0\">\n                <button\n                  type=\"button\"\n                  aria-pressed={isSelected}\n                  className={\n                    \"grid min-h-[4.5rem] w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-l-4 px-3 py-3 text-left outline-none transition-colors hover:bg-background focus-visible:bg-background sm:px-4 \" +\n                    (isSelected ? \"border-l-main bg-background\" : \"border-l-transparent\")\n                  }\n                  onClick={() => onSelect(post.id)}\n                >\n                  <span className=\"min-w-0\">\n                    <span className=\"block truncate text-sm font-heading\">\n                      {getDisplayPostTitle(post)}\n                    </span>\n                    <span className=\"mt-0.5 block truncate text-xs text-foreground/60\">\n                      {post.summary || \"No summary\"}\n                    </span>\n                  </span>\n\n                  <span className=\"grid justify-items-end gap-1.5\">\n                    <PostStatusBadge status={post.status} />\n                    <span className=\"text-xs text-foreground/60\">\n                      {post.dirty ? \"Unsaved\" : post.updatedLabel}\n                    </span>\n                  </span>\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      )}\n    </section>\n  );\n}\n\nfunction PostEditorPane({\n  onSubmit,\n  onUpdate,\n  post,\n  titleInputRef,\n}: {\n  onSubmit: React.FormEventHandler<HTMLFormElement>;\n  onUpdate: (changes: PostChanges) => void;\n  post: Post;\n  titleInputRef: React.RefObject<HTMLInputElement | null>;\n}) {\n  return (\n    <form className=\"flex h-full min-h-[24rem] flex-col\" onSubmit={onSubmit}>\n      <div className=\"flex flex-wrap items-center justify-between gap-3 border-b-2 border-border bg-background px-4 py-3\">\n        <div className=\"min-w-0\">\n          <h2 id=\"post-editor-title\" className=\"text-sm font-heading\">\n            Edit post\n          </h2>\n          <p className=\"mt-0.5 text-xs text-foreground/60\">\n            {post.dirty ? \"Unsaved changes\" : `Updated ${post.updatedLabel}`}\n          </p>\n        </div>\n\n        <Button type=\"submit\" size=\"sm\" disabled={!post.dirty}>\n          <Save aria-hidden=\"true\" />\n          Save\n        </Button>\n      </div>\n\n      <div className=\"grid flex-1 content-start gap-5 p-4 sm:p-5\">\n        <div className=\"space-y-1.5\">\n          <label htmlFor=\"post-title\" className=\"block text-sm font-heading\">\n            Title\n          </label>\n          <Input\n            ref={titleInputRef}\n            id=\"post-title\"\n            value={post.title}\n            onChange={(event) => onUpdate({ title: event.target.value })}\n          />\n        </div>\n\n        <div className=\"space-y-1.5\">\n          <label htmlFor=\"post-summary\" className=\"block text-sm font-heading\">\n            Summary\n          </label>\n          <Textarea\n            id=\"post-summary\"\n            className=\"min-h-40 resize-y\"\n            value={post.summary}\n            onChange={(event) => onUpdate({ summary: event.target.value })}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-between gap-4 border-t-2 border-border pt-4\">\n          <label htmlFor=\"post-published\" className=\"text-sm font-heading\">\n            Published\n          </label>\n          <Switch\n            id=\"post-published\"\n            size=\"sm\"\n            checked={post.status === \"published\"}\n            onCheckedChange={(checked) =>\n              onUpdate({\n                status: checked ? \"published\" : \"draft\",\n              })\n            }\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nfunction EmptyPostEditor({\n  onClearFilters,\n  onCreatePost,\n}: {\n  onClearFilters: () => void;\n  onCreatePost: () => void;\n}) {\n  return (\n    <div className=\"grid min-h-[24rem] place-content-center justify-items-center gap-3 p-6 text-center\">\n      <Search aria-hidden=\"true\" className=\"size-6 text-foreground/60\" />\n      <h2 id=\"post-editor-title\" className=\"text-base font-heading\">\n        No post selected\n      </h2>\n      <p className=\"max-w-xs text-sm text-foreground/60\">\n        Clear the current filters or start a new post.\n      </p>\n      <div className=\"mt-2 flex flex-wrap justify-center gap-3\">\n        <Button type=\"button\" variant=\"neutral\" onClick={onClearFilters}>\n          Clear filters\n        </Button>\n        <Button type=\"button\" onClick={onCreatePost}>\n          <Plus aria-hidden=\"true\" />\n          New post\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nexport default function CmsTemplate() {\n  const [posts, setPosts] = React.useState<Post[]>(INITIAL_POSTS);\n  const [query, setQuery] = React.useState(\"\");\n  const [selectedId, setSelectedId] = React.useState(INITIAL_POSTS[0].id);\n  const [statusFilter, setStatusFilter] = React.useState<StatusFilter>(\"all\");\n\n  const nextPostNumber = React.useRef(106);\n  const titleInputRef = React.useRef<HTMLInputElement>(null);\n\n  const normalizedQuery = query.trim().toLowerCase();\n  const filteredPosts = React.useMemo(\n    () =>\n      posts.filter((post) => {\n        const matchesStatus = statusFilter === \"all\" || post.status === statusFilter;\n        const searchText = (post.title + \" \" + post.summary).toLowerCase();\n        return matchesStatus && searchText.includes(normalizedQuery);\n      }),\n    [normalizedQuery, posts, statusFilter],\n  );\n  const resolvedSelectedId = resolveSelectedPostId(filteredPosts, selectedId);\n  const selectedPost = filteredPosts.find((post) => post.id === resolvedSelectedId);\n\n  React.useEffect(() => {\n    if (resolvedSelectedId !== selectedId) setSelectedId(resolvedSelectedId);\n  }, [resolvedSelectedId, selectedId]);\n\n  const handleFilterChange = (value: string) => {\n    if (isStatusFilter(value)) {\n      setStatusFilter(value);\n    }\n  };\n\n  const clearFilters = () => {\n    setQuery(\"\");\n    setStatusFilter(\"all\");\n  };\n\n  const updateSelectedPost = (changes: PostChanges) => {\n    setPosts((current) =>\n      current.map((post) =>\n        post.id === resolvedSelectedId ? { ...post, ...changes, dirty: true } : post,\n      ),\n    );\n  };\n\n  const saveSelectedPost = () => {\n    setPosts((current) =>\n      current.map((post) =>\n        post.id === resolvedSelectedId\n          ? {\n              ...post,\n              dirty: false,\n              summary: post.summary.trim(),\n              title: getDisplayPostTitle(post),\n              updatedLabel: \"Just now\",\n            }\n          : post,\n      ),\n    );\n  };\n\n  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    saveSelectedPost();\n  };\n\n  const createDraftPost = () => {\n    const post: Post = {\n      dirty: true,\n      id: \"post-\" + nextPostNumber.current,\n      status: \"draft\",\n      summary: \"\",\n      title: \"Untitled post\",\n      updatedLabel: \"Now\",\n    };\n\n    nextPostNumber.current += 1;\n    setPosts((current) => [post, ...current]);\n    setSelectedId(post.id);\n    setQuery(\"\");\n    setStatusFilter(\"all\");\n\n    window.requestAnimationFrame(() => {\n      titleInputRef.current?.focus();\n      titleInputRef.current?.select();\n    });\n  };\n\n  const publishedCount = posts.filter((post) => post.status === \"published\").length;\n\n  return (\n    <div className={`flex min-h-dvh flex-col bg-background text-foreground ${TEMPLATE_THEME}`}>\n      <header className=\"border-b-2 border-border bg-secondary-background\">\n        <div className=\"mx-auto flex h-12 w-full max-w-screen-xl items-center justify-between px-4 sm:px-6\">\n          <h1 className=\"text-base font-heading\">Folio CMS</h1>\n          <Button type=\"button\" size=\"sm\" onClick={createDraftPost}>\n            <Plus aria-hidden=\"true\" />\n            New post\n          </Button>\n        </div>\n      </header>\n\n      <main className=\"mx-auto flex w-full max-w-screen-xl flex-1 px-4 py-4 sm:px-6 sm:py-5\">\n        <section\n          aria-label=\"CMS workspace\"\n          className=\"grid w-full min-w-0 overflow-hidden rounded-base border-2 border-border bg-secondary-background lg:grid-cols-[minmax(18rem,2fr)_minmax(22rem,3fr)]\"\n        >\n          <div className=\"min-w-0 border-b-2 border-border lg:border-r-2 lg:border-b-0\">\n            <div className=\"space-y-2 border-b-2 border-border bg-background p-3\">\n              <div className=\"relative min-w-0\">\n                <label htmlFor=\"post-search\" className=\"sr-only\">\n                  Search posts\n                </label>\n                <Search\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/50\"\n                />\n                <Input\n                  id=\"post-search\"\n                  type=\"search\"\n                  className=\"h-9 pl-9\"\n                  placeholder=\"Search posts\"\n                  value={query}\n                  onChange={(event) => setQuery(event.target.value)}\n                />\n              </div>\n\n              <Tabs value={statusFilter} onValueChange={handleFilterChange}>\n                <TabsList\n                  aria-label=\"Filter posts by status\"\n                  className=\"grid h-9 w-full grid-cols-3 p-0\"\n                >\n                  {STATUS_TABS.map((tab) => (\n                    <TabsTrigger key={tab.value} value={tab.value} className=\"min-w-0 px-2\">\n                      {tab.label}\n                    </TabsTrigger>\n                  ))}\n                </TabsList>\n              </Tabs>\n            </div>\n\n            <PostListPane\n              posts={filteredPosts}\n              selectedId={resolvedSelectedId}\n              onSelect={setSelectedId}\n            />\n          </div>\n\n          <section aria-labelledby=\"post-editor-title\" className=\"min-w-0\">\n            {selectedPost ? (\n              <PostEditorPane\n                post={selectedPost}\n                titleInputRef={titleInputRef}\n                onSubmit={handleSubmit}\n                onUpdate={updateSelectedPost}\n              />\n            ) : (\n              <EmptyPostEditor onClearFilters={clearFilters} onCreatePost={createDraftPost} />\n            )}\n          </section>\n        </section>\n      </main>\n\n      <footer className=\"border-t-2 border-border bg-secondary-background\">\n        <div className=\"mx-auto flex h-9 w-full max-w-screen-xl items-center justify-between px-4 text-xs text-foreground/60 sm:px-6\">\n          <p>{posts.length} posts</p>\n          <p>{publishedCount} published</p>\n        </div>\n      </footer>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/templates/cms-template.tsx"
    },
    {
      "path": "src/blocks/templates/pages/cms/page.tsx",
      "content": "import CmsTemplate from \"@/components/templates/cms-template\";\n\nexport default function Page() {\n  return <CmsTemplate />;\n}\n",
      "type": "registry:page",
      "target": "app/cms/page.tsx"
    }
  ],
  "categories": [
    "template",
    "cms"
  ],
  "type": "registry:block"
}