使用 Next.js 和 AppWrite 构建留言板
太长不看
本文将介绍如何使用 Next.js 和 AppWrite 构建一个简单的留言板 Web 应用。这是一个很好的例子,可以帮助我们更好地学习如何使用这些工具,并在过程中构建一些有用的应用。
一个小小的请求
我正在努力让Preevy获得 1000 个 GitHub 星标。Preevy 是我和我的团队刚刚推出的一个开源工具,旨在轻松创建可共享的预览环境。
您能否帮忙给 Preevy 的 GitHub 仓库点个星标呢?这将对我们非常有帮助!谢谢!https://github.com/livecycle/preevy
现在,如果你准备好了,我们就开始吧。
介绍
本文将介绍如何使用Next.js和AppWrite构建一个简单的留言板 Web 应用。最终,我们将拥有一个可运行的 Web 应用,用户可以在其中进行身份验证、发布消息并阅读其他用户的消息。我与团队中的开发人员共同编写了本指南。我们认为,这是一个很好的学习案例,可以帮助大家更好地使用这些工具,并在过程中构建一些实用的应用。
Next.js 是一个流行的 React 框架,它提供卓越的性能和强大的服务器端渲染功能,是创建交互式 Web 应用的首选。而 AppWrite 则让编写后端代码变得极其简单,无需自行搭建后端。您可以直接从 Next.js 前端调用它。AppWrite 可以为您处理传统后端的所有任务:数据库、身份验证、文件上传等等。
样式方面,我们将使用TailwindCSS。为了处理前端对 AppWrite 的调用,我们将使用数据获取和缓存库 React Query。
设置
系统先决条件
- 请确保已安装最新版本的Node.js和 NPM。
- 已安装并运行Docker Desktop
AppWrite 安装
在开发过程中,我们会在本地机器上部署一个 AppWrite 实例。这可以通过 Docker 实现。您的机器上应该已经配置好了 Docker。AppWrite 内部包含多种服务,例如 Redis 和 MariaDB。手动配置所有这些服务会很麻烦。幸运的是,AppWrite 提供了一个docker-compose配置工具,只需一条命令即可启动所有服务。
首先,创建一个新文件夹message-board/,并在该文件夹中运行此命令来配置并启动 AppWrite 服务。下载 Docker 镜像可能需要一些时间。在设置过程中,所有选项都选择默认设置,并输入一个随机生成的密钥。
docker run -it --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
--entrypoint="install" \
appwrite/appwrite:1.3.4
之后,访问http://localhost并创建一个新的管理员帐户来管理 AppWrite。这样,我们就可以创建和配置 AppWrite 项目了。首先,注册一个新帐户。然后,点击“创建项目”并输入message-board项目名称。
现在选择“添加平台”→“Web应用程序”。message-board再次命名,并将其用作*主机名,因为这仅用于本地开发,我们希望确保可以从Next.js应用程序访问AppWrite。
您可以跳过有关 JavaScript 设置的可选步骤,我们稍后会进行设置。进入控制面板后,转到“数据库”并创建一个新的数据库。同样,给它命名message-board。将“数据库 ID”留空,以便系统随机生成。
现在在新建的数据库中创建一个集合。为此,请点击“创建集合”并将其命名为“集合” messages。同样,请将 ID 字段留空。
在模式中messages,创建一个新的“String”属性,长度设置为[此处应填写具体数值] 1024,并将其标记为“必填”。稍后我们将在此属性中存储用户提交的消息。
既然已经在这里了,我们也应该设置访问此集合的权限。请转到集合的“设置”选项卡,并根据此设置配置权限。我们将在教程的身份验证部分再次调整这些权限。
Next.js 设置
现在回到终端。在之前创建的文件夹中message-board/运行以下命令来配置一个新的 Next 应用。请注意我们下面选择的设置。您必须选择“启用 Tailwind CSS”和“禁用 App Router”。
现在切换到该message-board/message-board-app/文件夹,并安装 AppWrite 和 ReactQuery 所需的依赖项:
npm install appwrite react-query
让我们用你选择的编辑器打开 Next.js 应用。在src/pages/_app.js文件中配置 React Query。稍后我们将使用它从 AppWrite 获取数据。
import { QueryClient, QueryClientProvider } from 'react-query';
import '@/styles/globals.css'
export default function App({ Component, pageProps }) {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
);
}
现在,在你的 Next.js 文件夹中,创建一个名为 . 的新文件.env.local。我们需要在其中存储我们的 AppWrite 访问信息:
NEXT_PUBLIC_ENDPOINT=http://localhost/v1
NEXT_PUBLIC_PROJECT=646XXX
NEXT_PUBLIC_DATABASE=646XXX
NEXT_PUBLIC_MESSAGES_COLLECTION=646XXX
只要您按照 docker-compose 的安装步骤操作,端点始终保持不变。其他三个 ID 需要从 AppWrite 控制面板复制。您可以在项目、数据库和集合名称的标题旁边找到它们。请注意不要混淆项目、数据库和集合的 ID,因为它们看起来非常相似。
搭建留言板
基本脚手架
首先移除页面上src/pages/index.js现有的样板代码,然后创建用于显示、创建和删除消息的框架。目前,我们将显示静态数据,稍后再添加交互功能。
import { useState } from 'react';
const messages = [
{ $id: 1, message: 'Hello world' },
{ $id: 2, message: 'Hello world 2' },
];
export default function Home() {
const [input, setInput] = useState('');
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md">Delete</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4">Submit message</button>
</div>
</main>
);
}
顺便说一下,如果你想看看应用程序的样子,可以npm run dev在终端中输入message-board-app/命令启动它。
添加新消息
首先,创建一个新文件,用于src/appwrite.js存放我们与 AppWrite 后端的所有通信。这addMessage是一个异步函数,它接受消息字符串作为输入。然后,它会调用我们的 AppWrite 实例,将消息保存到数据库中。
import { Account, Client, Databases, ID } from 'appwrite';
const client = new Client();
const account = new Account(client);
const database = process.env.NEXT_PUBLIC_DATABASE;
const collection = process.env.NEXT_PUBLIC_MESSAGES_COLLECTION;
client.setEndpoint(process.env.NEXT_PUBLIC_ENDPOINT).setProject(process.env.NEXT_PUBLIC_PROJECT);
const databases = new Databases(client);
export const addMessage = async (message) => {
await databases.createDocument(
database,
collection,
ID.unique(),
{
message,
}
);
};
然后修改src/pages/index.js组件,使其能够连接用于添加消息的表单:
import { useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { addMessage } from '@/appwrite';
const messages = [
{ $id: 1, message: 'Hello world' },
{ $id: 2, message: 'Hello world 2' },
];
export default function Home() {
const [input, setInput] = useState('');
const queryClient = useQueryClient();
const addMessageMutation = useMutation(addMessage, {
onSuccess: () => {
setInput('');
queryClient.invalidateQueries('messages');
},
});
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md">Delete</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate(input)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
</div>
</main>
);
}
现在我们可以向数据库添加消息了。但是,由于我们还没有从 AppWrite 获取任何消息,所以目前还看不到这些消息。我们会尽快解决这个问题。
但我们在这里究竟在做什么呢?我们创建了一个带有useMutationHook 的 React Query mutation。它允许我们调用addMessageMutation.mutate(input)“提交消息”按钮。更重要的是,我们在 Hook 中添加了一个onSucess回调函数。因此,每当成功添加新消息时,我们都可以清除输入字段并使查询缓存失效。使缓存失效会触发 React Query 重新获取所有消息。这在下一节中将从 AppWrite 获取消息时会非常有用。
渲染消息
现在我们可以向数据库添加消息了,接下来我们还要显示这些消息。首先,我们需要将获取消息的操作添加到src/appwrite.js文件中:
// other code
export const getMessages = async () => {
const { documents: messages } = await databases.listDocuments(database, collection);
return messages;
};
现在我们可以使用 React Query useQueryhook 来获取它们:
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { addMessage, getMessages } from '@/appwrite';
export default function Home() {
const [input, setInput] = useState('');
const queryClient = useQueryClient();
const { data: messages } = useQuery('messages', getMessages);
const addMessageMutation = useMutation(addMessage, {
onSuccess: () => {
setInput('');
queryClient.invalidateQueries('messages');
},
});
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md"
>
Delete
</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate(input)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
</div>
</main>
);
}
通过表单添加一些消息,这样你就能看到它们了!请注意,每当你提交一条新消息时,现有消息都会自动重新加载,以便新消息出现在列表中。这就是 React Query 的神奇之处。
删除消息
最后一步是添加删除消息的功能。同样,我们首先需要将此功能添加到src/appwrite.js:
// other code
export const deleteMessage = async (id) => {
await databases.deleteDocument(database, collection, id);
};
接下来,只需onClick在页面上添加一个 mutation 和删除按钮的操作即可src/pages/index.js。不要复制整个组件,只需复制添加的删除 mutation 和新的 return 语句。另外,请确保deleteMessage从 AppWrite 导入。
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { addMessage, deleteMessage, getMessages } from '@/appwrite';
export default function Home() {
// other code
const deleteMessageMutation = useMutation(deleteMessage, {
onSuccess: () => {
queryClient.invalidateQueries('messages');
},
});
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button
onClick={() => deleteMessageMutation.mutate(message.$id)}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md"
>
Delete
</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate(input)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
</div>
</main>
);
}
再次,在成功删除一条消息后,我们告诉 React Query 重新获取所有消息,以便已删除的消息从列表中消失。
验证
为了添加身份验证功能,我们首先需要在src/appwrite.js文件中添加一些额外的操作。这样用户就可以注册并登录。此外,我们还需要一个注销操作和一个用于获取用户会话的函数。为了存储用户会话,还需要在该文件中创建一个新的 React Context。请确保createContext从react. 导入。
// other code
export const signUp = async (email, password) => {
return await account.create(ID.unique(), email, password);
};
export const signIn = async (email, password) => {
return await account.createEmailSession(email, password);
};
export const signOut = async () => {
return await account.deleteSessions();
};
export const getUser = async () => {
try {
return await account.get();
} catch {
return undefined;
}
};
// import createContext from react beforehand
export const UserContext = createContext(null);
现在我们需要在每次应用程序启动时自动获取用户,并通过上下文使其可用。我们将在以下代码中实现src/pages/_app.js:
import { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { UserContext, getUser } from '@/appwrite';
import '@/styles/globals.css';
export default function App({ Component, pageProps }) {
const [user, setUser] = useState(null);
const queryClient = new QueryClient();
useEffect(() => {
const user = async () => {
const user = await getUser();
if (!user) return;
setUser(user);
};
user();
}, []);
return (
<UserContext.Provider value={{ user, setUser }}>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</UserContext.Provider>
);
}
接下来我们可以创建注册用的掩码。为此,请添加一个新文件,src/pages/signup.js内容如下:
import { useRouter } from 'next/router';
import { useState } from 'react';
import { signUp } from '@/appwrite';
export default function SignUp() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
try {
await signUp(email, password);
router.push('/');
} catch {
console.log('Error signing up');
}
};
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-sm mx-auto">
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 py-6 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{/* Submit button */}
<div className="flex items-center justify-between">
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
type="submit"
>
Sign Up
</button>
</div>
</form>
</div>
</div>
);
}
请登录src/pages/signin.js:
import { useRouter } from 'next/router';
import { useState } from 'react';
import { signIn } from '@/appwrite';
export default function SignIn() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
try {
await signIn(email, password);
router.push('/');
} catch {
console.log('Error signing in');
}
};
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-sm mx-auto">
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 py-6 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex items-center justify-between">
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
type="submit"
>
Sign In
</button>
</div>
</form>
</div>
</div>
);
}
退出登录
现在,用户身份验证的最后一步是连接注销功能。我们已经有了所有需要的组件。我们只需要检查会话是否user存在,如果存在,则显示一个“注销”按钮,该按钮会调用signOutAppWrite 中的函数。我们将修改src/pages/index.js文件如下:
import { useContext, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { addMessage, getMessages, deleteMessage, UserContext, signOut } from '@/appwrite';
export default function Home() {
const [input, setInput] = useState('');
const queryClient = useQueryClient();
const { user, setUser } = useContext(UserContext);
const { data: messages } = useQuery('messages', getMessages);
const addMessageMutation = useMutation(addMessage, {
onSuccess: () => {
setInput('');
queryClient.invalidateQueries('messages');
},
});
const deleteMessageMutation = useMutation(deleteMessage, {
onSuccess: () => {
queryClient.invalidateQueries('messages');
},
});
const handleSignOut = async () => {
await signOut();
setUser(null);
};
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button
onClick={() => deleteMessageMutation.mutate(message.$id)}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md"
>
Delete
</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate(input)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
{user && (
<button
onClick={handleSignOut}
className="bg-red-500 ml-4 hover:bg-red-600 text-white px-4 py-2 rounded-md mt-4"
>
Sign out
</button>
)}
</div>
</main>
);
}
访问控制
身份验证实现后,我们现在要确保只有经过身份验证的用户才能创建新消息,并且用户只能删除自己的消息。所以,让我们回到控制面板,将messages架构权限更改为以下内容:
如您所见,从现在起,未经身份验证的用户只能阅读消息。如果您想创建消息,则需要先进行身份验证。但是,我们如何才能让用户删除自己的消息呢?我们将逐个文档地进行设置。您可以将文档想象成数据库中的一行数据。因此,在同一屏幕上,请确保您已启用“文档安全”功能。
回到代码中,addMessage按如下方式修改函数src/appwrite.js。另外,别忘了按所示更新导入语句。
import { Account, Client, Databases, Permission, Role, ID } from 'appwrite';
export const addMessage = async ({ message, userId }) => {
await databases.createDocument(
database,
collection,
ID.unique(),
{
message,
},
[Permission.delete(Role.user(userId))]
);
};
这样,我们就明确声明只有文档所有者才能删除它。现在,在src/app/index.js添加消息按钮的操作中,还要传递user.$id当前用户的 ID,以便 AppWrite 知道我们要授予哪个用户删除权限:
<button
onClick={() => addMessageMutation.mutate({ message: input, userId: user?.$id })}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
现在,在[平台名称]创建一个新用户/signup,然后使用相同的凭据/signin登录。之后,您就可以创建与您的帐户关联的新消息了。尝试删除这些消息,看看会发生什么。
在用户界面中显示身份验证状态
太好了!现在只有消息所有者才能删除消息。但是,我们仍然在每条消息旁边显示删除按钮,无论用户登录的是哪个账号。提交新消息的字段也是如此,未登录用户也能看到它。因此,我们需要修改用户界面,使其仅在用户实际可以执行这些操作时才显示这些选项。对于提交新消息的表单,这很简单。我们只需在用户登录时显示它即可:
{user && (
<>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate({ message: input, userId: user?.$id })}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
<button
onClick={handleSignOut}
className="bg-red-500 ml-4 hover:bg-red-600 text-white px-4 py-2 rounded-md mt-4"
>
Sign out
</button>
</>
)}
检查用户是否拥有该消息稍微复杂一些。每条消息都带有一个权限数组。如果用户拥有该消息,则其权限信息user.$id将出现在delete权限字段中。我们将编写一个函数来检查这一点:
const canDelete = (userID, array) => {
return array.some((element) => element.includes('delete') && element.includes(userID));
};
然后检查每条消息的用户界面,看看是否需要渲染删除按钮:
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
{canDelete(user?.$id, message.$permissions) && (
<button
onClick={() => deleteMessageMutation.mutate(message.$id, user.$id)}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md"
>
Delete
</button>
)}
</li>
))}
总结
至此,我们就完成了!用户现在只会看到他们拥有相应权限的操作。正如本教程所示,Next.js、AppWrite 和 React Query 配合得非常出色。得益于 AppWrite 与 React Query 的结合,我们无需编写任何后端代码即可获得卓越的全栈开发体验。
希望您在学习本教程的过程中有所收获。我们构建的应用程序可以作为您自己项目的起点。如果您想继续完善这个留言板,以下是一些功能建议:
- 在消息中添加评论。这将教你如何在 AppWrite 中创建关联关系。
- 添加无限滚动或分页功能,以处理无法一次性全部渲染的大量消息。
- 使用 Next 的服务器端渲染功能,在服务器端而不是客户端获取和渲染消息。
关于部署,我们已经将 AppWrite 配置在一个简洁的 docker-compose 环境中。您可以将其托管在诸如 Google Cloud 或 AWS 等主流云服务提供商上。此外,您还可以参考 Vercel 的这篇指南,将 Next.js 添加到 Docker 环境中。
同时,您还可以使用Preevy为您的项目设置预览环境。这样可以轻松地为您的应用程序提供预览环境,您可以与他人共享这些环境,以便快速获得反馈,并保持您的开发工作流程高效运转。
如果你不想采用自托管的方式,AppWrite 也提供云服务,由他们为你托管。但这样一来,你需要将 Next.js 单独托管在 Vercel 或 Netlify 上。
希望您喜欢并觉得本指南有用。
祝你好运!
文章来源:https://dev.to/livecycle/building-a-message-board-with-nextjs-and-appwrite-3910









