Content Management System
A lightweight CMS built on your existing stack: drizzle for the database, better-auth for authentication, the storage skill for file uploads, and Tiptap for rich text editing. Provides an admin UI at /admin/content for managing posts, pages, categories, and media.
Quick Start
# 1. Install CMS dependencies
bun add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder react-hook-form @hookform/resolvers @tailwindcss/typography
# 2. Install shadcn components
bunx shadcn@latest add table badge input textarea label separator tabs card select
# 3. Push schema to database
bunx drizzle-kit push
# 4. Start dev server and open admin
bun dev
open http://localhost:3000/admin/content
What Gets Created
Database Schema
src/db/schema/cms.ts— Drizzle schema for posts, pages, categories, and join tables
API Routes
src/app/api/cms/posts/route.ts— List and create postssrc/app/api/cms/posts/[id]/route.ts— Get, update, delete a postsrc/app/api/cms/pages/route.ts— List and create pagessrc/app/api/cms/pages/[id]/route.ts— Get, update, delete a pagesrc/app/api/cms/categories/route.ts— List, create, update, delete categories
Components
src/components/cms/tiptap-editor.tsx— Rich text editor with toolbarsrc/components/cms/media-picker.tsx— Image/video picker dialog using storagesrc/components/cms/post-form.tsx— Post create/edit formsrc/components/cms/page-form.tsx— Page create/edit form
Admin Pages
src/app/admin/content/layout.tsx— Admin sidebar layoutsrc/app/admin/content/page.tsx— Content dashboardsrc/app/admin/content/posts/page.tsx— Posts listsrc/app/admin/content/posts/new/page.tsx— Create postsrc/app/admin/content/posts/[id]/page.tsx— Edit postsrc/app/admin/content/pages/page.tsx— Pages listsrc/app/admin/content/pages/new/page.tsx— Create pagesrc/app/admin/content/pages/[id]/page.tsx— Edit pagesrc/app/admin/content/categories/page.tsx— Categories managementsrc/app/admin/content/media/page.tsx— Media management (wraps storage-ui)
Modified Files
src/db/index.ts— Add CMS schema import
Prerequisites
Before using this skill:
- auth skill completed — Provides
lib/auth-guard.ts(withAuth,withAdmin),db/index.ts,db/schema/auth.ts, drizzle setup - storage skill completed — Provides
lib/storage/and/api/storageendpoints - storage-ui skill completed — Provides
components/storage/UI components - shadcn/ui initialized — With base components installed
Project Structure Note
This skill uses src/ prefix paths (standard Next.js with --src-dir).
If your project uses --no-src-dir:
- Replace
src/lib/withlib/ - Replace
src/app/withapp/ - Replace
src/components/withcomponents/ - Replace
src/db/withdb/
Installation
# Tiptap editor
bun add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder
# Form handling
bun add react-hook-form @hookform/resolvers
# Tailwind typography (for prose styles in editor)
bun add @tailwindcss/typography
# shadcn components needed by CMS admin
bunx shadcn@latest add table badge input textarea label separator tabs card select
Setup Steps
1. Create CMS Database Schema (src/db/schema/cms.ts)
import {
pgTable,
text,
timestamp,
jsonb,
primaryKey,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { user } from "./auth";
export const categories = pgTable("cms_categories", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
description: text("description"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const posts = pgTable("cms_posts", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: jsonb("content"),
excerpt: text("excerpt"),
featuredMediaUrl: text("featured_media_url"),
featuredMediaKey: text("featured_media_key"),
featuredMediaType: text("featured_media_type"),
status: text("status").notNull().default("draft"),
authorId: text("author_id")
.notNull()
.references(() => user.id),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const pages = pgTable("cms_pages", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: jsonb("content"),
metaTitle: text("meta_title"),
metaDescription: text("meta_description"),
metaImageUrl: text("meta_image_url"),
metaImageKey: text("meta_image_key"),
status: text("status").notNull().default("draft"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const postsToCategories = pgTable(
"cms_posts_to_categories",
{
postId: text("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
categoryId: text("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
},
(t) => [primaryKey({ columns: [t.postId, t.categoryId] })]
);
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(user, { fields: [posts.authorId], references: [user.id] }),
postCategories: many(postsToCategories),
}));
export const categoriesRelations = relations(categories, ({ many }) => ({
categoryPosts: many(postsToCategories),
}));
export const postsToCategoriesRelations = relations(
postsToCategories,
({ one }) => ({
post: one(posts, {
fields: [postsToCategories.postId],
references: [posts.id],
}),
category: one(categories, {
fields: [postsToCategories.categoryId],
references: [categories.id],
}),
})
);
2. Update Database Client (src/db/index.ts)
Add the CMS schema import alongside the existing auth schema:
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as authSchema from "./schema/auth";
import * as cmsSchema from "./schema/cms";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is not set");
export const client = postgres(connectionString, { prepare: false });
export const db = drizzle(client, {
schema: { ...authSchema, ...cmsSchema },
});
3. Add Tailwind Typography Plugin
Add to your src/app/globals.css (after the existing @import "tailwindcss";):
@plugin "@tailwindcss/typography";
4. Create Posts API Routes
src/app/api/cms/posts/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { withAdmin } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import { posts, postsToCategories, categories } from "@/lib/db/schema/cms";
import { desc, eq, ilike, sql, and, inArray } from "drizzle-orm";
export const GET = withAdmin(async (request) => {
const url = request.nextUrl;
const page = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? "20")));
const status = url.searchParams.get("status");
const search = url.searchParams.get("search");
const offset = (page - 1) * limit;
const conditions = [];
if (status) conditions.push(eq(posts.status, status));
if (search) conditions.push(ilike(posts.title, `%${search}%`));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [items, countResult] = await Promise.all([
db
.select()
.from(posts)
.where(where)
.orderBy(desc(posts.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(posts)
.where(where),
]);
return NextResponse.json({
posts: items,
total: Number(countResult[0].count),
page,
limit,
});
});
export const POST = withAdmin(async (request, { user }) => {
const body = await request.json();
const { title, slug, content, excerpt, featuredMediaUrl, featuredMediaKey, featuredMediaType, status, categoryIds, publishedAt } = body;
if (!title || !slug) {
return NextResponse.json({ error: "Title and slug are required" }, { status: 400 });
}
const [post] = await db
.insert(posts)
.values({
title,
slug,
content: content ?? null,
excerpt: excerpt ?? null,
featuredMediaUrl: featuredMediaUrl ?? null,
featuredMediaKey: featuredMediaKey ?? null,
featuredMediaType: featuredMediaType ?? null,
status: status ?? "draft",
authorId: user.id,
publishedAt: publishedAt ? new Date(publishedAt) : null,
})
.returning();
if (categoryIds?.length > 0) {
await db.insert(postsToCategories).values(
categoryIds.map((categoryId: string) => ({
postId: post.id,
categoryId,
}))
);
}
return NextResponse.json({ post }, { status: 201 });
});
src/app/api/cms/posts/[id]/route.ts:
Note: For routes with dynamic params like [id], use getServerSession() directly instead of withAdmin, because withAdmin returns (request) => ... and does not forward Next.js route context.
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { posts, postsToCategories } from "@/lib/db/schema/cms";
import { eq } from "drizzle-orm";
import { getServerSession } from "@/lib/auth-guard";
type RouteParams = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, { params }: RouteParams) {
const session = await getServerSession();
if (!session || (session.user as { role?: string }).role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
with: {
postCategories: {
with: { category: true },
},
},
});
if (!post) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
return NextResponse.json({ post });
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const session = await getServerSession();
if (!session || (session.user as { role?: string }).role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const { title, slug, content, excerpt, featuredMediaUrl, featuredMediaKey, featuredMediaType, status, categoryIds, publishedAt } = body;
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (title !== undefined) updateData.title = title;
if (slug !== undefined) updateData.slug = slug;
if (content !== undefined) updateData.content = content;
if (excerpt !== undefined) updateData.excerpt = excerpt;
if (featuredMediaUrl !== undefined) updateData.featuredMediaUrl = featuredMediaUrl;
if (featuredMediaKey !== undefined) updateData.featuredMediaKey = featuredMediaKey;
if (featuredMediaType !== undefined) updateData.featuredMediaType = featuredMediaType;
if (status !== undefined) updateData.status = status;
if (publishedAt !== undefined) updateData.publishedAt = publishedAt ? new Date(publishedAt) : null;
const [updated] = await db
.update(posts)
.set(updateData)
.where(eq(posts.id, id))
.returning();
if (!updated) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
if (categoryIds !== undefined) {
await db.delete(postsToCategories).where(eq(postsToCategories.postId, id));
if (categoryIds.length > 0) {
await db.insert(postsToCategories).values(
categoryIds.map((categoryId: string) => ({
postId: id,
categoryId,
}))
);
}
}
return NextResponse.json({ post: updated });
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const session = await getServerSession();
if (!session || (session.user as { role?: string }).role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const [deleted] = await db
.delete(posts)
.where(eq(posts.id, id))
.returning();
if (!deleted) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
}
5. Create Pages API Routes
src/app/api/cms/pages/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { withAdmin } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import { pages } from "@/lib/db/schema/cms";
import { desc, eq, ilike, sql, and } from "drizzle-orm";
export const GET = withAdmin(async (request) => {
const url = request.nextUrl;
const page = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? "20")));
const status = url.searchParams.get("status");
const search = url.searchParams.get("search");
const offset = (page - 1) * limit;
const conditions = [];
if (status) conditions.push(eq(pages.status, status));
if (search) conditions.push(ilike(pages.title, `%${search}%`));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [items, countResult] = await Promise.all([
db
.select()
.from(pages)
.where(where)
.orderBy(desc(pages.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(pages)
.where(where),
]);
return NextResponse.json({
pages: items,
total: Number(countResult[0].count),
page,
limit,
});
});
export const POST = withAdmin(async (request) => {
const body = await request.json();
const { title, slug, content, metaTitle, metaDescription, metaImageUrl, metaImageKey, status } = body;
if (!title || !slug) {
return NextResponse.json({ error: "Title and slug are required" }, { status: 400 });
}
const [created] = await db
.insert(pages)
.values({
title,
slug,
content: content ?? null,
metaTitle: metaTitle ?? null,
metaDescription: metaDescription ?? null,
metaImageUrl: metaImageUrl ?? null,
metaImageKey: metaImageKey ?? null,
status: status ?? "draft",
})
.returning();
return NextResponse.json({ page: created }, { status: 201 });
});
src/app/api/cms/pages/[id]/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { pages } from "@/lib/db/schema/cms";
import { eq } from "drizzle-orm";
import { getServerSession } from "@/lib/auth-guard";
type RouteParams = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, { params }: RouteParams) {
const session = await getServerSession();
if (!session || (session.user as { role?: string }).role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const [page] = await db.select().from(pages).where(eq(pages.id, id));
if (!page) {
return NextResponse.json({ error: "Page not found" }, { status: 404 });
}
return NextResponse.json({ page });
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const session = await getServerSession();
if (!session || (session.user as { role?: string }).role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const { title, slug, content, metaTitle, metaDescription, metaImageUrl, metaImageKey, status } = body;
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (title !== undefined) updateData.title = title;
if (slug !== undefined) updateData.slug = slug;
if (content !== undefined) updateData.content = content;
if (metaTitle !== undefined) updateData.metaTitle = metaTitle;
if (metaDescription !== undefined) updateData.metaDescription = metaDescription;
if (metaImageUrl !== undefined) updateData.metaImageUrl = metaImageUrl;
if (metaImageKey !== undefined) updateData.metaImageKey = metaImageKey;
if (status !== undefined) updateData.status = status;
const [updated] = await db
.update(pages)
.set(updateData)
.where(eq(pages.id, id))
.returning();
if (!updated) {
return NextResponse.json({ error: "Page not found" }, { status: 404 });
}
return NextResponse.json({ page: updated });
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const session = await getServerSession();
if (!session || (session.user as { role?: string }).role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const [deleted] = await db
.delete(pages)
.where(eq(pages.id, id))
.returning();
if (!deleted) {
return NextResponse.json({ error: "Page not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
}
6. Create Categories API Route
src/app/api/cms/categories/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { withAdmin } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import { categories } from "@/lib/db/schema/cms";
import { eq, asc } from "drizzle-orm";
export const GET = withAdmin(async () => {
const items = await db
.select()
.from(categories)
.orderBy(asc(categories.name));
return NextResponse.json({ categories: items });
});
export const POST = withAdmin(async (request) => {
const body = await request.json();
const { name, slug, description } = body;
if (!name || !slug) {
return NextResponse.json({ error: "Name and slug are required" }, { status: 400 });
}
const [created] = await db
.insert(categories)
.values({
name,
slug,
description: description ?? null,
})
.returning();
return NextResponse.json({ category: created }, { status: 201 });
});
export const PATCH = withAdmin(async (request) => {
const body = await request.json();
const { id, name, slug, description } = body;
if (!id) {
return NextResponse.json({ error: "ID is required" }, { status: 400 });
}
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
if (description !== undefined) updateData.description = description;
const [updated] = await db
.update(categories)
.set(updateData)
.where(eq(categories.id, id))
.returning();
if (!updated) {
return NextResponse.json({ error: "Category not found" }, { status: 404 });
}
return NextResponse.json({ category: updated });
});
export const DELETE = withAdmin(async (request) => {
const body = await request.json();
const { id } = body;
if (!id) {
return NextResponse.json({ error: "ID is required" }, { status: 400 });
}
await db.delete(categories).where(eq(categories.id, id));
return NextResponse.json({ success: true });
});
7. Create Tiptap Editor Component (src/components/cms/tiptap-editor.tsx)
"use client";
import { memo } from "react";
import { useEditor, EditorContent, type JSONContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { cn } from "@/lib/utils";
import {
TextB,
TextItalic,
TextStrikethrough,
TextHOne,
TextHTwo,
TextHThree,
ListBullets,
ListNumbers,
Quotes,
Code,
ImageSquare,
LinkSimple,
ArrowUUpLeft,
ArrowUUpRight,
} from "@phosphor-icons/react";
type ToolbarButtonProps = {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
children: React.ReactNode;
title: string;
};
const ToolbarButton = memo(function ToolbarButton({ onClick, isActive, disabled, children, title }: ToolbarButtonProps) {
return (
<button
type="button"
disabled={disabled}
title={title}
className={cn(
"rounded p-1.5 transition-colors hover:bg-zinc-100",
isActive && "bg-zinc-200 text-zinc-900",
disabled && "cursor-not-allowed opacity-40"
)}
>
{children}
</button>
);
});
type TiptapEditorProps = {
content: JSONContent | null;
onChange: (content: JSONContent) => void;
onInsertImage?: () => void;
placeholder?: string;
};
export function TiptapEditor({
content,
onChange,
onInsertImage,
placeholder = "Write your content...",
}: TiptapEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({ inline: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder }),
],
content: content ?? undefined,
onUpdate: ({ editor: e }) => {
onChange(e.getJSON());
},
});
if (!editor) return null;
const addLink = () => {
const url = window.prompt("Enter URL:");
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
};
return (
<div className="rounded-lg border">
<div className="flex flex-wrap gap-0.5 border-b p-1.5">
<ToolbarButton
=> editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")}
title="Bold"
>
<TextB className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
=> editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive("italic")}
title="Italic"
>
<TextItalic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
=> editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive("strike")}
title="Strikethrough"
>
<TextStrikethrough className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-zinc-200" />
<ToolbarButton
=> editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive("heading", { level: 1 })}
title="Heading 1"
>
<TextHOne className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
=> editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive("heading", { level: 2 })}
title="Heading 2"
>
<TextHTwo className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
=> editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive("heading", { level: 3 })}
title="Heading 3"
>
<TextHThree className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-zinc-200" />
<ToolbarButton
=> editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive("bulletList")}
title="Bullet List"
>
<ListBullets className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
=> editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive("orderedList")}
title="Ordered List"
>
<ListNumbers className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
=> editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive("blockquote")}
title="Blockquote"
>
<Quotes className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
=> editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive("codeBlock")}
title="Code Block"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-zinc-200" />
<ToolbarButton isActive={editor.isActive("link")} title="Insert Link">
<LinkSimple className="h-4 w-4" />
</ToolbarButton>
{onInsertImage && (
<ToolbarButton title="Insert Image">
<ImageSquare className="h-4 w-4" />
</ToolbarButton>
)}
<div className="mx-1 w-px bg-zinc-200" />
<ToolbarButton
=> editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo"
>
<ArrowUUpLeft className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
=> editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo"
>
<ArrowUUpRight className="h-4 w-4" />
</ToolbarButton>
</div>
<EditorContent
editor={editor}
className="prose prose-zinc max-w-none p-4 [&_.ProseMirror]:min-h-[200px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-zinc-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0"
/>
</div>
);
}
export function insertImageIntoEditor(
editorRef: { current: ReturnType<typeof useEditor> | null },
url: string
) {
editorRef.current?.chain().focus().setImage({ src: url }).run();
}
8. Create Media Picker Component (src/components/cms/media-picker.tsx)
"use client";
import { useCallback, useEffect, useId, useState } from "react";
import { cn } from "@/lib/utils";
import { ImageSquare, VideoCamera, Check, CircleNotch, UploadSimple } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type MediaFile = {
key: string;
name: string;
size: number;
contentType: string;
url: string;
};
type MediaPickerProps = {
open: boolean;
onClose: () => void;
onSelect: (file: MediaFile) => void;
accept?: ("images" | "video")[];
};
export function MediaPicker({
open,
onClose,
onSelect,
accept = ["images", "video"],
}: MediaPickerProps) {
const listId = useId();
const [files, setFiles] = useState<MediaFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
const fetchFiles = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch("/api/storage");
if (response.ok) {
const data = await response.json();
const allFiles: MediaFile[] = data.files ?? [];
const filtered = allFiles.filter((f) => {
if (accept.includes("images") && f.contentType.startsWith("image/")) return true;
if (accept.includes("video") && f.contentType.startsWith("video/")) return true;
return false;
});
setFiles(filtered);
}
} finally {
setIsLoading(false);
}
}, [accept]);
useEffect(() => {
if (open) {
fetchFiles();
setSelected(null);
}
}, [open, fetchFiles]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList || fileList.length === 0) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", fileList[0]);
const response = await fetch("/api/storage/upload", {
method: "POST",
body: formData,
});
if (response.ok) {
await fetchFiles();
}
} finally {
setIsUploading(false);
e.target.value = "";
}
};
const handleConfirm = () => {
const file = files.find((f) => f.key === selected);
if (file) {
onSelect(file);
onClose();
}
};
const acceptMimes = [
...(accept.includes("images") ? ["image/*"] : []),
...(accept.includes("video") ? ["video/*"] : []),
].join(",");
return (
<Dialog open={open} => !o && onClose()}>
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Media</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 pb-4">
<Button variant="outline" size="sm" asChild className="relative">
<label>
<UploadSimple className="mr-2 h-4 w-4" />
{isUploading ? "Uploading..." : "Upload New"}
<input
type="file"
accept={acceptMimes}
disabled={isUploading}
className="absolute inset-0 cursor-pointer opacity-0"
/>
</label>
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<CircleNotch className="h-6 w-6 animate-spin text-zinc-400" />
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
<ImageSquare className="h-8 w-8 mb-2" />
<p className="text-sm">No media files found</p>
</div>
) : (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
{files.map((file) => {
const isImage = file.contentType.startsWith("image/");
return (
<button
key={`${listId}-${file.key}`}
type="button"
=> setSelected(file.key)}
className={cn(
"relative aspect-square overflow-hidden rounded-lg border-2 transition-colors",
selected === file.key
? "border-blue-500 ring-2 ring-blue-200"
: "border-transparent hover:border-zinc-300"
)}
>
{isImage ? (
<img
src={file.url}
alt={file.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center bg-zinc-100">
<VideoCamera className="h-8 w-8 text-zinc-400" />
<span className="mt-1 truncate px-2 text-xs text-zinc-500">
{file.name}
</span>
</div>
)}
{selected === file.key && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/20">
<Check className="h-6 w-6 text-blue-600" />
</div>
)}
</button>
);
})}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t pt-4">
<Button variant="outline"
Cancel
</Button>
<Button disabled={!selected}>
Select
</Button>
</div>
</DialogContent>
</Dialog>
);
}
9. Create Post Form Component (src/components/cms/post-form.tsx)
"use client";
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CircleNotch } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TiptapEditor } from "./tiptap-editor";
import { MediaPicker } from "./media-picker";
import type { JSONContent } from "@tiptap/react";
const postSchema = z.object({
title: z.string().min(1, "Title is required"),
slug: z.string().min(1, "Slug is required"),
excerpt: z.string().optional(),
status: z.enum(["draft", "published"]),
});
type PostFormValues = z.infer<typeof postSchema>;
type Category = {
id: string;
name: string;
slug: string;
};
type PostData = {
id?: string;
title: string;
slug: string;
content: JSONContent | null;
excerpt: string | null;
featuredMediaUrl: string | null;
featuredMediaKey: string | null;
featuredMediaType: string | null;
status: string;
publishedAt: string | null;
postCategories?: Array<{ category: Category }>;
};
type PostFormProps = {
post?: PostData;
isEdit?: boolean;
};
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
export function PostForm({ post, isEdit }: PostFormProps) {
const router = useRouter();
const [content, setContent] = useState<JSONContent | null>(post?.content ?? null);
const [featuredMedia, setFeaturedMedia] = useState<{
url: string | null;
key: string | null;
type: string | null;
}>({
url: post?.featuredMediaUrl ?? null,
key: post?.featuredMediaKey ?? null,
type: post?.featuredMediaType ?? null,
});
const [mediaPickerOpen, setMediaPickerOpen] = useState(false);
const [mediaPickerTarget, setMediaPickerTarget] = useState<"featured" | "editor">("featured");
const [allCategories, setAllCategories] = useState<Category[]>([]);
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>(
post?.postCategories?.map((pc) => pc.category.id) ?? []
);
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<PostFormValues>({
resolver: zodResolver(postSchema),
defaultValues: {
title: post?.title ?? "",
slug: post?.slug ?? "",
excerpt: post?.excerpt ?? "",
status: (post?.status as "draft" | "published") ?? "draft",
},
});
const title = watch("title");
useEffect(() => {
if (!isEdit && title) {
setValue("slug", slugify(title));
}
}, [title, isEdit, setValue]);
useEffect(() => {
fetch("/api/cms/categories")
.then((r) => r.json())
.then((data) => setAllCategories(data.categories ?? []))
.catch(() => {});
}, []);
const (values: PostFormValues) => {
setIsSubmitting(true);
try {
const payload = {
...values,
content,
featuredMediaUrl: featuredMedia.url,
featuredMediaKey: featuredMedia.key,
featuredMediaType: featuredMedia.type,
categoryIds: selectedCategoryIds,
publishedAt: values.status === "published" ? new Date().toISOString() : null,
};
const url = isEdit ? `/api/cms/posts/${post?.id}` : "/api/cms/posts";
const method = isEdit ? "PATCH" : "POST";
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (response.ok) {
router.push("/admin/content/posts");
router.refresh();
}
} finally {
setIsSubmitting(false);
}
};
const openMediaPicker = useCallback((target: "featured" | "editor") => {
setMediaPickerTarget(target);
setMediaPickerOpen(true);
}, []);
const handleMediaSelect = useCallback(
(file: { url: string; key: string; contentType: string }) => {
if (mediaPickerTarget === "featured") {
setFeaturedMedia({
url: file.url,
key: file.key,
type: file.contentType.startsWith("video/") ? "video" : "image",
});
}
// For editor image insertion, the TiptapEditor handles it via onInsertImage
},
[mediaPickerTarget]
);
const toggleCategory = (categoryId: string) => {
setSelectedCategoryIds((prev) =>
prev.includes(categoryId)
? prev.filter((id) => id !== categoryId)
: [...prev, categoryId]
);
};
return (
<>
<form className="space-y-6">
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
{/* Main content */}
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input id="title" {...register("title")} placeholder="Post title" />
{errors.title && <p className="text-sm text-red-500">{errors.title.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input id="slug" {...register("slug")} placeholder="post-slug" />
{errors.slug && <p className="text-sm text-red-500">{errors.slug.message}</p>}
</div>
<div className="space-y-2">
<Label>Content</Label>
<TiptapEditor
content={content}
=> openMediaPicker("editor")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="excerpt">Excerpt</Label>
<Textarea id="excerpt" {...register("excerpt")} placeholder="Brief description..." rows={3} />
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className="space-y-2">
<Label>Status</Label>
<Select
defaultValue={post?.status ?? "draft"}
=> setValue("status", value as "draft" | "published")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Featured Media</Label>
{featuredMedia.url ? (
<div className="relative rounded-lg border overflow-hidden">
{featuredMedia.type === "video" ? (
<video src={featuredMedia.url} controls className="w-full" />
) : (
<img src={featuredMedia.url} alt="Featured" className="w-full object-cove
…(truncated)