# CMS template

> An editorial workspace with content preview, local save, and change recovery.

A shadcn/ui block from the [@neobrutal-ui](https://neobrutal-ui.andongmin.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/neobrutal-ui/cms-template).

- Page, with a live preview: https://shadcn.exchange/r/neobrutal-ui/cms-template
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/neobrutal-ui/cms-template-light-6d373750d8120dcd.png
- Registry item: https://neobrutal-ui.andongmin.com/r/cms-template.json
- Categories: template, cms
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/cms-template.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `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/textarea.json`

## Files

### `src/blocks/templates/cms-template.tsx`

```tsx
"use client";

import { Eye, Plus, RotateCcw, Save, Search } from "lucide-react";
import * as React from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";

type PostStatus = "draft" | "published";
type StatusFilter = "all" | PostStatus;

type Post = {
  body: string;
  dirty: boolean;
  id: string;
  status: PostStatus;
  summary: string;
  title: string;
  updatedLabel: string;
};

type PostChanges = Partial<Pick<Post, "body" | "status" | "summary" | "title">>;

const INITIAL_POSTS: Post[] = [
  {
    dirty: false,
    body: "This release brings clearer ownership, faster search, and a simpler review queue.\n\nStart with the updated workspace overview, then review the changes with your team.",
    id: "post-105",
    status: "published",
    summary: "Highlights from the latest workspace release.",
    title: "July product update",
    updatedLabel: "12 min",
  },
  {
    dirty: false,
    body: "Start with one shared space. Assign an owner to each area, write down the naming rules, and invite a small group to test the workflow.",
    id: "post-104",
    status: "draft",
    summary: "A practical structure for growing teams.",
    title: "Organize your first team space",
    updatedLabel: "38 min",
  },
  {
    dirty: false,
    body: "The team replaced scattered feedback with a single review queue. Every draft now has a named reviewer and a clear next action.",
    id: "post-103",
    status: "published",
    summary: "How Verdant Studio simplified editorial review.",
    title: "A faster review process",
    updatedLabel: "2 hr",
  },
  {
    dirty: false,
    body: "Check ownership and access. Read the page on a narrow screen. Test every link and confirm that the contact details are current.",
    id: "post-102",
    status: "draft",
    summary: "Final ownership, access, and link checks.",
    title: "Public page checklist",
    updatedLabel: "Yesterday",
  },
  {
    dirty: false,
    body: "Route each request to one responsible team. Keep unassigned requests visible and review routing rules when the team changes.",
    id: "post-101",
    status: "published",
    summary: "Set routing rules and keep requests visible.",
    title: "Shared inbox routing",
    updatedLabel: "Jul 11",
  },
];

const STATUS_TABS: Array<{ label: string; value: StatusFilter }> = [
  { label: "All", value: "all" },
  { label: "Draft", value: "draft" },
  { label: "Published", value: "published" },
];

function getFilteredPosts(posts: Post[], query: string, statusFilter: StatusFilter) {
  const normalizedQuery = query.trim().toLowerCase();
  return posts.filter((post) => {
    const matchesStatus = statusFilter === "all" || post.status === statusFilter;
    return (
      matchesStatus && (post.title + " " + post.summary).toLowerCase().includes(normalizedQuery)
    );
  });
}

function getDisplayPostTitle(post: Post) {
  return post.title.trim() || "Untitled post";
}

function resolveSelectedPostId(posts: Post[], selectedId: string) {
  return posts.some((post) => post.id === selectedId) ? selectedId : (posts[0]?.id ?? "");
}

function PostStatusBadge({ status }: { status: PostStatus }) {
  return (
    <Badge variant={status === "published" ? "default" : "neutral"} className="justify-self-start">
      {status === "published" ? "Published" : "Draft"}
    </Badge>
  );
}

function PostListPane({
  onSelect,
  posts,
  selectedId,
}: {
  onSelect: (postId: string) => void;
  posts: Post[];
  selectedId: string;
}) {
  return (
    <section aria-label="Posts" className="min-w-0">
      {posts.length === 0 ? (
        <p className="px-4 py-12 text-center text-sm text-foreground/60">No posts found.</p>
      ) : (
        <ul>
          {posts.map((post) => {
            const isSelected = post.id === selectedId;

            return (
              <li key={post.id} className="border-b-2 border-border last:border-b-0">
                <button
                  type="button"
                  aria-pressed={isSelected}
                  className={
                    "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:ring-1 focus-visible:ring-inset focus-visible:ring-ring sm:px-4 " +
                    (isSelected ? "border-l-main bg-background" : "border-l-transparent")
                  }
                  onClick={() => onSelect(post.id)}
                >
                  <span className="min-w-0">
                    <span className="block truncate text-sm font-heading">
                      {getDisplayPostTitle(post)}
                    </span>
                    <span className="mt-0.5 block truncate text-xs text-foreground/60">
                      {post.summary || "No summary"}
                    </span>
                  </span>

                  <span className="grid justify-items-end gap-1.5">
                    <PostStatusBadge status={post.status} />
                    <span className="text-xs text-foreground/60">
                      {post.dirty ? "Unsaved" : post.updatedLabel}
                    </span>
                  </span>
                </button>
              </li>
            );
          })}
        </ul>
      )}
    </section>
  );
}

function PostEditorPane({
  hiddenByFilters,
  onClearFilters,
  onDiscard,
  onSubmit,
  onUpdate,
  post,
  titleInputRef,
}: {
  hiddenByFilters: boolean;
  onClearFilters: () => void;
  onDiscard: () => void;
  onSubmit: React.FormEventHandler<HTMLFormElement>;
  onUpdate: (changes: PostChanges) => void;
  post: Post;
  titleInputRef: React.RefObject<HTMLInputElement | null>;
}) {
  return (
    <form className="flex h-full min-h-[24rem] flex-col" onSubmit={onSubmit}>
      <div className="flex flex-wrap items-center justify-between gap-3 border-b-2 border-border bg-background px-4 py-3">
        <div className="min-w-0">
          <h2 id="post-editor-title" className="text-sm font-heading">
            Edit post
          </h2>
          <p className="mt-0.5 text-xs text-foreground/60">
            {post.dirty ? "Unsaved changes" : `Updated ${post.updatedLabel}`}
          </p>
        </div>

        <div className="flex flex-wrap gap-3">
          <Button
            type="button"
            size="sm"
            variant="neutral"
            disabled={!post.dirty}
            onClick={onDiscard}
          >
            <RotateCcw aria-hidden="true" />
            Discard
          </Button>
          <Button type="submit" size="sm" disabled={!post.dirty}>
            <Save aria-hidden="true" />
            Save
          </Button>
        </div>
      </div>

      {hiddenByFilters ? (
        <div className="border-b-2 border-border px-4 py-3 text-sm">
          <p role="status">
            This post no longer matches the current filters. Your edits are still here.
          </p>
          <Button
            type="button"
            variant="link"
            size="sm"
            className="mt-1 px-0"
            onClick={onClearFilters}
          >
            Clear filters
          </Button>
        </div>
      ) : null}

      <div className="grid flex-1 content-start gap-5 p-4 sm:p-5">
        <div className="space-y-1.5">
          <label htmlFor="post-title" className="block text-sm font-heading">
            Title
          </label>
          <Input
            ref={titleInputRef}
            id="post-title"
            required
            value={post.title}
            onChange={(event) => onUpdate({ title: event.target.value })}
          />
        </div>

        <div className="space-y-1.5">
          <label htmlFor="post-summary" className="block text-sm font-heading">
            Summary
          </label>
          <Textarea
            id="post-summary"
            className="min-h-24 resize-y"
            value={post.summary}
            onChange={(event) => onUpdate({ summary: event.target.value })}
          />
        </div>

        <div className="space-y-1.5">
          <label htmlFor="post-body" className="block text-sm font-heading">
            Content
          </label>
          <Textarea
            id="post-body"
            className="min-h-48 resize-y"
            value={post.body}
            onChange={(event) => onUpdate({ body: event.target.value })}
            aria-describedby="post-body-help"
          />
          <p id="post-body-help" className="text-xs text-foreground/70">
            Plain text. Paragraph breaks are preserved in the preview.
          </p>
        </div>

        <details className="border-y-2 border-border py-3">
          <summary className="flex cursor-pointer items-center gap-2 font-heading text-sm">
            <Eye aria-hidden="true" className="size-4" />
            Read preview
          </summary>
          <article aria-label="Post preview" className="mt-4 space-y-3 break-words">
            <PostStatusBadge status={post.status} />
            <h3 className="text-2xl font-heading">{getDisplayPostTitle(post)}</h3>
            <p className="text-sm text-foreground/70">{post.summary || "No summary yet."}</p>
            <p className="whitespace-pre-wrap text-sm leading-7">
              {post.body || "Start writing to preview your post."}
            </p>
          </article>
        </details>

        <div className="flex items-center justify-between gap-4 border-t-2 border-border pt-4">
          <label htmlFor="post-published" className="text-sm font-heading">
            Published
          </label>
          <Switch
            id="post-published"
            checked={post.status === "published"}
            onCheckedChange={(checked) =>
              onUpdate({
                status: checked ? "published" : "draft",
              })
            }
          />
        </div>
      </div>
    </form>
  );
}

function EmptyPostEditor({
  onClearFilters,
  onCreatePost,
}: {
  onClearFilters: () => void;
  onCreatePost: () => void;
}) {
  return (
    <div className="grid min-h-[24rem] place-content-center justify-items-center gap-3 p-6 text-center">
      <Search aria-hidden="true" className="size-6 text-foreground/60" />
      <h2 id="post-editor-title" className="text-base font-heading">
        No post selected
      </h2>
      <p className="max-w-xs text-sm text-foreground/60">
        Clear the current filters or start a new post.
      </p>
      <div className="mt-2 flex flex-wrap justify-center gap-3">
        <Button type="button" variant="neutral" onClick={onClearFilters}>
          Clear filters
        </Button>
        <Button type="button" onClick={onCreatePost}>
          <Plus aria-hidden="true" />
          New post
        </Button>
      </div>
    </div>
  );
}

export default function CmsTemplate() {
  const [posts, setPosts] = React.useState<Post[]>(INITIAL_POSTS);
  const [savedPosts, setSavedPosts] = React.useState<Post[]>(INITIAL_POSTS);
  const [feedback, setFeedback] = React.useState("");
  const [query, setQuery] = React.useState("");
  const [selectedId, setSelectedId] = React.useState(INITIAL_POSTS[0].id);
  const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");

  const nextPostNumber = React.useRef(106);
  const titleInputRef = React.useRef<HTMLInputElement>(null);

  const filteredPosts = getFilteredPosts(posts, query, statusFilter);
  const selectedPost = posts.find((post) => post.id === selectedId);

  const changeFilters = (nextQuery: string, nextStatus: StatusFilter) => {
    const matches = getFilteredPosts(posts, nextQuery, nextStatus);
    setFeedback("");
    setQuery(nextQuery);
    setStatusFilter(nextStatus);
    setSelectedId(resolveSelectedPostId(matches, selectedId));
  };

  const clearFilters = () => changeFilters("", "all");

  const updateSelectedPost = (changes: PostChanges) => {
    setFeedback("");
    const saved = savedPosts.find((post) => post.id === selectedId);
    setPosts((current) =>
      current.map((post) => {
        if (post.id !== selectedId) return post;
        const next = { ...post, ...changes };
        next.dirty =
          !saved ||
          (["body", "status", "summary", "title"] as const).some((key) => next[key] !== saved[key]);
        return next;
      }),
    );
  };

  const saveSelectedPost = () => {
    if (!selectedPost || !selectedPost.dirty) return;
    const saved: Post = {
      ...selectedPost,
      body: selectedPost.body.trim(),
      dirty: false,
      summary: selectedPost.summary.trim(),
      title: getDisplayPostTitle(selectedPost),
      updatedLabel: "Just now",
    };
    setPosts((current) => current.map((post) => (post.id === saved.id ? saved : post)));
    setSavedPosts((current) => [...current.filter((post) => post.id !== saved.id), saved]);
    setFeedback("Saved in this preview. Refreshing the page resets all edits.");
  };

  const discardSelectedPost = () => {
    const saved = savedPosts.find((post) => post.id === selectedId);
    if (!saved) return;
    setPosts((current) => current.map((post) => (post.id === selectedId ? saved : post)));
    setFeedback("Restored the last locally saved version.");
    titleInputRef.current?.focus();
  };

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    saveSelectedPost();
  };

  const createDraftPost = () => {
    const post: Post = {
      body: "",
      dirty: true,
      id: "post-" + nextPostNumber.current,
      status: "draft",
      summary: "",
      title: "Untitled post",
      updatedLabel: "Now",
    };

    nextPostNumber.current += 1;
    setPosts((current) => [post, ...current]);
    setSavedPosts((current) => [{ ...post, dirty: false }, ...current]);
    setFeedback("");
    setSelectedId(post.id);
    setQuery("");
    setStatusFilter("all");

    window.requestAnimationFrame(() => {
      titleInputRef.current?.focus();
      titleInputRef.current?.select();
    });
  };

  const publishedCount = posts.filter((post) => post.status === "published").length;

  return (
    <div className="flex min-h-dvh flex-col bg-background text-foreground">
      <header className="border-b-2 border-border bg-secondary-background">
        <div className="mx-auto flex h-12 w-full max-w-screen-xl items-center justify-between px-4 sm:px-6">
          <h1 className="text-base font-heading">Folio CMS</h1>
          <Button type="button" size="sm" onClick={createDraftPost}>
            <Plus aria-hidden="true" />
            New post
          </Button>
        </div>
      </header>

      <div className="mx-auto w-full max-w-screen-xl px-4 pt-5 sm:px-6">
        <div className="grid grid-cols-3 divide-x-2 divide-border border-y-2 border-border py-3">
          {[
            { label: "Total posts", value: posts.length },
            { label: "Published", value: publishedCount },
            { label: "Unsaved", value: posts.filter((post) => post.dirty).length },
          ].map((metric) => (
            <div key={metric.label} className="px-3 first:pl-0">
              <p className="text-xs text-foreground/70">{metric.label}</p>
              <p className="mt-1 text-2xl font-heading tabular-nums">{metric.value}</p>
            </div>
          ))}
        </div>
        <p className="mt-3 text-xs leading-5 text-foreground/70">
          Local workspace demo. Save and discard affect this page only; nothing is published online.
        </p>
        {feedback ? (
          <p role="status" className="mt-2 text-sm">
            {feedback}
          </p>
        ) : null}
      </div>

      <main className="mx-auto flex w-full max-w-screen-xl flex-1 px-4 py-4 sm:px-6 sm:py-5">
        <section
          aria-label="CMS workspace"
          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)]"
        >
          <div className="min-w-0 border-b-2 border-border lg:border-r-2 lg:border-b-0">
            <div className="space-y-2 border-b-2 border-border bg-background p-3">
              <div className="relative min-w-0">
                <label htmlFor="post-search" className="sr-only">
                  Search posts
                </label>
                <Search
                  aria-hidden="true"
                  className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/50"
                />
                <Input
                  id="post-search"
                  type="search"
                  className="h-10 pl-9"
                  placeholder="Search posts"
                  value={query}
                  onChange={(event) => changeFilters(event.target.value, statusFilter)}
                />
              </div>

              <div
                role="group"
                aria-label="Filter posts by status"
                className="grid h-10 w-full grid-cols-3 overflow-hidden rounded-base border-2 border-border bg-secondary-background"
              >
                {STATUS_TABS.map((tab) => {
                  const isActive = statusFilter === tab.value;

                  return (
                    <button
                      key={tab.value}
                      type="button"
                      aria-pressed={isActive}
                      className={
                        "relative h-full min-w-0 border-0 border-r-2 border-border px-2 text-sm font-heading outline-none transition-colors last:border-r-0 focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring " +
                        (isActive
                          ? "bg-main text-main-foreground"
                          : "bg-secondary-background text-foreground hover:bg-background")
                      }
                      onClick={() => changeFilters(query, tab.value)}
                    >
                      {tab.label}
                    </button>
                  );
                })}
              </div>
            </div>

            <PostListPane
              posts={filteredPosts}
              selectedId={selectedId}
              onSelect={(id) => {
                setSelectedId(id);
                setFeedback("");
              }}
            />
          </div>

          <section aria-labelledby="post-editor-title" className="min-w-0">
            {selectedPost ? (
              <PostEditorPane
                hiddenByFilters={!filteredPosts.some((post) => post.id === selectedId)}
                onClearFilters={clearFilters}
                post={selectedPost}
                titleInputRef={titleInputRef}
                onDiscard={discardSelectedPost}
                onSubmit={handleSubmit}
                onUpdate={updateSelectedPost}
              />
            ) : (
              <EmptyPostEditor onClearFilters={clearFilters} onCreatePost={createDraftPost} />
            )}
          </section>
        </section>
      </main>

      <footer className="border-t-2 border-border bg-secondary-background">
        <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">
          <p>{posts.length} posts</p>
          <p>{publishedCount} published</p>
        </div>
      </footer>
    </div>
  );
}
```

### `src/blocks/templates/pages/cms/page.tsx`

```tsx
import CmsTemplate from "@/components/templates/cms-template";

export default function Page() {
  return <CmsTemplate />;
}
```
