全栈开发

TipTap富文本编辑器Demo

1、创建nextjs项目

npx create-next-app@latest template-blog-tiptap

2、安装tiptap

npx @tiptap/cli@latest add simple-editor

3、安装shadcn

npx shadcn@latest init
npx shadcn@latest add label input button

4、安装prisma

npm install prisma --save-dev
npm install @prisma/client @prisma/adapter-pg pg dotenv tsx
npm install -D prisma @types/pg

4.1、初始化prisma

npx prisma init

4.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 push

4.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 = prisma

5、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 Page

4、文章详情页

/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