guideNext.js

本指南介绍了在 Next.js 应用程序中集成 CKBox 的方法。如果你想直接跳到代码,可以在 GitHub 上找到本指南中描述的 应用程序的完整代码

# 先决条件

在开始之前,请确保你的系统中安装了最新版本的 LTS Node.js。如果命令行中没有此工具,请首先按照 Node.js 下载说明进行操作。

本指南假设我们将使用 Next.js v13 及其 Pages Router

# 创建应用程序

作为示例的基准,我们将使用 create-next-app 命令创建一个新的 Next.js 项目。在分步向导中选择以下答案

npx create-next-app@13 ckbox-nextjs-example
  • TypeScript
  • ESLint
  • Tailwind CSS
  • src/ 目录
  • App Router
  • 导入别名

项目创建完成后,你可以进入目录并启动开发服务器

cd ckbox-nextjs-example && npm run dev

# 环境变量

首先,让我们创建一个 .env.local 文件并添加第一个条目:NEXT_PUBLIC_URL=https://127.0.0.1:3000。稍后将在 CKBox 和 CKEditor 5 的 tokenUrl 配置选项中引用它。

# .env.local

NEXT_PUBLIC_URL=https://127.0.0.1:3000

# 带有 CKBox 插件的 CKEditor 组件

让我们从安装必要的依赖项开始

npm add ckbox @ckeditor/ckeditor5-build-classic @ckeditor/ckeditor5-react

在撰写本指南时,CKEditor React 组件 无法与 Next.js 项目中的 SSR 一起使用。因此,我们将从创建一个简单的组件开始,它将帮助我们将所有编辑器的依赖项集中在一个地方。

对于 CKBox 和 CKEditor 5 的插件,我们将使用 lark 主题,这是一个内置预设,它在样式方面将 CKBox 和 CKEditor 结合在一起。

下面是完整的代码。请注意,CKBox 是 CKEditor 的对等依赖项,而 CKEditor 依赖于 window.CKBox 对象。我们可以通过简单地导入 CKBox UMD 构建(通过 import 'ckbox/dist/ckbox')来确保 CKBox 在全局范围内注册。

// components/CKEditor.tsx

// This component can be used on client-side only
// Do not use it with SSR

import React from "react";
import { CKEditor as CKEditorComponent } from "@ckeditor/ckeditor5-react";
import ClassicEditor from "@ckeditor/ckeditor5-build-classic";
import "ckbox/dist/styles/themes/lark.css";

// CKBox is a peer dependency of CKEditor. It must be present in the global scope.
// Importing UMD build of CKBox will make sure that `window.CKBox` will be available.
import "ckbox/dist/ckbox";

export default function CKEditor() {
    const config = {
        ckbox: {
            tokenUrl: `${process.env.NEXT_PUBLIC_URL}/api/ckbox`,
            theme: "lark",
        },
        toolbar: [
            "ckbox",
            "imageUpload",
            "|",
            "heading",
            "|",
            "undo",
            "redo",
            "|",
            "bold",
            "italic",
            "|",
            "blockQuote",
            "indent",
            "link",
            "|",
            "bulletedList",
            "numberedList",
        ],
    };

    return (
        <>
            <style>{`.ck-editor__editable_inline { min-height: 400px; }`}</style>
            <CKEditorComponent editor={ClassicEditor} config={config} />
        </>
    );
}

上面的示例包含一个 预定义的 CKEditor 5 构建,其中包含必要的 CKBox 插件。请注意,CKEditor 已配置为通过设置 ckbox 属性的必要参数来使用 CKBox。请注意,在 ckbox.tokenUrl 配置选项中,我们传递了将在本指南的后续步骤中创建的令牌端点的 URL。

最后,让我们禁用 React 的严格模式,因为它在开发模式下干扰了 CKEditor 5 React 组件。

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
    reactStrictMode: false,
};

module.exports = nextConfig;

# UI 组件

让我们继续创建一些 UI 组件,这些组件将在页面之间使用。除了可重用性之外,这些组件将为我们的应用程序带来更美观的外观和感觉。

首先,让我们更新 styles/globals.css 文件。我们将删除 create-next-app 命令引入的样式,并且只保留 Tailwind 的指令

