文章
TipTap富文本编辑器Demo
1、创建nextjs项目
npx create-next-app@latest template-blog-tiptap2、安装tiptap
npx @tiptap/cli@latest add simple-editor3、安装shadcn
npx shadcn@latest init
npx shadcn@latest add label input button4、安装prisma
npm install prisma --save-dev
npm install @prisma/client @prisma/adapter-pg pg dotenv tsx
npm install -D prisma @types/pg4.1、初始化prisma
npx prisma init4.2、添加prisma模型
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Post {
id String @id @default(cuid())
title String
content String @db.Text
createAt DateTime @default(now())
}4.3、配置prisma库连接信息
DATABASE_URL="postgres://pguser:password@localhost:5432/db?schema=public"4.4、推送表结构
npx prisma db push4.5、prisma client
/lib/prisma.ts
import { PrismaClient } from "./generated/prisma/client"
import { PrismaPg } from '@prisma/adapter-pg'
const prismaClientSingleton = () => {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! })
return new PrismaClient({ adapter })
}
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined
}
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma5、Tiptap修改
components/tiptap-templates/simple/simple-editor.scss
.simple-editor-wrapper {
// width: 100vw;
height: 100vh;
overflow: auto;
}
.simple-editor-content {
// max-width: 648px;
width: 100%;
margin: 0 auto;
...
}注释以上样式
文章发布功能开发
1、api route
/api/posts/route.ts
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET() {
const posts = await prisma.post.findMany({
orderBy: { createAt: "desc" }
})
return NextResponse.json(posts);
}
export async function POST(request: Request) {
const { title, content } = await request.json()
if (!title || !content) {
return NextResponse.json(
{ error: "Title and content required" },
{ status: 400 }
)
}
const post = await prisma.post.create({
data: { title, content }
})
return NextResponse.json(post);
}
2、文章发布页
/app/page.tsx
"use client"
import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Editor } from "@tiptap/react";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
export default function Home() {
const [title, setTitle] = useState("");
const [editor, setEditor] = useState<Editor | null>(null);
const [saving, setSaving] = useState(false);
const router = useRouter();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!title.trim() || !editor) {
alert("Please fill in title and content");
return;
}
setSaving(true);
try {
const res = await fetch("api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
content: editor.getHTML(),
}),
});
if (!res.ok) throw new Error("Failed to save");
const post = await res.json();
router.push(`/posts/${post.id}`);
} catch (error) {
alert("Failed to save post")
} finally {
setSaving(false);
}
};
return (
<div className="max-w-5xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Create Post</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
placeholder="Post title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-xl"
/>
<div className="border rounded-lg">
<SimpleEditor onEditorReady={setEditor} />
</div>
<div className="flex gap-4">
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save Post"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/posts")}
>
View Post
</Button>
</div>
</form>
</div>
);
}3、文章列表页
/posts/page.tsx
import { Button } from '@/components/ui/button';
import { prisma } from '@/lib/prisma'
import Link from 'next/link';
import React from 'react'
async function Page() {
const posts = await prisma.post.findMany({
orderBy: { createAt: "desc" },
});
return (
<div className='max-w-4xl mx-auto p-8'>
<div className='flex justify-between items-center mb-8'>
<h1 className='text-3xl font-bold'>
Posts
</h1>
<Link href="/">
<Button>Create Post</Button>
</Link>
</div>
<div className='space-y-4'>
{posts.map((post) => (
<Link key={post.id} href={`/posts/${post.id}`}>
<div className='border rounded-lg p-6 hover:bg-slate-50'>
<h2 className='text-2xl font-bold'>
{post.title}
</h2>
<p className='text-sm text-slate-500 mt-2'>
{new Date(post.createAt).toLocaleDateString()}
</p>
</div>
</Link>
))}
</div>
</div>
)
}
export default Page4、文章详情页
/posts/[id]/page.tsx
import ReadOnlyEditor from '@/components/tiptap-templates/simple/read-only-editor';
import { Button } from '@/components/ui/button';
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import React from 'react'
async function Page({ params }: {
params: Promise<{ id: string }>
}) {
const { id } = await params;
if (!id) return <div>Invalid post Id</div>
const post = await prisma.post.findUnique({
where: { id },
});
if (!post) notFound();
return (
<div className='max-w-4xl mx-auto p-8'>
<Link href="/posts">
<Button variant="ghost" className='mb-4'>
← Back
</Button>
</Link>
<article className='border rounded-lg p-8'>
<h1 className='text-4xl font-bold mb-4'>{post.title}</h1>
<p className='text-sm text-slate-500 mb-8'>
{new Date(post.createAt).toLocaleDateString()}
</p>
<ReadOnlyEditor content={post.content} />
</article>
</div>
)
}
export default Page