/* styles/globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

然后,让我们添加 ButtonLinkPage 组件。

// components/Button.tsx

import React from "react";

type Props = {
    children: React.ReactNode;
    onClick: () => void;
};

export default function Button({ children, onClick }: Props) {
    return (
        <button
            className="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-1.5"
            onClick={onClick}
        >
            {children}
        </button>
    );
}
// components/Link.tsx

import React from "react";
import NextLink, { type LinkProps } from "next/link";

export default function Link(
    props: Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &
        LinkProps & {
            children?: React.ReactNode;
        } & React.RefAttributes<HTMLAnchorElement>
) {
    return <NextLink className="text-blue-600 dark:text-blue-500 hover:underline" {...props} />;
}
// components/Page.tsx

import React from "react";

type Props = {
    children: React.ReactNode;
};

export default function Page({ children }: Props) {
    return <main className="w-full max-w-4xl mx-auto py-16 h-full flex flex-col">{children}</main>;
}

# 身份验证

在典型的场景中,对 CKBox 的访问将仅限于 已认证 的用户。因此,让我们为我们的应用程序引入一个简单的身份验证机制。让我们从安装 NextAuth.js 开始,这是一个流行的 Next.js 身份验证库

npm add next-auth

然后,让我们初始化 API 路由。我们将使用 CredentialsProvider,它将允许我们引入一个简单的基于密码的身份验证。我们将使用 3 个测试用户,每个用户将被分配不同的 CKBox 角色:useradminsuperadmin

下面是身份验证处理程序的完整代码。

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials";
import type { AuthOptions, User } from "next-auth";

const dbUsers: (User & { password: string })[] = [
    {
        id: "1",
        role: "user",
        email: "user@acme.com",
        password: "testpwd123",
        name: "John",
    },
    {
        id: "2",
        role: "admin",
        email: "admin@acme.com",
        password: "testpwd123",
        name: "Joe",
    },
    {
        id: "3",
        role: "superadmin",
        email: "superadmin@acme.com",
        password: "testpwd123",
        name: "Alice",
    },
];

export const authOptions: AuthOptions = {
    providers: [
        CredentialsProvider({
            credentials: {
                email: {
                    label: "Email",
                },
                password: {
                    label: "Password",
                },
            },
            authorize: (credentials) => {
                const email = credentials?.email;
                const password = credentials?.password;

                const dbUser = dbUsers.find(
                    (dbUser) => dbUser.email === email && dbUser.password === password
                );

                if (!dbUser) {
                    throw new Error("Auth: Provide correct email and password");
                }

                return {
                    id: dbUser.email,
                    email: dbUser.email,
                    name: dbUser.name,
                    role: dbUser.role,
                };
            },
        }),
    ],
    callbacks: {
        jwt: ({ token, user }) => {
            if (user) {
                token.user = user;
            }

            return token;
        },
        session: ({ token, session }) => {
            session.user = token.user;

            return session;
        },
    },
};

export default NextAuth(authOptions);

身份验证库希望我们设置几个环境变量,即 NEXTAUTH_URLNEXTAUTH_SECRET。让我们将它们添加到 .env.local 文件中

# .env.local

# Already set
NEXT_PUBLIC_URL=https://127.0.0.1:3000

# Added now
NEXTAUTH_URL=https://127.0.0.1:3000
NEXTAUTH_SECRET=G4JLoUae5Nke7/CZBkzFsR5NzLDKCcijsyUhTq3fQrA=

在生产环境中,你必须为 NEXTAUTH_SECRET 生成唯一的 value,请参阅 NextAuth 的文档

此时,你可能在 [...nextauth].ts 文件中看到类型错误。为了消除这些错误,我们必须根据库的 官方指南 调整 NextAuth 的类型。此步骤将允许我们为 SessionUserJWT 对象使用自定义类型。让我们在项目的根目录中创建 auth.d.ts 文件

// auth.d.ts

import NextAuth, { DefaultSession } from "next-auth";

declare module "next-auth" {
    type CKBoxRole = "user" | "admin" | "superadmin";

    interface User {
        email: string;
        name: string;
        role: CKBoxRole;
    }

    interface Session {
        user: User & DefaultSession["user"];
    }
}

declare module "next-auth/jwt" {
    interface JWT {
        user: User;
    }
}

接下来,让我们用 NextAuth 的会话提供程序增强主 App 组件。

// pages/_app.tsx

import { SessionProvider } from "next-auth/react";
import type { AppProps } from "next/app";
import "@/styles/globals.css";

function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
    return (
        <SessionProvider session={session}>
            <Component {...pageProps} />
        </SessionProvider>
    );
}

export default App;

最后,让我们添加 Nav 组件,它将允许用户登录。

// components/Nav.tsx

import React from "react";
import { signIn, signOut, useSession } from "next-auth/react";
import Link from "./Link";
import Button from "./Button";

export default function Nav() {
    const { data, status } = useSession();

    return (
        <nav className="border-b border-gray-200 py-5 relative z-20 bg-background shadow-[0_0_15px_0_rgb(0,0,0,0.1)]">
            <div className="flex items-center lg:px-6 px-8 mx-auto max-w-7xl">
                <div className="flex-1 hidden md:flex">
                    <Link href="/">Home</Link>
                </div>
                <div className="flex-1 justify-end flex items-center md:flex gap-3 h-8">
                    {status === "authenticated" ? (
                        <>
                            <span>Welcome, {data?.user?.name}!</span>
                            <Button onClick={() => signOut()}>Sign out</Button>
                        </>
                    ) : status === "loading" ? null : (
                        <Button onClick={() => signIn()}>Sign in</Button>
                    )}
                    <Link
                        href="https://github.com/ckbox-io/ckbox-nextjs-example"
                        target="_blank"
                        rel="noreferrer"
                    >
                        GitHub
                    </Link>
                </div>
            </div>
        </nav>
    );
}

Nav 组件将作为 Layout 组件的一部分显示。让我们也添加它。

// components/Layout.tsx

import React from "react";
import Nav from "./Nav";

type Props = {
    children: React.ReactNode;
};

export default function Layout({ children }: Props) {
    return (
        <div className="mx-auto h-screen flex flex-col">
            <Nav />
            <div className="px-8 flex-1 bg-accents-0">{children}</div>
        </div>
    );
}

# 令牌 URL

CKBox(与其他 CKEditor 云服务一样)使用 JWT 令牌进行身份验证和授权。所有这些令牌都在你的应用程序端生成,并使用你可以在 CKEditor 生态系统仪表板 中获得的共享密钥进行签名。有关如何创建访问凭据的信息,请参阅 创建访问凭据 文章,该文章位于 身份验证指南

现在我们有了必要的访问凭据,即:环境 ID 和访问密钥,让我们创建令牌端点。我们将使用 Node.js 中的通用令牌端点 的代码作为基准。

首先,让我们安装用于创建 JWT 令牌的 jsonwebtoken

npm add jsonwebtoken
npm add -D @types/jsonwebtoken

作为参考,我们将使用 Node.js 中的通用令牌端点 的代码。让我们用 Next.js API 处理程序包装 Node.js 令牌端点的逻辑,并使用 CKBox 所需的有效负载 生成令牌。请注意,对该端点的访问仅限于已认证的用户。

// pages/api/ckbox.ts

import jwt from "jsonwebtoken";
import { getServerSession } from "next-auth";
import type { NextApiRequest, NextApiResponse } from "next";
import { authOptions } from "./auth/[...nextauth]";

const CKBOX_ENVIRONMENT_ID = process.env.CKBOX_ENVIRONMENT_ID;
const CKBOX_ACCESS_KEY = process.env.CKBOX_ACCESS_KEY;

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    const session = await getServerSession(req, res, authOptions);

    if (session && CKBOX_ACCESS_KEY && CKBOX_ENVIRONMENT_ID) {
        const user = session.user;

        const payload = {
            aud: CKBOX_ENVIRONMENT_ID,
            sub: user.id,
            auth: {
                ckbox: {
                    role: user.role,
                },
            },
        };

        res.send(
            jwt.sign(payload, CKBOX_ACCESS_KEY, {
                algorithm: "HS256",
                expiresIn: "1h",
            })
        );
    } else {
        res.status(401);
    }

    res.end();
}

如你所见,上面的代码中,用于签署 JWT 令牌的访问凭据是从环境变量中获取的。因此,你可以方便地将它们添加到 .env.local 文件中

# .env.local

# Already set
NEXT_PUBLIC_URL=https://127.0.0.1:3000
NEXTAUTH_URL=https://127.0.0.1:3000
NEXTAUTH_SECRET=G4JLoUae5Nke7/CZBkzFsR5NzLDKCcijsyUhTq3fQrA=

# Added now
CKBOX_ENVIRONMENT_ID=REPLACE-WITH-ENVIRONMENT-ID
CKBOX_ACCESS_KEY=REPLACE-WITH-ACCESS-KEY

# 添加 CKBox 页面

CKBox 可以通过 多种方式 嵌入应用程序。本指南中的示例将涵盖三种流行的场景

  • 与 CKEditor 5 集成的 CKBox
  • 用作文件选择器的 CKBox(对话框模式)
  • 用作文件管理器的 CKBox(内联模式)

# CKBox 与 CKEditor

让我们使用之前步骤中创建的组件来组合 /ckeditor 页面

// pages/ckeditor.tsx

import { GetServerSideProps } from "next";
import { getServerSession } from "next-auth";
import Layout from "@/components/Layout";
import Page from "@/components/Page";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import dynamic from "next/dynamic";

// Use client-side rendering for CKEditor component
const CKEditor = dynamic(() => import("@/components/CKEditor").then((e) => e.default), {
    ssr: false,
});

export default function CKEditorPage() {
    return (
        <Layout>
            <Page>
                <section className="flex flex-col gap-6">
                    <h2 className="text-4xl font-semibold tracking-tight">CKEditor</h2>
                </section>
                <hr className="border-t border-accents-2 my-6" />
                <section className="flex flex-col gap-3 h-4/5">
                    In this example CKBox is integrated with CKEditor. With CKBox plugin, CKEditor
                    will upload files directly to your CKBox environment. Use icon in the top-left
                    corner of the editor to open CKBox as a file picker.
                    <CKEditor />
                </section>
            </Page>
        </Layout>
    );
}

// Access to this page allowed to signed-in users only
export const getServerSideProps: GetServerSideProps = async ({ req, res, resolvedUrl }) => {
    const session = await getServerSession(req, res, authOptions);

    if (!session) {
        return {
            redirect: {
                destination: `/api/auth/signin?callbackUrl=${encodeURIComponent(resolvedUrl)}`,
                permanent: false,
            },
        };
    }

    return {
        props: {
            session,
        },
    };
};

# CKBox 作为文件选择器

常见场景之一是使用 CKBox 作为文件选择器,用户可以在其中选择文件管理器中存储的其中一个文件。选择文件后,我们想要获取有关所选文件的详细信息,特别是它们的 URL。这可以通过使用作为 CKBox 配置选项传递的 assets.onChoose 回调来实现。

在 Next.js 中,我们可以直接将 CKBox 用作 React 组件。但是,目前,CKBox 官方组件无法在服务器端渲染。因此,我们必须确保它只在客户端使用。

让我们从安装必要的包开始

npm add @ckbox/core @ckbox/components

完整的页面代码如下所示。

// pages/file-picker.tsx

import React from 'react';
import { GetServerSideProps } from 'next';
import dynamic from 'next/dynamic';
import { getServerSession } from 'next-auth';
import type { Asset, Props } from '@ckbox/core';
import Layout from '@/components/Layout';
import Page from '@/components/Page';
import Button from '@/components/Button';
import Link from '@/components/Link';
import { authOptions } from '@/pages/api/auth/[...nextauth]';

// CKBox cannot be currently rendered on the server
const CKBox = dynamic(() => import('@ckbox/core').then((e) => e.CKBox), {
    ssr: false
});

// Let's import stylesheet separately, since it's not bundled with the official React component
import '@ckbox/components/dist/styles/ckbox.css';

export default function FilePicker() {
    const [assets, setAssets] = React.useState<Asset[]>([]);
    const [open, setOpen] = React.useState(false);

    const handleOpen = () => {
        setOpen(true);
    };

    const handleClose = () => {
        setOpen(false);
    };

    const handleChoose = (assets: Asset[]) => {
        setOpen(false);
        setAssets(assets);
    };

    const ckboxProps: Props = {
        assets: { onChoose: handleChoose },
        dialog: { open, onClose: handleClose },
        tokenUrl: `${process.env.NEXT_PUBLIC_URL}/api/ckbox`
    };

    return (
        <Layout>
            <Page>
                <section className="flex flex-col gap-6">
                    <h2 className="text-4xl font-semibold tracking-tight">
                        File Picker
                    </h2>
                </section>
                <hr className="border-t border-accents-2 my-6" />
                <section className="flex flex-col gap-3">
                    One of the common scenarios is to use CKBox as a file
                    picker, where the user can choose one of the files stored in
                    the file manager. After choosing the file, we want to obtain
                    information about the chosen files, especially their URLs.
                    <div>
                        <Button onClick={handleOpen}>Choose assets</Button>
                    </div>
                    <CKBox {...ckboxProps} />
                </section>
                <section className="flex flex-col gap-3">
                    <ul>
                        {assets.map(({ data }) => {
                            const name = `${data.name}.${data.extension}`;
                            const content = data.url ? (
                                <Link
                                    target="_blank"
                                    rel="noreferrer"
                                    href={data.url}
                                >
                                    {name}
                                </Link>
                            ) : (
                                name
                            );

                            return <li key={data.id}>{content}</li>;
                        })}
                    </ul>
                </section>
            </Page>
        </Layout>
    );
}

// Access to this page allowed to signed-in users only
export const getServerSideProps: GetServerSideProps = async ({
    req,
    res,
    resolvedUrl
}) => {
    const session = await getServerSession(req, res, authOptions);

    if (!session) {
        return {
            redirect: {
                destination: `/api/auth/signin?callbackUrl=${encodeURIComponent(
                    resolvedUrl
                )}`,
                permanent: false
            }
        };
    }

    return {
        props: {
            session
        }
    };
};

此时,调整 Next.js 的 Webpack 配置也很重要。我们必须确保 @ckbox/* 依赖项不会被代码拆分。以下是完整的 next.config.js 文件。

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
    reactStrictMode: false,
    webpack: (config, options) => {
        if (!options.isServer) {
            config.optimization.splitChunks.cacheGroups = {
                ...config.optimization.splitChunks.cacheGroups,
                ckbox: {
                    test: /@ckbox\//,
                    minChunks: 1,
                    priority: 50,
                },
            };
        }

        return config;
    },
};

module.exports = nextConfig;

# 内联模式下的 CKBox

要以内联模式启动 CKBox,你可以简单地将其安装在应用程序的所需位置。这是 CKBox 的默认嵌入模式,因此除了 tokenUrl 之外不需要额外的配置属性。CKBox 将占用其父元素允许的尽可能多的空间。

// pages/inline.tsx

import { GetServerSideProps } from "next";
import { getServerSession } from "next-auth";
import dynamic from "next/dynamic";
import Layout from "@/components/Layout";
import Page from "@/components/Page";
import { authOptions } from "@/pages/api/auth/[...nextauth]";

// CKBox cannot be currently rendered on the server
const CKBox = dynamic(() => import("@ckbox/core").then((e) => e.CKBox), {
    ssr: false,
});

// Let's import stylesheet separately, since it's not bundled with the React component
import "@ckbox/components/dist/styles/ckbox.css";

export default function Inline() {
    return (
        <Layout>
            <Page>
                <section className="flex flex-col gap-6">
                    <h2 className="text-4xl font-semibold tracking-tight">Inline</h2>
                </section>
                <hr className="border-t border-accents-2 my-6" />
                <section className="flex flex-col gap-3 flex-1">
                    <p>
                        To start CKBox in inline mode, you can instantiate it in an arbitrary
                        container. CKBox will respect height and width of the container.
                    </p>
                    <CKBox tokenUrl={`${process.env.NEXT_PUBLIC_URL}/api/ckbox`} />
                </section>
            </Page>
        </Layout>
    );
}

// Access to this page allowed to signed-in users only
export const getServerSideProps: GetServerSideProps = async ({ req, res, resolvedUrl }) => {
    const session = await getServerSession(req, res, authOptions);

    if (!session) {
        return {
            redirect: {
                destination: `/api/auth/signin?callbackUrl=${encodeURIComponent(resolvedUrl)}`,
                permanent: false,
            },
        };
    }

    return {
        props: {
            session,
        },
    };
};

# 主页

最后,让我们调整主页,使其显示指向上面创建的所有页面的链接。

// pages/index.tsx

import Layout from "@/components/Layout";
import Link from "@/components/Link";
import Page from "@/components/Page";

export default function Home() {
    return (
        <Layout>
            <Page>
                <section className="flex flex-col gap-6">
                    <h2 className="text-3xl font-semibold tracking-tight">
                        CKBox integration with Next.js
                    </h2>
                </section>
                <hr className="border-t border-accents-2 my-6" />
                <section className="flex flex-col gap-3">
                    <p>Below you can find example integrations of CKBox.</p>
                    <p>
                        In a typical scenario access to CKBox will be restricted to authenticated
                        users only. Therefore, each sample is restricted to signed in users only.
                        Use different credentials to unlock various CKBox roles. See available users{" "}
                        <Link
                            href="https://github.com/ckbox-io/ckbox-nextjs-example/blob/main/pages/api/auth/%5B...nextauth%5D.ts"
                            target="_blank"
                            rel="noreferrer"
                        >
                            here
                        </Link>
                        .
                    </p>
                    <div className="flex-1 hidden md:flex gap-2">
                        <ol>
                            <li>
                                <Link href="/inline">Inline mode</Link>
                            </li>
                            <li>
                                <Link href="/file-picker">File picker</Link>
                            </li>
                            <li>
                                <Link href="/ckeditor">CKEditor</Link>
                            </li>
                        </ol>
                    </div>
                </section>
            </Page>
        </Layout>
    );
}

# 恭喜

恭喜你完成本指南!现在你可以以开发模式访问该应用程序

npm run dev

或者使用生产构建

npm run build && npm start

# 完整代码

你可以在 GitHub 上找到本指南中描述的 应用程序的完整代码